Merge branch 'master' into mononaut/mempool-effective-rates

This commit is contained in:
softsimon 2023-05-04 00:58:49 +04:00 committed by GitHub
commit ee2aea2458
63 changed files with 1859 additions and 318 deletions

View File

@ -44,7 +44,8 @@
}, },
"ESPLORA": { "ESPLORA": {
"REST_API_URL": "http://127.0.0.1:3000", "REST_API_URL": "http://127.0.0.1:3000",
"UNIX_SOCKET_PATH": "/tmp/esplora-bitcoin-mainnet" "UNIX_SOCKET_PATH": "/tmp/esplora-bitcoin-mainnet",
"RETRY_UNIX_SOCKET_AFTER": 30000
}, },
"SECOND_CORE_RPC": { "SECOND_CORE_RPC": {
"HOST": "127.0.0.1", "HOST": "127.0.0.1",
@ -60,7 +61,8 @@
"SOCKET": "/var/run/mysql/mysql.sock", "SOCKET": "/var/run/mysql/mysql.sock",
"DATABASE": "mempool", "DATABASE": "mempool",
"USERNAME": "mempool", "USERNAME": "mempool",
"PASSWORD": "mempool" "PASSWORD": "mempool",
"TIMEOUT": 180000
}, },
"SYSLOG": { "SYSLOG": {
"ENABLED": true, "ENABLED": true,

View File

@ -45,7 +45,8 @@
}, },
"ESPLORA": { "ESPLORA": {
"REST_API_URL": "__ESPLORA_REST_API_URL__", "REST_API_URL": "__ESPLORA_REST_API_URL__",
"UNIX_SOCKET_PATH": "__ESPLORA_UNIX_SOCKET_PATH__" "UNIX_SOCKET_PATH": "__ESPLORA_UNIX_SOCKET_PATH__",
"RETRY_UNIX_SOCKET_AFTER": "__ESPLORA_RETRY_UNIX_SOCKET_AFTER__"
}, },
"SECOND_CORE_RPC": { "SECOND_CORE_RPC": {
"HOST": "__SECOND_CORE_RPC_HOST__", "HOST": "__SECOND_CORE_RPC_HOST__",
@ -61,7 +62,8 @@
"PORT": 18, "PORT": 18,
"DATABASE": "__DATABASE_DATABASE__", "DATABASE": "__DATABASE_DATABASE__",
"USERNAME": "__DATABASE_USERNAME__", "USERNAME": "__DATABASE_USERNAME__",
"PASSWORD": "__DATABASE_PASSWORD__" "PASSWORD": "__DATABASE_PASSWORD__",
"TIMEOUT": "__DATABASE_TIMEOUT__"
}, },
"SYSLOG": { "SYSLOG": {
"ENABLED": false, "ENABLED": false,

View File

@ -47,7 +47,7 @@ describe('Mempool Backend Config', () => {
expect(config.ELECTRUM).toStrictEqual({ HOST: '127.0.0.1', PORT: 3306, TLS_ENABLED: true }); expect(config.ELECTRUM).toStrictEqual({ HOST: '127.0.0.1', PORT: 3306, TLS_ENABLED: true });
expect(config.ESPLORA).toStrictEqual({ REST_API_URL: 'http://127.0.0.1:3000', UNIX_SOCKET_PATH: null }); expect(config.ESPLORA).toStrictEqual({ REST_API_URL: 'http://127.0.0.1:3000', UNIX_SOCKET_PATH: null, RETRY_UNIX_SOCKET_AFTER: 30000 });
expect(config.CORE_RPC).toStrictEqual({ expect(config.CORE_RPC).toStrictEqual({
HOST: '127.0.0.1', HOST: '127.0.0.1',
@ -72,7 +72,8 @@ describe('Mempool Backend Config', () => {
PORT: 3306, PORT: 3306,
DATABASE: 'mempool', DATABASE: 'mempool',
USERNAME: 'mempool', USERNAME: 'mempool',
PASSWORD: 'mempool' PASSWORD: 'mempool',
TIMEOUT: 180000,
}); });
expect(config.SYSLOG).toStrictEqual({ expect(config.SYSLOG).toStrictEqual({

View File

@ -93,17 +93,7 @@ class Audit {
} else { } else {
if (!isDisplaced[tx.txid]) { if (!isDisplaced[tx.txid]) {
added.push(tx.txid); added.push(tx.txid);
} else {
} }
let blockIndex = -1;
let index = -1;
projectedBlocks.forEach((block, bi) => {
const i = block.transactionIds.indexOf(tx.txid);
if (i >= 0) {
blockIndex = bi;
index = i;
}
});
overflowWeight += tx.weight; overflowWeight += tx.weight;
} }
totalWeight += tx.weight; totalWeight += tx.weight;

View File

@ -32,8 +32,10 @@ class BitcoinRoutes {
.get(config.MEMPOOL.API_URL_PREFIX + 'backend-info', this.getBackendInfo) .get(config.MEMPOOL.API_URL_PREFIX + 'backend-info', this.getBackendInfo)
.get(config.MEMPOOL.API_URL_PREFIX + 'init-data', this.getInitData) .get(config.MEMPOOL.API_URL_PREFIX + 'init-data', this.getInitData)
.get(config.MEMPOOL.API_URL_PREFIX + 'validate-address/:address', this.validateAddress) .get(config.MEMPOOL.API_URL_PREFIX + 'validate-address/:address', this.validateAddress)
.get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/replaces', this.getRbfHistory) .get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/rbf', this.getRbfHistory)
.get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/cached', this.getCachedTx) .get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/cached', this.getCachedTx)
.get(config.MEMPOOL.API_URL_PREFIX + 'replacements', this.getRbfReplacements)
.get(config.MEMPOOL.API_URL_PREFIX + 'fullrbf/replacements', this.getFullRbfReplacements)
.post(config.MEMPOOL.API_URL_PREFIX + 'tx/push', this.$postTransactionForm) .post(config.MEMPOOL.API_URL_PREFIX + 'tx/push', this.$postTransactionForm)
.get(config.MEMPOOL.API_URL_PREFIX + 'donations', async (req, res) => { .get(config.MEMPOOL.API_URL_PREFIX + 'donations', async (req, res) => {
try { try {
@ -94,6 +96,7 @@ class BitcoinRoutes {
.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/audit-summary', this.getBlockAuditSummary) .get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/audit-summary', this.getBlockAuditSummary)
.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))
@ -110,7 +113,6 @@ class BitcoinRoutes {
.get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/status', this.getTransactionStatus) .get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/status', this.getTransactionStatus)
.get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/outspends', this.getTransactionOutspends) .get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/outspends', this.getTransactionOutspends)
.get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/header', this.getBlockHeader) .get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/header', this.getBlockHeader)
.get(config.MEMPOOL.API_URL_PREFIX + 'blocks/tip/height', this.getBlockTipHeight)
.get(config.MEMPOOL.API_URL_PREFIX + 'blocks/tip/hash', this.getBlockTipHash) .get(config.MEMPOOL.API_URL_PREFIX + 'blocks/tip/hash', this.getBlockTipHash)
.get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/raw', this.getRawBlock) .get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/raw', this.getRawBlock)
.get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/txids', this.getTxIdsForBlock) .get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/txids', this.getTxIdsForBlock)
@ -589,10 +591,14 @@ class BitcoinRoutes {
} }
} }
private async getBlockTipHeight(req: Request, res: Response) { private getBlockTipHeight(req: Request, res: Response) {
try { try {
const result = await bitcoinApi.$getBlockHeightTip(); const result = blocks.getCurrentBlockHeight();
res.json(result); if (!result) {
return res.status(503).send(`Service Temporarily Unavailable`);
}
res.setHeader('content-type', 'text/plain');
res.send(result.toString());
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); res.status(500).send(e instanceof Error ? e.message : e);
} }
@ -638,8 +644,30 @@ class BitcoinRoutes {
private async getRbfHistory(req: Request, res: Response) { private async getRbfHistory(req: Request, res: Response) {
try { try {
const result = rbfCache.getReplaces(req.params.txId); const replacements = rbfCache.getRbfTree(req.params.txId) || null;
res.json(result || []); const replaces = rbfCache.getReplaces(req.params.txId) || null;
res.json({
replacements,
replaces
});
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
private async getRbfReplacements(req: Request, res: Response) {
try {
const result = rbfCache.getRbfTrees(false);
res.json(result);
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
private async getFullRbfReplacements(req: Request, res: Response) {
try {
const result = rbfCache.getRbfTrees(true);
res.json(result);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); res.status(500).send(e instanceof Error ? e.message : e);
} }

View File

@ -3,68 +3,102 @@ import axios, { AxiosRequestConfig } from 'axios';
import http from 'http'; import http from 'http';
import { AbstractBitcoinApi } from './bitcoin-api-abstract-factory'; import { AbstractBitcoinApi } from './bitcoin-api-abstract-factory';
import { IEsploraApi } from './esplora-api.interface'; import { IEsploraApi } from './esplora-api.interface';
import logger from '../../logger';
const axiosConnection = axios.create({ const axiosConnection = axios.create({
httpAgent: new http.Agent({ keepAlive: true, }) httpAgent: new http.Agent({ keepAlive: true, })
}); });
class ElectrsApi implements AbstractBitcoinApi { class ElectrsApi implements AbstractBitcoinApi {
axiosConfig: AxiosRequestConfig = config.ESPLORA.UNIX_SOCKET_PATH ? { private axiosConfigWithUnixSocket: AxiosRequestConfig = config.ESPLORA.UNIX_SOCKET_PATH ? {
socketPath: config.ESPLORA.UNIX_SOCKET_PATH, socketPath: config.ESPLORA.UNIX_SOCKET_PATH,
timeout: 10000, timeout: 10000,
} : { } : {
timeout: 10000, timeout: 10000,
}; };
private axiosConfigTcpSocketOnly: AxiosRequestConfig = {
timeout: 10000,
};
constructor() { } unixSocketRetryTimeout;
activeAxiosConfig;
constructor() {
this.activeAxiosConfig = this.axiosConfigWithUnixSocket;
}
fallbackToTcpSocket() {
if (!this.unixSocketRetryTimeout) {
logger.err(`Unable to connect to esplora unix socket. Falling back to tcp socket. Retrying unix socket in ${config.ESPLORA.RETRY_UNIX_SOCKET_AFTER / 1000} seconds`);
// Retry the unix socket after a few seconds
this.unixSocketRetryTimeout = setTimeout(() => {
logger.info(`Retrying to use unix socket for esplora now (applied for the next query)`);
this.activeAxiosConfig = this.axiosConfigWithUnixSocket;
this.unixSocketRetryTimeout = undefined;
}, config.ESPLORA.RETRY_UNIX_SOCKET_AFTER);
}
// Use the TCP socket (reach a different esplora instance through nginx)
this.activeAxiosConfig = this.axiosConfigTcpSocketOnly;
}
$queryWrapper<T>(url, responseType = 'json'): Promise<T> {
return axiosConnection.get<T>(url, { ...this.activeAxiosConfig, responseType: responseType })
.then((response) => response.data)
.catch((e) => {
if (e?.code === 'ECONNREFUSED') {
this.fallbackToTcpSocket();
// Retry immediately
return axiosConnection.get<T>(url, this.activeAxiosConfig)
.then((response) => response.data)
.catch((e) => {
logger.warn(`Cannot query esplora through the unix socket nor the tcp socket. Exception ${e}`);
throw e;
});
} else {
throw e;
}
});
}
$getRawMempool(): Promise<IEsploraApi.Transaction['txid'][]> { $getRawMempool(): Promise<IEsploraApi.Transaction['txid'][]> {
return axiosConnection.get<IEsploraApi.Transaction['txid'][]>(config.ESPLORA.REST_API_URL + '/mempool/txids', this.axiosConfig) return this.$queryWrapper<IEsploraApi.Transaction['txid'][]>(config.ESPLORA.REST_API_URL + '/mempool/txids');
.then((response) => response.data);
} }
$getRawTransaction(txId: string): Promise<IEsploraApi.Transaction> { $getRawTransaction(txId: string): Promise<IEsploraApi.Transaction> {
return axiosConnection.get<IEsploraApi.Transaction>(config.ESPLORA.REST_API_URL + '/tx/' + txId, this.axiosConfig) return this.$queryWrapper<IEsploraApi.Transaction>(config.ESPLORA.REST_API_URL + '/tx/' + txId);
.then((response) => response.data);
} }
$getTransactionHex(txId: string): Promise<string> { $getTransactionHex(txId: string): Promise<string> {
return axiosConnection.get<string>(config.ESPLORA.REST_API_URL + '/tx/' + txId + '/hex', this.axiosConfig) return this.$queryWrapper<string>(config.ESPLORA.REST_API_URL + '/tx/' + txId + '/hex');
.then((response) => response.data);
} }
$getBlockHeightTip(): Promise<number> { $getBlockHeightTip(): Promise<number> {
return axiosConnection.get<number>(config.ESPLORA.REST_API_URL + '/blocks/tip/height', this.axiosConfig) return this.$queryWrapper<number>(config.ESPLORA.REST_API_URL + '/blocks/tip/height');
.then((response) => response.data);
} }
$getBlockHashTip(): Promise<string> { $getBlockHashTip(): Promise<string> {
return axiosConnection.get<string>(config.ESPLORA.REST_API_URL + '/blocks/tip/hash', this.axiosConfig) return this.$queryWrapper<string>(config.ESPLORA.REST_API_URL + '/blocks/tip/hash');
.then((response) => response.data);
} }
$getTxIdsForBlock(hash: string): Promise<string[]> { $getTxIdsForBlock(hash: string): Promise<string[]> {
return axiosConnection.get<string[]>(config.ESPLORA.REST_API_URL + '/block/' + hash + '/txids', this.axiosConfig) return this.$queryWrapper<string[]>(config.ESPLORA.REST_API_URL + '/block/' + hash + '/txids');
.then((response) => response.data);
} }
$getBlockHash(height: number): Promise<string> { $getBlockHash(height: number): Promise<string> {
return axiosConnection.get<string>(config.ESPLORA.REST_API_URL + '/block-height/' + height, this.axiosConfig) return this.$queryWrapper<string>(config.ESPLORA.REST_API_URL + '/block-height/' + height);
.then((response) => response.data);
} }
$getBlockHeader(hash: string): Promise<string> { $getBlockHeader(hash: string): Promise<string> {
return axiosConnection.get<string>(config.ESPLORA.REST_API_URL + '/block/' + hash + '/header', this.axiosConfig) return this.$queryWrapper<string>(config.ESPLORA.REST_API_URL + '/block/' + hash + '/header');
.then((response) => response.data);
} }
$getBlock(hash: string): Promise<IEsploraApi.Block> { $getBlock(hash: string): Promise<IEsploraApi.Block> {
return axiosConnection.get<IEsploraApi.Block>(config.ESPLORA.REST_API_URL + '/block/' + hash, this.axiosConfig) return this.$queryWrapper<IEsploraApi.Block>(config.ESPLORA.REST_API_URL + '/block/' + hash);
.then((response) => response.data);
} }
$getRawBlock(hash: string): Promise<Buffer> { $getRawBlock(hash: string): Promise<Buffer> {
return axiosConnection.get<string>(config.ESPLORA.REST_API_URL + '/block/' + hash + "/raw", { ...this.axiosConfig, responseType: 'arraybuffer' }) return this.$queryWrapper<any>(config.ESPLORA.REST_API_URL + '/block/' + hash + "/raw", 'arraybuffer')
.then((response) => { return Buffer.from(response.data); }); .then((response) => { return Buffer.from(response.data); });
} }
@ -85,13 +119,11 @@ class ElectrsApi implements AbstractBitcoinApi {
} }
$getOutspend(txId: string, vout: number): Promise<IEsploraApi.Outspend> { $getOutspend(txId: string, vout: number): Promise<IEsploraApi.Outspend> {
return axiosConnection.get<IEsploraApi.Outspend>(config.ESPLORA.REST_API_URL + '/tx/' + txId + '/outspend/' + vout, this.axiosConfig) return this.$queryWrapper<IEsploraApi.Outspend>(config.ESPLORA.REST_API_URL + '/tx/' + txId + '/outspend/' + vout);
.then((response) => response.data);
} }
$getOutspends(txId: string): Promise<IEsploraApi.Outspend[]> { $getOutspends(txId: string): Promise<IEsploraApi.Outspend[]> {
return axiosConnection.get<IEsploraApi.Outspend[]>(config.ESPLORA.REST_API_URL + '/tx/' + txId + '/outspends', this.axiosConfig) return this.$queryWrapper<IEsploraApi.Outspend[]>(config.ESPLORA.REST_API_URL + '/tx/' + txId + '/outspends');
.then((response) => response.data);
} }
async $getBatchedOutspends(txId: string[]): Promise<IEsploraApi.Outspend[][]> { async $getBatchedOutspends(txId: string[]): Promise<IEsploraApi.Outspend[][]> {

View File

@ -36,6 +36,8 @@ class Blocks {
private newBlockCallbacks: ((block: BlockExtended, txIds: string[], transactions: TransactionExtended[]) => void)[] = []; private newBlockCallbacks: ((block: BlockExtended, txIds: string[], transactions: TransactionExtended[]) => void)[] = [];
private newAsyncBlockCallbacks: ((block: BlockExtended, txIds: string[], transactions: TransactionExtended[]) => Promise<void>)[] = []; private newAsyncBlockCallbacks: ((block: BlockExtended, txIds: string[], transactions: TransactionExtended[]) => Promise<void>)[] = [];
private mainLoopTimeout: number = 120000;
constructor() { } constructor() { }
public getBlocks(): BlockExtended[] { public getBlocks(): BlockExtended[] {
@ -528,8 +530,12 @@ class Blocks {
} }
public async $updateBlocks() { public async $updateBlocks() {
// warn if this run stalls the main loop for more than 2 minutes
const timer = this.startTimer();
let fastForwarded = false; let fastForwarded = false;
const blockHeightTip = await bitcoinApi.$getBlockHeightTip(); const blockHeightTip = await bitcoinApi.$getBlockHeightTip();
this.updateTimerProgress(timer, 'got block height tip');
if (this.blocks.length === 0) { if (this.blocks.length === 0) {
this.currentBlockHeight = Math.max(blockHeightTip - config.MEMPOOL.INITIAL_BLOCKS_AMOUNT, -1); this.currentBlockHeight = Math.max(blockHeightTip - config.MEMPOOL.INITIAL_BLOCKS_AMOUNT, -1);
@ -547,16 +553,21 @@ class Blocks {
if (!this.lastDifficultyAdjustmentTime) { if (!this.lastDifficultyAdjustmentTime) {
const blockchainInfo = await bitcoinClient.getBlockchainInfo(); const blockchainInfo = await bitcoinClient.getBlockchainInfo();
this.updateTimerProgress(timer, 'got blockchain info for initial difficulty adjustment');
if (blockchainInfo.blocks === blockchainInfo.headers) { if (blockchainInfo.blocks === blockchainInfo.headers) {
const heightDiff = blockHeightTip % 2016; const heightDiff = blockHeightTip % 2016;
const blockHash = await bitcoinApi.$getBlockHash(blockHeightTip - heightDiff); const blockHash = await bitcoinApi.$getBlockHash(blockHeightTip - heightDiff);
this.updateTimerProgress(timer, 'got block hash for initial difficulty adjustment');
const block: IEsploraApi.Block = await bitcoinCoreApi.$getBlock(blockHash); const block: IEsploraApi.Block = await bitcoinCoreApi.$getBlock(blockHash);
this.updateTimerProgress(timer, 'got block for initial difficulty adjustment');
this.lastDifficultyAdjustmentTime = block.timestamp; this.lastDifficultyAdjustmentTime = block.timestamp;
this.currentDifficulty = block.difficulty; this.currentDifficulty = block.difficulty;
if (blockHeightTip >= 2016) { if (blockHeightTip >= 2016) {
const previousPeriodBlockHash = await bitcoinApi.$getBlockHash(blockHeightTip - heightDiff - 2016); const previousPeriodBlockHash = await bitcoinApi.$getBlockHash(blockHeightTip - heightDiff - 2016);
this.updateTimerProgress(timer, 'got previous block hash for initial difficulty adjustment');
const previousPeriodBlock: IEsploraApi.Block = await bitcoinCoreApi.$getBlock(previousPeriodBlockHash); const previousPeriodBlock: IEsploraApi.Block = await bitcoinCoreApi.$getBlock(previousPeriodBlockHash);
this.updateTimerProgress(timer, 'got previous block for initial difficulty adjustment');
this.previousDifficultyRetarget = (block.difficulty - previousPeriodBlock.difficulty) / previousPeriodBlock.difficulty * 100; this.previousDifficultyRetarget = (block.difficulty - previousPeriodBlock.difficulty) / previousPeriodBlock.difficulty * 100;
logger.debug(`Initial difficulty adjustment data set.`); logger.debug(`Initial difficulty adjustment data set.`);
} }
@ -571,9 +582,11 @@ class Blocks {
} else { } else {
this.currentBlockHeight++; this.currentBlockHeight++;
logger.debug(`New block found (#${this.currentBlockHeight})!`); logger.debug(`New block found (#${this.currentBlockHeight})!`);
this.updateTimerProgress(timer, `getting orphaned blocks for ${this.currentBlockHeight}`);
await chainTips.updateOrphanedBlocks(); await chainTips.updateOrphanedBlocks();
} }
this.updateTimerProgress(timer, `getting block data for ${this.currentBlockHeight}`);
const blockHash = await bitcoinApi.$getBlockHash(this.currentBlockHeight); const blockHash = await bitcoinApi.$getBlockHash(this.currentBlockHeight);
const verboseBlock = await bitcoinClient.getBlock(blockHash, 2); const verboseBlock = await bitcoinClient.getBlock(blockHash, 2);
const block = BitcoinApi.convertBlock(verboseBlock); const block = BitcoinApi.convertBlock(verboseBlock);
@ -582,39 +595,51 @@ class Blocks {
const cpfpSummary: CpfpSummary = Common.calculateCpfp(block.height, transactions); const cpfpSummary: CpfpSummary = Common.calculateCpfp(block.height, transactions);
const blockExtended: BlockExtended = await this.$getBlockExtended(block, cpfpSummary.transactions); const blockExtended: BlockExtended = await this.$getBlockExtended(block, cpfpSummary.transactions);
const blockSummary: BlockSummary = this.summarizeBlock(verboseBlock); const blockSummary: BlockSummary = this.summarizeBlock(verboseBlock);
this.updateTimerProgress(timer, `got block data for ${this.currentBlockHeight}`);
// start async callbacks // start async callbacks
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, transactions));
if (Common.indexingEnabled()) { if (Common.indexingEnabled()) {
if (!fastForwarded) { if (!fastForwarded) {
const lastBlock = await blocksRepository.$getBlockByHeight(blockExtended.height - 1); const lastBlock = await blocksRepository.$getBlockByHeight(blockExtended.height - 1);
this.updateTimerProgress(timer, `got block by height for ${this.currentBlockHeight}`);
if (lastBlock !== null && blockExtended.previousblockhash !== lastBlock.id) { if (lastBlock !== null && blockExtended.previousblockhash !== lastBlock.id) {
logger.warn(`Chain divergence detected at block ${lastBlock.height}, re-indexing most recent data`, logger.tags.mining); logger.warn(`Chain divergence detected at block ${lastBlock.height}, re-indexing most recent data`, logger.tags.mining);
// We assume there won't be a reorg with more than 10 block depth // We assume there won't be a reorg with more than 10 block depth
this.updateTimerProgress(timer, `rolling back diverged chain from ${this.currentBlockHeight}`);
await BlocksRepository.$deleteBlocksFrom(lastBlock.height - 10); await BlocksRepository.$deleteBlocksFrom(lastBlock.height - 10);
await HashratesRepository.$deleteLastEntries(); await HashratesRepository.$deleteLastEntries();
await cpfpRepository.$deleteClustersFrom(lastBlock.height - 10); await cpfpRepository.$deleteClustersFrom(lastBlock.height - 10);
this.updateTimerProgress(timer, `rolled back chain divergence from ${this.currentBlockHeight}`);
for (let i = 10; i >= 0; --i) { for (let i = 10; i >= 0; --i) {
const newBlock = await this.$indexBlock(lastBlock.height - i); const newBlock = await this.$indexBlock(lastBlock.height - i);
this.updateTimerProgress(timer, `reindexed block`);
await this.$getStrippedBlockTransactions(newBlock.id, true, true); await this.$getStrippedBlockTransactions(newBlock.id, true, true);
this.updateTimerProgress(timer, `reindexed block summary`);
if (config.MEMPOOL.CPFP_INDEXING) { if (config.MEMPOOL.CPFP_INDEXING) {
await this.$indexCPFP(newBlock.id, lastBlock.height - i); await this.$indexCPFP(newBlock.id, lastBlock.height - i);
this.updateTimerProgress(timer, `reindexed block cpfp`);
} }
} }
await mining.$indexDifficultyAdjustments(); await mining.$indexDifficultyAdjustments();
await DifficultyAdjustmentsRepository.$deleteLastAdjustment(); await DifficultyAdjustmentsRepository.$deleteLastAdjustment();
this.updateTimerProgress(timer, `reindexed difficulty adjustments`);
logger.info(`Re-indexed 10 blocks and summaries. Also re-indexed the last difficulty adjustments. Will re-index latest hashrates in a few seconds.`, logger.tags.mining); logger.info(`Re-indexed 10 blocks and summaries. Also re-indexed the last difficulty adjustments. Will re-index latest hashrates in a few seconds.`, logger.tags.mining);
indexer.reindex(); indexer.reindex();
} }
await blocksRepository.$saveBlockInDatabase(blockExtended); await blocksRepository.$saveBlockInDatabase(blockExtended);
this.updateTimerProgress(timer, `saved ${this.currentBlockHeight} to database`);
const lastestPriceId = await PricesRepository.$getLatestPriceId(); const lastestPriceId = await PricesRepository.$getLatestPriceId();
this.updateTimerProgress(timer, `got latest price id ${this.currentBlockHeight}`);
if (priceUpdater.historyInserted === true && lastestPriceId !== null) { if (priceUpdater.historyInserted === true && lastestPriceId !== null) {
await blocksRepository.$saveBlockPrices([{ await blocksRepository.$saveBlockPrices([{
height: blockExtended.height, height: blockExtended.height,
priceId: lastestPriceId, priceId: lastestPriceId,
}]); }]);
this.updateTimerProgress(timer, `saved prices for ${this.currentBlockHeight}`);
} else { } else {
logger.debug(`Cannot save block price for ${blockExtended.height} because the price updater hasnt completed yet. Trying again in 10 seconds.`, logger.tags.mining); logger.debug(`Cannot save block price for ${blockExtended.height} because the price updater hasnt completed yet. Trying again in 10 seconds.`, logger.tags.mining);
setTimeout(() => { setTimeout(() => {
@ -625,9 +650,11 @@ class Blocks {
// Save blocks summary for visualization if it's enabled // Save blocks summary for visualization if it's enabled
if (Common.blocksSummariesIndexingEnabled() === true) { if (Common.blocksSummariesIndexingEnabled() === true) {
await this.$getStrippedBlockTransactions(blockExtended.id, true); await this.$getStrippedBlockTransactions(blockExtended.id, true);
this.updateTimerProgress(timer, `saved block summary for ${this.currentBlockHeight}`);
} }
if (config.MEMPOOL.CPFP_INDEXING) { if (config.MEMPOOL.CPFP_INDEXING) {
this.$saveCpfp(blockExtended.id, this.currentBlockHeight, cpfpSummary); this.$saveCpfp(blockExtended.id, this.currentBlockHeight, cpfpSummary);
this.updateTimerProgress(timer, `saved cpfp for ${this.currentBlockHeight}`);
} }
} }
} }
@ -640,6 +667,7 @@ class Blocks {
difficulty: block.difficulty, difficulty: block.difficulty,
adjustment: Math.round((block.difficulty / this.currentDifficulty) * 1000000) / 1000000, // Remove float point noise adjustment: Math.round((block.difficulty / this.currentDifficulty) * 1000000) / 1000000, // Remove float point noise
}); });
this.updateTimerProgress(timer, `saved difficulty adjustment for ${this.currentBlockHeight}`);
} }
this.previousDifficultyRetarget = (block.difficulty - this.currentDifficulty) / this.currentDifficulty * 100; this.previousDifficultyRetarget = (block.difficulty - this.currentDifficulty) / this.currentDifficulty * 100;
@ -664,7 +692,33 @@ class Blocks {
} }
// wait for pending async callbacks to finish // wait for pending async callbacks to finish
this.updateTimerProgress(timer, `waiting for async callbacks to complete for ${this.currentBlockHeight}`);
await Promise.all(callbackPromises); await Promise.all(callbackPromises);
this.updateTimerProgress(timer, `async callbacks completed for ${this.currentBlockHeight}`);
}
this.clearTimer(timer);
}
private startTimer() {
const state: any = {
start: Date.now(),
progress: 'begin $updateBlocks',
timer: null,
};
state.timer = setTimeout(() => {
logger.err(`$updateBlocks stalled at "${state.progress}"`);
}, this.mainLoopTimeout);
return state;
}
private updateTimerProgress(state, msg) {
state.progress = msg;
}
private clearTimer(state) {
if (state.timer) {
clearTimeout(state.timer);
} }
} }
@ -857,6 +911,7 @@ class Blocks {
} }
if (cleanBlock.fee_amt_percentiles !== null) { if (cleanBlock.fee_amt_percentiles !== null) {
cleanBlock.median_fee_amt = cleanBlock.fee_amt_percentiles[3]; cleanBlock.median_fee_amt = cleanBlock.fee_amt_percentiles[3];
await blocksRepository.$updateFeeAmounts(cleanBlock.hash, cleanBlock.fee_amt_percentiles, cleanBlock.median_fee_amt);
} }
} }

View File

@ -57,11 +57,11 @@ export class Common {
return arr; return arr;
} }
static findRbfTransactions(added: TransactionExtended[], deleted: TransactionExtended[]): { [txid: string]: TransactionExtended } { static findRbfTransactions(added: TransactionExtended[], deleted: TransactionExtended[]): { [txid: string]: TransactionExtended[] } {
const matches: { [txid: string]: TransactionExtended } = {}; const matches: { [txid: string]: TransactionExtended[] } = {};
deleted added
.forEach((deletedTx) => { .forEach((addedTx) => {
const foundMatches = added.find((addedTx) => { const foundMatches = deleted.filter((deletedTx) => {
// The new tx must, absolutely speaking, pay at least as much fee as the replaced tx. // The new tx must, absolutely speaking, pay at least as much fee as the replaced tx.
return addedTx.fee > deletedTx.fee return addedTx.fee > deletedTx.fee
// The new transaction must pay more fee per kB than the replaced tx. // The new transaction must pay more fee per kB than the replaced tx.
@ -70,8 +70,8 @@ export class Common {
&& deletedTx.vin.some((deletedVin) => && deletedTx.vin.some((deletedVin) =>
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) { if (foundMatches?.length) {
matches[deletedTx.txid] = foundMatches; matches[addedTx.txid] = foundMatches;
} }
}); });
return matches; return matches;

View File

@ -7,14 +7,18 @@ import logger from '../logger';
import config from '../config'; import config from '../config';
import { TransactionExtended } from '../mempool.interfaces'; import { TransactionExtended } from '../mempool.interfaces';
import { Common } from './common'; import { Common } from './common';
import rbfCache from './rbf-cache';
class DiskCache { class DiskCache {
private cacheSchemaVersion = 3; private cacheSchemaVersion = 3;
private rbfCacheSchemaVersion = 1;
private static TMP_FILE_NAME = config.MEMPOOL.CACHE_DIR + '/tmp-cache.json'; private static TMP_FILE_NAME = config.MEMPOOL.CACHE_DIR + '/tmp-cache.json';
private static TMP_FILE_NAMES = config.MEMPOOL.CACHE_DIR + '/tmp-cache{number}.json'; private static TMP_FILE_NAMES = config.MEMPOOL.CACHE_DIR + '/tmp-cache{number}.json';
private static FILE_NAME = config.MEMPOOL.CACHE_DIR + '/cache.json'; private static FILE_NAME = config.MEMPOOL.CACHE_DIR + '/cache.json';
private static FILE_NAMES = config.MEMPOOL.CACHE_DIR + '/cache{number}.json'; private static FILE_NAMES = config.MEMPOOL.CACHE_DIR + '/cache{number}.json';
private static TMP_RBF_FILE_NAME = config.MEMPOOL.CACHE_DIR + '/tmp-rbfcache.json';
private static RBF_FILE_NAME = config.MEMPOOL.CACHE_DIR + '/rbfcache.json';
private static CHUNK_FILES = 25; private static CHUNK_FILES = 25;
private isWritingCache = false; private isWritingCache = false;
@ -100,6 +104,32 @@ class DiskCache {
logger.warn('Error writing to cache file: ' + (e instanceof Error ? e.message : e)); logger.warn('Error writing to cache file: ' + (e instanceof Error ? e.message : e));
this.isWritingCache = false; this.isWritingCache = false;
} }
try {
logger.debug('Writing rbf data to disk cache (async)...');
this.isWritingCache = true;
const rbfData = rbfCache.dump();
if (sync) {
fs.writeFileSync(DiskCache.TMP_RBF_FILE_NAME, JSON.stringify({
network: config.MEMPOOL.NETWORK,
rbfCacheSchemaVersion: this.rbfCacheSchemaVersion,
rbf: rbfData,
}), { flag: 'w' });
fs.renameSync(DiskCache.TMP_RBF_FILE_NAME, DiskCache.RBF_FILE_NAME);
} else {
await fsPromises.writeFile(DiskCache.TMP_RBF_FILE_NAME, JSON.stringify({
network: config.MEMPOOL.NETWORK,
rbfCacheSchemaVersion: this.rbfCacheSchemaVersion,
rbf: rbfData,
}), { flag: 'w' });
await fsPromises.rename(DiskCache.TMP_RBF_FILE_NAME, DiskCache.RBF_FILE_NAME);
}
logger.debug('Rbf data saved to disk cache');
this.isWritingCache = false;
} catch (e) {
logger.warn('Error writing rbf data to cache file: ' + (e instanceof Error ? e.message : e));
this.isWritingCache = false;
}
} }
wipeCache(): void { wipeCache(): void {
@ -124,7 +154,19 @@ class DiskCache {
} }
} }
loadMempoolCache(): void { wipeRbfCache() {
logger.notice(`Wipping nodejs backend cache/rbfcache.json file`);
try {
fs.unlinkSync(DiskCache.RBF_FILE_NAME);
} catch (e: any) {
if (e?.code !== 'ENOENT') {
logger.err(`Cannot wipe cache file ${DiskCache.RBF_FILE_NAME}. Exception ${JSON.stringify(e)}`);
}
}
}
async $loadMempoolCache(): Promise<void> {
if (!fs.existsSync(DiskCache.FILE_NAME)) { if (!fs.existsSync(DiskCache.FILE_NAME)) {
return; return;
} }
@ -168,12 +210,35 @@ class DiskCache {
} }
} }
memPool.setMempool(data.mempool); await memPool.$setMempool(data.mempool);
blocks.setBlocks(data.blocks); blocks.setBlocks(data.blocks);
blocks.setBlockSummaries(data.blockSummaries || []); blocks.setBlockSummaries(data.blockSummaries || []);
} catch (e) { } catch (e) {
logger.warn('Failed to parse mempoool and blocks cache. Skipping. Reason: ' + (e instanceof Error ? e.message : e)); logger.warn('Failed to parse mempoool and blocks cache. Skipping. Reason: ' + (e instanceof Error ? e.message : e));
} }
try {
let rbfData: any = {};
const rbfCacheData = fs.readFileSync(DiskCache.RBF_FILE_NAME, 'utf8');
if (rbfCacheData) {
logger.info('Restoring rbf data from disk cache');
rbfData = JSON.parse(rbfCacheData);
if (rbfData.rbfCacheSchemaVersion === undefined || rbfData.rbfCacheSchemaVersion !== this.rbfCacheSchemaVersion) {
logger.notice('Rbf disk cache contains an outdated schema version. Clearing it and skipping the cache loading.');
return this.wipeRbfCache();
}
if (rbfData.network && rbfData.network !== config.MEMPOOL.NETWORK) {
logger.notice('Rbf disk cache contains data from a different network. Clearing it and skipping the cache loading.');
return this.wipeRbfCache();
}
}
if (rbfData?.rbf) {
rbfCache.load(rbfData.rbf);
}
} catch (e) {
logger.warn('Failed to parse rbf cache. Skipping. Reason: ' + (e instanceof Error ? e.message : e));
}
} }
} }

View File

@ -2,7 +2,7 @@ import * as fs from 'fs';
import logger from '../../logger'; import logger from '../../logger';
class Icons { class Icons {
private static FILE_NAME = './icons.json'; private static FILE_NAME = '/elements/asset_registry_db/icons.json';
private iconIds: string[] = []; private iconIds: string[] = [];
private icons: { [assetId: string]: string; } = {}; private icons: { [assetId: string]: string; } = {};

View File

@ -54,7 +54,14 @@ class MempoolBlocks {
}); });
// First sort // First sort
memPoolArray.sort((a, b) => b.feePerVsize - a.feePerVsize); memPoolArray.sort((a, b) => {
if (a.feePerVsize === b.feePerVsize) {
// tie-break by lexicographic txid order for stability
return a.txid < b.txid ? -1 : 1;
} else {
return b.feePerVsize - a.feePerVsize;
}
});
// Loop through and traverse all ancestors and sum up all the sizes + fees // Loop through and traverse all ancestors and sum up all the sizes + fees
// Pass down size + fee to all unconfirmed children // Pass down size + fee to all unconfirmed children
@ -68,7 +75,14 @@ class MempoolBlocks {
}); });
// Final sort, by effective fee // Final sort, by effective fee
memPoolArray.sort((a, b) => b.effectiveFeePerVsize - a.effectiveFeePerVsize); memPoolArray.sort((a, b) => {
if (a.effectiveFeePerVsize === b.effectiveFeePerVsize) {
// tie-break by lexicographic txid order for stability
return a.txid < b.txid ? -1 : 1;
} else {
return b.effectiveFeePerVsize - a.effectiveFeePerVsize;
}
});
const end = new Date().getTime(); const end = new Date().getTime();
const time = end - start; const time = end - start;
@ -88,14 +102,26 @@ class MempoolBlocks {
private calculateMempoolBlocks(transactionsSorted: TransactionExtended[]): MempoolBlockWithTransactions[] { private calculateMempoolBlocks(transactionsSorted: TransactionExtended[]): MempoolBlockWithTransactions[] {
const mempoolBlocks: MempoolBlockWithTransactions[] = []; const mempoolBlocks: MempoolBlockWithTransactions[] = [];
let blockWeight = 0; let blockWeight = 0;
let blockVsize = 0;
let transactions: TransactionExtended[] = []; let transactions: TransactionExtended[] = [];
transactionsSorted.forEach((tx) => { transactionsSorted.forEach((tx) => {
if (blockWeight + tx.weight <= config.MEMPOOL.BLOCK_WEIGHT_UNITS if (blockWeight + tx.weight <= config.MEMPOOL.BLOCK_WEIGHT_UNITS
|| mempoolBlocks.length === config.MEMPOOL.MEMPOOL_BLOCKS_AMOUNT - 1) { || mempoolBlocks.length === config.MEMPOOL.MEMPOOL_BLOCKS_AMOUNT - 1) {
tx.position = {
block: mempoolBlocks.length,
vsize: blockVsize + (tx.vsize / 2),
};
blockWeight += tx.weight; blockWeight += tx.weight;
blockVsize += tx.vsize;
transactions.push(tx); transactions.push(tx);
} else { } else {
mempoolBlocks.push(this.dataToMempoolBlocks(transactions)); mempoolBlocks.push(this.dataToMempoolBlocks(transactions));
blockVsize = 0;
tx.position = {
block: mempoolBlocks.length,
vsize: blockVsize + (tx.vsize / 2),
};
blockVsize += tx.vsize;
blockWeight = tx.weight; blockWeight = tx.weight;
transactions = [tx]; transactions = [tx];
} }
@ -148,7 +174,7 @@ class MempoolBlocks {
return mempoolBlockDeltas; return mempoolBlockDeltas;
} }
public async makeBlockTemplates(newMempool: { [txid: string]: TransactionExtended }, saveResults: boolean = false): Promise<MempoolBlockWithTransactions[]> { public async $makeBlockTemplates(newMempool: { [txid: string]: TransactionExtended }, saveResults: boolean = false): Promise<MempoolBlockWithTransactions[]> {
// prepare a stripped down version of the mempool with only the minimum necessary data // prepare a stripped down version of the mempool with only the minimum necessary data
// to reduce the overhead of passing this data to the worker thread // to reduce the overhead of passing this data to the worker thread
const strippedMempool: { [txid: string]: ThreadTransaction } = {}; const strippedMempool: { [txid: string]: ThreadTransaction } = {};
@ -206,10 +232,10 @@ class MempoolBlocks {
return this.mempoolBlocks; return this.mempoolBlocks;
} }
public async updateBlockTemplates(newMempool: { [txid: string]: TransactionExtended }, added: TransactionExtended[], removed: string[], saveResults: boolean = false): Promise<void> { public async $updateBlockTemplates(newMempool: { [txid: string]: TransactionExtended }, added: TransactionExtended[], removed: string[], saveResults: boolean = false): Promise<void> {
if (!this.txSelectionWorker) { if (!this.txSelectionWorker) {
// need to reset the worker // need to reset the worker
this.makeBlockTemplates(newMempool, saveResults); await this.$makeBlockTemplates(newMempool, saveResults);
return; return;
} }
// prepare a stripped down version of the mempool with only the minimum necessary data // prepare a stripped down version of the mempool with only the minimum necessary data
@ -256,9 +282,17 @@ class MempoolBlocks {
private processBlockTemplates(mempool, blocks, clusters, saveResults): MempoolBlockWithTransactions[] { private processBlockTemplates(mempool, blocks, clusters, saveResults): MempoolBlockWithTransactions[] {
// update this thread's mempool with the results // update this thread's mempool with the results
blocks.forEach(block => { blocks.forEach((block, blockIndex) => {
let runningVsize = 0;
block.forEach(tx => { block.forEach(tx => {
if (tx.txid && tx.txid in mempool) { if (tx.txid && tx.txid in mempool) {
// save position in projected blocks
mempool[tx.txid].position = {
block: blockIndex,
vsize: runningVsize + (mempool[tx.txid].vsize / 2),
};
runningVsize += mempool[tx.txid].vsize;
if (tx.effectiveFeePerVsize != null) { if (tx.effectiveFeePerVsize != null) {
mempool[tx.txid].effectiveFeePerVsize = tx.effectiveFeePerVsize; mempool[tx.txid].effectiveFeePerVsize = tx.effectiveFeePerVsize;
} }

View File

@ -20,7 +20,7 @@ class Mempool {
maxmempool: 300000000, mempoolminfee: 0.00001000, minrelaytxfee: 0.00001000 }; maxmempool: 300000000, mempoolminfee: 0.00001000, minrelaytxfee: 0.00001000 };
private mempoolChangedCallback: ((newMempool: {[txId: string]: TransactionExtended; }, newTransactions: TransactionExtended[], private mempoolChangedCallback: ((newMempool: {[txId: string]: TransactionExtended; }, newTransactions: TransactionExtended[],
deletedTransactions: TransactionExtended[]) => void) | undefined; deletedTransactions: TransactionExtended[]) => void) | undefined;
private asyncMempoolChangedCallback: ((newMempool: {[txId: string]: TransactionExtended; }, newTransactions: TransactionExtended[], private $asyncMempoolChangedCallback: ((newMempool: {[txId: string]: TransactionExtended; }, newTransactions: TransactionExtended[],
deletedTransactions: TransactionExtended[]) => Promise<void>) | undefined; deletedTransactions: TransactionExtended[]) => Promise<void>) | undefined;
private txPerSecondArray: number[] = []; private txPerSecondArray: number[] = [];
@ -36,6 +36,8 @@ class Mempool {
private timer = new Date().getTime(); private timer = new Date().getTime();
private missingTxCount = 0; private missingTxCount = 0;
private mainLoopTimeout: number = 120000;
constructor() { constructor() {
setInterval(this.updateTxPerSecond.bind(this), 1000); setInterval(this.updateTxPerSecond.bind(this), 1000);
} }
@ -71,20 +73,20 @@ class Mempool {
public setAsyncMempoolChangedCallback(fn: (newMempool: { [txId: string]: TransactionExtended; }, public setAsyncMempoolChangedCallback(fn: (newMempool: { [txId: string]: TransactionExtended; },
newTransactions: TransactionExtended[], deletedTransactions: TransactionExtended[]) => Promise<void>) { newTransactions: TransactionExtended[], deletedTransactions: TransactionExtended[]) => Promise<void>) {
this.asyncMempoolChangedCallback = fn; this.$asyncMempoolChangedCallback = fn;
} }
public getMempool(): { [txid: string]: TransactionExtended } { public getMempool(): { [txid: string]: TransactionExtended } {
return this.mempoolCache; return this.mempoolCache;
} }
public setMempool(mempoolData: { [txId: string]: TransactionExtended }) { public async $setMempool(mempoolData: { [txId: string]: TransactionExtended }) {
this.mempoolCache = mempoolData; this.mempoolCache = mempoolData;
if (this.mempoolChangedCallback) { if (this.mempoolChangedCallback) {
this.mempoolChangedCallback(this.mempoolCache, [], []); this.mempoolChangedCallback(this.mempoolCache, [], []);
} }
if (this.asyncMempoolChangedCallback) { if (this.$asyncMempoolChangedCallback) {
this.asyncMempoolChangedCallback(this.mempoolCache, [], []); await this.$asyncMempoolChangedCallback(this.mempoolCache, [], []);
} }
} }
@ -119,10 +121,15 @@ class Mempool {
public async $updateMempool(): Promise<void> { public async $updateMempool(): Promise<void> {
logger.debug(`Updating mempool...`); logger.debug(`Updating mempool...`);
// warn if this run stalls the main loop for more than 2 minutes
const timer = this.startTimer();
const start = new Date().getTime(); const start = new Date().getTime();
let hasChange: boolean = false; let hasChange: boolean = false;
const currentMempoolSize = Object.keys(this.mempoolCache).length; const currentMempoolSize = Object.keys(this.mempoolCache).length;
const transactions = await bitcoinApi.$getRawMempool(); const transactions = await bitcoinApi.$getRawMempool();
this.updateTimerProgress(timer, 'got raw mempool');
const diff = transactions.length - currentMempoolSize; const diff = transactions.length - currentMempoolSize;
const newTransactions: TransactionExtended[] = []; const newTransactions: TransactionExtended[] = [];
@ -146,6 +153,7 @@ class Mempool {
if (!this.mempoolCache[txid]) { if (!this.mempoolCache[txid]) {
try { try {
const transaction = await transactionUtils.$getTransactionExtended(txid); const transaction = await transactionUtils.$getTransactionExtended(txid);
this.updateTimerProgress(timer, 'fetched new transaction');
this.mempoolCache[txid] = transaction; this.mempoolCache[txid] = transaction;
if (this.inSync) { if (this.inSync) {
this.txPerSecondArray.push(new Date().getTime()); this.txPerSecondArray.push(new Date().getTime());
@ -222,22 +230,50 @@ class Mempool {
if (this.mempoolChangedCallback && (hasChange || deletedTransactions.length)) { if (this.mempoolChangedCallback && (hasChange || deletedTransactions.length)) {
this.mempoolChangedCallback(this.mempoolCache, newTransactions, deletedTransactions); this.mempoolChangedCallback(this.mempoolCache, newTransactions, deletedTransactions);
} }
if (this.asyncMempoolChangedCallback && (hasChange || deletedTransactions.length)) { if (this.$asyncMempoolChangedCallback && (hasChange || deletedTransactions.length)) {
await this.asyncMempoolChangedCallback(this.mempoolCache, newTransactions, deletedTransactions); this.updateTimerProgress(timer, 'running async mempool callback');
await this.$asyncMempoolChangedCallback(this.mempoolCache, newTransactions, deletedTransactions);
this.updateTimerProgress(timer, 'completed async mempool callback');
} }
const end = new Date().getTime(); const end = new Date().getTime();
const time = end - start; const time = end - start;
logger.debug(`Mempool updated in ${time / 1000} seconds. New size: ${Object.keys(this.mempoolCache).length} (${diff > 0 ? '+' + diff : diff})`); logger.debug(`Mempool updated in ${time / 1000} seconds. New size: ${Object.keys(this.mempoolCache).length} (${diff > 0 ? '+' + diff : diff})`);
this.clearTimer(timer);
} }
public handleRbfTransactions(rbfTransactions: { [txid: string]: TransactionExtended; }) { private startTimer() {
const state: any = {
start: Date.now(),
progress: 'begin $updateMempool',
timer: null,
};
state.timer = setTimeout(() => {
logger.err(`$updateMempool stalled at "${state.progress}"`);
}, this.mainLoopTimeout);
return state;
}
private updateTimerProgress(state, msg) {
state.progress = msg;
}
private clearTimer(state) {
if (state.timer) {
clearTimeout(state.timer);
}
}
public handleRbfTransactions(rbfTransactions: { [txid: string]: TransactionExtended[]; }): void {
for (const rbfTransaction in rbfTransactions) { for (const rbfTransaction in rbfTransactions) {
if (this.mempoolCache[rbfTransaction]) { if (this.mempoolCache[rbfTransaction] && rbfTransactions[rbfTransaction]?.length) {
// Store replaced transactions // Store replaced transactions
rbfCache.add(this.mempoolCache[rbfTransaction], rbfTransactions[rbfTransaction].txid); rbfCache.add(rbfTransactions[rbfTransaction], this.mempoolCache[rbfTransaction]);
// Erase the replaced transactions from the local mempool // Erase the replaced transactions from the local mempool
delete this.mempoolCache[rbfTransaction]; for (const replaced of rbfTransactions[rbfTransaction]) {
delete this.mempoolCache[replaced.txid];
}
} }
} }
} }

View File

@ -1,46 +1,173 @@
import { TransactionExtended } from "../mempool.interfaces"; import { TransactionExtended, TransactionStripped } from "../mempool.interfaces";
import bitcoinApi from './bitcoin/bitcoin-api-factory';
import { Common } from "./common";
interface RbfTransaction extends TransactionStripped {
rbf?: boolean;
mined?: boolean;
}
interface RbfTree {
tx: RbfTransaction;
time: number;
interval?: number;
mined?: boolean;
fullRbf: boolean;
replaces: RbfTree[];
}
class RbfCache { class RbfCache {
private replacedBy: { [txid: string]: string; } = {}; private replacedBy: Map<string, string> = new Map();
private replaces: { [txid: string]: string[] } = {}; private replaces: Map<string, string[]> = new Map();
private txs: { [txid: string]: TransactionExtended } = {}; private rbfTrees: Map<string, RbfTree> = new Map(); // sequences of consecutive replacements
private expiring: { [txid: string]: Date } = {}; private dirtyTrees: Set<string> = new Set();
private treeMap: Map<string, string> = new Map(); // map of txids to sequence ids
private txs: Map<string, TransactionExtended> = new Map();
private expiring: Map<string, Date> = new Map();
constructor() { constructor() {
setInterval(this.cleanup.bind(this), 1000 * 60 * 60); setInterval(this.cleanup.bind(this), 1000 * 60 * 60);
} }
public add(replacedTx: TransactionExtended, newTxId: string): void { public add(replaced: TransactionExtended[], newTxExtended: TransactionExtended): void {
this.replacedBy[replacedTx.txid] = newTxId; if (!newTxExtended || !replaced?.length) {
this.txs[replacedTx.txid] = replacedTx; return;
if (!this.replaces[newTxId]) {
this.replaces[newTxId] = [];
} }
this.replaces[newTxId].push(replacedTx.txid);
const newTx = Common.stripTransaction(newTxExtended) as RbfTransaction;
const newTime = newTxExtended.firstSeen || Date.now();
newTx.rbf = newTxExtended.vin.some((v) => v.sequence < 0xfffffffe);
this.txs.set(newTx.txid, newTxExtended);
// maintain rbf trees
let fullRbf = false;
const replacedTrees: RbfTree[] = [];
for (const replacedTxExtended of replaced) {
const replacedTx = Common.stripTransaction(replacedTxExtended) as RbfTransaction;
replacedTx.rbf = replacedTxExtended.vin.some((v) => v.sequence < 0xfffffffe);
this.replacedBy.set(replacedTx.txid, newTx.txid);
if (this.treeMap.has(replacedTx.txid)) {
const treeId = this.treeMap.get(replacedTx.txid);
if (treeId) {
const tree = this.rbfTrees.get(treeId);
this.rbfTrees.delete(treeId);
if (tree) {
tree.interval = newTime - tree?.time;
replacedTrees.push(tree);
fullRbf = fullRbf || tree.fullRbf;
}
}
} else {
const replacedTime = replacedTxExtended.firstSeen || Date.now();
replacedTrees.push({
tx: replacedTx,
time: replacedTime,
interval: newTime - replacedTime,
fullRbf: !replacedTx.rbf,
replaces: [],
});
fullRbf = fullRbf || !replacedTx.rbf;
this.txs.set(replacedTx.txid, replacedTxExtended);
}
}
const treeId = replacedTrees[0].tx.txid;
const newTree = {
tx: newTx,
time: newTxExtended.firstSeen || Date.now(),
fullRbf,
replaces: replacedTrees
};
this.rbfTrees.set(treeId, newTree);
this.updateTreeMap(treeId, newTree);
this.replaces.set(newTx.txid, replacedTrees.map(tree => tree.tx.txid));
this.dirtyTrees.add(treeId);
} }
public getReplacedBy(txId: string): string | undefined { public getReplacedBy(txId: string): string | undefined {
return this.replacedBy[txId]; return this.replacedBy.get(txId);
} }
public getReplaces(txId: string): string[] | undefined { public getReplaces(txId: string): string[] | undefined {
return this.replaces[txId]; return this.replaces.get(txId);
} }
public getTx(txId: string): TransactionExtended | undefined { public getTx(txId: string): TransactionExtended | undefined {
return this.txs[txId]; return this.txs.get(txId);
}
public getRbfTree(txId: string): RbfTree | void {
return this.rbfTrees.get(this.treeMap.get(txId) || '');
}
// get a paginated list of RbfTrees
// ordered by most recent replacement time
public getRbfTrees(onlyFullRbf: boolean, after?: string): RbfTree[] {
const limit = 25;
const trees: RbfTree[] = [];
const used = new Set<string>();
const replacements: string[][] = Array.from(this.replacedBy).reverse();
const afterTree = after ? this.treeMap.get(after) : null;
let ready = !afterTree;
for (let i = 0; i < replacements.length && trees.length <= limit - 1; i++) {
const txid = replacements[i][1];
const treeId = this.treeMap.get(txid) || '';
if (treeId === afterTree) {
ready = true;
} else if (ready) {
if (!used.has(treeId)) {
const tree = this.rbfTrees.get(treeId);
used.add(treeId);
if (tree && (!onlyFullRbf || tree.fullRbf)) {
trees.push(tree);
}
}
}
}
return trees;
}
// get map of rbf trees that have been updated since the last call
public getRbfChanges(): { trees: {[id: string]: RbfTree }, map: { [txid: string]: string }} {
const changes: { trees: {[id: string]: RbfTree }, map: { [txid: string]: string }} = {
trees: {},
map: {},
};
this.dirtyTrees.forEach(id => {
const tree = this.rbfTrees.get(id);
if (tree) {
changes.trees[id] = tree;
this.getTransactionsInTree(tree).forEach(tx => {
changes.map[tx.txid] = id;
});
}
});
this.dirtyTrees = new Set();
return changes;
}
public mined(txid): void {
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.evict(txid);
} }
// flag a transaction as removed from the mempool // flag a transaction as removed from the mempool
public evict(txid): void { public evict(txid): void {
this.expiring[txid] = new Date(Date.now() + 1000 * 86400); // 24 hours this.expiring.set(txid, new Date(Date.now() + 1000 * 86400)); // 24 hours
} }
private cleanup(): void { private cleanup(): void {
const currentDate = new Date(); const currentDate = new Date();
for (const txid in this.expiring) { for (const txid in this.expiring) {
if (this.expiring[txid] < currentDate) { if ((this.expiring.get(txid) || 0) < currentDate) {
delete this.expiring[txid]; this.expiring.delete(txid);
this.remove(txid); this.remove(txid);
} }
} }
@ -48,18 +175,147 @@ class RbfCache {
// remove a transaction & all previous versions from the cache // remove a transaction & all previous versions from the cache
private remove(txid): void { private remove(txid): void {
// don't remove a transaction while a newer version remains in the mempool // don't remove a transaction if a newer version remains in the mempool
if (this.replaces[txid] && !this.replacedBy[txid]) { if (!this.replacedBy.has(txid)) {
const replaces = this.replaces[txid]; const replaces = this.replaces.get(txid);
delete this.replaces[txid]; this.replaces.delete(txid);
for (const tx of replaces) { this.treeMap.delete(txid);
this.txs.delete(txid);
this.expiring.delete(txid);
for (const tx of (replaces || [])) {
// recursively remove prior versions from the cache // recursively remove prior versions from the cache
delete this.replacedBy[tx]; this.replacedBy.delete(tx);
delete this.txs[tx]; // if this is the id of a tree, remove that too
if (this.treeMap.get(tx) === tx) {
this.rbfTrees.delete(tx);
}
this.remove(tx); this.remove(tx);
} }
} }
} }
private updateTreeMap(newId: string, tree: RbfTree): void {
this.treeMap.set(tree.tx.txid, newId);
tree.replaces.forEach(subtree => {
this.updateTreeMap(newId, subtree);
});
}
private getTransactionsInTree(tree: RbfTree, txs: RbfTransaction[] = []): RbfTransaction[] {
txs.push(tree.tx);
tree.replaces.forEach(subtree => {
this.getTransactionsInTree(subtree, txs);
});
return txs;
}
private setTreeMined(tree: RbfTree, txid: string): void {
if (tree.tx.txid === txid) {
tree.tx.mined = true;
} else {
tree.replaces.forEach(subtree => {
this.setTreeMined(subtree, txid);
});
}
}
public dump(): any {
const trees = Array.from(this.rbfTrees.values()).map((tree: RbfTree) => { return this.exportTree(tree); });
return {
txs: Array.from(this.txs.entries()),
trees,
expiring: Array.from(this.expiring.entries()),
};
}
public async load({ txs, trees, expiring }): Promise<void> {
txs.forEach(txEntry => {
this.txs.set(txEntry[0], txEntry[1]);
});
for (const deflatedTree of trees) {
await this.importTree(deflatedTree.root, deflatedTree.root, deflatedTree, this.txs);
}
expiring.forEach(expiringEntry => {
this.expiring.set(expiringEntry[0], expiringEntry[1]);
});
this.cleanup();
}
exportTree(tree: RbfTree, deflated: any = null) {
if (!deflated) {
deflated = {
root: tree.tx.txid,
};
}
deflated[tree.tx.txid] = {
tx: tree.tx.txid,
txMined: tree.tx.mined,
time: tree.time,
interval: tree.interval,
mined: tree.mined,
fullRbf: tree.fullRbf,
replaces: tree.replaces.map(child => child.tx.txid),
};
tree.replaces.forEach(child => {
this.exportTree(child, deflated);
});
return deflated;
}
async importTree(root, txid, deflated, txs: Map<string, TransactionExtended>, mined: boolean = false): Promise<RbfTree | void> {
const treeInfo = deflated[txid];
const replaces: RbfTree[] = [];
// check if any transactions in this tree have already been confirmed
mined = mined || treeInfo.mined;
if (!mined) {
try {
const apiTx = await bitcoinApi.$getRawTransaction(txid);
if (apiTx?.status?.confirmed) {
mined = true;
this.evict(txid);
}
} catch (e) {
// most transactions do not exist
}
}
// recursively reconstruct child trees
for (const childId of treeInfo.replaces) {
const replaced = await this.importTree(root, childId, deflated, txs, mined);
if (replaced) {
this.replacedBy.set(replaced.tx.txid, txid);
replaces.push(replaced);
if (replaced.mined) {
mined = true;
}
}
}
this.replaces.set(txid, replaces.map(t => t.tx.txid));
const tx = txs.get(txid);
if (!tx) {
return;
}
const strippedTx = Common.stripTransaction(tx) as RbfTransaction;
strippedTx.rbf = tx.vin.some((v) => v.sequence < 0xfffffffe);
strippedTx.mined = treeInfo.txMined;
const tree = {
tx: strippedTx,
time: treeInfo.time,
interval: treeInfo.interval,
mined: mined,
fullRbf: treeInfo.fullRbf,
replaces,
};
this.treeMap.set(txid, root);
if (root === txid) {
this.rbfTrees.set(root, tree);
this.dirtyTrees.add(root);
}
return tree;
}
} }
export default new RbfCache(); export default new RbfCache();

View File

@ -2,7 +2,6 @@ import config from '../config';
import logger from '../logger'; import logger from '../logger';
import { ThreadTransaction, MempoolBlockWithTransactions, AuditTransaction } from '../mempool.interfaces'; import { ThreadTransaction, MempoolBlockWithTransactions, AuditTransaction } from '../mempool.interfaces';
import { PairingHeap } from '../utils/pairing-heap'; import { PairingHeap } from '../utils/pairing-heap';
import { Common } from './common';
import { parentPort } from 'worker_threads'; import { parentPort } from 'worker_threads';
let mempool: { [txid: string]: ThreadTransaction } = {}; let mempool: { [txid: string]: ThreadTransaction } = {};
@ -72,7 +71,14 @@ function makeBlockTemplates(mempool: { [txid: string]: ThreadTransaction })
} }
// Sort by descending ancestor score // Sort by descending ancestor score
mempoolArray.sort((a, b) => (b.score || 0) - (a.score || 0)); mempoolArray.sort((a, b) => {
if (b.score === a.score) {
// tie-break by lexicographic txid order for stability
return a.txid < b.txid ? -1 : 1;
} else {
return (b.score || 0) - (a.score || 0);
}
});
// Build blocks by greedily choosing the highest feerate package // Build blocks by greedily choosing the highest feerate package
// (i.e. the package rooted in the transaction with the best ancestor score) // (i.e. the package rooted in the transaction with the best ancestor score)
@ -80,7 +86,14 @@ function makeBlockTemplates(mempool: { [txid: string]: ThreadTransaction })
let blockWeight = 4000; let blockWeight = 4000;
let blockSize = 0; let blockSize = 0;
let transactions: AuditTransaction[] = []; let transactions: AuditTransaction[] = [];
const modified: PairingHeap<AuditTransaction> = new PairingHeap((a, b): boolean => (a.score || 0) > (b.score || 0)); const modified: PairingHeap<AuditTransaction> = new PairingHeap((a, b): boolean => {
if (a.score === b.score) {
// tie-break by lexicographic txid order for stability
return a.txid > b.txid;
} else {
return (a.score || 0) > (b.score || 0);
}
});
let overflow: AuditTransaction[] = []; let overflow: AuditTransaction[] = [];
let failures = 0; let failures = 0;
let top = 0; let top = 0;
@ -107,7 +120,7 @@ function makeBlockTemplates(mempool: { [txid: string]: ThreadTransaction })
if (nextTx && !nextTx?.used) { if (nextTx && !nextTx?.used) {
// Check if the package fits into this block // Check if the package fits into this block
if (blockWeight + nextTx.ancestorWeight < config.MEMPOOL.BLOCK_WEIGHT_UNITS) { if (blocks.length >= 7 || (blockWeight + nextTx.ancestorWeight < config.MEMPOOL.BLOCK_WEIGHT_UNITS)) {
const ancestors: AuditTransaction[] = Array.from(nextTx.ancestorMap.values()); const ancestors: AuditTransaction[] = Array.from(nextTx.ancestorMap.values());
// sort ancestors by dependency graph (equivalent to sorting by ascending ancestor count) // sort ancestors by dependency graph (equivalent to sorting by ascending ancestor count)
const sortedTxSet = [...ancestors.sort((a, b) => { return (a.ancestorMap.size || 0) - (b.ancestorMap.size || 0); }), nextTx]; const sortedTxSet = [...ancestors.sort((a, b) => { return (a.ancestorMap.size || 0) - (b.ancestorMap.size || 0); }), nextTx];
@ -175,34 +188,14 @@ function makeBlockTemplates(mempool: { [txid: string]: ThreadTransaction })
overflow = []; overflow = [];
} }
} }
// pack any leftover transactions into the last block
for (const tx of overflow) { if (overflow.length > 0) {
if (!tx || tx?.used) { logger.warn('GBT overflow list unexpectedly non-empty after final block constructed');
continue;
} }
blockWeight += tx.weight; // add the final unbounded block if it contains any transactions
const mempoolTx = mempool[tx.txid]; if (transactions.length > 0) {
// update original copy of this tx with effective fee rate & relatives data blocks.push(transactions.map(t => mempool[t.txid]));
mempoolTx.effectiveFeePerVsize = tx.score;
if (tx.ancestorMap.size > 0) {
cpfpClusters[tx.txid] = Array.from(tx.ancestorMap?.values()).map(a => a.txid);
mempoolTx.cpfpRoot = tx.txid;
} }
mempoolTx.cpfpChecked = true;
transactions.push(tx);
tx.used = true;
}
const blockTransactions = transactions.map(t => mempool[t.txid]);
restOfArray.forEach(tx => {
blockWeight += tx.weight;
tx.effectiveFeePerVsize = tx.feePerVsize;
tx.cpfpChecked = false;
blockTransactions.push(tx);
});
if (blockTransactions.length) {
blocks.push(blockTransactions);
}
transactions = [];
const end = Date.now(); const end = Date.now();
const time = end - start; const time = end - start;

View File

@ -26,6 +26,10 @@ class WebsocketHandler {
private wss: WebSocket.Server | undefined; private wss: WebSocket.Server | undefined;
private extraInitProperties = {}; private extraInitProperties = {};
private numClients = 0;
private numConnected = 0;
private numDisconnected = 0;
constructor() { } constructor() { }
setWebsocketServer(wss: WebSocket.Server) { setWebsocketServer(wss: WebSocket.Server) {
@ -42,7 +46,11 @@ class WebsocketHandler {
} }
this.wss.on('connection', (client: WebSocket) => { this.wss.on('connection', (client: WebSocket) => {
this.numConnected++;
client.on('error', logger.info); client.on('error', logger.info);
client.on('close', () => {
this.numDisconnected++;
});
client.on('message', async (message: string) => { client.on('message', async (message: string) => {
try { try {
const parsedMessage: WebsocketResponse = JSON.parse(message); const parsedMessage: WebsocketResponse = JSON.parse(message);
@ -58,9 +66,10 @@ class WebsocketHandler {
if (parsedMessage && parsedMessage['track-tx']) { if (parsedMessage && parsedMessage['track-tx']) {
if (/^[a-fA-F0-9]{64}$/.test(parsedMessage['track-tx'])) { if (/^[a-fA-F0-9]{64}$/.test(parsedMessage['track-tx'])) {
client['track-tx'] = parsedMessage['track-tx']; client['track-tx'] = parsedMessage['track-tx'];
const trackTxid = client['track-tx'];
// Client is telling the transaction wasn't found // Client is telling the transaction wasn't found
if (parsedMessage['watch-mempool']) { if (parsedMessage['watch-mempool']) {
const rbfCacheTxid = rbfCache.getReplacedBy(client['track-tx']); const rbfCacheTxid = rbfCache.getReplacedBy(trackTxid);
if (rbfCacheTxid) { if (rbfCacheTxid) {
response['txReplaced'] = { response['txReplaced'] = {
txid: rbfCacheTxid, txid: rbfCacheTxid,
@ -68,7 +77,7 @@ class WebsocketHandler {
client['track-tx'] = null; client['track-tx'] = null;
} else { } else {
// It might have appeared before we had the time to start watching for it // It might have appeared before we had the time to start watching for it
const tx = memPool.getMempool()[client['track-tx']]; const tx = memPool.getMempool()[trackTxid];
if (tx) { if (tx) {
if (config.MEMPOOL.BACKEND === 'esplora') { if (config.MEMPOOL.BACKEND === 'esplora') {
response['tx'] = tx; response['tx'] = tx;
@ -92,6 +101,13 @@ class WebsocketHandler {
} }
} }
} }
const tx = memPool.getMempool()[trackTxid];
if (tx && tx.position) {
response['txPosition'] = {
txid: trackTxid,
position: tx.position,
};
}
} else { } else {
client['track-tx'] = null; client['track-tx'] = null;
} }
@ -132,6 +148,14 @@ class WebsocketHandler {
} }
} }
if (parsedMessage && parsedMessage['track-rbf'] !== undefined) {
if (['all', 'fullRbf'].includes(parsedMessage['track-rbf'])) {
client['track-rbf'] = parsedMessage['track-rbf'];
} else {
client['track-rbf'] = false;
}
}
if (parsedMessage.action === 'init') { if (parsedMessage.action === 'init') {
const _blocks = blocks.getBlocks().slice(-config.MEMPOOL.INITIAL_BLOCKS_AMOUNT); const _blocks = blocks.getBlocks().slice(-config.MEMPOOL.INITIAL_BLOCKS_AMOUNT);
if (!_blocks) { if (!_blocks) {
@ -232,6 +256,8 @@ class WebsocketHandler {
throw new Error('WebSocket.Server is not set'); throw new Error('WebSocket.Server is not set');
} }
this.printLogs();
this.wss.clients.forEach((client) => { this.wss.clients.forEach((client) => {
if (client.readyState !== WebSocket.OPEN) { if (client.readyState !== WebSocket.OPEN) {
return; return;
@ -247,14 +273,16 @@ class WebsocketHandler {
}); });
} }
async handleMempoolChange(newMempool: { [txid: string]: TransactionExtended }, async $handleMempoolChange(newMempool: { [txid: string]: TransactionExtended },
newTransactions: TransactionExtended[], deletedTransactions: TransactionExtended[]): Promise<void> { newTransactions: TransactionExtended[], deletedTransactions: TransactionExtended[]): Promise<void> {
if (!this.wss) { if (!this.wss) {
throw new Error('WebSocket.Server is not set'); throw new Error('WebSocket.Server is not set');
} }
this.printLogs();
if (config.MEMPOOL.ADVANCED_GBT_MEMPOOL) { if (config.MEMPOOL.ADVANCED_GBT_MEMPOOL) {
await mempoolBlocks.updateBlockTemplates(newMempool, newTransactions, deletedTransactions.map(tx => tx.txid), true); await mempoolBlocks.$updateBlockTemplates(newMempool, newTransactions, deletedTransactions.map(tx => tx.txid), true);
} else { } else {
mempoolBlocks.updateMempoolBlocks(newMempool, true); mempoolBlocks.updateMempoolBlocks(newMempool, true);
} }
@ -266,6 +294,13 @@ class WebsocketHandler {
const rbfTransactions = Common.findRbfTransactions(newTransactions, deletedTransactions); const rbfTransactions = Common.findRbfTransactions(newTransactions, deletedTransactions);
const da = difficultyAdjustment.getDifficultyAdjustment(); const da = difficultyAdjustment.getDifficultyAdjustment();
memPool.handleRbfTransactions(rbfTransactions); memPool.handleRbfTransactions(rbfTransactions);
const rbfChanges = rbfCache.getRbfChanges();
let rbfReplacements;
let fullRbfReplacements;
if (Object.keys(rbfChanges.trees).length) {
rbfReplacements = rbfCache.getRbfTrees(false);
fullRbfReplacements = rbfCache.getRbfTrees(true);
}
const recommendedFees = feeApi.getRecommendedFee(); const recommendedFees = feeApi.getRecommendedFee();
this.wss.clients.forEach(async (client) => { this.wss.clients.forEach(async (client) => {
@ -374,9 +409,10 @@ class WebsocketHandler {
} }
if (client['track-tx']) { if (client['track-tx']) {
const trackTxid = client['track-tx'];
const outspends: object = {}; const outspends: object = {};
newTransactions.forEach((tx) => tx.vin.forEach((vin, i) => { newTransactions.forEach((tx) => tx.vin.forEach((vin, i) => {
if (vin.txid === client['track-tx']) { if (vin.txid === trackTxid) {
outspends[vin.vout] = { outspends[vin.vout] = {
vin: i, vin: i,
txid: tx.txid, txid: tx.txid,
@ -388,15 +424,24 @@ class WebsocketHandler {
response['utxoSpent'] = outspends; response['utxoSpent'] = outspends;
} }
if (rbfTransactions[client['track-tx']]) { const rbfReplacedBy = rbfCache.getReplacedBy(client['track-tx']);
for (const rbfTransaction in rbfTransactions) { if (rbfReplacedBy) {
if (client['track-tx'] === rbfTransaction) {
response['rbfTransaction'] = { response['rbfTransaction'] = {
txid: rbfTransactions[rbfTransaction].txid, txid: rbfReplacedBy,
}
}
const rbfChange = rbfChanges.map[client['track-tx']];
if (rbfChange) {
response['rbfInfo'] = rbfChanges.trees[rbfChange];
}
const mempoolTx = newMempool[trackTxid];
if (mempoolTx && mempoolTx.position) {
response['txPosition'] = {
txid: trackTxid,
position: mempoolTx.position,
}; };
break;
}
}
} }
} }
@ -410,6 +455,12 @@ class WebsocketHandler {
} }
} }
if (client['track-rbf'] === 'all' && rbfReplacements) {
response['rbfLatest'] = rbfReplacements;
} else if (client['track-rbf'] === 'fullRbf' && fullRbfReplacements) {
response['rbfLatest'] = fullRbfReplacements;
}
if (Object.keys(response).length) { if (Object.keys(response).length) {
client.send(JSON.stringify(response)); client.send(JSON.stringify(response));
} }
@ -421,18 +472,26 @@ class WebsocketHandler {
throw new Error('WebSocket.Server is not set'); throw new Error('WebSocket.Server is not set');
} }
this.printLogs();
const _memPool = memPool.getMempool(); const _memPool = memPool.getMempool();
if (config.MEMPOOL.AUDIT) { if (config.MEMPOOL.AUDIT) {
let projectedBlocks; let projectedBlocks;
let auditMempool = _memPool;
// template calculation functions have mempool side effects, so calculate audits using // template calculation functions have mempool side effects, so calculate audits using
// a cloned copy of the mempool if we're running a different algorithm for mempool updates // a cloned copy of the mempool if we're running a different algorithm for mempool updates
const auditMempool = (config.MEMPOOL.ADVANCED_GBT_AUDIT === config.MEMPOOL.ADVANCED_GBT_MEMPOOL) ? _memPool : deepClone(_memPool); const separateAudit = config.MEMPOOL.ADVANCED_GBT_AUDIT !== config.MEMPOOL.ADVANCED_GBT_MEMPOOL;
if (separateAudit) {
auditMempool = deepClone(_memPool);
if (config.MEMPOOL.ADVANCED_GBT_AUDIT) { if (config.MEMPOOL.ADVANCED_GBT_AUDIT) {
projectedBlocks = await mempoolBlocks.makeBlockTemplates(auditMempool, false); projectedBlocks = await mempoolBlocks.$makeBlockTemplates(auditMempool, false);
} else { } else {
projectedBlocks = mempoolBlocks.updateMempoolBlocks(auditMempool, false); projectedBlocks = mempoolBlocks.updateMempoolBlocks(auditMempool, false);
} }
} else {
projectedBlocks = mempoolBlocks.getMempoolBlocksWithTransactions();
}
if (Common.indexingEnabled() && memPool.isInSync()) { if (Common.indexingEnabled() && memPool.isInSync()) {
const { censored, added, fresh, score, similarity } = Audit.auditBlock(transactions, projectedBlocks, auditMempool); const { censored, added, fresh, score, similarity } = Audit.auditBlock(transactions, projectedBlocks, auditMempool);
@ -477,16 +536,14 @@ class WebsocketHandler {
} }
} }
const removed: string[] = [];
// Update mempool to remove transactions included in the new block // Update mempool to remove transactions included in the new block
for (const txId of txIds) { for (const txId of txIds) {
delete _memPool[txId]; delete _memPool[txId];
removed.push(txId); rbfCache.mined(txId);
rbfCache.evict(txId);
} }
if (config.MEMPOOL.ADVANCED_GBT_MEMPOOL) { if (config.MEMPOOL.ADVANCED_GBT_MEMPOOL) {
await mempoolBlocks.updateBlockTemplates(_memPool, [], removed, true); await mempoolBlocks.$makeBlockTemplates(_memPool, true);
} else { } else {
mempoolBlocks.updateMempoolBlocks(_memPool, true); mempoolBlocks.updateMempoolBlocks(_memPool, true);
} }
@ -516,8 +573,19 @@ class WebsocketHandler {
response['mempool-blocks'] = mBlocks; response['mempool-blocks'] = mBlocks;
} }
if (client['track-tx'] && txIds.indexOf(client['track-tx']) > -1) { if (client['track-tx']) {
const trackTxid = client['track-tx'];
if (txIds.indexOf(trackTxid) > -1) {
response['txConfirmed'] = true; response['txConfirmed'] = true;
} else {
const mempoolTx = _memPool[trackTxid];
if (mempoolTx && mempoolTx.position) {
response['txPosition'] = {
txid: trackTxid,
position: mempoolTx.position,
};
}
}
} }
if (client['track-address']) { if (client['track-address']) {
@ -597,6 +665,17 @@ class WebsocketHandler {
client.send(JSON.stringify(response)); client.send(JSON.stringify(response));
}); });
} }
private printLogs(): void {
if (this.wss) {
const count = this.wss?.clients?.size || 0;
const diff = count - this.numClients;
this.numClients = count;
logger.debug(`${count} websocket clients | ${this.numConnected} connected | ${this.numDisconnected} disconnected | (${diff >= 0 ? '+' : ''}${diff})`);
this.numConnected = 0;
this.numDisconnected = 0;
}
}
} }
export default new WebsocketHandler(); export default new WebsocketHandler();

View File

@ -38,6 +38,7 @@ interface IConfig {
ESPLORA: { ESPLORA: {
REST_API_URL: string; REST_API_URL: string;
UNIX_SOCKET_PATH: string | void | null; UNIX_SOCKET_PATH: string | void | null;
RETRY_UNIX_SOCKET_AFTER: number;
}; };
LIGHTNING: { LIGHTNING: {
ENABLED: boolean; ENABLED: boolean;
@ -85,6 +86,7 @@ interface IConfig {
DATABASE: string; DATABASE: string;
USERNAME: string; USERNAME: string;
PASSWORD: string; PASSWORD: string;
TIMEOUT: number;
}; };
SYSLOG: { SYSLOG: {
ENABLED: boolean; ENABLED: boolean;
@ -165,6 +167,7 @@ const defaults: IConfig = {
'ESPLORA': { 'ESPLORA': {
'REST_API_URL': 'http://127.0.0.1:3000', 'REST_API_URL': 'http://127.0.0.1:3000',
'UNIX_SOCKET_PATH': null, 'UNIX_SOCKET_PATH': null,
'RETRY_UNIX_SOCKET_AFTER': 30000,
}, },
'ELECTRUM': { 'ELECTRUM': {
'HOST': '127.0.0.1', 'HOST': '127.0.0.1',
@ -192,7 +195,8 @@ const defaults: IConfig = {
'PORT': 3306, 'PORT': 3306,
'DATABASE': 'mempool', 'DATABASE': 'mempool',
'USERNAME': 'mempool', 'USERNAME': 'mempool',
'PASSWORD': 'mempool' 'PASSWORD': 'mempool',
'TIMEOUT': 180000,
}, },
'SYSLOG': { 'SYSLOG': {
'ENABLED': true, 'ENABLED': true,

View File

@ -33,9 +33,33 @@ import { FieldPacket, OkPacket, PoolOptions, ResultSetHeader, RowDataPacket } fr
OkPacket[] | ResultSetHeader>(query, params?): Promise<[T, FieldPacket[]]> OkPacket[] | ResultSetHeader>(query, params?): Promise<[T, FieldPacket[]]>
{ {
this.checkDBFlag(); this.checkDBFlag();
let hardTimeout;
if (query?.timeout != null) {
hardTimeout = Math.floor(query.timeout * 1.1);
} else {
hardTimeout = config.DATABASE.TIMEOUT;
}
if (hardTimeout > 0) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`DB query failed to return, reject or time out within ${hardTimeout / 1000}s - ${query?.sql?.slice(0, 160) || (typeof(query) === 'string' || query instanceof String ? query?.slice(0, 160) : 'unknown query')}`));
}, hardTimeout);
this.getPool().then(pool => {
return pool.query(query, params) as Promise<[T, FieldPacket[]]>;
}).then(result => {
resolve(result);
}).catch(error => {
reject(error);
}).finally(() => {
clearTimeout(timer);
});
});
} else {
const pool = await this.getPool(); const pool = await this.getPool();
return pool.query(query, params); return pool.query(query, params);
} }
}
public async checkDbConnection() { public async checkDbConnection() {
this.checkDBFlag(); this.checkDBFlag();

View File

@ -45,7 +45,8 @@ class Server {
private wss: WebSocket.Server | undefined; private wss: WebSocket.Server | undefined;
private server: http.Server | undefined; private server: http.Server | undefined;
private app: Application; private app: Application;
private currentBackendRetryInterval = 5; private currentBackendRetryInterval = 1;
private backendRetryCount = 0;
private maxHeapSize: number = 0; private maxHeapSize: number = 0;
private heapLogInterval: number = 60; private heapLogInterval: number = 60;
@ -120,7 +121,7 @@ class Server {
await poolsUpdater.updatePoolsJson(); // Needs to be done before loading the disk cache because we sometimes wipe it await poolsUpdater.updatePoolsJson(); // Needs to be done before loading the disk cache because we sometimes wipe it
await syncAssets.syncAssets$(); await syncAssets.syncAssets$();
if (config.MEMPOOL.ENABLED) { if (config.MEMPOOL.ENABLED) {
diskCache.loadMempoolCache(); await diskCache.$loadMempoolCache();
} }
if (config.STATISTICS.ENABLED && config.DATABASE.ENABLED && cluster.isPrimary) { if (config.STATISTICS.ENABLED && config.DATABASE.ENABLED && cluster.isPrimary) {
@ -178,23 +179,23 @@ class Server {
logger.debug(msg); logger.debug(msg);
} }
} }
memPool.deleteExpiredTransactions();
await blocks.$updateBlocks(); await blocks.$updateBlocks();
memPool.deleteExpiredTransactions();
await memPool.$updateMempool(); await memPool.$updateMempool();
indexer.$run(); indexer.$run();
setTimeout(this.runMainUpdateLoop.bind(this), config.MEMPOOL.POLL_RATE_MS); setTimeout(this.runMainUpdateLoop.bind(this), config.MEMPOOL.POLL_RATE_MS);
this.currentBackendRetryInterval = 5; this.backendRetryCount = 0;
} catch (e: any) { } catch (e: any) {
let loggerMsg = `Exception in runMainUpdateLoop(). Retrying in ${this.currentBackendRetryInterval} sec.`; this.backendRetryCount++;
let loggerMsg = `Exception in runMainUpdateLoop() (count: ${this.backendRetryCount}). Retrying in ${this.currentBackendRetryInterval} sec.`;
loggerMsg += ` Reason: ${(e instanceof Error ? e.message : e)}.`; loggerMsg += ` Reason: ${(e instanceof Error ? e.message : e)}.`;
if (e?.stack) { if (e?.stack) {
loggerMsg += ` Stack trace: ${e.stack}`; loggerMsg += ` Stack trace: ${e.stack}`;
} }
// When we get a first Exception, only `logger.debug` it and retry after 5 seconds // When we get a first Exception, only `logger.debug` it and retry after 5 seconds
// From the second Exception, `logger.warn` the Exception and increase the retry delay // From the second Exception, `logger.warn` the Exception and increase the retry delay
// Maximum retry delay is 60 seconds if (this.backendRetryCount >= 5) {
if (this.currentBackendRetryInterval > 5) {
logger.warn(loggerMsg); logger.warn(loggerMsg);
mempool.setOutOfSync(); mempool.setOutOfSync();
} else { } else {
@ -204,8 +205,6 @@ class Server {
logger.debug(`AxiosError: ${e?.message}`); logger.debug(`AxiosError: ${e?.message}`);
} }
setTimeout(this.runMainUpdateLoop.bind(this), 1000 * this.currentBackendRetryInterval); setTimeout(this.runMainUpdateLoop.bind(this), 1000 * this.currentBackendRetryInterval);
this.currentBackendRetryInterval *= 2;
this.currentBackendRetryInterval = Math.min(this.currentBackendRetryInterval, 60);
} }
} }
@ -238,7 +237,7 @@ class Server {
websocketHandler.setupConnectionHandling(); websocketHandler.setupConnectionHandling();
if (config.MEMPOOL.ENABLED) { if (config.MEMPOOL.ENABLED) {
statistics.setNewStatisticsEntryCallback(websocketHandler.handleNewStatistic.bind(websocketHandler)); statistics.setNewStatisticsEntryCallback(websocketHandler.handleNewStatistic.bind(websocketHandler));
memPool.setAsyncMempoolChangedCallback(websocketHandler.handleMempoolChange.bind(websocketHandler)); memPool.setAsyncMempoolChangedCallback(websocketHandler.$handleMempoolChange.bind(websocketHandler));
blocks.setNewAsyncBlockCallback(websocketHandler.handleNewBlock.bind(websocketHandler)); blocks.setNewAsyncBlockCallback(websocketHandler.handleNewBlock.bind(websocketHandler));
} }
priceUpdater.setRatesChangedCallback(websocketHandler.handleNewConversionRates.bind(websocketHandler)); priceUpdater.setRatesChangedCallback(websocketHandler.handleNewConversionRates.bind(websocketHandler));

View File

@ -81,6 +81,10 @@ export interface TransactionExtended extends IEsploraApi.Transaction {
bestDescendant?: BestDescendant | null; bestDescendant?: BestDescendant | null;
cpfpChecked?: boolean; cpfpChecked?: boolean;
deleteAfter?: number; deleteAfter?: number;
position?: {
block: number,
vsize: number,
};
} }
export interface AuditTransaction { export interface AuditTransaction {

View File

@ -13,6 +13,48 @@ import chainTips from '../api/chain-tips';
import blocks from '../api/blocks'; import blocks from '../api/blocks';
import BlocksAuditsRepository from './BlocksAuditsRepository'; import BlocksAuditsRepository from './BlocksAuditsRepository';
interface DatabaseBlock {
id: string;
height: number;
version: number;
timestamp: number;
bits: number;
nonce: number;
difficulty: number;
merkle_root: string;
tx_count: number;
size: number;
weight: number;
previousblockhash: string;
mediantime: number;
totalFees: number;
medianFee: number;
feeRange: string;
reward: number;
poolId: number;
poolName: string;
poolSlug: string;
avgFee: number;
avgFeeRate: number;
coinbaseRaw: string;
coinbaseAddress: string;
coinbaseSignature: string;
coinbaseSignatureAscii: string;
avgTxSize: number;
totalInputs: number;
totalOutputs: number;
totalOutputAmt: number;
medianFeeAmt: number;
feePercentiles: string;
segwitTotalTxs: number;
segwitTotalSize: number;
segwitTotalWeight: number;
header: string;
utxoSetChange: number;
utxoSetSize: number;
totalInputAmt: number;
}
const BLOCK_DB_FIELDS = ` const BLOCK_DB_FIELDS = `
blocks.hash AS id, blocks.hash AS id,
blocks.height, blocks.height,
@ -52,7 +94,7 @@ 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 totalInputAmts blocks.total_input_amt AS totalInputAmt
`; `;
class BlocksRepository { class BlocksRepository {
@ -171,6 +213,32 @@ class BlocksRepository {
} }
} }
/**
* Update missing fee amounts fields
*
* @param blockHash
* @param feeAmtPercentiles
* @param medianFeeAmt
*/
public async $updateFeeAmounts(blockHash: string, feeAmtPercentiles, medianFeeAmt) : Promise<void> {
try {
const query = `
UPDATE blocks
SET fee_percentiles = ?, median_fee_amt = ?
WHERE hash = ?
`;
const params: any[] = [
JSON.stringify(feeAmtPercentiles),
medianFeeAmt,
blockHash
];
await DB.query(query, params);
} catch (e: any) {
logger.err(`Cannot update fee amounts for block ${blockHash}. Reason: ' + ${e instanceof Error ? e.message : e}`);
throw e;
}
}
/** /**
* Get all block height that have not been indexed between [startHeight, endHeight] * Get all block height that have not been indexed between [startHeight, endHeight]
*/ */
@ -432,7 +500,7 @@ class BlocksRepository {
const blocks: BlockExtended[] = []; const blocks: BlockExtended[] = [];
for (const block of rows) { for (const block of rows) {
blocks.push(await this.formatDbBlockIntoExtendedBlock(block)); blocks.push(await this.formatDbBlockIntoExtendedBlock(block as DatabaseBlock));
} }
return blocks; return blocks;
@ -459,7 +527,7 @@ class BlocksRepository {
return null; return null;
} }
return await this.formatDbBlockIntoExtendedBlock(rows[0]); return await this.formatDbBlockIntoExtendedBlock(rows[0] as DatabaseBlock);
} catch (e) { } catch (e) {
logger.err(`Cannot get indexed block ${height}. Reason: ` + (e instanceof Error ? e.message : e)); logger.err(`Cannot get indexed block ${height}. Reason: ` + (e instanceof Error ? e.message : e));
throw e; throw e;
@ -908,7 +976,7 @@ class BlocksRepository {
* *
* @param dbBlk * @param dbBlk
*/ */
private async formatDbBlockIntoExtendedBlock(dbBlk: any): Promise<BlockExtended> { private async formatDbBlockIntoExtendedBlock(dbBlk: DatabaseBlock): Promise<BlockExtended> {
const blk: Partial<BlockExtended> = {}; const blk: Partial<BlockExtended> = {};
const extras: Partial<BlockExtension> = {}; const extras: Partial<BlockExtension> = {};
@ -980,11 +1048,12 @@ class BlocksRepository {
if (extras.feePercentiles === null) { if (extras.feePercentiles === null) {
const block = await bitcoinClient.getBlock(dbBlk.id, 2); const block = await bitcoinClient.getBlock(dbBlk.id, 2);
const summary = blocks.summarizeBlock(block); const summary = blocks.summarizeBlock(block);
await BlocksSummariesRepository.$saveTransactions(dbBlk.height, dbBlk.hash, summary.transactions); await BlocksSummariesRepository.$saveTransactions(dbBlk.height, dbBlk.id, summary.transactions);
extras.feePercentiles = await BlocksSummariesRepository.$getFeePercentilesByBlockId(dbBlk.id); extras.feePercentiles = await BlocksSummariesRepository.$getFeePercentilesByBlockId(dbBlk.id);
} }
if (extras.feePercentiles !== null) { if (extras.feePercentiles !== null) {
extras.medianFeeAmt = extras.feePercentiles[3]; extras.medianFeeAmt = extras.feePercentiles[3];
await this.$updateFeeAmounts(dbBlk.id, extras.feePercentiles, extras.medianFeeAmt);
} }
} }

View File

@ -17,26 +17,6 @@ class BlocksSummariesRepository {
return undefined; return undefined;
} }
public async $saveSummary(params: { height: number, mined?: BlockSummary}): Promise<void> {
const blockId = params.mined?.id;
try {
const transactions = JSON.stringify(params.mined?.transactions || []);
await DB.query(`
INSERT INTO blocks_summaries (height, id, transactions, template)
VALUE (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
transactions = ?
`, [params.height, blockId, transactions, '[]', transactions]);
} catch (e: any) {
if (e.errno === 1062) { // ER_DUP_ENTRY - This scenario is possible upon node backend restart
logger.debug(`Cannot save block summary for ${blockId} because it has already been indexed, ignoring`);
} else {
logger.debug(`Cannot save block summary for ${blockId}. Reason: ${e instanceof Error ? e.message : e}`);
throw e;
}
}
}
public async $saveTransactions(blockHeight: number, blockId: string, transactions: TransactionStripped[]): Promise<void> { public async $saveTransactions(blockHeight: number, blockId: string, transactions: TransactionStripped[]): Promise<void> {
try { try {
const transactionsStr = JSON.stringify(transactions); const transactionsStr = JSON.stringify(transactions);

View File

@ -152,7 +152,7 @@ class ForensicsService {
++progress; ++progress;
const elapsedSeconds = Math.round((new Date().getTime() / 1000) - this.loggerTimer); const elapsedSeconds = Math.round((new Date().getTime() / 1000) - this.loggerTimer);
if (elapsedSeconds > 10) { if (elapsedSeconds > 10) {
logger.info(`Updating channel closed channel forensics ${progress}/${channels.length}`); logger.debug(`Updating channel closed channel forensics ${progress}/${channels.length}`);
this.loggerTimer = new Date().getTime() / 1000; this.loggerTimer = new Date().getTime() / 1000;
} }
} }
@ -257,7 +257,7 @@ class ForensicsService {
++progress; ++progress;
const elapsedSeconds = Math.round((new Date().getTime() / 1000) - this.loggerTimer); const elapsedSeconds = Math.round((new Date().getTime() / 1000) - this.loggerTimer);
if (elapsedSeconds > 10) { if (elapsedSeconds > 10) {
logger.info(`Updating opened channel forensics ${progress}/${channels?.length}`); logger.debug(`Updating opened channel forensics ${progress}/${channels?.length}`);
this.loggerTimer = new Date().getTime() / 1000; this.loggerTimer = new Date().getTime() / 1000;
this.truncateTempCache(); this.truncateTempCache();
} }

View File

@ -300,7 +300,7 @@ class NetworkSyncService {
++progress; ++progress;
const elapsedSeconds = Math.round((new Date().getTime() / 1000) - this.loggerTimer); const elapsedSeconds = Math.round((new Date().getTime() / 1000) - this.loggerTimer);
if (elapsedSeconds > config.LIGHTNING.LOGGER_UPDATE_INTERVAL) { if (elapsedSeconds > config.LIGHTNING.LOGGER_UPDATE_INTERVAL) {
logger.info(`Checking if channel has been closed ${progress}/${channels.length}`, logger.tags.ln); logger.debug(`Checking if channel has been closed ${progress}/${channels.length}`, logger.tags.ln);
this.loggerTimer = new Date().getTime() / 1000; this.loggerTimer = new Date().getTime() / 1000;
} }
} }

View File

@ -205,7 +205,8 @@ Corresponding `docker-compose.yml` overrides:
```json ```json
"ESPLORA": { "ESPLORA": {
"REST_API_URL": "http://127.0.0.1:3000", "REST_API_URL": "http://127.0.0.1:3000",
"UNIX_SOCKET_PATH": "/tmp/esplora-socket" "UNIX_SOCKET_PATH": "/tmp/esplora-socket",
"RETRY_UNIX_SOCKET_AFTER": 30000
}, },
``` ```
@ -215,6 +216,7 @@ Corresponding `docker-compose.yml` overrides:
environment: environment:
ESPLORA_REST_API_URL: "" ESPLORA_REST_API_URL: ""
ESPLORA_UNIX_SOCKET_PATH: "" ESPLORA_UNIX_SOCKET_PATH: ""
ESPLORA_RETRY_UNIX_SOCKET_AFTER: ""
... ...
``` ```
@ -267,6 +269,7 @@ Corresponding `docker-compose.yml` overrides:
DATABASE_DATABASE: "" DATABASE_DATABASE: ""
DATABASE_USERNAME: "" DATABASE_USERNAME: ""
DATABASE_PASSWORD: "" DATABASE_PASSWORD: ""
DATABASE_TIMEOUT: ""
... ...
``` ```

View File

@ -43,7 +43,8 @@
}, },
"ESPLORA": { "ESPLORA": {
"REST_API_URL": "__ESPLORA_REST_API_URL__", "REST_API_URL": "__ESPLORA_REST_API_URL__",
"UNIX_SOCKET_PATH": "__ESPLORA_UNIX_SOCKET_PATH__" "UNIX_SOCKET_PATH": "__ESPLORA_UNIX_SOCKET_PATH__",
"RETRY_UNIX_SOCKET_AFTER": __ESPLORA_RETRY_UNIX_SOCKET_AFTER__
}, },
"SECOND_CORE_RPC": { "SECOND_CORE_RPC": {
"HOST": "__SECOND_CORE_RPC_HOST__", "HOST": "__SECOND_CORE_RPC_HOST__",
@ -59,7 +60,8 @@
"PORT": __DATABASE_PORT__, "PORT": __DATABASE_PORT__,
"DATABASE": "__DATABASE_DATABASE__", "DATABASE": "__DATABASE_DATABASE__",
"USERNAME": "__DATABASE_USERNAME__", "USERNAME": "__DATABASE_USERNAME__",
"PASSWORD": "__DATABASE_PASSWORD__" "PASSWORD": "__DATABASE_PASSWORD__",
"TIMEOUT": "__DATABASE_TIMEOUT__"
}, },
"SYSLOG": { "SYSLOG": {
"ENABLED": __SYSLOG_ENABLED__, "ENABLED": __SYSLOG_ENABLED__,

View File

@ -47,6 +47,7 @@ __ELECTRUM_TLS_ENABLED__=${ELECTRUM_TLS_ENABLED:=false}
# ESPLORA # ESPLORA
__ESPLORA_REST_API_URL__=${ESPLORA_REST_API_URL:=http://127.0.0.1:3000} __ESPLORA_REST_API_URL__=${ESPLORA_REST_API_URL:=http://127.0.0.1:3000}
__ESPLORA_UNIX_SOCKET_PATH__=${ESPLORA_UNIX_SOCKET_PATH:=null} __ESPLORA_UNIX_SOCKET_PATH__=${ESPLORA_UNIX_SOCKET_PATH:=null}
__ESPLORA_RETRY_UNIX_SOCKET_AFTER__=${ESPLORA_RETRY_UNIX_SOCKET_AFTER:=30000}
# SECOND_CORE_RPC # SECOND_CORE_RPC
__SECOND_CORE_RPC_HOST__=${SECOND_CORE_RPC_HOST:=127.0.0.1} __SECOND_CORE_RPC_HOST__=${SECOND_CORE_RPC_HOST:=127.0.0.1}
@ -63,6 +64,7 @@ __DATABASE_PORT__=${DATABASE_PORT:=3306}
__DATABASE_DATABASE__=${DATABASE_DATABASE:=mempool} __DATABASE_DATABASE__=${DATABASE_DATABASE:=mempool}
__DATABASE_USERNAME__=${DATABASE_USERNAME:=mempool} __DATABASE_USERNAME__=${DATABASE_USERNAME:=mempool}
__DATABASE_PASSWORD__=${DATABASE_PASSWORD:=mempool} __DATABASE_PASSWORD__=${DATABASE_PASSWORD:=mempool}
__DATABASE_TIMEOUT__=${DATABASE_TIMEOUT:=180000}
# SYSLOG # SYSLOG
__SYSLOG_ENABLED__=${SYSLOG_ENABLED:=false} __SYSLOG_ENABLED__=${SYSLOG_ENABLED:=false}
@ -168,6 +170,7 @@ sed -i "s/__ELECTRUM_TLS_ENABLED__/${__ELECTRUM_TLS_ENABLED__}/g" mempool-config
sed -i "s!__ESPLORA_REST_API_URL__!${__ESPLORA_REST_API_URL__}!g" mempool-config.json sed -i "s!__ESPLORA_REST_API_URL__!${__ESPLORA_REST_API_URL__}!g" mempool-config.json
sed -i "s!__ESPLORA_UNIX_SOCKET_PATH__!${__ESPLORA_UNIX_SOCKET_PATH__}!g" mempool-config.json sed -i "s!__ESPLORA_UNIX_SOCKET_PATH__!${__ESPLORA_UNIX_SOCKET_PATH__}!g" mempool-config.json
sed -i "s!__ESPLORA_RETRY_UNIX_SOCKET_AFTER__!${__ESPLORA_RETRY_UNIX_SOCKET_AFTER__}!g" mempool-config.json
sed -i "s/__SECOND_CORE_RPC_HOST__/${__SECOND_CORE_RPC_HOST__}/g" mempool-config.json sed -i "s/__SECOND_CORE_RPC_HOST__/${__SECOND_CORE_RPC_HOST__}/g" mempool-config.json
sed -i "s/__SECOND_CORE_RPC_PORT__/${__SECOND_CORE_RPC_PORT__}/g" mempool-config.json sed -i "s/__SECOND_CORE_RPC_PORT__/${__SECOND_CORE_RPC_PORT__}/g" mempool-config.json

View File

@ -39,6 +39,7 @@ __AUDIT__=${AUDIT:=false}
__MAINNET_BLOCK_AUDIT_START_HEIGHT__=${MAINNET_BLOCK_AUDIT_START_HEIGHT:=0} __MAINNET_BLOCK_AUDIT_START_HEIGHT__=${MAINNET_BLOCK_AUDIT_START_HEIGHT:=0}
__TESTNET_BLOCK_AUDIT_START_HEIGHT__=${TESTNET_BLOCK_AUDIT_START_HEIGHT:=0} __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}
__FULL_RBF_ENABLED__=${FULL_RBF_ENABLED:=false}
__HISTORICAL_PRICE__=${HISTORICAL_PRICE:=true} __HISTORICAL_PRICE__=${HISTORICAL_PRICE:=true}
# Export as environment variables to be used by envsubst # Export as environment variables to be used by envsubst
@ -65,6 +66,7 @@ export __AUDIT__
export __MAINNET_BLOCK_AUDIT_START_HEIGHT__ export __MAINNET_BLOCK_AUDIT_START_HEIGHT__
export __TESTNET_BLOCK_AUDIT_START_HEIGHT__ export __TESTNET_BLOCK_AUDIT_START_HEIGHT__
export __SIGNET_BLOCK_AUDIT_START_HEIGHT__ export __SIGNET_BLOCK_AUDIT_START_HEIGHT__
export __FULL_RBF_ENABLED__
export __HISTORICAL_PRICE__ export __HISTORICAL_PRICE__
folder=$(find /var/www/mempool -name "config.js" | xargs dirname) folder=$(find /var/www/mempool -name "config.js" | xargs dirname)

View File

@ -22,5 +22,6 @@
"TESTNET_BLOCK_AUDIT_START_HEIGHT": 0, "TESTNET_BLOCK_AUDIT_START_HEIGHT": 0,
"SIGNET_BLOCK_AUDIT_START_HEIGHT": 0, "SIGNET_BLOCK_AUDIT_START_HEIGHT": 0,
"LIGHTNING": false, "LIGHTNING": false,
"FULL_RBF_ENABLED": false,
"HISTORICAL_PRICE": true "HISTORICAL_PRICE": true
} }

View File

@ -14,6 +14,7 @@ import { TrademarkPolicyComponent } from './components/trademark-policy/trademar
import { BisqMasterPageComponent } from './components/bisq-master-page/bisq-master-page.component'; import { BisqMasterPageComponent } from './components/bisq-master-page/bisq-master-page.component';
import { PushTransactionComponent } from './components/push-transaction/push-transaction.component'; import { PushTransactionComponent } from './components/push-transaction/push-transaction.component';
import { BlocksList } from './components/blocks-list/blocks-list.component'; import { BlocksList } from './components/blocks-list/blocks-list.component';
import { RbfList } from './components/rbf-list/rbf-list.component';
import { LiquidMasterPageComponent } from './components/liquid-master-page/liquid-master-page.component'; import { LiquidMasterPageComponent } from './components/liquid-master-page/liquid-master-page.component';
import { AssetGroupComponent } from './components/assets/asset-group/asset-group.component'; import { AssetGroupComponent } from './components/assets/asset-group/asset-group.component';
import { AssetsFeaturedComponent } from './components/assets/assets-featured/assets-featured.component'; import { AssetsFeaturedComponent } from './components/assets/assets-featured/assets-featured.component';
@ -56,6 +57,10 @@ let routes: Routes = [
path: 'blocks', path: 'blocks',
component: BlocksList, component: BlocksList,
}, },
{
path: 'rbf',
component: RbfList,
},
{ {
path: 'terms-of-service', path: 'terms-of-service',
component: TermsOfServiceComponent component: TermsOfServiceComponent
@ -162,6 +167,10 @@ let routes: Routes = [
path: 'blocks', path: 'blocks',
component: BlocksList, component: BlocksList,
}, },
{
path: 'rbf',
component: RbfList,
},
{ {
path: 'terms-of-service', path: 'terms-of-service',
component: TermsOfServiceComponent component: TermsOfServiceComponent
@ -264,6 +273,10 @@ let routes: Routes = [
path: 'blocks', path: 'blocks',
component: BlocksList, component: BlocksList,
}, },
{
path: 'rbf',
component: RbfList,
},
{ {
path: 'terms-of-service', path: 'terms-of-service',
component: TermsOfServiceComponent component: TermsOfServiceComponent

View File

@ -2,7 +2,7 @@
<div class="mempool-blocks-container" [class.time-ltr]="timeLtr" *ngIf="(difficultyAdjustments$ | async) as da;"> <div class="mempool-blocks-container" [class.time-ltr]="timeLtr" *ngIf="(difficultyAdjustments$ | async) as da;">
<div class="flashing"> <div class="flashing">
<ng-template ngFor let-projectedBlock [ngForOf]="mempoolBlocks$ | async" let-i="index" [ngForTrackBy]="trackByFn"> <ng-template ngFor let-projectedBlock [ngForOf]="mempoolBlocks$ | async" let-i="index" [ngForTrackBy]="trackByFn">
<div @blockEntryTrigger [@.disabled]="!animateEntry" [attr.data-cy]="'mempool-block-' + i" class="bitcoin-block text-center mempool-block" id="mempool-block-{{ i }}" [ngStyle]="mempoolBlockStyles[i]" [class.blink-bg]="projectedBlock.blink"> <div @blockEntryTrigger [@.disabled]="i > 0 || !animateEntry" [attr.data-cy]="'mempool-block-' + i" class="bitcoin-block text-center mempool-block" id="mempool-block-{{ i }}" [ngStyle]="mempoolBlockStyles[i]" [class.blink-bg]="projectedBlock.blink">
<a draggable="false" [routerLink]="['/mempool-block/' | relativeUrl, i]" <a draggable="false" [routerLink]="['/mempool-block/' | relativeUrl, i]"
class="blockLink" [ngClass]="{'disabled': (this.stateService.blockScrolling$ | async)}">&nbsp;</a> class="blockLink" [ngClass]="{'disabled': (this.stateService.blockScrolling$ | async)}">&nbsp;</a>
<div class="block-body"> <div class="block-body">

View File

@ -1,4 +1,4 @@
import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core'; import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, ChangeDetectorRef, HostListener } from '@angular/core';
import { Subscription, Observable, fromEvent, merge, of, combineLatest } from 'rxjs'; import { Subscription, Observable, fromEvent, merge, of, combineLatest } from 'rxjs';
import { MempoolBlock } from '../../interfaces/websocket.interface'; import { MempoolBlock } from '../../interfaces/websocket.interface';
import { StateService } from '../../services/state.service'; import { StateService } from '../../services/state.service';
@ -8,7 +8,7 @@ import { feeLevels, mempoolFeeColors } from '../../app.constants';
import { specialBlocks } from '../../app.constants'; import { specialBlocks } from '../../app.constants';
import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe';
import { Location } from '@angular/common'; import { Location } from '@angular/common';
import { DifficultyAdjustment } from '../../interfaces/node-api.interface'; import { DifficultyAdjustment, MempoolPosition } from '../../interfaces/node-api.interface';
import { animate, style, transition, trigger } from '@angular/animations'; import { animate, style, transition, trigger } from '@angular/animations';
@Component({ @Component({
@ -58,6 +58,7 @@ export class MempoolBlocksComponent implements OnInit, OnDestroy {
transition = 'background 2s, right 2s, transform 1s'; transition = 'background 2s, right 2s, transform 1s';
markIndex: number; markIndex: number;
txPosition: MempoolPosition;
txFeePerVSize: number; txFeePerVSize: number;
resetTransitionTimeout: number; resetTransitionTimeout: number;
@ -152,10 +153,14 @@ export class MempoolBlocksComponent implements OnInit, OnDestroy {
this.markBlocksSubscription = this.stateService.markBlock$ this.markBlocksSubscription = this.stateService.markBlock$
.subscribe((state) => { .subscribe((state) => {
this.markIndex = undefined; this.markIndex = undefined;
this.txPosition = undefined;
this.txFeePerVSize = undefined; this.txFeePerVSize = undefined;
if (state.mempoolBlockIndex !== undefined) { if (state.mempoolBlockIndex !== undefined) {
this.markIndex = state.mempoolBlockIndex; this.markIndex = state.mempoolBlockIndex;
} }
if (state.mempoolPosition) {
this.txPosition = state.mempoolPosition;
}
if (state.txFeePerVSize) { if (state.txFeePerVSize) {
this.txFeePerVSize = state.txFeePerVSize; this.txFeePerVSize = state.txFeePerVSize;
} }
@ -222,8 +227,13 @@ export class MempoolBlocksComponent implements OnInit, OnDestroy {
clearTimeout(this.resetTransitionTimeout); clearTimeout(this.resetTransitionTimeout);
} }
@HostListener('window:resize', ['$event'])
onResize(): void {
this.animateEntry = false;
}
trackByFn(index: number, block: MempoolBlock) { trackByFn(index: number, block: MempoolBlock) {
return (block.isStack) ? 'stack' : block.index; return (block.isStack) ? `stack-${block.index}` : block.index;
} }
reduceMempoolBlocksToFitScreen(blocks: MempoolBlock[]): MempoolBlock[] { reduceMempoolBlocksToFitScreen(blocks: MempoolBlock[]): MempoolBlock[] {
@ -297,7 +307,7 @@ export class MempoolBlocksComponent implements OnInit, OnDestroy {
} }
calculateTransactionPosition() { calculateTransactionPosition() {
if ((!this.txFeePerVSize && (this.markIndex === undefined || this.markIndex === -1)) || !this.mempoolBlocks) { if ((!this.txPosition && !this.txFeePerVSize && (this.markIndex === undefined || this.markIndex === -1)) || !this.mempoolBlocks) {
this.arrowVisible = false; this.arrowVisible = false;
return; return;
} else if (this.markIndex > -1) { } else if (this.markIndex > -1) {
@ -315,6 +325,15 @@ export class MempoolBlocksComponent implements OnInit, OnDestroy {
this.arrowVisible = true; this.arrowVisible = true;
if (this.txPosition) {
if (this.txPosition.block >= this.mempoolBlocks.length) {
this.rightPosition = ((this.mempoolBlocks.length - 1) * (this.blockWidth + this.blockPadding)) + this.blockWidth;
} else {
const positionInBlock = Math.min(1, this.txPosition.vsize / this.stateService.blockVSize) * this.blockWidth;
const positionOfBlock = this.txPosition.block * (this.blockWidth + this.blockPadding);
this.rightPosition = positionOfBlock + positionInBlock;
}
} else {
let found = false; let found = false;
for (let txInBlockIndex = 0; txInBlockIndex < this.mempoolBlocks.length && !found; txInBlockIndex++) { for (let txInBlockIndex = 0; txInBlockIndex < this.mempoolBlocks.length && !found; txInBlockIndex++) {
const block = this.mempoolBlocks[txInBlockIndex]; const block = this.mempoolBlocks[txInBlockIndex];
@ -344,6 +363,7 @@ export class MempoolBlocksComponent implements OnInit, OnDestroy {
} }
} }
} }
}
mountEmptyBlocks() { mountEmptyBlocks() {
const emptyBlocks = []; const emptyBlocks = [];

View File

@ -0,0 +1,46 @@
<div class="container-xl" style="min-height: 335px">
<h1 class="float-left" i18n="page.rbf-replacements">RBF Replacements</h1>
<div *ngIf="isLoading" class="spinner-border ml-3" role="status"></div>
<div class="mode-toggle float-right" *ngIf="fullRbfEnabled">
<form class="formRadioGroup">
<div class="btn-group btn-group-toggle" name="radioBasic">
<label class="btn btn-primary btn-sm" [class.active]="!fullRbf">
<input type="radio" [value]="'All'" fragment="" [routerLink]="[]"> All
</label>
<label class="btn btn-primary btn-sm" [class.active]="fullRbf">
<input type="radio" [value]="'Full RBF'" fragment="fullrbf" [routerLink]="[]"> Full RBF
</label>
</div>
</form>
</div>
<div class="clearfix"></div>
<div class="rbf-trees" style="min-height: 295px">
<ng-container *ngIf="rbfTrees$ | async as trees">
<div *ngFor="let tree of trees" class="tree">
<p class="info">
<span class="type">
<span *ngIf="isMined(tree)" class="badge badge-success" i18n="transaction.rbf.mined">Mined</span>
<span *ngIf="isFullRbf(tree)" class="badge badge-info" i18n="transaction.full-rbf">Full RBF</span>
</span>
<app-time kind="since" [time]="tree.time"></app-time>
</p>
<div class="timeline-wrapper" [class.mined]="isMined(tree)">
<app-rbf-timeline [replacements]="tree"></app-rbf-timeline>
</div>
</div>
<div class="no-replacements" *ngIf="!trees?.length">
<p i18n="rbf.no-replacements-yet">there are no replacements in the mempool yet!</p>
</div>
</ng-container>
<!-- <ngb-pagination class="pagination-container float-right mt-2" [class]="isLoading ? 'disabled' : ''"
[collectionSize]="blocksCount" [rotate]="true" [maxSize]="maxSize" [pageSize]="15" [(page)]="page"
(pageChange)="pageChange(page)" [boundaryLinks]="true" [ellipses]="false">
</ngb-pagination> -->
</div>
</div>

View File

@ -0,0 +1,35 @@
.spinner-border {
height: 25px;
width: 25px;
margin-top: 13px;
}
.rbf-trees {
.info {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: baseline;
margin: 0;
margin-bottom: 0.5em;
.type {
.badge {
margin-left: .5em;
}
}
}
.tree {
margin-bottom: 1em;
}
.timeline-wrapper.mined {
border: solid 4px #1a9436;
}
.no-replacements {
margin: 1em;
text-align: center;
}
}

View File

@ -0,0 +1,81 @@
import { Component, OnInit, ChangeDetectionStrategy, OnDestroy } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { BehaviorSubject, EMPTY, merge, Observable, Subscription } from 'rxjs';
import { catchError, switchMap, tap } from 'rxjs/operators';
import { WebsocketService } from '../../services/websocket.service';
import { RbfTree } from '../../interfaces/node-api.interface';
import { ApiService } from '../../services/api.service';
import { StateService } from '../../services/state.service';
@Component({
selector: 'app-rbf-list',
templateUrl: './rbf-list.component.html',
styleUrls: ['./rbf-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RbfList implements OnInit, OnDestroy {
rbfTrees$: Observable<RbfTree[]>;
nextRbfSubject = new BehaviorSubject(null);
urlFragmentSubscription: Subscription;
fullRbfEnabled: boolean;
fullRbf: boolean;
isLoading = true;
constructor(
private route: ActivatedRoute,
private router: Router,
private apiService: ApiService,
public stateService: StateService,
private websocketService: WebsocketService,
) {
this.fullRbfEnabled = stateService.env.FULL_RBF_ENABLED;
}
ngOnInit(): void {
this.urlFragmentSubscription = this.route.fragment.subscribe((fragment) => {
this.fullRbf = (fragment === 'fullrbf');
this.websocketService.startTrackRbf(this.fullRbf ? 'fullRbf' : 'all');
this.nextRbfSubject.next(null);
});
this.rbfTrees$ = merge(
this.nextRbfSubject.pipe(
switchMap(() => {
return this.apiService.getRbfList$(this.fullRbf);
}),
catchError((e) => {
return EMPTY;
})
),
this.stateService.rbfLatest$
)
.pipe(
tap(() => {
this.isLoading = false;
})
);
}
toggleFullRbf(event) {
this.router.navigate([], {
relativeTo: this.route,
fragment: this.fullRbf ? null : 'fullrbf'
});
}
isFullRbf(tree: RbfTree): boolean {
return tree.fullRbf;
}
isMined(tree: RbfTree): boolean {
return tree.mined;
}
// pageChange(page: number) {
// this.fromTreeSubject.next(this.lastTreeId);
// }
ngOnDestroy(): void {
this.websocketService.stopTrackRbf();
}
}

View File

@ -0,0 +1,38 @@
<div
#tooltip
*ngIf="rbfInfo && tooltipPosition !== null"
class="rbf-tooltip"
[style.left]="tooltipPosition.x + 'px'"
[style.top]="tooltipPosition.y + 'px'"
>
<table>
<tbody>
<tr>
<td class="td-width" i18n="shared.transaction">Transaction</td>
<td>
<a [routerLink]="['/tx/' | relativeUrl, rbfInfo.tx.txid]">{{ rbfInfo.tx.txid | shortenString : 16}}</a>
</td>
</tr>
<tr>
<td class="td-width" i18n="transaction.first-seen|Transaction first seen">First seen</td>
<td><i><app-time kind="since" [time]="rbfInfo.time" [fastRender]="true"></app-time></i></td>
</tr>
<tr>
<td class="td-width" i18n="transaction.fee|Transaction fee">Fee</td>
<td>{{ rbfInfo.tx.fee | number }} <span class="symbol" i18n="shared.sat|sat">sat</span></td>
</tr>
<tr>
<td class="td-width" i18n="transaction.vsize|Transaction Virtual Size">Virtual size</td>
<td [innerHTML]="'&lrm;' + (rbfInfo.tx.vsize | vbytes: 2)"></td>
</tr>
<tr>
<td class="td-width" i18n="transaction.status|Transaction Status">Status</td>
<td>
<span *ngIf="rbfInfo.tx.rbf; else rbfDisabled" class="badge badge-success" i18n="rbfInfo-features.tag.rbf|RBF">RBF</span>
<ng-template #rbfDisabled><span class="badge badge-danger mr-1"><del i18n="rbfInfo-features.tag.rbf|RBF">RBF</del></span></ng-template>
<span *ngIf="rbfInfo.tx.mined" class="badge badge-success" i18n="transaction.rbf.mined">Mined</span>
</td>
</tr>
</tbody>
</table>
</div>

View File

@ -0,0 +1,25 @@
.rbf-tooltip {
position: fixed;
z-index: 3;
background: rgba(#11131f, 0.95);
border-radius: 4px;
box-shadow: 1px 1px 10px rgba(0,0,0,0.5);
color: #b1b1b1;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 10px 15px;
text-align: left;
pointer-events: none;
.badge {
margin-right: 1em;
&:last-child {
margin-right: 0;
}
}
}
.td-width {
padding-right: 10px;
}

View File

@ -0,0 +1,35 @@
import { Component, ElementRef, ViewChild, Input, OnChanges } from '@angular/core';
import { RbfInfo } from '../../interfaces/node-api.interface';
@Component({
selector: 'app-rbf-timeline-tooltip',
templateUrl: './rbf-timeline-tooltip.component.html',
styleUrls: ['./rbf-timeline-tooltip.component.scss'],
})
export class RbfTimelineTooltipComponent implements OnChanges {
@Input() rbfInfo: RbfInfo | void;
@Input() cursorPosition: { x: number, y: number };
tooltipPosition = 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 };
}
}
}

View File

@ -0,0 +1,73 @@
<div class="rbf-timeline box" [class.mined]="replacements.mined">
<div class="timeline-wrapper">
<div class="timeline" *ngFor="let timeline of rows">
<div class="intervals">
<ng-container *ngFor="let cell of timeline; let i = index;">
<div class="node-spacer"></div>
<ng-container *ngIf="i < timeline.length - 1">
<div class="interval" *ngIf="cell.replacement?.interval != null; else intervalSpacer">
<div class="interval-time">
<app-time [time]="cell.replacement.interval" [relative]="false"></app-time>
</div>
</div>
</ng-container>
</ng-container>
</div>
<div class="nodes">
<ng-container *ngFor="let cell of timeline; let i = index;">
<ng-container *ngIf="cell.replacement; else nonNode">
<div class="node"
[id]="'node-'+cell.replacement.tx.txid"
[class.selected]="txid === cell.replacement.tx.txid"
[class.mined]="cell.replacement.tx.mined"
[class.first-node]="cell.first"
>
<div class="track"></div>
<a class="shape-border"
[class.rbf]="cell.replacement.tx.rbf"
[routerLink]="['/tx/' | relativeUrl, cell.replacement.tx.txid]"
(pointerover)="onHover($event, cell.replacement);"
(pointerout)="onBlur($event);"
>
<div class="shape"></div>
</a>
<span class="fee-rate">{{ cell.replacement.tx.fee / (cell.replacement.tx.vsize) | feeRounding }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span></span>
</div>
</ng-container>
<ng-template #nonNode>
<ng-container [ngSwitch]="cell.connector">
<div class="connector" *ngSwitchCase="'pipe'"><div class="pipe"></div></div>
<div class="connector" *ngSwitchCase="'corner'"><div class="corner"></div></div>
<div class="node-spacer" *ngSwitchDefault></div>
</ng-container>
</ng-template>
<ng-container *ngIf="i < timeline.length - 1">
<div class="interval-spacer" *ngIf="cell.replacement?.interval != null; else intervalSpacer">
<div class="track"></div>
</div>
</ng-container>
</ng-container>
</div>
</div>
</div>
<ng-template #nodeSpacer>
<div class="node-spacer"></div>
</ng-template>
<ng-template #intervalSpacer>
<div class="interval-spacer"></div>
</ng-template>
<app-rbf-timeline-tooltip
[rbfInfo]="hoverInfo"
[cursorPosition]="tooltipPosition"
></app-rbf-timeline-tooltip>
<!-- <app-rbf-timeline-tooltip
*ngIf=[tooltip]
[line]="hoverLine"
[cursorPosition]="tooltipPosition"
[isConnector]="hoverConnector"
></app-rbf-timeline-tooltip> -->
</div>

View File

@ -0,0 +1,193 @@
.rbf-timeline {
position: relative;
width: 100%;
padding: 1em 0;
&::after, &::before {
content: '';
display: block;
position: absolute;
top: 0;
bottom: 0;
width: 2em;
z-index: 2;
}
&::before {
left: 0;
background: linear-gradient(to right, #24273e, #24273e, transparent);
}
&::after {
right: 0;
background: linear-gradient(to left, #24273e, #24273e, transparent);
}
.timeline-wrapper {
position: relative;
width: calc(100% - 2em);
margin: auto;
overflow-x: auto;
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.intervals, .nodes {
min-width: 100%;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
text-align: center;
.node, .node-spacer, .connector {
width: 6em;
min-width: 6em;
flex-grow: 1;
}
.interval, .interval-spacer {
width: 8em;
min-width: 5em;
max-width: 8em;
height: 32px;
display: flex;
flex-direction: row;
justify-content: center;
align-items: flex-end;
}
.interval {
overflow: visible;
}
.interval-time {
font-size: 12px;
line-height: 16px;
white-space: nowrap;
}
}
.node, .interval-spacer {
position: relative;
.track {
position: absolute;
height: 10px;
left: -5px;
right: -5px;
top: 0;
transform: translateY(-50%);
background: #105fb0;
border-radius: 5px;
}
&.first-node {
.track {
left: 50%;
}
}
&:last-child {
.track {
right: 50%;
}
}
}
.nodes {
position: relative;
margin-top: 1em;
.node {
.shape-border {
display: block;
margin: auto;
height: calc(1em + 8px);
width: calc(1em + 8px);
margin-bottom: -8px;
transform: translateY(-50%);
border-radius: 10%;
cursor: pointer;
padding: 4px;
background: transparent;
transition: background-color 300ms, padding 300ms;
.shape {
width: 100%;
height: 100%;
border-radius: 10%;
background: white;
transition: background-color 300ms, border 300ms;
}
&.rbf, &.rbf .shape {
border-radius: 50%;
}
}
.symbol::ng-deep {
display: block;
margin-top: -0.5em;
}
&.selected {
.shape-border {
background: #9339f4;
}
}
&.mined {
.shape-border {
background: #1a9436;
}
}
.shape-border:hover {
padding: 0px;
.shape {
background: #1bd8f4;
}
}
&.selected.mined {
.shape-border {
background: #1a9436;
height: calc(1em + 16px);
width: calc(1em + 16px);
.shape {
border: solid 4px #9339f4;
}
&:hover {
padding: 4px;
.shape {
border-width: 1px;
border-color: #1bd8f4
}
}
}
}
}
.connector {
position: relative;
height: 10px;
.corner, .pipe {
position: absolute;
left: -10px;
width: 20px;
height: 108px;
bottom: 50%;
border-right: solid 10px #105fb0;
}
.corner {
border-bottom: solid 10px #105fb0;
border-bottom-right-radius: 10px;
}
}
}
}

View File

@ -0,0 +1,191 @@
import { Component, Input, OnInit, OnChanges, Inject, LOCALE_ID, HostListener } from '@angular/core';
import { Router } from '@angular/router';
import { RbfInfo, RbfTree } from '../../interfaces/node-api.interface';
import { StateService } from '../../services/state.service';
import { ApiService } from '../../services/api.service';
type Connector = 'pipe' | 'corner';
interface TimelineCell {
replacement?: RbfInfo,
connector?: Connector,
first?: boolean,
}
@Component({
selector: 'app-rbf-timeline',
templateUrl: './rbf-timeline.component.html',
styleUrls: ['./rbf-timeline.component.scss'],
})
export class RbfTimelineComponent implements OnInit, OnChanges {
@Input() replacements: RbfTree;
@Input() txid: string;
rows: TimelineCell[][] = [];
hoverInfo: RbfInfo | void = null;
tooltipPosition = null;
dir: 'rtl' | 'ltr' = 'ltr';
constructor(
private router: Router,
private stateService: StateService,
private apiService: ApiService,
@Inject(LOCALE_ID) private locale: string,
) {
if (this.locale.startsWith('ar') || this.locale.startsWith('fa') || this.locale.startsWith('he')) {
this.dir = 'rtl';
}
}
ngOnInit(): void {
this.rows = this.buildTimelines(this.replacements);
}
ngOnChanges(changes): void {
this.rows = this.buildTimelines(this.replacements);
if (changes.txid) {
setTimeout(() => { this.scrollToSelected(); });
}
}
// converts a tree of RBF events into a format that can be more easily rendered in HTML
buildTimelines(tree: RbfTree): TimelineCell[][] {
if (!tree) return [];
const split = this.splitTimelines(tree);
const timelines = this.prepareTimelines(split);
return this.connectTimelines(timelines);
}
// splits a tree into N leaf-to-root paths
splitTimelines(tree: RbfTree, tail: RbfInfo[] = []): RbfInfo[][] {
const replacements = [...tail, tree];
if (tree.replaces.length) {
return [].concat(...tree.replaces.map(subtree => this.splitTimelines(subtree, replacements)));
} else {
return [[...replacements]];
}
}
// merges separate leaf-to-root paths into a coherent forking timeline
// represented as a 2D array of Rbf events
prepareTimelines(lines: RbfInfo[][]): RbfInfo[][] {
lines.sort((a, b) => b.length - a.length);
const rows = lines.map(() => []);
let lineGroups = [lines];
let done = false;
let column = 0; // sanity check for while loop stopping condition
while (!done && column < 100) {
// iterate over timelines element-by-element
// at each step, group lines which share a common transaction at their head
// (i.e. lines terminating in the same replacement event)
let index = 0;
let emptyCount = 0;
const nextGroups = [];
for (const group of lineGroups) {
const toMerge: { [txid: string]: RbfInfo[][] } = {};
let emptyInGroup = 0;
let first = true;
for (const line of group) {
const head = line.shift() || null;
if (first) {
// only insert the first instance of the replacement node
rows[index].unshift(head);
first = false;
} else {
// substitute duplicates with empty cells
// (we'll fill these in with connecting lines later)
rows[index].unshift(null);
}
// group the tails of the remaining lines for the next iteration
if (line.length) {
const nextId = line[0].tx.txid;
if (!toMerge[nextId]) {
toMerge[nextId] = [];
}
toMerge[nextId].push(line);
} else {
emptyInGroup++;
}
index++;
}
for (const merged of Object.values(toMerge).sort((a, b) => b.length - a.length)) {
nextGroups.push(merged);
}
for (let i = 0; i < emptyInGroup; i++) {
nextGroups.push([[]]);
}
emptyCount += emptyInGroup;
lineGroups = nextGroups;
done = (emptyCount >= rows.length);
}
column++;
}
return rows;
}
// annotates a 2D timeline array with info needed to draw connecting lines for multi-replacements
connectTimelines(timelines: RbfInfo[][]): TimelineCell[][] {
const rows: TimelineCell[][] = [];
timelines.forEach((lines, row) => {
rows.push([]);
let started = false;
let finished = false;
lines.forEach((replacement, column) => {
const cell: TimelineCell = {};
if (replacement) {
cell.replacement = replacement;
}
rows[row].push(cell);
if (replacement) {
if (!started) {
cell.first = true;
started = true;
}
} else if (started && !finished) {
if (column < timelines[row].length) {
let matched = false;
for (let i = row; i >= 0 && !matched; i--) {
const nextCell = rows[i][column];
if (nextCell.replacement) {
matched = true;
} else if (i === row) {
rows[i][column] = {
connector: 'corner'
};
} else if (nextCell.connector !== 'corner') {
rows[i][column] = {
connector: 'pipe'
};
}
}
}
finished = true;
}
});
});
return rows;
}
scrollToSelected() {
const node = document.getElementById('node-' + this.txid);
if (node) {
node.scrollIntoView({ block: 'nearest', inline: 'center' });
}
}
@HostListener('pointermove', ['$event'])
onPointerMove(event) {
this.tooltipPosition = { x: event.clientX, y: event.clientY };
}
onHover(event, replacement): void {
this.hoverInfo = replacement;
}
onBlur(event): void {
this.hoverInfo = null;
}
}

View File

@ -137,10 +137,12 @@ export class StartComponent implements OnInit, OnDestroy {
} }
onMouseDown(event: MouseEvent) { onMouseDown(event: MouseEvent) {
if (!(event.which > 1 || event.button > 0)) {
this.mouseDragStartX = event.clientX; this.mouseDragStartX = event.clientX;
this.resetMomentum(event.clientX); this.resetMomentum(event.clientX);
this.blockchainScrollLeftInit = this.blockchainContainer.nativeElement.scrollLeft; this.blockchainScrollLeftInit = this.blockchainContainer.nativeElement.scrollLeft;
} }
}
onPointerDown(event: PointerEvent) { onPointerDown(event: PointerEvent) {
if (this.isiOS) { if (this.isiOS) {
event.preventDefault(); event.preventDefault();

View File

@ -1,18 +1,11 @@
<div class="container-xl"> <div class="container-xl">
<div class="title-block"> <div class="title-block">
<div *ngIf="rbfTransaction" class="alert alert-mempool" role="alert"> <div *ngIf="rbfTransaction && !tx?.status?.confirmed" class="alert alert-mempool" role="alert">
<span i18n="transaction.rbf.replacement|RBF replacement">This transaction has been replaced by:</span> <span i18n="transaction.rbf.replacement|RBF replacement">This transaction has been replaced by:</span>
<app-truncate [text]="rbfTransaction.txid" [lastChars]="12" [link]="['/tx/' | relativeUrl, rbfTransaction.txid]"></app-truncate> <app-truncate [text]="rbfTransaction.txid" [lastChars]="12" [link]="['/tx/' | relativeUrl, rbfTransaction.txid]"></app-truncate>
</div> </div>
<div *ngIf="rbfReplaces?.length" class="alert alert-mempool" role="alert">
<span i18n="transaction.rbf.replaced|RBF replaced">This transaction replaced:</span>
<div class="tx-list">
<app-truncate [text]="replaced" [lastChars]="12" *ngFor="let replaced of rbfReplaces" [link]="['/tx/' | relativeUrl, replaced]"></app-truncate>
</div>
</div>
<ng-container *ngIf="!rbfTransaction || rbfTransaction?.size || tx"> <ng-container *ngIf="!rbfTransaction || rbfTransaction?.size || tx">
<h1 i18n="shared.transaction">Transaction</h1> <h1 i18n="shared.transaction">Transaction</h1>
@ -45,7 +38,7 @@
<ng-template [ngIf]="!isLoadingTx && !error"> <ng-template [ngIf]="!isLoadingTx && !error">
<ng-template [ngIf]="tx.status.confirmed" [ngIfElse]="unconfirmedTemplate"> <ng-template [ngIf]="tx?.status?.confirmed" [ngIfElse]="unconfirmedTemplate">
<div class="box"> <div class="box">
<div class="row"> <div class="row">
@ -104,22 +97,22 @@
</tr> </tr>
</ng-template> </ng-template>
</ng-template> </ng-template>
<tr *ngIf="!replaced"> <tr *ngIf="!replaced && !isCached">
<td class="td-width" i18n="transaction.eta|Transaction ETA">ETA</td> <td class="td-width" i18n="transaction.eta|Transaction ETA">ETA</td>
<td> <td>
<ng-template [ngIf]="txInBlockIndex === undefined" [ngIfElse]="estimationTmpl"> <ng-template [ngIf]="this.mempoolPosition?.block == null" [ngIfElse]="estimationTmpl">
<span class="skeleton-loader"></span> <span class="skeleton-loader"></span>
</ng-template> </ng-template>
<ng-template #estimationTmpl> <ng-template #estimationTmpl>
<ng-template [ngIf]="txInBlockIndex >= 7" [ngIfElse]="belowBlockLimit"> <ng-template [ngIf]="this.mempoolPosition.block >= 7" [ngIfElse]="belowBlockLimit">
<span i18n="transaction.eta.in-several-hours|Transaction ETA in several hours or more">In several hours (or more)</span> <span i18n="transaction.eta.in-several-hours|Transaction ETA in several hours or more">In several hours (or more)</span>
</ng-template> </ng-template>
<ng-template #belowBlockLimit> <ng-template #belowBlockLimit>
<ng-template [ngIf]="network === 'liquid' || network === 'liquidtestnet'" [ngIfElse]="timeEstimateDefault"> <ng-template [ngIf]="network === 'liquid' || network === 'liquidtestnet'" [ngIfElse]="timeEstimateDefault">
<app-time kind="until" [time]="(60 * 1000 * txInBlockIndex) + now" [fastRender]="false" [fixedRender]="true"></app-time> <app-time kind="until" [time]="(60 * 1000 * this.mempoolPosition.block) + now" [fastRender]="false" [fixedRender]="true" [forceFloorOnTimeIntervals]="['hour']"></app-time>
</ng-template> </ng-template>
<ng-template #timeEstimateDefault> <ng-template #timeEstimateDefault>
<app-time kind="until" *ngIf="(timeAvg$ | async) as timeAvg;" [time]="(timeAvg * txInBlockIndex) + now + timeAvg" [fastRender]="false" [fixedRender]="true" [forceFloorOnTimeIntervals]="['hour']"></app-time> <app-time kind="until" *ngIf="(timeAvg$ | async) as timeAvg;" [time]="(timeAvg * this.mempoolPosition.block) + now + timeAvg" [fastRender]="false" [fixedRender]="true" [forceFloorOnTimeIntervals]="['hour']"></app-time>
</ng-template> </ng-template>
</ng-template> </ng-template>
</ng-template> </ng-template>
@ -197,6 +190,15 @@
<br> <br>
<ng-container *ngIf="rbfInfo">
<div class="title float-left">
<h2 id="rbf" i18n="transaction.rbf-history|RBF History">RBF History</h2>
</div>
<div class="clearfix"></div>
<app-rbf-timeline [txid]="txId" [replacements]="rbfInfo"></app-rbf-timeline>
<br>
</ng-container>
<ng-container *ngIf="flowEnabled; else flowPlaceholder"> <ng-container *ngIf="flowEnabled; else flowPlaceholder">
<div class="title float-left"> <div class="title float-left">
<h2 id="flow" i18n="transaction.flow|Transaction flow">Flow</h2> <h2 id="flow" i18n="transaction.flow|Transaction flow">Flow</h2>
@ -477,7 +479,7 @@
<td i18n="transaction.fee-rate|Transaction fee rate">Fee rate</td> <td i18n="transaction.fee-rate|Transaction fee rate">Fee rate</td>
<td> <td>
{{ tx.feePerVsize | feeRounding }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span> {{ tx.feePerVsize | feeRounding }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span>
<ng-template [ngIf]="tx.status.confirmed"> <ng-template [ngIf]="tx?.status?.confirmed">
&nbsp; &nbsp;
<app-tx-fee-rating *ngIf="tx.fee && ((cpfpInfo && !cpfpInfo?.descendants?.length && !cpfpInfo?.bestDescendant && !cpfpInfo?.ancestors?.length) || !cpfpInfo)" [tx]="tx"></app-tx-fee-rating> <app-tx-fee-rating *ngIf="tx.fee && ((cpfpInfo && !cpfpInfo?.descendants?.length && !cpfpInfo?.bestDescendant && !cpfpInfo?.ancestors?.length) || !cpfpInfo)" [tx]="tx"></app-tx-fee-rating>
</ng-template> </ng-template>
@ -488,7 +490,7 @@
<td> <td>
<div class="effective-fee-container"> <div class="effective-fee-container">
{{ tx.effectiveFeePerVsize | feeRounding }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span> {{ tx.effectiveFeePerVsize | feeRounding }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span>
<ng-template [ngIf]="tx.status.confirmed"> <ng-template [ngIf]="tx?.status?.confirmed">
<app-tx-fee-rating class="ml-2 mr-2" *ngIf="tx.fee || tx.effectiveFeePerVsize" [tx]="tx"></app-tx-fee-rating> <app-tx-fee-rating class="ml-2 mr-2" *ngIf="tx.fee || tx.effectiveFeePerVsize" [tx]="tx"></app-tx-fee-rating>
</ng-template> </ng-template>
</div> </div>

View File

@ -19,7 +19,7 @@ import { WebsocketService } from '../../services/websocket.service';
import { AudioService } from '../../services/audio.service'; import { AudioService } from '../../services/audio.service';
import { ApiService } from '../../services/api.service'; import { ApiService } from '../../services/api.service';
import { SeoService } from '../../services/seo.service'; import { SeoService } from '../../services/seo.service';
import { BlockExtended, CpfpInfo } from '../../interfaces/node-api.interface'; import { BlockExtended, CpfpInfo, RbfTree, MempoolPosition } from '../../interfaces/node-api.interface';
import { LiquidUnblinding } from './liquid-ublinding'; import { LiquidUnblinding } from './liquid-ublinding';
import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe';
import { Price, PriceService } from '../../services/price.service'; import { Price, PriceService } from '../../services/price.service';
@ -35,6 +35,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
tx: Transaction; tx: Transaction;
txId: string; txId: string;
txInBlockIndex: number; txInBlockIndex: number;
mempoolPosition: MempoolPosition;
isLoadingTx = true; isLoadingTx = true;
error: any = undefined; error: any = undefined;
errorUnblinded: any = undefined; errorUnblinded: any = undefined;
@ -46,20 +47,24 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
fetchRbfSubscription: Subscription; fetchRbfSubscription: Subscription;
fetchCachedTxSubscription: Subscription; fetchCachedTxSubscription: Subscription;
txReplacedSubscription: Subscription; txReplacedSubscription: Subscription;
txRbfInfoSubscription: Subscription;
mempoolPositionSubscription: Subscription;
blocksSubscription: Subscription; blocksSubscription: Subscription;
queryParamsSubscription: Subscription; queryParamsSubscription: Subscription;
urlFragmentSubscription: Subscription; urlFragmentSubscription: Subscription;
mempoolBlocksSubscription: Subscription;
fragmentParams: URLSearchParams; fragmentParams: URLSearchParams;
rbfTransaction: undefined | Transaction; rbfTransaction: undefined | Transaction;
replaced: boolean = false; replaced: boolean = false;
rbfReplaces: string[]; rbfReplaces: string[];
rbfInfo: RbfTree;
cpfpInfo: CpfpInfo | null; cpfpInfo: CpfpInfo | null;
showCpfpDetails = false; showCpfpDetails = false;
fetchCpfp$ = new Subject<string>(); fetchCpfp$ = new Subject<string>();
fetchRbfHistory$ = new Subject<string>(); fetchRbfHistory$ = new Subject<string>();
fetchCachedTx$ = new Subject<string>(); fetchCachedTx$ = new Subject<string>();
isCached: boolean = false; isCached: boolean = false;
now = new Date().getTime(); now = Date.now();
timeAvg$: Observable<number>; timeAvg$: Observable<number>;
liquidUnblinding = new LiquidUnblinding(); liquidUnblinding = new LiquidUnblinding();
inputIndex: number; inputIndex: number;
@ -168,11 +173,6 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
this.tx.effectiveFeePerVsize = totalFees / (totalWeight / 4); this.tx.effectiveFeePerVsize = totalFees / (totalWeight / 4);
if (!this.tx.status.confirmed) {
this.stateService.markBlock$.next({
txFeePerVSize: this.tx.effectiveFeePerVsize,
});
}
this.cpfpInfo = cpfpInfo; this.cpfpInfo = cpfpInfo;
}); });
@ -183,10 +183,11 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
.getRbfHistory$(txId) .getRbfHistory$(txId)
), ),
catchError(() => { catchError(() => {
return of([]); return of(null);
}) })
).subscribe((replaces) => { ).subscribe((rbfResponse) => {
this.rbfReplaces = replaces; this.rbfInfo = rbfResponse?.replacements;
this.rbfReplaces = rbfResponse?.replaces || null;
}); });
this.fetchCachedTxSubscription = this.fetchCachedTx$ this.fetchCachedTxSubscription = this.fetchCachedTx$
@ -203,6 +204,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
return; return;
} }
if (!this.tx) {
this.tx = tx; this.tx = tx;
this.setFeatures(); this.setFeatures();
this.isCached = true; this.isCached = true;
@ -214,10 +216,29 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
this.error = undefined; this.error = undefined;
this.waitingForTransaction = false; this.waitingForTransaction = false;
this.graphExpanded = false; this.graphExpanded = false;
this.transactionTime = 0;
this.setupGraph(); this.setupGraph();
if (!this.tx?.status?.confirmed) {
this.fetchRbfHistory$.next(this.tx.txid); this.fetchRbfHistory$.next(this.tx.txid);
this.txRbfInfoSubscription = this.stateService.txRbfInfo$.subscribe((rbfInfo) => {
if (this.tx) {
this.rbfInfo = rbfInfo;
}
});
}
});
this.mempoolPositionSubscription = this.stateService.mempoolTxPosition$.subscribe(txPosition => {
if (txPosition && txPosition.txid === this.txId && txPosition.position) {
this.mempoolPosition = txPosition.position;
if (this.tx && !this.tx.status.confirmed) {
this.stateService.markBlock$.next({
mempoolPosition: this.mempoolPosition
});
this.txInBlockIndex = this.mempoolPosition.block;
}
} else {
this.mempoolPosition = null;
} }
}); });
@ -258,7 +279,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
of(true), of(true),
this.stateService.connectionState$.pipe( this.stateService.connectionState$.pipe(
filter( filter(
(state) => state === 2 && this.tx && !this.tx.status.confirmed (state) => state === 2 && this.tx && !this.tx.status?.confirmed
) )
) )
); );
@ -295,6 +316,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
) )
.subscribe((tx: Transaction) => { .subscribe((tx: Transaction) => {
if (!tx) { if (!tx) {
this.fetchCachedTx$.next(this.txId);
return; return;
} }
@ -308,18 +330,21 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
this.isLoadingTx = false; this.isLoadingTx = false;
this.error = undefined; this.error = undefined;
this.waitingForTransaction = false; this.waitingForTransaction = false;
this.setMempoolBlocksSubscription();
this.websocketService.startTrackTransaction(tx.txid); this.websocketService.startTrackTransaction(tx.txid);
this.graphExpanded = false; this.graphExpanded = false;
this.setupGraph(); this.setupGraph();
if (!tx.status.confirmed && tx.firstSeen) { if (!tx.status?.confirmed) {
if (tx.firstSeen) {
this.transactionTime = tx.firstSeen; this.transactionTime = tx.firstSeen;
} else {
this.transactionTime = 0;
}
} else { } else {
this.getTransactionTime(); this.getTransactionTime();
} }
if (this.tx.status.confirmed) { if (this.tx?.status?.confirmed) {
this.stateService.markBlock$.next({ this.stateService.markBlock$.next({
blockHeight: tx.status.block_height, blockHeight: tx.status.block_height,
}); });
@ -328,6 +353,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
if (tx.cpfpChecked) { if (tx.cpfpChecked) {
this.stateService.markBlock$.next({ this.stateService.markBlock$.next({
txFeePerVSize: tx.effectiveFeePerVsize, txFeePerVSize: tx.effectiveFeePerVsize,
mempoolPosition: this.mempoolPosition,
}); });
this.cpfpInfo = { this.cpfpInfo = {
ancestors: tx.ancestors, ancestors: tx.ancestors,
@ -336,10 +362,10 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
} else { } else {
this.fetchCpfp$.next(this.tx.txid); this.fetchCpfp$.next(this.tx.txid);
} }
this.fetchRbfHistory$.next(this.tx.txid);
} }
this.fetchRbfHistory$.next(this.tx.txid);
this.priceService.getBlockPrice$(tx.status.block_time, true).pipe( this.priceService.getBlockPrice$(tx.status?.block_time, true).pipe(
tap((price) => { tap((price) => {
this.blockConversion = price; this.blockConversion = price;
}) })
@ -380,6 +406,12 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
} }
}); });
this.txRbfInfoSubscription = this.stateService.txRbfInfo$.subscribe((rbfInfo) => {
if (this.tx) {
this.rbfInfo = rbfInfo;
}
});
this.queryParamsSubscription = this.route.queryParams.subscribe((params) => { this.queryParamsSubscription = this.route.queryParams.subscribe((params) => {
if (params.showFlow === 'false') { if (params.showFlow === 'false') {
this.overrideFlowPreference = false; this.overrideFlowPreference = false;
@ -391,6 +423,34 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
this.setFlowEnabled(); this.setFlowEnabled();
this.setGraphSize(); this.setGraphSize();
}); });
this.mempoolBlocksSubscription = this.stateService.mempoolBlocks$.subscribe((mempoolBlocks) => {
if (!this.tx || this.mempoolPosition) {
return;
}
this.now = Date.now();
const txFeePerVSize =
this.tx.effectiveFeePerVsize || this.tx.fee / (this.tx.weight / 4);
let found = false;
this.txInBlockIndex = 0;
for (const block of mempoolBlocks) {
for (let i = 0; i < block.feeRange.length - 1 && !found; i++) {
if (
txFeePerVSize <= block.feeRange[i + 1] &&
txFeePerVSize >= block.feeRange[i]
) {
this.txInBlockIndex = mempoolBlocks.indexOf(block);
found = true;
}
}
}
if (!found && txFeePerVSize < mempoolBlocks[mempoolBlocks.length - 1].feeRange[0]) {
this.txInBlockIndex = 7;
}
});
} }
ngAfterViewInit(): void { ngAfterViewInit(): void {
@ -407,28 +467,6 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
return of(false); return of(false);
} }
setMempoolBlocksSubscription() {
this.stateService.mempoolBlocks$.subscribe((mempoolBlocks) => {
if (!this.tx) {
return;
}
const txFeePerVSize =
this.tx.effectiveFeePerVsize || this.tx.fee / (this.tx.weight / 4);
for (const block of mempoolBlocks) {
for (let i = 0; i < block.feeRange.length - 1; i++) {
if (
txFeePerVSize <= block.feeRange[i + 1] &&
txFeePerVSize >= block.feeRange[i]
) {
this.txInBlockIndex = mempoolBlocks.indexOf(block);
}
}
}
});
}
getTransactionTime() { getTransactionTime() {
this.apiService this.apiService
.getTransactionTimes$([this.tx.txid]) .getTransactionTimes$([this.tx.txid])
@ -460,8 +498,11 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
this.replaced = false; this.replaced = false;
this.transactionTime = -1; this.transactionTime = -1;
this.cpfpInfo = null; this.cpfpInfo = null;
this.rbfInfo = null;
this.rbfReplaces = []; this.rbfReplaces = [];
this.showCpfpDetails = false; this.showCpfpDetails = false;
this.txInBlockIndex = null;
this.mempoolPosition = null;
document.body.scrollTo(0, 0); document.body.scrollTo(0, 0);
this.leaveTransaction(); this.leaveTransaction();
} }
@ -519,9 +560,13 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
@HostListener('window:resize', ['$event']) @HostListener('window:resize', ['$event'])
setGraphSize(): void { setGraphSize(): void {
this.isMobile = window.innerWidth < 850; this.isMobile = window.innerWidth < 850;
if (this.graphContainer) { if (this.graphContainer?.nativeElement) {
setTimeout(() => { setTimeout(() => {
if (this.graphContainer?.nativeElement) {
this.graphWidth = this.graphContainer.nativeElement.clientWidth; this.graphWidth = this.graphContainer.nativeElement.clientWidth;
} else {
setTimeout(() => { this.setGraphSize(); }, 1);
}
}, 1); }, 1);
} }
} }
@ -532,10 +577,14 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
this.fetchRbfSubscription.unsubscribe(); this.fetchRbfSubscription.unsubscribe();
this.fetchCachedTxSubscription.unsubscribe(); this.fetchCachedTxSubscription.unsubscribe();
this.txReplacedSubscription.unsubscribe(); this.txReplacedSubscription.unsubscribe();
this.txRbfInfoSubscription.unsubscribe();
this.blocksSubscription.unsubscribe(); this.blocksSubscription.unsubscribe();
this.queryParamsSubscription.unsubscribe(); this.queryParamsSubscription.unsubscribe();
this.flowPrefSubscription.unsubscribe(); this.flowPrefSubscription.unsubscribe();
this.urlFragmentSubscription.unsubscribe(); this.urlFragmentSubscription.unsubscribe();
this.mempoolBlocksSubscription.unsubscribe();
this.mempoolPositionSubscription.unsubscribe();
this.mempoolBlocksSubscription.unsubscribe();
this.leaveTransaction(); this.leaveTransaction();
} }
} }

View File

@ -26,6 +26,18 @@ export interface CpfpInfo {
bestDescendant?: BestDescendant | null; bestDescendant?: BestDescendant | null;
} }
export interface RbfInfo {
tx: RbfTransaction;
time: number;
interval?: number;
}
export interface RbfTree extends RbfInfo {
mined?: boolean;
fullRbf: boolean;
replaces: RbfTree[];
}
export interface DifficultyAdjustment { export interface DifficultyAdjustment {
progressPercent: number; progressPercent: number;
difficultyChange: number; difficultyChange: number;
@ -146,6 +158,15 @@ export interface TransactionStripped {
status?: 'found' | 'missing' | 'fresh' | 'added' | 'censored' | 'selected'; status?: 'found' | 'missing' | 'fresh' | 'added' | 'censored' | 'selected';
} }
interface RbfTransaction extends TransactionStripped {
rbf?: boolean;
mined?: boolean,
}
export interface MempoolPosition {
block: number,
vsize: number,
}
export interface RewardStats { export interface RewardStats {
startBlock: number; startBlock: number;
endBlock: number; endBlock: number;

View File

@ -1,6 +1,6 @@
import { ILoadingIndicators } from '../services/state.service'; import { ILoadingIndicators } from '../services/state.service';
import { Transaction } from './electrs.interface'; import { Transaction } from './electrs.interface';
import { BlockExtended, DifficultyAdjustment } from './node-api.interface'; import { BlockExtended, DifficultyAdjustment, RbfTree } from './node-api.interface';
export interface WebsocketResponse { export interface WebsocketResponse {
block?: BlockExtended; block?: BlockExtended;
@ -16,6 +16,8 @@ export interface WebsocketResponse {
tx?: Transaction; tx?: Transaction;
rbfTransaction?: ReplacedTransaction; rbfTransaction?: ReplacedTransaction;
txReplaced?: ReplacedTransaction; txReplaced?: ReplacedTransaction;
rbfInfo?: RbfTree;
rbfLatest?: RbfTree[];
utxoSpent?: object; utxoSpent?: object;
transactions?: TransactionStripped[]; transactions?: TransactionStripped[];
loadingIndicators?: ILoadingIndicators; loadingIndicators?: ILoadingIndicators;
@ -26,6 +28,7 @@ export interface WebsocketResponse {
'track-address'?: string; 'track-address'?: string;
'track-asset'?: string; 'track-asset'?: string;
'track-mempool-block'?: number; 'track-mempool-block'?: number;
'track-rbf'?: string;
'watch-mempool'?: boolean; 'watch-mempool'?: boolean;
'track-bisq-market'?: string; 'track-bisq-market'?: string;
} }

View File

@ -1,7 +1,7 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http'; import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http';
import { CpfpInfo, OptimizedMempoolStats, AddressInformation, LiquidPegs, ITranslators, import { CpfpInfo, OptimizedMempoolStats, AddressInformation, LiquidPegs, ITranslators,
PoolStat, BlockExtended, TransactionStripped, RewardStats, AuditScore, BlockSizesAndWeights } from '../interfaces/node-api.interface'; PoolStat, BlockExtended, TransactionStripped, RewardStats, AuditScore, BlockSizesAndWeights, RbfTree } from '../interfaces/node-api.interface';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { StateService } from './state.service'; import { StateService } from './state.service';
import { WebsocketResponse } from '../interfaces/websocket.interface'; import { WebsocketResponse } from '../interfaces/websocket.interface';
@ -124,14 +124,18 @@ export class ApiService {
return this.httpClient.get<AddressInformation>(this.apiBaseUrl + this.apiBasePath + '/api/v1/validate-address/' + address); return this.httpClient.get<AddressInformation>(this.apiBaseUrl + this.apiBasePath + '/api/v1/validate-address/' + address);
} }
getRbfHistory$(txid: string): Observable<string[]> { getRbfHistory$(txid: string): Observable<{ replacements: RbfTree, replaces: string[] }> {
return this.httpClient.get<string[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/tx/' + txid + '/replaces'); return this.httpClient.get<{ replacements: RbfTree, replaces: string[] }>(this.apiBaseUrl + this.apiBasePath + '/api/v1/tx/' + txid + '/rbf');
} }
getRbfCachedTx$(txid: string): Observable<Transaction> { getRbfCachedTx$(txid: string): Observable<Transaction> {
return this.httpClient.get<Transaction>(this.apiBaseUrl + this.apiBasePath + '/api/v1/tx/' + txid + '/cached'); return this.httpClient.get<Transaction>(this.apiBaseUrl + this.apiBasePath + '/api/v1/tx/' + txid + '/cached');
} }
getRbfList$(fullRbf: boolean, after?: string): Observable<RbfTree[]> {
return this.httpClient.get<RbfTree[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/' + (fullRbf ? 'fullrbf/' : '') + 'replacements/' + (after || ''));
}
listLiquidPegsMonth$(): Observable<LiquidPegs[]> { listLiquidPegsMonth$(): Observable<LiquidPegs[]> {
return this.httpClient.get<LiquidPegs[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/liquid/pegs/month'); return this.httpClient.get<LiquidPegs[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/liquid/pegs/month');
} }

View File

@ -2,7 +2,7 @@ import { Inject, Injectable, PLATFORM_ID, LOCALE_ID } from '@angular/core';
import { ReplaySubject, BehaviorSubject, Subject, fromEvent, Observable } from 'rxjs'; import { ReplaySubject, BehaviorSubject, Subject, fromEvent, Observable } from 'rxjs';
import { Transaction } from '../interfaces/electrs.interface'; import { Transaction } from '../interfaces/electrs.interface';
import { IBackendInfo, MempoolBlock, MempoolBlockWithTransactions, MempoolBlockDelta, MempoolInfo, Recommendedfees, ReplacedTransaction, TransactionStripped } from '../interfaces/websocket.interface'; import { IBackendInfo, MempoolBlock, MempoolBlockWithTransactions, MempoolBlockDelta, MempoolInfo, Recommendedfees, ReplacedTransaction, TransactionStripped } from '../interfaces/websocket.interface';
import { BlockExtended, DifficultyAdjustment, OptimizedMempoolStats } from '../interfaces/node-api.interface'; import { BlockExtended, DifficultyAdjustment, MempoolPosition, OptimizedMempoolStats, RbfTree } from '../interfaces/node-api.interface';
import { Router, NavigationStart } from '@angular/router'; import { Router, NavigationStart } from '@angular/router';
import { isPlatformBrowser } from '@angular/common'; import { isPlatformBrowser } from '@angular/common';
import { map, shareReplay } from 'rxjs/operators'; import { map, shareReplay } from 'rxjs/operators';
@ -12,6 +12,7 @@ interface MarkBlockState {
blockHeight?: number; blockHeight?: number;
mempoolBlockIndex?: number; mempoolBlockIndex?: number;
txFeePerVSize?: number; txFeePerVSize?: number;
mempoolPosition?: MempoolPosition;
} }
export interface ILoadingIndicators { [name: string]: number; } export interface ILoadingIndicators { [name: string]: number; }
@ -43,6 +44,7 @@ export interface Env {
MAINNET_BLOCK_AUDIT_START_HEIGHT: number; MAINNET_BLOCK_AUDIT_START_HEIGHT: number;
TESTNET_BLOCK_AUDIT_START_HEIGHT: number; TESTNET_BLOCK_AUDIT_START_HEIGHT: number;
SIGNET_BLOCK_AUDIT_START_HEIGHT: number; SIGNET_BLOCK_AUDIT_START_HEIGHT: number;
FULL_RBF_ENABLED: boolean;
HISTORICAL_PRICE: boolean; HISTORICAL_PRICE: boolean;
} }
@ -73,6 +75,7 @@ const defaultEnv: Env = {
'MAINNET_BLOCK_AUDIT_START_HEIGHT': 0, 'MAINNET_BLOCK_AUDIT_START_HEIGHT': 0,
'TESTNET_BLOCK_AUDIT_START_HEIGHT': 0, 'TESTNET_BLOCK_AUDIT_START_HEIGHT': 0,
'SIGNET_BLOCK_AUDIT_START_HEIGHT': 0, 'SIGNET_BLOCK_AUDIT_START_HEIGHT': 0,
'FULL_RBF_ENABLED': false,
'HISTORICAL_PRICE': true, 'HISTORICAL_PRICE': true,
}; };
@ -98,9 +101,12 @@ export class StateService {
mempoolBlockTransactions$ = new Subject<TransactionStripped[]>(); mempoolBlockTransactions$ = new Subject<TransactionStripped[]>();
mempoolBlockDelta$ = new Subject<MempoolBlockDelta>(); mempoolBlockDelta$ = new Subject<MempoolBlockDelta>();
txReplaced$ = new Subject<ReplacedTransaction>(); txReplaced$ = new Subject<ReplacedTransaction>();
txRbfInfo$ = new Subject<RbfTree>();
rbfLatest$ = new Subject<RbfTree[]>();
utxoSpent$ = new Subject<object>(); utxoSpent$ = new Subject<object>();
difficultyAdjustment$ = new ReplaySubject<DifficultyAdjustment>(1); difficultyAdjustment$ = new ReplaySubject<DifficultyAdjustment>(1);
mempoolTransactions$ = new Subject<Transaction>(); mempoolTransactions$ = new Subject<Transaction>();
mempoolTxPosition$ = new Subject<{ txid: string, position: MempoolPosition}>();
blockTransactions$ = new Subject<Transaction>(); blockTransactions$ = new Subject<Transaction>();
isLoadingWebSocket$ = new ReplaySubject<boolean>(1); isLoadingWebSocket$ = new ReplaySubject<boolean>(1);
vbytesPerSecond$ = new ReplaySubject<number>(1); vbytesPerSecond$ = new ReplaySubject<number>(1);

View File

@ -28,6 +28,7 @@ export class WebsocketService {
private isTrackingTx = false; private isTrackingTx = false;
private trackingTxId: string; private trackingTxId: string;
private isTrackingMempoolBlock = false; private isTrackingMempoolBlock = false;
private isTrackingRbf = false;
private trackingMempoolBlock: number; private trackingMempoolBlock: number;
private latestGitCommit = ''; private latestGitCommit = '';
private onlineCheckTimeout: number; private onlineCheckTimeout: number;
@ -173,6 +174,16 @@ export class WebsocketService {
this.isTrackingMempoolBlock = false this.isTrackingMempoolBlock = false
} }
startTrackRbf(mode: 'all' | 'fullRbf') {
this.websocketSubject.next({ 'track-rbf': mode });
this.isTrackingRbf = true;
}
stopTrackRbf() {
this.websocketSubject.next({ 'track-rbf': 'stop' });
this.isTrackingRbf = false;
}
startTrackBisqMarket(market: string) { startTrackBisqMarket(market: string) {
this.websocketSubject.next({ 'track-bisq-market': market }); this.websocketSubject.next({ 'track-bisq-market': market });
} }
@ -238,6 +249,10 @@ export class WebsocketService {
this.stateService.mempoolTransactions$.next(response.tx); this.stateService.mempoolTransactions$.next(response.tx);
} }
if (response['txPosition']) {
this.stateService.mempoolTxPosition$.next(response['txPosition']);
}
if (response.block) { if (response.block) {
if (response.block.height > this.stateService.latestBlockHeight) { if (response.block.height > this.stateService.latestBlockHeight) {
this.stateService.updateChainTip(response.block.height); this.stateService.updateChainTip(response.block.height);
@ -257,6 +272,14 @@ export class WebsocketService {
this.stateService.txReplaced$.next(response.rbfTransaction); this.stateService.txReplaced$.next(response.rbfTransaction);
} }
if (response.rbfInfo) {
this.stateService.txRbfInfo$.next(response.rbfInfo);
}
if (response.rbfLatest) {
this.stateService.rbfLatest$.next(response.rbfLatest);
}
if (response.txReplaced) { if (response.txReplaced) {
this.stateService.txReplaced$.next(response.txReplaced); this.stateService.txReplaced$.next(response.txReplaced);
} }

View File

@ -61,6 +61,8 @@ import { DifficultyComponent } from '../components/difficulty/difficulty.compone
import { DifficultyTooltipComponent } from '../components/difficulty/difficulty-tooltip.component'; import { DifficultyTooltipComponent } from '../components/difficulty/difficulty-tooltip.component';
import { DifficultyMiningComponent } from '../components/difficulty-mining/difficulty-mining.component'; import { DifficultyMiningComponent } from '../components/difficulty-mining/difficulty-mining.component';
import { TermsOfServiceComponent } from '../components/terms-of-service/terms-of-service.component'; import { TermsOfServiceComponent } from '../components/terms-of-service/terms-of-service.component';
import { RbfTimelineComponent } from '../components/rbf-timeline/rbf-timeline.component';
import { RbfTimelineTooltipComponent } from '../components/rbf-timeline/rbf-timeline-tooltip.component';
import { TxBowtieGraphComponent } from '../components/tx-bowtie-graph/tx-bowtie-graph.component'; import { TxBowtieGraphComponent } from '../components/tx-bowtie-graph/tx-bowtie-graph.component';
import { TxBowtieGraphTooltipComponent } from '../components/tx-bowtie-graph-tooltip/tx-bowtie-graph-tooltip.component'; import { TxBowtieGraphTooltipComponent } from '../components/tx-bowtie-graph-tooltip/tx-bowtie-graph-tooltip.component';
import { PrivacyPolicyComponent } from '../components/privacy-policy/privacy-policy.component'; import { PrivacyPolicyComponent } from '../components/privacy-policy/privacy-policy.component';
@ -72,6 +74,7 @@ import { AssetCirculationComponent } from '../components/asset-circulation/asset
import { AmountShortenerPipe } from '../shared/pipes/amount-shortener.pipe'; import { AmountShortenerPipe } from '../shared/pipes/amount-shortener.pipe';
import { DifficultyAdjustmentsTable } from '../components/difficulty-adjustments-table/difficulty-adjustments-table.components'; import { DifficultyAdjustmentsTable } from '../components/difficulty-adjustments-table/difficulty-adjustments-table.components';
import { BlocksList } from '../components/blocks-list/blocks-list.component'; import { BlocksList } from '../components/blocks-list/blocks-list.component';
import { RbfList } from '../components/rbf-list/rbf-list.component';
import { RewardStatsComponent } from '../components/reward-stats/reward-stats.component'; import { RewardStatsComponent } from '../components/reward-stats/reward-stats.component';
import { DataCyDirective } from '../data-cy.directive'; import { DataCyDirective } from '../data-cy.directive';
import { LoadingIndicatorComponent } from '../components/loading-indicator/loading-indicator.component'; import { LoadingIndicatorComponent } from '../components/loading-indicator/loading-indicator.component';
@ -138,6 +141,8 @@ import { TestnetAlertComponent } from './components/testnet-alert/testnet-alert.
DifficultyComponent, DifficultyComponent,
DifficultyMiningComponent, DifficultyMiningComponent,
DifficultyTooltipComponent, DifficultyTooltipComponent,
RbfTimelineComponent,
RbfTimelineTooltipComponent,
TxBowtieGraphComponent, TxBowtieGraphComponent,
TxBowtieGraphTooltipComponent, TxBowtieGraphTooltipComponent,
TermsOfServiceComponent, TermsOfServiceComponent,
@ -151,6 +156,7 @@ import { TestnetAlertComponent } from './components/testnet-alert/testnet-alert.
AmountShortenerPipe, AmountShortenerPipe,
DifficultyAdjustmentsTable, DifficultyAdjustmentsTable,
BlocksList, BlocksList,
RbfList,
DataCyDirective, DataCyDirective,
RewardStatsComponent, RewardStatsComponent,
LoadingIndicatorComponent, LoadingIndicatorComponent,
@ -242,6 +248,8 @@ import { TestnetAlertComponent } from './components/testnet-alert/testnet-alert.
DifficultyComponent, DifficultyComponent,
DifficultyMiningComponent, DifficultyMiningComponent,
DifficultyTooltipComponent, DifficultyTooltipComponent,
RbfTimelineComponent,
RbfTimelineTooltipComponent,
TxBowtieGraphComponent, TxBowtieGraphComponent,
TxBowtieGraphTooltipComponent, TxBowtieGraphTooltipComponent,
TermsOfServiceComponent, TermsOfServiceComponent,

View File

@ -1,5 +1,5 @@
@reboot sleep 30 ; screen -dmS mainnet /bitcoin/electrs/electrs-start-mainnet @reboot screen -dmS mainnet /bitcoin/electrs/electrs-start-mainnet
@reboot sleep 60 ; /usr/local/bin/bitcoind -testnet >/dev/null 2>&1 @reboot /usr/local/bin/bitcoind -testnet >/dev/null 2>&1
@reboot sleep 70 ; screen -dmS testnet /bitcoin/electrs/electrs-start-testnet @reboot screen -dmS testnet /bitcoin/electrs/electrs-start-testnet
@reboot sleep 80 ; /usr/local/bin/bitcoind -signet >/dev/null 2>&1 @reboot /usr/local/bin/bitcoind -signet >/dev/null 2>&1
@reboot sleep 90 ; screen -dmS signet /bitcoin/electrs/electrs-start-signet @reboot screen -dmS signet /bitcoin/electrs/electrs-start-signet

View File

@ -1,10 +1,10 @@
# start elements on reboot # start elements on reboot
@reboot sleep 60 ; /usr/local/bin/elementsd -chain=liquidv1 >/dev/null 2>&1 @reboot /usr/local/bin/elementsd -chain=liquidv1 >/dev/null 2>&1
@reboot sleep 60 ; /usr/local/bin/elementsd -chain=liquidtestnet >/dev/null 2>&1 @reboot /usr/local/bin/elementsd -chain=liquidtestnet >/dev/null 2>&1
# start electrs on reboot # start electrs on reboot
@reboot sleep 90 ; screen -dmS liquidv1 /elements/electrs/electrs-start-liquid @reboot screen -dmS liquidv1 /elements/electrs/electrs-start-liquid
@reboot sleep 90 ; screen -dmS liquidtestnet /elements/electrs/electrs-start-liquidtestnet @reboot screen -dmS liquidtestnet /elements/electrs/electrs-start-liquidtestnet
# hourly asset update and electrs restart # hourly asset update and electrs restart
6 * * * * cd $HOME/asset_registry_db && git pull --quiet origin master && cd $HOME/asset_registry_testnet_db && git pull --quiet origin master && killall electrs 6 * * * * cd $HOME/asset_registry_db && git pull --quiet origin master && cd $HOME/asset_registry_testnet_db && git pull --quiet origin master && killall electrs

View File

@ -1325,10 +1325,10 @@ case $OS in
public_ipv4=$( ifconfig | grep 'inet ' | awk -F ' ' '{ print $2 }' | grep -v '^103\.165\.192\.' | grep -v '^127\.0\.0\.1' ) public_ipv4=$( ifconfig | grep 'inet ' | awk -F ' ' '{ print $2 }' | grep -v '^103\.165\.192\.' | grep -v '^127\.0\.0\.1' )
public_ipv6=$( ifconfig | grep 'inet6' | awk -F ' ' '{ print $2 }' | grep -v '^2001:df6:7280::' | grep -v '^fe80::' | grep -v '^::1' ) public_ipv6=$( ifconfig | grep 'inet6' | awk -F ' ' '{ print $2 }' | grep -v '^2001:df6:7280::' | grep -v '^fe80::' | grep -v '^::1' )
crontab_cln+="@reboot sleep 60 ; screen -dmS main lightningd --rpc-file-mode 0660 --alias `hostname` --disable-ip-discovery --autolisten false --bind-addr $public_ipv4 --announce-addr $public_ipv4 --bind-addr $public_ipv6 --announce-addr $public_ipv6\n" crontab_cln+="@reboot sleep 10 ; screen -dmS main lightningd --rpc-file-mode 0660 --alias `hostname` --disable-ip-discovery --autolisten false --bind-addr $public_ipv4 --announce-addr $public_ipv4 --bind-addr $public_ipv6 --announce-addr $public_ipv6\n"
crontab_cln+="@reboot sleep 90 ; screen -dmS tes lightningd --rpc-file-mode 0660 --alias `hostname` --network testnet --disable-ip-discovery --autolisten false --bind-addr $public_ipv4 --announce-addr $public_ipv4 --bind-addr $public_ipv6 --announce-addr $public_ipv6\n" crontab_cln+="@reboot sleep 10 ; screen -dmS tes lightningd --rpc-file-mode 0660 --alias `hostname` --network testnet --disable-ip-discovery --autolisten false --bind-addr $public_ipv4 --announce-addr $public_ipv4 --bind-addr $public_ipv6 --announce-addr $public_ipv6\n"
crontab_cln+="@reboot sleep 120 ; screen -dmS sig lightningd --rpc-file-mode 0660 --alias `hostname` --network signet --disable-ip-discovery --autolisten false --bind-addr $public_ipv4 --announce-addr $public_ipv4 --bind-addr $public_ipv6 --announce-addr $public_ipv6 \n" crontab_cln+="@reboot sleep 10 ; screen -dmS sig lightningd --rpc-file-mode 0660 --alias `hostname` --network signet --disable-ip-discovery --autolisten false --bind-addr $public_ipv4 --announce-addr $public_ipv4 --bind-addr $public_ipv6 --announce-addr $public_ipv6 \n"
crontab_cln+="@reboot sleep 180 ; /mempool/mempool.space/lightning-seeder >/dev/null 2>&1\n" crontab_cln+="@reboot sleep 20 ; /mempool/mempool.space/lightning-seeder >/dev/null 2>&1\n"
crontab_cln+="1 * * * * /mempool/mempool.space/lightning-seeder >/dev/null 2>&1\n" crontab_cln+="1 * * * * /mempool/mempool.space/lightning-seeder >/dev/null 2>&1\n"
echo "${crontab_cln}" | crontab -u "${CLN_USER}" - echo "${crontab_cln}" | crontab -u "${CLN_USER}" -
;; ;;

View File

@ -15,7 +15,8 @@
"PASSWORD": "__BITCOIN_RPC_PASS__" "PASSWORD": "__BITCOIN_RPC_PASS__"
}, },
"ESPLORA": { "ESPLORA": {
"REST_API_URL": "http://127.0.0.1:4000" "REST_API_URL": "http://127.0.0.1:5000",
"UNIX_SOCKET_PATH": "/bitcoin/socket/esplora-bitcoin-mainnet"
}, },
"DATABASE": { "DATABASE": {
"ENABLED": false, "ENABLED": false,

View File

@ -23,6 +23,7 @@
"PASSWORD": "__BITCOIN_RPC_PASS__" "PASSWORD": "__BITCOIN_RPC_PASS__"
}, },
"ESPLORA": { "ESPLORA": {
"REST_API_URL": "http://127.0.0.1:5001",
"UNIX_SOCKET_PATH": "/elements/socket/esplora-liquid-mainnet" "UNIX_SOCKET_PATH": "/elements/socket/esplora-liquid-mainnet"
}, },
"DATABASE": { "DATABASE": {

View File

@ -23,6 +23,7 @@
"PASSWORD": "__BITCOIN_RPC_PASS__" "PASSWORD": "__BITCOIN_RPC_PASS__"
}, },
"ESPLORA": { "ESPLORA": {
"REST_API_URL": "http://127.0.0.1:5004",
"UNIX_SOCKET_PATH": "/elements/socket/esplora-liquid-testnet" "UNIX_SOCKET_PATH": "/elements/socket/esplora-liquid-testnet"
}, },
"DATABASE": { "DATABASE": {

View File

@ -15,7 +15,8 @@
"PASSWORD": "__BITCOIN_RPC_PASS__" "PASSWORD": "__BITCOIN_RPC_PASS__"
}, },
"ESPLORA": { "ESPLORA": {
"REST_API_URL": "http://127.0.0.1:4000" "REST_API_URL": "http://127.0.0.1:5000",
"UNIX_SOCKET_PATH": "/bitcoin/socket/esplora-bitcoin-mainnet"
}, },
"LIGHTNING": { "LIGHTNING": {
"ENABLED": true, "ENABLED": true,

View File

@ -31,6 +31,7 @@
"PASSWORD": "__BITCOIN_RPC_PASS__" "PASSWORD": "__BITCOIN_RPC_PASS__"
}, },
"ESPLORA": { "ESPLORA": {
"REST_API_URL": "http://127.0.0.1:5000",
"UNIX_SOCKET_PATH": "/bitcoin/socket/esplora-bitcoin-mainnet" "UNIX_SOCKET_PATH": "/bitcoin/socket/esplora-bitcoin-mainnet"
}, },
"DATABASE": { "DATABASE": {

View File

@ -15,7 +15,8 @@
"PASSWORD": "__BITCOIN_RPC_PASS__" "PASSWORD": "__BITCOIN_RPC_PASS__"
}, },
"ESPLORA": { "ESPLORA": {
"REST_API_URL": "http://127.0.0.1:4003" "REST_API_URL": "http://127.0.0.1:5003",
"UNIX_SOCKET_PATH": "/bitcoin/socket/esplora-bitcoin-signet"
}, },
"LIGHTNING": { "LIGHTNING": {
"ENABLED": true, "ENABLED": true,

View File

@ -22,6 +22,7 @@
"PASSWORD": "__BITCOIN_RPC_PASS__" "PASSWORD": "__BITCOIN_RPC_PASS__"
}, },
"ESPLORA": { "ESPLORA": {
"REST_API_URL": "http://127.0.0.1:5003",
"UNIX_SOCKET_PATH": "/bitcoin/socket/esplora-bitcoin-signet" "UNIX_SOCKET_PATH": "/bitcoin/socket/esplora-bitcoin-signet"
}, },
"DATABASE": { "DATABASE": {

View File

@ -15,7 +15,8 @@
"PASSWORD": "__BITCOIN_RPC_PASS__" "PASSWORD": "__BITCOIN_RPC_PASS__"
}, },
"ESPLORA": { "ESPLORA": {
"REST_API_URL": "http://127.0.0.1:4002" "REST_API_URL": "http://127.0.0.1:5002",
"UNIX_SOCKET_PATH": "/bitcoin/socket/esplora-bitcoin-testnet"
}, },
"LIGHTNING": { "LIGHTNING": {
"ENABLED": true, "ENABLED": true,

View File

@ -22,6 +22,7 @@
"PASSWORD": "__BITCOIN_RPC_PASS__" "PASSWORD": "__BITCOIN_RPC_PASS__"
}, },
"ESPLORA": { "ESPLORA": {
"REST_API_URL": "http://127.0.0.1:5002",
"UNIX_SOCKET_PATH": "/bitcoin/socket/esplora-bitcoin-testnet" "UNIX_SOCKET_PATH": "/bitcoin/socket/esplora-bitcoin-testnet"
}, },
"DATABASE": { "DATABASE": {

View File

@ -1,5 +1,5 @@
# start on reboot # start on reboot
@reboot sleep 10 ; $HOME/start @reboot sleep 90 ; $HOME/start
# daily backup # daily backup
37 13 * * * sleep 30 ; /mempool/mempool.space/backup >/dev/null 2>&1 & 37 13 * * * sleep 30 ; /mempool/mempool.space/backup >/dev/null 2>&1 &

View File

@ -1 +1 @@
@reboot sleep 120 ; /usr/local/bin/bitcoind >/dev/null 2>&1 @reboot /usr/local/bin/bitcoind >/dev/null 2>&1