Attempt to merge master into #5376

This commit is contained in:
nymkappa 2024-12-09 08:54:26 +01:00
parent 79e2883ebe
commit 78844f5787
No known key found for this signature in database
GPG Key ID: 92358FC85D9645DE
472 changed files with 25806 additions and 13790 deletions

12
LICENSE
View File

@ -1,5 +1,5 @@
The Mempool Open Source Project® The Mempool Open Source Project®
Copyright (c) 2019-2023 Mempool Space K.K. and other shadowy super-coders Copyright (c) 2019-2024 Mempool Space K.K. and other shadowy super-coders
This program is free software; you can redistribute it and/or modify it under This program is free software; you can redistribute it and/or modify it under
the terms of the GNU Affero General Public License as published by the Free the terms of the GNU Affero General Public License as published by the Free
@ -12,10 +12,12 @@ or any other contributor to The Mempool Open Source Project.
The Mempool Open Source Project®, Mempool Accelerator™, Mempool Enterprise®, The Mempool Open Source Project®, Mempool Accelerator™, Mempool Enterprise®,
Mempool Liquidity™, mempool.space®, Be your own explorer™, Explore the full Mempool Liquidity™, mempool.space®, Be your own explorer™, Explore the full
Bitcoin ecosystem™, Mempool Goggles™, the mempool Logo, the mempool Square logo, Bitcoin ecosystem™, Mempool Goggles™, the mempool Logo, the mempool Square Logo,
the mempool Blocks logo, the mempool Blocks 3 | 2 logo, the mempool.space Vertical the mempool block visualization Logo, the mempool Blocks Logo, the mempool
Logo, and the mempool.space Horizontal logo are registered trademarks or trademarks transaction Logo, the mempool Blocks 3 | 2 Logo, the mempool research Logo,
of Mempool Space K.K in Japan, the United States, and/or other countries. the mempool.space Vertical Logo, and the mempool.space Horizontal Logo are
registered trademarks or trademarks of Mempool Space K.K in Japan,
the United States, and/or other countries.
See our full Trademark Policy and Guidelines for more details, published on See our full Trademark Policy and Guidelines for more details, published on
<https://mempool.space/trademark-policy>. <https://mempool.space/trademark-policy>.

View File

@ -77,7 +77,7 @@ Query OK, 0 rows affected (0.00 sec)
#### Build #### Build
_Make sure to use Node.js 16.10 and npm 7._ _Make sure to use Node.js 20.x and npm 9.x or newer_
_The build process requires [Rust](https://www.rust-lang.org/tools/install) to be installed._ _The build process requires [Rust](https://www.rust-lang.org/tools/install) to be installed._

View File

@ -27,8 +27,9 @@
"AUTOMATIC_POOLS_UPDATE": false, "AUTOMATIC_POOLS_UPDATE": false,
"POOLS_JSON_URL": "https://raw.githubusercontent.com/mempool/mining-pools/master/pools-v2.json", "POOLS_JSON_URL": "https://raw.githubusercontent.com/mempool/mining-pools/master/pools-v2.json",
"POOLS_JSON_TREE_URL": "https://api.github.com/repos/mempool/mining-pools/git/trees/master", "POOLS_JSON_TREE_URL": "https://api.github.com/repos/mempool/mining-pools/git/trees/master",
"POOLS_UPDATE_DELAY": 604800,
"AUDIT": false, "AUDIT": false,
"RUST_GBT": false, "RUST_GBT": true,
"LIMIT_GBT": false, "LIMIT_GBT": false,
"CPFP_INDEXING": false, "CPFP_INDEXING": false,
"DISK_CACHE_BLOCK_INTERVAL": 6, "DISK_CACHE_BLOCK_INTERVAL": 6,
@ -45,7 +46,8 @@
"PASSWORD": "mempool", "PASSWORD": "mempool",
"TIMEOUT": 60000, "TIMEOUT": 60000,
"COOKIE": false, "COOKIE": false,
"COOKIE_PATH": "/path/to/bitcoin/.cookie" "COOKIE_PATH": "/path/to/bitcoin/.cookie",
"DEBUG_LOG_PATH": "/path/to/bitcoin/debug.log"
}, },
"ELECTRUM": { "ELECTRUM": {
"HOST": "127.0.0.1", "HOST": "127.0.0.1",

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "mempool-backend", "name": "mempool-backend",
"version": "3.0.0-dev", "version": "3.1.0-dev",
"description": "Bitcoin mempool visualizer and blockchain explorer backend", "description": "Bitcoin mempool visualizer and blockchain explorer backend",
"license": "GNU Affero General Public License v3.0", "license": "GNU Affero General Public License v3.0",
"homepage": "https://mempool.space", "homepage": "https://mempool.space",
@ -39,24 +39,24 @@
"prettier": "./node_modules/.bin/prettier --write \"src/**/*.{js,ts}\"" "prettier": "./node_modules/.bin/prettier --write \"src/**/*.{js,ts}\""
}, },
"dependencies": { "dependencies": {
"@babel/core": "^7.24.0", "@babel/core": "^7.25.2",
"@mempool/electrum-client": "1.1.9", "@mempool/electrum-client": "1.1.9",
"@types/node": "^18.15.3", "@types/node": "^18.15.3",
"axios": "~1.7.2", "axios": "1.7.2",
"bitcoinjs-lib": "~6.1.3", "bitcoinjs-lib": "~6.1.3",
"crypto-js": "~4.2.0", "crypto-js": "~4.2.0",
"express": "~4.19.2", "express": "~4.21.1",
"maxmind": "~4.3.11", "maxmind": "~4.3.11",
"mysql2": "~3.10.0", "mysql2": "~3.11.0",
"rust-gbt": "file:./rust-gbt", "rust-gbt": "file:./rust-gbt",
"redis": "^4.6.6", "redis": "^4.7.0",
"socks-proxy-agent": "~7.0.0", "socks-proxy-agent": "~7.0.0",
"typescript": "~4.9.3", "typescript": "~4.9.3",
"ws": "~8.18.0" "ws": "~8.18.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/code-frame": "^7.18.6", "@babel/code-frame": "^7.18.6",
"@babel/core": "^7.24.0", "@babel/core": "^7.25.2",
"@types/compression": "^1.7.2", "@types/compression": "^1.7.2",
"@types/crypto-js": "^4.1.1", "@types/crypto-js": "^4.1.1",
"@types/express": "^4.17.17", "@types/express": "^4.17.17",

View File

@ -28,6 +28,7 @@
"INDEXING_BLOCKS_AMOUNT": 14, "INDEXING_BLOCKS_AMOUNT": 14,
"POOLS_JSON_TREE_URL": "__MEMPOOL_POOLS_JSON_TREE_URL__", "POOLS_JSON_TREE_URL": "__MEMPOOL_POOLS_JSON_TREE_URL__",
"POOLS_JSON_URL": "__MEMPOOL_POOLS_JSON_URL__", "POOLS_JSON_URL": "__MEMPOOL_POOLS_JSON_URL__",
"POOLS_UPDATE_DELAY": 604800,
"AUDIT": true, "AUDIT": true,
"RUST_GBT": false, "RUST_GBT": false,
"LIMIT_GBT": false, "LIMIT_GBT": false,
@ -46,7 +47,8 @@
"PASSWORD": "__CORE_RPC_PASSWORD__", "PASSWORD": "__CORE_RPC_PASSWORD__",
"TIMEOUT": 1000, "TIMEOUT": 1000,
"COOKIE": false, "COOKIE": false,
"COOKIE_PATH": "__CORE_RPC_COOKIE_PATH__" "COOKIE_PATH": "__CORE_RPC_COOKIE_PATH__",
"DEBUG_LOG_PATH": "__CORE_RPC_DEBUG_LOG_PATH__"
}, },
"ELECTRUM": { "ELECTRUM": {
"HOST": "__ELECTRUM_HOST__", "HOST": "__ELECTRUM_HOST__",

View File

@ -1,5 +1,5 @@
import { Common } from '../../api/common'; import { Common } from '../../api/common';
import { MempoolTransactionExtended } from '../../mempool.interfaces'; import { MempoolTransactionExtended, TransactionExtended } from '../../mempool.interfaces';
const randomTransactions = require('./test-data/transactions-random.json'); const randomTransactions = require('./test-data/transactions-random.json');
const replacedTransactions = require('./test-data/transactions-replaced.json'); const replacedTransactions = require('./test-data/transactions-replaced.json');
@ -10,14 +10,14 @@ describe('Common', () => {
describe('RBF', () => { describe('RBF', () => {
const newTransactions = rbfTransactions.concat(randomTransactions); const newTransactions = rbfTransactions.concat(randomTransactions);
test('should detect RBF transactions with fast method', () => { test('should detect RBF transactions with fast method', () => {
const result: { [txid: string]: MempoolTransactionExtended[] } = Common.findRbfTransactions(newTransactions, replacedTransactions); const result: { [txid: string]: { replaced: MempoolTransactionExtended[], replacedBy: TransactionExtended }} = Common.findRbfTransactions(newTransactions, replacedTransactions);
expect(Object.values(result).length).toEqual(2); expect(Object.values(result).length).toEqual(2);
expect(result).toHaveProperty('7219d95161f3718335991ac6d967d24eedec370908c9879bb1e192e6d797d0a6'); expect(result).toHaveProperty('7219d95161f3718335991ac6d967d24eedec370908c9879bb1e192e6d797d0a6');
expect(result).toHaveProperty('5387881d695d4564d397026dc5f740f816f8390b4b2c5ec8c20309122712a875'); expect(result).toHaveProperty('5387881d695d4564d397026dc5f740f816f8390b4b2c5ec8c20309122712a875');
}); });
test('should detect RBF transactions with scalable method', () => { test('should detect RBF transactions with scalable method', () => {
const result: { [txid: string]: MempoolTransactionExtended[] } = Common.findRbfTransactions(newTransactions, replacedTransactions, true); const result: { [txid: string]: { replaced: MempoolTransactionExtended[], replacedBy: TransactionExtended }} = Common.findRbfTransactions(newTransactions, replacedTransactions, true);
expect(Object.values(result).length).toEqual(2); expect(Object.values(result).length).toEqual(2);
expect(result).toHaveProperty('7219d95161f3718335991ac6d967d24eedec370908c9879bb1e192e6d797d0a6'); expect(result).toHaveProperty('7219d95161f3718335991ac6d967d24eedec370908c9879bb1e192e6d797d0a6');
expect(result).toHaveProperty('5387881d695d4564d397026dc5f740f816f8390b4b2c5ec8c20309122712a875'); expect(result).toHaveProperty('5387881d695d4564d397026dc5f740f816f8390b4b2c5ec8c20309122712a875');

View File

@ -41,8 +41,9 @@ describe('Mempool Backend Config', () => {
STDOUT_LOG_MIN_PRIORITY: 'debug', STDOUT_LOG_MIN_PRIORITY: 'debug',
POOLS_JSON_TREE_URL: 'https://api.github.com/repos/mempool/mining-pools/git/trees/master', POOLS_JSON_TREE_URL: 'https://api.github.com/repos/mempool/mining-pools/git/trees/master',
POOLS_JSON_URL: 'https://raw.githubusercontent.com/mempool/mining-pools/master/pools-v2.json', POOLS_JSON_URL: 'https://raw.githubusercontent.com/mempool/mining-pools/master/pools-v2.json',
POOLS_UPDATE_DELAY: 604800,
AUDIT: false, AUDIT: false,
RUST_GBT: false, RUST_GBT: true,
LIMIT_GBT: false, LIMIT_GBT: false,
CPFP_INDEXING: false, CPFP_INDEXING: false,
MAX_BLOCKS_BULK_QUERY: 0, MAX_BLOCKS_BULK_QUERY: 0,
@ -73,7 +74,8 @@ describe('Mempool Backend Config', () => {
PASSWORD: 'mempool', PASSWORD: 'mempool',
TIMEOUT: 60000, TIMEOUT: 60000,
COOKIE: false, COOKIE: false,
COOKIE_PATH: '/bitcoin/.cookie' COOKIE_PATH: '/bitcoin/.cookie',
DEBUG_LOG_PATH: '',
}); });
expect(config.SECOND_CORE_RPC).toStrictEqual({ expect(config.SECOND_CORE_RPC).toStrictEqual({

View File

@ -70,7 +70,7 @@ class AboutRoutes {
res.status(500).end(); res.status(500).end();
} }
}) })
.get(config.MEMPOOL.API_URL_PREFIX + 'services/account/images/:username', async (req, res) => { .get(config.MEMPOOL.API_URL_PREFIX + 'services/account/images/:username/:md5', async (req, res) => {
const url = `${config.MEMPOOL_SERVICES.API}/${req.originalUrl.replace('/api/v1/services/', '')}`; const url = `${config.MEMPOOL_SERVICES.API}/${req.originalUrl.replace('/api/v1/services/', '')}`;
try { try {
const response = await axios.get(url, { responseType: 'stream', timeout: 10000 }); const response = await axios.get(url, { responseType: 'stream', timeout: 10000 });

View File

@ -2,24 +2,28 @@ import config from '../config';
import logger from '../logger'; import logger from '../logger';
import { MempoolTransactionExtended, MempoolBlockWithTransactions } from '../mempool.interfaces'; import { MempoolTransactionExtended, MempoolBlockWithTransactions } from '../mempool.interfaces';
import rbfCache from './rbf-cache'; import rbfCache from './rbf-cache';
import transactionUtils from './transaction-utils';
const PROPAGATION_MARGIN = 180; // in seconds, time since a transaction is first seen after which it is assumed to have propagated to all miners const PROPAGATION_MARGIN = 180; // in seconds, time since a transaction is first seen after which it is assumed to have propagated to all miners
class Audit { class Audit {
auditBlock(transactions: MempoolTransactionExtended[], projectedBlocks: MempoolBlockWithTransactions[], mempool: { [txId: string]: MempoolTransactionExtended }, useAccelerations: boolean = false) auditBlock(height: number, transactions: MempoolTransactionExtended[], projectedBlocks: MempoolBlockWithTransactions[], mempool: { [txId: string]: MempoolTransactionExtended })
: { censored: string[], added: string[], prioritized: string[], fresh: string[], sigop: string[], fullrbf: string[], accelerated: string[], score: number, similarity: number } { : { unseen: string[], censored: string[], added: string[], prioritized: string[], fresh: string[], sigop: string[], fullrbf: string[], accelerated: string[], score: number, similarity: number } {
if (!projectedBlocks?.[0]?.transactionIds || !mempool) { if (!projectedBlocks?.[0]?.transactionIds || !mempool) {
return { censored: [], added: [], prioritized: [], fresh: [], sigop: [], fullrbf: [], accelerated: [], score: 1, similarity: 1 }; return { unseen: [], censored: [], added: [], prioritized: [], fresh: [], sigop: [], fullrbf: [], accelerated: [], score: 1, similarity: 1 };
} }
const matches: string[] = []; // present in both mined block and template const matches: string[] = []; // present in both mined block and template
const added: string[] = []; // present in mined block, not in template const added: string[] = []; // present in mined block, not in template
const prioritized: string[] = [] // present in the mined block, not in the template, but further down in the mempool const unseen: string[] = []; // present in the mined block, not in our mempool
let prioritized: string[] = []; // higher in the block than would be expected by in-band feerate alone
let deprioritized: string[] = []; // lower in the block than would be expected by in-band feerate alone
const fresh: string[] = []; // missing, but firstSeen or lastBoosted within PROPAGATION_MARGIN const fresh: string[] = []; // missing, but firstSeen or lastBoosted within PROPAGATION_MARGIN
const rbf: string[] = []; // either missing or present, and either part of a full-rbf replacement, or a conflict with the mined block const rbf: string[] = []; // either missing or present, and either part of a full-rbf replacement, or a conflict with the mined block
const accelerated: string[] = []; // prioritized by the mempool accelerator const accelerated: string[] = []; // prioritized by the mempool accelerator
const isCensored = {}; // missing, without excuse const isCensored = {}; // missing, without excuse
const isDisplaced = {}; const isDisplaced = {};
const isAccelerated = {};
let displacedWeight = 0; let displacedWeight = 0;
let matchedWeight = 0; let matchedWeight = 0;
let projectedWeight = 0; let projectedWeight = 0;
@ -32,6 +36,7 @@ class Audit {
inBlock[tx.txid] = tx; inBlock[tx.txid] = tx;
if (mempool[tx.txid] && mempool[tx.txid].acceleration) { if (mempool[tx.txid] && mempool[tx.txid].acceleration) {
accelerated.push(tx.txid); accelerated.push(tx.txid);
isAccelerated[tx.txid] = true;
} }
} }
// coinbase is always expected // coinbase is always expected
@ -113,11 +118,16 @@ class Audit {
} else { } else {
if (rbfCache.has(tx.txid)) { if (rbfCache.has(tx.txid)) {
rbf.push(tx.txid); rbf.push(tx.txid);
} else if (!isDisplaced[tx.txid]) { if (!mempool[tx.txid] && !rbfCache.getReplacedBy(tx.txid)) {
unseen.push(tx.txid);
}
} else {
if (mempool[tx.txid]) { if (mempool[tx.txid]) {
prioritized.push(tx.txid); if (isDisplaced[tx.txid]) {
added.push(tx.txid);
}
} else { } else {
added.push(tx.txid); unseen.push(tx.txid);
} }
} }
overflowWeight += tx.weight; overflowWeight += tx.weight;
@ -125,6 +135,8 @@ class Audit {
totalWeight += tx.weight; totalWeight += tx.weight;
} }
({ prioritized, deprioritized } = transactionUtils.identifyPrioritizedTransactions(transactions, 'effectiveFeePerVsize'));
// transactions missing from near the end of our template are probably not being censored // transactions missing from near the end of our template are probably not being censored
let overflowWeightRemaining = overflowWeight - (config.MEMPOOL.BLOCK_WEIGHT_UNITS - totalWeight); let overflowWeightRemaining = overflowWeight - (config.MEMPOOL.BLOCK_WEIGHT_UNITS - totalWeight);
let maxOverflowRate = 0; let maxOverflowRate = 0;
@ -165,6 +177,7 @@ class Audit {
const similarity = projectedWeight ? matchedWeight / projectedWeight : 1; const similarity = projectedWeight ? matchedWeight / projectedWeight : 1;
return { return {
unseen,
censored: Object.keys(isCensored), censored: Object.keys(isCensored),
added, added,
prioritized, prioritized,

View File

@ -1,4 +1,4 @@
import { IBitcoinApi, TestMempoolAcceptResult } from './bitcoin-api.interface'; import { IBitcoinApi, SubmitPackageResult, TestMempoolAcceptResult } from './bitcoin-api.interface';
import { IEsploraApi } from './esplora-api.interface'; import { IEsploraApi } from './esplora-api.interface';
export interface AbstractBitcoinApi { export interface AbstractBitcoinApi {
@ -23,6 +23,7 @@ export interface AbstractBitcoinApi {
$getScriptHashTransactions(address: string, lastSeenTxId: string): Promise<IEsploraApi.Transaction[]>; $getScriptHashTransactions(address: string, lastSeenTxId: string): Promise<IEsploraApi.Transaction[]>;
$sendRawTransaction(rawTransaction: string): Promise<string>; $sendRawTransaction(rawTransaction: string): Promise<string>;
$testMempoolAccept(rawTransactions: string[], maxfeerate?: number): Promise<TestMempoolAcceptResult[]>; $testMempoolAccept(rawTransactions: string[], maxfeerate?: number): Promise<TestMempoolAcceptResult[]>;
$submitPackage(rawTransactions: string[], maxfeerate?: number, maxburnamount?: number): Promise<SubmitPackageResult>;
$getOutspend(txId: string, vout: number): Promise<IEsploraApi.Outspend>; $getOutspend(txId: string, vout: number): Promise<IEsploraApi.Outspend>;
$getOutspends(txId: string): Promise<IEsploraApi.Outspend[]>; $getOutspends(txId: string): Promise<IEsploraApi.Outspend[]>;
$getBatchedOutspends(txId: string[]): Promise<IEsploraApi.Outspend[][]>; $getBatchedOutspends(txId: string[]): Promise<IEsploraApi.Outspend[][]>;

View File

@ -218,3 +218,21 @@ export interface TestMempoolAcceptResult {
}, },
['reject-reason']?: string, ['reject-reason']?: string,
} }
export interface SubmitPackageResult {
package_msg: string;
"tx-results": { [wtxid: string]: TxResult };
"replaced-transactions"?: string[];
}
export interface TxResult {
txid: string;
"other-wtxid"?: string;
vsize?: number;
fees?: {
base: number;
"effective-feerate"?: number;
"effective-includes"?: string[];
};
error?: string;
}

View File

@ -1,6 +1,6 @@
import * as bitcoinjs from 'bitcoinjs-lib'; import * as bitcoinjs from 'bitcoinjs-lib';
import { AbstractBitcoinApi, HealthCheckHost } from './bitcoin-api-abstract-factory'; import { AbstractBitcoinApi, HealthCheckHost } from './bitcoin-api-abstract-factory';
import { IBitcoinApi, TestMempoolAcceptResult } from './bitcoin-api.interface'; import { IBitcoinApi, SubmitPackageResult, TestMempoolAcceptResult } from './bitcoin-api.interface';
import { IEsploraApi } from './esplora-api.interface'; import { IEsploraApi } from './esplora-api.interface';
import blocks from '../blocks'; import blocks from '../blocks';
import mempool from '../mempool'; import mempool from '../mempool';
@ -196,6 +196,10 @@ class BitcoinApi implements AbstractBitcoinApi {
} }
} }
$submitPackage(rawTransactions: string[], maxfeerate?: number, maxburnamount?: number): Promise<SubmitPackageResult> {
return this.bitcoindClient.submitPackage(rawTransactions, maxfeerate ?? undefined, maxburnamount ?? undefined);
}
async $getOutspend(txId: string, vout: number): Promise<IEsploraApi.Outspend> { async $getOutspend(txId: string, vout: number): Promise<IEsploraApi.Outspend> {
const txOut = await this.bitcoindClient.getTxOut(txId, vout, false); const txOut = await this.bitcoindClient.getTxOut(txId, vout, false);
return { return {
@ -327,6 +331,7 @@ class BitcoinApi implements AbstractBitcoinApi {
'witness_v1_taproot': 'v1_p2tr', 'witness_v1_taproot': 'v1_p2tr',
'nonstandard': 'nonstandard', 'nonstandard': 'nonstandard',
'multisig': 'multisig', 'multisig': 'multisig',
'anchor': 'anchor',
'nulldata': 'op_return' 'nulldata': 'op_return'
}; };

View File

@ -1,6 +1,7 @@
import { Application, NextFunction, Request, Response } from 'express'; import { Application, NextFunction, Request, Response } from 'express';
import logger from '../../logger'; import logger from '../../logger';
import bitcoinClient from './bitcoin-client'; import bitcoinClient from './bitcoin-client';
import config from '../../config';
/** /**
* Define a set of routes used by the accelerator server * Define a set of routes used by the accelerator server
@ -11,15 +12,15 @@ class BitcoinBackendRoutes {
public initRoutes(app: Application) { public initRoutes(app: Application) {
app app
.get('/api/internal/bitcoin-core/' + 'get-mempool-entry', this.disableCache, this.$getMempoolEntry) .get(config.MEMPOOL.API_URL_PREFIX + 'internal/bitcoin-core/' + 'get-mempool-entry', this.disableCache, this.$getMempoolEntry)
.post('/api/internal/bitcoin-core/' + 'decode-raw-transaction', this.disableCache, this.$decodeRawTransaction) .post(config.MEMPOOL.API_URL_PREFIX + 'internal/bitcoin-core/' + 'decode-raw-transaction', this.disableCache, this.$decodeRawTransaction)
.get('/api/internal/bitcoin-core/' + 'get-raw-transaction', this.disableCache, this.$getRawTransaction) .get(config.MEMPOOL.API_URL_PREFIX + 'internal/bitcoin-core/' + 'get-raw-transaction', this.disableCache, this.$getRawTransaction)
.post('/api/internal/bitcoin-core/' + 'send-raw-transaction', this.disableCache, this.$sendRawTransaction) .post(config.MEMPOOL.API_URL_PREFIX + 'internal/bitcoin-core/' + 'send-raw-transaction', this.disableCache, this.$sendRawTransaction)
.post('/api/internal/bitcoin-core/' + 'test-mempool-accept', this.disableCache, this.$testMempoolAccept) .post(config.MEMPOOL.API_URL_PREFIX + 'internal/bitcoin-core/' + 'test-mempool-accept', this.disableCache, this.$testMempoolAccept)
.get('/api/internal/bitcoin-core/' + 'get-mempool-ancestors', this.disableCache, this.$getMempoolAncestors) .get(config.MEMPOOL.API_URL_PREFIX + 'internal/bitcoin-core/' + 'get-mempool-ancestors', this.disableCache, this.$getMempoolAncestors)
.get('/api/internal/bitcoin-core/' + 'get-block', this.disableCache, this.$getBlock) .get(config.MEMPOOL.API_URL_PREFIX + 'internal/bitcoin-core/' + 'get-block', this.disableCache, this.$getBlock)
.get('/api/internal/bitcoin-core/' + 'get-block-hash', this.disableCache, this.$getBlockHash) .get(config.MEMPOOL.API_URL_PREFIX + 'internal/bitcoin-core/' + 'get-block-hash', this.disableCache, this.$getBlockHash)
.get('/api/internal/bitcoin-core/' + 'get-block-count', this.disableCache, this.$getBlockCount) .get(config.MEMPOOL.API_URL_PREFIX + 'internal/bitcoin-core/' + 'get-block-count', this.disableCache, this.$getBlockCount)
; ;
} }

View File

@ -20,6 +20,7 @@ import difficultyAdjustment from '../difficulty-adjustment';
import transactionRepository from '../../repositories/TransactionRepository'; import transactionRepository from '../../repositories/TransactionRepository';
import rbfCache from '../rbf-cache'; import rbfCache from '../rbf-cache';
import { calculateMempoolTxCpfp } from '../cpfp'; import { calculateMempoolTxCpfp } from '../cpfp';
import { handleError } from '../../utils/api';
class BitcoinRoutes { class BitcoinRoutes {
public initRoutes(app: Application) { public initRoutes(app: Application) {
@ -41,12 +42,15 @@ class BitcoinRoutes {
.get(config.MEMPOOL.API_URL_PREFIX + 'blocks/:height', this.getBlocks.bind(this)) .get(config.MEMPOOL.API_URL_PREFIX + 'blocks/:height', this.getBlocks.bind(this))
.get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash', this.getBlock) .get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash', this.getBlock)
.get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/summary', this.getStrippedBlockTransactions) .get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/summary', this.getStrippedBlockTransactions)
.get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/tx/:txid/summary', this.getStrippedBlockTransaction)
.get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/audit-summary', this.getBlockAuditSummary) .get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/audit-summary', this.getBlockAuditSummary)
.get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/tx/:txid/audit', this.$getBlockTxAuditSummary) .get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/tx/:txid/audit', this.$getBlockTxAuditSummary)
.get(config.MEMPOOL.API_URL_PREFIX + 'blocks/tip/height', this.getBlockTipHeight) .get(config.MEMPOOL.API_URL_PREFIX + 'blocks/tip/height', this.getBlockTipHeight)
.post(config.MEMPOOL.API_URL_PREFIX + 'psbt/addparents', this.postPsbtCompletion) .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', this.getBlocksByBulk.bind(this))
.get(config.MEMPOOL.API_URL_PREFIX + 'blocks-bulk/:from/:to', this.getBlocksByBulk.bind(this)) .get(config.MEMPOOL.API_URL_PREFIX + 'blocks-bulk/:from/:to', this.getBlocksByBulk.bind(this))
// Temporarily add txs/package endpoint for all backends until esplora supports it
.post(config.MEMPOOL.API_URL_PREFIX + 'txs/package', this.$submitPackage)
; ;
if (config.MEMPOOL.BACKEND !== 'esplora') { if (config.MEMPOOL.BACKEND !== 'esplora') {
@ -86,7 +90,7 @@ class BitcoinRoutes {
res.set('Content-Type', 'application/json'); res.set('Content-Type', 'application/json');
res.send(result); res.send(result);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -105,13 +109,13 @@ class BitcoinRoutes {
const result = mempoolBlocks.getMempoolBlocks(); const result = mempoolBlocks.getMempoolBlocks();
res.json(result); res.json(result);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
private getTransactionTimes(req: Request, res: Response) { private getTransactionTimes(req: Request, res: Response) {
if (!Array.isArray(req.query.txId)) { if (!Array.isArray(req.query.txId)) {
res.status(500).send('Not an array'); handleError(req, res, 500, 'Not an array');
return; return;
} }
const txIds: string[] = []; const txIds: string[] = [];
@ -128,12 +132,12 @@ class BitcoinRoutes {
private async $getBatchedOutspends(req: Request, res: Response): Promise<IEsploraApi.Outspend[][] | void> { private async $getBatchedOutspends(req: Request, res: Response): Promise<IEsploraApi.Outspend[][] | void> {
const txids_csv = req.query.txids; const txids_csv = req.query.txids;
if (!txids_csv || typeof txids_csv !== 'string') { if (!txids_csv || typeof txids_csv !== 'string') {
res.status(500).send('Invalid txids format'); handleError(req, res, 500, 'Invalid txids format');
return; return;
} }
const txids = txids_csv.split(','); const txids = txids_csv.split(',');
if (txids.length > 50) { if (txids.length > 50) {
res.status(400).send('Too many txids requested'); handleError(req, res, 400, 'Too many txids requested');
return; return;
} }
@ -141,13 +145,13 @@ class BitcoinRoutes {
const batchedOutspends = await bitcoinApi.$getBatchedOutspends(txids); const batchedOutspends = await bitcoinApi.$getBatchedOutspends(txids);
res.json(batchedOutspends); res.json(batchedOutspends);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
private async $getCpfpInfo(req: Request, res: Response) { private async $getCpfpInfo(req: Request, res: Response) {
if (!/^[a-fA-F0-9]{64}$/.test(req.params.txId)) { if (!/^[a-fA-F0-9]{64}$/.test(req.params.txId)) {
res.status(501).send(`Invalid transaction ID.`); handleError(req, res, 501, `Invalid transaction ID.`);
return; return;
} }
@ -165,6 +169,7 @@ class BitcoinRoutes {
acceleration: tx.acceleration, acceleration: tx.acceleration,
acceleratedBy: tx.acceleratedBy || undefined, acceleratedBy: tx.acceleratedBy || undefined,
acceleratedAt: tx.acceleratedAt || undefined, acceleratedAt: tx.acceleratedAt || undefined,
feeDelta: tx.feeDelta || undefined,
}); });
return; return;
} }
@ -179,7 +184,7 @@ class BitcoinRoutes {
try { try {
cpfpInfo = await transactionRepository.$getCpfpInfo(req.params.txId); cpfpInfo = await transactionRepository.$getCpfpInfo(req.params.txId);
} catch (e) { } catch (e) {
res.status(500).send('failed to get CPFP info'); handleError(req, res, 500, 'failed to get CPFP info');
return; return;
} }
} }
@ -208,7 +213,7 @@ class BitcoinRoutes {
if (e instanceof Error && e instanceof Error && e.message && e.message.indexOf('No such mempool or blockchain transaction') > -1) { if (e instanceof Error && e instanceof Error && e.message && e.message.indexOf('No such mempool or blockchain transaction') > -1) {
statusCode = 404; statusCode = 404;
} }
res.status(statusCode).send(e instanceof Error ? e.message : e); handleError(req, res, statusCode, e instanceof Error ? e.message : e);
} }
} }
@ -222,7 +227,7 @@ class BitcoinRoutes {
if (e instanceof Error && e.message && e.message.indexOf('No such mempool or blockchain transaction') > -1) { if (e instanceof Error && e.message && e.message.indexOf('No such mempool or blockchain transaction') > -1) {
statusCode = 404; statusCode = 404;
} }
res.status(statusCode).send(e instanceof Error ? e.message : e); handleError(req, res, statusCode, e instanceof Error ? e.message : e);
} }
} }
@ -283,13 +288,13 @@ class BitcoinRoutes {
// Not modified // Not modified
// 422 Unprocessable Entity // 422 Unprocessable Entity
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/422 // https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/422
res.status(422).send(`Psbt had no missing nonWitnessUtxos.`); handleError(req, res, 422, `Psbt had no missing nonWitnessUtxos.`);
} }
} catch (e: any) { } catch (e: any) {
if (e instanceof Error && new RegExp(notFoundError).test(e.message)) { if (e instanceof Error && new RegExp(notFoundError).test(e.message)) {
res.status(404).send(e.message); handleError(req, res, 404, e.message);
} else { } else {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
} }
@ -303,7 +308,7 @@ class BitcoinRoutes {
if (e instanceof Error && e.message && e.message.indexOf('No such mempool or blockchain transaction') > -1) { if (e instanceof Error && e.message && e.message.indexOf('No such mempool or blockchain transaction') > -1) {
statusCode = 404; statusCode = 404;
} }
res.status(statusCode).send(e instanceof Error ? e.message : e); handleError(req, res, statusCode, e instanceof Error ? e.message : e);
} }
} }
@ -312,6 +317,20 @@ class BitcoinRoutes {
const transactions = await blocks.$getStrippedBlockTransactions(req.params.hash); const transactions = await blocks.$getStrippedBlockTransactions(req.params.hash);
res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24 * 30).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24 * 30).toUTCString());
res.json(transactions); res.json(transactions);
} catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e);
}
}
private async getStrippedBlockTransaction(req: Request, res: Response) {
try {
const transaction = await blocks.$getSingleTxFromSummary(req.params.hash, req.params.txid);
if (!transaction) {
handleError(req, res, 404, `transaction not found in summary`);
return;
}
res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24 * 30).toUTCString());
res.json(transaction);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); res.status(500).send(e instanceof Error ? e.message : e);
} }
@ -335,7 +354,7 @@ class BitcoinRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * cacheDuration).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * cacheDuration).toUTCString());
res.json(block); res.json(block);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -345,7 +364,7 @@ class BitcoinRoutes {
res.setHeader('content-type', 'text/plain'); res.setHeader('content-type', 'text/plain');
res.send(blockHeader); res.send(blockHeader);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -356,10 +375,11 @@ class BitcoinRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24 * 30).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24 * 30).toUTCString());
res.json(auditSummary); res.json(auditSummary);
} else { } else {
return res.status(404).send(`audit not available`); handleError(req, res, 404, `audit not available`);
return;
} }
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -370,7 +390,8 @@ class BitcoinRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24 * 30).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24 * 30).toUTCString());
res.json(auditSummary); res.json(auditSummary);
} else { } else {
return res.status(404).send(`transaction audit not available`); handleError(req, res, 404, `transaction audit not available`);
return;
} }
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); res.status(500).send(e instanceof Error ? e.message : e);
@ -387,42 +408,49 @@ class BitcoinRoutes {
return await this.getLegacyBlocks(req, res); return await this.getLegacyBlocks(req, res);
} }
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
private async getBlocksByBulk(req: Request, res: Response) { private async getBlocksByBulk(req: Request, res: Response) {
try { try {
if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK) === false) { // Liquid - Not implemented if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK) === false) { // Liquid - Not implemented
return res.status(404).send(`This API is only available for Bitcoin networks`); handleError(req, res, 404, `This API is only available for Bitcoin networks`);
return;
} }
if (config.MEMPOOL.MAX_BLOCKS_BULK_QUERY <= 0) { if (config.MEMPOOL.MAX_BLOCKS_BULK_QUERY <= 0) {
return res.status(404).send(`This API is disabled. Set config.MEMPOOL.MAX_BLOCKS_BULK_QUERY to a positive number to enable it.`); handleError(req, res, 404, `This API is disabled. Set config.MEMPOOL.MAX_BLOCKS_BULK_QUERY to a positive number to enable it.`);
return;
} }
if (!Common.indexingEnabled()) { if (!Common.indexingEnabled()) {
return res.status(404).send(`Indexing is required for this API`); handleError(req, res, 404, `Indexing is required for this API`);
return;
} }
const from = parseInt(req.params.from, 10); const from = parseInt(req.params.from, 10);
if (!req.params.from || from < 0) { if (!req.params.from || from < 0) {
return res.status(400).send(`Parameter 'from' must be a block height (integer)`); handleError(req, res, 400, `Parameter 'from' must be a block height (integer)`);
return;
} }
const to = req.params.to === undefined ? await bitcoinApi.$getBlockHeightTip() : parseInt(req.params.to, 10); const to = req.params.to === undefined ? await bitcoinApi.$getBlockHeightTip() : parseInt(req.params.to, 10);
if (to < 0) { if (to < 0) {
return res.status(400).send(`Parameter 'to' must be a block height (integer)`); handleError(req, res, 400, `Parameter 'to' must be a block height (integer)`);
return;
} }
if (from > to) { if (from > to) {
return res.status(400).send(`Parameter 'to' must be a higher block height than 'from'`); handleError(req, res, 400, `Parameter 'to' must be a higher block height than 'from'`);
return;
} }
if ((to - from + 1) > config.MEMPOOL.MAX_BLOCKS_BULK_QUERY) { if ((to - from + 1) > config.MEMPOOL.MAX_BLOCKS_BULK_QUERY) {
return res.status(400).send(`You can only query ${config.MEMPOOL.MAX_BLOCKS_BULK_QUERY} blocks at once.`); handleError(req, res, 400, `You can only query ${config.MEMPOOL.MAX_BLOCKS_BULK_QUERY} blocks at once.`);
return;
} }
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
res.json(await blocks.$getBlocksBetweenHeight(from, to)); res.json(await blocks.$getBlocksBetweenHeight(from, to));
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -457,10 +485,10 @@ class BitcoinRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
res.json(returnBlocks); res.json(returnBlocks);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
private async getBlockTransactions(req: Request, res: Response) { private async getBlockTransactions(req: Request, res: Response) {
try { try {
loadingIndicators.setProgress('blocktxs-' + req.params.hash, 0); loadingIndicators.setProgress('blocktxs-' + req.params.hash, 0);
@ -482,7 +510,7 @@ class BitcoinRoutes {
res.json(transactions); res.json(transactions);
} catch (e) { } catch (e) {
loadingIndicators.setProgress('blocktxs-' + req.params.hash, 100); loadingIndicators.setProgress('blocktxs-' + req.params.hash, 100);
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -491,13 +519,13 @@ class BitcoinRoutes {
const blockHash = await bitcoinApi.$getBlockHash(parseInt(req.params.height, 10)); const blockHash = await bitcoinApi.$getBlockHash(parseInt(req.params.height, 10));
res.send(blockHash); res.send(blockHash);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
private async getAddress(req: Request, res: Response) { private async getAddress(req: Request, res: Response) {
if (config.MEMPOOL.BACKEND === 'none') { if (config.MEMPOOL.BACKEND === 'none') {
res.status(405).send('Address lookups cannot be used with bitcoind as backend.'); handleError(req, res, 405, 'Address lookups cannot be used with bitcoind as backend.');
return; return;
} }
@ -506,15 +534,16 @@ class BitcoinRoutes {
res.json(addressData); res.json(addressData);
} catch (e) { } catch (e) {
if (e instanceof Error && e.message && (e.message.indexOf('too long') > 0 || e.message.indexOf('confirmed status') > 0)) { if (e instanceof Error && e.message && (e.message.indexOf('too long') > 0 || e.message.indexOf('confirmed status') > 0)) {
return res.status(413).send(e instanceof Error ? e.message : e); handleError(req, res, 413, e instanceof Error ? e.message : e);
return;
} }
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
private async getAddressTransactions(req: Request, res: Response): Promise<void> { private async getAddressTransactions(req: Request, res: Response): Promise<void> {
if (config.MEMPOOL.BACKEND === 'none') { if (config.MEMPOOL.BACKEND === 'none') {
res.status(405).send('Address lookups cannot be used with bitcoind as backend.'); handleError(req, res, 405, 'Address lookups cannot be used with bitcoind as backend.');
return; return;
} }
@ -527,23 +556,23 @@ class BitcoinRoutes {
res.json(transactions); res.json(transactions);
} catch (e) { } catch (e) {
if (e instanceof Error && e.message && (e.message.indexOf('too long') > 0 || e.message.indexOf('confirmed status') > 0)) { if (e instanceof Error && e.message && (e.message.indexOf('too long') > 0 || e.message.indexOf('confirmed status') > 0)) {
res.status(413).send(e instanceof Error ? e.message : e); handleError(req, res, 413, e instanceof Error ? e.message : e);
return; return;
} }
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
private async getAddressTransactionSummary(req: Request, res: Response): Promise<void> { private async getAddressTransactionSummary(req: Request, res: Response): Promise<void> {
if (config.MEMPOOL.BACKEND !== 'esplora') { if (config.MEMPOOL.BACKEND !== 'esplora') {
res.status(405).send('Address summary lookups require mempool/electrs backend.'); handleError(req, res, 405, 'Address summary lookups require mempool/electrs backend.');
return; return;
} }
} }
private async getScriptHash(req: Request, res: Response) { private async getScriptHash(req: Request, res: Response) {
if (config.MEMPOOL.BACKEND === 'none') { if (config.MEMPOOL.BACKEND === 'none') {
res.status(405).send('Address lookups cannot be used with bitcoind as backend.'); handleError(req, res, 405, 'Address lookups cannot be used with bitcoind as backend.');
return; return;
} }
@ -554,15 +583,16 @@ class BitcoinRoutes {
res.json(addressData); res.json(addressData);
} catch (e) { } catch (e) {
if (e instanceof Error && e.message && (e.message.indexOf('too long') > 0 || e.message.indexOf('confirmed status') > 0)) { if (e instanceof Error && e.message && (e.message.indexOf('too long') > 0 || e.message.indexOf('confirmed status') > 0)) {
return res.status(413).send(e instanceof Error ? e.message : e); handleError(req, res, 413, e instanceof Error ? e.message : e);
return;
} }
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
private async getScriptHashTransactions(req: Request, res: Response): Promise<void> { private async getScriptHashTransactions(req: Request, res: Response): Promise<void> {
if (config.MEMPOOL.BACKEND === 'none') { if (config.MEMPOOL.BACKEND === 'none') {
res.status(405).send('Address lookups cannot be used with bitcoind as backend.'); handleError(req, res, 405, 'Address lookups cannot be used with bitcoind as backend.');
return; return;
} }
@ -577,16 +607,16 @@ class BitcoinRoutes {
res.json(transactions); res.json(transactions);
} catch (e) { } catch (e) {
if (e instanceof Error && e.message && (e.message.indexOf('too long') > 0 || e.message.indexOf('confirmed status') > 0)) { if (e instanceof Error && e.message && (e.message.indexOf('too long') > 0 || e.message.indexOf('confirmed status') > 0)) {
res.status(413).send(e instanceof Error ? e.message : e); handleError(req, res, 413, e instanceof Error ? e.message : e);
return; return;
} }
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
private async getScriptHashTransactionSummary(req: Request, res: Response): Promise<void> { private async getScriptHashTransactionSummary(req: Request, res: Response): Promise<void> {
if (config.MEMPOOL.BACKEND !== 'esplora') { if (config.MEMPOOL.BACKEND !== 'esplora') {
res.status(405).send('Scripthash summary lookups require mempool/electrs backend.'); handleError(req, res, 405, 'Scripthash summary lookups require mempool/electrs backend.');
return; return;
} }
} }
@ -596,7 +626,7 @@ class BitcoinRoutes {
const blockHash = await bitcoinApi.$getAddressPrefix(req.params.prefix); const blockHash = await bitcoinApi.$getAddressPrefix(req.params.prefix);
res.send(blockHash); res.send(blockHash);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -623,7 +653,7 @@ class BitcoinRoutes {
const rawMempool = await bitcoinApi.$getRawMempool(); const rawMempool = await bitcoinApi.$getRawMempool();
res.send(rawMempool); res.send(rawMempool);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -631,12 +661,13 @@ class BitcoinRoutes {
try { try {
const result = blocks.getCurrentBlockHeight(); const result = blocks.getCurrentBlockHeight();
if (!result) { if (!result) {
return res.status(503).send(`Service Temporarily Unavailable`); handleError(req, res, 503, `Service Temporarily Unavailable`);
return;
} }
res.setHeader('content-type', 'text/plain'); res.setHeader('content-type', 'text/plain');
res.send(result.toString()); res.send(result.toString());
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -646,7 +677,7 @@ class BitcoinRoutes {
res.setHeader('content-type', 'text/plain'); res.setHeader('content-type', 'text/plain');
res.send(result); res.send(result);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -656,7 +687,7 @@ class BitcoinRoutes {
res.setHeader('content-type', 'application/octet-stream'); res.setHeader('content-type', 'application/octet-stream');
res.send(result); res.send(result);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -665,7 +696,7 @@ class BitcoinRoutes {
const result = await bitcoinApi.$getTxIdsForBlock(req.params.hash); const result = await bitcoinApi.$getTxIdsForBlock(req.params.hash);
res.json(result); res.json(result);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -674,7 +705,7 @@ class BitcoinRoutes {
const result = await bitcoinClient.validateAddress(req.params.address); const result = await bitcoinClient.validateAddress(req.params.address);
res.json(result); res.json(result);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -687,7 +718,7 @@ class BitcoinRoutes {
replaces replaces
}); });
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -696,7 +727,7 @@ class BitcoinRoutes {
const result = rbfCache.getRbfTrees(false); const result = rbfCache.getRbfTrees(false);
res.json(result); res.json(result);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -705,7 +736,7 @@ class BitcoinRoutes {
const result = rbfCache.getRbfTrees(true); const result = rbfCache.getRbfTrees(true);
res.json(result); res.json(result);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -718,7 +749,7 @@ class BitcoinRoutes {
res.status(204).send(); res.status(204).send();
} }
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -727,7 +758,7 @@ class BitcoinRoutes {
const result = await bitcoinApi.$getOutspends(req.params.txId); const result = await bitcoinApi.$getOutspends(req.params.txId);
res.json(result); res.json(result);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -737,10 +768,10 @@ class BitcoinRoutes {
if (da) { if (da) {
res.json(da); res.json(da);
} else { } else {
res.status(503).send(`Service Temporarily Unavailable`); handleError(req, res, 503, `Service Temporarily Unavailable`);
} }
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -751,7 +782,7 @@ class BitcoinRoutes {
const txIdResult = await bitcoinApi.$sendRawTransaction(rawTx); const txIdResult = await bitcoinApi.$sendRawTransaction(rawTx);
res.send(txIdResult); res.send(txIdResult);
} catch (e: any) { } catch (e: any) {
res.status(400).send(e.message && e.code ? 'sendrawtransaction RPC error: ' + JSON.stringify({ code: e.code, message: e.message }) handleError(req, res, 400, e.message && e.code ? 'sendrawtransaction RPC error: ' + JSON.stringify({ code: e.code, message: e.message })
: (e.message || 'Error')); : (e.message || 'Error'));
} }
} }
@ -763,7 +794,7 @@ class BitcoinRoutes {
const txIdResult = await bitcoinClient.sendRawTransaction(txHex); const txIdResult = await bitcoinClient.sendRawTransaction(txHex);
res.send(txIdResult); res.send(txIdResult);
} catch (e: any) { } catch (e: any) {
res.status(400).send(e.message && e.code ? 'sendrawtransaction RPC error: ' + JSON.stringify({ code: e.code, message: e.message }) handleError(req, res, 400, e.message && e.code ? 'sendrawtransaction RPC error: ' + JSON.stringify({ code: e.code, message: e.message })
: (e.message || 'Error')); : (e.message || 'Error'));
} }
} }
@ -775,8 +806,20 @@ class BitcoinRoutes {
const result = await bitcoinApi.$testMempoolAccept(rawTxs, maxfeerate); const result = await bitcoinApi.$testMempoolAccept(rawTxs, maxfeerate);
res.send(result); res.send(result);
} catch (e: any) { } catch (e: any) {
res.setHeader('content-type', 'text/plain'); handleError(req, res, 400, e.message && e.code ? 'testmempoolaccept RPC error: ' + JSON.stringify({ code: e.code, message: e.message })
res.status(400).send(e.message && e.code ? 'testmempoolaccept RPC error: ' + JSON.stringify({ code: e.code, message: e.message }) : (e.message || 'Error'));
}
}
private async $submitPackage(req: Request, res: Response) {
try {
const rawTxs = Common.getTransactionsFromRequest(req);
const maxfeerate = parseFloat(req.query.maxfeerate as string);
const maxburnamount = parseFloat(req.query.maxburnamount as string);
const result = await bitcoinClient.submitPackage(rawTxs, maxfeerate ?? undefined, maxburnamount ?? undefined);
res.send(result);
} catch (e: any) {
handleError(req, res, 400, e.message && e.code ? 'submitpackage RPC error: ' + JSON.stringify({ code: e.code, message: e.message })
: (e.message || 'Error')); : (e.message || 'Error'));
} }
} }

View File

@ -5,7 +5,7 @@ import { AbstractBitcoinApi, HealthCheckHost } from './bitcoin-api-abstract-fact
import { IEsploraApi } from './esplora-api.interface'; import { IEsploraApi } from './esplora-api.interface';
import logger from '../../logger'; import logger from '../../logger';
import { Common } from '../common'; import { Common } from '../common';
import { TestMempoolAcceptResult } from './bitcoin-api.interface'; import { SubmitPackageResult, TestMempoolAcceptResult } from './bitcoin-api.interface';
interface FailoverHost { interface FailoverHost {
host: string, host: string,
@ -305,7 +305,7 @@ class ElectrsApi implements AbstractBitcoinApi {
} }
$getAddress(address: string): Promise<IEsploraApi.Address> { $getAddress(address: string): Promise<IEsploraApi.Address> {
throw new Error('Method getAddress not implemented.'); return this.failoverRouter.$get<IEsploraApi.Address>('/address/' + address);
} }
$getAddressTransactions(address: string, txId?: string): Promise<IEsploraApi.Transaction[]> { $getAddressTransactions(address: string, txId?: string): Promise<IEsploraApi.Transaction[]> {
@ -332,6 +332,10 @@ class ElectrsApi implements AbstractBitcoinApi {
throw new Error('Method not implemented.'); throw new Error('Method not implemented.');
} }
$submitPackage(rawTransactions: string[]): Promise<SubmitPackageResult> {
throw new Error('Method not implemented.');
}
$getOutspend(txId: string, vout: number): Promise<IEsploraApi.Outspend> { $getOutspend(txId: string, vout: number): Promise<IEsploraApi.Outspend> {
return this.failoverRouter.$get<IEsploraApi.Outspend>('/tx/' + txId + '/outspend/' + vout); return this.failoverRouter.$get<IEsploraApi.Outspend>('/tx/' + txId + '/outspend/' + vout);
} }

View File

@ -33,6 +33,8 @@ import AccelerationRepository from '../repositories/AccelerationRepository';
import { calculateFastBlockCpfp, calculateGoodBlockCpfp } from './cpfp'; import { calculateFastBlockCpfp, calculateGoodBlockCpfp } from './cpfp';
import mempool from './mempool'; import mempool from './mempool';
import CpfpRepository from '../repositories/CpfpRepository'; import CpfpRepository from '../repositories/CpfpRepository';
import accelerationApi from './services/acceleration';
import { parseDATUMTemplateCreator } from '../utils/bitcoin-script';
class Blocks { class Blocks {
private blocks: BlockExtended[] = []; private blocks: BlockExtended[] = [];
@ -218,10 +220,10 @@ class Blocks {
}; };
} }
public summarizeBlockTransactions(hash: string, transactions: TransactionExtended[]): BlockSummary { public summarizeBlockTransactions(hash: string, height: number, transactions: TransactionExtended[]): BlockSummary {
return { return {
id: hash, id: hash,
transactions: Common.classifyTransactions(transactions), transactions: Common.classifyTransactions(transactions, height),
}; };
} }
@ -341,7 +343,12 @@ class Blocks {
id: pool.uniqueId, id: pool.uniqueId,
name: pool.name, name: pool.name,
slug: pool.slug, slug: pool.slug,
minerNames: null,
}; };
if (extras.pool.name === 'OCEAN') {
extras.pool.minerNames = parseDATUMTemplateCreator(extras.coinbaseRaw);
}
} }
extras.matchRate = null; extras.matchRate = null;
@ -405,8 +412,16 @@ class Blocks {
} }
try { try {
const blockchainInfo = await bitcoinClient.getBlockchainInfo();
const currentBlockHeight = blockchainInfo.blocks;
let indexingBlockAmount = Math.min(config.MEMPOOL.INDEXING_BLOCKS_AMOUNT, currentBlockHeight);
if (indexingBlockAmount <= -1) {
indexingBlockAmount = currentBlockHeight + 1;
}
const lastBlockToIndex = Math.max(0, currentBlockHeight - indexingBlockAmount + 1);
// Get all indexed block hash // Get all indexed block hash
const indexedBlocks = await blocksRepository.$getIndexedBlocks(); const indexedBlocks = (await blocksRepository.$getIndexedBlocks()).filter(block => block.height >= lastBlockToIndex);
const indexedBlockSummariesHashesArray = await BlocksSummariesRepository.$getIndexedSummariesId(); const indexedBlockSummariesHashesArray = await BlocksSummariesRepository.$getIndexedSummariesId();
const indexedBlockSummariesHashes = {}; // Use a map for faster seek during the indexing loop const indexedBlockSummariesHashes = {}; // Use a map for faster seek during the indexing loop
@ -439,7 +454,7 @@ class Blocks {
if (config.MEMPOOL.BACKEND === 'esplora') { if (config.MEMPOOL.BACKEND === 'esplora') {
const txs = (await bitcoinApi.$getTxsForBlock(block.hash)).map(tx => transactionUtils.extendTransaction(tx)); const txs = (await bitcoinApi.$getTxsForBlock(block.hash)).map(tx => transactionUtils.extendMempoolTransaction(tx));
const cpfpSummary = await this.$indexCPFP(block.hash, block.height, txs); const cpfpSummary = await this.$indexCPFP(block.hash, block.height, txs);
if (cpfpSummary) { if (cpfpSummary) {
await this.$getStrippedBlockTransactions(block.hash, true, true, cpfpSummary, block.height); // This will index the block summary await this.$getStrippedBlockTransactions(block.hash, true, true, cpfpSummary, block.height); // This will index the block summary
@ -615,7 +630,7 @@ class Blocks {
// add CPFP // add CPFP
const cpfpSummary = calculateGoodBlockCpfp(height, txs, []); const cpfpSummary = calculateGoodBlockCpfp(height, txs, []);
// classify // classify
const { transactions: classifiedTxs } = this.summarizeBlockTransactions(blockHash, cpfpSummary.transactions); const { transactions: classifiedTxs } = this.summarizeBlockTransactions(blockHash, height, cpfpSummary.transactions);
await BlocksSummariesRepository.$saveTransactions(height, blockHash, classifiedTxs, 2); await BlocksSummariesRepository.$saveTransactions(height, blockHash, classifiedTxs, 2);
if (unclassifiedBlocks[height].version < 2 && targetSummaryVersion === 2) { if (unclassifiedBlocks[height].version < 2 && targetSummaryVersion === 2) {
const cpfpClusters = await CpfpRepository.$getClustersAt(height); const cpfpClusters = await CpfpRepository.$getClustersAt(height);
@ -652,7 +667,7 @@ class Blocks {
} }
const cpfpSummary = calculateGoodBlockCpfp(height, templateTxs?.filter(tx => tx['effectiveFeePerVsize'] != null) as MempoolTransactionExtended[], []); const cpfpSummary = calculateGoodBlockCpfp(height, templateTxs?.filter(tx => tx['effectiveFeePerVsize'] != null) as MempoolTransactionExtended[], []);
// classify // classify
const { transactions: classifiedTxs } = this.summarizeBlockTransactions(blockHash, cpfpSummary.transactions); const { transactions: classifiedTxs } = this.summarizeBlockTransactions(blockHash, height, cpfpSummary.transactions);
const classifiedTxMap: { [txid: string]: TransactionClassified } = {}; const classifiedTxMap: { [txid: string]: TransactionClassified } = {};
for (const tx of classifiedTxs) { for (const tx of classifiedTxs) {
classifiedTxMap[tx.txid] = tx; classifiedTxMap[tx.txid] = tx;
@ -904,9 +919,14 @@ class Blocks {
} }
} }
const cpfpSummary: CpfpSummary = calculateGoodBlockCpfp(block.height, transactions, Object.values(mempool.getAccelerations()).map(a => ({ txid: a.txid, max_bid: a.feeDelta }))); let accelerations = Object.values(mempool.getAccelerations());
if (accelerations?.length > 0) {
const pool = await this.$findBlockMiner(transactionUtils.stripCoinbaseTransaction(transactions[0]));
accelerations = accelerations.filter(a => a.pools.includes(pool.uniqueId));
}
const cpfpSummary: CpfpSummary = calculateGoodBlockCpfp(block.height, transactions, accelerations.map(a => ({ txid: a.txid, max_bid: a.feeDelta })));
const blockExtended: BlockExtended = await this.$getBlockExtended(block, cpfpSummary.transactions); const blockExtended: BlockExtended = await this.$getBlockExtended(block, cpfpSummary.transactions);
const blockSummary: BlockSummary = this.summarizeBlockTransactions(block.id, cpfpSummary.transactions); const blockSummary: BlockSummary = this.summarizeBlockTransactions(block.id, block.height, cpfpSummary.transactions);
this.updateTimerProgress(timer, `got block data for ${this.currentBlockHeight}`); this.updateTimerProgress(timer, `got block data for ${this.currentBlockHeight}`);
if (Common.indexingEnabled()) { if (Common.indexingEnabled()) {
@ -927,12 +947,12 @@ class Blocks {
const newBlock = await this.$indexBlock(lastBlock.height - i); const newBlock = await this.$indexBlock(lastBlock.height - i);
this.blocks.push(newBlock); this.blocks.push(newBlock);
this.updateTimerProgress(timer, `reindexed block`); this.updateTimerProgress(timer, `reindexed block`);
let cpfpSummary; let newCpfpSummary;
if (config.MEMPOOL.CPFP_INDEXING) { if (config.MEMPOOL.CPFP_INDEXING) {
cpfpSummary = await this.$indexCPFP(newBlock.id, lastBlock.height - i); newCpfpSummary = await this.$indexCPFP(newBlock.id, lastBlock.height - i);
this.updateTimerProgress(timer, `reindexed block cpfp`); this.updateTimerProgress(timer, `reindexed block cpfp`);
} }
await this.$getStrippedBlockTransactions(newBlock.id, true, true, cpfpSummary, newBlock.height); await this.$getStrippedBlockTransactions(newBlock.id, true, true, newCpfpSummary, newBlock.height);
this.updateTimerProgress(timer, `reindexed block summary`); this.updateTimerProgress(timer, `reindexed block summary`);
} }
await mining.$indexDifficultyAdjustments(); await mining.$indexDifficultyAdjustments();
@ -981,7 +1001,7 @@ class Blocks {
// start async callbacks // start async callbacks
this.updateTimerProgress(timer, `starting async callbacks for ${this.currentBlockHeight}`); this.updateTimerProgress(timer, `starting async callbacks for ${this.currentBlockHeight}`);
const callbackPromises = this.newAsyncBlockCallbacks.map((cb) => cb(blockExtended, txIds, transactions)); const callbackPromises = this.newAsyncBlockCallbacks.map((cb) => cb(blockExtended, txIds, cpfpSummary.transactions));
if (block.height % 2016 === 0) { if (block.height % 2016 === 0) {
if (Common.indexingEnabled()) { if (Common.indexingEnabled()) {
@ -1163,7 +1183,7 @@ class Blocks {
transactions: cpfpSummary.transactions.map(tx => { transactions: cpfpSummary.transactions.map(tx => {
let flags: number = 0; let flags: number = 0;
try { try {
flags = Common.getTransactionFlags(tx); flags = Common.getTransactionFlags(tx, height);
} catch (e) { } catch (e) {
logger.warn('Failed to classify transaction: ' + (e instanceof Error ? e.message : e)); logger.warn('Failed to classify transaction: ' + (e instanceof Error ? e.message : e));
} }
@ -1178,11 +1198,11 @@ class Blocks {
}; };
}), }),
}; };
summaryVersion = 1; summaryVersion = cpfpSummary.version;
} else { } else {
if (config.MEMPOOL.BACKEND === 'esplora') { if (config.MEMPOOL.BACKEND === 'esplora') {
const txs = (await bitcoinApi.$getTxsForBlock(hash)).map(tx => transactionUtils.extendTransaction(tx)); const txs = (await bitcoinApi.$getTxsForBlock(hash)).map(tx => transactionUtils.extendTransaction(tx));
summary = this.summarizeBlockTransactions(hash, txs); summary = this.summarizeBlockTransactions(hash, height || 0, txs);
summaryVersion = 1; summaryVersion = 1;
} else { } else {
// Call Core RPC // Call Core RPC
@ -1204,6 +1224,11 @@ class Blocks {
return summary.transactions; return summary.transactions;
} }
public async $getSingleTxFromSummary(hash: string, txid: string): Promise<TransactionClassified | null> {
const txs = await this.$getStrippedBlockTransactions(hash);
return txs.find(tx => tx.txid === txid) || null;
}
/** /**
* Get 15 blocks * Get 15 blocks
* *
@ -1318,7 +1343,7 @@ class Blocks {
let summaryVersion = 0; let summaryVersion = 0;
if (config.MEMPOOL.BACKEND === 'esplora') { if (config.MEMPOOL.BACKEND === 'esplora') {
const txs = (await bitcoinApi.$getTxsForBlock(cleanBlock.hash)).map(tx => transactionUtils.extendTransaction(tx)); const txs = (await bitcoinApi.$getTxsForBlock(cleanBlock.hash)).map(tx => transactionUtils.extendTransaction(tx));
summary = this.summarizeBlockTransactions(cleanBlock.hash, txs); summary = this.summarizeBlockTransactions(cleanBlock.hash, cleanBlock.height, txs);
summaryVersion = 1; summaryVersion = 1;
} else { } else {
// Call Core RPC // Call Core RPC
@ -1397,11 +1422,11 @@ class Blocks {
return this.currentBlockHeight; return this.currentBlockHeight;
} }
public async $indexCPFP(hash: string, height: number, txs?: TransactionExtended[]): Promise<CpfpSummary | null> { public async $indexCPFP(hash: string, height: number, txs?: MempoolTransactionExtended[]): Promise<CpfpSummary | null> {
let transactions = txs; let transactions = txs;
if (!transactions) { if (!transactions) {
if (config.MEMPOOL.BACKEND === 'esplora') { if (config.MEMPOOL.BACKEND === 'esplora') {
transactions = (await bitcoinApi.$getTxsForBlock(hash)).map(tx => transactionUtils.extendTransaction(tx)); transactions = (await bitcoinApi.$getTxsForBlock(hash)).map(tx => transactionUtils.extendMempoolTransaction(tx));
} }
if (!transactions) { if (!transactions) {
const block = await bitcoinClient.getBlock(hash, 2); const block = await bitcoinClient.getBlock(hash, 2);
@ -1413,7 +1438,7 @@ class Blocks {
} }
if (transactions?.length != null) { if (transactions?.length != null) {
const summary = calculateFastBlockCpfp(height, transactions as TransactionExtended[]); const summary = calculateFastBlockCpfp(height, transactions);
await this.$saveCpfp(hash, height, summary); await this.$saveCpfp(hash, height, summary);

View File

@ -1,6 +1,6 @@
import * as bitcoinjs from 'bitcoinjs-lib'; import * as bitcoinjs from 'bitcoinjs-lib';
import { Request } from 'express'; import { Request } from 'express';
import { CpfpInfo, CpfpSummary, CpfpCluster, EffectiveFeeStats, MempoolBlockWithTransactions, TransactionExtended, MempoolTransactionExtended, TransactionStripped, WorkingEffectiveFeeStats, TransactionClassified, TransactionFlags } from '../mempool.interfaces'; import { EffectiveFeeStats, MempoolBlockWithTransactions, TransactionExtended, MempoolTransactionExtended, TransactionStripped, WorkingEffectiveFeeStats, TransactionClassified, TransactionFlags } from '../mempool.interfaces';
import config from '../config'; import config from '../config';
import { NodeSocket } from '../repositories/NodesSocketsRepository'; import { NodeSocket } from '../repositories/NodesSocketsRepository';
import { isIP } from 'net'; import { isIP } from 'net';
@ -10,7 +10,6 @@ import logger from '../logger';
import { getVarIntLength, opcodes, parseMultisigScript } from '../utils/bitcoin-script'; import { getVarIntLength, opcodes, parseMultisigScript } from '../utils/bitcoin-script';
// Bitcoin Core default policy settings // Bitcoin Core default policy settings
const TX_MAX_STANDARD_VERSION = 2;
const MAX_STANDARD_TX_WEIGHT = 400_000; const MAX_STANDARD_TX_WEIGHT = 400_000;
const MAX_BLOCK_SIGOPS_COST = 80_000; const MAX_BLOCK_SIGOPS_COST = 80_000;
const MAX_STANDARD_TX_SIGOPS_COST = (MAX_BLOCK_SIGOPS_COST / 5); const MAX_STANDARD_TX_SIGOPS_COST = (MAX_BLOCK_SIGOPS_COST / 5);
@ -80,8 +79,8 @@ export class Common {
return arr; return arr;
} }
static findRbfTransactions(added: MempoolTransactionExtended[], deleted: MempoolTransactionExtended[], forceScalable = false): { [txid: string]: MempoolTransactionExtended[] } { static findRbfTransactions(added: MempoolTransactionExtended[], deleted: MempoolTransactionExtended[], forceScalable = false): { [txid: string]: { replaced: MempoolTransactionExtended[], replacedBy: TransactionExtended }} {
const matches: { [txid: string]: MempoolTransactionExtended[] } = {}; const matches: { [txid: string]: { replaced: MempoolTransactionExtended[], replacedBy: TransactionExtended }} = {};
// For small N, a naive nested loop is extremely fast, but it doesn't scale // For small N, a naive nested loop is extremely fast, but it doesn't scale
if (added.length < 1000 && deleted.length < 50 && !forceScalable) { if (added.length < 1000 && deleted.length < 50 && !forceScalable) {
@ -96,7 +95,7 @@ export class Common {
addedTx.vin.some((vin) => vin.txid === deletedVin.txid && vin.vout === deletedVin.vout)); addedTx.vin.some((vin) => vin.txid === deletedVin.txid && vin.vout === deletedVin.vout));
}); });
if (foundMatches?.length) { if (foundMatches?.length) {
matches[addedTx.txid] = [...new Set(foundMatches)]; matches[addedTx.txid] = { replaced: [...new Set(foundMatches)], replacedBy: addedTx };
} }
}); });
} else { } else {
@ -124,7 +123,7 @@ export class Common {
foundMatches.add(deletedTx); foundMatches.add(deletedTx);
} }
if (foundMatches.size) { if (foundMatches.size) {
matches[addedTx.txid] = [...foundMatches]; matches[addedTx.txid] = { replaced: [...foundMatches], replacedBy: addedTx };
} }
} }
} }
@ -139,17 +138,17 @@ export class Common {
const replaced: Set<MempoolTransactionExtended> = new Set(); const replaced: Set<MempoolTransactionExtended> = new Set();
for (let i = 0; i < tx.vin.length; i++) { for (let i = 0; i < tx.vin.length; i++) {
const vin = tx.vin[i]; const vin = tx.vin[i];
const match = spendMap.get(`${vin.txid}:${vin.vout}`); const key = `${vin.txid}:${vin.vout}`;
const match = spendMap.get(key);
if (match && match.txid !== tx.txid) { if (match && match.txid !== tx.txid) {
replaced.add(match); replaced.add(match);
// remove this tx from the spendMap // remove this tx from the spendMap
// prevents the same tx being replaced more than once // prevents the same tx being replaced more than once
for (const replacedVin of match.vin) { for (const replacedVin of match.vin) {
const key = `${replacedVin.txid}:${replacedVin.vout}`; const replacedKey = `${replacedVin.txid}:${replacedVin.vout}`;
spendMap.delete(key); spendMap.delete(replacedKey);
} }
} }
const key = `${vin.txid}:${vin.vout}`;
spendMap.delete(key); spendMap.delete(key);
} }
if (replaced.size) { if (replaced.size) {
@ -200,10 +199,13 @@ export class Common {
* *
* returns true early if any standardness rule is violated, otherwise false * returns true early if any standardness rule is violated, otherwise false
* (except for non-mandatory-script-verify-flag and p2sh script evaluation rules which are *not* enforced) * (except for non-mandatory-script-verify-flag and p2sh script evaluation rules which are *not* enforced)
*
* As standardness rules change, we'll need to apply the rules in force *at the time* to older blocks.
* For now, just pull out individual rules into versioned functions where necessary.
*/ */
static isNonStandard(tx: TransactionExtended): boolean { static isNonStandard(tx: TransactionExtended, height?: number): boolean {
// version // version
if (tx.version > TX_MAX_STANDARD_VERSION) { if (this.isNonStandardVersion(tx, height)) {
return true; return true;
} }
@ -250,6 +252,8 @@ export class Common {
} }
} else if (['unknown', 'provably_unspendable', 'empty'].includes(vin.prevout?.scriptpubkey_type || '')) { } else if (['unknown', 'provably_unspendable', 'empty'].includes(vin.prevout?.scriptpubkey_type || '')) {
return true; return true;
} else if (this.isNonStandardAnchor(tx, height)) {
return true;
} }
// TODO: bad-witness-nonstandard // TODO: bad-witness-nonstandard
} }
@ -335,6 +339,49 @@ export class Common {
return false; return false;
} }
// Individual versioned standardness rules
static V3_STANDARDNESS_ACTIVATION_HEIGHT = {
'testnet4': 42_000,
'testnet': 2_900_000,
'signet': 211_000,
'': 863_500,
};
static isNonStandardVersion(tx: TransactionExtended, height?: number): boolean {
let TX_MAX_STANDARD_VERSION = 3;
if (
height != null
&& this.V3_STANDARDNESS_ACTIVATION_HEIGHT[config.MEMPOOL.NETWORK]
&& height <= this.V3_STANDARDNESS_ACTIVATION_HEIGHT[config.MEMPOOL.NETWORK]
) {
// V3 transactions were non-standard to spend before v28.x (scheduled for 2024/09/30 https://github.com/bitcoin/bitcoin/issues/29891)
TX_MAX_STANDARD_VERSION = 2;
}
if (tx.version > TX_MAX_STANDARD_VERSION) {
return true;
}
return false;
}
static ANCHOR_STANDARDNESS_ACTIVATION_HEIGHT = {
'testnet4': 42_000,
'testnet': 2_900_000,
'signet': 211_000,
'': 863_500,
};
static isNonStandardAnchor(tx: TransactionExtended, height?: number): boolean {
if (
height != null
&& this.ANCHOR_STANDARDNESS_ACTIVATION_HEIGHT[config.MEMPOOL.NETWORK]
&& height <= this.ANCHOR_STANDARDNESS_ACTIVATION_HEIGHT[config.MEMPOOL.NETWORK]
) {
// anchor outputs were non-standard to spend before v28.x (scheduled for 2024/09/30 https://github.com/bitcoin/bitcoin/issues/29891)
return true;
}
return false;
}
static getNonWitnessSize(tx: TransactionExtended): number { static getNonWitnessSize(tx: TransactionExtended): number {
let weight = tx.weight; let weight = tx.weight;
let hasWitness = false; let hasWitness = false;
@ -415,7 +462,7 @@ export class Common {
return flags; return flags;
} }
static getTransactionFlags(tx: TransactionExtended): number { static getTransactionFlags(tx: TransactionExtended, height?: number): number {
let flags = tx.flags ? BigInt(tx.flags) : 0n; let flags = tx.flags ? BigInt(tx.flags) : 0n;
// Update variable flags (CPFP, RBF) // Update variable flags (CPFP, RBF)
@ -548,7 +595,7 @@ export class Common {
if (hasFakePubkey) { if (hasFakePubkey) {
flags |= TransactionFlags.fake_pubkey; flags |= TransactionFlags.fake_pubkey;
} }
// fast but bad heuristic to detect possible coinjoins // fast but bad heuristic to detect possible coinjoins
// (at least 5 inputs and 5 outputs, less than half of which are unique amounts, with no address reuse) // (at least 5 inputs and 5 outputs, less than half of which are unique amounts, with no address reuse)
const addressReuse = Object.keys(reusedOutputAddresses).reduce((acc, key) => Math.max(acc, (reusedInputAddresses[key] || 0) + (reusedOutputAddresses[key] || 0)), 0) > 1; const addressReuse = Object.keys(reusedOutputAddresses).reduce((acc, key) => Math.max(acc, (reusedInputAddresses[key] || 0) + (reusedOutputAddresses[key] || 0)), 0) > 1;
@ -564,17 +611,17 @@ export class Common {
flags |= TransactionFlags.batch_payout; flags |= TransactionFlags.batch_payout;
} }
if (this.isNonStandard(tx)) { if (this.isNonStandard(tx, height)) {
flags |= TransactionFlags.nonstandard; flags |= TransactionFlags.nonstandard;
} }
return Number(flags); return Number(flags);
} }
static classifyTransaction(tx: TransactionExtended): TransactionClassified { static classifyTransaction(tx: TransactionExtended, height?: number): TransactionClassified {
let flags = 0; let flags = 0;
try { try {
flags = Common.getTransactionFlags(tx); flags = Common.getTransactionFlags(tx, height);
} catch (e) { } catch (e) {
logger.warn('Failed to add classification flags to transaction: ' + (e instanceof Error ? e.message : e)); logger.warn('Failed to add classification flags to transaction: ' + (e instanceof Error ? e.message : e));
} }
@ -585,8 +632,8 @@ export class Common {
}; };
} }
static classifyTransactions(txs: TransactionExtended[]): TransactionClassified[] { static classifyTransactions(txs: TransactionExtended[], height?: number): TransactionClassified[] {
return txs.map(Common.classifyTransaction); return txs.map(tx => Common.classifyTransaction(tx, height));
} }
static stripTransaction(tx: TransactionExtended): TransactionStripped { static stripTransaction(tx: TransactionExtended): TransactionStripped {

View File

@ -6,7 +6,7 @@ import { Acceleration } from './acceleration/acceleration';
const CPFP_UPDATE_INTERVAL = 60_000; // update CPFP info at most once per 60s per transaction const CPFP_UPDATE_INTERVAL = 60_000; // update CPFP info at most once per 60s per transaction
const MAX_CLUSTER_ITERATIONS = 100; const MAX_CLUSTER_ITERATIONS = 100;
export function calculateFastBlockCpfp(height: number, transactions: TransactionExtended[], saveRelatives: boolean = false): CpfpSummary { export function calculateFastBlockCpfp(height: number, transactions: MempoolTransactionExtended[], saveRelatives: boolean = false): CpfpSummary {
const clusters: CpfpCluster[] = []; // list of all cpfp clusters in this block const clusters: CpfpCluster[] = []; // list of all cpfp clusters in this block
const clusterMap: { [txid: string]: CpfpCluster } = {}; // map transactions to their cpfp cluster const clusterMap: { [txid: string]: CpfpCluster } = {}; // map transactions to their cpfp cluster
let clusterTxs: TransactionExtended[] = []; // working list of elements of the current cluster let clusterTxs: TransactionExtended[] = []; // working list of elements of the current cluster
@ -93,6 +93,7 @@ export function calculateFastBlockCpfp(height: number, transactions: Transaction
return { return {
transactions, transactions,
clusters, clusters,
version: 1,
}; };
} }
@ -159,6 +160,7 @@ export function calculateGoodBlockCpfp(height: number, transactions: MempoolTran
return { return {
transactions: transactions.map(tx => txMap[tx.txid]), transactions: transactions.map(tx => txMap[tx.txid]),
clusters: clusterArray, clusters: clusterArray,
version: 2,
}; };
} }

View File

@ -7,7 +7,7 @@ import cpfpRepository from '../repositories/CpfpRepository';
import { RowDataPacket } from 'mysql2'; import { RowDataPacket } from 'mysql2';
class DatabaseMigration { class DatabaseMigration {
private static currentVersion = 80; private static currentVersion = 93;
private queryTimeout = 3600_000; private queryTimeout = 3600_000;
private statisticsAddedIndexed = false; private statisticsAddedIndexed = false;
private uniqueLogs: string[] = []; private uniqueLogs: string[] = [];
@ -653,9 +653,11 @@ class DatabaseMigration {
await this.$executeQuery('ALTER TABLE `prices` ADD `TRY` float DEFAULT "-1"'); await this.$executeQuery('ALTER TABLE `prices` ADD `TRY` float DEFAULT "-1"');
await this.$executeQuery('ALTER TABLE `prices` ADD `ZAR` float DEFAULT "-1"'); await this.$executeQuery('ALTER TABLE `prices` ADD `ZAR` float DEFAULT "-1"');
await this.$executeQuery('TRUNCATE hashrates'); if (isBitcoin === true) {
await this.$executeQuery('TRUNCATE difficulty_adjustments'); await this.$executeQuery('TRUNCATE hashrates');
await this.$executeQuery(`UPDATE state SET string = NULL WHERE name = 'pools_json_sha'`); await this.$executeQuery('TRUNCATE difficulty_adjustments');
await this.$executeQuery(`UPDATE state SET string = NULL WHERE name = 'pools_json_sha'`);
}
await this.updateToSchemaVersion(75); await this.updateToSchemaVersion(75);
} }
@ -691,6 +693,114 @@ class DatabaseMigration {
await this.$executeQuery('ALTER TABLE `blocks` ADD coinbase_addresses JSON DEFAULT NULL'); await this.$executeQuery('ALTER TABLE `blocks` ADD coinbase_addresses JSON DEFAULT NULL');
await this.updateToSchemaVersion(80); await this.updateToSchemaVersion(80);
} }
if (databaseSchemaVersion < 81 && isBitcoin === true) {
await this.$executeQuery('ALTER TABLE `blocks_audits` ADD version INT NOT NULL DEFAULT 0');
await this.$executeQuery('ALTER TABLE `blocks_audits` ADD INDEX `version` (`version`)');
await this.$executeQuery('ALTER TABLE `blocks_audits` ADD unseen_txs JSON DEFAULT "[]"');
await this.updateToSchemaVersion(81);
}
if (databaseSchemaVersion < 82 && isBitcoin === true && config.MEMPOOL.NETWORK === 'mainnet') {
await this.$fixBadV1AuditBlocks();
await this.updateToSchemaVersion(82);
}
if (databaseSchemaVersion < 83 && isBitcoin === true) {
await this.$executeQuery('ALTER TABLE `blocks` ADD first_seen datetime(6) DEFAULT NULL');
await this.updateToSchemaVersion(83);
}
// add new pools indexes
if (databaseSchemaVersion < 84 && isBitcoin === true) {
await this.$executeQuery(`
ALTER TABLE \`pools\`
ADD INDEX \`slug\` (\`slug\`),
ADD INDEX \`unique_id\` (\`unique_id\`)
`);
await this.updateToSchemaVersion(84);
}
// lightning channels indexes
if (databaseSchemaVersion < 85 && isBitcoin === true) {
await this.$executeQuery(`
ALTER TABLE \`channels\`
ADD INDEX \`created\` (\`created\`),
ADD INDEX \`capacity\` (\`capacity\`),
ADD INDEX \`closing_reason\` (\`closing_reason\`),
ADD INDEX \`closing_resolved\` (\`closing_resolved\`)
`);
await this.updateToSchemaVersion(85);
}
// lightning nodes indexes
if (databaseSchemaVersion < 86 && isBitcoin === true) {
await this.$executeQuery(`
ALTER TABLE \`nodes\`
ADD INDEX \`status\` (\`status\`),
ADD INDEX \`channels\` (\`channels\`),
ADD INDEX \`country_id\` (\`country_id\`),
ADD INDEX \`as_number\` (\`as_number\`),
ADD INDEX \`first_seen\` (\`first_seen\`)
`);
await this.updateToSchemaVersion(86);
}
// lightning node sockets indexes
if (databaseSchemaVersion < 87 && isBitcoin === true) {
await this.$executeQuery('ALTER TABLE `nodes_sockets` ADD INDEX `type` (`type`)');
await this.updateToSchemaVersion(87);
}
// lightning stats indexes
if (databaseSchemaVersion < 88 && isBitcoin === true) {
await this.$executeQuery('ALTER TABLE `lightning_stats` ADD INDEX `added` (`added`)');
await this.updateToSchemaVersion(88);
}
// geo names indexes
if (databaseSchemaVersion < 89 && isBitcoin === true) {
await this.$executeQuery('ALTER TABLE `geo_names` ADD INDEX `names` (`names`)');
await this.updateToSchemaVersion(89);
}
// hashrates indexes
if (databaseSchemaVersion < 90 && isBitcoin === true) {
await this.$executeQuery('ALTER TABLE `hashrates` ADD INDEX `type` (`type`)');
await this.updateToSchemaVersion(90);
}
// block audits indexes
if (databaseSchemaVersion < 91 && isBitcoin === true) {
await this.$executeQuery('ALTER TABLE `blocks_audits` ADD INDEX `time` (`time`)');
await this.updateToSchemaVersion(91);
}
// elements_pegs indexes
if (databaseSchemaVersion < 92 && config.MEMPOOL.NETWORK === 'liquid') {
await this.$executeQuery(`
ALTER TABLE \`elements_pegs\`
ADD INDEX \`block\` (\`block\`),
ADD INDEX \`datetime\` (\`datetime\`),
ADD INDEX \`amount\` (\`amount\`),
ADD INDEX \`bitcoinaddress\` (\`bitcoinaddress\`),
ADD INDEX \`bitcointxid\` (\`bitcointxid\`)
`);
await this.updateToSchemaVersion(92);
}
// federation_txos indexes
if (databaseSchemaVersion < 93 && config.MEMPOOL.NETWORK === 'liquid') {
await this.$executeQuery(`
ALTER TABLE \`federation_txos\`
ADD INDEX \`unspent\` (\`unspent\`),
ADD INDEX \`lastblockupdate\` (\`lastblockupdate\`),
ADD INDEX \`blocktime\` (\`blocktime\`),
ADD INDEX \`emergencyKey\` (\`emergencyKey\`),
ADD INDEX \`expiredAt\` (\`expiredAt\`)
`);
await this.updateToSchemaVersion(93);
}
} }
/** /**
@ -1305,6 +1415,28 @@ class DatabaseMigration {
logger.warn(`Failed to migrate cpfp transaction data`); logger.warn(`Failed to migrate cpfp transaction data`);
} }
} }
private async $fixBadV1AuditBlocks(): Promise<void> {
const badBlocks = [
'000000000000000000011ad49227fc8c9ba0ca96ad2ebce41a862f9a244478dc',
'000000000000000000010ac1f68b3080153f2826ffddc87ceffdd68ed97d6960',
'000000000000000000024cbdafeb2660ae8bd2947d166e7fe15d1689e86b2cf7',
'00000000000000000002e1dbfbf6ae057f331992a058b822644b368034f87286',
'0000000000000000000019973b2778f08ad6d21e083302ff0833d17066921ebb',
];
for (const hash of badBlocks) {
try {
await this.$executeQuery(`
UPDATE blocks_audits
SET prioritized_txs = '[]'
WHERE hash = '${hash}'
`, true);
} catch (e) {
continue;
}
}
}
} }
export default new DatabaseMigration(); export default new DatabaseMigration();

View File

@ -257,6 +257,7 @@ class DiskCache {
trees: rbfData.rbf.trees, trees: rbfData.rbf.trees,
expiring: rbfData.rbf.expiring.map(([txid, value]) => ({ key: txid, value })), expiring: rbfData.rbf.expiring.map(([txid, value]) => ({ key: txid, value })),
mempool: memPool.getMempool(), mempool: memPool.getMempool(),
spendMap: memPool.getSpendMap(),
}); });
} }
} catch (e) { } catch (e) {

View File

@ -1,6 +1,7 @@
import config from '../../config'; import config from '../../config';
import { Application, Request, Response } from 'express'; import { Application, Request, Response } from 'express';
import channelsApi from './channels.api'; import channelsApi from './channels.api';
import { handleError } from '../../utils/api';
class ChannelsRoutes { class ChannelsRoutes {
constructor() { } constructor() { }
@ -22,7 +23,7 @@ class ChannelsRoutes {
const channels = await channelsApi.$searchChannelsById(req.params.search); const channels = await channelsApi.$searchChannelsById(req.params.search);
res.json(channels); res.json(channels);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -38,7 +39,7 @@ class ChannelsRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
res.json(channel); res.json(channel);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -53,11 +54,11 @@ class ChannelsRoutes {
const status: string = typeof req.query.status === 'string' ? req.query.status : ''; const status: string = typeof req.query.status === 'string' ? req.query.status : '';
if (index < -1) { if (index < -1) {
res.status(400).send('Invalid index'); handleError(req, res, 400, 'Invalid index');
return; return;
} }
if (['open', 'active', 'closed'].includes(status) === false) { if (['open', 'active', 'closed'].includes(status) === false) {
res.status(400).send('Invalid status'); handleError(req, res, 400, 'Invalid status');
return; return;
} }
@ -69,14 +70,14 @@ class ChannelsRoutes {
res.header('X-Total-Count', channelsCount.toString()); res.header('X-Total-Count', channelsCount.toString());
res.json(channels); res.json(channels);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
private async $getChannelsByTransactionIds(req: Request, res: Response): Promise<void> { private async $getChannelsByTransactionIds(req: Request, res: Response): Promise<void> {
try { try {
if (!Array.isArray(req.query.txId)) { if (!Array.isArray(req.query.txId)) {
res.status(400).send('Not an array'); handleError(req, res, 400, 'Not an array');
return; return;
} }
const txIds: string[] = []; const txIds: string[] = [];
@ -107,7 +108,7 @@ class ChannelsRoutes {
res.json(result); res.json(result);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -119,7 +120,7 @@ class ChannelsRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
res.json(channels); res.json(channels);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -132,7 +133,7 @@ class ChannelsRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
res.json(channels); res.json(channels);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }

View File

@ -3,6 +3,8 @@ import { Application, Request, Response } from 'express';
import nodesApi from './nodes.api'; import nodesApi from './nodes.api';
import channelsApi from './channels.api'; import channelsApi from './channels.api';
import statisticsApi from './statistics.api'; import statisticsApi from './statistics.api';
import { handleError } from '../../utils/api';
class GeneralLightningRoutes { class GeneralLightningRoutes {
constructor() { } constructor() { }
@ -27,7 +29,7 @@ class GeneralLightningRoutes {
channels: channels, channels: channels,
}); });
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -41,7 +43,7 @@ class GeneralLightningRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
res.json(statistics); res.json(statistics);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -50,7 +52,7 @@ class GeneralLightningRoutes {
const statistics = await statisticsApi.$getLatestStatistics(); const statistics = await statisticsApi.$getLatestStatistics();
res.json(statistics); res.json(statistics);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
} }

View File

@ -3,6 +3,7 @@ import { Application, Request, Response } from 'express';
import nodesApi from './nodes.api'; import nodesApi from './nodes.api';
import DB from '../../database'; import DB from '../../database';
import { INodesRanking } from '../../mempool.interfaces'; import { INodesRanking } from '../../mempool.interfaces';
import { handleError } from '../../utils/api';
class NodesRoutes { class NodesRoutes {
constructor() { } constructor() { }
@ -31,7 +32,7 @@ class NodesRoutes {
const nodes = await nodesApi.$searchNodeByPublicKeyOrAlias(req.params.search); const nodes = await nodesApi.$searchNodeByPublicKeyOrAlias(req.params.search);
res.json(nodes); res.json(nodes);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -181,13 +182,13 @@ class NodesRoutes {
} }
} catch (e) {} } catch (e) {}
} }
res.header('Pragma', 'public'); res.header('Pragma', 'public');
res.header('Cache-control', 'public'); res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
res.json(nodes); res.json(nodes);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -195,7 +196,7 @@ class NodesRoutes {
try { try {
const node = await nodesApi.$getNode(req.params.public_key); const node = await nodesApi.$getNode(req.params.public_key);
if (!node) { if (!node) {
res.status(404).send('Node not found'); handleError(req, res, 404, 'Node not found');
return; return;
} }
res.header('Pragma', 'public'); res.header('Pragma', 'public');
@ -203,7 +204,7 @@ class NodesRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
res.json(node); res.json(node);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -215,7 +216,7 @@ class NodesRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
res.json(statistics); res.json(statistics);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -223,7 +224,7 @@ class NodesRoutes {
try { try {
const node = await nodesApi.$getFeeHistogram(req.params.public_key); const node = await nodesApi.$getFeeHistogram(req.params.public_key);
if (!node) { if (!node) {
res.status(404).send('Node not found'); handleError(req, res, 404, 'Node not found');
return; return;
} }
res.header('Pragma', 'public'); res.header('Pragma', 'public');
@ -231,7 +232,7 @@ class NodesRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
res.json(node); res.json(node);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -247,7 +248,7 @@ class NodesRoutes {
topByChannels: topChannelsNodes, topByChannels: topChannelsNodes,
}); });
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -259,7 +260,7 @@ class NodesRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
res.json(topCapacityNodes); res.json(topCapacityNodes);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -271,7 +272,7 @@ class NodesRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
res.json(topCapacityNodes); res.json(topCapacityNodes);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -283,7 +284,7 @@ class NodesRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
res.json(topCapacityNodes); res.json(topCapacityNodes);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -295,7 +296,7 @@ class NodesRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 600).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 600).toUTCString());
res.json(nodesPerAs); res.json(nodesPerAs);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -307,7 +308,7 @@ class NodesRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 600).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 600).toUTCString());
res.json(worldNodes); res.json(worldNodes);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -322,7 +323,7 @@ class NodesRoutes {
); );
if (country.length === 0) { if (country.length === 0) {
res.status(404).send(`This country does not exist or does not host any lightning nodes on clearnet`); handleError(req, res, 404, `This country does not exist or does not host any lightning nodes on clearnet`);
return; return;
} }
@ -335,7 +336,7 @@ class NodesRoutes {
nodes: nodes, nodes: nodes,
}); });
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -349,7 +350,7 @@ class NodesRoutes {
); );
if (isp.length === 0) { if (isp.length === 0) {
res.status(404).send(`This ISP does not exist or does not host any lightning nodes on clearnet`); handleError(req, res, 404, `This ISP does not exist or does not host any lightning nodes on clearnet`);
return; return;
} }
@ -362,7 +363,7 @@ class NodesRoutes {
nodes: nodes, nodes: nodes,
}); });
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -374,7 +375,7 @@ class NodesRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 600).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 600).toUTCString());
res.json(nodesPerAs); res.json(nodesPerAs);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
} }

View File

@ -3,6 +3,7 @@ import { Application, Request, Response } from 'express';
import config from '../../config'; import config from '../../config';
import elementsParser from './elements-parser'; import elementsParser from './elements-parser';
import icons from './icons'; import icons from './icons';
import { handleError } from '../../utils/api';
class LiquidRoutes { class LiquidRoutes {
public initRoutes(app: Application) { public initRoutes(app: Application) {
@ -42,7 +43,7 @@ class LiquidRoutes {
res.setHeader('content-length', result.length); res.setHeader('content-length', result.length);
res.send(result); res.send(result);
} else { } else {
res.status(404).send('Asset icon not found'); handleError(req, res, 404, 'Asset icon not found');
} }
} }
@ -51,7 +52,7 @@ class LiquidRoutes {
if (result) { if (result) {
res.json(result); res.json(result);
} else { } else {
res.status(404).send('Asset icons not found'); handleError(req, res, 404, 'Asset icons not found');
} }
} }
@ -82,7 +83,7 @@ class LiquidRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 60 * 60).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 60 * 60).toUTCString());
res.json(pegs); res.json(pegs);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -94,7 +95,7 @@ class LiquidRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 60 * 60).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 60 * 60).toUTCString());
res.json(reserves); res.json(reserves);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -106,7 +107,7 @@ class LiquidRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
res.json(currentSupply); res.json(currentSupply);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -118,7 +119,7 @@ class LiquidRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
res.json(currentReserves); res.json(currentReserves);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -130,7 +131,7 @@ class LiquidRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
res.json(auditStatus); res.json(auditStatus);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -142,7 +143,7 @@ class LiquidRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
res.json(federationAddresses); res.json(federationAddresses);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -154,7 +155,7 @@ class LiquidRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
res.json(federationAddresses); res.json(federationAddresses);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -166,7 +167,7 @@ class LiquidRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
res.json(federationUtxos); res.json(federationUtxos);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -178,7 +179,7 @@ class LiquidRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
res.json(expiredUtxos); res.json(expiredUtxos);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -190,7 +191,7 @@ class LiquidRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
res.json(federationUtxos); res.json(federationUtxos);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -202,7 +203,7 @@ class LiquidRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
res.json(emergencySpentUtxos); res.json(emergencySpentUtxos);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -214,7 +215,7 @@ class LiquidRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
res.json(emergencySpentUtxos); res.json(emergencySpentUtxos);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -226,7 +227,7 @@ class LiquidRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
res.json(recentPegs); res.json(recentPegs);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -238,7 +239,7 @@ class LiquidRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
res.json(pegsVolume); res.json(pegsVolume);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -250,7 +251,7 @@ class LiquidRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
res.json(pegsCount); res.json(pegsCount);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }

View File

@ -369,7 +369,7 @@ class MempoolBlocks {
const lastBlockIndex = blocks.length - 1; const lastBlockIndex = blocks.length - 1;
let hasBlockStack = blocks.length >= 8; let hasBlockStack = blocks.length >= 8;
let stackWeight; let stackWeight;
let feeStatsCalculator: OnlineFeeStatsCalculator | void; let feeStatsCalculator: OnlineFeeStatsCalculator | null = null;
if (hasBlockStack) { if (hasBlockStack) {
if (blockWeights && blockWeights[7] !== null) { if (blockWeights && blockWeights[7] !== null) {
stackWeight = blockWeights[7]; stackWeight = blockWeights[7];
@ -380,28 +380,36 @@ class MempoolBlocks {
feeStatsCalculator = new OnlineFeeStatsCalculator(stackWeight, 0.5, [10, 20, 30, 40, 50, 60, 70, 80, 90]); feeStatsCalculator = new OnlineFeeStatsCalculator(stackWeight, 0.5, [10, 20, 30, 40, 50, 60, 70, 80, 90]);
} }
const ancestors: Ancestor[] = [];
const descendants: Ancestor[] = [];
let ancestor: MempoolTransactionExtended;
for (const cluster of clusters) { for (const cluster of clusters) {
for (const memberTxid of cluster) { for (const memberTxid of cluster) {
const mempoolTx = mempool[memberTxid]; const mempoolTx = mempool[memberTxid];
if (mempoolTx) { if (mempoolTx) {
const ancestors: Ancestor[] = []; // ugly micro-optimization to avoid allocating new arrays
const descendants: Ancestor[] = []; ancestors.length = 0;
descendants.length = 0;
let matched = false; let matched = false;
cluster.forEach(txid => { cluster.forEach(txid => {
ancestor = mempool[txid];
if (txid === memberTxid) { if (txid === memberTxid) {
matched = true; matched = true;
} else { } else {
if (!mempool[txid]) { if (!ancestor) {
console.log('txid missing from mempool! ', txid, candidates?.txs[txid]); console.log('txid missing from mempool! ', txid, candidates?.txs[txid]);
return;
} }
const relative = { const relative = {
txid: txid, txid: txid,
fee: mempool[txid].fee, fee: ancestor.fee,
weight: (mempool[txid].adjustedVsize * 4), weight: (ancestor.adjustedVsize * 4),
}; };
if (matched) { if (matched) {
descendants.push(relative); descendants.push(relative);
mempoolTx.lastBoosted = Math.max(mempoolTx.lastBoosted || 0, mempool[txid].firstSeen || 0); if (!mempoolTx.lastBoosted || (ancestor.firstSeen && ancestor.firstSeen > mempoolTx.lastBoosted)) {
mempoolTx.lastBoosted = ancestor.firstSeen;
}
} else { } else {
ancestors.push(relative); ancestors.push(relative);
} }
@ -410,7 +418,20 @@ class MempoolBlocks {
if (mempoolTx.ancestors?.length !== ancestors.length || mempoolTx.descendants?.length !== descendants.length) { if (mempoolTx.ancestors?.length !== ancestors.length || mempoolTx.descendants?.length !== descendants.length) {
mempoolTx.cpfpDirty = true; mempoolTx.cpfpDirty = true;
} }
Object.assign(mempoolTx, {ancestors, descendants, bestDescendant: null, cpfpChecked: true}); // ugly micro-optimization to avoid allocating new arrays or objects
if (mempoolTx.ancestors) {
mempoolTx.ancestors.length = 0;
} else {
mempoolTx.ancestors = [];
}
if (mempoolTx.descendants) {
mempoolTx.descendants.length = 0;
} else {
mempoolTx.descendants = [];
}
mempoolTx.ancestors.push(...ancestors);
mempoolTx.descendants.push(...descendants);
mempoolTx.cpfpChecked = true;
} }
} }
} }
@ -420,7 +441,10 @@ class MempoolBlocks {
const sizeLimit = (config.MEMPOOL.BLOCK_WEIGHT_UNITS / 4) * 1.2; const sizeLimit = (config.MEMPOOL.BLOCK_WEIGHT_UNITS / 4) * 1.2;
// update this thread's mempool with the results // update this thread's mempool with the results
let mempoolTx: MempoolTransactionExtended; let mempoolTx: MempoolTransactionExtended;
const mempoolBlocks: MempoolBlockWithTransactions[] = blocks.map((block, blockIndex) => { let acceleration: Acceleration;
const mempoolBlocks: MempoolBlockWithTransactions[] = [];
for (let blockIndex = 0; blockIndex < blocks.length; blockIndex++) {
const block = blocks[blockIndex];
let totalSize = 0; let totalSize = 0;
let totalVsize = 0; let totalVsize = 0;
let totalWeight = 0; let totalWeight = 0;
@ -436,8 +460,9 @@ class MempoolBlocks {
} }
} }
for (const txid of block) { for (let i = 0; i < block.length; i++) {
if (txid) { const txid = block[i];
if (txid in mempool) {
mempoolTx = mempool[txid]; mempoolTx = mempool[txid];
// save position in projected blocks // save position in projected blocks
mempoolTx.position = { mempoolTx.position = {
@ -445,28 +470,40 @@ class MempoolBlocks {
vsize: totalVsize + (mempoolTx.vsize / 2), vsize: totalVsize + (mempoolTx.vsize / 2),
}; };
const acceleration = accelerations[txid]; if (txid in accelerations) {
if (isAcceleratedBy[txid] || (acceleration && (!accelerationPool || acceleration.pools.includes(accelerationPool)))) { acceleration = accelerations[txid];
if (!mempoolTx.acceleration) { if (isAcceleratedBy[txid] || (acceleration && (!accelerationPool || acceleration.pools.includes(accelerationPool)))) {
mempoolTx.cpfpDirty = true; if (!mempoolTx.acceleration) {
} mempoolTx.cpfpDirty = true;
mempoolTx.acceleration = true; }
mempoolTx.acceleratedBy = isAcceleratedBy[txid] || acceleration?.pools; mempoolTx.acceleration = true;
mempoolTx.acceleratedAt = acceleration?.added; mempoolTx.acceleratedBy = isAcceleratedBy[txid] || acceleration?.pools;
for (const ancestor of mempoolTx.ancestors || []) { mempoolTx.acceleratedAt = acceleration?.added;
if (!mempool[ancestor.txid].acceleration) { mempoolTx.feeDelta = acceleration?.feeDelta;
mempool[ancestor.txid].cpfpDirty = true; for (const ancestor of mempoolTx.ancestors || []) {
if (!(ancestor.txid in mempool)) {
continue;
}
if (!mempool[ancestor.txid].acceleration) {
mempool[ancestor.txid].cpfpDirty = true;
}
mempool[ancestor.txid].acceleration = true;
mempool[ancestor.txid].acceleratedBy = mempoolTx.acceleratedBy;
mempool[ancestor.txid].acceleratedAt = mempoolTx.acceleratedAt;
mempool[ancestor.txid].feeDelta = mempoolTx.feeDelta;
isAcceleratedBy[ancestor.txid] = mempoolTx.acceleratedBy;
}
} else {
if (mempoolTx.acceleration) {
mempoolTx.cpfpDirty = true;
delete mempoolTx.acceleration;
} }
mempool[ancestor.txid].acceleration = true;
mempool[ancestor.txid].acceleratedBy = mempoolTx.acceleratedBy;
mempool[ancestor.txid].acceleratedAt = mempoolTx.acceleratedAt;
isAcceleratedBy[ancestor.txid] = mempoolTx.acceleratedBy;
} }
} else { } else {
if (mempoolTx.acceleration) { if (mempoolTx.acceleration) {
mempoolTx.cpfpDirty = true; mempoolTx.cpfpDirty = true;
delete mempoolTx.acceleration;
} }
delete mempoolTx.acceleration;
} }
// online calculation of stack-of-blocks fee stats // online calculation of stack-of-blocks fee stats
@ -484,7 +521,7 @@ class MempoolBlocks {
} }
} }
} }
return this.dataToMempoolBlocks( mempoolBlocks[blockIndex] = this.dataToMempoolBlocks(
block, block,
transactions, transactions,
totalSize, totalSize,
@ -492,7 +529,7 @@ class MempoolBlocks {
totalFees, totalFees,
(hasBlockStack && blockIndex === lastBlockIndex && feeStatsCalculator) ? feeStatsCalculator.getRawFeeStats() : undefined, (hasBlockStack && blockIndex === lastBlockIndex && feeStatsCalculator) ? feeStatsCalculator.getRawFeeStats() : undefined,
); );
}); };
if (saveResults) { if (saveResults) {
const deltas = this.calculateMempoolDeltas(this.mempoolBlocks, mempoolBlocks); const deltas = this.calculateMempoolDeltas(this.mempoolBlocks, mempoolBlocks);
@ -654,7 +691,7 @@ class MempoolBlocks {
[pool: string]: { name: string, block: number, vsize: number, accelerations: string[], complete: boolean }; [pool: string]: { name: string, block: number, vsize: number, accelerations: string[], complete: boolean };
} = {}; } = {};
// prepare a list of accelerations in ascending order (we'll pop items off the end of the list) // prepare a list of accelerations in ascending order (we'll pop items off the end of the list)
const accQueue: { acceleration: Acceleration, rate: number, vsize: number }[] = Object.values(accelerations).map(acc => { const accQueue: { acceleration: Acceleration, rate: number, vsize: number }[] = Object.values(accelerations).filter(acc => acc.txid in mempoolCache).map(acc => {
let vsize = mempoolCache[acc.txid].vsize; let vsize = mempoolCache[acc.txid].vsize;
for (const ancestor of mempoolCache[acc.txid].ancestors || []) { for (const ancestor of mempoolCache[acc.txid].ancestors || []) {
vsize += (ancestor.weight / 4); vsize += (ancestor.weight / 4);

View File

@ -10,6 +10,7 @@ import bitcoinClient from './bitcoin/bitcoin-client';
import bitcoinSecondClient from './bitcoin/bitcoin-second-client'; import bitcoinSecondClient from './bitcoin/bitcoin-second-client';
import rbfCache from './rbf-cache'; import rbfCache from './rbf-cache';
import { Acceleration } from './services/acceleration'; import { Acceleration } from './services/acceleration';
import accelerationApi from './services/acceleration';
import redisCache from './redis-cache'; import redisCache from './redis-cache';
import blocks from './blocks'; import blocks from './blocks';
@ -19,12 +20,13 @@ class Mempool {
private mempoolCache: { [txId: string]: MempoolTransactionExtended } = {}; private mempoolCache: { [txId: string]: MempoolTransactionExtended } = {};
private mempoolCandidates: { [txid: string ]: boolean } = {}; private mempoolCandidates: { [txid: string ]: boolean } = {};
private spendMap = new Map<string, MempoolTransactionExtended>(); private spendMap = new Map<string, MempoolTransactionExtended>();
private recentlyDeleted: MempoolTransactionExtended[][] = []; // buffer of transactions deleted in recent mempool updates
private mempoolInfo: IBitcoinApi.MempoolInfo = { loaded: false, size: 0, bytes: 0, usage: 0, total_fee: 0, private mempoolInfo: IBitcoinApi.MempoolInfo = { loaded: false, size: 0, bytes: 0, usage: 0, total_fee: 0,
maxmempool: 300000000, mempoolminfee: Common.isLiquid() ? 0.00000100 : 0.00001000, minrelaytxfee: Common.isLiquid() ? 0.00000100 : 0.00001000 }; maxmempool: 300000000, mempoolminfee: Common.isLiquid() ? 0.00000100 : 0.00001000, minrelaytxfee: Common.isLiquid() ? 0.00000100 : 0.00001000 };
private mempoolChangedCallback: ((newMempool: {[txId: string]: MempoolTransactionExtended; }, newTransactions: MempoolTransactionExtended[], private mempoolChangedCallback: ((newMempool: {[txId: string]: MempoolTransactionExtended; }, newTransactions: MempoolTransactionExtended[],
deletedTransactions: MempoolTransactionExtended[], accelerationDelta: string[]) => void) | undefined; deletedTransactions: MempoolTransactionExtended[][], accelerationDelta: string[]) => void) | undefined;
private $asyncMempoolChangedCallback: ((newMempool: {[txId: string]: MempoolTransactionExtended; }, mempoolSize: number, newTransactions: MempoolTransactionExtended[], private $asyncMempoolChangedCallback: ((newMempool: {[txId: string]: MempoolTransactionExtended; }, mempoolSize: number, newTransactions: MempoolTransactionExtended[],
deletedTransactions: MempoolTransactionExtended[], accelerationDelta: string[], candidates?: GbtCandidates) => Promise<void>) | undefined; deletedTransactions: MempoolTransactionExtended[][], accelerationDelta: string[], candidates?: GbtCandidates) => Promise<void>) | undefined;
private accelerations: { [txId: string]: Acceleration } = {}; private accelerations: { [txId: string]: Acceleration } = {};
private accelerationPositions: { [txid: string]: { poolId: number, pool: string, block: number, vsize: number }[] } = {}; private accelerationPositions: { [txid: string]: { poolId: number, pool: string, block: number, vsize: number }[] } = {};
@ -74,12 +76,12 @@ class Mempool {
} }
public setMempoolChangedCallback(fn: (newMempool: { [txId: string]: MempoolTransactionExtended; }, public setMempoolChangedCallback(fn: (newMempool: { [txId: string]: MempoolTransactionExtended; },
newTransactions: MempoolTransactionExtended[], deletedTransactions: MempoolTransactionExtended[], accelerationDelta: string[]) => void): void { newTransactions: MempoolTransactionExtended[], deletedTransactions: MempoolTransactionExtended[][], accelerationDelta: string[]) => void): void {
this.mempoolChangedCallback = fn; this.mempoolChangedCallback = fn;
} }
public setAsyncMempoolChangedCallback(fn: (newMempool: { [txId: string]: MempoolTransactionExtended; }, mempoolSize: number, public setAsyncMempoolChangedCallback(fn: (newMempool: { [txId: string]: MempoolTransactionExtended; }, mempoolSize: number,
newTransactions: MempoolTransactionExtended[], deletedTransactions: MempoolTransactionExtended[], accelerationDelta: string[], newTransactions: MempoolTransactionExtended[], deletedTransactions: MempoolTransactionExtended[][], accelerationDelta: string[],
candidates?: GbtCandidates) => Promise<void>): void { candidates?: GbtCandidates) => Promise<void>): void {
this.$asyncMempoolChangedCallback = fn; this.$asyncMempoolChangedCallback = fn;
} }
@ -206,7 +208,7 @@ class Mempool {
return txTimes; return txTimes;
} }
public async $updateMempool(transactions: string[], accelerations: Acceleration[] | null, minFeeMempool: string[], minFeeTip: number, pollRate: number): Promise<void> { public async $updateMempool(transactions: string[], accelerations: Record<string, Acceleration> | null, minFeeMempool: string[], minFeeTip: number, pollRate: number): Promise<void> {
logger.debug(`Updating mempool...`); logger.debug(`Updating mempool...`);
// warn if this run stalls the main loop for more than 2 minutes // warn if this run stalls the main loop for more than 2 minutes
@ -353,7 +355,7 @@ class Mempool {
const newTransactionsStripped = newTransactions.map((tx) => Common.stripTransaction(tx)); const newTransactionsStripped = newTransactions.map((tx) => Common.stripTransaction(tx));
this.latestTransactions = newTransactionsStripped.concat(this.latestTransactions).slice(0, 6); this.latestTransactions = newTransactionsStripped.concat(this.latestTransactions).slice(0, 6);
const accelerationDelta = accelerations != null ? await this.$updateAccelerations(accelerations) : []; const accelerationDelta = accelerations != null ? await this.updateAccelerations(accelerations) : [];
if (accelerationDelta.length) { if (accelerationDelta.length) {
hasChange = true; hasChange = true;
} }
@ -362,12 +364,15 @@ class Mempool {
const candidatesChanged = candidates?.added?.length || candidates?.removed?.length; const candidatesChanged = candidates?.added?.length || candidates?.removed?.length;
if (this.mempoolChangedCallback && (hasChange || deletedTransactions.length)) { this.recentlyDeleted.unshift(deletedTransactions);
this.mempoolChangedCallback(this.mempoolCache, newTransactions, deletedTransactions, accelerationDelta); this.recentlyDeleted.length = Math.min(this.recentlyDeleted.length, 10); // truncate to the last 10 mempool updates
if (this.mempoolChangedCallback && (hasChange || newTransactions.length || deletedTransactions.length)) {
this.mempoolChangedCallback(this.mempoolCache, newTransactions, this.recentlyDeleted, accelerationDelta);
} }
if (this.$asyncMempoolChangedCallback && (hasChange || deletedTransactions.length || candidatesChanged)) { if (this.$asyncMempoolChangedCallback && (hasChange || newTransactions.length || deletedTransactions.length || candidatesChanged)) {
this.updateTimerProgress(timer, 'running async mempool callback'); this.updateTimerProgress(timer, 'running async mempool callback');
await this.$asyncMempoolChangedCallback(this.mempoolCache, newMempoolSize, newTransactions, deletedTransactions, accelerationDelta, candidates); await this.$asyncMempoolChangedCallback(this.mempoolCache, newMempoolSize, newTransactions, this.recentlyDeleted, accelerationDelta, candidates);
this.updateTimerProgress(timer, 'completed async mempool callback'); this.updateTimerProgress(timer, 'completed async mempool callback');
} }
@ -395,58 +400,11 @@ class Mempool {
return this.accelerations; return this.accelerations;
} }
public $updateAccelerations(newAccelerations: Acceleration[]): string[] { public updateAccelerations(newAccelerationMap: Record<string, Acceleration>): string[] {
try { try {
const changed: string[] = []; const accelerationDelta = accelerationApi.getAccelerationDelta(this.accelerations, newAccelerationMap);
const newAccelerationMap: { [txid: string]: Acceleration } = {};
for (const acceleration of newAccelerations) {
// skip transactions we don't know about
if (!this.mempoolCache[acceleration.txid]) {
continue;
}
newAccelerationMap[acceleration.txid] = acceleration;
if (this.accelerations[acceleration.txid] == null) {
// new acceleration
changed.push(acceleration.txid);
} else {
if (this.accelerations[acceleration.txid].feeDelta !== acceleration.feeDelta) {
// feeDelta changed
changed.push(acceleration.txid);
} else if (this.accelerations[acceleration.txid].pools?.length) {
let poolsChanged = false;
const pools = new Set();
this.accelerations[acceleration.txid].pools.forEach(pool => {
pools.add(pool);
});
acceleration.pools.forEach(pool => {
if (!pools.has(pool)) {
poolsChanged = true;
} else {
pools.delete(pool);
}
});
if (pools.size > 0) {
poolsChanged = true;
}
if (poolsChanged) {
// pools changed
changed.push(acceleration.txid);
}
}
}
}
for (const oldTxid of Object.keys(this.accelerations)) {
if (!newAccelerationMap[oldTxid]) {
// removed
changed.push(oldTxid);
}
}
this.accelerations = newAccelerationMap; this.accelerations = newAccelerationMap;
return accelerationDelta;
return changed;
} catch (e: any) { } catch (e: any) {
logger.debug(`Failed to update accelerations: ` + (e instanceof Error ? e.message : e)); logger.debug(`Failed to update accelerations: ` + (e instanceof Error ? e.message : e));
return []; return [];
@ -541,16 +499,7 @@ class Mempool {
} }
} }
public handleRbfTransactions(rbfTransactions: { [txid: string]: MempoolTransactionExtended[]; }): void { public handleRbfTransactions(rbfTransactions: { [txid: string]: { replaced: MempoolTransactionExtended[], replacedBy: TransactionExtended }}): void {
for (const rbfTransaction in rbfTransactions) {
if (this.mempoolCache[rbfTransaction] && rbfTransactions[rbfTransaction]?.length) {
// Store replaced transactions
rbfCache.add(rbfTransactions[rbfTransaction], this.mempoolCache[rbfTransaction]);
}
}
}
public handleMinedRbfTransactions(rbfTransactions: { [txid: string]: { replaced: MempoolTransactionExtended[], replacedBy: TransactionExtended }}): void {
for (const rbfTransaction in rbfTransactions) { for (const rbfTransaction in rbfTransactions) {
if (rbfTransactions[rbfTransaction].replacedBy && rbfTransactions[rbfTransaction]?.replaced?.length) { if (rbfTransactions[rbfTransaction].replacedBy && rbfTransactions[rbfTransaction]?.replaced?.length) {
// Store replaced transactions // Store replaced transactions

View File

@ -337,7 +337,7 @@ export function makeBlockTemplate(candidates: MempoolTransactionExtended[], acce
let failures = 0; let failures = 0;
while (mempoolArray.length || modified.length) { while (mempoolArray.length || modified.length) {
// skip invalid transactions // skip invalid transactions
while (mempoolArray[0].used || mempoolArray[0].modified) { while (mempoolArray[0]?.used || mempoolArray[0]?.modified) {
mempoolArray.shift(); mempoolArray.shift();
} }

View File

@ -10,6 +10,7 @@ import mining from "./mining";
import PricesRepository from '../../repositories/PricesRepository'; import PricesRepository from '../../repositories/PricesRepository';
import AccelerationRepository from '../../repositories/AccelerationRepository'; import AccelerationRepository from '../../repositories/AccelerationRepository';
import accelerationApi from '../services/acceleration'; import accelerationApi from '../services/acceleration';
import { handleError } from '../../utils/api';
class MiningRoutes { class MiningRoutes {
public initRoutes(app: Application) { public initRoutes(app: Application) {
@ -53,12 +54,12 @@ class MiningRoutes {
res.header('Cache-control', 'public'); res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString());
if (['testnet', 'signet', 'liquidtestnet'].includes(config.MEMPOOL.NETWORK)) { if (['testnet', 'signet', 'liquidtestnet'].includes(config.MEMPOOL.NETWORK)) {
res.status(400).send('Prices are not available on testnets.'); handleError(req, res, 400, 'Prices are not available on testnets.');
return; return;
} }
const timestamp = parseInt(req.query.timestamp as string, 10) || 0; const timestamp = parseInt(req.query.timestamp as string, 10) || 0;
const currency = req.query.currency as string; const currency = req.query.currency as string;
let response; let response;
if (timestamp && currency) { if (timestamp && currency) {
response = await PricesRepository.$getNearestHistoricalPrice(timestamp, currency); response = await PricesRepository.$getNearestHistoricalPrice(timestamp, currency);
@ -71,7 +72,7 @@ class MiningRoutes {
} }
res.status(200).send(response); res.status(200).send(response);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -84,9 +85,9 @@ class MiningRoutes {
res.json(stats); res.json(stats);
} catch (e) { } catch (e) {
if (e instanceof Error && e.message.indexOf('This mining pool does not exist') > -1) { if (e instanceof Error && e.message.indexOf('This mining pool does not exist') > -1) {
res.status(404).send(e.message); handleError(req, res, 404, e.message);
} else { } else {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
} }
@ -103,9 +104,9 @@ class MiningRoutes {
res.json(poolBlocks); res.json(poolBlocks);
} catch (e) { } catch (e) {
if (e instanceof Error && e.message.indexOf('This mining pool does not exist') > -1) { if (e instanceof Error && e.message.indexOf('This mining pool does not exist') > -1) {
res.status(404).send(e.message); handleError(req, res, 404, e.message);
} else { } else {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
} }
@ -129,7 +130,7 @@ class MiningRoutes {
res.json(pools); res.json(pools);
} }
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -143,7 +144,7 @@ class MiningRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
res.json(stats); res.json(stats);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -157,7 +158,7 @@ class MiningRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString());
res.json(hashrates); res.json(hashrates);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -172,9 +173,9 @@ class MiningRoutes {
res.json(hashrates); res.json(hashrates);
} catch (e) { } catch (e) {
if (e instanceof Error && e.message.indexOf('This mining pool does not exist') > -1) { if (e instanceof Error && e.message.indexOf('This mining pool does not exist') > -1) {
res.status(404).send(e.message); handleError(req, res, 404, e.message);
} else { } else {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
} }
@ -182,7 +183,7 @@ class MiningRoutes {
private async $getHistoricalHashrate(req: Request, res: Response) { private async $getHistoricalHashrate(req: Request, res: Response) {
let currentHashrate = 0, currentDifficulty = 0; let currentHashrate = 0, currentDifficulty = 0;
try { try {
currentHashrate = await bitcoinClient.getNetworkHashPs(); currentHashrate = await bitcoinClient.getNetworkHashPs(1008);
currentDifficulty = await bitcoinClient.getDifficulty(); currentDifficulty = await bitcoinClient.getDifficulty();
} catch (e) { } catch (e) {
logger.debug('Bitcoin Core is not available, using zeroed value for current hashrate and difficulty'); logger.debug('Bitcoin Core is not available, using zeroed value for current hashrate and difficulty');
@ -203,7 +204,7 @@ class MiningRoutes {
currentDifficulty: currentDifficulty, currentDifficulty: currentDifficulty,
}); });
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -217,7 +218,7 @@ class MiningRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
res.json(blockFees); res.json(blockFees);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -235,7 +236,7 @@ class MiningRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
res.json(blockFees); res.json(blockFees);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -249,7 +250,7 @@ class MiningRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
res.json(blockRewards); res.json(blockRewards);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -263,7 +264,7 @@ class MiningRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
res.json(blockFeeRates); res.json(blockFeeRates);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -281,7 +282,7 @@ class MiningRoutes {
weights: blockWeights weights: blockWeights
}); });
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -293,7 +294,7 @@ class MiningRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString());
res.json(difficulty.map(adj => [adj.time, adj.height, adj.difficulty, adj.adjustment])); res.json(difficulty.map(adj => [adj.time, adj.height, adj.difficulty, adj.adjustment]));
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -317,7 +318,7 @@ class MiningRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
res.json(blocksHealth.map(health => [health.time, health.height, health.match_rate])); res.json(blocksHealth.map(health => [health.time, health.height, health.match_rate]));
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -326,7 +327,7 @@ class MiningRoutes {
const audit = await BlocksAuditsRepository.$getBlockAudit(req.params.hash); const audit = await BlocksAuditsRepository.$getBlockAudit(req.params.hash);
if (!audit) { if (!audit) {
res.status(204).send(`This block has not been audited.`); handleError(req, res, 204, `This block has not been audited.`);
return; return;
} }
@ -335,7 +336,7 @@ class MiningRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24).toUTCString());
res.json(audit); res.json(audit);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -358,7 +359,7 @@ class MiningRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString());
res.json(result); res.json(result);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -371,7 +372,7 @@ class MiningRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
res.json(await BlocksAuditsRepository.$getBlockAuditScores(height, height - 15)); res.json(await BlocksAuditsRepository.$getBlockAuditScores(height, height - 15));
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -384,7 +385,7 @@ class MiningRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24).toUTCString());
res.json(audit || 'null'); res.json(audit || 'null');
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -394,12 +395,12 @@ class MiningRoutes {
res.header('Cache-control', 'public'); res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
if (!config.MEMPOOL_SERVICES.ACCELERATIONS || ['testnet', 'signet', 'liquidtestnet', 'liquid'].includes(config.MEMPOOL.NETWORK)) { if (!config.MEMPOOL_SERVICES.ACCELERATIONS || ['testnet', 'signet', 'liquidtestnet', 'liquid'].includes(config.MEMPOOL.NETWORK)) {
res.status(400).send('Acceleration data is not available.'); handleError(req, res, 400, 'Acceleration data is not available.');
return; return;
} }
res.status(200).send(await AccelerationRepository.$getAccelerationInfo(req.params.slug)); res.status(200).send(await AccelerationRepository.$getAccelerationInfo(req.params.slug));
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -409,13 +410,13 @@ class MiningRoutes {
res.header('Cache-control', 'public'); res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24).toUTCString());
if (!config.MEMPOOL_SERVICES.ACCELERATIONS || ['testnet', 'signet', 'liquidtestnet', 'liquid'].includes(config.MEMPOOL.NETWORK)) { if (!config.MEMPOOL_SERVICES.ACCELERATIONS || ['testnet', 'signet', 'liquidtestnet', 'liquid'].includes(config.MEMPOOL.NETWORK)) {
res.status(400).send('Acceleration data is not available.'); handleError(req, res, 400, 'Acceleration data is not available.');
return; return;
} }
const height = req.params.height === undefined ? undefined : parseInt(req.params.height, 10); const height = req.params.height === undefined ? undefined : parseInt(req.params.height, 10);
res.status(200).send(await AccelerationRepository.$getAccelerationInfo(null, height)); res.status(200).send(await AccelerationRepository.$getAccelerationInfo(null, height));
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -425,12 +426,12 @@ class MiningRoutes {
res.header('Cache-control', 'public'); res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
if (!config.MEMPOOL_SERVICES.ACCELERATIONS || ['testnet', 'signet', 'liquidtestnet', 'liquid'].includes(config.MEMPOOL.NETWORK)) { if (!config.MEMPOOL_SERVICES.ACCELERATIONS || ['testnet', 'signet', 'liquidtestnet', 'liquid'].includes(config.MEMPOOL.NETWORK)) {
res.status(400).send('Acceleration data is not available.'); handleError(req, res, 400, 'Acceleration data is not available.');
return; return;
} }
res.status(200).send(await AccelerationRepository.$getAccelerationInfo(null, null, req.params.interval)); res.status(200).send(await AccelerationRepository.$getAccelerationInfo(null, null, req.params.interval));
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -440,12 +441,12 @@ class MiningRoutes {
res.header('Cache-control', 'public'); res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
if (!config.MEMPOOL_SERVICES.ACCELERATIONS || ['testnet', 'signet', 'liquidtestnet', 'liquid'].includes(config.MEMPOOL.NETWORK)) { if (!config.MEMPOOL_SERVICES.ACCELERATIONS || ['testnet', 'signet', 'liquidtestnet', 'liquid'].includes(config.MEMPOOL.NETWORK)) {
res.status(400).send('Acceleration data is not available.'); handleError(req, res, 400, 'Acceleration data is not available.');
return; return;
} }
res.status(200).send(await AccelerationRepository.$getAccelerationTotals(<string>req.query.pool, <string>req.query.interval)); res.status(200).send(await AccelerationRepository.$getAccelerationTotals(<string>req.query.pool, <string>req.query.interval));
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
@ -455,28 +456,24 @@ class MiningRoutes {
res.header('Cache-control', 'public'); res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
if (!config.MEMPOOL_SERVICES.ACCELERATIONS || ['testnet', 'signet', 'liquidtestnet', 'liquid'].includes(config.MEMPOOL.NETWORK)) { if (!config.MEMPOOL_SERVICES.ACCELERATIONS || ['testnet', 'signet', 'liquidtestnet', 'liquid'].includes(config.MEMPOOL.NETWORK)) {
res.status(400).send('Acceleration data is not available.'); handleError(req, res, 400, 'Acceleration data is not available.');
return; return;
} }
res.status(200).send(accelerationApi.accelerations || []); res.status(200).send(Object.values(accelerationApi.getAccelerations() || {}));
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
private async $requestAcceleration(req: Request, res: Response): Promise<void> { private async $requestAcceleration(req: Request, res: Response): Promise<void> {
if (config.MEMPOOL_SERVICES.ACCELERATIONS || config.MEMPOOL.OFFICIAL) {
res.status(405).send('not available.');
return;
}
res.setHeader('Pragma', 'no-cache'); res.setHeader('Pragma', 'no-cache');
res.setHeader('Cache-control', 'private, no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0'); res.setHeader('Cache-control', 'private, no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0');
res.setHeader('expires', -1); res.setHeader('expires', -1);
try { try {
accelerationApi.accelerationRequested(req.params.txid); accelerationApi.accelerationRequested(req.params.txid);
res.status(200).send('ok'); res.status(200).send();
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, e instanceof Error ? e.message : e);
} }
} }
} }

View File

@ -136,9 +136,13 @@ class Mining {
poolsStatistics['blockCount'] = blockCount; poolsStatistics['blockCount'] = blockCount;
const totalBlock24h: number = await BlocksRepository.$blockCount(null, '24h'); const totalBlock24h: number = await BlocksRepository.$blockCount(null, '24h');
const totalBlock3d: number = await BlocksRepository.$blockCount(null, '3d');
const totalBlock1w: number = await BlocksRepository.$blockCount(null, '1w');
try { try {
poolsStatistics['lastEstimatedHashrate'] = await bitcoinClient.getNetworkHashPs(totalBlock24h); poolsStatistics['lastEstimatedHashrate'] = await bitcoinClient.getNetworkHashPs(totalBlock24h);
poolsStatistics['lastEstimatedHashrate3d'] = await bitcoinClient.getNetworkHashPs(totalBlock3d);
poolsStatistics['lastEstimatedHashrate1w'] = await bitcoinClient.getNetworkHashPs(totalBlock1w);
} catch (e) { } catch (e) {
poolsStatistics['lastEstimatedHashrate'] = 0; poolsStatistics['lastEstimatedHashrate'] = 0;
logger.debug('Bitcoin Core is not available, using zeroed value for current hashrate', logger.tags.mining); logger.debug('Bitcoin Core is not available, using zeroed value for current hashrate', logger.tags.mining);

View File

@ -1,10 +1,15 @@
import { Application, Request, Response } from 'express'; import { Application, Request, Response } from 'express';
import config from '../../config'; import config from '../../config';
import pricesUpdater from '../../tasks/price-updater'; import pricesUpdater from '../../tasks/price-updater';
import logger from '../../logger';
import PricesRepository from '../../repositories/PricesRepository';
class PricesRoutes { class PricesRoutes {
public initRoutes(app: Application): void { public initRoutes(app: Application): void {
app.get(config.MEMPOOL.API_URL_PREFIX + 'prices', this.$getCurrentPrices.bind(this)); app
.get(config.MEMPOOL.API_URL_PREFIX + 'prices', this.$getCurrentPrices.bind(this))
.get(config.MEMPOOL.API_URL_PREFIX + 'internal/usd-price-history', this.$getAllPrices.bind(this))
;
} }
private $getCurrentPrices(req: Request, res: Response): void { private $getCurrentPrices(req: Request, res: Response): void {
@ -14,6 +19,23 @@ class PricesRoutes {
res.json(pricesUpdater.getLatestPrices()); res.json(pricesUpdater.getLatestPrices());
} }
private async $getAllPrices(req: Request, res: Response): Promise<void> {
res.header('Pragma', 'public');
res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 360_0000 / config.MEMPOOL.PRICE_UPDATES_PER_HOUR).toUTCString());
try {
const usdPriceHistory = await PricesRepository.$getPricesTimesAndId();
const responseData = usdPriceHistory.map(p => {
return { time: p.time, USD: p.USD };
});
res.status(200).json(responseData);
} catch (e: any) {
logger.err(`Exception ${e} in PricesRoutes::$getAllPrices. Code: ${e.code}. Message: ${e.message}`);
res.status(403).send();
}
}
} }
export default new PricesRoutes(); export default new PricesRoutes();

View File

@ -44,6 +44,22 @@ interface CacheEvent {
value?: any, value?: any,
} }
/**
* Singleton for tracking RBF trees
*
* Maintains a set of RBF trees, where each tree represents a sequence of
* consecutive RBF replacements.
*
* Trees are identified by the txid of the root transaction.
*
* To maintain consistency, the following invariants must be upheld:
* - Symmetry: replacedBy(A) = B <=> A in replaces(B)
* - Unique id: treeMap(treeMap(X)) = treeMap(X)
* - Unique tree: A in replaces(B) => treeMap(A) == treeMap(B)
* - Existence: X in treeMap => treeMap(X) in rbfTrees
* - Completeness: X in replacedBy => X in treeMap, Y in replaces => Y in treeMap
*/
class RbfCache { class RbfCache {
private replacedBy: Map<string, string> = new Map(); private replacedBy: Map<string, string> = new Map();
private replaces: Map<string, string[]> = new Map(); private replaces: Map<string, string[]> = new Map();
@ -61,6 +77,10 @@ class RbfCache {
setInterval(this.cleanup.bind(this), 1000 * 60 * 10); setInterval(this.cleanup.bind(this), 1000 * 60 * 10);
} }
/**
* Low level cache operations
*/
private addTx(txid: string, tx: MempoolTransactionExtended): void { private addTx(txid: string, tx: MempoolTransactionExtended): void {
this.txs.set(txid, tx); this.txs.set(txid, tx);
this.cacheQueue.push({ op: CacheOp.Add, type: 'tx', txid }); this.cacheQueue.push({ op: CacheOp.Add, type: 'tx', txid });
@ -92,6 +112,12 @@ class RbfCache {
this.cacheQueue.push({ op: CacheOp.Remove, type: 'exp', txid }); this.cacheQueue.push({ op: CacheOp.Remove, type: 'exp', txid });
} }
/**
* Basic data structure operations
* must uphold tree invariants
*/
public add(replaced: MempoolTransactionExtended[], newTxExtended: MempoolTransactionExtended): void { public add(replaced: MempoolTransactionExtended[], newTxExtended: MempoolTransactionExtended): void {
if (!newTxExtended || !replaced?.length || this.txs.has(newTxExtended.txid)) { if (!newTxExtended || !replaced?.length || this.txs.has(newTxExtended.txid)) {
return; return;
@ -114,6 +140,10 @@ class RbfCache {
if (!replacedTx.rbf) { if (!replacedTx.rbf) {
txFullRbf = true; txFullRbf = true;
} }
if (this.replacedBy.has(replacedTx.txid)) {
// should never happen
continue;
}
this.replacedBy.set(replacedTx.txid, newTx.txid); this.replacedBy.set(replacedTx.txid, newTx.txid);
if (this.treeMap.has(replacedTx.txid)) { if (this.treeMap.has(replacedTx.txid)) {
const treeId = this.treeMap.get(replacedTx.txid); const treeId = this.treeMap.get(replacedTx.txid);
@ -140,18 +170,47 @@ class RbfCache {
} }
} }
newTx.fullRbf = txFullRbf; newTx.fullRbf = txFullRbf;
const treeId = replacedTrees[0].tx.txid;
const newTree = { const newTree = {
tx: newTx, tx: newTx,
time: newTime, time: newTime,
fullRbf: treeFullRbf, fullRbf: treeFullRbf,
replaces: replacedTrees replaces: replacedTrees
}; };
this.addTree(treeId, newTree); this.addTree(newTree.tx.txid, newTree);
this.updateTreeMap(treeId, newTree); this.updateTreeMap(newTree.tx.txid, newTree);
this.replaces.set(newTx.txid, replacedTrees.map(tree => tree.tx.txid)); this.replaces.set(newTx.txid, replacedTrees.map(tree => tree.tx.txid));
} }
public mined(txid): void {
if (!this.txs.has(txid)) {
return;
}
const treeId = this.treeMap.get(txid);
if (treeId && this.rbfTrees.has(treeId)) {
const tree = this.rbfTrees.get(treeId);
if (tree) {
this.setTreeMined(tree, txid);
tree.mined = true;
this.dirtyTrees.add(treeId);
this.cacheQueue.push({ op: CacheOp.Change, type: 'tree', txid: treeId });
}
}
this.evict(txid);
}
// flag a transaction as removed from the mempool
public evict(txid: string, fast: boolean = false): void {
this.evictionCount++;
if (this.txs.has(txid) && (fast || !this.expiring.has(txid))) {
const expiryTime = fast ? Date.now() + (1000 * 60 * 10) : Date.now() + (1000 * 86400); // 24 hours
this.addExpiration(txid, expiryTime);
}
}
/**
* Read-only public interface
*/
public has(txId: string): boolean { public has(txId: string): boolean {
return this.txs.has(txId); return this.txs.has(txId);
} }
@ -232,32 +291,6 @@ class RbfCache {
return changes; return changes;
} }
public mined(txid): void {
if (!this.txs.has(txid)) {
return;
}
const treeId = this.treeMap.get(txid);
if (treeId && this.rbfTrees.has(treeId)) {
const tree = this.rbfTrees.get(treeId);
if (tree) {
this.setTreeMined(tree, txid);
tree.mined = true;
this.dirtyTrees.add(treeId);
this.cacheQueue.push({ op: CacheOp.Change, type: 'tree', txid: treeId });
}
}
this.evict(txid);
}
// flag a transaction as removed from the mempool
public evict(txid: string, fast: boolean = false): void {
this.evictionCount++;
if (this.txs.has(txid) && (fast || !this.expiring.has(txid))) {
const expiryTime = fast ? Date.now() + (1000 * 60 * 10) : Date.now() + (1000 * 86400); // 24 hours
this.addExpiration(txid, expiryTime);
}
}
// is the transaction involved in a full rbf replacement? // is the transaction involved in a full rbf replacement?
public isFullRbf(txid: string): boolean { public isFullRbf(txid: string): boolean {
const treeId = this.treeMap.get(txid); const treeId = this.treeMap.get(txid);
@ -271,6 +304,10 @@ class RbfCache {
return tree?.fullRbf; return tree?.fullRbf;
} }
/**
* Cache maintenance & utility functions
*/
private cleanup(): void { private cleanup(): void {
const now = Date.now(); const now = Date.now();
for (const txid of this.expiring.keys()) { for (const txid of this.expiring.keys()) {
@ -299,10 +336,6 @@ class RbfCache {
for (const tx of (replaces || [])) { for (const tx of (replaces || [])) {
// recursively remove prior versions from the cache // recursively remove prior versions from the cache
this.replacedBy.delete(tx); this.replacedBy.delete(tx);
// if this is the id of a tree, remove that too
if (this.treeMap.get(tx) === tx) {
this.removeTree(tx);
}
this.remove(tx); this.remove(tx);
} }
} }
@ -370,14 +403,21 @@ class RbfCache {
}; };
} }
public async load({ txs, trees, expiring, mempool }): Promise<void> { public async load({ txs, trees, expiring, mempool, spendMap }): Promise<void> {
try { try {
txs.forEach(txEntry => { txs.forEach(txEntry => {
this.txs.set(txEntry.value.txid, txEntry.value); this.txs.set(txEntry.value.txid, txEntry.value);
}); });
this.staleCount = 0; this.staleCount = 0;
for (const deflatedTree of trees) { for (const deflatedTree of trees.sort((a, b) => Object.keys(b).length - Object.keys(a).length)) {
await this.importTree(mempool, deflatedTree.root, deflatedTree.root, deflatedTree, this.txs); const tree = await this.importTree(mempool, deflatedTree.root, deflatedTree.root, deflatedTree, this.txs);
if (tree) {
this.addTree(tree.tx.txid, tree);
this.updateTreeMap(tree.tx.txid, tree);
if (tree.mined) {
this.evict(tree.tx.txid);
}
}
} }
expiring.forEach(expiringEntry => { expiring.forEach(expiringEntry => {
if (this.txs.has(expiringEntry.key)) { if (this.txs.has(expiringEntry.key)) {
@ -385,6 +425,31 @@ class RbfCache {
} }
}); });
this.staleCount = 0; this.staleCount = 0;
// connect cached trees to current mempool transactions
const conflicts: Record<string, { replacedBy: MempoolTransactionExtended, replaces: Set<MempoolTransactionExtended> }> = {};
for (const tree of this.rbfTrees.values()) {
const tx = this.getTx(tree.tx.txid);
if (!tx || tree.mined) {
continue;
}
for (const vin of tx.vin) {
const conflict = spendMap.get(`${vin.txid}:${vin.vout}`);
if (conflict && conflict.txid !== tx.txid) {
if (!conflicts[conflict.txid]) {
conflicts[conflict.txid] = {
replacedBy: conflict,
replaces: new Set(),
};
}
conflicts[conflict.txid].replaces.add(tx);
}
}
}
for (const { replacedBy, replaces } of Object.values(conflicts)) {
this.add([...replaces.values()], replacedBy);
}
await this.checkTrees(); await this.checkTrees();
logger.debug(`loaded ${txs.length} txs, ${trees.length} trees into rbf cache, ${expiring.length} due to expire, ${this.staleCount} were stale`); logger.debug(`loaded ${txs.length} txs, ${trees.length} trees into rbf cache, ${expiring.length} due to expire, ${this.staleCount} were stale`);
this.cleanup(); this.cleanup();
@ -426,6 +491,12 @@ class RbfCache {
return; return;
} }
// if this tx is already in the cache, return early
if (this.treeMap.has(txid)) {
this.removeTree(deflated.key);
return;
}
// recursively reconstruct child trees // recursively reconstruct child trees
for (const childId of treeInfo.replaces) { for (const childId of treeInfo.replaces) {
const replaced = await this.importTree(mempool, root, childId, deflated, txs, mined); const replaced = await this.importTree(mempool, root, childId, deflated, txs, mined);
@ -457,10 +528,6 @@ class RbfCache {
fullRbf: treeInfo.fullRbf, fullRbf: treeInfo.fullRbf,
replaces, replaces,
}; };
this.treeMap.set(txid, root);
if (root === txid) {
this.addTree(root, tree);
}
return tree; return tree;
} }
@ -511,6 +578,7 @@ class RbfCache {
processTxs(txs); processTxs(txs);
} }
// evict missing transactions
for (const txid of txids) { for (const txid of txids) {
if (!found[txid]) { if (!found[txid]) {
this.evict(txid, false); this.evict(txid, false);

View File

@ -365,6 +365,7 @@ class RedisCache {
trees: rbfTrees.map(loadedTree => { loadedTree.value.key = loadedTree.key; return loadedTree.value; }), trees: rbfTrees.map(loadedTree => { loadedTree.value.key = loadedTree.key; return loadedTree.value; }),
expiring: rbfExpirations, expiring: rbfExpirations,
mempool: memPool.getMempool(), mempool: memPool.getMempool(),
spendMap: memPool.getSpendMap(),
}); });
} }

View File

@ -1,7 +1,10 @@
import { WebSocket } from 'ws';
import config from '../../config'; import config from '../../config';
import logger from '../../logger'; import logger from '../../logger';
import { BlockExtended } from '../../mempool.interfaces'; import { BlockExtended } from '../../mempool.interfaces';
import axios from 'axios'; import axios from 'axios';
import mempool from '../mempool';
import websocketHandler from '../websocket-handler';
type MyAccelerationStatus = 'requested' | 'accelerating' | 'done'; type MyAccelerationStatus = 'requested' | 'accelerating' | 'done';
@ -37,13 +40,23 @@ export interface AccelerationHistory {
}; };
class AccelerationApi { class AccelerationApi {
private ws: WebSocket | null = null;
private useWebsocket: boolean = config.MEMPOOL.OFFICIAL && config.MEMPOOL_SERVICES.ACCELERATIONS;
private startedWebsocketLoop: boolean = false;
private websocketConnected: boolean = false;
private onDemandPollingEnabled = !config.MEMPOOL_SERVICES.ACCELERATIONS;
private apiPath = config.MEMPOOL.OFFICIAL ? (config.MEMPOOL_SERVICES.API + '/accelerator/accelerations') : (config.EXTERNAL_DATA_SERVER.MEMPOOL_API + '/accelerations'); private apiPath = config.MEMPOOL.OFFICIAL ? (config.MEMPOOL_SERVICES.API + '/accelerator/accelerations') : (config.EXTERNAL_DATA_SERVER.MEMPOOL_API + '/accelerations');
private _accelerations: Acceleration[] | null = null; private websocketPath = config.MEMPOOL_SERVICES?.API ? `${config.MEMPOOL_SERVICES.API.replace('https://', 'wss://').replace('http://', 'ws://')}/accelerator/ws` : '/';
private _accelerations: Record<string, Acceleration> = {};
private lastPoll = 0; private lastPoll = 0;
private lastPing = Date.now();
private lastPong = Date.now();
private forcePoll = false; private forcePoll = false;
private myAccelerations: Record<string, { status: MyAccelerationStatus, added: number, acceleration?: Acceleration }> = {}; private myAccelerations: Record<string, { status: MyAccelerationStatus, added: number, acceleration?: Acceleration }> = {};
public get accelerations(): Acceleration[] | null { public constructor() {}
public getAccelerations(): Record<string, Acceleration> {
return this._accelerations; return this._accelerations;
} }
@ -52,7 +65,9 @@ class AccelerationApi {
} }
public accelerationRequested(txid: string): void { public accelerationRequested(txid: string): void {
this.myAccelerations[txid] = { status: 'requested', added: Date.now() }; if (this.onDemandPollingEnabled) {
this.myAccelerations[txid] = { status: 'requested', added: Date.now() };
}
} }
public accelerationConfirmed(): void { public accelerationConfirmed(): void {
@ -69,11 +84,18 @@ class AccelerationApi {
} }
} }
public async $updateAccelerations(): Promise<Acceleration[] | null> { public async $updateAccelerations(): Promise<Record<string, Acceleration> | null> {
if (config.MEMPOOL_SERVICES.ACCELERATIONS) { if (this.useWebsocket && this.websocketConnected) {
return this._accelerations;
}
if (!this.onDemandPollingEnabled) {
const accelerations = await this.$fetchAccelerations(); const accelerations = await this.$fetchAccelerations();
if (accelerations) { if (accelerations) {
this._accelerations = accelerations; const latestAccelerations = {};
for (const acc of accelerations) {
latestAccelerations[acc.txid] = acc;
}
this._accelerations = latestAccelerations;
return this._accelerations; return this._accelerations;
} }
} else { } else {
@ -82,7 +104,7 @@ class AccelerationApi {
return null; return null;
} }
private async $updateAccelerationsOnDemand(): Promise<Acceleration[] | null> { private async $updateAccelerationsOnDemand(): Promise<Record<string, Acceleration> | null> {
const shouldUpdate = this.forcePoll const shouldUpdate = this.forcePoll
|| this.countMyAccelerationsWithStatus('requested') > 0 || this.countMyAccelerationsWithStatus('requested') > 0
|| (this.countMyAccelerationsWithStatus('accelerating') > 0 && this.lastPoll < (Date.now() - (10 * 60 * 1000))); || (this.countMyAccelerationsWithStatus('accelerating') > 0 && this.lastPoll < (Date.now() - (10 * 60 * 1000)));
@ -117,7 +139,11 @@ class AccelerationApi {
} }
} }
this._accelerations = Object.values(this.myAccelerations).map(({ acceleration }) => acceleration).filter(acc => acc) as Acceleration[]; const latestAccelerations = {};
for (const acc of Object.values(this.myAccelerations).map(({ acceleration }) => acceleration).filter(acc => acc) as Acceleration[]) {
latestAccelerations[acc.txid] = acc;
}
this._accelerations = latestAccelerations;
return this._accelerations; return this._accelerations;
} }
@ -149,6 +175,148 @@ class AccelerationApi {
} }
return anyAccelerated; return anyAccelerated;
} }
// get a list of accelerations that have changed between two sets of accelerations
public getAccelerationDelta(oldAccelerationMap: Record<string, Acceleration>, newAccelerationMap: Record<string, Acceleration>): string[] {
const changed: string[] = [];
const mempoolCache = mempool.getMempool();
for (const acceleration of Object.values(newAccelerationMap)) {
// skip transactions we don't know about
if (!mempoolCache[acceleration.txid]) {
continue;
}
if (oldAccelerationMap[acceleration.txid] == null) {
// new acceleration
changed.push(acceleration.txid);
} else {
if (oldAccelerationMap[acceleration.txid].feeDelta !== acceleration.feeDelta) {
// feeDelta changed
changed.push(acceleration.txid);
} else if (oldAccelerationMap[acceleration.txid].pools?.length) {
let poolsChanged = false;
const pools = new Set();
oldAccelerationMap[acceleration.txid].pools.forEach(pool => {
pools.add(pool);
});
acceleration.pools.forEach(pool => {
if (!pools.has(pool)) {
poolsChanged = true;
} else {
pools.delete(pool);
}
});
if (pools.size > 0) {
poolsChanged = true;
}
if (poolsChanged) {
// pools changed
changed.push(acceleration.txid);
}
}
}
}
for (const oldTxid of Object.keys(oldAccelerationMap)) {
if (!newAccelerationMap[oldTxid]) {
// removed
changed.push(oldTxid);
}
}
return changed;
}
private handleWebsocketMessage(msg: any): void {
if (msg?.accelerations !== null) {
const latestAccelerations = {};
for (const acc of msg?.accelerations || []) {
latestAccelerations[acc.txid] = acc;
}
this._accelerations = latestAccelerations;
websocketHandler.handleAccelerationsChanged(this._accelerations);
}
}
public async connectWebsocket(): Promise<void> {
if (this.startedWebsocketLoop) {
return;
}
while (this.useWebsocket) {
this.startedWebsocketLoop = true;
if (!this.ws) {
this.ws = new WebSocket(this.websocketPath);
this.lastPing = 0;
this.ws.on('open', () => {
logger.info(`Acceleration websocket opened to ${this.websocketPath}`);
this.websocketConnected = true;
this.ws?.send(JSON.stringify({
'watch-accelerations': true
}));
});
this.ws.on('error', (error) => {
let errMsg = `Acceleration websocket error on ${this.websocketPath}: ${error['code']}`;
if (error['errors']) {
errMsg += ' - ' + error['errors'].join(' - ');
}
logger.err(errMsg);
this.ws = null;
this.websocketConnected = false;
});
this.ws.on('close', () => {
logger.info('Acceleration websocket closed');
this.ws = null;
this.websocketConnected = false;
});
this.ws.on('message', (data, isBinary) => {
try {
const msg = (isBinary ? data : data.toString()) as string;
const parsedMsg = msg?.length ? JSON.parse(msg) : null;
this.handleWebsocketMessage(parsedMsg);
} catch (e) {
logger.warn('Failed to parse acceleration websocket message: ' + (e instanceof Error ? e.message : e));
}
});
this.ws.on('ping', () => {
logger.debug('received ping from acceleration websocket server');
});
this.ws.on('pong', () => {
logger.debug('received pong from acceleration websocket server');
this.lastPong = Date.now();
});
} else if (this.websocketConnected) {
if (this.lastPing && this.lastPing > this.lastPong && (Date.now() - this.lastPing > 10000)) {
logger.warn('No pong received within 10 seconds, terminating connection');
try {
this.ws?.terminate();
} catch (e) {
logger.warn('failed to terminate acceleration websocket connection: ' + (e instanceof Error ? e.message : e));
} finally {
this.ws = null;
this.websocketConnected = false;
this.lastPing = 0;
}
} else if (!this.lastPing || (Date.now() - this.lastPing > 30000)) {
logger.debug('sending ping to acceleration websocket server');
if (this.ws?.readyState === WebSocket.OPEN) {
try {
this.ws?.ping();
this.lastPing = Date.now();
} catch (e) {
logger.warn('failed to send ping to acceleration websocket server: ' + (e instanceof Error ? e.message : e));
}
}
}
}
await new Promise(resolve => setTimeout(resolve, 5000));
}
}
} }
export default new AccelerationApi(); export default new AccelerationApi();

View File

@ -8,7 +8,15 @@ import { TransactionExtended } from '../../mempool.interfaces';
interface WalletAddress { interface WalletAddress {
address: string; address: string;
active: boolean; active: boolean;
transactions?: IEsploraApi.AddressTxSummary[]; stats: {
funded_txo_count: number;
funded_txo_sum: number;
spent_txo_count: number;
spent_txo_sum: number;
tx_count: number;
};
transactions: IEsploraApi.AddressTxSummary[];
lastSync: number;
} }
interface WalletConfig { interface WalletConfig {
@ -22,7 +30,7 @@ interface Wallet extends WalletConfig {
lastPoll: number; lastPoll: number;
} }
const POLL_FREQUENCY = 60 * 60 * 1000; // 1 hour const POLL_FREQUENCY = 5 * 60 * 1000; // 5 minutes
class WalletApi { class WalletApi {
private wallets: Record<string, Wallet> = {}; private wallets: Record<string, Wallet> = {};
@ -39,8 +47,11 @@ class WalletApi {
return this.wallets?.[wallet]?.addresses || {}; return this.wallets?.[wallet]?.addresses || {};
} }
// resync wallet addresses from the provided API // resync wallet addresses from the services backend
async $syncWallets(): Promise<void> { async $syncWallets(): Promise<void> {
if (!config.WALLETS.ENABLED || this.syncing) {
return;
}
this.syncing = true; this.syncing = true;
for (const walletKey of Object.keys(this.wallets)) { for (const walletKey of Object.keys(this.wallets)) {
const wallet = this.wallets[walletKey]; const wallet = this.wallets[walletKey];
@ -77,10 +88,14 @@ class WalletApi {
const refreshTransactions = !wallet.addresses[address.address] || address.active; const refreshTransactions = !wallet.addresses[address.address] || address.active;
if (refreshTransactions) { if (refreshTransactions) {
try { try {
const summary = await bitcoinApi.$getAddressTransactionSummary(address.address);
const addressInfo = await bitcoinApi.$getAddress(address.address);
const walletAddress: WalletAddress = { const walletAddress: WalletAddress = {
address: address.address, address: address.address,
active: address.active, active: address.active,
transactions: await bitcoinApi.$getAddressTransactionSummary(address.address), transactions: await bitcoinApi.$getAddressTransactionSummary(address.address),
stats: addressInfo.chain_stats,
lastSync: Date.now(),
}; };
logger.debug(`Synced ${walletAddress.transactions?.length || 0} transactions for wallet ${wallet.name} address ${address.address}`); logger.debug(`Synced ${walletAddress.transactions?.length || 0} transactions for wallet ${wallet.name} address ${address.address}`);
wallet.addresses[address.address] = walletAddress; wallet.addresses[address.address] = walletAddress;
@ -91,36 +106,61 @@ class WalletApi {
} }
// check a new block for transactions that affect wallet address balances, and add relevant transactions to wallets // check a new block for transactions that affect wallet address balances, and add relevant transactions to wallets
processBlock(block: IEsploraApi.Block, blockTxs: TransactionExtended[]): Record<string, Record<string, IEsploraApi.AddressTxSummary[]>> { processBlock(block: IEsploraApi.Block, blockTxs: TransactionExtended[]): Record<string, IEsploraApi.Transaction[]> {
const walletTransactions: Record<string, Record<string, IEsploraApi.AddressTxSummary[]>> = {}; const walletTransactions: Record<string, IEsploraApi.Transaction[]> = {};
for (const walletKey of Object.keys(this.wallets)) { for (const walletKey of Object.keys(this.wallets)) {
const wallet = this.wallets[walletKey]; const wallet = this.wallets[walletKey];
walletTransactions[walletKey] = {}; walletTransactions[walletKey] = [];
for (const tx of blockTxs) { for (const tx of blockTxs) {
const funded: Record<string, number> = {}; const funded: Record<string, number> = {};
const spent: Record<string, number> = {}; const spent: Record<string, number> = {};
const fundedCount: Record<string, number> = {};
const spentCount: Record<string, number> = {};
let anyMatch = false;
for (const vin of tx.vin) { for (const vin of tx.vin) {
const address = vin.prevout?.scriptpubkey_address; const address = vin.prevout?.scriptpubkey_address;
if (address && wallet.addresses[address]) { if (address && wallet.addresses[address]) {
anyMatch = true;
spent[address] = (spent[address] ?? 0) + (vin.prevout?.value ?? 0); spent[address] = (spent[address] ?? 0) + (vin.prevout?.value ?? 0);
spentCount[address] = (spentCount[address] ?? 0) + 1;
} }
} }
for (const vout of tx.vout) { for (const vout of tx.vout) {
const address = vout.scriptpubkey_address; const address = vout.scriptpubkey_address;
if (address && wallet.addresses[address]) { if (address && wallet.addresses[address]) {
anyMatch = true;
funded[address] = (funded[address] ?? 0) + (vout.value ?? 0); funded[address] = (funded[address] ?? 0) + (vout.value ?? 0);
fundedCount[address] = (fundedCount[address] ?? 0) + 1;
} }
} }
for (const address of Object.keys({ ...funded, ...spent })) { for (const address of Object.keys({ ...funded, ...spent })) {
if (!walletTransactions[walletKey][address]) { // update address stats
walletTransactions[walletKey][address] = []; wallet.addresses[address].stats.tx_count++;
} wallet.addresses[address].stats.funded_txo_count += fundedCount[address] || 0;
walletTransactions[walletKey][address].push({ wallet.addresses[address].stats.spent_txo_count += spentCount[address] || 0;
wallet.addresses[address].stats.funded_txo_sum += funded[address] || 0;
wallet.addresses[address].stats.spent_txo_sum += spent[address] || 0;
// add tx to summary
const txSummary: IEsploraApi.AddressTxSummary = {
txid: tx.txid, txid: tx.txid,
value: (funded[address] ?? 0) - (spent[address] ?? 0), value: (funded[address] ?? 0) - (spent[address] ?? 0),
height: block.height, height: block.height,
time: block.timestamp, time: block.timestamp,
}); };
wallet.addresses[address].transactions?.push(txSummary);
}
if (anyMatch) {
for (const address of Object.keys({ ...funded, ...spent })) {
if (!walletTransactions[walletKey][address]) {
walletTransactions[walletKey][address] = [];
}
walletTransactions[walletKey][address].push({
txid: tx.txid,
value: (funded[address] ?? 0) - (spent[address] ?? 0),
height: block.height,
time: block.timestamp,
});
}
} }
} }
} }
@ -128,4 +168,4 @@ class WalletApi {
} }
} }
export default new WalletApi(); export default new WalletApi();

View File

@ -121,6 +121,7 @@ class TransactionUtils {
const adjustedVsize = Math.max(fractionalVsize, sigops * 5); // adjusted vsize = Max(weight, sigops * bytes_per_sigop) / witness_scale_factor const adjustedVsize = Math.max(fractionalVsize, sigops * 5); // adjusted vsize = Max(weight, sigops * bytes_per_sigop) / witness_scale_factor
const feePerVbytes = (transaction.fee || 0) / fractionalVsize; const feePerVbytes = (transaction.fee || 0) / fractionalVsize;
const adjustedFeePerVsize = (transaction.fee || 0) / adjustedVsize; const adjustedFeePerVsize = (transaction.fee || 0) / adjustedVsize;
const effectiveFeePerVsize = transaction['effectiveFeePerVsize'] || adjustedFeePerVsize || feePerVbytes;
const transactionExtended: MempoolTransactionExtended = Object.assign(transaction, { const transactionExtended: MempoolTransactionExtended = Object.assign(transaction, {
order: this.txidToOrdering(transaction.txid), order: this.txidToOrdering(transaction.txid),
vsize, vsize,
@ -128,7 +129,7 @@ class TransactionUtils {
sigops, sigops,
feePerVsize: feePerVbytes, feePerVsize: feePerVbytes,
adjustedFeePerVsize: adjustedFeePerVsize, adjustedFeePerVsize: adjustedFeePerVsize,
effectiveFeePerVsize: adjustedFeePerVsize, effectiveFeePerVsize: effectiveFeePerVsize,
}); });
if (!transactionExtended?.status?.confirmed && !transactionExtended.firstSeen) { if (!transactionExtended?.status?.confirmed && !transactionExtended.firstSeen) {
transactionExtended.firstSeen = Math.round((Date.now() / 1000)); transactionExtended.firstSeen = Math.round((Date.now() / 1000));
@ -338,6 +339,87 @@ class TransactionUtils {
const positionOfScript = hasAnnex ? witness.length - 3 : witness.length - 2; const positionOfScript = hasAnnex ? witness.length - 3 : witness.length - 2;
return witness[positionOfScript]; return witness[positionOfScript];
} }
// calculate the most parsimonious set of prioritizations given a list of block transactions
// (i.e. the most likely prioritizations and deprioritizations)
public identifyPrioritizedTransactions(transactions: any[], rateKey: string): { prioritized: string[], deprioritized: string[] } {
// find the longest increasing subsequence of transactions
// (adapted from https://en.wikipedia.org/wiki/Longest_increasing_subsequence#Efficient_algorithms)
// should be O(n log n)
const X = transactions.slice(1).reverse().map((tx) => ({ txid: tx.txid, rate: tx[rateKey] })); // standard block order is by *decreasing* effective fee rate, but we want to iterate in increasing order (and skip the coinbase)
if (X.length < 2) {
return { prioritized: [], deprioritized: [] };
}
const N = X.length;
const P: number[] = new Array(N);
const M: number[] = new Array(N + 1);
M[0] = -1; // undefined so can be set to any value
let L = 0;
for (let i = 0; i < N; i++) {
// Binary search for the smallest positive l ≤ L
// such that X[M[l]].effectiveFeePerVsize > X[i].effectiveFeePerVsize
let lo = 1;
let hi = L + 1;
while (lo < hi) {
const mid = lo + Math.floor((hi - lo) / 2); // lo <= mid < hi
if (X[M[mid]].rate > X[i].rate) {
hi = mid;
} else { // if X[M[mid]].effectiveFeePerVsize < X[i].effectiveFeePerVsize
lo = mid + 1;
}
}
// After searching, lo == hi is 1 greater than the
// length of the longest prefix of X[i]
const newL = lo;
// The predecessor of X[i] is the last index of
// the subsequence of length newL-1
P[i] = M[newL - 1];
M[newL] = i;
if (newL > L) {
// If we found a subsequence longer than any we've
// found yet, update L
L = newL;
}
}
// Reconstruct the longest increasing subsequence
// It consists of the values of X at the L indices:
// ..., P[P[M[L]]], P[M[L]], M[L]
const LIS: any[] = new Array(L);
let k = M[L];
for (let j = L - 1; j >= 0; j--) {
LIS[j] = X[k];
k = P[k];
}
const lisMap = new Map<string, number>();
LIS.forEach((tx, index) => lisMap.set(tx.txid, index));
const prioritized: string[] = [];
const deprioritized: string[] = [];
let lastRate = X[0].rate;
for (const tx of X) {
if (lisMap.has(tx.txid)) {
lastRate = tx.rate;
} else {
if (Math.abs(tx.rate - lastRate) < 0.1) {
// skip if the rate is almost the same as the previous transaction
} else if (tx.rate <= lastRate) {
prioritized.push(tx.txid);
} else {
deprioritized.push(tx.txid);
}
}
}
return { prioritized, deprioritized };
}
} }
export default new TransactionUtils(); export default new TransactionUtils();

View File

@ -3,7 +3,7 @@ import * as WebSocket from 'ws';
import { import {
BlockExtended, TransactionExtended, MempoolTransactionExtended, WebsocketResponse, BlockExtended, TransactionExtended, MempoolTransactionExtended, WebsocketResponse,
OptimizedStatistic, ILoadingIndicators, GbtCandidates, TxTrackingInfo, OptimizedStatistic, ILoadingIndicators, GbtCandidates, TxTrackingInfo,
MempoolBlockDelta, MempoolDelta, MempoolDeltaTxids MempoolDelta, MempoolDeltaTxids
} from '../mempool.interfaces'; } from '../mempool.interfaces';
import blocks from './blocks'; import blocks from './blocks';
import memPool from './mempool'; import memPool from './mempool';
@ -16,11 +16,13 @@ import transactionUtils from './transaction-utils';
import rbfCache, { ReplacementInfo } from './rbf-cache'; import rbfCache, { ReplacementInfo } from './rbf-cache';
import difficultyAdjustment from './difficulty-adjustment'; import difficultyAdjustment from './difficulty-adjustment';
import feeApi from './fee-api'; import feeApi from './fee-api';
import BlocksRepository from '../repositories/BlocksRepository';
import BlocksAuditsRepository from '../repositories/BlocksAuditsRepository'; import BlocksAuditsRepository from '../repositories/BlocksAuditsRepository';
import BlocksSummariesRepository from '../repositories/BlocksSummariesRepository'; import BlocksSummariesRepository from '../repositories/BlocksSummariesRepository';
import Audit from './audit'; import Audit from './audit';
import priceUpdater from '../tasks/price-updater'; import priceUpdater from '../tasks/price-updater';
import { ApiPrice } from '../repositories/PricesRepository'; import { ApiPrice } from '../repositories/PricesRepository';
import { Acceleration } from './services/acceleration';
import accelerationApi from './services/acceleration'; import accelerationApi from './services/acceleration';
import mempool from './mempool'; import mempool from './mempool';
import statistics from './statistics/statistics'; import statistics from './statistics/statistics';
@ -35,6 +37,7 @@ interface AddressTransactions {
} }
import bitcoinSecondClient from './bitcoin/bitcoin-second-client'; import bitcoinSecondClient from './bitcoin/bitcoin-second-client';
import { calculateMempoolTxCpfp } from './cpfp'; import { calculateMempoolTxCpfp } from './cpfp';
import { getRecentFirstSeen } from '../utils/file-read';
// valid 'want' subscriptions // valid 'want' subscriptions
const wantable = [ const wantable = [
@ -58,6 +61,8 @@ class WebsocketHandler {
private lastRbfSummary: ReplacementInfo[] | null = null; private lastRbfSummary: ReplacementInfo[] | null = null;
private mempoolSequence: number = 0; private mempoolSequence: number = 0;
private accelerations: Record<string, Acceleration> = {};
constructor() { } constructor() { }
addWebsocketServer(wss: WebSocket.Server) { addWebsocketServer(wss: WebSocket.Server) {
@ -493,6 +498,42 @@ class WebsocketHandler {
} }
} }
handleAccelerationsChanged(accelerations: Record<string, Acceleration>): void {
if (!this.webSocketServers.length) {
throw new Error('No WebSocket.Server has been set');
}
const websocketAccelerationDelta = accelerationApi.getAccelerationDelta(this.accelerations, accelerations);
this.accelerations = accelerations;
if (!websocketAccelerationDelta.length) {
return;
}
// pre-compute acceleration delta
const accelerationUpdate = {
added: websocketAccelerationDelta.map(txid => accelerations[txid]).filter(acc => acc != null),
removed: websocketAccelerationDelta.filter(txid => !accelerations[txid]),
};
try {
const response = JSON.stringify({
accelerations: accelerationUpdate,
});
for (const server of this.webSocketServers) {
server.clients.forEach((client) => {
if (client.readyState !== WebSocket.OPEN) {
return;
}
client.send(response);
});
}
} catch (e) {
logger.debug(`Error sending acceleration update to websocket clients: ${e}`);
}
}
handleReorg(): void { handleReorg(): void {
if (!this.webSocketServers.length) { if (!this.webSocketServers.length) {
throw new Error('No WebSocket.Server have been set'); throw new Error('No WebSocket.Server have been set');
@ -529,8 +570,17 @@ class WebsocketHandler {
} }
} }
/**
*
* @param newMempool
* @param mempoolSize
* @param newTransactions array of transactions added this mempool update.
* @param recentlyDeletedTransactions array of arrays of transactions removed in the last N mempool updates, most recent first.
* @param accelerationDelta
* @param candidates
*/
async $handleMempoolChange(newMempool: { [txid: string]: MempoolTransactionExtended }, mempoolSize: number, async $handleMempoolChange(newMempool: { [txid: string]: MempoolTransactionExtended }, mempoolSize: number,
newTransactions: MempoolTransactionExtended[], deletedTransactions: MempoolTransactionExtended[], accelerationDelta: string[], newTransactions: MempoolTransactionExtended[], recentlyDeletedTransactions: MempoolTransactionExtended[][], accelerationDelta: string[],
candidates?: GbtCandidates): Promise<void> { candidates?: GbtCandidates): Promise<void> {
if (!this.webSocketServers.length) { if (!this.webSocketServers.length) {
throw new Error('No WebSocket.Server have been set'); throw new Error('No WebSocket.Server have been set');
@ -538,6 +588,8 @@ class WebsocketHandler {
this.printLogs(); this.printLogs();
const deletedTransactions = recentlyDeletedTransactions.length ? recentlyDeletedTransactions[0] : [];
const transactionIds = (memPool.limitGBT && candidates) ? Object.keys(candidates?.txs || {}) : Object.keys(newMempool); const transactionIds = (memPool.limitGBT && candidates) ? Object.keys(candidates?.txs || {}) : Object.keys(newMempool);
let added = newTransactions; let added = newTransactions;
let removed = deletedTransactions; let removed = deletedTransactions;
@ -556,9 +608,9 @@ class WebsocketHandler {
const mBlockDeltas = mempoolBlocks.getMempoolBlockDeltas(); const mBlockDeltas = mempoolBlocks.getMempoolBlockDeltas();
const mempoolInfo = memPool.getMempoolInfo(); const mempoolInfo = memPool.getMempoolInfo();
const vBytesPerSecond = memPool.getVBytesPerSecond(); const vBytesPerSecond = memPool.getVBytesPerSecond();
const rbfTransactions = Common.findRbfTransactions(newTransactions, deletedTransactions); const rbfTransactions = Common.findRbfTransactions(newTransactions, recentlyDeletedTransactions.flat());
const da = difficultyAdjustment.getDifficultyAdjustment(); const da = difficultyAdjustment.getDifficultyAdjustment();
const accelerations = memPool.getAccelerations(); const accelerations = accelerationApi.getAccelerations();
memPool.handleRbfTransactions(rbfTransactions); memPool.handleRbfTransactions(rbfTransactions);
const rbfChanges = rbfCache.getRbfChanges(); const rbfChanges = rbfCache.getRbfChanges();
let rbfReplacements; let rbfReplacements;
@ -587,7 +639,7 @@ class WebsocketHandler {
const replacedTransactions: { replaced: string, by: TransactionExtended }[] = []; const replacedTransactions: { replaced: string, by: TransactionExtended }[] = [];
for (const tx of newTransactions) { for (const tx of newTransactions) {
if (rbfTransactions[tx.txid]) { if (rbfTransactions[tx.txid]) {
for (const replaced of rbfTransactions[tx.txid]) { for (const replaced of rbfTransactions[tx.txid].replaced) {
replacedTransactions.push({ replaced: replaced.txid, by: tx }); replacedTransactions.push({ replaced: replaced.txid, by: tx });
} }
} }
@ -666,10 +718,13 @@ class WebsocketHandler {
const addressCache = this.makeAddressCache(newTransactions); const addressCache = this.makeAddressCache(newTransactions);
const removedAddressCache = this.makeAddressCache(deletedTransactions); const removedAddressCache = this.makeAddressCache(deletedTransactions);
const websocketAccelerationDelta = accelerationApi.getAccelerationDelta(this.accelerations, accelerations);
this.accelerations = accelerations;
// pre-compute acceleration delta // pre-compute acceleration delta
const accelerationUpdate = { const accelerationUpdate = {
added: accelerationDelta.map(txid => accelerations[txid]).filter(acc => acc != null), added: websocketAccelerationDelta.map(txid => accelerations[txid]).filter(acc => acc != null),
removed: accelerationDelta.filter(txid => !accelerations[txid]), removed: websocketAccelerationDelta.filter(txid => !accelerations[txid]),
}; };
// TODO - Fix indentation after PR is merged // TODO - Fix indentation after PR is merged
@ -832,6 +887,7 @@ class WebsocketHandler {
accelerated: mempoolTx.acceleration || undefined, accelerated: mempoolTx.acceleration || undefined,
acceleratedBy: mempoolTx.acceleratedBy || undefined, acceleratedBy: mempoolTx.acceleratedBy || undefined,
acceleratedAt: mempoolTx.acceleratedAt || undefined, acceleratedAt: mempoolTx.acceleratedAt || undefined,
feeDelta: mempoolTx.feeDelta || undefined,
}, },
accelerationPositions: memPool.getAccelerationPositions(mempoolTx.txid), accelerationPositions: memPool.getAccelerationPositions(mempoolTx.txid),
}; };
@ -873,6 +929,7 @@ class WebsocketHandler {
accelerated: mempoolTx.acceleration || undefined, accelerated: mempoolTx.acceleration || undefined,
acceleratedBy: mempoolTx.acceleratedBy || undefined, acceleratedBy: mempoolTx.acceleratedBy || undefined,
acceleratedAt: mempoolTx.acceleratedAt || undefined, acceleratedAt: mempoolTx.acceleratedAt || undefined,
feeDelta: mempoolTx.feeDelta || undefined,
}; };
if (!mempoolTx.cpfpChecked) { if (!mempoolTx.cpfpChecked) {
calculateMempoolTxCpfp(mempoolTx, newMempool); calculateMempoolTxCpfp(mempoolTx, newMempool);
@ -940,6 +997,8 @@ class WebsocketHandler {
throw new Error('No WebSocket.Server have been set'); throw new Error('No WebSocket.Server have been set');
} }
const blockTransactions = structuredClone(transactions);
this.printLogs(); this.printLogs();
await statistics.runStatistics(); await statistics.runStatistics();
@ -949,10 +1008,10 @@ class WebsocketHandler {
let transactionIds: string[] = (memPool.limitGBT) ? Object.keys(candidates?.txs || {}) : Object.keys(_memPool); let transactionIds: string[] = (memPool.limitGBT) ? Object.keys(candidates?.txs || {}) : Object.keys(_memPool);
const accelerations = Object.values(mempool.getAccelerations()); const accelerations = Object.values(mempool.getAccelerations());
await accelerationRepository.$indexAccelerationsForBlock(block, accelerations, transactions); await accelerationRepository.$indexAccelerationsForBlock(block, accelerations, structuredClone(transactions));
const rbfTransactions = Common.findMinedRbfTransactions(transactions, memPool.getSpendMap()); const rbfTransactions = Common.findMinedRbfTransactions(transactions, memPool.getSpendMap());
memPool.handleMinedRbfTransactions(rbfTransactions); memPool.handleRbfTransactions(rbfTransactions);
memPool.removeFromSpendMap(transactions); memPool.removeFromSpendMap(transactions);
if (config.MEMPOOL.AUDIT && memPool.isInSync()) { if (config.MEMPOOL.AUDIT && memPool.isInSync()) {
@ -969,7 +1028,7 @@ class WebsocketHandler {
} }
if (Common.indexingEnabled()) { if (Common.indexingEnabled()) {
const { censored, added, prioritized, fresh, sigop, fullrbf, accelerated, score, similarity } = Audit.auditBlock(transactions, projectedBlocks, auditMempool); const { unseen, censored, added, prioritized, fresh, sigop, fullrbf, accelerated, score, similarity } = Audit.auditBlock(block.height, blockTransactions, projectedBlocks, auditMempool);
const matchRate = Math.round(score * 100 * 100) / 100; const matchRate = Math.round(score * 100 * 100) / 100;
const stripped = projectedBlocks[0]?.transactions ? projectedBlocks[0].transactions : []; const stripped = projectedBlocks[0]?.transactions ? projectedBlocks[0].transactions : [];
@ -991,9 +1050,11 @@ class WebsocketHandler {
}); });
BlocksAuditsRepository.$saveAudit({ BlocksAuditsRepository.$saveAudit({
version: 1,
time: block.timestamp, time: block.timestamp,
height: block.height, height: block.height,
hash: block.id, hash: block.id,
unseenTxs: unseen,
addedTxs: added, addedTxs: added,
prioritizedTxs: prioritized, prioritizedTxs: prioritized,
missingTxs: censored, missingTxs: censored,
@ -1020,6 +1081,14 @@ class WebsocketHandler {
} }
} }
if (config.CORE_RPC.DEBUG_LOG_PATH && block.extras) {
const firstSeen = getRecentFirstSeen(block.id);
if (firstSeen) {
BlocksRepository.$saveFirstSeenTime(block.id, firstSeen);
block.extras.firstSeen = firstSeen;
}
}
const confirmedTxids: { [txid: string]: boolean } = {}; const confirmedTxids: { [txid: string]: boolean } = {};
// Update mempool to remove transactions included in the new block // Update mempool to remove transactions included in the new block
@ -1150,6 +1219,7 @@ class WebsocketHandler {
accelerated: mempoolTx.acceleration || undefined, accelerated: mempoolTx.acceleration || undefined,
acceleratedBy: mempoolTx.acceleratedBy || undefined, acceleratedBy: mempoolTx.acceleratedBy || undefined,
acceleratedAt: mempoolTx.acceleratedAt || undefined, acceleratedAt: mempoolTx.acceleratedAt || undefined,
feeDelta: mempoolTx.feeDelta || undefined,
}, },
accelerationPositions: memPool.getAccelerationPositions(mempoolTx.txid), accelerationPositions: memPool.getAccelerationPositions(mempoolTx.txid),
}); });
@ -1172,6 +1242,7 @@ class WebsocketHandler {
accelerated: mempoolTx.acceleration || undefined, accelerated: mempoolTx.acceleration || undefined,
acceleratedBy: mempoolTx.acceleratedBy || undefined, acceleratedBy: mempoolTx.acceleratedBy || undefined,
acceleratedAt: mempoolTx.acceleratedAt || undefined, acceleratedAt: mempoolTx.acceleratedAt || undefined,
feeDelta: mempoolTx.feeDelta || undefined,
}; };
} }
} }

View File

@ -32,6 +32,7 @@ interface IConfig {
AUTOMATIC_POOLS_UPDATE: boolean; AUTOMATIC_POOLS_UPDATE: boolean;
POOLS_JSON_URL: string, POOLS_JSON_URL: string,
POOLS_JSON_TREE_URL: string, POOLS_JSON_TREE_URL: string,
POOLS_UPDATE_DELAY: number,
AUDIT: boolean; AUDIT: boolean;
RUST_GBT: boolean; RUST_GBT: boolean;
LIMIT_GBT: boolean; LIMIT_GBT: boolean;
@ -85,6 +86,7 @@ interface IConfig {
TIMEOUT: number; TIMEOUT: number;
COOKIE: boolean; COOKIE: boolean;
COOKIE_PATH: string; COOKIE_PATH: string;
DEBUG_LOG_PATH: string;
}; };
SECOND_CORE_RPC: { SECOND_CORE_RPC: {
HOST: string; HOST: string;
@ -200,8 +202,9 @@ const defaults: IConfig = {
'AUTOMATIC_POOLS_UPDATE': false, 'AUTOMATIC_POOLS_UPDATE': false,
'POOLS_JSON_URL': 'https://raw.githubusercontent.com/mempool/mining-pools/master/pools-v2.json', 'POOLS_JSON_URL': 'https://raw.githubusercontent.com/mempool/mining-pools/master/pools-v2.json',
'POOLS_JSON_TREE_URL': 'https://api.github.com/repos/mempool/mining-pools/git/trees/master', 'POOLS_JSON_TREE_URL': 'https://api.github.com/repos/mempool/mining-pools/git/trees/master',
'POOLS_UPDATE_DELAY': 604800, // in seconds, default is one week
'AUDIT': false, 'AUDIT': false,
'RUST_GBT': false, 'RUST_GBT': true,
'LIMIT_GBT': false, 'LIMIT_GBT': false,
'CPFP_INDEXING': false, 'CPFP_INDEXING': false,
'MAX_BLOCKS_BULK_QUERY': 0, 'MAX_BLOCKS_BULK_QUERY': 0,
@ -233,7 +236,8 @@ const defaults: IConfig = {
'PASSWORD': 'mempool', 'PASSWORD': 'mempool',
'TIMEOUT': 60000, 'TIMEOUT': 60000,
'COOKIE': false, 'COOKIE': false,
'COOKIE_PATH': '/bitcoin/.cookie' 'COOKIE_PATH': '/bitcoin/.cookie',
'DEBUG_LOG_PATH': '',
}, },
'SECOND_CORE_RPC': { 'SECOND_CORE_RPC': {
'HOST': '127.0.0.1', 'HOST': '127.0.0.1',

View File

@ -213,6 +213,8 @@ class Server {
} }
}); });
} }
poolsUpdater.$startService();
} }
async runMainUpdateLoop(): Promise<void> { async runMainUpdateLoop(): Promise<void> {
@ -231,11 +233,11 @@ class Server {
const newMempool = await bitcoinApi.$getRawMempool(); const newMempool = await bitcoinApi.$getRawMempool();
const minFeeMempool = memPool.limitGBT ? await bitcoinSecondClient.getRawMemPool() : null; const minFeeMempool = memPool.limitGBT ? await bitcoinSecondClient.getRawMemPool() : null;
const minFeeTip = memPool.limitGBT ? await bitcoinSecondClient.getBlockCount() : -1; const minFeeTip = memPool.limitGBT ? await bitcoinSecondClient.getBlockCount() : -1;
const newAccelerations = await accelerationApi.$updateAccelerations(); const latestAccelerations = await accelerationApi.$updateAccelerations();
const numHandledBlocks = await blocks.$updateBlocks(); const numHandledBlocks = await blocks.$updateBlocks();
const pollRate = config.MEMPOOL.POLL_RATE_MS * (indexer.indexerIsRunning() ? 10 : 1); const pollRate = config.MEMPOOL.POLL_RATE_MS * (indexer.indexerIsRunning() ? 10 : 1);
if (numHandledBlocks === 0) { if (numHandledBlocks === 0) {
await memPool.$updateMempool(newMempool, newAccelerations, minFeeMempool, minFeeTip, pollRate); await memPool.$updateMempool(newMempool, latestAccelerations, minFeeMempool, minFeeTip, pollRate);
} }
indexer.$run(); indexer.$run();
if (config.WALLETS.ENABLED) { if (config.WALLETS.ENABLED) {
@ -316,8 +318,10 @@ class Server {
priceUpdater.setRatesChangedCallback(websocketHandler.handleNewConversionRates.bind(websocketHandler)); priceUpdater.setRatesChangedCallback(websocketHandler.handleNewConversionRates.bind(websocketHandler));
} }
loadingIndicators.setProgressChangedCallback(websocketHandler.handleLoadingChanged.bind(websocketHandler)); loadingIndicators.setProgressChangedCallback(websocketHandler.handleLoadingChanged.bind(websocketHandler));
accelerationApi.connectWebsocket();
} }
setUpHttpApiRoutes(): void { setUpHttpApiRoutes(): void {
bitcoinRoutes.initRoutes(this.app); bitcoinRoutes.initRoutes(this.app);
bitcoinCoreRoutes.initRoutes(this.app); bitcoinCoreRoutes.initRoutes(this.app);

View File

@ -10,6 +10,7 @@ import config from './config';
import auditReplicator from './replication/AuditReplication'; import auditReplicator from './replication/AuditReplication';
import statisticsReplicator from './replication/StatisticsReplication'; import statisticsReplicator from './replication/StatisticsReplication';
import AccelerationRepository from './repositories/AccelerationRepository'; import AccelerationRepository from './repositories/AccelerationRepository';
import BlocksAuditsRepository from './repositories/BlocksAuditsRepository';
export interface CoreIndex { export interface CoreIndex {
name: string; name: string;
@ -192,6 +193,7 @@ class Indexer {
await auditReplicator.$sync(); await auditReplicator.$sync();
await statisticsReplicator.$sync(); await statisticsReplicator.$sync();
await AccelerationRepository.$indexPastAccelerations(); await AccelerationRepository.$indexPastAccelerations();
await BlocksAuditsRepository.$migrateAuditsV0toV1();
// do not wait for classify blocks to finish // do not wait for classify blocks to finish
blocks.$classifyBlocks(); blocks.$classifyBlocks();
} catch (e) { } catch (e) {

View File

@ -29,9 +29,11 @@ export interface PoolStats extends PoolInfo {
} }
export interface BlockAudit { export interface BlockAudit {
version: number,
time: number, time: number,
height: number, height: number,
hash: string, hash: string,
unseenTxs: string[],
missingTxs: string[], missingTxs: string[],
freshTxs: string[], freshTxs: string[],
sigopTxs: string[], sigopTxs: string[],
@ -126,6 +128,7 @@ export interface TransactionExtended extends IEsploraApi.Transaction {
acceleration?: boolean; acceleration?: boolean;
acceleratedBy?: number[]; acceleratedBy?: number[];
acceleratedAt?: number; acceleratedAt?: number;
feeDelta?: number;
replacement?: boolean; replacement?: boolean;
uid?: number; uid?: number;
flags?: number; flags?: number;
@ -296,6 +299,7 @@ export interface BlockExtension {
id: number; // Note - This is the `unique_id`, not to mix with the auto increment `id` id: number; // Note - This is the `unique_id`, not to mix with the auto increment `id`
name: string; name: string;
slug: string; slug: string;
minerNames: string[] | null;
}; };
avgFee: number; avgFee: number;
avgFeeRate: number; avgFeeRate: number;
@ -316,6 +320,7 @@ export interface BlockExtension {
segwitTotalSize: number; segwitTotalSize: number;
segwitTotalWeight: number; segwitTotalWeight: number;
header: string; header: string;
firstSeen: number | null;
utxoSetChange: number; utxoSetChange: number;
// Requires coinstatsindex, will be set to NULL otherwise // Requires coinstatsindex, will be set to NULL otherwise
utxoSetSize: number | null; utxoSetSize: number | null;
@ -382,8 +387,9 @@ export interface CpfpCluster {
} }
export interface CpfpSummary { export interface CpfpSummary {
transactions: TransactionExtended[]; transactions: MempoolTransactionExtended[];
clusters: CpfpCluster[]; clusters: CpfpCluster[];
version: number;
} }
export interface Statistic { export interface Statistic {
@ -449,7 +455,7 @@ export interface OptimizedStatistic {
export interface TxTrackingInfo { export interface TxTrackingInfo {
replacedBy?: string, replacedBy?: string,
position?: { block: number, vsize: number, accelerated?: boolean, acceleratedBy?: number[], acceleratedAt?: number }, position?: { block: number, vsize: number, accelerated?: boolean, acceleratedBy?: number[], acceleratedAt?: number, feeDelta?: number },
cpfp?: { cpfp?: {
ancestors?: Ancestor[], ancestors?: Ancestor[],
bestDescendant?: Ancestor | null, bestDescendant?: Ancestor | null,
@ -462,6 +468,7 @@ export interface TxTrackingInfo {
accelerated?: boolean, accelerated?: boolean,
acceleratedBy?: number[], acceleratedBy?: number[],
acceleratedAt?: number, acceleratedAt?: number,
feeDelta?: number,
confirmed?: boolean confirmed?: boolean
} }

View File

@ -31,11 +31,11 @@ class AuditReplication {
const missingAudits = await this.$getMissingAuditBlocks(); const missingAudits = await this.$getMissingAuditBlocks();
logger.debug(`Fetching missing audit data for ${missingAudits.length} blocks from trusted servers`, 'Replication'); logger.debug(`Fetching missing audit data for ${missingAudits.length} blocks from trusted servers`, 'Replication');
let totalSynced = 0; let totalSynced = 0;
let totalMissed = 0; let totalMissed = 0;
let loggerTimer = Date.now(); let loggerTimer = Date.now();
// process missing audits in batches of // process missing audits in batches of BATCH_SIZE
for (let i = 0; i < missingAudits.length; i += BATCH_SIZE) { for (let i = 0; i < missingAudits.length; i += BATCH_SIZE) {
const slice = missingAudits.slice(i, i + BATCH_SIZE); const slice = missingAudits.slice(i, i + BATCH_SIZE);
const results = await Promise.all(slice.map(hash => this.$syncAudit(hash))); const results = await Promise.all(slice.map(hash => this.$syncAudit(hash)));
@ -109,9 +109,11 @@ class AuditReplication {
version: 1, version: 1,
}); });
await blocksAuditsRepository.$saveAudit({ await blocksAuditsRepository.$saveAudit({
version: auditSummary.version || 0,
hash: blockHash, hash: blockHash,
height: auditSummary.height, height: auditSummary.height,
time: auditSummary.timestamp || auditSummary.time, time: auditSummary.timestamp || auditSummary.time,
unseenTxs: auditSummary.unseenTxs || [],
missingTxs: auditSummary.missingTxs || [], missingTxs: auditSummary.missingTxs || [],
addedTxs: auditSummary.addedTxs || [], addedTxs: auditSummary.addedTxs || [],
prioritizedTxs: auditSummary.prioritizedTxs || [], prioritizedTxs: auditSummary.prioritizedTxs || [],

View File

@ -192,6 +192,7 @@ class AccelerationRepository {
} }
} }
// modifies block transactions
public async $indexAccelerationsForBlock(block: BlockExtended, accelerations: Acceleration[], transactions: MempoolTransactionExtended[]): Promise<void> { public async $indexAccelerationsForBlock(block: BlockExtended, accelerations: Acceleration[], transactions: MempoolTransactionExtended[]): Promise<void> {
const blockTxs: { [txid: string]: MempoolTransactionExtended } = {}; const blockTxs: { [txid: string]: MempoolTransactionExtended } = {};
for (const tx of transactions) { for (const tx of transactions) {

View File

@ -1,13 +1,24 @@
import blocks from '../api/blocks';
import DB from '../database'; import DB from '../database';
import logger from '../logger'; import logger from '../logger';
import { BlockAudit, AuditScore, TransactionAudit } from '../mempool.interfaces'; import bitcoinApi from '../api/bitcoin/bitcoin-api-factory';
import { BlockAudit, AuditScore, TransactionAudit, TransactionStripped } from '../mempool.interfaces';
interface MigrationAudit {
version: number,
height: number,
id: string,
timestamp: number,
prioritizedTxs: string[],
acceleratedTxs: string[],
template: TransactionStripped[],
transactions: TransactionStripped[],
}
class BlocksAuditRepositories { class BlocksAuditRepositories {
public async $saveAudit(audit: BlockAudit): Promise<void> { public async $saveAudit(audit: BlockAudit): Promise<void> {
try { try {
await DB.query(`INSERT INTO blocks_audits(time, height, hash, missing_txs, added_txs, prioritized_txs, fresh_txs, sigop_txs, fullrbf_txs, accelerated_txs, match_rate, expected_fees, expected_weight) await DB.query(`INSERT INTO blocks_audits(version, time, height, hash, unseen_txs, missing_txs, added_txs, prioritized_txs, fresh_txs, sigop_txs, fullrbf_txs, accelerated_txs, match_rate, expected_fees, expected_weight)
VALUE (FROM_UNIXTIME(?), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [audit.time, audit.height, audit.hash, JSON.stringify(audit.missingTxs), VALUE (?, FROM_UNIXTIME(?), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [audit.version, audit.time, audit.height, audit.hash, JSON.stringify(audit.unseenTxs), JSON.stringify(audit.missingTxs),
JSON.stringify(audit.addedTxs), JSON.stringify(audit.prioritizedTxs), JSON.stringify(audit.freshTxs), JSON.stringify(audit.sigopTxs), JSON.stringify(audit.fullrbfTxs), JSON.stringify(audit.acceleratedTxs), audit.matchRate, audit.expectedFees, audit.expectedWeight]); JSON.stringify(audit.addedTxs), JSON.stringify(audit.prioritizedTxs), JSON.stringify(audit.freshTxs), JSON.stringify(audit.sigopTxs), JSON.stringify(audit.fullrbfTxs), JSON.stringify(audit.acceleratedTxs), audit.matchRate, audit.expectedFees, audit.expectedWeight]);
} catch (e: any) { } catch (e: any) {
if (e.errno === 1062) { // ER_DUP_ENTRY - This scenario is possible upon node backend restart if (e.errno === 1062) { // ER_DUP_ENTRY - This scenario is possible upon node backend restart
@ -62,24 +73,30 @@ class BlocksAuditRepositories {
public async $getBlockAudit(hash: string): Promise<BlockAudit | null> { public async $getBlockAudit(hash: string): Promise<BlockAudit | null> {
try { try {
const [rows]: any[] = await DB.query( const [rows]: any[] = await DB.query(
`SELECT blocks_audits.height, blocks_audits.hash as id, UNIX_TIMESTAMP(blocks_audits.time) as timestamp, `SELECT
template, blocks_audits.version,
missing_txs as missingTxs, blocks_audits.height,
added_txs as addedTxs, blocks_audits.hash as id,
prioritized_txs as prioritizedTxs, UNIX_TIMESTAMP(blocks_audits.time) as timestamp,
fresh_txs as freshTxs, template,
sigop_txs as sigopTxs, unseen_txs as unseenTxs,
fullrbf_txs as fullrbfTxs, missing_txs as missingTxs,
accelerated_txs as acceleratedTxs, added_txs as addedTxs,
match_rate as matchRate, prioritized_txs as prioritizedTxs,
expected_fees as expectedFees, fresh_txs as freshTxs,
expected_weight as expectedWeight sigop_txs as sigopTxs,
fullrbf_txs as fullrbfTxs,
accelerated_txs as acceleratedTxs,
match_rate as matchRate,
expected_fees as expectedFees,
expected_weight as expectedWeight
FROM blocks_audits FROM blocks_audits
JOIN blocks_templates ON blocks_templates.id = blocks_audits.hash JOIN blocks_templates ON blocks_templates.id = blocks_audits.hash
WHERE blocks_audits.hash = ? WHERE blocks_audits.hash = ?
`, [hash]); `, [hash]);
if (rows.length) { if (rows.length) {
rows[0].unseenTxs = JSON.parse(rows[0].unseenTxs);
rows[0].missingTxs = JSON.parse(rows[0].missingTxs); rows[0].missingTxs = JSON.parse(rows[0].missingTxs);
rows[0].addedTxs = JSON.parse(rows[0].addedTxs); rows[0].addedTxs = JSON.parse(rows[0].addedTxs);
rows[0].prioritizedTxs = JSON.parse(rows[0].prioritizedTxs); rows[0].prioritizedTxs = JSON.parse(rows[0].prioritizedTxs);
@ -101,7 +118,7 @@ class BlocksAuditRepositories {
public async $getBlockTxAudit(hash: string, txid: string): Promise<TransactionAudit | null> { public async $getBlockTxAudit(hash: string, txid: string): Promise<TransactionAudit | null> {
try { try {
const blockAudit = await this.$getBlockAudit(hash); const blockAudit = await this.$getBlockAudit(hash);
if (blockAudit) { if (blockAudit) {
const isAdded = blockAudit.addedTxs.includes(txid); const isAdded = blockAudit.addedTxs.includes(txid);
const isPrioritized = blockAudit.prioritizedTxs.includes(txid); const isPrioritized = blockAudit.prioritizedTxs.includes(txid);
@ -115,16 +132,17 @@ class BlocksAuditRepositories {
firstSeen = tx.time; firstSeen = tx.time;
} }
}); });
const wasSeen = blockAudit.version === 1 ? !blockAudit.unseenTxs.includes(txid) : (isExpected || isPrioritized || isAccelerated);
return { return {
seen: isExpected || isPrioritized || isAccelerated, seen: wasSeen,
expected: isExpected, expected: isExpected,
added: isAdded, added: isAdded && (blockAudit.version === 0 || !wasSeen),
prioritized: isPrioritized, prioritized: isPrioritized,
conflict: isConflict, conflict: isConflict,
accelerated: isAccelerated, accelerated: isAccelerated,
firstSeen, firstSeen,
} };
} }
return null; return null;
} catch (e: any) { } catch (e: any) {
@ -186,6 +204,96 @@ class BlocksAuditRepositories {
throw e; throw e;
} }
} }
/**
* [INDEXING] Migrate audits from v0 to v1
*/
public async $migrateAuditsV0toV1(): Promise<void> {
try {
let done = false;
let processed = 0;
let lastHeight;
while (!done) {
const [toMigrate]: MigrationAudit[][] = await DB.query(
`SELECT
blocks_audits.height as height,
blocks_audits.hash as id,
UNIX_TIMESTAMP(blocks_audits.time) as timestamp,
blocks_summaries.transactions as transactions,
blocks_templates.template as template,
blocks_audits.prioritized_txs as prioritizedTxs,
blocks_audits.accelerated_txs as acceleratedTxs
FROM blocks_audits
JOIN blocks_summaries ON blocks_summaries.id = blocks_audits.hash
JOIN blocks_templates ON blocks_templates.id = blocks_audits.hash
WHERE blocks_audits.version = 0
AND blocks_summaries.version = 2
ORDER BY blocks_audits.height DESC
LIMIT 100
`) as any[];
if (toMigrate.length <= 0 || lastHeight === toMigrate[0].height) {
done = true;
break;
}
lastHeight = toMigrate[0].height;
logger.info(`migrating ${toMigrate.length} audits to version 1`);
for (const audit of toMigrate) {
// unpack JSON-serialized transaction lists
audit.transactions = JSON.parse((audit.transactions as any as string) || '[]');
audit.template = JSON.parse((audit.template as any as string) || '[]');
// we know transactions in the template, or marked "prioritized" or "accelerated"
// were seen in our mempool before the block was mined.
const isSeen = new Set<string>();
for (const tx of audit.template) {
isSeen.add(tx.txid);
}
for (const txid of audit.prioritizedTxs) {
isSeen.add(txid);
}
for (const txid of audit.acceleratedTxs) {
isSeen.add(txid);
}
const unseenTxs = audit.transactions.slice(0).map(tx => tx.txid).filter(txid => !isSeen.has(txid));
// identify "prioritized" transactions
const prioritizedTxs: string[] = [];
let lastEffectiveRate = 0;
// Iterate over the mined template from bottom to top (excluding the coinbase)
// Transactions should appear in ascending order of mining priority.
for (let i = audit.transactions.length - 1; i > 0; i--) {
const blockTx = audit.transactions[i];
// If a tx has a lower in-band effective fee rate than the previous tx,
// it must have been prioritized out-of-band (in order to have a higher mining priority)
// so exclude from the analysis.
if ((blockTx.rate || 0) < lastEffectiveRate) {
prioritizedTxs.push(blockTx.txid);
} else {
lastEffectiveRate = blockTx.rate || 0;
}
}
// Update audit in the database
await DB.query(`
UPDATE blocks_audits SET
version = ?,
unseen_txs = ?,
prioritized_txs = ?
WHERE hash = ?
`, [1, JSON.stringify(unseenTxs), JSON.stringify(prioritizedTxs), audit.id]);
}
processed += toMigrate.length;
}
logger.info(`migrated ${processed} audits to version 1`);
} catch (e: any) {
logger.err(`Error while migrating audits from v0 to v1. Will try again later. Reason: ` + (e instanceof Error ? e.message : e));
}
}
} }
export default new BlocksAuditRepositories(); export default new BlocksAuditRepositories();

View File

@ -14,6 +14,7 @@ import chainTips from '../api/chain-tips';
import blocks from '../api/blocks'; import blocks from '../api/blocks';
import BlocksAuditsRepository from './BlocksAuditsRepository'; import BlocksAuditsRepository from './BlocksAuditsRepository';
import transactionUtils from '../api/transaction-utils'; import transactionUtils from '../api/transaction-utils';
import { parseDATUMTemplateCreator } from '../utils/bitcoin-script';
interface DatabaseBlock { interface DatabaseBlock {
id: string; id: string;
@ -56,6 +57,7 @@ interface DatabaseBlock {
utxoSetChange: number; utxoSetChange: number;
utxoSetSize: number; utxoSetSize: number;
totalInputAmt: number; totalInputAmt: number;
firstSeen: number;
} }
const BLOCK_DB_FIELDS = ` const BLOCK_DB_FIELDS = `
@ -98,7 +100,8 @@ const BLOCK_DB_FIELDS = `
blocks.header, blocks.header,
blocks.utxoset_change AS utxoSetChange, blocks.utxoset_change AS utxoSetChange,
blocks.utxoset_size AS utxoSetSize, blocks.utxoset_size AS utxoSetSize,
blocks.total_input_amt AS totalInputAmt blocks.total_input_amt AS totalInputAmt,
UNIX_TIMESTAMP(blocks.first_seen) AS firstSeen
`; `;
class BlocksRepository { class BlocksRepository {
@ -498,7 +501,7 @@ class BlocksRepository {
} }
query += ` ORDER BY height DESC query += ` ORDER BY height DESC
LIMIT 10`; LIMIT 100`;
try { try {
const [rows]: any[] = await DB.query(query, params); const [rows]: any[] = await DB.query(query, params);
@ -1020,6 +1023,24 @@ class BlocksRepository {
} }
} }
/**
* Save block first seen time
*
* @param id
*/
public async $saveFirstSeenTime(id: string, firstSeen: number): Promise<void> {
try {
await DB.query(`
UPDATE blocks SET first_seen = FROM_UNIXTIME(?)
WHERE hash = ?`,
[firstSeen, id]
);
} catch (e) {
logger.err(`Cannot update block first seen time. Reason: ` + (e instanceof Error ? e.message : e));
throw e;
}
}
/** /**
* Convert a mysql row block into a BlockExtended. Note that you * Convert a mysql row block into a BlockExtended. Note that you
* must provide the correct field into dbBlk object param * must provide the correct field into dbBlk object param
@ -1054,6 +1075,7 @@ class BlocksRepository {
id: dbBlk.poolId, id: dbBlk.poolId,
name: dbBlk.poolName, name: dbBlk.poolName,
slug: dbBlk.poolSlug, slug: dbBlk.poolSlug,
minerNames: null,
}; };
extras.avgFee = dbBlk.avgFee; extras.avgFee = dbBlk.avgFee;
extras.avgFeeRate = dbBlk.avgFeeRate; extras.avgFeeRate = dbBlk.avgFeeRate;
@ -1076,6 +1098,7 @@ class BlocksRepository {
extras.utxoSetSize = dbBlk.utxoSetSize; extras.utxoSetSize = dbBlk.utxoSetSize;
extras.totalInputAmt = dbBlk.totalInputAmt; extras.totalInputAmt = dbBlk.totalInputAmt;
extras.virtualSize = dbBlk.weight / 4.0; extras.virtualSize = dbBlk.weight / 4.0;
extras.firstSeen = dbBlk.firstSeen;
// Re-org can happen after indexing so we need to always get the // Re-org can happen after indexing so we need to always get the
// latest state from core // latest state from core
@ -1106,7 +1129,7 @@ class BlocksRepository {
let summaryVersion = 0; let summaryVersion = 0;
if (config.MEMPOOL.BACKEND === 'esplora') { if (config.MEMPOOL.BACKEND === 'esplora') {
const txs = (await bitcoinApi.$getTxsForBlock(dbBlk.id)).map(tx => transactionUtils.extendTransaction(tx)); const txs = (await bitcoinApi.$getTxsForBlock(dbBlk.id)).map(tx => transactionUtils.extendTransaction(tx));
summary = blocks.summarizeBlockTransactions(dbBlk.id, txs); summary = blocks.summarizeBlockTransactions(dbBlk.id, dbBlk.height, txs);
summaryVersion = 1; summaryVersion = 1;
} else { } else {
// Call Core RPC // Call Core RPC
@ -1123,6 +1146,10 @@ class BlocksRepository {
} }
} }
if (extras.pool.name === 'OCEAN') {
extras.pool.minerNames = parseDATUMTemplateCreator(extras.coinbaseRaw);
}
blk.extras = <BlockExtension>extras; blk.extras = <BlockExtension>extras;
return <BlockExtended>blk; return <BlockExtended>blk;
} }

View File

@ -83,6 +83,7 @@ module.exports = {
signRawTransaction: 'signrawtransaction', // bitcoind v0.7.0+ signRawTransaction: 'signrawtransaction', // bitcoind v0.7.0+
stop: 'stop', stop: 'stop',
submitBlock: 'submitblock', // bitcoind v0.7.0+ submitBlock: 'submitblock', // bitcoind v0.7.0+
submitPackage: 'submitpackage',
validateAddress: 'validateaddress', validateAddress: 'validateaddress',
verifyChain: 'verifychain', // bitcoind v0.9.0+ verifyChain: 'verifychain', // bitcoind v0.9.0+
verifyMessage: 'verifymessage', verifyMessage: 'verifymessage',

View File

@ -6,16 +6,30 @@ import backendInfo from '../api/backend-info';
import logger from '../logger'; import logger from '../logger';
import { SocksProxyAgent } from 'socks-proxy-agent'; import { SocksProxyAgent } from 'socks-proxy-agent';
import * as https from 'https'; import * as https from 'https';
import { Common } from '../api/common';
/** /**
* Maintain the most recent version of pools-v2.json * Maintain the most recent version of pools-v2.json
*/ */
class PoolsUpdater { class PoolsUpdater {
tag = 'PoolsUpdater';
lastRun: number = 0; lastRun: number = 0;
currentSha: string | null = null; currentSha: string | null = null;
poolsUrl: string = config.MEMPOOL.POOLS_JSON_URL; poolsUrl: string = config.MEMPOOL.POOLS_JSON_URL;
treeUrl: string = config.MEMPOOL.POOLS_JSON_TREE_URL; treeUrl: string = config.MEMPOOL.POOLS_JSON_TREE_URL;
public async $startService(): Promise<void> {
while ('Bitcoin is still alive') {
try {
await this.updatePoolsJson();
} catch (e: any) {
logger.info(`Exception ${e} in PoolsUpdater::$startService. Code: ${e.code}. Message: ${e.message}`, this.tag);
}
await Common.sleep$(10000);
}
}
public async updatePoolsJson(): Promise<void> { public async updatePoolsJson(): Promise<void> {
if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK) === false || if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK) === false ||
config.MEMPOOL.ENABLED === false config.MEMPOOL.ENABLED === false
@ -23,11 +37,8 @@ class PoolsUpdater {
return; return;
} }
const oneWeek = 604800;
const oneDay = 86400;
const now = new Date().getTime() / 1000; const now = new Date().getTime() / 1000;
if (now - this.lastRun < oneWeek) { // Execute the PoolsUpdate only once a week, or upon restart if (now - this.lastRun < config.MEMPOOL.POOLS_UPDATE_DELAY) { // Execute the PoolsUpdate only once a week, or upon restart
return; return;
} }
@ -43,7 +54,7 @@ class PoolsUpdater {
this.currentSha = await this.getShaFromDb(); this.currentSha = await this.getShaFromDb();
} }
logger.debug(`pools-v2.json sha | Current: ${this.currentSha} | Github: ${githubSha}`); logger.debug(`pools-v2.json sha | Current: ${this.currentSha} | Github: ${githubSha}`, this.tag);
if (this.currentSha !== null && this.currentSha === githubSha) { if (this.currentSha !== null && this.currentSha === githubSha) {
return; return;
} }
@ -53,16 +64,16 @@ class PoolsUpdater {
config.MEMPOOL.AUTOMATIC_POOLS_UPDATE !== true && // Automatic pools update is disabled config.MEMPOOL.AUTOMATIC_POOLS_UPDATE !== true && // Automatic pools update is disabled
!process.env.npm_config_update_pools // We're not manually updating mining pool !process.env.npm_config_update_pools // We're not manually updating mining pool
) { ) {
logger.warn(`Updated mining pools data is available (${githubSha}) but AUTOMATIC_POOLS_UPDATE is disabled`); logger.warn(`Updated mining pools data is available (${githubSha}) but AUTOMATIC_POOLS_UPDATE is disabled`, this.tag);
logger.info(`You can update your mining pools using the --update-pools command flag. You may want to clear your nginx cache as well if applicable`); logger.info(`You can update your mining pools using the --update-pools command flag. You may want to clear your nginx cache as well if applicable`, this.tag);
return; return;
} }
const network = config.SOCKS5PROXY.ENABLED ? 'tor' : 'clearnet'; const network = config.SOCKS5PROXY.ENABLED ? 'tor' : 'clearnet';
if (this.currentSha === null) { if (this.currentSha === null) {
logger.info(`Downloading pools-v2.json for the first time from ${this.poolsUrl} over ${network}`, logger.tags.mining); logger.info(`Downloading pools-v2.json for the first time from ${this.poolsUrl} over ${network}`, this.tag);
} else { } else {
logger.warn(`pools-v2.json is outdated, fetching latest from ${this.poolsUrl} over ${network}`, logger.tags.mining); logger.warn(`pools-v2.json is outdated, fetching latest from ${this.poolsUrl} over ${network}`, this.tag);
} }
const poolsJson = await this.query(this.poolsUrl); const poolsJson = await this.query(this.poolsUrl);
if (poolsJson === undefined) { if (poolsJson === undefined) {
@ -71,7 +82,7 @@ class PoolsUpdater {
poolsParser.setMiningPools(poolsJson); poolsParser.setMiningPools(poolsJson);
if (config.DATABASE.ENABLED === false) { // Don't run db operations if (config.DATABASE.ENABLED === false) { // Don't run db operations
logger.info(`Mining pools-v2.json (${githubSha}) import completed (no database)`); logger.info(`Mining pools-v2.json (${githubSha}) import completed (no database)`, this.tag);
return; return;
} }
@ -81,14 +92,14 @@ class PoolsUpdater {
await this.updateDBSha(githubSha); await this.updateDBSha(githubSha);
await DB.query('COMMIT;'); await DB.query('COMMIT;');
} catch (e) { } catch (e) {
logger.err(`Could not migrate mining pools, rolling back. Exception: ${JSON.stringify(e)}`, logger.tags.mining); logger.err(`Could not migrate mining pools, rolling back. Exception: ${JSON.stringify(e)}`, this.tag);
await DB.query('ROLLBACK;'); await DB.query('ROLLBACK;');
} }
logger.info(`Mining pools-v2.json (${githubSha}) import completed`); logger.info(`Mining pools-v2.json (${githubSha}) import completed`, this.tag);
} catch (e) { } catch (e) {
this.lastRun = now - (oneWeek - oneDay); // Try again in 24h instead of waiting next week this.lastRun = now - 600; // Try again in 10 minutes
logger.err(`PoolsUpdater failed. Will try again in 24h. Exception: ${JSON.stringify(e)}`, logger.tags.mining); logger.err(`PoolsUpdater failed. Will try again in 10 minutes. Exception: ${JSON.stringify(e)}`, this.tag);
} }
} }
@ -102,7 +113,7 @@ class PoolsUpdater {
await DB.query('DELETE FROM state where name="pools_json_sha"'); await DB.query('DELETE FROM state where name="pools_json_sha"');
await DB.query(`INSERT INTO state VALUES('pools_json_sha', NULL, '${githubSha}')`); await DB.query(`INSERT INTO state VALUES('pools_json_sha', NULL, '${githubSha}')`);
} catch (e) { } catch (e) {
logger.err('Cannot save github pools-v2.json sha into the db. Reason: ' + (e instanceof Error ? e.message : e), logger.tags.mining); logger.err('Cannot save github pools-v2.json sha into the db. Reason: ' + (e instanceof Error ? e.message : e), this.tag);
} }
} }
} }
@ -115,7 +126,7 @@ class PoolsUpdater {
const [rows]: any[] = await DB.query('SELECT string FROM state WHERE name="pools_json_sha"'); const [rows]: any[] = await DB.query('SELECT string FROM state WHERE name="pools_json_sha"');
return (rows.length > 0 ? rows[0].string : null); return (rows.length > 0 ? rows[0].string : null);
} catch (e) { } catch (e) {
logger.err('Cannot fetch pools-v2.json sha from db. Reason: ' + (e instanceof Error ? e.message : e), logger.tags.mining); logger.err('Cannot fetch pools-v2.json sha from db. Reason: ' + (e instanceof Error ? e.message : e), this.tag);
return null; return null;
} }
} }
@ -134,7 +145,7 @@ class PoolsUpdater {
} }
} }
logger.err(`Cannot find "pools-v2.json" in git tree (${this.treeUrl})`, logger.tags.mining); logger.err(`Cannot find "pools-v2.json" in git tree (${this.treeUrl})`, this.tag);
return null; return null;
} }
@ -186,7 +197,7 @@ class PoolsUpdater {
} }
return data.data; return data.data;
} catch (e) { } catch (e) {
logger.err('Could not connect to Github. Reason: ' + (e instanceof Error ? e.message : e)); logger.err('Could not connect to Github. Reason: ' + (e instanceof Error ? e.message : e), this.tag);
retry++; retry++;
} }
await setDelay(config.MEMPOOL.EXTERNAL_RETRY_INTERVAL); await setDelay(config.MEMPOOL.EXTERNAL_RETRY_INTERVAL);

9
backend/src/utils/api.ts Normal file
View File

@ -0,0 +1,9 @@
import { Request, Response } from 'express';
export function handleError(req: Request, res: Response, statusCode: number, errorMessage: string | unknown): void {
if (req.accepts('json')) {
res.status(statusCode).json({ error: errorMessage });
} else {
res.status(statusCode).send(errorMessage);
}
}

View File

@ -158,7 +158,7 @@ export function parseMultisigScript(script: string): void | { m: number, n: numb
if (!opN) { if (!opN) {
return; return;
} }
if (!opN.startsWith('OP_PUSHNUM_')) { if (opN !== 'OP_0' && !opN.startsWith('OP_PUSHNUM_')) {
return; return;
} }
const n = parseInt(opN.match(/[0-9]+/)?.[0] || '', 10); const n = parseInt(opN.match(/[0-9]+/)?.[0] || '', 10);
@ -178,7 +178,7 @@ export function parseMultisigScript(script: string): void | { m: number, n: numb
if (!opM) { if (!opM) {
return; return;
} }
if (!opM.startsWith('OP_PUSHNUM_')) { if (opM !== 'OP_0' && !opM.startsWith('OP_PUSHNUM_')) {
return; return;
} }
const m = parseInt(opM.match(/[0-9]+/)?.[0] || '', 10); const m = parseInt(opM.match(/[0-9]+/)?.[0] || '', 10);
@ -200,4 +200,28 @@ export function getVarIntLength(n: number): number {
} else { } else {
return 9; return 9;
} }
}
/** Extracts miner names from a DATUM coinbase transaction */
export function parseDATUMTemplateCreator(coinbaseRaw: string): string[] | null {
let bytes: number[] = [];
for (let c = 0; c < coinbaseRaw.length; c += 2) {
bytes.push(parseInt(coinbaseRaw.slice(c, c + 2), 16));
}
// Skip block height
let tagLengthByte = 1 + bytes[0];
let tagsLength = bytes[tagLengthByte];
if (tagsLength == 0x4c) {
tagLengthByte += 1;
tagsLength = bytes[tagLengthByte];
}
const tagStart = tagLengthByte + 1;
const tags = bytes.slice(tagStart, tagStart + tagsLength);
let tagString = String.fromCharCode(...tags);
tagString = tagString.replace('\x00', '');
return tagString.split('\x0f').map((name) => name.replace(/[^a-zA-Z0-9 ]/g, ''));
} }

View File

@ -0,0 +1,58 @@
import * as fs from 'fs';
import logger from '../logger';
import config from '../config';
function readFile(filePath: string, bufferSize?: number): string[] {
const fileSize = fs.statSync(filePath).size;
const chunkSize = bufferSize || fileSize;
const fileDescriptor = fs.openSync(filePath, 'r');
const buffer = Buffer.alloc(chunkSize);
fs.readSync(fileDescriptor, buffer, 0, chunkSize, fileSize - chunkSize);
fs.closeSync(fileDescriptor);
const lines = buffer.toString('utf8', 0, chunkSize).split('\n');
return lines;
}
function extractDateFromLogLine(line: string): number | undefined {
// Extract time from log: "2021-08-31T12:34:56Z" or "2021-08-31T12:34:56.123456Z"
const dateMatch = line.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{6})?Z/);
if (!dateMatch) {
return undefined;
}
const dateStr = dateMatch[0];
const date = new Date(dateStr);
let timestamp = Math.floor(date.getTime() / 1000); // Remove decimal (microseconds are added later)
const timePart = dateStr.split('T')[1];
const microseconds = timePart.split('.')[1] || '';
if (!microseconds) {
return timestamp;
}
return parseFloat(timestamp + '.' + microseconds);
}
export function getRecentFirstSeen(hash: string): number | undefined {
const debugLogPath = config.CORE_RPC.DEBUG_LOG_PATH;
if (debugLogPath) {
try {
// Read the last few lines of debug.log
const lines = readFile(debugLogPath, 2048);
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i];
if (line && line.includes(`Saw new header hash=${hash}`)) {
return extractDateFromLogLine(line);
}
}
} catch (e) {
logger.err(`Cannot parse block first seen time from Core logs. Reason: ` + (e instanceof Error ? e.message : e));
}
}
return undefined;
}

View File

@ -109,6 +109,7 @@ Below we list all settings from `mempool-config.json` and the corresponding over
"AUTOMATIC_POOLS_UPDATE": false, "AUTOMATIC_POOLS_UPDATE": false,
"POOLS_JSON_URL": "https://raw.githubusercontent.com/mempool/mining-pools/master/pools-v2.json", "POOLS_JSON_URL": "https://raw.githubusercontent.com/mempool/mining-pools/master/pools-v2.json",
"POOLS_JSON_TREE_URL": "https://api.github.com/repos/mempool/mining-pools/git/trees/master", "POOLS_JSON_TREE_URL": "https://api.github.com/repos/mempool/mining-pools/git/trees/master",
"POOLS_UPDATE_DELAY": 604800,
"CPFP_INDEXING": false, "CPFP_INDEXING": false,
"MAX_BLOCKS_BULK_QUERY": 0, "MAX_BLOCKS_BULK_QUERY": 0,
"DISK_CACHE_BLOCK_INTERVAL": 6, "DISK_CACHE_BLOCK_INTERVAL": 6,
@ -140,6 +141,7 @@ Corresponding `docker-compose.yml` overrides:
MEMPOOL_AUTOMATIC_POOLS_UPDATE: "" MEMPOOL_AUTOMATIC_POOLS_UPDATE: ""
MEMPOOL_POOLS_JSON_URL: "" MEMPOOL_POOLS_JSON_URL: ""
MEMPOOL_POOLS_JSON_TREE_URL: "" MEMPOOL_POOLS_JSON_TREE_URL: ""
MEMPOOL_POOLS_UPDATE_DELAY: ""
MEMPOOL_CPFP_INDEXING: "" MEMPOOL_CPFP_INDEXING: ""
MEMPOOL_MAX_BLOCKS_BULK_QUERY: "" MEMPOOL_MAX_BLOCKS_BULK_QUERY: ""
MEMPOOL_DISK_CACHE_BLOCK_INTERVAL: "" MEMPOOL_DISK_CACHE_BLOCK_INTERVAL: ""

View File

@ -36,6 +36,7 @@
"ALLOW_UNREACHABLE": __MEMPOOL_ALLOW_UNREACHABLE__, "ALLOW_UNREACHABLE": __MEMPOOL_ALLOW_UNREACHABLE__,
"POOLS_JSON_TREE_URL": "__MEMPOOL_POOLS_JSON_TREE_URL__", "POOLS_JSON_TREE_URL": "__MEMPOOL_POOLS_JSON_TREE_URL__",
"POOLS_JSON_URL": "__MEMPOOL_POOLS_JSON_URL__", "POOLS_JSON_URL": "__MEMPOOL_POOLS_JSON_URL__",
"POOLS_UPDATE_DELAY": __MEMPOOL_POOLS_UPDATE_DELAY__,
"PRICE_UPDATES_PER_HOUR": __MEMPOOL_PRICE_UPDATES_PER_HOUR__, "PRICE_UPDATES_PER_HOUR": __MEMPOOL_PRICE_UPDATES_PER_HOUR__,
"MAX_TRACKED_ADDRESSES": __MEMPOOL_MAX_TRACKED_ADDRESSES__ "MAX_TRACKED_ADDRESSES": __MEMPOOL_MAX_TRACKED_ADDRESSES__
}, },
@ -46,7 +47,8 @@
"PASSWORD": "__CORE_RPC_PASSWORD__", "PASSWORD": "__CORE_RPC_PASSWORD__",
"TIMEOUT": __CORE_RPC_TIMEOUT__, "TIMEOUT": __CORE_RPC_TIMEOUT__,
"COOKIE": __CORE_RPC_COOKIE__, "COOKIE": __CORE_RPC_COOKIE__,
"COOKIE_PATH": "__CORE_RPC_COOKIE_PATH__" "COOKIE_PATH": "__CORE_RPC_COOKIE_PATH__",
"DEBUG_LOG_PATH": "__CORE_RPC_DEBUG_LOG_PATH__"
}, },
"ELECTRUM": { "ELECTRUM": {
"HOST": "__ELECTRUM_HOST__", "HOST": "__ELECTRUM_HOST__",

View File

@ -29,8 +29,9 @@ __MEMPOOL_STDOUT_LOG_MIN_PRIORITY__=${MEMPOOL_STDOUT_LOG_MIN_PRIORITY:=info}
__MEMPOOL_AUTOMATIC_POOLS_UPDATE__=${MEMPOOL_AUTOMATIC_POOLS_UPDATE:=false} __MEMPOOL_AUTOMATIC_POOLS_UPDATE__=${MEMPOOL_AUTOMATIC_POOLS_UPDATE:=false}
__MEMPOOL_POOLS_JSON_URL__=${MEMPOOL_POOLS_JSON_URL:=https://raw.githubusercontent.com/mempool/mining-pools/master/pools-v2.json} __MEMPOOL_POOLS_JSON_URL__=${MEMPOOL_POOLS_JSON_URL:=https://raw.githubusercontent.com/mempool/mining-pools/master/pools-v2.json}
__MEMPOOL_POOLS_JSON_TREE_URL__=${MEMPOOL_POOLS_JSON_TREE_URL:=https://api.github.com/repos/mempool/mining-pools/git/trees/master} __MEMPOOL_POOLS_JSON_TREE_URL__=${MEMPOOL_POOLS_JSON_TREE_URL:=https://api.github.com/repos/mempool/mining-pools/git/trees/master}
__MEMPOOL_POOLS_UPDATE_DELAY__=${MEMPOOL_POOLS_UPDATE_DELAY:=604800}
__MEMPOOL_AUDIT__=${MEMPOOL_AUDIT:=false} __MEMPOOL_AUDIT__=${MEMPOOL_AUDIT:=false}
__MEMPOOL_RUST_GBT__=${MEMPOOL_RUST_GBT:=false} __MEMPOOL_RUST_GBT__=${MEMPOOL_RUST_GBT:=true}
__MEMPOOL_LIMIT_GBT__=${MEMPOOL_LIMIT_GBT:=false} __MEMPOOL_LIMIT_GBT__=${MEMPOOL_LIMIT_GBT:=false}
__MEMPOOL_CPFP_INDEXING__=${MEMPOOL_CPFP_INDEXING:=false} __MEMPOOL_CPFP_INDEXING__=${MEMPOOL_CPFP_INDEXING:=false}
__MEMPOOL_MAX_BLOCKS_BULK_QUERY__=${MEMPOOL_MAX_BLOCKS_BULK_QUERY:=0} __MEMPOOL_MAX_BLOCKS_BULK_QUERY__=${MEMPOOL_MAX_BLOCKS_BULK_QUERY:=0}
@ -48,6 +49,7 @@ __CORE_RPC_PASSWORD__=${CORE_RPC_PASSWORD:=mempool}
__CORE_RPC_TIMEOUT__=${CORE_RPC_TIMEOUT:=60000} __CORE_RPC_TIMEOUT__=${CORE_RPC_TIMEOUT:=60000}
__CORE_RPC_COOKIE__=${CORE_RPC_COOKIE:=false} __CORE_RPC_COOKIE__=${CORE_RPC_COOKIE:=false}
__CORE_RPC_COOKIE_PATH__=${CORE_RPC_COOKIE_PATH:=""} __CORE_RPC_COOKIE_PATH__=${CORE_RPC_COOKIE_PATH:=""}
__CORE_RPC_DEBUG_LOG_PATH__=${CORE_RPC_DEBUG_LOG_PATH:=""}
# ELECTRUM # ELECTRUM
__ELECTRUM_HOST__=${ELECTRUM_HOST:=127.0.0.1} __ELECTRUM_HOST__=${ELECTRUM_HOST:=127.0.0.1}
@ -144,7 +146,7 @@ __REPLICATION_STATISTICS_START_TIME__=${REPLICATION_STATISTICS_START_TIME:=14819
__REPLICATION_SERVERS__=${REPLICATION_SERVERS:=[]} __REPLICATION_SERVERS__=${REPLICATION_SERVERS:=[]}
# MEMPOOL_SERVICES # MEMPOOL_SERVICES
__MEMPOOL_SERVICES_API__=${MEMPOOL_SERVICES_API:=""} __MEMPOOL_SERVICES_API__=${MEMPOOL_SERVICES_API:="https://mempool.space/api/v1/services"}
__MEMPOOL_SERVICES_ACCELERATIONS__=${MEMPOOL_SERVICES_ACCELERATIONS:=false} __MEMPOOL_SERVICES_ACCELERATIONS__=${MEMPOOL_SERVICES_ACCELERATIONS:=false}
# REDIS # REDIS
@ -187,6 +189,7 @@ sed -i "s!__MEMPOOL_STDOUT_LOG_MIN_PRIORITY__!${__MEMPOOL_STDOUT_LOG_MIN_PRIORIT
sed -i "s!__MEMPOOL_AUTOMATIC_POOLS_UPDATE__!${__MEMPOOL_AUTOMATIC_POOLS_UPDATE__}!g" mempool-config.json sed -i "s!__MEMPOOL_AUTOMATIC_POOLS_UPDATE__!${__MEMPOOL_AUTOMATIC_POOLS_UPDATE__}!g" mempool-config.json
sed -i "s!__MEMPOOL_POOLS_JSON_URL__!${__MEMPOOL_POOLS_JSON_URL__}!g" mempool-config.json sed -i "s!__MEMPOOL_POOLS_JSON_URL__!${__MEMPOOL_POOLS_JSON_URL__}!g" mempool-config.json
sed -i "s!__MEMPOOL_POOLS_JSON_TREE_URL__!${__MEMPOOL_POOLS_JSON_TREE_URL__}!g" mempool-config.json sed -i "s!__MEMPOOL_POOLS_JSON_TREE_URL__!${__MEMPOOL_POOLS_JSON_TREE_URL__}!g" mempool-config.json
sed -i "s!__MEMPOOL_POOLS_UPDATE_DELAY__!${__MEMPOOL_POOLS_UPDATE_DELAY__}!g" mempool-config.json
sed -i "s!__MEMPOOL_AUDIT__!${__MEMPOOL_AUDIT__}!g" mempool-config.json sed -i "s!__MEMPOOL_AUDIT__!${__MEMPOOL_AUDIT__}!g" mempool-config.json
sed -i "s!__MEMPOOL_RUST_GBT__!${__MEMPOOL_RUST_GBT__}!g" mempool-config.json sed -i "s!__MEMPOOL_RUST_GBT__!${__MEMPOOL_RUST_GBT__}!g" mempool-config.json
sed -i "s!__MEMPOOL_LIMIT_GBT__!${__MEMPOOL_LIMIT_GBT__}!g" mempool-config.json sed -i "s!__MEMPOOL_LIMIT_GBT__!${__MEMPOOL_LIMIT_GBT__}!g" mempool-config.json
@ -205,6 +208,7 @@ sed -i "s!__CORE_RPC_PASSWORD__!${__CORE_RPC_PASSWORD__}!g" mempool-config.json
sed -i "s!__CORE_RPC_TIMEOUT__!${__CORE_RPC_TIMEOUT__}!g" mempool-config.json sed -i "s!__CORE_RPC_TIMEOUT__!${__CORE_RPC_TIMEOUT__}!g" mempool-config.json
sed -i "s!__CORE_RPC_COOKIE__!${__CORE_RPC_COOKIE__}!g" mempool-config.json sed -i "s!__CORE_RPC_COOKIE__!${__CORE_RPC_COOKIE__}!g" mempool-config.json
sed -i "s!__CORE_RPC_COOKIE_PATH__!${__CORE_RPC_COOKIE_PATH__}!g" mempool-config.json sed -i "s!__CORE_RPC_COOKIE_PATH__!${__CORE_RPC_COOKIE_PATH__}!g" mempool-config.json
sed -i "s!__CORE_RPC_DEBUG_LOG_PATH__!${__CORE_RPC_DEBUG_LOG_PATH__}!g" mempool-config.json
sed -i "s!__ELECTRUM_HOST__!${__ELECTRUM_HOST__}!g" mempool-config.json sed -i "s!__ELECTRUM_HOST__!${__ELECTRUM_HOST__}!g" mempool-config.json
sed -i "s!__ELECTRUM_PORT__!${__ELECTRUM_PORT__}!g" mempool-config.json sed -i "s!__ELECTRUM_PORT__!${__ELECTRUM_PORT__}!g" mempool-config.json

View File

@ -41,7 +41,7 @@ __TESTNET_BLOCK_AUDIT_START_HEIGHT__=${TESTNET_BLOCK_AUDIT_START_HEIGHT:=0}
__SIGNET_BLOCK_AUDIT_START_HEIGHT__=${SIGNET_BLOCK_AUDIT_START_HEIGHT:=0} __SIGNET_BLOCK_AUDIT_START_HEIGHT__=${SIGNET_BLOCK_AUDIT_START_HEIGHT:=0}
__ACCELERATOR__=${ACCELERATOR:=false} __ACCELERATOR__=${ACCELERATOR:=false}
__ACCELERATOR_BUTTON__=${ACCELERATOR_BUTTON:=true} __ACCELERATOR_BUTTON__=${ACCELERATOR_BUTTON:=true}
__SERVICES_API__=${SERVICES_API:=false} __SERVICES_API__=${SERVICES_API:=https://mempool.space/api/v1/services}
__PUBLIC_ACCELERATIONS__=${PUBLIC_ACCELERATIONS:=false} __PUBLIC_ACCELERATIONS__=${PUBLIC_ACCELERATIONS:=false}
__HISTORICAL_PRICE__=${HISTORICAL_PRICE:=true} __HISTORICAL_PRICE__=${HISTORICAL_PRICE:=true}
__ADDITIONAL_CURRENCIES__=${ADDITIONAL_CURRENCIES:=false} __ADDITIONAL_CURRENCIES__=${ADDITIONAL_CURRENCIES:=false}

View File

@ -33,7 +33,7 @@ $ npm run config:defaults:liquid
### 3. Run the Frontend ### 3. Run the Frontend
_Make sure to use Node.js 16.10 and npm 7._ _Make sure to use Node.js 20.x and npm 9.x or newer._
Install project dependencies and run the frontend server: Install project dependencies and run the frontend server:
@ -70,7 +70,7 @@ Set up the [Mempool backend](../backend/) first, if you haven't already.
### 1. Build the Frontend ### 1. Build the Frontend
_Make sure to use Node.js 16.10 and npm 7._ _Make sure to use Node.js 20.x and npm 9.x or newer._
Build the frontend: Build the frontend:

View File

@ -54,6 +54,10 @@
"translation": "src/locale/messages.fr.xlf", "translation": "src/locale/messages.fr.xlf",
"baseHref": "/fr/" "baseHref": "/fr/"
}, },
"hr": {
"translation": "src/locale/messages.hr.xlf",
"baseHref": "/hr/"
},
"ja": { "ja": {
"translation": "src/locale/messages.ja.xlf", "translation": "src/locale/messages.ja.xlf",
"baseHref": "/ja/" "baseHref": "/ja/"

View File

@ -0,0 +1,51 @@
{
"theme": "contrast",
"enterprise": "meta",
"branding": {
"name": "metaplanet",
"title": "Metaplanet",
"site_id": 21,
"header_img": "/resources/metalogo.svg",
"footer_img": "/resources/metalogo.svg"
},
"dashboard": {
"widgets": [
{
"component": "fees",
"mobileOrder": 4
},
{
"component": "walletBalance",
"mobileOrder": 1,
"props": {
"wallet": "3350"
}
},
{
"component": "twitter",
"mobileOrder": 5,
"props": {
"handle": "Metaplanet_JP"
}
},
{
"component": "wallet",
"mobileOrder": 2,
"props": {
"wallet": "3350",
"period": "all"
}
},
{
"component": "blocks"
},
{
"component": "walletTransactions",
"mobileOrder": 3,
"props": {
"wallet": "3350"
}
}
]
}
}

View File

@ -543,16 +543,7 @@ describe('Mainnet', () => {
} }
}); });
cy.get('.alert').should('be.visible'); cy.get('.alert-replaced').should('be.visible');
cy.get('.alert').invoke('css', 'width').then((alertWidth) => {
cy.get('.container-xl > :nth-child(3)').invoke('css', 'width').should('equal', alertWidth);
});
cy.get('.btn-warning').then(getRectangle).then((rectA) => {
cy.get('.alert').then(getRectangle).then((rectB) => {
expect(areOverlapping(rectA, rectB), 'Confirmations box and RBF alert are overlapping').to.be.false;
});
});
}); });
it('shows RBF transactions properly (desktop)', () => { it('shows RBF transactions properly (desktop)', () => {

View File

@ -750,7 +750,7 @@
}, },
"backendInfo": { "backendInfo": {
"hostname": "node205.tk7.mempool.space", "hostname": "node205.tk7.mempool.space",
"version": "3.0.0-dev", "version": "3.1.0-dev",
"gitCommit": "abbc8a134", "gitCommit": "abbc8a134",
"lightning": false "lightning": false
}, },

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "mempool-frontend", "name": "mempool-frontend",
"version": "3.0.0-dev", "version": "3.1.0-dev",
"description": "Bitcoin mempool visualizer and blockchain explorer backend", "description": "Bitcoin mempool visualizer and blockchain explorer backend",
"license": "GNU Affero General Public License v3.0", "license": "GNU Affero General Public License v3.0",
"homepage": "https://mempool.space", "homepage": "https://mempool.space",
@ -92,10 +92,10 @@
"ngx-infinite-scroll": "^17.0.0", "ngx-infinite-scroll": "^17.0.0",
"qrcode": "1.5.1", "qrcode": "1.5.1",
"rxjs": "~7.8.1", "rxjs": "~7.8.1",
"esbuild": "^0.23.0", "esbuild": "^0.24.0",
"tinyify": "^4.0.0", "tinyify": "^4.0.0",
"tlite": "^0.1.9", "tlite": "^0.1.9",
"tslib": "~2.6.0", "tslib": "~2.8.0",
"zone.js": "~0.14.4" "zone.js": "~0.14.4"
}, },
"devDependencies": { "devDependencies": {
@ -105,7 +105,7 @@
"@typescript-eslint/eslint-plugin": "^7.4.0", "@typescript-eslint/eslint-plugin": "^7.4.0",
"@typescript-eslint/parser": "^7.4.0", "@typescript-eslint/parser": "^7.4.0",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"browser-sync": "^3.0.0", "browser-sync": "^3.0.3",
"http-proxy-middleware": "~2.0.6", "http-proxy-middleware": "~2.0.6",
"prettier": "^3.0.0", "prettier": "^3.0.0",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
@ -115,7 +115,7 @@
"optionalDependencies": { "optionalDependencies": {
"@cypress/schematic": "^2.5.0", "@cypress/schematic": "^2.5.0",
"@types/cypress": "^1.1.3", "@types/cypress": "^1.1.3",
"cypress": "^13.13.0", "cypress": "^13.15.0",
"cypress-fail-on-console-error": "~5.1.0", "cypress-fail-on-console-error": "~5.1.0",
"cypress-wait-until": "^2.0.1", "cypress-wait-until": "^2.0.1",
"mock-socket": "~9.3.1", "mock-socket": "~9.3.1",

View File

@ -1,14 +1,15 @@
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router'; import { Routes, RouterModule } from '@angular/router';
import { AppPreloadingStrategy } from './app.preloading-strategy' import { AppPreloadingStrategy } from '@app/app.preloading-strategy'
import { BlockViewComponent } from './components/block-view/block-view.component'; import { BlockViewComponent } from '@components/block-view/block-view.component';
import { EightBlocksComponent } from './components/eight-blocks/eight-blocks.component'; import { EightBlocksComponent } from '@components/eight-blocks/eight-blocks.component';
import { MempoolBlockViewComponent } from './components/mempool-block-view/mempool-block-view.component'; import { MempoolBlockViewComponent } from '@components/mempool-block-view/mempool-block-view.component';
import { ClockComponent } from './components/clock/clock.component'; import { ClockComponent } from '@components/clock/clock.component';
import { StatusViewComponent } from './components/status-view/status-view.component'; import { StatusViewComponent } from '@components/status-view/status-view.component';
import { AddressGroupComponent } from './components/address-group/address-group.component'; import { AddressGroupComponent } from '@components/address-group/address-group.component';
import { TrackerComponent } from './components/tracker/tracker.component'; import { TrackerComponent } from '@components/tracker/tracker.component';
import { AccelerateCheckout } from './components/accelerate-checkout/accelerate-checkout.component'; import { AccelerateCheckout } from '@components/accelerate-checkout/accelerate-checkout.component';
import { TrackerGuard } from '@app/route-guards';
const browserWindow = window || {}; const browserWindow = window || {};
// @ts-ignore // @ts-ignore
@ -21,16 +22,16 @@ let routes: Routes = [
{ {
path: '', path: '',
pathMatch: 'full', pathMatch: 'full',
loadChildren: () => import('./bitcoin-graphs.module').then(m => m.BitcoinGraphsModule), loadChildren: () => import('@app/bitcoin-graphs.module').then(m => m.BitcoinGraphsModule),
data: { preload: true }, data: { preload: true },
}, },
{ {
path: '', path: '',
loadChildren: () => import('./master-page.module').then(m => m.MasterPageModule), loadChildren: () => import('@app/master-page.module').then(m => m.MasterPageModule),
data: { preload: true }, data: { preload: true },
}, },
{ {
path: 'wallet', path: 'widget/wallet',
children: [], children: [],
component: AddressGroupComponent, component: AddressGroupComponent,
data: { data: {
@ -44,7 +45,7 @@ let routes: Routes = [
}, },
{ {
path: '', path: '',
loadChildren: () => import('./bitcoin-graphs.module').then(m => m.BitcoinGraphsModule), loadChildren: () => import('@app/bitcoin-graphs.module').then(m => m.BitcoinGraphsModule),
data: { preload: true }, data: { preload: true },
}, },
{ {
@ -59,12 +60,12 @@ let routes: Routes = [
{ {
path: '', path: '',
pathMatch: 'full', pathMatch: 'full',
loadChildren: () => import('./bitcoin-graphs.module').then(m => m.BitcoinGraphsModule), loadChildren: () => import('@app/bitcoin-graphs.module').then(m => m.BitcoinGraphsModule),
data: { preload: true }, data: { preload: true },
}, },
{ {
path: '', path: '',
loadChildren: () => import('./master-page.module').then(m => m.MasterPageModule), loadChildren: () => import('@app/master-page.module').then(m => m.MasterPageModule),
data: { preload: true }, data: { preload: true },
}, },
{ {
@ -82,7 +83,7 @@ let routes: Routes = [
}, },
{ {
path: '', path: '',
loadChildren: () => import('./bitcoin-graphs.module').then(m => m.BitcoinGraphsModule), loadChildren: () => import('@app/bitcoin-graphs.module').then(m => m.BitcoinGraphsModule),
data: { preload: true }, data: { preload: true },
}, },
{ {
@ -102,16 +103,16 @@ let routes: Routes = [
{ {
path: '', path: '',
pathMatch: 'full', pathMatch: 'full',
loadChildren: () => import('./bitcoin-graphs.module').then(m => m.BitcoinGraphsModule), loadChildren: () => import('@app/bitcoin-graphs.module').then(m => m.BitcoinGraphsModule),
data: { preload: true }, data: { preload: true },
}, },
{ {
path: '', path: '',
loadChildren: () => import('./master-page.module').then(m => m.MasterPageModule), loadChildren: () => import('@app/master-page.module').then(m => m.MasterPageModule),
data: { preload: true }, data: { preload: true },
}, },
{ {
path: 'wallet', path: 'widget/wallet',
children: [], children: [],
component: AddressGroupComponent, component: AddressGroupComponent,
data: { data: {
@ -125,7 +126,7 @@ let routes: Routes = [
}, },
{ {
path: '', path: '',
loadChildren: () => import('./bitcoin-graphs.module').then(m => m.BitcoinGraphsModule), loadChildren: () => import('@app/bitcoin-graphs.module').then(m => m.BitcoinGraphsModule),
data: { preload: true }, data: { preload: true },
}, },
{ {
@ -137,21 +138,22 @@ let routes: Routes = [
{ {
path: '', path: '',
pathMatch: 'full', pathMatch: 'full',
loadChildren: () => import('./bitcoin-graphs.module').then(m => m.BitcoinGraphsModule), loadChildren: () => import('@app/bitcoin-graphs.module').then(m => m.BitcoinGraphsModule),
data: { preload: true }, data: { preload: true },
}, },
{
path: 'tx',
canMatch: [TrackerGuard],
runGuardsAndResolvers: 'always',
loadChildren: () => import('@components/tracker/tracker.module').then(m => m.TrackerModule),
},
{ {
path: '', path: '',
loadChildren: () => import('./master-page.module').then(m => m.MasterPageModule), loadChildren: () => import('@app/master-page.module').then(m => m.MasterPageModule),
data: { preload: true }, data: { preload: true },
}, },
{ {
path: 'tracker', path: 'widget/wallet',
data: { networkSpecific: true },
loadChildren: () => import('./components/tracker/tracker.module').then(m => m.TrackerModule),
},
{
path: 'wallet',
children: [], children: [],
component: AddressGroupComponent, component: AddressGroupComponent,
data: { data: {
@ -163,19 +165,19 @@ let routes: Routes = [
children: [ children: [
{ {
path: '', path: '',
loadChildren: () => import('./previews.module').then(m => m.PreviewsModule) loadChildren: () => import('@app/previews.module').then(m => m.PreviewsModule)
}, },
{ {
path: 'testnet', path: 'testnet',
loadChildren: () => import('./previews.module').then(m => m.PreviewsModule) loadChildren: () => import('@app/previews.module').then(m => m.PreviewsModule)
}, },
{ {
path: 'testnet4', path: 'testnet4',
loadChildren: () => import('./previews.module').then(m => m.PreviewsModule) loadChildren: () => import('@app/previews.module').then(m => m.PreviewsModule)
}, },
{ {
path: 'signet', path: 'signet',
loadChildren: () => import('./previews.module').then(m => m.PreviewsModule) loadChildren: () => import('@app/previews.module').then(m => m.PreviewsModule)
}, },
], ],
}, },
@ -210,13 +212,9 @@ let routes: Routes = [
}, },
{ {
path: '', path: '',
loadChildren: () => import('./bitcoin-graphs.module').then(m => m.BitcoinGraphsModule), loadChildren: () => import('@app/bitcoin-graphs.module').then(m => m.BitcoinGraphsModule),
data: { preload: true }, data: { preload: true },
}, },
{
path: '**',
redirectTo: ''
},
]; ];
if (browserWindowEnv && browserWindowEnv.BASE_MODULE === 'liquid') { if (browserWindowEnv && browserWindowEnv.BASE_MODULE === 'liquid') {
@ -227,16 +225,16 @@ if (browserWindowEnv && browserWindowEnv.BASE_MODULE === 'liquid') {
{ {
path: '', path: '',
pathMatch: 'full', pathMatch: 'full',
loadChildren: () => import('./liquid/liquid-graphs.module').then(m => m.LiquidGraphsModule), loadChildren: () => import('@app/liquid/liquid-graphs.module').then(m => m.LiquidGraphsModule),
data: { preload: true }, data: { preload: true },
}, },
{ {
path: '', path: '',
loadChildren: () => import ('./liquid/liquid-master-page.module').then(m => m.LiquidMasterPageModule), loadChildren: () => import ('@app/liquid/liquid-master-page.module').then(m => m.LiquidMasterPageModule),
data: { preload: true }, data: { preload: true },
}, },
{ {
path: 'wallet', path: 'widget/wallet',
children: [], children: [],
component: AddressGroupComponent, component: AddressGroupComponent,
data: { data: {
@ -250,7 +248,7 @@ if (browserWindowEnv && browserWindowEnv.BASE_MODULE === 'liquid') {
}, },
{ {
path: '', path: '',
loadChildren: () => import('./liquid/liquid-graphs.module').then(m => m.LiquidGraphsModule), loadChildren: () => import('@app/liquid/liquid-graphs.module').then(m => m.LiquidGraphsModule),
data: { preload: true }, data: { preload: true },
}, },
{ {
@ -262,16 +260,16 @@ if (browserWindowEnv && browserWindowEnv.BASE_MODULE === 'liquid') {
{ {
path: '', path: '',
pathMatch: 'full', pathMatch: 'full',
loadChildren: () => import('./liquid/liquid-graphs.module').then(m => m.LiquidGraphsModule), loadChildren: () => import('@app/liquid/liquid-graphs.module').then(m => m.LiquidGraphsModule),
data: { preload: true }, data: { preload: true },
}, },
{ {
path: '', path: '',
loadChildren: () => import ('./liquid/liquid-master-page.module').then(m => m.LiquidMasterPageModule), loadChildren: () => import ('@app/liquid/liquid-master-page.module').then(m => m.LiquidMasterPageModule),
data: { preload: true }, data: { preload: true },
}, },
{ {
path: 'wallet', path: 'widget/wallet',
children: [], children: [],
component: AddressGroupComponent, component: AddressGroupComponent,
data: { data: {
@ -283,11 +281,11 @@ if (browserWindowEnv && browserWindowEnv.BASE_MODULE === 'liquid') {
children: [ children: [
{ {
path: '', path: '',
loadChildren: () => import('./previews.module').then(m => m.PreviewsModule) loadChildren: () => import('@app/previews.module').then(m => m.PreviewsModule)
}, },
{ {
path: 'testnet', path: 'testnet',
loadChildren: () => import('./previews.module').then(m => m.PreviewsModule) loadChildren: () => import('@app/previews.module').then(m => m.PreviewsModule)
}, },
], ],
}, },
@ -298,16 +296,19 @@ if (browserWindowEnv && browserWindowEnv.BASE_MODULE === 'liquid') {
}, },
{ {
path: '', path: '',
loadChildren: () => import('./liquid/liquid-graphs.module').then(m => m.LiquidGraphsModule), loadChildren: () => import('@app/liquid/liquid-graphs.module').then(m => m.LiquidGraphsModule),
data: { preload: true }, data: { preload: true },
}, },
{
path: '**',
redirectTo: ''
},
]; ];
} }
if (!window['isMempoolSpaceBuild']) {
routes.push({
path: '**',
redirectTo: ''
});
}
@NgModule({ @NgModule({
imports: [RouterModule.forRoot(routes, { imports: [RouterModule.forRoot(routes, {
initialNavigation: 'enabledBlocking', initialNavigation: 'enabledBlocking',

View File

@ -151,7 +151,7 @@ export const languages: Language[] = [
{ code: 'fr', name: 'Français' }, // French { code: 'fr', name: 'Français' }, // French
// { code: 'gl', name: 'Galego' }, // Galician // { code: 'gl', name: 'Galego' }, // Galician
{ code: 'ko', name: '한국어' }, // Korean { code: 'ko', name: '한국어' }, // Korean
// { code: 'hr', name: 'Hrvatski' }, // Croatian { code: 'hr', name: 'Hrvatski' }, // Croatian
// { code: 'id', name: 'Bahasa Indonesia' },// Indonesian // { code: 'id', name: 'Bahasa Indonesia' },// Indonesian
{ code: 'hi', name: 'हिन्दी' }, // Hindi { code: 'hi', name: 'हिन्दी' }, // Hindi
{ code: 'ne', name: 'नेपाली' }, // Nepalese { code: 'ne', name: 'नेपाली' }, // Nepalese

View File

@ -2,11 +2,11 @@ import { HTTP_INTERCEPTORS } from '@angular/common/http';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { ServerModule } from '@angular/platform-server'; import { ServerModule } from '@angular/platform-server';
import { ZONE_SERVICE } from './injection-tokens'; import { ZONE_SERVICE } from '@app/injection-tokens';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { AppComponent } from './components/app/app.component'; import { AppComponent } from '@components/app/app.component';
import { HttpCacheInterceptor } from './services/http-cache.interceptor'; import { HttpCacheInterceptor } from '@app/services/http-cache.interceptor';
import { ZoneService } from './services/zone.service'; import { ZoneService } from '@app/services/zone.service';
@NgModule({ @NgModule({
@ -20,4 +20,4 @@ import { ZoneService } from './services/zone.service';
], ],
bootstrap: [AppComponent], bootstrap: [AppComponent],
}) })
export class AppServerModule {} export class AppServerModule {}

View File

@ -2,35 +2,38 @@ import { BrowserModule } from '@angular/platform-browser';
import { ModuleWithProviders, NgModule } from '@angular/core'; import { ModuleWithProviders, NgModule } from '@angular/core';
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http'; import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { ZONE_SERVICE } from './injection-tokens'; import { ZONE_SERVICE } from '@app/injection-tokens';
import { AppRoutingModule } from './app-routing.module'; import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './components/app/app.component'; import { AppComponent } from '@components/app/app.component';
import { ElectrsApiService } from './services/electrs-api.service'; import { ElectrsApiService } from '@app/services/electrs-api.service';
import { StateService } from './services/state.service'; import { OrdApiService } from '@app/services/ord-api.service';
import { CacheService } from './services/cache.service'; import { StateService } from '@app/services/state.service';
import { PriceService } from './services/price.service'; import { CacheService } from '@app/services/cache.service';
import { EnterpriseService } from './services/enterprise.service'; import { PriceService } from '@app/services/price.service';
import { WebsocketService } from './services/websocket.service'; import { EnterpriseService } from '@app/services/enterprise.service';
import { AudioService } from './services/audio.service'; import { WebsocketService } from '@app/services/websocket.service';
import { PreloadService } from './services/preload.service'; import { AudioService } from '@app/services/audio.service';
import { SeoService } from './services/seo.service'; import { PreloadService } from '@app/services/preload.service';
import { OpenGraphService } from './services/opengraph.service'; import { SeoService } from '@app/services/seo.service';
import { ZoneService } from './services/zone-shim.service'; import { OpenGraphService } from '@app/services/opengraph.service';
import { SharedModule } from './shared/shared.module'; import { ZoneService } from '@app/services/zone-shim.service';
import { StorageService } from './services/storage.service'; import { SharedModule } from '@app/shared/shared.module';
import { HttpCacheInterceptor } from './services/http-cache.interceptor'; import { StorageService } from '@app/services/storage.service';
import { LanguageService } from './services/language.service'; import { HttpCacheInterceptor } from '@app/services/http-cache.interceptor';
import { ThemeService } from './services/theme.service'; import { LanguageService } from '@app/services/language.service';
import { FiatShortenerPipe } from './shared/pipes/fiat-shortener.pipe'; import { ThemeService } from '@app/services/theme.service';
import { FiatCurrencyPipe } from './shared/pipes/fiat-currency.pipe'; import { TimeService } from '@app/services/time.service';
import { ShortenStringPipe } from './shared/pipes/shorten-string-pipe/shorten-string.pipe'; import { FiatShortenerPipe } from '@app/shared/pipes/fiat-shortener.pipe';
import { CapAddressPipe } from './shared/pipes/cap-address-pipe/cap-address-pipe'; import { FiatCurrencyPipe } from '@app/shared/pipes/fiat-currency.pipe';
import { AppPreloadingStrategy } from './app.preloading-strategy'; import { ShortenStringPipe } from '@app/shared/pipes/shorten-string-pipe/shorten-string.pipe';
import { ServicesApiServices } from './services/services-api.service'; import { CapAddressPipe } from '@app/shared/pipes/cap-address-pipe/cap-address-pipe';
import { AppPreloadingStrategy } from '@app/app.preloading-strategy';
import { ServicesApiServices } from '@app/services/services-api.service';
import { DatePipe } from '@angular/common'; import { DatePipe } from '@angular/common';
const providers = [ const providers = [
ElectrsApiService, ElectrsApiService,
OrdApiService,
StateService, StateService,
CacheService, CacheService,
PriceService, PriceService,
@ -42,6 +45,7 @@ const providers = [
EnterpriseService, EnterpriseService,
LanguageService, LanguageService,
ThemeService, ThemeService,
TimeService,
ShortenStringPipe, ShortenStringPipe,
FiatShortenerPipe, FiatShortenerPipe,
FiatCurrencyPipe, FiatCurrencyPipe,

View File

@ -1,13 +1,13 @@
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Routes, RouterModule } from '@angular/router'; import { Routes, RouterModule } from '@angular/router';
import { MasterPageComponent } from './components/master-page/master-page.component'; import { MasterPageComponent } from '@components/master-page/master-page.component';
const routes: Routes = [ const routes: Routes = [
{ {
path: '', path: '',
component: MasterPageComponent, component: MasterPageComponent,
loadChildren: () => import('./graphs/graphs.module').then(m => m.GraphsModule), loadChildren: () => import('@app/graphs/graphs.module').then(m => m.GraphsModule),
data: { preload: true }, data: { preload: true },
} }
]; ];

View File

@ -1,5 +1,5 @@
import { Transaction, Vin } from './interfaces/electrs.interface'; import { Transaction, Vin } from '@interfaces/electrs.interface';
import { Hash } from './shared/sha256'; import { Hash } from '@app/shared/sha256';
const P2SH_P2WPKH_COST = 21 * 4; // the WU cost for the non-witness part of P2SH-P2WPKH const P2SH_P2WPKH_COST = 21 * 4; // the WU cost for the non-witness part of P2SH-P2WPKH
const P2SH_P2WSH_COST = 35 * 4; // the WU cost for the non-witness part of P2SH-P2WSH const P2SH_P2WSH_COST = 35 * 4; // the WU cost for the non-witness part of P2SH-P2WSH
@ -135,7 +135,7 @@ export function parseMultisigScript(script: string): void | { m: number, n: numb
return; return;
} }
const opN = ops.pop(); const opN = ops.pop();
if (!opN.startsWith('OP_PUSHNUM_')) { if (opN !== 'OP_0' && !opN.startsWith('OP_PUSHNUM_')) {
return; return;
} }
const n = parseInt(opN.match(/[0-9]+/)[0], 10); const n = parseInt(opN.match(/[0-9]+/)[0], 10);
@ -152,7 +152,7 @@ export function parseMultisigScript(script: string): void | { m: number, n: numb
} }
} }
const opM = ops.pop(); const opM = ops.pop();
if (!opM.startsWith('OP_PUSHNUM_')) { if (opM !== 'OP_0' && !opM.startsWith('OP_PUSHNUM_')) {
return; return;
} }
const m = parseInt(opM.match(/[0-9]+/)[0], 10); const m = parseInt(opM.match(/[0-9]+/)[0], 10);
@ -303,4 +303,4 @@ export async function calcScriptHash$(script: string): Promise<string> {
return hashArray return hashArray
.map((bytes) => bytes.toString(16).padStart(2, '0')) .map((bytes) => bytes.toString(16).padStart(2, '0'))
.join(''); .join('');
} }

View File

@ -1,5 +1,5 @@
import { Component, Input } from '@angular/core'; import { Component, Input } from '@angular/core';
import { EnterpriseService } from '../../services/enterprise.service'; import { EnterpriseService } from '@app/services/enterprise.service';
@Component({ @Component({
selector: 'app-about-sponsors', selector: 'app-about-sponsors',

View File

@ -53,7 +53,7 @@
<span>Spiral</span> <span>Spiral</span>
</a> </a>
<a href="https://foundrydigital.com/" target="_blank" title="Foundry"> <a href="https://foundrydigital.com/" target="_blank" title="Foundry">
<svg xmlns="http://www.w3.org/2000/svg" id="b" data-name="Layer 2" style="zoom: 1;" width="32" height="76" viewBox="0 0 32 76"> <svg xmlns="http://www.w3.org/2000/svg" id="b" data-name="Layer 2" style="zoom: 1;" width="32" height="90" viewBox="0 -5 32 90" class="image">
<defs> <defs>
<style> <style>
.d { .d {
@ -125,17 +125,14 @@
<span>Blockstream</span> <span>Blockstream</span>
</a> </a>
<a href="https://unchained.com/" target="_blank" title="Unchained"> <a href="https://unchained.com/" target="_blank" title="Unchained">
<svg id="Layer_1" width="78" height="78" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 156.68 156.68"><defs><style>.cls-unchained-1{fill:#fff;}</style></defs><path class="cls-unchained-1" d="m78.34,0C35.07,0,0,35.07,0,78.34s35.07,78.34,78.34,78.34,78.34-35.07,78.34-78.34S121.6,0,78.34,0ZM20.23,109.5c-4.99-9.28-7.81-19.89-7.81-31.16C12.42,41.93,41.93,12.42,78.34,12.42c33.15,0,60.58,24.46,65.23,56.32h-37.48c-45.29,0-71.19,20.05-85.85,40.76Zm58.11,34.76c-12.42,0-24.04-3.44-33.96-9.41,3.94-8.85,9.11-18.7,15.84-28.9,20.99-31.8,52.2-31.19,76.49-31.19h7.45c.06,1.18.1,2.38.1,3.58,0,36.41-29.51,65.92-65.92,65.92Z"/><path class="cls-unchained-1" d="m91.98,42.4l-3.62-1.18c-3.94-1.29-7.03-4.38-8.32-8.32l-1.18-3.63c-.13-.39-.68-.39-.81,0l-1.18,3.63c-1.29,3.94-4.38,7.03-8.32,8.32l-3.62,1.18c-.39.13-.39.68,0,.81l3.62,1.18c3.94,1.29,7.03,4.38,8.32,8.32l1.18,3.63c.13.39.68.39.81,0l1.18-3.63c1.29-3.94,4.38-7.03,8.32-8.32l3.62-1.18c.39-.13.39-.68,0-.81Z"/></svg> <svg id="Layer_1" width="78" height="78" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 156.68 156.68" class="image">
<defs><style>.cls-unchained-1{fill:#fff;}</style></defs><path class="cls-unchained-1" d="m78.34,0C35.07,0,0,35.07,0,78.34s35.07,78.34,78.34,78.34,78.34-35.07,78.34-78.34S121.6,0,78.34,0ZM20.23,109.5c-4.99-9.28-7.81-19.89-7.81-31.16C12.42,41.93,41.93,12.42,78.34,12.42c33.15,0,60.58,24.46,65.23,56.32h-37.48c-45.29,0-71.19,20.05-85.85,40.76Zm58.11,34.76c-12.42,0-24.04-3.44-33.96-9.41,3.94-8.85,9.11-18.7,15.84-28.9,20.99-31.8,52.2-31.19,76.49-31.19h7.45c.06,1.18.1,2.38.1,3.58,0,36.41-29.51,65.92-65.92,65.92Z"/><path class="cls-unchained-1" d="m91.98,42.4l-3.62-1.18c-3.94-1.29-7.03-4.38-8.32-8.32l-1.18-3.63c-.13-.39-.68-.39-.81,0l-1.18,3.63c-1.29,3.94-4.38,7.03-8.32,8.32l-3.62,1.18c-.39.13-.39.68,0,.81l3.62,1.18c3.94,1.29,7.03,4.38,8.32,8.32l1.18,3.63c.13.39.68.39.81,0l1.18-3.63c1.29-3.94,4.38-7.03,8.32-8.32l3.62-1.18c.39-.13.39-.68,0-.81Z"/>
</svg>
<span>Unchained</span> <span>Unchained</span>
</a> </a>
<a href="https://gemini.com/" target="_blank" title="Gemini"> <a href="https://bitkey.world/" target="_blank" title="Bitkey">
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="360" height="360" viewBox="0 0 360 360" class="image"> <img class="image" src="/resources/profile/bitkey.svg" />
<rect style="fill: black" width="360" height="360" /> <span>Bitkey</span>
<g transform="matrix(0.62 0 0 0.62 180 180)">
<path style="fill: rgb(0,220,250)" transform=" translate(-162, -162)" d="M 211.74 0 C 154.74 0 106.35 43.84 100.25 100.25 C 43.84 106.35 1.4210854715202004e-14 154.76 1.4210854715202004e-14 211.74 C 0.044122601308501076 273.7212006364817 50.27879936351834 323.95587739869154 112.26 324 C 169.26 324 217.84 280.15999999999997 223.75 223.75 C 280.15999999999997 217.65 324 169.24 324 112.26 C 323.95587739869154 50.278799363518324 273.72120063648174 0.04412260130848722 211.74 -1.4210854715202004e-14 z M 297.74 124.84 C 291.9644950552469 162.621439649343 262.2969457716857 192.26062994820046 224.51 198 L 224.51 124.84 z M 26.3 199.16 C 31.986912917108594 161.30935034910615 61.653433460549415 131.56986937804106 99.48999999999998 125.78999999999999 L 99.49 199 L 26.3 199 z M 198.21 224.51 C 191.87736076583954 267.0991541201681 155.312384597087 298.62923417787493 112.255 298.62923417787493 C 69.19761540291302 298.62923417787493 32.63263923416048 267.0991541201682 26.3 224.51 z M 199.16 124.83999999999999 L 199.16 199 L 124.84 199 L 124.84 124.84 z M 297.7 99.48999999999998 L 125.78999999999999 99.48999999999998 C 132.12263923416046 56.90084587983182 168.687615402913 25.37076582212505 211.745 25.37076582212505 C 254.80238459708698 25.37076582212505 291.3673607658395 56.900845879831834 297.7 99.49 z" stroke-linecap="round" />
</g>
</svg>
<span>Gemini</span>
</a> </a>
<a href="https://bullbitcoin.com/" target="_blank" title="Bull Bitcoin"> <a href="https://bullbitcoin.com/" target="_blank" title="Bull Bitcoin">
<svg aria-hidden="true" class="image" viewBox="0 -5 40 40" xmlns="http://www.w3.org/2000/svg"> <svg aria-hidden="true" class="image" viewBox="0 -5 40 40" xmlns="http://www.w3.org/2000/svg">
@ -150,7 +147,7 @@
<span>Bull Bitcoin</span> <span>Bull Bitcoin</span>
</a> </a>
<a href="https://exodus.com/" target="_blank" title="Exodus"> <a href="https://exodus.com/" target="_blank" title="Exodus">
<svg width="80" height="80" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="80" height="80" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg" class="image">
<circle cx="250" cy="250" r="250" fill="#1F2033"/> <circle cx="250" cy="250" r="250" fill="#1F2033"/>
<g clip-path="url(#clip0_2_14)"> <g clip-path="url(#clip0_2_14)">
<path d="M411.042 178.303L271.79 87V138.048L361.121 196.097L350.612 229.351H271.79V271.648H350.612L361.121 304.903L271.79 362.952V414L411.042 322.989L388.271 250.646L411.042 178.303Z" fill="url(#paint0_linear_2_14)"/> <path d="M411.042 178.303L271.79 87V138.048L361.121 196.097L350.612 229.351H271.79V271.648H350.612L361.121 304.903L271.79 362.952V414L411.042 322.989L388.271 250.646L411.042 178.303Z" fill="url(#paint0_linear_2_14)"/>
@ -191,12 +188,30 @@
</svg> </svg>
<span>Exodus</span> <span>Exodus</span>
</a> </a>
<a href="https://gemini.com/" target="_blank" title="Gemini">
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="360" height="360" viewBox="0 0 360 360" class="image">
<rect style="fill: black" width="360" height="360" />
<g transform="matrix(0.62 0 0 0.62 180 180)">
<path style="fill: rgb(0,220,250)" transform=" translate(-162, -162)" d="M 211.74 0 C 154.74 0 106.35 43.84 100.25 100.25 C 43.84 106.35 1.4210854715202004e-14 154.76 1.4210854715202004e-14 211.74 C 0.044122601308501076 273.7212006364817 50.27879936351834 323.95587739869154 112.26 324 C 169.26 324 217.84 280.15999999999997 223.75 223.75 C 280.15999999999997 217.65 324 169.24 324 112.26 C 323.95587739869154 50.278799363518324 273.72120063648174 0.04412260130848722 211.74 -1.4210854715202004e-14 z M 297.74 124.84 C 291.9644950552469 162.621439649343 262.2969457716857 192.26062994820046 224.51 198 L 224.51 124.84 z M 26.3 199.16 C 31.986912917108594 161.30935034910615 61.653433460549415 131.56986937804106 99.48999999999998 125.78999999999999 L 99.49 199 L 26.3 199 z M 198.21 224.51 C 191.87736076583954 267.0991541201681 155.312384597087 298.62923417787493 112.255 298.62923417787493 C 69.19761540291302 298.62923417787493 32.63263923416048 267.0991541201682 26.3 224.51 z M 199.16 124.83999999999999 L 199.16 199 L 124.84 199 L 124.84 124.84 z M 297.7 99.48999999999998 L 125.78999999999999 99.48999999999998 C 132.12263923416046 56.90084587983182 168.687615402913 25.37076582212505 211.745 25.37076582212505 C 254.80238459708698 25.37076582212505 291.3673607658395 56.900845879831834 297.7 99.49 z" stroke-linecap="round" />
</g>
</svg>
<span>Gemini</span>
</a>
<a href="https://leather.io/" target="_blank" title="Leather">
<img class="image" src="/resources/profile/leather.svg" />
<span>Leather</span>
</a>
<a href="https://taprootwizards.com/" target="_blank" title="Taproot Wizards">
<img class="image" src="/resources/profile/wizardhat.png" />
<span>Taproot Wizards</span>
</a>
</div> </div>
</div> </div>
<ng-container> <ng-container>
<div *ngIf="profiles$ | async as profiles" id="community-sponsors-anchor"> <div *ngIf="profiles$ | async as profiles" id="community-sponsors-anchor">
<div class="community-sponsor" style="margin-bottom: 68px" *ngIf="profiles.whales.length > 0"> <div class="community-sponsor whale-sponsor" style="margin-bottom: 68px" *ngIf="profiles.whales.length > 0">
<h3 i18n="about.sponsors.withHeart">Whale Sponsors</h3> <h3 i18n="about.sponsors.withHeart">Whale Sponsors</h3>
<div class="wrapper"> <div class="wrapper">
<ng-container> <ng-container>
@ -435,7 +450,7 @@
Trademark Notice<br> Trademark Notice<br>
</div> </div>
<p> <p>
The Mempool Open Source Project&reg;, Mempool Accelerator&trade;, Mempool Enterprise&reg;, Mempool Liquidity&trade;, mempool.space&reg;, Be your own explorer&trade;, Explore the full Bitcoin ecosystem&reg;, Mempool Goggles&trade;, the mempool logo, the mempool Square logo, the mempool Blocks logo, the mempool Blocks 3 | 2 logo, the mempool.space Vertical Logo, and the mempool.space Horizontal logo are either registered trademarks or trademarks of Mempool Space K.K in Japan, the United States, and/or other countries. The Mempool Open Source Project&reg;, Mempool Accelerator&trade;, Mempool Enterprise&reg;, Mempool Liquidity&trade;, mempool.space&reg;, Be your own explorer&trade;, Explore the full Bitcoin ecosystem&reg;, Mempool Goggles&trade;, the mempool Logo, the mempool Square Logo, the mempool block visualization Logo, the mempool Blocks Logo, the mempool transaction Logo, the mempool Blocks 3 | 2 Logo, the mempool research Logo, the mempool.space Vertical Logo, and the mempool.space Horizontal Logo are either registered trademarks or trademarks of Mempool Space K.K in Japan, the United States, and/or other countries.
</p> </p>
<p> <p>
While our software is available under an open source software license, the copyright license does not include an implied right or license to use our trademarks. See our <a href="https://mempool.space/trademark-policy">Trademark Policy and Guidelines</a> for more details, published on &lt;https://mempool.space/trademark-policy&gt;. While our software is available under an open source software license, the copyright license does not include an implied right or license to use our trademarks. See our <a href="https://mempool.space/trademark-policy">Trademark Policy and Guidelines</a> for more details, published on &lt;https://mempool.space/trademark-policy&gt;.

View File

@ -13,8 +13,6 @@
.image.not-rounded { .image.not-rounded {
border-radius: 0; border-radius: 0;
width: 60px;
height: 60px;
} }
.intro { .intro {
@ -94,6 +92,13 @@
} }
} }
.whale-sponsor {
img {
width: 70px;
height: 70px;
}
}
.alliances { .alliances {
margin-bottom: 100px; margin-bottom: 100px;
a { a {
@ -158,9 +163,8 @@
margin: 40px 29px 10px; margin: 40px 29px 10px;
&.image.coldcard { &.image.coldcard {
border-radius: 0; border-radius: 0;
width: auto; height: auto;
max-height: 50px; margin: 20px 29px 20px;
margin: 40px 29px 14px 29px;
} }
} }
} }
@ -254,3 +258,12 @@
width: 64px; width: 64px;
height: 64px; height: 64px;
} }
.enterprise-sponsor {
.wrapper {
display: flex;
flex-wrap: wrap;
justify-content: center;
max-width: 800px;
}
}

View File

@ -1,16 +1,16 @@
import { ChangeDetectionStrategy, Component, ElementRef, Inject, LOCALE_ID, OnInit, ViewChild } from '@angular/core'; import { ChangeDetectionStrategy, Component, ElementRef, Inject, LOCALE_ID, OnInit, ViewChild } from '@angular/core';
import { WebsocketService } from '../../services/websocket.service'; import { WebsocketService } from '@app/services/websocket.service';
import { SeoService } from '../../services/seo.service'; import { SeoService } from '@app/services/seo.service';
import { OpenGraphService } from '../../services/opengraph.service'; import { OpenGraphService } from '@app/services/opengraph.service';
import { StateService } from '../../services/state.service'; import { StateService } from '@app/services/state.service';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { ApiService } from '../../services/api.service'; import { ApiService } from '@app/services/api.service';
import { IBackendInfo } from '../../interfaces/websocket.interface'; import { IBackendInfo } from '@interfaces/websocket.interface';
import { Router, ActivatedRoute } from '@angular/router'; import { Router, ActivatedRoute } from '@angular/router';
import { map, share, tap } from 'rxjs/operators'; import { map, share, tap } from 'rxjs/operators';
import { ITranslators } from '../../interfaces/node-api.interface'; import { ITranslators } from '@interfaces/node-api.interface';
import { DOCUMENT } from '@angular/common'; import { DOCUMENT } from '@angular/common';
import { EnterpriseService } from '../../services/enterprise.service'; import { EnterpriseService } from '@app/services/enterprise.service';
@Component({ @Component({
selector: 'app-about', selector: 'app-about',

View File

@ -1,9 +1,9 @@
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Routes, RouterModule } from '@angular/router'; import { Routes, RouterModule } from '@angular/router';
import { AboutComponent } from './about.component'; import { AboutComponent } from '@components/about/about.component';
import { AboutSponsorsComponent } from './about-sponsors.component'; import { AboutSponsorsComponent } from '@components/about/about-sponsors.component';
import { SharedModule } from '../../shared/shared.module'; import { SharedModule } from '@app/shared/shared.module';
const routes: Routes = [ const routes: Routes = [
{ {

View File

@ -389,21 +389,29 @@
</div> </div>
} }
</div> </div>
@if (canPayWithCashapp || canPayWithApplePay) { @if (canPayWithCashapp || canPayWithApplePay || canPayWithGooglePay) {
<div class="col-sm text-center flex-grow-0 d-flex flex-column justify-content-center align-items-center"> <div class="col-sm text-center flex-grow-0 d-flex flex-column justify-content-center align-items-center">
<p class="text-nowrap">&mdash;<span i18n="or">OR</span>&mdash;</p> <p class="text-nowrap">&mdash;<span i18n="or">OR</span>&mdash;</p>
</div> </div>
} }
} }
@if (canPayWithCashapp || canPayWithApplePay) { @if (canPayWithCashapp || canPayWithApplePay || canPayWithGooglePay) {
<div class="col-sm text-center d-flex flex-column justify-content-center align-items-center"> <div class="col-sm text-center d-flex flex-column justify-content-center align-items-center">
<p><ng-container i18n="transaction.pay|Pay button label">Pay</ng-container>&nbsp;<app-fiat [value]="cost"></app-fiat> with</p> <p><ng-container i18n="transaction.pay|Pay button label">Pay</ng-container>&nbsp;<app-fiat [value]="cost"></app-fiat> with</p>
@if (canPayWithCashapp) { @if (canPayWithCashapp) {
<img class="paymentMethod mx-2" style="width: 200px" src="/resources/cash-app.svg" height=55 (click)="moveToStep('cashapp')"> <img class="paymentMethod mx-2" style="width: 200px" src="/resources/cash-app.svg" height=55 (click)="moveToStep('cashapp')">
} }
@if (canPayWithApplePay) { @if (canPayWithApplePay) {
@if (canPayWithCashapp) { <hr class="w-25 mt-2 mb-2"> } @if (canPayWithCashapp) { <span class="mt-1 mb-1"></span> }
<img style="cursor: pointer;" src="/resources/apple-pay.svg" height=55 (click)="moveToStep('applepay')"> <div class="paymentMethod mx-2" style="width: 200px; height: 55px" (click)="moveToStep('applepay')">
<img src="/resources/apple-pay.png" height=37>
</div>
}
@if (canPayWithGooglePay) {
@if (canPayWithCashapp || canPayWithApplePay) { <span class="mt-1 mb-1"></span> }
<div class="paymentMethod mx-2" style="width: 200px; height: 55px" (click)="moveToStep('googlepay')">
<img src="/resources/google-pay.png" height=37>
</div>
} }
</div> </div>
} }
@ -427,7 +435,7 @@
<button type="button" class="mt-1 btn btn-secondary btn-sm rounded-pill align-self-center" style="width: 200px" (click)="moveToStep('summary')" i18n="go-back">Go back</button> <button type="button" class="mt-1 btn btn-secondary btn-sm rounded-pill align-self-center" style="width: 200px" (click)="moveToStep('summary')" i18n="go-back">Go back</button>
</div> </div>
</div> </div>
} @else if (step === 'cashapp' || step === 'applepay') { } @else if (step === 'cashapp' || step === 'applepay' || step === 'googlepay') {
<!-- Show checkout page --> <!-- Show checkout page -->
<div class="row mb-md-1 text-center" id="confirm-title"> <div class="row mb-md-1 text-center" id="confirm-title">
<div class="col-sm" id="confirm-payment-title"> <div class="col-sm" id="confirm-payment-title">
@ -443,7 +451,7 @@
</div> </div>
</div> </div>
@if (step === 'cashapp' && !loadingCashapp || step === 'applepay' && !loadingApplePay) { @if (step === 'cashapp' && !loadingCashapp || step === 'applepay' && !loadingApplePay || step === 'googlepay' && !loadingGooglePay) {
<div class="row text-center mt-1"> <div class="row text-center mt-1">
<div class="col-sm"> <div class="col-sm">
<div class="form-group w-100"> <div class="form-group w-100">
@ -463,11 +471,13 @@
<div class="col-sm"> <div class="col-sm">
<div class="form-group w-100"> <div class="form-group w-100">
@if (step === 'applepay') { @if (step === 'applepay') {
<div id="apple-pay-button" class="apple-pay-button apple-pay-button-white" [style]="loadingApplePay ? 'opacity: 0; width: 0px; height: 0px; pointer-events: none;' : ''"></div> <div id="apple-pay-button" class="apple-pay-button apple-pay-button-black" style="height: 50px" [style]="loadingApplePay ? 'opacity: 0; width: 0px; height: 0px; pointer-events: none;' : ''"></div>
} @else if (step === 'cashapp') { } @else if (step === 'cashapp') {
<div id="cash-app-pay" class="d-inline-block" [style]="loadingCashapp ? 'opacity: 0; width: 0px; height: 0px; pointer-events: none;' : ''"></div> <div id="cash-app-pay" class="d-inline-block" style="height: 50px" [style]="loadingCashapp ? 'opacity: 0; width: 0px; height: 0px; pointer-events: none;' : ''"></div>
} @else if (step === 'googlepay') {
<div id="google-pay-button" class="d-inline-block" style="height: 50px" [style]="loadingGooglePay ? 'opacity: 0; width: 0px; height: 0px; pointer-events: none;' : ''"></div>
} }
@if (loadingCashapp || loadingApplePay) { @if (loadingCashapp || loadingApplePay || loadingGooglePay) {
<div display="d-flex flex-row justify-content-center"> <div display="d-flex flex-row justify-content-center">
<span i18n="accelerator.loading-payment-method">Loading payment method...</span> <span i18n="accelerator.loading-payment-method">Loading payment method...</span>
<div class="ml-2 spinner-border text-light" style="width: 25px; height: 25px"></div> <div class="ml-2 spinner-border text-light" style="width: 25px; height: 25px"></div>
@ -515,7 +525,7 @@
<div class="col-sm"> <div class="col-sm">
<div class="d-flex flex-row flex-column justify-content-center align-items-center"> <div class="d-flex flex-row flex-column justify-content-center align-items-center">
<span i18n="accelerator.confirming-acceleration-with-miners">Confirming your acceleration with our mining pool partners...</span> <span i18n="accelerator.confirming-acceleration-with-miners">Confirming your acceleration with our mining pool partners...</span>
@if (timeSincePaid > 20000) { @if (timeSincePaid > 30000) {
<span i18n="accelerator.confirming-acceleration-with-miners">...sorry, this is taking longer than expected...</span> <span i18n="accelerator.confirming-acceleration-with-miners">...sorry, this is taking longer than expected...</span>
} }
<div class="m-2 spinner-border text-light" style="width: 25px; height: 25px"></div> <div class="m-2 spinner-border text-light" style="width: 25px; height: 25px"></div>

View File

@ -172,10 +172,6 @@
background-color: var(--tertiary); background-color: var(--tertiary);
} }
.btn-small-height {
line-height: 1;
}
.summary-row { .summary-row {
display: flex; display: flex;
flex-direction: row; flex-direction: row;

View File

@ -1,17 +1,19 @@
/* eslint-disable no-console */
import { Component, OnInit, OnDestroy, Output, EventEmitter, Input, ChangeDetectorRef, SimpleChanges, HostListener } from '@angular/core'; import { Component, OnInit, OnDestroy, Output, EventEmitter, Input, ChangeDetectorRef, SimpleChanges, HostListener } from '@angular/core';
import { Subscription, tap, of, catchError, Observable, switchMap } from 'rxjs'; import { Subscription, tap, of, catchError, Observable, switchMap } from 'rxjs';
import { ServicesApiServices } from '../../services/services-api.service'; import { ServicesApiServices } from '@app/services/services-api.service';
import { md5, nextRoundNumber, insecureRandomUUID } from '../../shared/common.utils'; import { md5 } from '@app/shared/common.utils';
import { StateService } from '../../services/state.service'; import { StateService } from '@app/services/state.service';
import { AudioService } from '../../services/audio.service'; import { AudioService } from '@app/services/audio.service';
import { ETA, EtaService } from '../../services/eta.service'; import { ETA, EtaService } from '@app/services/eta.service';
import { Transaction } from '../../interfaces/electrs.interface'; import { Transaction } from '@interfaces/electrs.interface';
import { MiningStats } from '../../services/mining.service'; import { MiningStats } from '@app/services/mining.service';
import { IAuth, AuthServiceMempool } from '../../services/auth.service'; import { IAuth, AuthServiceMempool } from '@app/services/auth.service';
import { EnterpriseService } from '../../services/enterprise.service'; import { EnterpriseService } from '@app/services/enterprise.service';
import { ApiService } from '../../services/api.service'; import { ApiService } from '@app/services/api.service';
import { isDevMode } from '@angular/core';
export type PaymentMethod = 'balance' | 'bitcoin' | 'cashapp'; export type PaymentMethod = 'balance' | 'bitcoin' | 'cashapp' | 'applePay' | 'googlePay';
export type AccelerationEstimate = { export type AccelerationEstimate = {
hasAccess: boolean; hasAccess: boolean;
@ -24,7 +26,7 @@ export type AccelerationEstimate = {
mempoolBaseFee: number; mempoolBaseFee: number;
vsizeFee: number; vsizeFee: number;
pools: number[]; pools: number[];
availablePaymentMethods: {[method: string]: {min: number, max: number}}; availablePaymentMethods: Record<PaymentMethod, {min: number, max: number}>;
unavailable?: boolean; unavailable?: boolean;
options: { // recommended bid options options: { // recommended bid options
fee: number; // recommended userBid in sats fee: number; // recommended userBid in sats
@ -47,7 +49,7 @@ export const MIN_BID_RATIO = 1;
export const DEFAULT_BID_RATIO = 2; export const DEFAULT_BID_RATIO = 2;
export const MAX_BID_RATIO = 4; export const MAX_BID_RATIO = 4;
type CheckoutStep = 'quote' | 'summary' | 'checkout' | 'cashapp' | 'applepay' | 'processing' | 'paid' | 'success'; type CheckoutStep = 'quote' | 'summary' | 'checkout' | 'cashapp' | 'applepay' | 'googlepay' | 'processing' | 'paid' | 'success';
@Component({ @Component({
selector: 'app-accelerate-checkout', selector: 'app-accelerate-checkout',
@ -62,6 +64,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
@Input() scrollEvent: boolean; @Input() scrollEvent: boolean;
@Input() cashappEnabled: boolean = true; @Input() cashappEnabled: boolean = true;
@Input() applePayEnabled: boolean = false; @Input() applePayEnabled: boolean = false;
@Input() googlePayEnabled: boolean = true;
@Input() advancedEnabled: boolean = false; @Input() advancedEnabled: boolean = false;
@Input() forceMobile: boolean = false; @Input() forceMobile: boolean = false;
@Input() showDetails: boolean = false; @Input() showDetails: boolean = false;
@ -72,6 +75,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
@Output() changeMode = new EventEmitter<boolean>(); @Output() changeMode = new EventEmitter<boolean>();
calculating = true; calculating = true;
processing = false;
selectedOption: 'wait' | 'accel'; selectedOption: 'wait' | 'accel';
cantPayReason = ''; cantPayReason = '';
quoteError = ''; // error fetching estimate or initial data quoteError = ''; // error fetching estimate or initial data
@ -80,18 +84,16 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
timePaid: number = 0; // time acceleration requested timePaid: number = 0; // time acceleration requested
math = Math; math = Math;
isMobile: boolean = window.innerWidth <= 767.98; isMobile: boolean = window.innerWidth <= 767.98;
isProdDomain = false;
private _step: CheckoutStep = 'summary'; private _step: CheckoutStep = 'summary';
simpleMode: boolean = true; simpleMode: boolean = true;
paymentMethod: 'cashapp' | 'btcpay';
timeoutTimer: any; timeoutTimer: any;
authSubscription$: Subscription; authSubscription$: Subscription;
auth: IAuth | null = null; auth: IAuth | null = null;
// accelerator stuff // accelerator stuff
square: { appId: string, locationId: string};
accelerationUUID: string;
accelerationSubscription: Subscription; accelerationSubscription: Subscription;
difficultySubscription: Subscription; difficultySubscription: Subscription;
estimateSubscription: Subscription; estimateSubscription: Subscription;
@ -112,14 +114,14 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
// square // square
loadingCashapp = false; loadingCashapp = false;
loadingApplePay = false; loadingApplePay = false;
cashappError = false; loadingGooglePay = false;
cashappSubmit: any;
payments: any; payments: any;
cashAppPay: any; cashAppPay: any;
applePay: any; applePay: any;
googlePay: any;
conversionsSubscription: Subscription; conversionsSubscription: Subscription;
conversions: any; conversions: Record<string, number>;
// btcpay // btcpay
loadingBtcpayInvoice = false; loadingBtcpayInvoice = false;
invoice = undefined; invoice = undefined;
@ -134,16 +136,16 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
private authService: AuthServiceMempool, private authService: AuthServiceMempool,
private enterpriseService: EnterpriseService, private enterpriseService: EnterpriseService,
) { ) {
this.accelerationUUID = insecureRandomUUID(); this.isProdDomain = this.stateService.env.PROD_DOMAINS.indexOf(document.location.hostname) > -1;
// Check if Apple Pay available // Check if Apple Pay available
// @ts-ignore https://developer.apple.com/documentation/apple_pay_on_the_web/apple_pay_js_api/checking_for_apple_pay_availability#overview // https://developer.apple.com/documentation/apple_pay_on_the_web/apple_pay_js_api/checking_for_apple_pay_availability#overview
if (window.ApplePaySession) { if (window['ApplePaySession']) {
this.applePayEnabled = true; this.applePayEnabled = true;
} }
} }
ngOnInit() { ngOnInit(): void {
this.authSubscription$ = this.authService.getAuth$().subscribe((auth) => { this.authSubscription$ = this.authService.getAuth$().subscribe((auth) => {
if (this.auth?.user?.userId !== auth?.user?.userId) { if (this.auth?.user?.userId !== auth?.user?.userId) {
this.auth = auth; this.auth = auth;
@ -168,13 +170,6 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
this.moveToStep('summary'); this.moveToStep('summary');
} }
this.servicesApiService.setupSquare$().subscribe(ids => {
this.square = {
appId: ids.squareAppId,
locationId: ids.squareLocationId
};
});
this.conversionsSubscription = this.stateService.conversions$.subscribe( this.conversionsSubscription = this.stateService.conversions$.subscribe(
async (conversions) => { async (conversions) => {
this.conversions = conversions; this.conversions = conversions;
@ -182,7 +177,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
); );
} }
ngOnDestroy() { ngOnDestroy(): void {
if (this.estimateSubscription) { if (this.estimateSubscription) {
this.estimateSubscription.unsubscribe(); this.estimateSubscription.unsubscribe();
} }
@ -195,14 +190,17 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
if (changes.scrollEvent && this.scrollEvent) { if (changes.scrollEvent && this.scrollEvent) {
this.scrollToElement('acceleratePreviewAnchor', 'start'); this.scrollToElement('acceleratePreviewAnchor', 'start');
} }
if (changes.accelerating) { if (changes.accelerating && this.accelerating) {
if ((this.step === 'processing' || this.step === 'paid') && this.accelerating) { if (this.step === 'processing' || this.step === 'paid') {
this.moveToStep('success'); this.moveToStep('success');
} else { // Edge case where the transaction gets accelerated by someone else or on another session
this.closeModal();
} }
} }
} }
moveToStep(step: CheckoutStep) { moveToStep(step: CheckoutStep): void {
this.processing = false;
this._step = step; this._step = step;
if (this.timeoutTimer) { if (this.timeoutTimer) {
clearTimeout(this.timeoutTimer); clearTimeout(this.timeoutTimer);
@ -211,6 +209,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
this.fetchEstimate(); this.fetchEstimate();
} }
if (this._step === 'checkout') { if (this._step === 'checkout') {
this.insertSquare();
this.enterpriseService.goal(8); this.enterpriseService.goal(8);
} }
if (this._step === 'checkout' && this.canPayWithBitcoin) { if (this._step === 'checkout' && this.canPayWithBitcoin) {
@ -220,12 +219,14 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
this.requestBTCPayInvoice(); this.requestBTCPayInvoice();
} else if (this._step === 'cashapp' && this.cashappEnabled) { } else if (this._step === 'cashapp' && this.cashappEnabled) {
this.loadingCashapp = true; this.loadingCashapp = true;
this.insertSquare();
this.setupSquare(); this.setupSquare();
this.scrollToElementWithTimeout('confirm-title', 'center', 100); this.scrollToElementWithTimeout('confirm-title', 'center', 100);
} else if (this._step === 'applepay' && this.applePayEnabled) { } else if (this._step === 'applepay' && this.applePayEnabled) {
this.loadingApplePay = true; this.loadingApplePay = true;
this.insertSquare(); this.setupSquare();
this.scrollToElementWithTimeout('confirm-title', 'center', 100);
} else if (this._step === 'googlepay' && this.googlePayEnabled) {
this.loadingGooglePay = true;
this.setupSquare(); this.setupSquare();
this.scrollToElementWithTimeout('confirm-title', 'center', 100); this.scrollToElementWithTimeout('confirm-title', 'center', 100);
} else if (this._step === 'paid') { } else if (this._step === 'paid') {
@ -234,7 +235,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
if (this.step === 'paid') { if (this.step === 'paid') {
this.accelerateError = 'internal_server_error'; this.accelerateError = 'internal_server_error';
} }
}, 120000) }, 120000);
} }
this.hasDetails.emit(this._step === 'quote'); this.hasDetails.emit(this._step === 'quote');
} }
@ -252,7 +253,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
this.scrollToElement(id, position); this.scrollToElement(id, position);
}, timeout); }, timeout);
} }
scrollToElement(id: string, position: ScrollLogicalPosition) { scrollToElement(id: string, position: ScrollLogicalPosition): void {
const acceleratePreviewAnchor = document.getElementById(id); const acceleratePreviewAnchor = document.getElementById(id);
if (acceleratePreviewAnchor) { if (acceleratePreviewAnchor) {
this.cd.markForCheck(); this.cd.markForCheck();
@ -267,7 +268,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
/** /**
* Accelerator * Accelerator
*/ */
fetchEstimate() { fetchEstimate(): void {
if (this.estimateSubscription) { if (this.estimateSubscription) {
this.estimateSubscription.unsubscribe(); this.estimateSubscription.unsubscribe();
} }
@ -331,7 +332,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
} }
}), }),
catchError((response) => { catchError(() => {
this.estimate = undefined; this.estimate = undefined;
this.quoteError = `cannot_accelerate_tx`; this.quoteError = `cannot_accelerate_tx`;
this.estimateSubscription.unsubscribe(); this.estimateSubscription.unsubscribe();
@ -367,6 +368,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
this.selectFeeRateIndex = index; this.selectFeeRateIndex = index;
this.userBid = Math.max(0, fee); this.userBid = Math.max(0, fee);
this.cost = this.userBid + this.estimate.mempoolBaseFee + this.estimate.vsizeFee; this.cost = this.userBid + this.estimate.mempoolBaseFee + this.estimate.vsizeFee;
this.validateChoice();
} }
} }
@ -374,18 +376,19 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
* Account-based acceleration request * Account-based acceleration request
*/ */
accelerateWithMempoolAccount(): void { accelerateWithMempoolAccount(): void {
if (!this.canPay || this.calculating) { if (!this.canPay || this.calculating || this.processing) {
return; return;
} }
this.processing = true;
if (this.accelerationSubscription) { if (this.accelerationSubscription) {
this.accelerationSubscription.unsubscribe(); this.accelerationSubscription.unsubscribe();
} }
this.accelerationSubscription = this.servicesApiService.accelerate$( this.accelerationSubscription = this.servicesApiService.accelerate$(
this.tx.txid, this.tx.txid,
this.userBid, this.userBid,
this.accelerationUUID
).subscribe({ ).subscribe({
next: () => { next: () => {
this.processing = false;
this.apiService.logAccelerationRequest$(this.tx.txid).subscribe(); this.apiService.logAccelerationRequest$(this.tx.txid).subscribe();
this.audioService.playSound('ascend-chime-cartoon'); this.audioService.playSound('ascend-chime-cartoon');
this.showSuccess = true; this.showSuccess = true;
@ -393,6 +396,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
this.moveToStep('paid'); this.moveToStep('paid');
}, },
error: (response) => { error: (response) => {
this.processing = false;
this.accelerateError = response.error; this.accelerateError = response.error;
} }
}); });
@ -402,63 +406,74 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
* Square * Square
*/ */
insertSquare(): void { insertSquare(): void {
//@ts-ignore if (!this.isProdDomain && !isDevMode()) {
if (window.Square) { return;
}
if (window['Square']) {
return; return;
} }
let statsUrl = 'https://sandbox.web.squarecdn.com/v1/square.js'; let statsUrl = 'https://sandbox.web.squarecdn.com/v1/square.js';
if (document.location.hostname === 'mempool-staging.fmt.mempool.space' || if (this.isProdDomain) {
document.location.hostname === 'mempool-staging.va1.mempool.space' || statsUrl = '/square/v1/square.js';
document.location.hostname === 'mempool-staging.fra.mempool.space' ||
document.location.hostname === 'mempool-staging.tk7.mempool.space' ||
document.location.hostname === 'mempool.space') {
statsUrl = 'https://web.squarecdn.com/v1/square.js';
} }
(function() { (function(): void {
const d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0]; const d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
// @ts-ignore
g.type='text/javascript'; g.src=statsUrl; s.parentNode.insertBefore(g, s); g.type='text/javascript'; g.src=statsUrl; s.parentNode.insertBefore(g, s);
})(); })();
} }
setupSquare() { setupSquare(): void {
const init = () => { if (!this.isProdDomain && !isDevMode()) {
return;
}
const init = (): void => {
this.initSquare(); this.initSquare();
}; };
//@ts-ignore if (!window['Square']) {
if (!window.Square) { console.debug('Square.js failed to load properly. Retrying.');
console.debug('Square.js failed to load properly. Retrying in 1 second.'); setTimeout(this.setupSquare.bind(this), 100);
setTimeout(init, 1000);
} else { } else {
init(); init();
} }
} }
async initSquare(): Promise<void> { async initSquare(): Promise<void> {
try { try {
//@ts-ignore this.servicesApiService.setupSquare$().subscribe({
this.payments = window.Square.payments(this.square.appId, this.square.locationId) next: async (ids) => {
const urlParams = new URLSearchParams(window.location.search); this.payments = window['Square'].payments(ids.squareAppId, ids.squareLocationId);
if (this._step === 'cashapp' || urlParams.get('cash_request_id')) { const urlParams = new URLSearchParams(window.location.search);
await this.requestCashAppPayment(); if (this._step === 'cashapp' || urlParams.get('cash_request_id')) {
} else if (this._step === 'applepay') { await this.requestCashAppPayment();
await this.requestApplePayPayment(); } else if (this._step === 'applepay') {
} await this.requestApplePayPayment();
} else if (this._step === 'googlepay') {
await this.requestGooglePayPayment();
}
},
error: () => {
console.debug('Error loading Square Payments');
this.accelerateError = 'cannot_setup_square';
}
});
} catch (e) { } catch (e) {
console.debug('Error loading Square Payments', e); console.debug('Error loading Square Payments', e);
this.cashappError = true; this.accelerateError = 'cannot_setup_square';
return;
} }
} }
/** /**
* APPLE PAY * APPLE PAY
*/ */
async requestApplePayPayment() { async requestApplePayPayment(): Promise<void> {
if (this.processing) {
return;
}
if (this.conversionsSubscription) { if (this.conversionsSubscription) {
this.conversionsSubscription.unsubscribe(); this.conversionsSubscription.unsubscribe();
} }
this.processing = true;
this.conversionsSubscription = this.stateService.conversions$.subscribe( this.conversionsSubscription = this.stateService.conversions$.subscribe(
async (conversions) => { async (conversions) => {
this.conversions = conversions; this.conversions = conversions;
@ -483,6 +498,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
console.error(`Unable to find apple pay button id='apple-pay-button'`); console.error(`Unable to find apple pay button id='apple-pay-button'`);
// Try again // Try again
setTimeout(this.requestApplePayPayment.bind(this), 500); setTimeout(this.requestApplePayPayment.bind(this), 500);
this.processing = false;
return; return;
} }
this.loadingApplePay = false; this.loadingApplePay = false;
@ -494,6 +510,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
if (!card || !card.brand || !card.expMonth || !card.expYear || !card.last4) { if (!card || !card.brand || !card.expMonth || !card.expYear || !card.last4) {
console.error(`Cannot retreive payment card details`); console.error(`Cannot retreive payment card details`);
this.accelerateError = 'apple_pay_no_card_details'; this.accelerateError = 'apple_pay_no_card_details';
this.processing = false;
return; return;
} }
const cardTag = md5(`${card.brand}${card.expMonth}${card.expYear}${card.last4}`.toLowerCase()); const cardTag = md5(`${card.brand}${card.expMonth}${card.expYear}${card.last4}`.toLowerCase());
@ -502,9 +519,11 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
tokenResult.token, tokenResult.token,
cardTag, cardTag,
`accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`, `accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`,
this.accelerationUUID costUSD
).subscribe({ ).subscribe({
next: () => { next: () => {
this.processing = false;
this.apiService.logAccelerationRequest$(this.tx.txid).subscribe();
this.audioService.playSound('ascend-chime-cartoon'); this.audioService.playSound('ascend-chime-cartoon');
if (this.applePay) { if (this.applePay) {
this.applePay.destroy(); this.applePay.destroy();
@ -514,6 +533,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
}, 1000); }, 1000);
}, },
error: (response) => { error: (response) => {
this.processing = false;
this.accelerateError = response.error; this.accelerateError = response.error;
if (!(response.status === 403 && response.error === 'not_available')) { if (!(response.status === 403 && response.error === 'not_available')) {
setTimeout(() => { setTimeout(() => {
@ -525,6 +545,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
} }
}); });
} else { } else {
this.processing = false;
let errorMessage = `Tokenization failed with status: ${tokenResult.status}`; let errorMessage = `Tokenization failed with status: ${tokenResult.status}`;
if (tokenResult.errors) { if (tokenResult.errors) {
errorMessage += ` and errors: ${JSON.stringify( errorMessage += ` and errors: ${JSON.stringify(
@ -535,6 +556,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
} }
}); });
} catch (e) { } catch (e) {
this.processing = false;
console.error(e); console.error(e);
} }
} }
@ -542,13 +564,112 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
} }
/** /**
* CASHAPP * GOOGLE PAY
*/ */
async requestCashAppPayment() { async requestGooglePayPayment(): Promise<void> {
if (this.processing) {
return;
}
if (this.conversionsSubscription) { if (this.conversionsSubscription) {
this.conversionsSubscription.unsubscribe(); this.conversionsSubscription.unsubscribe();
} }
this.processing = true;
this.conversionsSubscription = this.stateService.conversions$.subscribe(
async (conversions) => {
this.conversions = conversions;
if (this.googlePay) {
this.googlePay.destroy();
}
const costUSD = this.cost / 100_000_000 * conversions.USD;
const paymentRequest = this.payments.paymentRequest({
countryCode: 'US',
currencyCode: 'USD',
total: {
amount: costUSD.toFixed(2),
label: 'Total'
}
});
this.googlePay = await this.payments.googlePay(paymentRequest , {
referenceId: `accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`,
});
await this.googlePay.attach(`#google-pay-button`, {
buttonType: 'pay',
buttonSizeMode: 'fill',
});
this.loadingGooglePay = false;
document.getElementById('google-pay-button').addEventListener('click', async event => {
event.preventDefault();
const tokenResult = await this.googlePay.tokenize();
if (tokenResult?.status === 'OK') {
const card = tokenResult.details?.card;
if (!card || !card.brand || !card.expMonth || !card.expYear || !card.last4) {
console.error(`Cannot retreive payment card details`);
this.accelerateError = 'apple_pay_no_card_details';
this.processing = false;
return;
}
const cardTag = md5(`${card.brand}${card.expMonth}${card.expYear}${card.last4}`.toLowerCase());
this.servicesApiService.accelerateWithGooglePay$(
this.tx.txid,
tokenResult.token,
cardTag,
`accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`,
costUSD
).subscribe({
next: () => {
this.processing = false;
this.apiService.logAccelerationRequest$(this.tx.txid).subscribe();
this.audioService.playSound('ascend-chime-cartoon');
if (this.googlePay) {
this.googlePay.destroy();
}
setTimeout(() => {
this.moveToStep('paid');
}, 1000);
},
error: (response) => {
this.processing = false;
this.accelerateError = response.error;
if (!(response.status === 403 && response.error === 'not_available')) {
setTimeout(() => {
// Reset everything by reloading the page :D, can be improved
const urlParams = new URLSearchParams(window.location.search);
window.location.assign(window.location.toString().replace(`?cash_request_id=${urlParams.get('cash_request_id')}`, ``));
}, 3000);
}
}
});
} else {
this.processing = false;
let errorMessage = `Tokenization failed with status: ${tokenResult.status}`;
if (tokenResult.errors) {
errorMessage += ` and errors: ${JSON.stringify(
tokenResult.errors,
)}`;
}
throw new Error(errorMessage);
}
});
}
);
}
/**
* CASHAPP
*/
async requestCashAppPayment(): Promise<void> {
if (this.processing) {
return;
}
if (this.conversionsSubscription) {
this.conversionsSubscription.unsubscribe();
}
this.processing = true;
this.conversionsSubscription = this.stateService.conversions$.subscribe( this.conversionsSubscription = this.stateService.conversions$.subscribe(
async (conversions) => { async (conversions) => {
this.conversions = conversions; this.conversions = conversions;
@ -565,24 +686,21 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
amount: costUSD.toFixed(2), amount: costUSD.toFixed(2),
label: 'Total', label: 'Total',
pending: true, pending: true,
productUrl: `${redirectHostname}/tracker/${this.tx.txid}`, productUrl: `${redirectHostname}/tx/${this.tx.txid}`,
}, }
button: { shape: 'semiround', size: 'small', theme: 'light'}
}); });
this.cashAppPay = await this.payments.cashAppPay(paymentRequest, { this.cashAppPay = await this.payments.cashAppPay(paymentRequest, {
redirectURL: `${redirectHostname}/tracker/${this.tx.txid}`, redirectURL: `${redirectHostname}/tx/${this.tx.txid}`,
referenceId: `accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`, referenceId: `accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`
button: { shape: 'semiround', size: 'small', theme: 'light'}
}); });
if (this.step === 'cashapp') { await this.cashAppPay.attach(`#cash-app-pay`, { theme: 'dark' });
await this.cashAppPay.attach(`#cash-app-pay`, { theme: 'light', size: 'small', shape: 'semiround' })
}
this.loadingCashapp = false; this.loadingCashapp = false;
this.cashAppPay.addEventListener('ontokenization', event => { this.cashAppPay.addEventListener('ontokenization', event => {
const { tokenResult, error } = event.detail; const { tokenResult, error } = event.detail;
if (error) { if (error) {
this.processing = false;
this.accelerateError = error; this.accelerateError = error;
} else if (tokenResult.status === 'OK') { } else if (tokenResult.status === 'OK') {
this.servicesApiService.accelerateWithCashApp$( this.servicesApiService.accelerateWithCashApp$(
@ -590,9 +708,10 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
tokenResult.token, tokenResult.token,
tokenResult.details.cashAppPay.cashtag, tokenResult.details.cashAppPay.cashtag,
tokenResult.details.cashAppPay.referenceId, tokenResult.details.cashAppPay.referenceId,
this.accelerationUUID costUSD
).subscribe({ ).subscribe({
next: () => { next: () => {
this.processing = false;
this.apiService.logAccelerationRequest$(this.tx.txid).subscribe(); this.apiService.logAccelerationRequest$(this.tx.txid).subscribe();
this.audioService.playSound('ascend-chime-cartoon'); this.audioService.playSound('ascend-chime-cartoon');
if (this.cashAppPay) { if (this.cashAppPay) {
@ -607,6 +726,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
}, 1000); }, 1000);
}, },
error: (response) => { error: (response) => {
this.processing = false;
this.accelerateError = response.error; this.accelerateError = response.error;
if (!(response.status === 403 && response.error === 'not_available')) { if (!(response.status === 403 && response.error === 'not_available')) {
setTimeout(() => { setTimeout(() => {
@ -626,7 +746,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
/** /**
* BTCPay * BTCPay
*/ */
async requestBTCPayInvoice() { async requestBTCPayInvoice(): Promise<void> {
this.servicesApiService.generateBTCPayAcceleratorInvoice$(this.tx.txid, this.userBid).pipe( this.servicesApiService.generateBTCPayAcceleratorInvoice$(this.tx.txid, this.userBid).pipe(
switchMap(response => { switchMap(response => {
return this.servicesApiService.retreiveInvoice$(response.btcpayInvoiceId); return this.servicesApiService.retreiveInvoice$(response.btcpayInvoiceId);
@ -656,54 +776,61 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
/** /**
* UI events * UI events
*/ */
selectedOptionChanged(event) { selectedOptionChanged(event): void {
this.selectedOption = event.target.id; this.selectedOption = event.target.id;
} }
get step() { get step(): CheckoutStep {
return this._step; return this._step;
} }
get paymentMethods() { get paymentMethods(): PaymentMethod[] {
return Object.keys(this.estimate?.availablePaymentMethods || {}); return Object.keys(this.estimate?.availablePaymentMethods || {}) as PaymentMethod[];
} }
get couldPayWithBitcoin() { get couldPayWithBitcoin(): boolean {
return !!this.estimate?.availablePaymentMethods?.bitcoin; return !!this.estimate?.availablePaymentMethods?.bitcoin;
} }
get couldPayWithCashapp() { get couldPayWithCashapp(): boolean {
if (!this.cashappEnabled) { if (!this.cashappEnabled) {
return false; return false;
} }
return !!this.estimate?.availablePaymentMethods?.cashapp; return !!this.estimate?.availablePaymentMethods?.cashapp;
} }
get couldPayWithApplePay() { get couldPayWithApplePay(): boolean {
if (!this.applePayEnabled) { if (!this.applePayEnabled) {
return false; return false;
} }
return !!this.estimate?.availablePaymentMethods?.applePay; return !!this.estimate?.availablePaymentMethods?.applePay;
} }
get couldPayWithBalance() { get couldPayWithGooglePay(): boolean {
if (!this.googlePayEnabled) {
return false;
}
return !!this.estimate?.availablePaymentMethods?.googlePay;
}
get couldPayWithBalance(): boolean {
if (!this.hasAccessToBalanceMode) { if (!this.hasAccessToBalanceMode) {
return false; return false;
} }
return !!this.estimate?.availablePaymentMethods?.balance; return !!this.estimate?.availablePaymentMethods?.balance;
} }
get couldPay() { get couldPay(): boolean {
return this.couldPayWithBalance || this.couldPayWithBitcoin || this.couldPayWithCashapp || this.couldPayWithApplePay; return this.couldPayWithBalance || this.couldPayWithBitcoin || this.couldPayWithCashapp || this.couldPayWithApplePay || this.couldPayWithGooglePay;
} }
get canPayWithBitcoin() { get canPayWithBitcoin(): boolean {
const paymentMethod = this.estimate?.availablePaymentMethods?.bitcoin; const paymentMethod = this.estimate?.availablePaymentMethods?.bitcoin;
return paymentMethod && this.cost >= paymentMethod.min && this.cost <= paymentMethod.max; return paymentMethod && this.cost >= paymentMethod.min && this.cost <= paymentMethod.max;
} }
get canPayWithCashapp() { get canPayWithCashapp(): boolean {
if (!this.cashappEnabled || !this.conversions) { if (!this.cashappEnabled || !this.conversions || (!this.isProdDomain && !isDevMode())) {
return false; return false;
} }
@ -718,8 +845,8 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
return false; return false;
} }
get canPayWithApplePay() { get canPayWithApplePay(): boolean {
if (!this.applePayEnabled || !this.conversions) { if (!this.applePayEnabled || !this.conversions || (!this.isProdDomain && !isDevMode())) {
return false; return false;
} }
@ -734,7 +861,23 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
return false; return false;
} }
get canPayWithBalance() { get canPayWithGooglePay(): boolean {
if (!this.googlePayEnabled || !this.conversions || (!this.isProdDomain && !isDevMode())) {
return false;
}
const paymentMethod = this.estimate?.availablePaymentMethods?.googlePay;
if (paymentMethod) {
const costUSD = (this.cost / 100_000_000 * this.conversions.USD);
if (costUSD >= paymentMethod.min && costUSD <= paymentMethod.max) {
return true;
}
}
return false;
}
get canPayWithBalance(): boolean {
if (!this.hasAccessToBalanceMode) { if (!this.hasAccessToBalanceMode) {
return false; return false;
} }
@ -742,11 +885,11 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
return paymentMethod && this.cost >= paymentMethod.min && this.cost <= paymentMethod.max && this.cost <= this.estimate?.userBalance; return paymentMethod && this.cost >= paymentMethod.min && this.cost <= paymentMethod.max && this.cost <= this.estimate?.userBalance;
} }
get canPay() { get canPay(): boolean {
return this.canPayWithBalance || this.canPayWithBitcoin || this.canPayWithCashapp || this.canPayWithApplePay; return this.canPayWithBalance || this.canPayWithBitcoin || this.canPayWithCashapp || this.canPayWithApplePay || this.canPayWithGooglePay;
} }
get hasAccessToBalanceMode() { get hasAccessToBalanceMode(): boolean {
return this.isLoggedIn() && this.estimate?.hasAccess; return this.isLoggedIn() && this.estimate?.hasAccess;
} }

View File

@ -12,7 +12,7 @@
</p> </p>
</div> </div>
<div class="spacer"></div> <div class="spacer"></div>
<span class="fee">{{ bar.class === 'tx' ? '' : '+' }}{{ bar.fee | number }} <span class="symbol" i18n="shared.sat|sat">sat</span></span> <span class="fee">{{ bar.class === 'tx' ? '' : '+' }}{{ bar.fee | number }} <span class="symbol" i18n="shared.sats">sats</span></span>
<div class="spacer"></div> <div class="spacer"></div>
<div class="spacer"></div> <div class="spacer"></div>
</div> </div>

View File

@ -1,6 +1,6 @@
import { Component, Input, Output, OnChanges, EventEmitter, HostListener, OnInit, ViewChild, ElementRef, AfterViewInit, OnDestroy, ChangeDetectorRef } from '@angular/core'; import { Component, Input, Output, OnChanges, EventEmitter, HostListener, OnInit, ViewChild, ElementRef, AfterViewInit, OnDestroy, ChangeDetectorRef } from '@angular/core';
import { Transaction } from '../../interfaces/electrs.interface'; import { Transaction } from '@interfaces/electrs.interface';
import { AccelerationEstimate, RateOption } from './accelerate-checkout.component'; import { AccelerationEstimate, RateOption } from '@components/accelerate-checkout/accelerate-checkout.component';
interface GraphBar { interface GraphBar {
rate: number; rate: number;

View File

@ -0,0 +1,63 @@
<div
#tooltip
*ngIf="accelerationInfo && tooltipPosition !== null"
class="acceleration-tooltip"
[style.left]="tooltipPosition.x + 'px'"
[style.top]="tooltipPosition.y + 'px'"
>
<table>
<tbody>
<tr>
<td class="label" i18n="transaction.status|Transaction Status">Status</td>
<td class="value">
@if (accelerationInfo.status === 'seen') {
<span class="badge badge-primary" i18n="transaction.first-seen|Transaction first seen">First seen</span>
} @else if (accelerationInfo.status === 'accelerated') {
<span class="badge badge-accelerated" i18n="transaction.audit.accelerated">Accelerated</span>
} @else if (accelerationInfo.status === 'mined') {
<span class="badge badge-success" i18n="transaction.rbf.mined">Mined</span>
}
</td>
</tr>
<tr *ngIf="accelerationInfo.fee">
<td class="label" i18n="transaction.fee|Transaction fee">Fee</td>
<td class="value">{{ accelerationInfo.fee | number }} <span class="symbol" i18n="shared.sats">sats</span></td>
</tr>
<tr *ngIf="accelerationInfo.bidBoost >= 0 || accelerationInfo.feeDelta">
<td class="label" i18n="transaction.out-of-band-fees">Out-of-band fees</td>
@if (accelerationInfo.status === 'accelerated') {
<td class="value oobFees">{{ accelerationInfo.feeDelta | number }} <span class="symbol" i18n="shared.sats">sats</span></td>
} @else {
<td class="value oobFees">{{ accelerationInfo.bidBoost | number }} <span class="symbol" i18n="shared.sats">sats</span></td>
}
</tr>
<tr *ngIf="accelerationInfo.fee && accelerationInfo.weight">
@if (accelerationInfo.status === 'seen') {
<td class="label" i18n="transaction.fee-rate|Transaction fee rate">Fee rate</td>
<td class="value"><app-fee-rate [fee]="accelerationInfo.fee" [weight]="accelerationInfo.weight"></app-fee-rate></td>
} @else if (accelerationInfo.status === 'accelerated' || accelerationInfo.status === 'mined') {
<td class="label" i18n="transaction.accelerated-fee-rate|Accelerated transaction fee rate">Accelerated fee rate</td>
@if (accelerationInfo.status === 'accelerated') {
<td class="value oobFees"><app-fee-rate [fee]="accelerationInfo.fee + (accelerationInfo.feeDelta || 0)" [weight]="accelerationInfo.weight"></app-fee-rate></td>
} @else {
<td class="value oobFees"><app-fee-rate [fee]="accelerationInfo.fee + (accelerationInfo.bidBoost || 0)" [weight]="accelerationInfo.weight"></app-fee-rate></td>
}
}
</tr>
<tr *ngIf="['accelerated', 'mined'].includes(accelerationInfo.status) && hasPoolsData()">
<td class="label" i18n="transaction.accelerated-by-hashrate|Accelerated to hashrate">Accelerated by</td>
<td class="value" *ngIf="accelerationInfo.pools">
<ng-container *ngFor="let pool of accelerationInfo.pools; let i = index;">
<img *ngIf="accelerationInfo.poolsData[pool]"
class="pool-logo"
[style.opacity]="accelerationInfo?.minedByPoolUniqueId && pool !== accelerationInfo?.minedByPoolUniqueId ? '0.3' : '1'"
[src]="'/resources/mining-pools/' + accelerationInfo.poolsData[pool].slug + '.svg'"
onError="this.src = '/resources/mining-pools/default.svg'"
[alt]="'Logo of ' + pool.name + ' mining pool'">
<br *ngIf="i % 6 === 5">
</ng-container>
</td>
</tr>
</tbody>
</table>
</div>

View File

@ -0,0 +1,41 @@
.acceleration-tooltip {
position: fixed;
z-index: 3;
background: color-mix(in srgb, var(--active-bg) 95%, transparent);
border-radius: 4px;
box-shadow: 1px 1px 10px rgba(0,0,0,0.5);
color: var(--tooltip-grey);
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 10px 15px;
text-align: left;
pointer-events: none;
.badge.badge-accelerated {
background-color: var(--tertiary);
color: white;
}
.value {
text-align: end;
}
.label {
padding-right: 30px;
vertical-align: top;
}
.pool-logo {
width: 22px;
height: 22px;
position: relative;
top: -1px;
margin-right: 4px;
margin-bottom: 4px;
}
.oobFees {
color: #905cf4;
}
}

View File

@ -0,0 +1,38 @@
import { Component, ElementRef, ViewChild, Input, OnChanges } from '@angular/core';
@Component({
selector: 'app-acceleration-timeline-tooltip',
templateUrl: './acceleration-timeline-tooltip.component.html',
styleUrls: ['./acceleration-timeline-tooltip.component.scss'],
})
export class AccelerationTimelineTooltipComponent implements OnChanges {
@Input() accelerationInfo: any;
@Input() cursorPosition: { x: number, y: number };
tooltipPosition: any = null;
@ViewChild('tooltip') tooltipElement: ElementRef<HTMLCanvasElement>;
constructor() {}
ngOnChanges(changes): void {
if (changes.cursorPosition && changes.cursorPosition.currentValue) {
let x = Math.max(10, changes.cursorPosition.currentValue.x - 50);
let y = changes.cursorPosition.currentValue.y + 20;
if (this.tooltipElement) {
const elementBounds = this.tooltipElement.nativeElement.getBoundingClientRect();
if ((x + elementBounds.width) > (window.innerWidth - 10)) {
x = Math.max(0, window.innerWidth - elementBounds.width - 10);
}
if (y + elementBounds.height > (window.innerHeight - 20)) {
y = y - elementBounds.height - 20;
}
}
this.tooltipPosition = { x, y };
}
}
hasPoolsData(): boolean {
return Object.keys(this.accelerationInfo.poolsData).length > 0;
}
}

View File

@ -1,6 +1,6 @@
<div class="acceleration-timeline box" [class.lower-padding]="!tx.status.confirmed"> <div class="acceleration-timeline box" [class.lower-padding]="!tx.status.confirmed">
<div class="timeline-wrapper"> <div class="timeline-wrapper">
@if (!tx.status.confirmed) { @if (!tx.status.confirmed || canceled) {
<div class="timeline"> <div class="timeline">
<div class="intervals"> <div class="intervals">
<div class="node-spacer"></div> <div class="node-spacer"></div>
@ -8,8 +8,8 @@
<div class="node-spacer"></div> <div class="node-spacer"></div>
<div class="interval"> <div class="interval">
<div class="interval-time"> <div class="interval-time">
@if (eta) { @if (eta && !canceled) {
~<app-time [time]="eta?.wait / 1000"></app-time> <!-- <span *ngIf="accelerateRatio > 1" class="compare"> ({{ accelerateRatio }}x faster)</span> --> ~<app-time [time]="eta?.wait / 1000"></app-time>
} }
</div> </div>
</div> </div>
@ -19,16 +19,20 @@
<div class="node-spacer"></div> <div class="node-spacer"></div>
<div class="interval-spacer"></div> <div class="interval-spacer"></div>
<div class="node"> <div class="node">
<div class="acc-to-confirmed right go-faster"></div> <div class="acc-to-confirmed right go-faster" [class.no-animation]="canceled"></div>
</div> </div>
<div class="interval-spacer"> <div class="interval-spacer">
</div> </div>
<div class="node" [id]="'confirmed'"> <div class="node" [id]="'confirmed'">
<div class="acc-to-confirmed left go-faster"></div> <div class="acc-to-confirmed left go-faster" [class.no-animation]="canceled"></div>
<div class="shape-border waiting"> <div class="shape-border waiting">
<div class="shape animate"></div> <div class="shape"></div>
</div> </div>
<div class="status"><span class="badge badge-waiting" i18n="transaction.rbf.mined">Mined</span></div> @if (canceled) {
<div class="status"><span class="badge badge-danger" i18n="accelerator.canceled">Canceled</span></div>
} @else {
<div class="status"><span class="badge badge-waiting" i18n="transaction.rbf.mined">Mined</span></div>
}
</div> </div>
</div> </div>
</div> </div>
@ -38,18 +42,16 @@
<div class="node-spacer"></div> <div class="node-spacer"></div>
<div class="interval"> <div class="interval">
<div class="interval-time"> <div class="interval-time">
<app-time [time]="acceleratedAt - transactionTime"></app-time> <app-time [time]="firstSeenToAccelerated"></app-time>
</div> </div>
</div> </div>
<div class="node-spacer"></div> <div class="node-spacer"></div>
<div class="interval"> <div class="interval">
<div class="interval-time"> <div class="interval-time">
@if (tx.status.confirmed) { @if (tx.status.confirmed) {
<div class="interval-time"> <app-time [time]="acceleratedToMined"></app-time>
<app-time [time]="tx.status.block_time - acceleratedAt"></app-time> } @else if (eta && canceled) {
</div> ~<app-time [time]="eta?.wait / 1000"></app-time>
} @else if (standardETA && !tx.status.confirmed) {
<!-- ~<app-time [time]="standardETA / 1000 - now"></app-time> -->
} }
</div> </div>
</div> </div>
@ -58,7 +60,7 @@
<div class="nodes"> <div class="nodes">
<div class="node" [id]="'first-seen'"> <div class="node" [id]="'first-seen'">
<div class="seen-to-acc right"></div> <div class="seen-to-acc right"></div>
<div class="shape-border"> <div class="shape-border hovering" (pointerover)="onHover($event, 'seen');" (pointerout)="onBlur($event);">
<div class="shape"></div> <div class="shape"></div>
</div> </div>
<div class="status"><span class="badge badge-primary" i18n="transaction.first-seen|Transaction first seen">First seen</span></div> <div class="status"><span class="badge badge-primary" i18n="transaction.first-seen|Transaction first seen">First seen</span></div>
@ -73,47 +75,50 @@
<div class="interval-spacer"> <div class="interval-spacer">
<div class="seen-to-acc"></div> <div class="seen-to-acc"></div>
</div> </div>
<div class="node" [class.accelerated]="!tx.status.confirmed" [id]="'accelerated'"> <div class="node" [class.accelerated]="!tx.status.confirmed && !canceled" [id]="'accelerated'">
<div class="seen-to-acc left"></div> <div class="seen-to-acc left"></div>
@if (tx.status.confirmed) { @if (tx.status.confirmed && !canceled) {
<div class="acc-to-confirmed right"></div> <div class="acc-to-confirmed right"></div>
} @else { } @else {
<div class="seen-to-acc right"></div> <div class="seen-to-acc right"></div>
} }
<div class="shape-border"> <div class="shape-border hovering" (pointerover)="onHover($event, 'accelerated');" (pointerout)="onBlur($event);">
<div class="shape"></div> <div class="shape"></div>
@if (!tx.status.confirmed) { @if (!tx.status.confirmed || canceled) {
<div class="connector down loading"></div> <div class="connector down" [class.loading]="!canceled"></div>
} }
</div> </div>
@if (tx.status.confirmed) { @if (tx.status.confirmed && !canceled) {
<div class="status"><span class="badge badge-accelerated" i18n="transaction.audit.accelerated">Accelerated</span></div> <div class="status"><span class="badge badge-accelerated" i18n="transaction.audit.accelerated">Accelerated</span></div>
} }
<div class="time offset-left" [class.no-margin]="!tx.status.confirmed"> <div class="time" [class.no-margin]="!tx.status.confirmed || canceled" [class.offset-left]="!tx.status.confirmed || canceled">
@if (!tx.status.confirmed) { @if (!tx.status.confirmed) {
<span i18n="transaction.audit.accelerated">Accelerated</span>{{ "" }} <span i18n="transaction.audit.accelerated">Accelerated</span>{{ "" }}
} }
@if (useAbsoluteTime) { @if (useAbsoluteTime) {
<span>{{ acceleratedAt * 1000 | date }}</span> <span>{{ acceleratedAt * 1000 | date }}</span>
} @else { } @else {
<app-time kind="since" [time]="acceleratedAt" [lowercaseStart]="true"></app-time> <app-time kind="since" [time]="acceleratedAt" [lowercaseStart]="!tx.status.confirmed || canceled"></app-time>
} }
</div> </div>
</div> </div>
<div class="interval-spacer"> <div class="interval-spacer">
@if (tx.status.confirmed) { @if (tx.status.confirmed && !canceled) {
<div class="acc-to-confirmed"></div> <div class="acc-to-confirmed"></div>
} @else { } @else {
<div class="seen-to-acc"></div> <div class="seen-to-acc"></div>
} }
</div> </div>
<div class="node" [class.selected]="tx.status.confirmed" [id]="'confirmed'"> <div class="node" [class.selected]="tx.status.confirmed" [id]="'confirmed'">
@if (tx.status.confirmed) { @if (tx.status.confirmed && !canceled) {
<div class="acc-to-confirmed left"></div> <div class="acc-to-confirmed left"></div>
} @else { } @else {
<div class="seen-to-acc left"></div> <div class="seen-to-acc left"></div>
} }
<div class="shape-border" [class.waiting]="!tx.status.confirmed"> <div class="shape-border"
[ngClass]="{'waiting': !tx.status.confirmed, 'hovering': tx.status.confirmed}"
(pointerover)="onHover($event, tx.status.confirmed ? 'mined' : null)"
(pointerout)="onBlur($event);">
<div class="shape"></div> <div class="shape"></div>
</div> </div>
@if (tx.status.confirmed) { @if (tx.status.confirmed) {
@ -130,4 +135,10 @@
</div> </div>
</div> </div>
</div> </div>
<app-acceleration-timeline-tooltip
[accelerationInfo]="hoverInfo"
[cursorPosition]="tooltipPosition"
></app-acceleration-timeline-tooltip>
</div> </div>

View File

@ -129,6 +129,9 @@
margin-left: calc(-4em + 5px); margin-left: calc(-4em + 5px);
animation: goFasterLeft 0.8s infinite linear; animation: goFasterLeft 0.8s infinite linear;
} }
&.no-animation {
animation: none;
}
} }
&.left { &.left {
@ -152,9 +155,16 @@
margin-bottom: -8px; margin-bottom: -8px;
transform: translateY(-50%); transform: translateY(-50%);
border-radius: 50%; border-radius: 50%;
cursor: pointer;
padding: 4px; padding: 4px;
background: transparent; background: transparent;
transition: background-color 300ms, padding 300ms;
&.hovering {
cursor: pointer;
&:hover {
padding: 0px;
}
}
.shape { .shape {
position: relative; position: relative;

View File

@ -1,6 +1,8 @@
import { Component, Input, OnInit, OnChanges } from '@angular/core'; import { Component, Input, OnInit, OnChanges, HostListener } from '@angular/core';
import { ETA } from '../../services/eta.service'; import { ETA } from '@app/services/eta.service';
import { Transaction } from '../../interfaces/electrs.interface'; import { Transaction } from '@interfaces/electrs.interface';
import { Acceleration, SinglePoolStats } from '@interfaces/node-api.interface';
import { MiningService } from '@app/services/mining.service';
@Component({ @Component({
selector: 'app-acceleration-timeline', selector: 'app-acceleration-timeline',
@ -9,47 +11,82 @@ import { Transaction } from '../../interfaces/electrs.interface';
}) })
export class AccelerationTimelineComponent implements OnInit, OnChanges { export class AccelerationTimelineComponent implements OnInit, OnChanges {
@Input() transactionTime: number; @Input() transactionTime: number;
@Input() acceleratedAt: number;
@Input() tx: Transaction; @Input() tx: Transaction;
@Input() accelerationInfo: Acceleration;
@Input() eta: ETA; @Input() eta: ETA;
// A mined transaction has standard ETA and accelerated ETA undefined @Input() canceled: boolean;
// A transaction in mempool has either standardETA defined (if accelerated) or acceleratedETA defined (if not accelerated yet)
@Input() standardETA: number;
@Input() acceleratedETA: number;
acceleratedAt: number;
now: number; now: number;
accelerateRatio: number; accelerateRatio: number;
useAbsoluteTime: boolean = false; useAbsoluteTime: boolean = false;
interval: number; firstSeenToAccelerated: number;
acceleratedToMined: number;
constructor() {} tooltipPosition = null;
hoverInfo: any = null;
poolsData: { [id: number]: SinglePoolStats } = {};
constructor(
private miningService: MiningService,
) {}
ngOnInit(): void { ngOnInit(): void {
this.acceleratedAt = this.tx.acceleratedAt ?? new Date().getTime() / 1000; this.updateTimes();
this.now = Math.floor(new Date().getTime() / 1000);
this.useAbsoluteTime = this.tx.status.block_time < this.now - 7 * 24 * 3600;
this.interval = window.setInterval(() => { this.miningService.getPools().subscribe(pools => {
this.now = Math.floor(new Date().getTime() / 1000); for (const pool of pools) {
this.useAbsoluteTime = this.tx.status.block_time < this.now - 7 * 24 * 3600; this.poolsData[pool.unique_id] = pool;
}, 60000); }
});
} }
ngOnChanges(changes): void { ngOnChanges(changes): void {
// Hide standard ETA while we don't have a proper standard ETA calculation, see https://github.com/mempool/mempool/issues/65 this.updateTimes();
// if (changes?.eta?.currentValue || changes?.standardETA?.currentValue || changes?.acceleratedETA?.currentValue) {
// if (changes?.eta?.currentValue) {
// if (changes?.acceleratedETA?.currentValue) {
// this.accelerateRatio = Math.floor((Math.floor(changes.eta.currentValue.time / 1000) - this.now) / (Math.floor(changes.acceleratedETA.currentValue / 1000) - this.now));
// } else if (changes?.standardETA?.currentValue) {
// this.accelerateRatio = Math.floor((Math.floor(changes.standardETA.currentValue / 1000) - this.now) / (Math.floor(changes.eta.currentValue.time / 1000) - this.now));
// }
// }
// }
} }
ngOnDestroy(): void { updateTimes(): void {
clearInterval(this.interval); this.now = Math.floor(new Date().getTime() / 1000);
this.useAbsoluteTime = this.tx.status.block_time < this.now - 7 * 24 * 3600;
this.firstSeenToAccelerated = Math.max(0, this.acceleratedAt - this.transactionTime);
this.acceleratedToMined = Math.max(0, this.tx.status.block_time - this.acceleratedAt);
}
onHover(event, status: string): void {
if (status === 'seen') {
this.hoverInfo = {
status,
fee: this.tx.fee,
weight: this.tx.weight
};
} else if (status === 'accelerated') {
this.hoverInfo = {
status,
fee: this.accelerationInfo?.effectiveFee || this.tx.fee,
weight: this.tx.weight,
feeDelta: this.accelerationInfo?.feeDelta || this.tx.feeDelta,
pools: this.tx.acceleratedBy || this.accelerationInfo?.pools,
poolsData: this.poolsData
};
} else if (status === 'mined') {
this.hoverInfo = {
status,
fee: this.accelerationInfo?.effectiveFee,
weight: this.tx.weight,
bidBoost: this.accelerationInfo?.bidBoost,
minedByPoolUniqueId: this.accelerationInfo?.minedByPoolUniqueId,
pools: this.tx.acceleratedBy || this.accelerationInfo?.pools,
poolsData: this.poolsData
};
}
}
onBlur(event): void {
this.hoverInfo = null;
}
@HostListener('pointermove', ['$event'])
onPointerMove(event) {
this.tooltipPosition = { x: event.clientX, y: event.clientY };
} }
} }

View File

@ -1,18 +1,18 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, NgZone, OnChanges, OnDestroy, OnInit, SimpleChanges } from '@angular/core'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, NgZone, OnChanges, OnDestroy, OnInit, SimpleChanges } from '@angular/core';
import { EChartsOption } from '../../../graphs/echarts'; import { EChartsOption } from '@app/graphs/echarts';
import { Observable, Subject, Subscription, combineLatest, fromEvent, merge, share } from 'rxjs'; import { Observable, Subject, Subscription, combineLatest, fromEvent, merge, share } from 'rxjs';
import { startWith, switchMap, tap } from 'rxjs/operators'; import { startWith, switchMap, tap } from 'rxjs/operators';
import { SeoService } from '../../../services/seo.service'; import { SeoService } from '@app/services/seo.service';
import { formatNumber } from '@angular/common'; import { formatNumber } from '@angular/common';
import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
import { download, formatterXAxis, formatterXAxisLabel, formatterXAxisTimeCategory } from '../../../shared/graphs.utils'; import { download, formatterXAxis, formatterXAxisLabel, formatterXAxisTimeCategory } from '@app/shared/graphs.utils';
import { StorageService } from '../../../services/storage.service'; import { StorageService } from '@app/services/storage.service';
import { MiningService } from '../../../services/mining.service'; import { MiningService } from '@app/services/mining.service';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { Acceleration } from '../../../interfaces/node-api.interface'; import { Acceleration } from '@interfaces/node-api.interface';
import { ServicesApiServices } from '../../../services/services-api.service'; import { ServicesApiServices } from '@app/services/services-api.service';
import { StateService } from '../../../services/state.service'; import { StateService } from '@app/services/state.service';
import { RelativeUrlPipe } from '../../../shared/pipes/relative-url/relative-url.pipe'; import { RelativeUrlPipe } from '@app/shared/pipes/relative-url/relative-url.pipe';
@Component({ @Component({
selector: 'app-acceleration-fees-graph', selector: 'app-acceleration-fees-graph',
@ -23,7 +23,7 @@ import { RelativeUrlPipe } from '../../../shared/pipes/relative-url/relative-url
position: absolute; position: absolute;
top: 50%; top: 50%;
left: calc(50% - 15px); left: calc(50% - 15px);
z-index: 100; z-index: 99;
} }
`], `],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@ -264,7 +264,7 @@ export class AccelerationFeesGraphComponent implements OnInit, OnChanges, OnDest
type: 'bar', type: 'bar',
barWidth: '90%', barWidth: '90%',
large: true, large: true,
barMinHeight: 1, barMinHeight: 3,
}, },
], ],
dataZoom: (this.widget || data.length === 0 )? undefined : [{ dataZoom: (this.widget || data.length === 0 )? undefined : [{

View File

@ -1,6 +1,6 @@
import { ChangeDetectionStrategy, Component, Input, OnChanges, OnInit } from '@angular/core'; import { ChangeDetectionStrategy, Component, Input, OnChanges, OnInit } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { ServicesApiServices } from '../../../services/services-api.service'; import { ServicesApiServices } from '@app/services/services-api.service';
export type AccelerationStats = { export type AccelerationStats = {
totalRequested: number; totalRequested: number;

View File

@ -4,8 +4,8 @@
<div class="clearfix"></div> <div class="clearfix"></div>
<div class="acceleration-list" *ngIf="accelerationList$ | async as accelerations"> <div class="acceleration-list" *ngIf="{ accelerations: accelerationList$ | async } as state">
<table *ngIf="!accelerations || accelerations.length; else noData" class="table table-borderless table-fixed"> <table *ngIf="nonEmptyAccelerations; else noData" class="table table-borderless table-fixed">
<thead> <thead>
<th class="txid text-left" i18n="dashboard.latest-transactions.txid">TXID</th> <th class="txid text-left" i18n="dashboard.latest-transactions.txid">TXID</th>
<ng-container *ngIf="pending"> <ng-container *ngIf="pending">
@ -21,8 +21,8 @@
<th class="date text-right" i18n="accelerator.requested" *ngIf="!this.widget">Requested</th> <th class="date text-right" i18n="accelerator.requested" *ngIf="!this.widget">Requested</th>
</ng-container> </ng-container>
</thead> </thead>
<tbody *ngIf="accelerations; else skeleton" [style]="isLoading ? 'opacity: 0.75' : ''"> <tbody *ngIf="state.accelerations && nonEmptyAccelerations; else skeleton" [style]="isLoading ? 'opacity: 0.75' : ''">
<tr *ngFor="let acceleration of accelerations; let i= index;"> <tr *ngFor="let acceleration of state.accelerations; let i= index;">
<td class="txid text-left"> <td class="txid text-left">
<a [routerLink]="['/tx' | relativeUrl, acceleration.txid]"> <a [routerLink]="['/tx' | relativeUrl, acceleration.txid]">
<app-truncate [text]="acceleration.txid" [lastChars]="5"></app-truncate> <app-truncate [text]="acceleration.txid" [lastChars]="5"></app-truncate>
@ -33,7 +33,7 @@
<app-fee-rate [fee]="acceleration.effectiveFee" [weight]="acceleration.effectiveVsize * 4"></app-fee-rate> <app-fee-rate [fee]="acceleration.effectiveFee" [weight]="acceleration.effectiveVsize * 4"></app-fee-rate>
</td> </td>
<td class="bid text-right"> <td class="bid text-right">
{{ (acceleration.feeDelta) | number }} <span class="symbol" i18n="shared.sat|sat">sat</span> {{ (acceleration.feeDelta) | number }} <span class="symbol" i18n="shared.sats">sats</span>
</td> </td>
<td class="time text-right"> <td class="time text-right">
<app-time kind="since" [time]="acceleration.added" [fastRender]="true" [showTooltip]="true"></app-time> <app-time kind="since" [time]="acceleration.added" [fastRender]="true" [showTooltip]="true"></app-time>
@ -41,7 +41,7 @@
</ng-container> </ng-container>
<ng-container *ngIf="!pending"> <ng-container *ngIf="!pending">
<td *ngIf="acceleration.boost != null" class="fee text-right"> <td *ngIf="acceleration.boost != null" class="fee text-right">
{{ acceleration.boost | number }} <span class="symbol" i18n="shared.sat|sat">sat</span> {{ acceleration.boost | number }} <span class="symbol" i18n="shared.sats">sats</span>
</td> </td>
<td *ngIf="acceleration.boost == null" class="fee text-right"> <td *ngIf="acceleration.boost == null" class="fee text-right">
~ ~
@ -62,8 +62,9 @@
</td> </td>
<td class="status text-right"> <td class="status text-right">
<span *ngIf="acceleration.status === 'accelerating'" class="badge badge-warning" i18n="accelerator.pending">Pending</span> <span *ngIf="acceleration.status === 'accelerating'" class="badge badge-warning" i18n="accelerator.pending">Pending</span>
<span *ngIf="acceleration.status.includes('completed')" class="badge badge-success" i18n="">Completed <span *ngIf="acceleration.status === 'completed_provisional'">🔄</span></span> <span *ngIf="acceleration.status.includes('completed') && acceleration.minedByPoolUniqueId && pools[acceleration.minedByPoolUniqueId]" class="badge badge-success"><ng-container i18n="accelerator.completed">Completed</ng-container><span *ngIf="acceleration.status === 'completed_provisional'">&nbsp;</span></span>
<span *ngIf="acceleration.status.includes('failed')" class="badge badge-danger" i18n="accelerator.canceled">Failed <span *ngIf="acceleration.status === 'failed_provisional'">🔄</span></span> <span *ngIf="acceleration.status.includes('completed') && (!acceleration.minedByPoolUniqueId || !pools[acceleration.minedByPoolUniqueId])" class="badge badge-success"><ng-container i18n="transaction.rbf.mined">Mined</ng-container><span *ngIf="acceleration.status === 'completed_provisional'">&nbsp;</span></span>
<span *ngIf="acceleration.status.includes('failed')" class="badge badge-danger"><ng-container i18n="accelerator.canceled">Canceled</ng-container><span *ngIf="acceleration.status === 'failed_provisional'">&nbsp;</span></span>
</td> </td>
<td class="date text-right" *ngIf="!this.widget"> <td class="date text-right" *ngIf="!this.widget">
<app-time kind="since" [time]="acceleration.added" [fastRender]="true" [showTooltip]="true"></app-time> <app-time kind="since" [time]="acceleration.added" [fastRender]="true" [showTooltip]="true"></app-time>
@ -72,22 +73,47 @@
</tr> </tr>
</tbody> </tbody>
<ng-template #skeleton> <ng-template #skeleton>
<tbody> @if (!pending) {
<tr *ngFor="let item of skeletonLines"> <tbody>
<td class="txid text-left"> <tr *ngFor="let item of skeletonLines">
<span class="skeleton-loader" style="max-width: 75px"></span> <td class="txid text-left">
</td> <span class="skeleton-loader" style="max-width: 200px"></span>
<td class="fee text-right"> </td>
<span class="skeleton-loader" style="max-width: 75px"></span> <td class="fee text-right">
</td> <span class="skeleton-loader" style="max-width: 100px"></span>
<td class="fee-delta text-right"> </td>
<span class="skeleton-loader" style="max-width: 75px"></span> <td class="block text-right">
</td> <span class="skeleton-loader" style="max-width: 100px"></span>
<td class="status text-right"> </td>
<span class="skeleton-loader" style="max-width: 75px"></span> <td class="pool text-right" *ngIf="!this.widget">
</td> <span class="skeleton-loader" style="max-width: 100px"></span>
</tr> </td>
</tbody> <td class="status text-right">
<span class="skeleton-loader" style="max-width: 100px"></span>
</td>
<td class="date text-right" *ngIf="!this.widget">
<span class="skeleton-loader" style="max-width: 100px"></span>
</td>
</tr>
</tbody>
} @else {
<tbody>
<tr *ngFor="let item of skeletonLines">
<td class="txid text-left">
<span class="skeleton-loader" style="max-width: 100px"></span>
</td>
<td class="fee-rate text-right">
<span class="skeleton-loader" style="max-width: 100px"></span>
</td>
<td class="bid text-right">
<span class="skeleton-loader" style="max-width: 100px"></span>
</td>
<td class="time text-right">
<span class="skeleton-loader" style="max-width: 100px"></span>
</td>
</tr>
</tbody>
}
</ng-template> </ng-template>
</table> </table>

View File

@ -85,8 +85,8 @@ tr, td, th {
} }
.pool-logo { .pool-logo {
width: 22px; width: 18px;
height: 22px; height: 18px;
position: relative; position: relative;
top: -1px; top: -1px;
margin-right: 2px; margin-right: 2px;

View File

@ -1,12 +1,12 @@
import { Component, OnInit, ChangeDetectionStrategy, Input, ChangeDetectorRef, OnDestroy, Inject, LOCALE_ID } from '@angular/core'; import { Component, OnInit, ChangeDetectionStrategy, Input, ChangeDetectorRef, OnDestroy, Inject, LOCALE_ID } from '@angular/core';
import { BehaviorSubject, Observable, Subscription, catchError, filter, of, switchMap, tap, throttleTime } from 'rxjs'; import { BehaviorSubject, Observable, Subscription, catchError, combineLatest, filter, of, switchMap, tap, throttleTime, timer } from 'rxjs';
import { Acceleration, BlockExtended, SinglePoolStats } from '../../../interfaces/node-api.interface'; import { Acceleration, BlockExtended, SinglePoolStats } from '@interfaces/node-api.interface';
import { StateService } from '../../../services/state.service'; import { StateService } from '@app/services/state.service';
import { WebsocketService } from '../../../services/websocket.service'; import { WebsocketService } from '@app/services/websocket.service';
import { ServicesApiServices } from '../../../services/services-api.service'; import { ServicesApiServices } from '@app/services/services-api.service';
import { SeoService } from '../../../services/seo.service'; import { SeoService } from '@app/services/seo.service';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { MiningService } from '../../../services/mining.service'; import { MiningService } from '@app/services/mining.service';
@Component({ @Component({
selector: 'app-accelerations-list', selector: 'app-accelerations-list',
@ -32,6 +32,7 @@ export class AccelerationsListComponent implements OnInit, OnDestroy {
dir: 'rtl' | 'ltr' = 'ltr'; dir: 'rtl' | 'ltr' = 'ltr';
paramSubscription: Subscription; paramSubscription: Subscription;
pools: { [id: number]: SinglePoolStats } = {}; pools: { [id: number]: SinglePoolStats } = {};
nonEmptyAccelerations: boolean = true;
constructor( constructor(
private servicesApiService: ServicesApiServices, private servicesApiService: ServicesApiServices,
@ -50,12 +51,21 @@ export class AccelerationsListComponent implements OnInit, OnDestroy {
} }
ngOnInit(): void { ngOnInit(): void {
this.miningService.getPools().subscribe(pools => {
for (const pool of pools) {
this.pools[pool.unique_id] = pool;
}
});
if (!this.widget) { if (!this.widget) {
this.websocketService.want(['blocks']); this.websocketService.want(['blocks']);
this.seoService.setTitle($localize`:@@02573b6980a2d611b4361a2595a4447e390058cd:Accelerations`); this.seoService.setTitle($localize`:@@02573b6980a2d611b4361a2595a4447e390058cd:Accelerations`);
this.paramSubscription = this.route.params.pipe( this.paramSubscription = combineLatest([
tap(params => { this.route.params,
timer(0),
]).pipe(
tap(([params]) => {
this.page = +params['page'] || 1; this.page = +params['page'] || 1;
this.pageSubject.next(this.page); this.pageSubject.next(this.page);
}) })
@ -82,12 +92,6 @@ export class AccelerationsListComponent implements OnInit, OnDestroy {
).subscribe(() => { ).subscribe(() => {
this.pageChange(this.page); this.pageChange(this.page);
}); });
this.miningService.getMiningStats('1m').subscribe(stats => {
for (const pool of stats.pools) {
this.pools[pool.poolUniqueId] = pool;
}
});
} }
this.skeletonLines = this.widget === true ? [...Array(6).keys()] : [...Array(15).keys()]; this.skeletonLines = this.widget === true ? [...Array(6).keys()] : [...Array(15).keys()];
@ -115,6 +119,7 @@ export class AccelerationsListComponent implements OnInit, OnDestroy {
for (const acc of accelerations) { for (const acc of accelerations) {
acc.boost = acc.boostCost != null ? acc.boostCost : acc.bidBoost; acc.boost = acc.boostCost != null ? acc.boostCost : acc.bidBoost;
} }
this.nonEmptyAccelerations = accelerations.length > 0;
if (this.widget) { if (this.widget) {
return of(accelerations.slice(0, 6)); return of(accelerations.slice(0, 6));
} else { } else {
@ -146,4 +151,4 @@ export class AccelerationsListComponent implements OnInit, OnDestroy {
this.paramSubscription?.unsubscribe(); this.paramSubscription?.unsubscribe();
this.keyNavigationSubscription?.unsubscribe(); this.keyNavigationSubscription?.unsubscribe();
} }
} }

View File

@ -1,18 +1,18 @@
import { ChangeDetectionStrategy, Component, HostListener, Inject, OnDestroy, OnInit, PLATFORM_ID } from '@angular/core'; import { ChangeDetectionStrategy, Component, HostListener, Inject, OnDestroy, OnInit, PLATFORM_ID } from '@angular/core';
import { SeoService } from '../../../services/seo.service'; import { SeoService } from '@app/services/seo.service';
import { OpenGraphService } from '../../../services/opengraph.service'; import { OpenGraphService } from '@app/services/opengraph.service';
import { WebsocketService } from '../../../services/websocket.service'; import { WebsocketService } from '@app/services/websocket.service';
import { Acceleration, BlockExtended } from '../../../interfaces/node-api.interface'; import { Acceleration, BlockExtended } from '@interfaces/node-api.interface';
import { StateService } from '../../../services/state.service'; import { StateService } from '@app/services/state.service';
import { Observable, Subscription, catchError, combineLatest, distinctUntilChanged, map, of, share, switchMap, tap } from 'rxjs'; import { Observable, Subscription, catchError, combineLatest, distinctUntilChanged, map, of, share, switchMap, tap } from 'rxjs';
import { Color } from '../../block-overview-graph/sprite-types'; import { Color } from '@components/block-overview-graph/sprite-types';
import { hexToColor } from '../../block-overview-graph/utils'; import { hexToColor } from '@components/block-overview-graph/utils';
import TxView from '../../block-overview-graph/tx-view'; import TxView from '@components/block-overview-graph/tx-view';
import { feeLevels, defaultMempoolFeeColors, contrastMempoolFeeColors } from '../../../app.constants'; import { feeLevels, defaultMempoolFeeColors, contrastMempoolFeeColors } from '@app/app.constants';
import { ServicesApiServices } from '../../../services/services-api.service'; import { ServicesApiServices } from '@app/services/services-api.service';
import { detectWebGL } from '../../../shared/graphs.utils'; import { detectWebGL } from '@app/shared/graphs.utils';
import { AudioService } from '../../../services/audio.service'; import { AudioService } from '@app/services/audio.service';
import { ThemeService } from '../../../services/theme.service'; import { ThemeService } from '@app/services/theme.service';
const acceleratedColor: Color = hexToColor('8F5FF6'); const acceleratedColor: Color = hexToColor('8F5FF6');
const normalColors = defaultMempoolFeeColors.map(hex => hexToColor(hex + '5F')); const normalColors = defaultMempoolFeeColors.map(hex => hexToColor(hex + '5F'));

View File

@ -1,7 +1,7 @@
@if (chartOnly) { @if (chartOnly) {
<ng-container *ngTemplateOutlet="pieChart"></ng-container> <ng-container *ngTemplateOutlet="pieChart"></ng-container>
} @else { } @else {
<table> <table style="width: 100%;">
<tbody> <tbody>
<tr> <tr>
<td class="td-width field-label" [class]="chartPositionLeft ? 'chart-left' : ''" i18n="transaction.accelerated-to-feerate|Accelerated to feerate">Accelerated to</td> <td class="td-width field-label" [class]="chartPositionLeft ? 'chart-left' : ''" i18n="transaction.accelerated-to-feerate|Accelerated to feerate">Accelerated to</td>
@ -10,17 +10,17 @@
</td> </td>
<td class="field-value" [class]="chartPositionLeft ? 'chart-left' : ''"> <td class="field-value" [class]="chartPositionLeft ? 'chart-left' : ''">
<div class="effective-fee-container"> <div class="effective-fee-container">
@if (accelerationInfo?.acceleratedFeeRate && (!tx.effectiveFeePerVsize || accelerationInfo.acceleratedFeeRate >= tx.effectiveFeePerVsize)) { @if (accelerationInfo?.acceleratedFeeRate && (!effectiveFeeRate || accelerationInfo.acceleratedFeeRate >= effectiveFeeRate)) {
<app-fee-rate [fee]="accelerationInfo.acceleratedFeeRate"></app-fee-rate> <app-fee-rate class="oobFees" [fee]="accelerationInfo.acceleratedFeeRate"></app-fee-rate>
} @else { } @else {
<app-fee-rate [fee]="tx.effectiveFeePerVsize"></app-fee-rate> <app-fee-rate class="oobFees" [fee]="effectiveFeeRate"></app-fee-rate>
} }
</div> </div>
</td> </td>
<td class="pie-chart" rowspan="2" *ngIf="!chartPositionLeft"> <td class="pie-chart" rowspan="2" *ngIf="!chartPositionLeft">
<div class="d-flex justify-content-between align-items-start"> <div class="d-flex justify-content-between align-items-start">
@if (hasCpfp) { @if (hasCpfp) {
<button type="button" class="btn btn-outline-info btn-sm btn-small-height float-right mt-0" (click)="onToggleCpfp()">CPFP <fa-icon [icon]="['fas', 'info-circle']" [fixedWidth]="true"></fa-icon></button> <button type="button" class="btn btn-outline-info btn-sm btn-small-height float-right mt-0" (click)="onToggleCpfp()">CPFP</button>
} }
<ng-container *ngTemplateOutlet="pieChart"></ng-container> <ng-container *ngTemplateOutlet="pieChart"></ng-container>
</div> </div>
@ -36,7 +36,7 @@
<tr> <tr>
<td colspan="3" class="pt-0"> <td colspan="3" class="pt-0">
<div class="d-flex justify-content-end align-items-start"> <div class="d-flex justify-content-end align-items-start">
<button type="button" class="btn btn-outline-info btn-sm btn-small-height float-right mt-0" (click)="onToggleCpfp()">CPFP <fa-icon [icon]="['fas', 'info-circle']" [fixedWidth]="true"></fa-icon></button> <button type="button" class="btn btn-outline-info btn-sm btn-small-height float-right mt-0" (click)="onToggleCpfp()">CPFP</button>
</div> </div>
</td> </td>
</tr> </tr>

View File

@ -61,4 +61,8 @@
& > div, & > div > svg { & > div, & > div > svg {
overflow: visible !important; overflow: visible !important;
} }
}
.oobFees {
color: #905cf4;
} }

View File

@ -1,8 +1,8 @@
import { Component, ChangeDetectionStrategy, Input, Output, OnChanges, SimpleChanges, EventEmitter } from '@angular/core'; import { Component, ChangeDetectionStrategy, Input, Output, OnChanges, SimpleChanges, EventEmitter, ChangeDetectorRef } from '@angular/core';
import { Transaction } from '../../../interfaces/electrs.interface'; import { Transaction } from '@interfaces/electrs.interface';
import { Acceleration, SinglePoolStats } from '../../../interfaces/node-api.interface'; import { Acceleration, SinglePoolStats } from '@interfaces/node-api.interface';
import { EChartsOption, PieSeriesOption } from '../../../graphs/echarts'; import { EChartsOption, PieSeriesOption } from '@app/graphs/echarts';
import { MiningStats } from '../../../services/mining.service'; import { MiningStats } from '@app/services/mining.service';
function lighten(color, p): { r, g, b } { function lighten(color, p): { r, g, b } {
return { return {
@ -23,7 +23,8 @@ function toRGB({r,g,b}): string {
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class ActiveAccelerationBox implements OnChanges { export class ActiveAccelerationBox implements OnChanges {
@Input() tx: Transaction; @Input() acceleratedBy?: number[];
@Input() effectiveFeeRate?: number;
@Input() accelerationInfo: Acceleration; @Input() accelerationInfo: Acceleration;
@Input() miningStats: MiningStats; @Input() miningStats: MiningStats;
@Input() pools: number[]; @Input() pools: number[];
@ -41,10 +42,12 @@ export class ActiveAccelerationBox implements OnChanges {
timespan = ''; timespan = '';
chartInstance: any = undefined; chartInstance: any = undefined;
constructor() {} constructor(
private cd: ChangeDetectorRef,
) {}
ngOnChanges(changes: SimpleChanges): void { ngOnChanges(changes: SimpleChanges): void {
const pools = this.pools || this.accelerationInfo?.pools || this.tx.acceleratedBy; const pools = this.pools || this.accelerationInfo?.pools || this.acceleratedBy;
if (pools && this.miningStats) { if (pools && this.miningStats) {
this.prepareChartOptions(pools); this.prepareChartOptions(pools);
} }
@ -67,16 +70,27 @@ export class ActiveAccelerationBox implements OnChanges {
const acceleratingPools = (poolList || []).filter(id => pools[id]).sort((a,b) => pools[a].lastEstimatedHashrate - pools[b].lastEstimatedHashrate); const acceleratingPools = (poolList || []).filter(id => pools[id]).sort((a,b) => pools[a].lastEstimatedHashrate - pools[b].lastEstimatedHashrate);
const totalAcceleratedHashrate = acceleratingPools.reduce((total, pool) => total + pools[pool].lastEstimatedHashrate, 0); const totalAcceleratedHashrate = acceleratingPools.reduce((total, pool) => total + pools[pool].lastEstimatedHashrate, 0);
// Find the first pool with at least 1% of the total network hashrate
const firstSignificantPool = acceleratingPools.findIndex(pool => pools[pool].lastEstimatedHashrate > this.miningStats.lastEstimatedHashrate / 100);
const numSignificantPools = acceleratingPools.length - firstSignificantPool;
acceleratingPools.forEach((poolId, index) => { acceleratingPools.forEach((poolId, index) => {
const pool = pools[poolId]; const pool = pools[poolId];
const poolShare = ((pool.lastEstimatedHashrate / this.miningStats.lastEstimatedHashrate) * 100).toFixed(1); const poolShare = ((pool.lastEstimatedHashrate / this.miningStats.lastEstimatedHashrate) * 100).toFixed(1);
let color = 'white';
if (index >= firstSignificantPool) {
if (numSignificantPools > 1) {
color = toRGB(lighten({ r: 147, g: 57, b: 244 }, 1 - (index - firstSignificantPool) / Math.max((numSignificantPools - 1), 1)));
} else {
color = toRGB({ r: 147, g: 57, b: 244 });
}
}
data.push(getDataItem( data.push(getDataItem(
pool.lastEstimatedHashrate, pool.lastEstimatedHashrate,
toRGB(lighten({ r: 147, g: 57, b: 244 }, index * .08)), color,
`<b style="color: white">${pool.name} (${poolShare}%)</b>`, `<b style="color: white">${pool.name} (${poolShare}%)</b>`,
true, true,
) as PieSeriesOption); ) as PieSeriesOption);
}) });
this.acceleratedByPercentage = ((totalAcceleratedHashrate / this.miningStats.lastEstimatedHashrate) * 100).toFixed(1) + '%'; this.acceleratedByPercentage = ((totalAcceleratedHashrate / this.miningStats.lastEstimatedHashrate) * 100).toFixed(1) + '%';
const notAcceleratedByPercentage = ((1 - (totalAcceleratedHashrate / this.miningStats.lastEstimatedHashrate)) * 100).toFixed(1) + '%'; const notAcceleratedByPercentage = ((1 - (totalAcceleratedHashrate / this.miningStats.lastEstimatedHashrate)) * 100).toFixed(1) + '%';
data.push(getDataItem( data.push(getDataItem(
@ -127,6 +141,7 @@ export class ActiveAccelerationBox implements OnChanges {
} }
] ]
}; };
this.cd.markForCheck();
} }
onChartInit(ec) { onChartInit(ec) {
@ -139,4 +154,4 @@ export class ActiveAccelerationBox implements OnChanges {
onToggleCpfp(): void { onToggleCpfp(): void {
this.toggleCpfp.emit(); this.toggleCpfp.emit();
} }
} }

View File

@ -1,9 +1,9 @@
import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core'; import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
import { Observable, of } from 'rxjs'; import { Observable, of } from 'rxjs';
import { switchMap } from 'rxjs/operators'; import { switchMap } from 'rxjs/operators';
import { Acceleration } from '../../../interfaces/node-api.interface'; import { Acceleration } from '@interfaces/node-api.interface';
import { StateService } from '../../../services/state.service'; import { StateService } from '@app/services/state.service';
import { WebsocketService } from '../../../services/websocket.service'; import { WebsocketService } from '@app/services/websocket.service';
@Component({ @Component({
selector: 'app-pending-stats', selector: 'app-pending-stats',

View File

@ -0,0 +1,5 @@
<div class="sparkles" #sparkleAnchor>
<div *ngFor="let sparkle of sparkles" class="sparkle" [style]="sparkle.style">
<span class="inner-sparkle" [style]="sparkle.rotation">+</span>
</div>
</div>

View File

@ -0,0 +1,45 @@
.sparkles {
position: absolute;
top: var(--block-size);
height: 50px;
right: 0;
}
.sparkle {
position: absolute;
color: rgba(152, 88, 255, 0.75);
opacity: 0;
transform: scale(0.8) rotate(0deg);
animation: pop ease 2000ms forwards, sparkle ease 500ms infinite;
}
.inner-sparkle {
display: block;
}
@keyframes pop {
0% {
transform: scale(0.8) rotate(0deg);
opacity: 0;
}
20% {
transform: scale(1) rotate(72deg);
opacity: 1;
}
100% {
transform: scale(0) rotate(360deg);
opacity: 0;
}
}
@keyframes sparkle {
0% {
color: rgba(152, 88, 255, 0.75);
}
50% {
color: rgba(198, 162, 255, 0.75);
}
100% {
color: rgba(152, 88, 255, 0.75);
}
}

View File

@ -0,0 +1,73 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, Input, OnChanges, SimpleChanges, ViewChild } from '@angular/core';
@Component({
selector: 'app-acceleration-sparkles',
templateUrl: './acceleration-sparkles.component.html',
styleUrls: ['./acceleration-sparkles.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AccelerationSparklesComponent implements OnChanges {
@Input() arrow: ElementRef<HTMLDivElement>;
@Input() run: boolean = false;
@ViewChild('sparkleAnchor')
sparkleAnchor: ElementRef<HTMLDivElement>;
constructor(
private cd: ChangeDetectorRef,
) {}
endTimeout: any;
lastSparkle: number = 0;
sparkleWidth: number = 0;
sparkles: any[] = [];
ngOnChanges(changes: SimpleChanges): void {
if (changes.run) {
if (this.endTimeout) {
clearTimeout(this.endTimeout);
this.endTimeout = null;
}
if (this.run) {
this.doSparkle();
} else {
this.endTimeout = setTimeout(() => {
this.sparkles = [];
}, 2000);
}
}
}
doSparkle(): void {
if (this.run) {
const now = performance.now();
if (now - this.lastSparkle > 20) {
this.lastSparkle = now;
if (this.arrow?.nativeElement && this.sparkleAnchor?.nativeElement) {
const anchor = this.sparkleAnchor.nativeElement.getBoundingClientRect().right;
const right = this.arrow.nativeElement.getBoundingClientRect().right;
const dx = (anchor - right) + 30;
const numSparkles = Math.ceil(Math.random() * 3);
for (let i = 0; i < numSparkles; i++) {
this.sparkles.push({
style: {
right: (dx + (Math.random() * 10)) + 'px',
top: (15 + (Math.random() * 30)) + 'px',
},
rotation: {
transform: `rotate(${Math.random() * 360}deg)`,
}
});
}
while (this.sparkles.length > 200) {
this.sparkles.shift();
}
this.cd.markForCheck();
}
}
requestAnimationFrame(() => {
this.doSparkle();
});
}
}
}

View File

@ -1,16 +1,15 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, NgZone, OnChanges, OnDestroy, SimpleChanges } from '@angular/core'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, NgZone, OnChanges, OnDestroy, SimpleChanges } from '@angular/core';
import { echarts, EChartsOption } from '../../graphs/echarts'; import { echarts, EChartsOption } from '@app/graphs/echarts';
import { BehaviorSubject, Observable, Subscription, combineLatest, of } from 'rxjs'; import { BehaviorSubject, Observable, Subscription, combineLatest, of } from 'rxjs';
import { catchError, map, switchMap, tap } from 'rxjs/operators'; import { catchError, map, switchMap, tap } from 'rxjs/operators';
import { AddressTxSummary, ChainStats } from '../../interfaces/electrs.interface'; import { AddressTxSummary, ChainStats } from '@interfaces/electrs.interface';
import { ElectrsApiService } from '../../services/electrs-api.service'; import { ElectrsApiService } from '@app/services/electrs-api.service';
import { AmountShortenerPipe } from '../../shared/pipes/amount-shortener.pipe'; import { AmountShortenerPipe } from '@app/shared/pipes/amount-shortener.pipe';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; import { RelativeUrlPipe } from '@app/shared/pipes/relative-url/relative-url.pipe';
import { StateService } from '../../services/state.service'; import { StateService } from '@app/services/state.service';
import { PriceService } from '../../services/price.service'; import { PriceService } from '@app/services/price.service';
import { FiatCurrencyPipe } from '../../shared/pipes/fiat-currency.pipe'; import { FiatCurrencyPipe } from '@app/shared/pipes/fiat-currency.pipe';
import { FiatShortenerPipe } from '../../shared/pipes/fiat-shortener.pipe';
const periodSeconds = { const periodSeconds = {
'1d': (60 * 60 * 24), '1d': (60 * 60 * 24),
@ -30,7 +29,7 @@ const periodSeconds = {
position: absolute; position: absolute;
top: 50%; top: 50%;
left: calc(50% - 15px); left: calc(50% - 15px);
z-index: 100; z-index: 99;
} }
`], `],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@ -45,6 +44,7 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
@Input() right: number | string = 10; @Input() right: number | string = 10;
@Input() left: number | string = 70; @Input() left: number | string = 70;
@Input() widget: boolean = false; @Input() widget: boolean = false;
@Input() defaultFiat: boolean = false;
data: any[] = []; data: any[] = [];
fiatData: any[] = []; fiatData: any[] = [];
@ -77,7 +77,6 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
private relativeUrlPipe: RelativeUrlPipe, private relativeUrlPipe: RelativeUrlPipe,
private priceService: PriceService, private priceService: PriceService,
private fiatCurrencyPipe: FiatCurrencyPipe, private fiatCurrencyPipe: FiatCurrencyPipe,
private fiatShortenerPipe: FiatShortenerPipe,
private zone: NgZone, private zone: NgZone,
) {} ) {}
@ -86,6 +85,9 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
if (!this.addressSummary$ && (!this.address || !this.stats)) { if (!this.addressSummary$ && (!this.address || !this.stats)) {
return; return;
} }
if (changes.defaultFiat) {
this.selected['Fiat'] = !!this.defaultFiat;
}
if (changes.address || changes.isPubkey || changes.addressSummary$ || changes.stats) { if (changes.address || changes.isPubkey || changes.addressSummary$ || changes.stats) {
if (this.subscription) { if (this.subscription) {
this.subscription.unsubscribe(); this.subscription.unsubscribe();
@ -147,7 +149,7 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
if (!summary) { if (!summary) {
return; return;
} }
const total = this.stats ? (this.stats.funded_txo_sum - this.stats.spent_txo_sum) : summary.reduce((acc, tx) => acc + tx.value, 0); const total = this.stats ? (this.stats.funded_txo_sum - this.stats.spent_txo_sum) : summary.reduce((acc, tx) => acc + tx.value, 0);
let runningTotal = total; let runningTotal = total;
const processData = summary.map(d => { const processData = summary.map(d => {
@ -161,7 +163,7 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
d d
}; };
}).reverse(); }).reverse();
this.data = processData.filter(({ d }) => d.txid !== undefined).map(({ time, balance, d }) => [time, balance, d]); this.data = processData.filter(({ d }) => d.txid !== undefined).map(({ time, balance, d }) => [time, balance, d]);
this.fiatData = processData.map(({ time, fiatBalance, balance, d }) => [time, fiatBalance, d, balance]); this.fiatData = processData.map(({ time, fiatBalance, balance, d }) => [time, fiatBalance, d, balance]);
@ -179,6 +181,9 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
const maxValue = this.data.reduce((acc, d) => Math.max(acc, Math.abs(d[1] ?? d.value[1])), 0); const maxValue = this.data.reduce((acc, d) => Math.max(acc, Math.abs(d[1] ?? d.value[1])), 0);
const minValue = this.data.reduce((acc, d) => Math.min(acc, Math.abs(d[1] ?? d.value[1])), maxValue); const minValue = this.data.reduce((acc, d) => Math.min(acc, Math.abs(d[1] ?? d.value[1])), maxValue);
this.right = this.selected['Fiat'] ? +this.initialRight + 40 : this.initialRight;
this.left = this.selected[$localize`:@@7e69426bd97a606d8ae6026762858e6e7c86a1fd:Balance`] ? this.initialLeft : +this.initialLeft - 40;
this.chartOptions = { this.chartOptions = {
color: [ color: [
new echarts.graphic.LinearGradient(0, 0, 0, 1, [ new echarts.graphic.LinearGradient(0, 0, 0, 1, [
@ -245,21 +250,22 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
let tooltip = '<div>'; let tooltip = '<div>';
const hasTx = data[0].data[2].txid; const hasTx = data[0].data[2].txid;
const date = new Date(data[0].data[0]).toLocaleTimeString(this.locale, { year: 'numeric', month: 'short', day: 'numeric' });
tooltip += `<div>
<div style="text-align: right;">
<div><b>${date}</b></div>`;
if (hasTx) { if (hasTx) {
const header = data.length === 1 const header = data.length === 1
? `${data[0].data[2].txid.slice(0, 6)}...${data[0].data[2].txid.slice(-6)}` ? `${data[0].data[2].txid.slice(0, 6)}...${data[0].data[2].txid.slice(-6)}`
: `${data.length} transactions`; : `${data.length} transactions`;
tooltip += `<span><b>${header}</b></span>`; tooltip += `<div><b>${header}</b></div>`;
} }
const date = new Date(data[0].data[0]).toLocaleTimeString(this.locale, { year: 'numeric', month: 'short', day: 'numeric' });
tooltip += `<div>
<div style="text-align: right;">`;
const formatBTC = (val, decimal) => (val / 100_000_000).toFixed(decimal); const formatBTC = (val, decimal) => (val / 100_000_000).toFixed(decimal);
const formatFiat = (val) => this.fiatCurrencyPipe.transform(val, null, 'USD'); const formatFiat = (val) => this.fiatCurrencyPipe.transform(val, null, 'USD');
const btcVal = btcData.reduce((total, d) => total + d.data[2].value, 0); const btcVal = btcData.reduce((total, d) => total + d.data[2].value, 0);
const fiatVal = fiatData.reduce((total, d) => total + d.data[2].value * d.data[2].price / 100_000_000, 0); const fiatVal = fiatData.reduce((total, d) => total + d.data[2].value * d.data[2].price / 100_000_000, 0);
const btcColor = btcVal === 0 ? '' : (btcVal > 0 ? 'var(--green)' : 'var(--red)'); const btcColor = btcVal === 0 ? '' : (btcVal > 0 ? 'var(--green)' : 'var(--red)');
@ -291,7 +297,7 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
} }
} }
tooltip += `</div><span>${date}</span></div>`; tooltip += `</div></div>`;
return tooltip; return tooltip;
}.bind(this) }.bind(this)
}, },
@ -311,18 +317,21 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
formatter: (val): string => { formatter: (val): string => {
let valSpan = maxValue - (this.period === 'all' ? 0 : minValue); let valSpan = maxValue - (this.period === 'all' ? 0 : minValue);
if (valSpan > 100_000_000_000) { if (valSpan > 100_000_000_000) {
return `${this.amountShortenerPipe.transform(Math.round(val / 100_000_000), 0)} BTC`; return `${this.amountShortenerPipe.transform(Math.round(val / 100_000_000), 0, undefined, true)} BTC`;
} }
else if (valSpan > 1_000_000_000) { else if (valSpan > 1_000_000_000) {
return `${this.amountShortenerPipe.transform(Math.round(val / 100_000_000), 2)} BTC`; return `${this.amountShortenerPipe.transform(Math.round(val / 100_000_000), 2, undefined, true)} BTC`;
} else if (valSpan > 100_000_000) { } else if (valSpan > 100_000_000) {
return `${(val / 100_000_000).toFixed(1)} BTC`; return `${(val / 100_000_000).toFixed(1)} BTC`;
} else if (valSpan > 10_000_000) { } else if (valSpan > 10_000_000) {
return `${(val / 100_000_000).toFixed(2)} BTC`; return `${(val / 100_000_000).toFixed(2)} BTC`;
} else if (valSpan > 1_000_000) { } else if (valSpan > 1_000_000) {
if (maxValue > 100_000_000_000) {
return `${this.amountShortenerPipe.transform(Math.round(val / 100_000_000), 3, undefined, true)} BTC`;
}
return `${(val / 100_000_000).toFixed(3)} BTC`; return `${(val / 100_000_000).toFixed(3)} BTC`;
} else { } else {
return `${this.amountShortenerPipe.transform(val, 0)} sats`; return `${this.amountShortenerPipe.transform(val, 0, undefined, true)} sats`;
} }
} }
}, },
@ -336,7 +345,7 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
axisLabel: { axisLabel: {
color: 'rgb(110, 112, 121)', color: 'rgb(110, 112, 121)',
formatter: function(val) { formatter: function(val) {
return this.fiatShortenerPipe.transform(val, null, 'USD'); return `$${this.amountShortenerPipe.transform(val, 0, undefined, true)}`;
}.bind(this) }.bind(this)
}, },
splitLine: { splitLine: {
@ -440,7 +449,7 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
right: this.right, right: this.right,
}] : undefined }] : undefined
}; };
if (this.chartInstance) { if (this.chartInstance) {
this.chartInstance.setOption(this.chartOptions); this.chartInstance.setOption(this.chartOptions);
} }

View File

@ -1,15 +1,15 @@
import { Component, OnInit, OnDestroy, ChangeDetectorRef, HostListener } from '@angular/core'; import { Component, OnInit, OnDestroy, ChangeDetectorRef, HostListener } from '@angular/core';
import { ActivatedRoute, ParamMap } from '@angular/router'; import { ActivatedRoute, ParamMap } from '@angular/router';
import { ElectrsApiService } from '../../services/electrs-api.service'; import { ElectrsApiService } from '@app/services/electrs-api.service';
import { switchMap, catchError } from 'rxjs/operators'; import { switchMap, catchError } from 'rxjs/operators';
import { Address, Transaction } from '../../interfaces/electrs.interface'; import { Address, Transaction } from '@interfaces/electrs.interface';
import { WebsocketService } from '../../services/websocket.service'; import { WebsocketService } from '@app/services/websocket.service';
import { StateService } from '../../services/state.service'; import { StateService } from '@app/services/state.service';
import { AudioService } from '../../services/audio.service'; import { AudioService } from '@app/services/audio.service';
import { ApiService } from '../../services/api.service'; import { ApiService } from '@app/services/api.service';
import { of, Subscription, forkJoin } from 'rxjs'; import { of, Subscription, forkJoin } from 'rxjs';
import { SeoService } from '../../services/seo.service'; import { SeoService } from '@app/services/seo.service';
import { AddressInformation } from '../../interfaces/node-api.interface'; import { AddressInformation } from '@interfaces/node-api.interface';
@Component({ @Component({
selector: 'app-address-group', selector: 'app-address-group',

View File

@ -1,7 +1,7 @@
import { Component, ChangeDetectionStrategy, Input, OnChanges } from '@angular/core'; import { Component, ChangeDetectionStrategy, Input, OnChanges } from '@angular/core';
import { Vin, Vout } from '../../interfaces/electrs.interface'; import { Vin, Vout } from '@interfaces/electrs.interface';
import { StateService } from '../../services/state.service'; import { StateService } from '@app/services/state.service';
import { AddressType, AddressTypeInfo } from '../../shared/address-utils'; import { AddressType, AddressTypeInfo } from '@app/shared/address-utils';
@Component({ @Component({
selector: 'app-address-labels', selector: 'app-address-labels',
@ -55,7 +55,7 @@ export class AddressLabelsComponent implements OnChanges {
} }
handleVin() { handleVin() {
const address = new AddressTypeInfo(this.network || 'mainnet', this.vin.prevout?.scriptpubkey_address, this.vin.prevout?.scriptpubkey_type as AddressType, [this.vin]) const address = new AddressTypeInfo(this.network || 'mainnet', this.vin.prevout?.scriptpubkey_address, this.vin.prevout?.scriptpubkey_type as AddressType, [this.vin]);
if (address?.scripts.size) { if (address?.scripts.size) {
const script = address?.scripts.values().next().value; const script = address?.scripts.values().next().value;
if (script.template?.label) { if (script.template?.label) {

View File

@ -1,9 +1,9 @@
import { Component, Input, OnChanges, OnDestroy, OnInit, SimpleChanges } from '@angular/core'; import { Component, Input, OnChanges, OnDestroy, OnInit, SimpleChanges } from '@angular/core';
import { StateService } from '../../services/state.service'; import { StateService } from '@app/services/state.service';
import { Address, AddressTxSummary } from '../../interfaces/electrs.interface'; import { Address, AddressTxSummary } from '@interfaces/electrs.interface';
import { ElectrsApiService } from '../../services/electrs-api.service'; import { ElectrsApiService } from '@app/services/electrs-api.service';
import { Observable, Subscription, catchError, map, of, switchMap, zip } from 'rxjs'; import { Observable, Subscription, catchError, map, of, switchMap, zip } from 'rxjs';
import { PriceService } from '../../services/price.service'; import { PriceService } from '@app/services/price.service';
@Component({ @Component({
selector: 'app-address-transactions-widget', selector: 'app-address-transactions-widget',

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