Merge branch 'master' into nymkappa/scan-closed-channel-no-mempool
This commit is contained in:
commit
cb9d09a930
@ -43,7 +43,9 @@
|
|||||||
"TLS_ENABLED": true
|
"TLS_ENABLED": true
|
||||||
},
|
},
|
||||||
"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",
|
||||||
|
"RETRY_UNIX_SOCKET_AFTER": 30000
|
||||||
},
|
},
|
||||||
"SECOND_CORE_RPC": {
|
"SECOND_CORE_RPC": {
|
||||||
"HOST": "127.0.0.1",
|
"HOST": "127.0.0.1",
|
||||||
@ -59,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,
|
||||||
|
6613
backend/package-lock.json
generated
6613
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -34,35 +34,35 @@
|
|||||||
"prettier": "./node_modules/.bin/prettier --write \"src/**/*.{js,ts}\""
|
"prettier": "./node_modules/.bin/prettier --write \"src/**/*.{js,ts}\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/core": "^7.20.12",
|
"@babel/core": "^7.21.3",
|
||||||
"@mempool/electrum-client": "1.1.9",
|
"@mempool/electrum-client": "1.1.9",
|
||||||
"@types/node": "^16.18.11",
|
"@types/node": "^18.15.3",
|
||||||
"axios": "~0.27.2",
|
"axios": "~0.27.2",
|
||||||
"bitcoinjs-lib": "~6.1.0",
|
"bitcoinjs-lib": "~6.1.0",
|
||||||
"crypto-js": "~4.1.1",
|
"crypto-js": "~4.1.1",
|
||||||
"express": "~4.18.2",
|
"express": "~4.18.2",
|
||||||
"maxmind": "~4.3.8",
|
"maxmind": "~4.3.8",
|
||||||
"mysql2": "~2.3.3",
|
"mysql2": "~3.2.0",
|
||||||
"node-worker-threads-pool": "~1.5.1",
|
"node-worker-threads-pool": "~1.5.1",
|
||||||
"socks-proxy-agent": "~7.0.0",
|
"socks-proxy-agent": "~7.0.0",
|
||||||
"typescript": "~4.7.4",
|
"typescript": "~4.7.4",
|
||||||
"ws": "~8.11.0"
|
"ws": "~8.13.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.20.7",
|
"@babel/core": "^7.21.3",
|
||||||
"@babel/code-frame": "^7.18.6",
|
"@babel/code-frame": "^7.18.6",
|
||||||
"@types/compression": "^1.7.2",
|
"@types/compression": "^1.7.2",
|
||||||
"@types/crypto-js": "^4.1.1",
|
"@types/crypto-js": "^4.1.1",
|
||||||
"@types/express": "^4.17.15",
|
"@types/express": "^4.17.15",
|
||||||
"@types/jest": "^29.2.5",
|
"@types/jest": "^29.5.0",
|
||||||
"@types/ws": "~8.5.4",
|
"@types/ws": "~8.5.4",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.48.1",
|
"@typescript-eslint/eslint-plugin": "^5.55.0",
|
||||||
"@typescript-eslint/parser": "^5.48.1",
|
"@typescript-eslint/parser": "^5.55.0",
|
||||||
"eslint": "^8.31.0",
|
"eslint": "^8.36.0",
|
||||||
"eslint-config-prettier": "^8.5.0",
|
"eslint-config-prettier": "^8.7.0",
|
||||||
"jest": "^29.3.1",
|
"jest": "^29.5.0",
|
||||||
"prettier": "^2.8.2",
|
"prettier": "^2.8.4",
|
||||||
"ts-jest": "^29.0.3",
|
"ts-jest": "^29.0.5",
|
||||||
"ts-node": "^10.9.1"
|
"ts-node": "^10.9.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -44,7 +44,9 @@
|
|||||||
"TLS_ENABLED": true
|
"TLS_ENABLED": true
|
||||||
},
|
},
|
||||||
"ESPLORA": {
|
"ESPLORA": {
|
||||||
"REST_API_URL": "__ESPLORA_REST_API_URL__"
|
"REST_API_URL": "__ESPLORA_REST_API_URL__",
|
||||||
|
"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__",
|
||||||
@ -60,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,
|
||||||
|
@ -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' });
|
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({
|
||||||
|
@ -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;
|
||||||
|
@ -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)
|
||||||
@ -128,8 +130,9 @@ class BitcoinRoutes {
|
|||||||
|
|
||||||
private getInitData(req: Request, res: Response) {
|
private getInitData(req: Request, res: Response) {
|
||||||
try {
|
try {
|
||||||
const result = websocketHandler.getInitData();
|
const result = websocketHandler.getSerializedInitData();
|
||||||
res.json(result);
|
res.set('Content-Type', 'application/json');
|
||||||
|
res.send(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);
|
||||||
}
|
}
|
||||||
@ -589,10 +592,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 +645,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);
|
||||||
}
|
}
|
||||||
|
@ -3,65 +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 = {
|
private axiosConfigWithUnixSocket: AxiosRequestConfig = config.ESPLORA.UNIX_SOCKET_PATH ? {
|
||||||
|
socketPath: config.ESPLORA.UNIX_SOCKET_PATH,
|
||||||
|
timeout: 10000,
|
||||||
|
} : {
|
||||||
|
timeout: 10000,
|
||||||
|
};
|
||||||
|
private axiosConfigTcpSocketOnly: AxiosRequestConfig = {
|
||||||
timeout: 10000,
|
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); });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -82,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[][]> {
|
||||||
|
@ -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[] {
|
||||||
@ -410,12 +412,13 @@ class Blocks {
|
|||||||
try {
|
try {
|
||||||
// Get all indexed block hash
|
// Get all indexed block hash
|
||||||
const unindexedBlockHeights = await blocksRepository.$getCPFPUnindexedBlocks();
|
const unindexedBlockHeights = await blocksRepository.$getCPFPUnindexedBlocks();
|
||||||
logger.info(`Indexing cpfp data for ${unindexedBlockHeights.length} blocks`);
|
|
||||||
|
|
||||||
if (!unindexedBlockHeights?.length) {
|
if (!unindexedBlockHeights?.length) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info(`Indexing cpfp data for ${unindexedBlockHeights.length} blocks`);
|
||||||
|
|
||||||
// Logging
|
// Logging
|
||||||
let count = 0;
|
let count = 0;
|
||||||
let countThisRun = 0;
|
let countThisRun = 0;
|
||||||
@ -526,9 +529,16 @@ class Blocks {
|
|||||||
return await BlocksRepository.$validateChain();
|
return await BlocksRepository.$validateChain();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async $updateBlocks() {
|
public async $updateBlocks(): Promise<number> {
|
||||||
|
// warn if this run stalls the main loop for more than 2 minutes
|
||||||
|
const timer = this.startTimer();
|
||||||
|
|
||||||
|
diskCache.lock();
|
||||||
|
|
||||||
let fastForwarded = false;
|
let fastForwarded = false;
|
||||||
|
let handledBlocks = 0;
|
||||||
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);
|
||||||
@ -546,16 +556,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.`);
|
||||||
}
|
}
|
||||||
@ -570,9 +585,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);
|
||||||
@ -581,42 +598,53 @@ 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.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 BlocksSummariesRepository.$deleteBlocksFrom(lastBlock.height - 10);
|
|
||||||
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();
|
||||||
logger.info(`Re-indexed 10 blocks and summaries. Also re-indexed the last difficulty adjustments. Will re-index latest hashrates in a few seconds.`);
|
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);
|
||||||
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.info(`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(() => {
|
||||||
indexer.runSingleTask('blocksPrices');
|
indexer.runSingleTask('blocksPrices');
|
||||||
}, 10000);
|
}, 10000);
|
||||||
@ -625,9 +653,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 +670,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 +695,39 @@ 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}`);
|
||||||
|
|
||||||
|
handledBlocks++;
|
||||||
|
}
|
||||||
|
|
||||||
|
diskCache.unlock();
|
||||||
|
|
||||||
|
this.clearTimer(timer);
|
||||||
|
|
||||||
|
return handledBlocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -736,7 +799,7 @@ class Blocks {
|
|||||||
|
|
||||||
// Index the response if needed
|
// Index the response if needed
|
||||||
if (Common.blocksSummariesIndexingEnabled() === true) {
|
if (Common.blocksSummariesIndexingEnabled() === true) {
|
||||||
await BlocksSummariesRepository.$saveSummary({height: block.height, mined: summary});
|
await BlocksSummariesRepository.$saveTransactions(block.height, block.hash, summary.transactions);
|
||||||
}
|
}
|
||||||
|
|
||||||
return summary.transactions;
|
return summary.transactions;
|
||||||
@ -852,11 +915,12 @@ class Blocks {
|
|||||||
if (cleanBlock.fee_amt_percentiles === null) {
|
if (cleanBlock.fee_amt_percentiles === null) {
|
||||||
const block = await bitcoinClient.getBlock(cleanBlock.hash, 2);
|
const block = await bitcoinClient.getBlock(cleanBlock.hash, 2);
|
||||||
const summary = this.summarizeBlock(block);
|
const summary = this.summarizeBlock(block);
|
||||||
await BlocksSummariesRepository.$saveSummary({ height: block.height, mined: summary });
|
await BlocksSummariesRepository.$saveTransactions(cleanBlock.height, cleanBlock.hash, summary.transactions);
|
||||||
cleanBlock.fee_amt_percentiles = await BlocksSummariesRepository.$getFeePercentilesByBlockId(cleanBlock.hash);
|
cleanBlock.fee_amt_percentiles = await BlocksSummariesRepository.$getFeePercentilesByBlockId(cleanBlock.hash);
|
||||||
}
|
}
|
||||||
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
||||||
@ -83,6 +83,7 @@ export class Common {
|
|||||||
fee: tx.fee,
|
fee: tx.fee,
|
||||||
vsize: tx.weight / 4,
|
vsize: tx.weight / 4,
|
||||||
value: tx.vout.reduce((acc, vout) => acc + (vout.value ? vout.value : 0), 0),
|
value: tx.vout.reduce((acc, vout) => acc + (vout.value ? vout.value : 0), 0),
|
||||||
|
rate: tx.effectiveFeePerVsize,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -7,17 +7,26 @@ 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;
|
||||||
|
|
||||||
|
private semaphore: { resume: (() => void)[], locks: number } = {
|
||||||
|
resume: [],
|
||||||
|
locks: 0,
|
||||||
|
};
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
if (!cluster.isPrimary) {
|
if (!cluster.isPrimary) {
|
||||||
return;
|
return;
|
||||||
@ -43,7 +52,7 @@ class DiskCache {
|
|||||||
const mempool = memPool.getMempool();
|
const mempool = memPool.getMempool();
|
||||||
const mempoolArray: TransactionExtended[] = [];
|
const mempoolArray: TransactionExtended[] = [];
|
||||||
for (const tx in mempool) {
|
for (const tx in mempool) {
|
||||||
if (mempool[tx] && !mempool[tx].deleteAfter) {
|
if (mempool[tx]) {
|
||||||
mempoolArray.push(mempool[tx]);
|
mempoolArray.push(mempool[tx]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -73,6 +82,7 @@ class DiskCache {
|
|||||||
fs.renameSync(DiskCache.TMP_FILE_NAMES.replace('{number}', i.toString()), DiskCache.FILE_NAMES.replace('{number}', i.toString()));
|
fs.renameSync(DiskCache.TMP_FILE_NAMES.replace('{number}', i.toString()), DiskCache.FILE_NAMES.replace('{number}', i.toString()));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
await this.$yield();
|
||||||
await fsPromises.writeFile(DiskCache.TMP_FILE_NAME, JSON.stringify({
|
await fsPromises.writeFile(DiskCache.TMP_FILE_NAME, JSON.stringify({
|
||||||
network: config.MEMPOOL.NETWORK,
|
network: config.MEMPOOL.NETWORK,
|
||||||
cacheSchemaVersion: this.cacheSchemaVersion,
|
cacheSchemaVersion: this.cacheSchemaVersion,
|
||||||
@ -82,6 +92,7 @@ class DiskCache {
|
|||||||
mempoolArray: mempoolArray.splice(0, chunkSize),
|
mempoolArray: mempoolArray.splice(0, chunkSize),
|
||||||
}), { flag: 'w' });
|
}), { flag: 'w' });
|
||||||
for (let i = 1; i < DiskCache.CHUNK_FILES; i++) {
|
for (let i = 1; i < DiskCache.CHUNK_FILES; i++) {
|
||||||
|
await this.$yield();
|
||||||
await fsPromises.writeFile(DiskCache.TMP_FILE_NAMES.replace('{number}', i.toString()), JSON.stringify({
|
await fsPromises.writeFile(DiskCache.TMP_FILE_NAMES.replace('{number}', i.toString()), JSON.stringify({
|
||||||
mempool: {},
|
mempool: {},
|
||||||
mempoolArray: mempoolArray.splice(0, chunkSize),
|
mempoolArray: mempoolArray.splice(0, chunkSize),
|
||||||
@ -100,6 +111,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 +161,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;
|
||||||
}
|
}
|
||||||
@ -164,16 +213,65 @@ class DiskCache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.info('Error parsing ' + fileName + '. Skipping. Reason: ' + (e instanceof Error ? e.message : e));
|
logger.err('Error parsing ' + fileName + '. Skipping. Reason: ' + (e instanceof Error ? e.message : e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private $yield(): Promise<void> {
|
||||||
|
if (this.semaphore.locks) {
|
||||||
|
logger.debug('Pause writing mempool and blocks data to disk cache (async)');
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
this.semaphore.resume.push(resolve);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public lock(): void {
|
||||||
|
this.semaphore.locks++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public unlock(): void {
|
||||||
|
this.semaphore.locks = Math.max(0, this.semaphore.locks - 1);
|
||||||
|
if (!this.semaphore.locks && this.semaphore.resume.length) {
|
||||||
|
const nextResume = this.semaphore.resume.shift();
|
||||||
|
if (nextResume) {
|
||||||
|
logger.debug('Resume writing mempool and blocks data to disk cache (async)');
|
||||||
|
nextResume();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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; } = {};
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import logger from '../logger';
|
import logger from '../logger';
|
||||||
import { MempoolBlock, TransactionExtended, ThreadTransaction, TransactionStripped, MempoolBlockWithTransactions, MempoolBlockDelta, Ancestor } from '../mempool.interfaces';
|
import { MempoolBlock, TransactionExtended, TransactionStripped, MempoolBlockWithTransactions, MempoolBlockDelta, Ancestor, CompactThreadTransaction } from '../mempool.interfaces';
|
||||||
import { Common } from './common';
|
import { Common } from './common';
|
||||||
import config from '../config';
|
import config from '../config';
|
||||||
import { Worker } from 'worker_threads';
|
import { Worker } from 'worker_threads';
|
||||||
@ -10,6 +10,9 @@ class MempoolBlocks {
|
|||||||
private mempoolBlockDeltas: MempoolBlockDelta[] = [];
|
private mempoolBlockDeltas: MempoolBlockDelta[] = [];
|
||||||
private txSelectionWorker: Worker | null = null;
|
private txSelectionWorker: Worker | null = null;
|
||||||
|
|
||||||
|
private nextUid: number = 1;
|
||||||
|
private uidMap: Map<number, string> = new Map(); // map short numerical uids to full txids
|
||||||
|
|
||||||
constructor() {}
|
constructor() {}
|
||||||
|
|
||||||
public getMempoolBlocks(): MempoolBlock[] {
|
public getMempoolBlocks(): MempoolBlock[] {
|
||||||
@ -54,7 +57,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 +78,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;
|
||||||
@ -87,21 +104,45 @@ class MempoolBlocks {
|
|||||||
|
|
||||||
private calculateMempoolBlocks(transactionsSorted: TransactionExtended[]): MempoolBlockWithTransactions[] {
|
private calculateMempoolBlocks(transactionsSorted: TransactionExtended[]): MempoolBlockWithTransactions[] {
|
||||||
const mempoolBlocks: MempoolBlockWithTransactions[] = [];
|
const mempoolBlocks: MempoolBlockWithTransactions[] = [];
|
||||||
|
let blockSize = 0;
|
||||||
let blockWeight = 0;
|
let blockWeight = 0;
|
||||||
|
let blockVsize = 0;
|
||||||
|
let blockFees = 0;
|
||||||
|
const sizeLimit = (config.MEMPOOL.BLOCK_WEIGHT_UNITS / 4) * 1.2;
|
||||||
|
let transactionIds: string[] = [];
|
||||||
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;
|
||||||
transactions.push(tx);
|
blockVsize += tx.vsize;
|
||||||
|
blockSize += tx.size;
|
||||||
|
blockFees += tx.fee;
|
||||||
|
if (blockVsize <= sizeLimit) {
|
||||||
|
transactions.push(tx);
|
||||||
|
}
|
||||||
|
transactionIds.push(tx.txid);
|
||||||
} else {
|
} else {
|
||||||
mempoolBlocks.push(this.dataToMempoolBlocks(transactions));
|
mempoolBlocks.push(this.dataToMempoolBlocks(transactionIds, transactions, blockSize, blockWeight, blockFees));
|
||||||
|
blockVsize = 0;
|
||||||
|
tx.position = {
|
||||||
|
block: mempoolBlocks.length,
|
||||||
|
vsize: blockVsize + (tx.vsize / 2),
|
||||||
|
};
|
||||||
|
blockVsize += tx.vsize;
|
||||||
blockWeight = tx.weight;
|
blockWeight = tx.weight;
|
||||||
|
blockSize = tx.size;
|
||||||
|
blockFees = tx.fee;
|
||||||
|
transactionIds = [tx.txid];
|
||||||
transactions = [tx];
|
transactions = [tx];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (transactions.length) {
|
if (transactions.length) {
|
||||||
mempoolBlocks.push(this.dataToMempoolBlocks(transactions));
|
mempoolBlocks.push(this.dataToMempoolBlocks(transactionIds, transactions, blockSize, blockWeight, blockFees));
|
||||||
}
|
}
|
||||||
|
|
||||||
return mempoolBlocks;
|
return mempoolBlocks;
|
||||||
@ -112,6 +153,7 @@ class MempoolBlocks {
|
|||||||
for (let i = 0; i < Math.max(mempoolBlocks.length, prevBlocks.length); i++) {
|
for (let i = 0; i < Math.max(mempoolBlocks.length, prevBlocks.length); i++) {
|
||||||
let added: TransactionStripped[] = [];
|
let added: TransactionStripped[] = [];
|
||||||
let removed: string[] = [];
|
let removed: string[] = [];
|
||||||
|
const changed: { txid: string, rate: number | undefined }[] = [];
|
||||||
if (mempoolBlocks[i] && !prevBlocks[i]) {
|
if (mempoolBlocks[i] && !prevBlocks[i]) {
|
||||||
added = mempoolBlocks[i].transactions;
|
added = mempoolBlocks[i].transactions;
|
||||||
} else if (!mempoolBlocks[i] && prevBlocks[i]) {
|
} else if (!mempoolBlocks[i] && prevBlocks[i]) {
|
||||||
@ -120,7 +162,7 @@ class MempoolBlocks {
|
|||||||
const prevIds = {};
|
const prevIds = {};
|
||||||
const newIds = {};
|
const newIds = {};
|
||||||
prevBlocks[i].transactions.forEach(tx => {
|
prevBlocks[i].transactions.forEach(tx => {
|
||||||
prevIds[tx.txid] = true;
|
prevIds[tx.txid] = tx;
|
||||||
});
|
});
|
||||||
mempoolBlocks[i].transactions.forEach(tx => {
|
mempoolBlocks[i].transactions.forEach(tx => {
|
||||||
newIds[tx.txid] = true;
|
newIds[tx.txid] = true;
|
||||||
@ -133,30 +175,43 @@ class MempoolBlocks {
|
|||||||
mempoolBlocks[i].transactions.forEach(tx => {
|
mempoolBlocks[i].transactions.forEach(tx => {
|
||||||
if (!prevIds[tx.txid]) {
|
if (!prevIds[tx.txid]) {
|
||||||
added.push(tx);
|
added.push(tx);
|
||||||
|
} else if (tx.rate !== prevIds[tx.txid].rate) {
|
||||||
|
changed.push({ txid: tx.txid, rate: tx.rate });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
mempoolBlockDeltas.push({
|
mempoolBlockDeltas.push({
|
||||||
added,
|
added,
|
||||||
removed
|
removed,
|
||||||
|
changed,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
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[]> {
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
// reset mempool short ids
|
||||||
|
this.resetUids();
|
||||||
|
for (const tx of Object.values(newMempool)) {
|
||||||
|
this.setUid(tx);
|
||||||
|
}
|
||||||
|
|
||||||
// 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: Map<number, CompactThreadTransaction> = new Map();
|
||||||
Object.values(newMempool).filter(tx => !tx.deleteAfter).forEach(entry => {
|
Object.values(newMempool).forEach(entry => {
|
||||||
strippedMempool[entry.txid] = {
|
if (entry.uid != null) {
|
||||||
txid: entry.txid,
|
strippedMempool.set(entry.uid, {
|
||||||
fee: entry.fee,
|
uid: entry.uid,
|
||||||
weight: entry.weight,
|
fee: entry.fee,
|
||||||
feePerVsize: entry.fee / (entry.weight / 4),
|
weight: entry.weight,
|
||||||
effectiveFeePerVsize: entry.fee / (entry.weight / 4),
|
feePerVsize: entry.fee / (entry.weight / 4),
|
||||||
vin: entry.vin.map(v => v.txid),
|
effectiveFeePerVsize: entry.effectiveFeePerVsize || (entry.fee / (entry.weight / 4)),
|
||||||
};
|
inputs: entry.vin.map(v => this.getUid(newMempool[v.txid])).filter(uid => uid != null) as number[],
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// (re)initialize tx selection worker thread
|
// (re)initialize tx selection worker thread
|
||||||
@ -175,7 +230,7 @@ class MempoolBlocks {
|
|||||||
// run the block construction algorithm in a separate thread, and wait for a result
|
// run the block construction algorithm in a separate thread, and wait for a result
|
||||||
let threadErrorListener;
|
let threadErrorListener;
|
||||||
try {
|
try {
|
||||||
const workerResultPromise = new Promise<{ blocks: ThreadTransaction[][], clusters: { [root: string]: string[] } }>((resolve, reject) => {
|
const workerResultPromise = new Promise<{ blocks: number[][], rates: Map<number, number>, clusters: Map<number, number[]> }>((resolve, reject) => {
|
||||||
threadErrorListener = reject;
|
threadErrorListener = reject;
|
||||||
this.txSelectionWorker?.once('message', (result): void => {
|
this.txSelectionWorker?.once('message', (result): void => {
|
||||||
resolve(result);
|
resolve(result);
|
||||||
@ -183,123 +238,151 @@ class MempoolBlocks {
|
|||||||
this.txSelectionWorker?.once('error', reject);
|
this.txSelectionWorker?.once('error', reject);
|
||||||
});
|
});
|
||||||
this.txSelectionWorker.postMessage({ type: 'set', mempool: strippedMempool });
|
this.txSelectionWorker.postMessage({ type: 'set', mempool: strippedMempool });
|
||||||
let { blocks, clusters } = await workerResultPromise;
|
const { blocks, rates, clusters } = this.convertResultTxids(await workerResultPromise);
|
||||||
// filter out stale transactions
|
|
||||||
const unfilteredCount = blocks.reduce((total, block) => { return total + block.length; }, 0);
|
|
||||||
blocks = blocks.map(block => block.filter(tx => (tx.txid && tx.txid in newMempool)));
|
|
||||||
const filteredCount = blocks.reduce((total, block) => { return total + block.length; }, 0);
|
|
||||||
if (filteredCount < unfilteredCount) {
|
|
||||||
logger.warn(`tx selection worker thread returned ${unfilteredCount - filteredCount} stale transactions from makeBlockTemplates`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// clean up thread error listener
|
// clean up thread error listener
|
||||||
this.txSelectionWorker?.removeListener('error', threadErrorListener);
|
this.txSelectionWorker?.removeListener('error', threadErrorListener);
|
||||||
|
|
||||||
return this.processBlockTemplates(newMempool, blocks, clusters, saveResults);
|
const processed = this.processBlockTemplates(newMempool, blocks, rates, clusters, saveResults);
|
||||||
|
logger.debug(`makeBlockTemplates completed in ${(Date.now() - start)/1000} seconds`);
|
||||||
|
return processed;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.err('makeBlockTemplates failed. ' + (e instanceof Error ? e.message : e));
|
logger.err('makeBlockTemplates failed. ' + (e instanceof Error ? e.message : e));
|
||||||
}
|
}
|
||||||
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: TransactionExtended[], 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
for (const tx of Object.values(added)) {
|
||||||
|
this.setUid(tx);
|
||||||
|
}
|
||||||
|
const removedUids = removed.map(tx => this.getUid(tx)).filter(uid => uid != null) as number[];
|
||||||
// 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 addedStripped: ThreadTransaction[] = added.map(entry => {
|
const addedStripped: CompactThreadTransaction[] = added.filter(entry => entry.uid != null).map(entry => {
|
||||||
return {
|
return {
|
||||||
txid: entry.txid,
|
uid: entry.uid || 0,
|
||||||
fee: entry.fee,
|
fee: entry.fee,
|
||||||
weight: entry.weight,
|
weight: entry.weight,
|
||||||
feePerVsize: entry.fee / (entry.weight / 4),
|
feePerVsize: entry.fee / (entry.weight / 4),
|
||||||
effectiveFeePerVsize: entry.fee / (entry.weight / 4),
|
effectiveFeePerVsize: entry.effectiveFeePerVsize || (entry.fee / (entry.weight / 4)),
|
||||||
vin: entry.vin.map(v => v.txid),
|
inputs: entry.vin.map(v => this.getUid(newMempool[v.txid])).filter(uid => uid != null) as number[],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// run the block construction algorithm in a separate thread, and wait for a result
|
// run the block construction algorithm in a separate thread, and wait for a result
|
||||||
let threadErrorListener;
|
let threadErrorListener;
|
||||||
try {
|
try {
|
||||||
const workerResultPromise = new Promise<{ blocks: ThreadTransaction[][], clusters: { [root: string]: string[] } }>((resolve, reject) => {
|
const workerResultPromise = new Promise<{ blocks: number[][], rates: Map<number, number>, clusters: Map<number, number[]> }>((resolve, reject) => {
|
||||||
threadErrorListener = reject;
|
threadErrorListener = reject;
|
||||||
this.txSelectionWorker?.once('message', (result): void => {
|
this.txSelectionWorker?.once('message', (result): void => {
|
||||||
resolve(result);
|
resolve(result);
|
||||||
});
|
});
|
||||||
this.txSelectionWorker?.once('error', reject);
|
this.txSelectionWorker?.once('error', reject);
|
||||||
});
|
});
|
||||||
this.txSelectionWorker.postMessage({ type: 'update', added: addedStripped, removed });
|
this.txSelectionWorker.postMessage({ type: 'update', added: addedStripped, removed: removedUids });
|
||||||
let { blocks, clusters } = await workerResultPromise;
|
const { blocks, rates, clusters } = this.convertResultTxids(await workerResultPromise);
|
||||||
// filter out stale transactions
|
|
||||||
const unfilteredCount = blocks.reduce((total, block) => { return total + block.length; }, 0);
|
this.removeUids(removedUids);
|
||||||
blocks = blocks.map(block => block.filter(tx => (tx.txid && tx.txid in newMempool)));
|
|
||||||
const filteredCount = blocks.reduce((total, block) => { return total + block.length; }, 0);
|
|
||||||
if (filteredCount < unfilteredCount) {
|
|
||||||
logger.warn(`tx selection worker thread returned ${unfilteredCount - filteredCount} stale transactions from updateBlockTemplates`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// clean up thread error listener
|
// clean up thread error listener
|
||||||
this.txSelectionWorker?.removeListener('error', threadErrorListener);
|
this.txSelectionWorker?.removeListener('error', threadErrorListener);
|
||||||
|
|
||||||
this.processBlockTemplates(newMempool, blocks, clusters, saveResults);
|
this.processBlockTemplates(newMempool, blocks, rates, clusters, saveResults);
|
||||||
|
logger.debug(`updateBlockTemplates completed in ${(Date.now() - start) / 1000} seconds`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.err('updateBlockTemplates failed. ' + (e instanceof Error ? e.message : e));
|
logger.err('updateBlockTemplates failed. ' + (e instanceof Error ? e.message : e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private processBlockTemplates(mempool, blocks, clusters, saveResults): MempoolBlockWithTransactions[] {
|
private processBlockTemplates(mempool, blocks: string[][], rates: { [root: string]: number }, clusters: { [root: string]: string[] }, saveResults): MempoolBlockWithTransactions[] {
|
||||||
// update this thread's mempool with the results
|
for (const txid of Object.keys(rates)) {
|
||||||
blocks.forEach(block => {
|
if (txid in mempool) {
|
||||||
block.forEach(tx => {
|
mempool[txid].effectiveFeePerVsize = rates[txid];
|
||||||
if (tx.txid && tx.txid in mempool) {
|
}
|
||||||
if (tx.effectiveFeePerVsize != null) {
|
}
|
||||||
mempool[tx.txid].effectiveFeePerVsize = tx.effectiveFeePerVsize;
|
|
||||||
}
|
|
||||||
if (tx.cpfpRoot && tx.cpfpRoot in clusters) {
|
|
||||||
const ancestors: Ancestor[] = [];
|
|
||||||
const descendants: Ancestor[] = [];
|
|
||||||
const cluster = clusters[tx.cpfpRoot];
|
|
||||||
let matched = false;
|
|
||||||
cluster.forEach(txid => {
|
|
||||||
if (!txid || !mempool[txid]) {
|
|
||||||
logger.warn('projected transaction ancestor missing from mempool cache');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (txid === tx.txid) {
|
|
||||||
matched = true;
|
|
||||||
} else {
|
|
||||||
const relative = {
|
|
||||||
txid: txid,
|
|
||||||
fee: mempool[txid].fee,
|
|
||||||
weight: mempool[txid].weight,
|
|
||||||
};
|
|
||||||
if (matched) {
|
|
||||||
descendants.push(relative);
|
|
||||||
} else {
|
|
||||||
ancestors.push(relative);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
mempool[tx.txid].ancestors = ancestors;
|
|
||||||
mempool[tx.txid].descendants = descendants;
|
|
||||||
mempool[tx.txid].bestDescendant = null;
|
|
||||||
}
|
|
||||||
mempool[tx.txid].cpfpChecked = tx.cpfpChecked;
|
|
||||||
} else {
|
|
||||||
logger.warn('projected transaction missing from mempool cache');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// unpack the condensed blocks into proper mempool blocks
|
const readyBlocks: { transactionIds, transactions, totalSize, totalWeight, totalFees }[] = [];
|
||||||
const mempoolBlocks = blocks.map((transactions) => {
|
const sizeLimit = (config.MEMPOOL.BLOCK_WEIGHT_UNITS / 4) * 1.2;
|
||||||
return this.dataToMempoolBlocks(transactions.map(tx => {
|
// update this thread's mempool with the results
|
||||||
return mempool[tx.txid] || null;
|
for (let blockIndex = 0; blockIndex < blocks.length; blockIndex++) {
|
||||||
}).filter(tx => !!tx));
|
const block: string[] = blocks[blockIndex];
|
||||||
});
|
let txid: string;
|
||||||
|
let mempoolTx: TransactionExtended;
|
||||||
|
let totalSize = 0;
|
||||||
|
let totalVsize = 0;
|
||||||
|
let totalWeight = 0;
|
||||||
|
let totalFees = 0;
|
||||||
|
const transactions: TransactionExtended[] = [];
|
||||||
|
for (let txIndex = 0; txIndex < block.length; txIndex++) {
|
||||||
|
txid = block[txIndex];
|
||||||
|
if (txid) {
|
||||||
|
mempoolTx = mempool[txid];
|
||||||
|
// save position in projected blocks
|
||||||
|
mempoolTx.position = {
|
||||||
|
block: blockIndex,
|
||||||
|
vsize: totalVsize + (mempoolTx.vsize / 2),
|
||||||
|
};
|
||||||
|
mempoolTx.cpfpChecked = true;
|
||||||
|
|
||||||
|
totalSize += mempoolTx.size;
|
||||||
|
totalVsize += mempoolTx.vsize;
|
||||||
|
totalWeight += mempoolTx.weight;
|
||||||
|
totalFees += mempoolTx.fee;
|
||||||
|
|
||||||
|
if (totalVsize <= sizeLimit) {
|
||||||
|
transactions.push(mempoolTx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
readyBlocks.push({
|
||||||
|
transactionIds: block,
|
||||||
|
transactions,
|
||||||
|
totalSize,
|
||||||
|
totalWeight,
|
||||||
|
totalFees
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const cluster of Object.values(clusters)) {
|
||||||
|
for (const memberTxid of cluster) {
|
||||||
|
if (memberTxid in mempool) {
|
||||||
|
const mempoolTx = mempool[memberTxid];
|
||||||
|
const ancestors: Ancestor[] = [];
|
||||||
|
const descendants: Ancestor[] = [];
|
||||||
|
let matched = false;
|
||||||
|
cluster.forEach(txid => {
|
||||||
|
if (txid === memberTxid) {
|
||||||
|
matched = true;
|
||||||
|
} else {
|
||||||
|
const relative = {
|
||||||
|
txid: txid,
|
||||||
|
fee: mempool[txid].fee,
|
||||||
|
weight: mempool[txid].weight,
|
||||||
|
};
|
||||||
|
if (matched) {
|
||||||
|
descendants.push(relative);
|
||||||
|
} else {
|
||||||
|
ancestors.push(relative);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
mempoolTx.ancestors = ancestors;
|
||||||
|
mempoolTx.descendants = descendants;
|
||||||
|
mempoolTx.bestDescendant = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mempoolBlocks = readyBlocks.map(b => this.dataToMempoolBlocks(b.transactionIds, b.transactions, b.totalSize, b.totalWeight, b.totalFees));
|
||||||
|
|
||||||
if (saveResults) {
|
if (saveResults) {
|
||||||
const deltas = this.calculateMempoolDeltas(this.mempoolBlocks, mempoolBlocks);
|
const deltas = this.calculateMempoolDeltas(this.mempoolBlocks, mempoolBlocks);
|
||||||
@ -310,29 +393,69 @@ class MempoolBlocks {
|
|||||||
return mempoolBlocks;
|
return mempoolBlocks;
|
||||||
}
|
}
|
||||||
|
|
||||||
private dataToMempoolBlocks(transactions: TransactionExtended[]): MempoolBlockWithTransactions {
|
private dataToMempoolBlocks(transactionIds: string[], transactions: TransactionExtended[], totalSize: number, totalWeight: number, totalFees: number): MempoolBlockWithTransactions {
|
||||||
let totalSize = 0;
|
|
||||||
let totalWeight = 0;
|
|
||||||
const fitTransactions: TransactionExtended[] = [];
|
|
||||||
transactions.forEach(tx => {
|
|
||||||
totalSize += tx.size;
|
|
||||||
totalWeight += tx.weight;
|
|
||||||
if ((totalWeight + tx.weight) <= config.MEMPOOL.BLOCK_WEIGHT_UNITS * 1.2) {
|
|
||||||
fitTransactions.push(tx);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const feeStats = Common.calcEffectiveFeeStatistics(transactions);
|
const feeStats = Common.calcEffectiveFeeStatistics(transactions);
|
||||||
return {
|
return {
|
||||||
blockSize: totalSize,
|
blockSize: totalSize,
|
||||||
blockVSize: totalWeight / 4,
|
blockVSize: (totalWeight / 4), // fractional vsize to avoid rounding errors
|
||||||
nTx: transactions.length,
|
nTx: transactionIds.length,
|
||||||
totalFees: transactions.reduce((acc, cur) => acc + cur.fee, 0),
|
totalFees: totalFees,
|
||||||
medianFee: feeStats.medianFee, // Common.percentile(transactions.map((tx) => tx.effectiveFeePerVsize), config.MEMPOOL.RECOMMENDED_FEE_PERCENTILE),
|
medianFee: feeStats.medianFee, // Common.percentile(transactions.map((tx) => tx.effectiveFeePerVsize), config.MEMPOOL.RECOMMENDED_FEE_PERCENTILE),
|
||||||
feeRange: feeStats.feeRange, //Common.getFeesInRange(transactions, rangeLength),
|
feeRange: feeStats.feeRange, //Common.getFeesInRange(transactions, rangeLength),
|
||||||
transactionIds: transactions.map((tx) => tx.txid),
|
transactionIds: transactionIds,
|
||||||
transactions: fitTransactions.map((tx) => Common.stripTransaction(tx)),
|
transactions: transactions.map((tx) => Common.stripTransaction(tx)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resetUids(): void {
|
||||||
|
this.uidMap.clear();
|
||||||
|
this.nextUid = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setUid(tx: TransactionExtended): number {
|
||||||
|
const uid = this.nextUid;
|
||||||
|
this.nextUid++;
|
||||||
|
this.uidMap.set(uid, tx.txid);
|
||||||
|
tx.uid = uid;
|
||||||
|
return uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getUid(tx: TransactionExtended): number | void {
|
||||||
|
if (tx?.uid != null && this.uidMap.has(tx.uid)) {
|
||||||
|
return tx.uid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeUids(uids: number[]): void {
|
||||||
|
for (const uid of uids) {
|
||||||
|
this.uidMap.delete(uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private convertResultTxids({ blocks, rates, clusters }: { blocks: number[][], rates: Map<number, number>, clusters: Map<number, number[]>})
|
||||||
|
: { blocks: string[][], rates: { [root: string]: number }, clusters: { [root: string]: string[] }} {
|
||||||
|
const convertedBlocks: string[][] = blocks.map(block => block.map(uid => {
|
||||||
|
return this.uidMap.get(uid) || '';
|
||||||
|
}));
|
||||||
|
const convertedRates = {};
|
||||||
|
for (const rateUid of rates.keys()) {
|
||||||
|
const rateTxid = this.uidMap.get(rateUid);
|
||||||
|
if (rateTxid) {
|
||||||
|
convertedRates[rateTxid] = rates.get(rateUid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const convertedClusters = {};
|
||||||
|
for (const rootUid of clusters.keys()) {
|
||||||
|
const rootTxid = this.uidMap.get(rootUid);
|
||||||
|
if (rootTxid) {
|
||||||
|
const members = clusters.get(rootUid)?.map(uid => {
|
||||||
|
return this.uidMap.get(uid);
|
||||||
|
});
|
||||||
|
convertedClusters[rootTxid] = members;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { blocks: convertedBlocks, rates: convertedRates, clusters: convertedClusters } as { blocks: string[][], rates: { [root: string]: number }, clusters: { [root: string]: string[] }};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new MempoolBlocks();
|
export default new MempoolBlocks();
|
||||||
|
@ -11,8 +11,6 @@ import bitcoinSecondClient from './bitcoin/bitcoin-second-client';
|
|||||||
import rbfCache from './rbf-cache';
|
import rbfCache from './rbf-cache';
|
||||||
|
|
||||||
class Mempool {
|
class Mempool {
|
||||||
private static WEBSOCKET_REFRESH_RATE_MS = 10000;
|
|
||||||
private static LAZY_DELETE_AFTER_SECONDS = 30;
|
|
||||||
private inSync: boolean = false;
|
private inSync: boolean = false;
|
||||||
private mempoolCacheDelta: number = -1;
|
private mempoolCacheDelta: number = -1;
|
||||||
private mempoolCache: { [txId: string]: TransactionExtended } = {};
|
private mempoolCache: { [txId: string]: TransactionExtended } = {};
|
||||||
@ -20,7 +18,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[] = [];
|
||||||
@ -35,6 +33,7 @@ class Mempool {
|
|||||||
private SAMPLE_TIME = 10000; // In ms
|
private SAMPLE_TIME = 10000; // In ms
|
||||||
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 +70,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, [], []);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -117,19 +116,23 @@ class Mempool {
|
|||||||
return txTimes;
|
return txTimes;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async $updateMempool(): Promise<void> {
|
public async $updateMempool(transactions: string[]): 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();
|
this.updateTimerProgress(timer, 'got raw mempool');
|
||||||
const diff = transactions.length - currentMempoolSize;
|
const diff = transactions.length - currentMempoolSize;
|
||||||
const newTransactions: TransactionExtended[] = [];
|
const newTransactions: TransactionExtended[] = [];
|
||||||
|
|
||||||
this.mempoolCacheDelta = Math.abs(diff);
|
this.mempoolCacheDelta = Math.abs(diff);
|
||||||
|
|
||||||
if (!this.inSync) {
|
if (!this.inSync) {
|
||||||
loadingIndicators.setProgress('mempool', Object.keys(this.mempoolCache).length / transactions.length * 100);
|
loadingIndicators.setProgress('mempool', currentMempoolSize / transactions.length * 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://github.com/mempool/mempool/issues/3283
|
// https://github.com/mempool/mempool/issues/3283
|
||||||
@ -142,10 +145,12 @@ class Mempool {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let loggerTimer = new Date().getTime() / 1000;
|
||||||
for (const txid of transactions) {
|
for (const txid of transactions) {
|
||||||
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());
|
||||||
@ -163,9 +168,12 @@ class Mempool {
|
|||||||
logger.debug(`Error finding transaction '${txid}' in the mempool: ` + (e instanceof Error ? e.message : e));
|
logger.debug(`Error finding transaction '${txid}' in the mempool: ` + (e instanceof Error ? e.message : e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const elapsedSeconds = Math.round((new Date().getTime() / 1000) - loggerTimer);
|
||||||
if ((new Date().getTime()) - start > Mempool.WEBSOCKET_REFRESH_RATE_MS) {
|
if (elapsedSeconds > 4) {
|
||||||
break;
|
const progress = (currentMempoolSize + newTransactions.length) / transactions.length * 100;
|
||||||
|
logger.debug(`Mempool is synchronizing. Processed ${newTransactions.length}/${diff} txs (${Math.round(progress)}%)`);
|
||||||
|
loadingIndicators.setProgress('mempool', progress);
|
||||||
|
loggerTimer = new Date().getTime() / 1000;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -199,13 +207,15 @@ class Mempool {
|
|||||||
const transactionsObject = {};
|
const transactionsObject = {};
|
||||||
transactions.forEach((txId) => transactionsObject[txId] = true);
|
transactions.forEach((txId) => transactionsObject[txId] = true);
|
||||||
|
|
||||||
// Flag transactions for lazy deletion
|
// Delete evicted transactions from mempool
|
||||||
for (const tx in this.mempoolCache) {
|
for (const tx in this.mempoolCache) {
|
||||||
if (!transactionsObject[tx] && !this.mempoolCache[tx].deleteAfter) {
|
if (!transactionsObject[tx]) {
|
||||||
deletedTransactions.push(this.mempoolCache[tx]);
|
deletedTransactions.push(this.mempoolCache[tx]);
|
||||||
this.mempoolCache[tx].deleteAfter = new Date().getTime() + Mempool.LAZY_DELETE_AFTER_SECONDS * 1000;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (const tx of deletedTransactions) {
|
||||||
|
delete this.mempoolCache[tx.txid];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const newTransactionsStripped = newTransactions.map((tx) => Common.stripTransaction(tx));
|
const newTransactionsStripped = newTransactions.map((tx) => Common.stripTransaction(tx));
|
||||||
@ -222,22 +232,46 @@ 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
|
|
||||||
delete this.mempoolCache[rbfTransaction];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -255,17 +289,6 @@ class Mempool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public deleteExpiredTransactions() {
|
|
||||||
const now = new Date().getTime();
|
|
||||||
for (const tx in this.mempoolCache) {
|
|
||||||
const lazyDeleteAt = this.mempoolCache[tx].deleteAfter;
|
|
||||||
if (lazyDeleteAt && lazyDeleteAt < now) {
|
|
||||||
delete this.mempoolCache[tx];
|
|
||||||
rbfCache.evict(tx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private $getMempoolInfo() {
|
private $getMempoolInfo() {
|
||||||
if (config.MEMPOOL.USE_SECOND_NODE_FOR_MINFEE) {
|
if (config.MEMPOOL.USE_SECOND_NODE_FOR_MINFEE) {
|
||||||
return Promise.all([
|
return Promise.all([
|
||||||
|
@ -452,7 +452,7 @@ class Mining {
|
|||||||
const elapsedSeconds = Math.max(1, Math.round((new Date().getTime() / 1000) - timer));
|
const elapsedSeconds = Math.max(1, Math.round((new Date().getTime() / 1000) - timer));
|
||||||
if (elapsedSeconds > 5) {
|
if (elapsedSeconds > 5) {
|
||||||
const progress = Math.round(totalBlockChecked / blocks.length * 100);
|
const progress = Math.round(totalBlockChecked / blocks.length * 100);
|
||||||
logger.info(`Indexing difficulty adjustment at block #${block.height} | Progress: ${progress}%`, logger.tags.mining);
|
logger.debug(`Indexing difficulty adjustment at block #${block.height} | Progress: ${progress}%`, logger.tags.mining);
|
||||||
timer = new Date().getTime() / 1000;
|
timer = new Date().getTime() / 1000;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -558,8 +558,10 @@ class Mining {
|
|||||||
currentBlockHeight -= 10000;
|
currentBlockHeight -= 10000;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (totalIndexed) {
|
if (totalIndexed > 0) {
|
||||||
logger.info(`Indexing missing coinstatsindex data completed`, logger.tags.mining);
|
logger.info(`Indexing missing coinstatsindex data completed. Indexed ${totalIndexed}`, logger.tags.mining);
|
||||||
|
} else {
|
||||||
|
logger.debug(`Indexing missing coinstatsindex data completed. Indexed 0.`, logger.tags.mining);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,65 +1,341 @@
|
|||||||
import { TransactionExtended } from "../mempool.interfaces";
|
import logger from "../logger";
|
||||||
|
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, number> = new Map();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
setInterval(this.cleanup.bind(this), 1000 * 60 * 60);
|
setInterval(this.cleanup.bind(this), 1000 * 60 * 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
||||||
|
if (!this.txs.has(txid)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const treeId = this.treeMap.get(txid);
|
||||||
|
if (treeId && this.rbfTrees.has(treeId)) {
|
||||||
|
const tree = this.rbfTrees.get(treeId);
|
||||||
|
if (tree) {
|
||||||
|
this.setTreeMined(tree, txid);
|
||||||
|
tree.mined = true;
|
||||||
|
this.dirtyTrees.add(treeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.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: string, fast: boolean = false): void {
|
||||||
this.expiring[txid] = new Date(Date.now() + 1000 * 86400); // 24 hours
|
if (this.txs.has(txid) && (fast || !this.expiring.has(txid))) {
|
||||||
|
this.expiring.set(txid, fast ? Date.now() + (1000 * 60 * 10) : Date.now() + (1000 * 86400)); // 24 hours
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private cleanup(): void {
|
private cleanup(): void {
|
||||||
const currentDate = new Date();
|
const now = Date.now();
|
||||||
for (const txid in this.expiring) {
|
for (const txid of this.expiring.keys()) {
|
||||||
if (this.expiring[txid] < currentDate) {
|
if ((this.expiring.get(txid) || 0) < now) {
|
||||||
delete this.expiring[txid];
|
this.expiring.delete(txid);
|
||||||
this.remove(txid);
|
this.remove(txid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
logger.debug(`rbf cache contains ${this.txs.size} txs, ${this.expiring.size} due to expire`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 => {
|
||||||
|
if (this.txs.has(expiringEntry[0])) {
|
||||||
|
this.expiring.set(expiringEntry[0], new Date(expiringEntry[1]).getTime());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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;
|
||||||
|
let exists = mined;
|
||||||
|
if (!mined) {
|
||||||
|
try {
|
||||||
|
const apiTx = await bitcoinApi.$getRawTransaction(txid);
|
||||||
|
if (apiTx) {
|
||||||
|
exists = true;
|
||||||
|
}
|
||||||
|
if (apiTx?.status?.confirmed) {
|
||||||
|
mined = true;
|
||||||
|
treeInfo.txMined = true;
|
||||||
|
this.evict(txid, true);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// most transactions do not exist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the root tx is not in the mempool or the blockchain
|
||||||
|
// evict this tree as soon as possible
|
||||||
|
if (root === txid && !exists) {
|
||||||
|
this.evict(txid, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
@ -1,11 +1,10 @@
|
|||||||
import config from '../config';
|
import config from '../config';
|
||||||
import logger from '../logger';
|
import logger from '../logger';
|
||||||
import { ThreadTransaction, MempoolBlockWithTransactions, AuditTransaction } from '../mempool.interfaces';
|
import { CompactThreadTransaction, 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: Map<number, CompactThreadTransaction> = new Map();
|
||||||
|
|
||||||
if (parentPort) {
|
if (parentPort) {
|
||||||
parentPort.on('message', (params) => {
|
parentPort.on('message', (params) => {
|
||||||
@ -13,18 +12,18 @@ if (parentPort) {
|
|||||||
mempool = params.mempool;
|
mempool = params.mempool;
|
||||||
} else if (params.type === 'update') {
|
} else if (params.type === 'update') {
|
||||||
params.added.forEach(tx => {
|
params.added.forEach(tx => {
|
||||||
mempool[tx.txid] = tx;
|
mempool.set(tx.uid, tx);
|
||||||
});
|
});
|
||||||
params.removed.forEach(txid => {
|
params.removed.forEach(uid => {
|
||||||
delete mempool[txid];
|
mempool.delete(uid);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { blocks, clusters } = makeBlockTemplates(mempool);
|
const { blocks, rates, clusters } = makeBlockTemplates(mempool);
|
||||||
|
|
||||||
// return the result to main thread.
|
// return the result to main thread.
|
||||||
if (parentPort) {
|
if (parentPort) {
|
||||||
parentPort.postMessage({ blocks, clusters });
|
parentPort.postMessage({ blocks, rates, clusters });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -33,26 +32,25 @@ if (parentPort) {
|
|||||||
* Build projected mempool blocks using an approximation of the transaction selection algorithm from Bitcoin Core
|
* Build projected mempool blocks using an approximation of the transaction selection algorithm from Bitcoin Core
|
||||||
* (see BlockAssembler in https://github.com/bitcoin/bitcoin/blob/master/src/node/miner.cpp)
|
* (see BlockAssembler in https://github.com/bitcoin/bitcoin/blob/master/src/node/miner.cpp)
|
||||||
*/
|
*/
|
||||||
function makeBlockTemplates(mempool: { [txid: string]: ThreadTransaction })
|
function makeBlockTemplates(mempool: Map<number, CompactThreadTransaction>)
|
||||||
: { blocks: ThreadTransaction[][], clusters: { [root: string]: string[] } } {
|
: { blocks: number[][], rates: Map<number, number>, clusters: Map<number, number[]> } {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
const auditPool: { [txid: string]: AuditTransaction } = {};
|
const auditPool: Map<number, AuditTransaction> = new Map();
|
||||||
const mempoolArray: AuditTransaction[] = [];
|
const mempoolArray: AuditTransaction[] = [];
|
||||||
const restOfArray: ThreadTransaction[] = [];
|
const cpfpClusters: Map<number, number[]> = new Map();
|
||||||
const cpfpClusters: { [root: string]: string[] } = {};
|
|
||||||
|
|
||||||
// grab the top feerate txs up to maxWeight
|
mempool.forEach(tx => {
|
||||||
Object.values(mempool).sort((a, b) => b.feePerVsize - a.feePerVsize).forEach(tx => {
|
tx.dirty = false;
|
||||||
// initializing everything up front helps V8 optimize property access later
|
// initializing everything up front helps V8 optimize property access later
|
||||||
auditPool[tx.txid] = {
|
auditPool.set(tx.uid, {
|
||||||
txid: tx.txid,
|
uid: tx.uid,
|
||||||
fee: tx.fee,
|
fee: tx.fee,
|
||||||
weight: tx.weight,
|
weight: tx.weight,
|
||||||
feePerVsize: tx.feePerVsize,
|
feePerVsize: tx.feePerVsize,
|
||||||
effectiveFeePerVsize: tx.feePerVsize,
|
effectiveFeePerVsize: tx.feePerVsize,
|
||||||
vin: tx.vin,
|
inputs: tx.inputs || [],
|
||||||
relativesSet: false,
|
relativesSet: false,
|
||||||
ancestorMap: new Map<string, AuditTransaction>(),
|
ancestorMap: new Map<number, AuditTransaction>(),
|
||||||
children: new Set<AuditTransaction>(),
|
children: new Set<AuditTransaction>(),
|
||||||
ancestorFee: 0,
|
ancestorFee: 0,
|
||||||
ancestorWeight: 0,
|
ancestorWeight: 0,
|
||||||
@ -60,8 +58,8 @@ function makeBlockTemplates(mempool: { [txid: string]: ThreadTransaction })
|
|||||||
used: false,
|
used: false,
|
||||||
modified: false,
|
modified: false,
|
||||||
modifiedNode: null,
|
modifiedNode: null,
|
||||||
};
|
});
|
||||||
mempoolArray.push(auditPool[tx.txid]);
|
mempoolArray.push(auditPool.get(tx.uid) as AuditTransaction);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build relatives graph & calculate ancestor scores
|
// Build relatives graph & calculate ancestor scores
|
||||||
@ -72,15 +70,28 @@ 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 uid for stability
|
||||||
|
return a.uid < b.uid ? -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)
|
||||||
const blocks: ThreadTransaction[][] = [];
|
const blocks: number[][] = [];
|
||||||
let blockWeight = 4000;
|
let blockWeight = 4000;
|
||||||
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 uid for stability
|
||||||
|
return a.uid > b.uid;
|
||||||
|
} 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,30 +118,36 @@ 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];
|
||||||
let isCluster = false;
|
let isCluster = false;
|
||||||
if (sortedTxSet.length > 1) {
|
if (sortedTxSet.length > 1) {
|
||||||
cpfpClusters[nextTx.txid] = sortedTxSet.map(tx => tx.txid);
|
cpfpClusters.set(nextTx.uid, sortedTxSet.map(tx => tx.uid));
|
||||||
isCluster = true;
|
isCluster = true;
|
||||||
}
|
}
|
||||||
const effectiveFeeRate = nextTx.ancestorFee / (nextTx.ancestorWeight / 4);
|
const effectiveFeeRate = nextTx.ancestorFee / (nextTx.ancestorWeight / 4);
|
||||||
const used: AuditTransaction[] = [];
|
const used: AuditTransaction[] = [];
|
||||||
while (sortedTxSet.length) {
|
while (sortedTxSet.length) {
|
||||||
const ancestor = sortedTxSet.pop();
|
const ancestor = sortedTxSet.pop();
|
||||||
const mempoolTx = mempool[ancestor.txid];
|
const mempoolTx = mempool.get(ancestor.uid);
|
||||||
|
if (!mempoolTx) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
ancestor.used = true;
|
ancestor.used = true;
|
||||||
ancestor.usedBy = nextTx.txid;
|
ancestor.usedBy = nextTx.uid;
|
||||||
// update original copy of this tx with effective fee rate & relatives data
|
// update original copy of this tx with effective fee rate & relatives data
|
||||||
mempoolTx.effectiveFeePerVsize = effectiveFeeRate;
|
if (mempoolTx.effectiveFeePerVsize !== effectiveFeeRate) {
|
||||||
if (isCluster) {
|
mempoolTx.effectiveFeePerVsize = effectiveFeeRate;
|
||||||
mempoolTx.cpfpRoot = nextTx.txid;
|
mempoolTx.dirty = true;
|
||||||
|
}
|
||||||
|
if (mempoolTx.cpfpRoot !== nextTx.uid) {
|
||||||
|
mempoolTx.cpfpRoot = isCluster ? nextTx.uid : null;
|
||||||
|
mempoolTx.dirty;
|
||||||
}
|
}
|
||||||
mempoolTx.cpfpChecked = true;
|
mempoolTx.cpfpChecked = true;
|
||||||
transactions.push(ancestor);
|
transactions.push(ancestor);
|
||||||
blockSize += ancestor.size;
|
|
||||||
blockWeight += ancestor.weight;
|
blockWeight += ancestor.weight;
|
||||||
used.push(ancestor);
|
used.push(ancestor);
|
||||||
}
|
}
|
||||||
@ -156,11 +173,10 @@ function makeBlockTemplates(mempool: { [txid: string]: ThreadTransaction })
|
|||||||
if ((exceededPackageTries || queueEmpty) && blocks.length < 7) {
|
if ((exceededPackageTries || queueEmpty) && blocks.length < 7) {
|
||||||
// construct this block
|
// construct this block
|
||||||
if (transactions.length) {
|
if (transactions.length) {
|
||||||
blocks.push(transactions.map(t => mempool[t.txid]));
|
blocks.push(transactions.map(t => t.uid));
|
||||||
}
|
}
|
||||||
// reset for the next block
|
// reset for the next block
|
||||||
transactions = [];
|
transactions = [];
|
||||||
blockSize = 0;
|
|
||||||
blockWeight = 4000;
|
blockWeight = 4000;
|
||||||
|
|
||||||
// 'overflow' packages didn't fit in this block, but are valid candidates for the next
|
// 'overflow' packages didn't fit in this block, but are valid candidates for the next
|
||||||
@ -175,50 +191,38 @@ 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;
|
|
||||||
const mempoolTx = mempool[tx.txid];
|
|
||||||
// update original copy of this tx with effective fee rate & relatives data
|
|
||||||
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]);
|
// add the final unbounded block if it contains any transactions
|
||||||
restOfArray.forEach(tx => {
|
if (transactions.length > 0) {
|
||||||
blockWeight += tx.weight;
|
blocks.push(transactions.map(t => t.uid));
|
||||||
tx.effectiveFeePerVsize = tx.feePerVsize;
|
}
|
||||||
tx.cpfpChecked = false;
|
|
||||||
blockTransactions.push(tx);
|
// get map of dirty transactions
|
||||||
});
|
const rates = new Map<number, number>();
|
||||||
if (blockTransactions.length) {
|
for (const tx of mempool.values()) {
|
||||||
blocks.push(blockTransactions);
|
if (tx?.dirty) {
|
||||||
|
rates.set(tx.uid, tx.effectiveFeePerVsize || tx.feePerVsize);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
transactions = [];
|
|
||||||
|
|
||||||
const end = Date.now();
|
const end = Date.now();
|
||||||
const time = end - start;
|
const time = end - start;
|
||||||
logger.debug('Mempool templates calculated in ' + time / 1000 + ' seconds');
|
logger.debug('Mempool templates calculated in ' + time / 1000 + ' seconds');
|
||||||
|
|
||||||
return { blocks, clusters: cpfpClusters };
|
return { blocks, rates, clusters: cpfpClusters };
|
||||||
}
|
}
|
||||||
|
|
||||||
// traverse in-mempool ancestors
|
// traverse in-mempool ancestors
|
||||||
// recursion unavoidable, but should be limited to depth < 25 by mempool policy
|
// recursion unavoidable, but should be limited to depth < 25 by mempool policy
|
||||||
function setRelatives(
|
function setRelatives(
|
||||||
tx: AuditTransaction,
|
tx: AuditTransaction,
|
||||||
mempool: { [txid: string]: AuditTransaction },
|
mempool: Map<number, AuditTransaction>,
|
||||||
): void {
|
): void {
|
||||||
for (const parent of tx.vin) {
|
for (const parent of tx.inputs) {
|
||||||
const parentTx = mempool[parent];
|
const parentTx = mempool.get(parent);
|
||||||
if (parentTx && !tx.ancestorMap?.has(parent)) {
|
if (parentTx && !tx.ancestorMap?.has(parent)) {
|
||||||
tx.ancestorMap.set(parent, parentTx);
|
tx.ancestorMap.set(parent, parentTx);
|
||||||
parentTx.children.add(tx);
|
parentTx.children.add(tx);
|
||||||
@ -227,7 +231,7 @@ function setRelatives(
|
|||||||
setRelatives(parentTx, mempool);
|
setRelatives(parentTx, mempool);
|
||||||
}
|
}
|
||||||
parentTx.ancestorMap.forEach((ancestor) => {
|
parentTx.ancestorMap.forEach((ancestor) => {
|
||||||
tx.ancestorMap.set(ancestor.txid, ancestor);
|
tx.ancestorMap.set(ancestor.uid, ancestor);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -245,7 +249,7 @@ function setRelatives(
|
|||||||
// avoids recursion to limit call stack depth
|
// avoids recursion to limit call stack depth
|
||||||
function updateDescendants(
|
function updateDescendants(
|
||||||
rootTx: AuditTransaction,
|
rootTx: AuditTransaction,
|
||||||
mempool: { [txid: string]: AuditTransaction },
|
mempool: Map<number, AuditTransaction>,
|
||||||
modified: PairingHeap<AuditTransaction>,
|
modified: PairingHeap<AuditTransaction>,
|
||||||
): void {
|
): void {
|
||||||
const descendantSet: Set<AuditTransaction> = new Set();
|
const descendantSet: Set<AuditTransaction> = new Set();
|
||||||
@ -261,9 +265,9 @@ function updateDescendants(
|
|||||||
});
|
});
|
||||||
while (descendants.length) {
|
while (descendants.length) {
|
||||||
descendantTx = descendants.pop();
|
descendantTx = descendants.pop();
|
||||||
if (descendantTx && descendantTx.ancestorMap && descendantTx.ancestorMap.has(rootTx.txid)) {
|
if (descendantTx && descendantTx.ancestorMap && descendantTx.ancestorMap.has(rootTx.uid)) {
|
||||||
// remove tx as ancestor
|
// remove tx as ancestor
|
||||||
descendantTx.ancestorMap.delete(rootTx.txid);
|
descendantTx.ancestorMap.delete(rootTx.uid);
|
||||||
descendantTx.ancestorFee -= rootTx.fee;
|
descendantTx.ancestorFee -= rootTx.fee;
|
||||||
descendantTx.ancestorWeight -= rootTx.weight;
|
descendantTx.ancestorWeight -= rootTx.weight;
|
||||||
tmpScore = descendantTx.score;
|
tmpScore = descendantTx.score;
|
||||||
|
@ -26,6 +26,13 @@ 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;
|
||||||
|
|
||||||
|
private initData: { [key: string]: string } = {};
|
||||||
|
private serializedInitData: string = '{}';
|
||||||
|
|
||||||
constructor() { }
|
constructor() { }
|
||||||
|
|
||||||
setWebsocketServer(wss: WebSocket.Server) {
|
setWebsocketServer(wss: WebSocket.Server) {
|
||||||
@ -34,6 +41,41 @@ class WebsocketHandler {
|
|||||||
|
|
||||||
setExtraInitProperties(property: string, value: any) {
|
setExtraInitProperties(property: string, value: any) {
|
||||||
this.extraInitProperties[property] = value;
|
this.extraInitProperties[property] = value;
|
||||||
|
this.setInitDataFields(this.extraInitProperties);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setInitDataFields(data: { [property: string]: any }): void {
|
||||||
|
for (const property of Object.keys(data)) {
|
||||||
|
if (data[property] != null) {
|
||||||
|
this.initData[property] = JSON.stringify(data[property]);
|
||||||
|
} else {
|
||||||
|
delete this.initData[property];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.serializedInitData = '{'
|
||||||
|
+ Object.keys(this.initData).map(key => `"${key}": ${this.initData[key]}`).join(', ')
|
||||||
|
+ '}';
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateInitData(): void {
|
||||||
|
const _blocks = blocks.getBlocks().slice(-config.MEMPOOL.INITIAL_BLOCKS_AMOUNT);
|
||||||
|
const da = difficultyAdjustment.getDifficultyAdjustment();
|
||||||
|
this.setInitDataFields({
|
||||||
|
'mempoolInfo': memPool.getMempoolInfo(),
|
||||||
|
'vBytesPerSecond': memPool.getVBytesPerSecond(),
|
||||||
|
'blocks': _blocks,
|
||||||
|
'conversions': priceUpdater.getLatestPrices(),
|
||||||
|
'mempool-blocks': mempoolBlocks.getMempoolBlocks(),
|
||||||
|
'transactions': memPool.getLatestTransactions(),
|
||||||
|
'backendInfo': backendInfo.getBackendInfo(),
|
||||||
|
'loadingIndicators': loadingIndicators.getLoadingIndicators(),
|
||||||
|
'da': da?.previousTime ? da : undefined,
|
||||||
|
'fees': feeApi.getRecommendedFee(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public getSerializedInitData(): string {
|
||||||
|
return this.serializedInitData;
|
||||||
}
|
}
|
||||||
|
|
||||||
setupConnectionHandling() {
|
setupConnectionHandling() {
|
||||||
@ -42,7 +84,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 +104,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 +115,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 +139,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,12 +186,22 @@ 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);
|
if (!this.initData['blocks']?.length || !this.initData['da']) {
|
||||||
if (!_blocks) {
|
this.updateInitData();
|
||||||
|
}
|
||||||
|
if (!this.initData['blocks']?.length) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
client.send(JSON.stringify(this.getInitData(_blocks)));
|
client.send(this.serializedInitData);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsedMessage.action === 'ping') {
|
if (parsedMessage.action === 'ping') {
|
||||||
@ -186,11 +250,14 @@ class WebsocketHandler {
|
|||||||
throw new Error('WebSocket.Server is not set');
|
throw new Error('WebSocket.Server is not set');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.setInitDataFields({ 'loadingIndicators': indicators });
|
||||||
|
|
||||||
|
const response = JSON.stringify({ loadingIndicators: indicators });
|
||||||
this.wss.clients.forEach((client) => {
|
this.wss.clients.forEach((client) => {
|
||||||
if (client.readyState !== WebSocket.OPEN) {
|
if (client.readyState !== WebSocket.OPEN) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
client.send(JSON.stringify({ loadingIndicators: indicators }));
|
client.send(response);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -199,39 +266,28 @@ class WebsocketHandler {
|
|||||||
throw new Error('WebSocket.Server is not set');
|
throw new Error('WebSocket.Server is not set');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.setInitDataFields({ 'conversions': conversionRates });
|
||||||
|
|
||||||
|
const response = JSON.stringify({ conversions: conversionRates });
|
||||||
this.wss.clients.forEach((client) => {
|
this.wss.clients.forEach((client) => {
|
||||||
if (client.readyState !== WebSocket.OPEN) {
|
if (client.readyState !== WebSocket.OPEN) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
client.send(JSON.stringify({ conversions: conversionRates }));
|
client.send(response);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getInitData(_blocks?: BlockExtended[]) {
|
|
||||||
if (!_blocks) {
|
|
||||||
_blocks = blocks.getBlocks().slice(-config.MEMPOOL.INITIAL_BLOCKS_AMOUNT);
|
|
||||||
}
|
|
||||||
const da = difficultyAdjustment.getDifficultyAdjustment();
|
|
||||||
return {
|
|
||||||
'mempoolInfo': memPool.getMempoolInfo(),
|
|
||||||
'vBytesPerSecond': memPool.getVBytesPerSecond(),
|
|
||||||
'blocks': _blocks,
|
|
||||||
'conversions': priceUpdater.getLatestPrices(),
|
|
||||||
'mempool-blocks': mempoolBlocks.getMempoolBlocks(),
|
|
||||||
'transactions': memPool.getLatestTransactions(),
|
|
||||||
'backendInfo': backendInfo.getBackendInfo(),
|
|
||||||
'loadingIndicators': loadingIndicators.getLoadingIndicators(),
|
|
||||||
'da': da?.previousTime ? da : undefined,
|
|
||||||
'fees': feeApi.getRecommendedFee(),
|
|
||||||
...this.extraInitProperties
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
handleNewStatistic(stats: OptimizedStatistic) {
|
handleNewStatistic(stats: OptimizedStatistic) {
|
||||||
if (!this.wss) {
|
if (!this.wss) {
|
||||||
throw new Error('WebSocket.Server is not set');
|
throw new Error('WebSocket.Server is not set');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.printLogs();
|
||||||
|
|
||||||
|
const response = JSON.stringify({
|
||||||
|
'live-2h-chart': stats
|
||||||
|
});
|
||||||
|
|
||||||
this.wss.clients.forEach((client) => {
|
this.wss.clients.forEach((client) => {
|
||||||
if (client.readyState !== WebSocket.OPEN) {
|
if (client.readyState !== WebSocket.OPEN) {
|
||||||
return;
|
return;
|
||||||
@ -241,20 +297,20 @@ class WebsocketHandler {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
client.send(JSON.stringify({
|
client.send(response);
|
||||||
'live-2h-chart': stats
|
|
||||||
}));
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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, true);
|
||||||
} else {
|
} else {
|
||||||
mempoolBlocks.updateMempoolBlocks(newMempool, true);
|
mempoolBlocks.updateMempoolBlocks(newMempool, true);
|
||||||
}
|
}
|
||||||
@ -266,8 +322,55 @@ 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);
|
||||||
|
}
|
||||||
|
for (const deletedTx of deletedTransactions) {
|
||||||
|
rbfCache.evict(deletedTx.txid);
|
||||||
|
}
|
||||||
const recommendedFees = feeApi.getRecommendedFee();
|
const recommendedFees = feeApi.getRecommendedFee();
|
||||||
|
|
||||||
|
// update init data
|
||||||
|
this.updateInitData();
|
||||||
|
|
||||||
|
// cache serialized objects to avoid stringify-ing the same thing for every client
|
||||||
|
const responseCache = { ...this.initData };
|
||||||
|
function getCachedResponse(key: string, data): string {
|
||||||
|
if (!responseCache[key]) {
|
||||||
|
responseCache[key] = JSON.stringify(data);
|
||||||
|
}
|
||||||
|
return responseCache[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
// pre-compute new tracked outspends
|
||||||
|
const outspendCache: { [txid: string]: { [vout: number]: { vin: number, txid: string } } } = {};
|
||||||
|
const trackedTxs = new Set<string>();
|
||||||
|
this.wss.clients.forEach((client) => {
|
||||||
|
if (client['track-tx']) {
|
||||||
|
trackedTxs.add(client['track-tx']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (trackedTxs.size > 0) {
|
||||||
|
for (const tx of newTransactions) {
|
||||||
|
for (let i = 0; i < tx.vin.length; i++) {
|
||||||
|
const vin = tx.vin[i];
|
||||||
|
if (trackedTxs.has(vin.txid)) {
|
||||||
|
if (!outspendCache[vin.txid]) {
|
||||||
|
outspendCache[vin.txid] = { [vin.vout]: { vin: i, txid: tx.txid }};
|
||||||
|
} else {
|
||||||
|
outspendCache[vin.txid][vin.vout] = { vin: i, txid: tx.txid };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestTransactions = newTransactions.slice(0, 6).map((tx) => Common.stripTransaction(tx));
|
||||||
|
|
||||||
this.wss.clients.forEach(async (client) => {
|
this.wss.clients.forEach(async (client) => {
|
||||||
if (client.readyState !== WebSocket.OPEN) {
|
if (client.readyState !== WebSocket.OPEN) {
|
||||||
return;
|
return;
|
||||||
@ -276,17 +379,17 @@ class WebsocketHandler {
|
|||||||
const response = {};
|
const response = {};
|
||||||
|
|
||||||
if (client['want-stats']) {
|
if (client['want-stats']) {
|
||||||
response['mempoolInfo'] = mempoolInfo;
|
response['mempoolInfo'] = getCachedResponse('mempoolInfo', mempoolInfo);
|
||||||
response['vBytesPerSecond'] = vBytesPerSecond;
|
response['vBytesPerSecond'] = getCachedResponse('vBytesPerSecond', vBytesPerSecond);
|
||||||
response['transactions'] = newTransactions.slice(0, 6).map((tx) => Common.stripTransaction(tx));
|
response['transactions'] = getCachedResponse('transactions', latestTransactions);
|
||||||
if (da?.previousTime) {
|
if (da?.previousTime) {
|
||||||
response['da'] = da;
|
response['da'] = getCachedResponse('da', da);
|
||||||
}
|
}
|
||||||
response['fees'] = recommendedFees;
|
response['fees'] = getCachedResponse('fees', recommendedFees);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (client['want-mempool-blocks']) {
|
if (client['want-mempool-blocks']) {
|
||||||
response['mempool-blocks'] = mBlocks;
|
response['mempool-blocks'] = getCachedResponse('mempool-blocks', mBlocks);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (client['track-mempool-tx']) {
|
if (client['track-mempool-tx']) {
|
||||||
@ -295,12 +398,12 @@ class WebsocketHandler {
|
|||||||
if (config.MEMPOOL.BACKEND !== 'esplora') {
|
if (config.MEMPOOL.BACKEND !== 'esplora') {
|
||||||
try {
|
try {
|
||||||
const fullTx = await transactionUtils.$getTransactionExtended(tx.txid, true);
|
const fullTx = await transactionUtils.$getTransactionExtended(tx.txid, true);
|
||||||
response['tx'] = fullTx;
|
response['tx'] = JSON.stringify(fullTx);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.debug('Error finding transaction in mempool: ' + (e instanceof Error ? e.message : e));
|
logger.debug('Error finding transaction in mempool: ' + (e instanceof Error ? e.message : e));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
response['tx'] = tx;
|
response['tx'] = JSON.stringify(tx);
|
||||||
}
|
}
|
||||||
client['track-mempool-tx'] = null;
|
client['track-mempool-tx'] = null;
|
||||||
}
|
}
|
||||||
@ -340,7 +443,7 @@ class WebsocketHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (foundTransactions.length) {
|
if (foundTransactions.length) {
|
||||||
response['address-transactions'] = foundTransactions;
|
response['address-transactions'] = JSON.stringify(foundTransactions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -369,49 +472,60 @@ class WebsocketHandler {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (foundTransactions.length) {
|
if (foundTransactions.length) {
|
||||||
response['address-transactions'] = foundTransactions;
|
response['address-transactions'] = JSON.stringify(foundTransactions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (client['track-tx']) {
|
if (client['track-tx']) {
|
||||||
const outspends: object = {};
|
const trackTxid = client['track-tx'];
|
||||||
newTransactions.forEach((tx) => tx.vin.forEach((vin, i) => {
|
const outspends = outspendCache[trackTxid];
|
||||||
if (vin.txid === client['track-tx']) {
|
|
||||||
outspends[vin.vout] = {
|
|
||||||
vin: i,
|
|
||||||
txid: tx.txid,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (Object.keys(outspends).length) {
|
if (outspends && Object.keys(outspends).length) {
|
||||||
response['utxoSpent'] = outspends;
|
response['utxoSpent'] = JSON.stringify(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'] = JSON.stringify({
|
||||||
response['rbfTransaction'] = {
|
txid: rbfReplacedBy,
|
||||||
txid: rbfTransactions[rbfTransaction].txid,
|
})
|
||||||
};
|
}
|
||||||
break;
|
|
||||||
}
|
const rbfChange = rbfChanges.map[client['track-tx']];
|
||||||
}
|
if (rbfChange) {
|
||||||
|
response['rbfInfo'] = JSON.stringify(rbfChanges.trees[rbfChange]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mempoolTx = newMempool[trackTxid];
|
||||||
|
if (mempoolTx && mempoolTx.position) {
|
||||||
|
response['txPosition'] = JSON.stringify({
|
||||||
|
txid: trackTxid,
|
||||||
|
position: mempoolTx.position,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (client['track-mempool-block'] >= 0) {
|
if (client['track-mempool-block'] >= 0) {
|
||||||
const index = client['track-mempool-block'];
|
const index = client['track-mempool-block'];
|
||||||
if (mBlockDeltas[index]) {
|
if (mBlockDeltas[index]) {
|
||||||
response['projected-block-transactions'] = {
|
response['projected-block-transactions'] = getCachedResponse(`projected-block-transactions-${index}`, {
|
||||||
index: index,
|
index: index,
|
||||||
delta: mBlockDeltas[index],
|
delta: mBlockDeltas[index],
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (client['track-rbf'] === 'all' && rbfReplacements) {
|
||||||
|
response['rbfLatest'] = getCachedResponse('rbfLatest', rbfReplacements);
|
||||||
|
} else if (client['track-rbf'] === 'fullRbf' && fullRbfReplacements) {
|
||||||
|
response['rbfLatest'] = getCachedResponse('fullrbfLatest', fullRbfReplacements);
|
||||||
|
}
|
||||||
|
|
||||||
if (Object.keys(response).length) {
|
if (Object.keys(response).length) {
|
||||||
client.send(JSON.stringify(response));
|
const serializedResponse = '{'
|
||||||
|
+ Object.keys(response).map(key => `"${key}": ${response[key]}`).join(', ')
|
||||||
|
+ '}';
|
||||||
|
client.send(serializedResponse);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -421,17 +535,25 @@ 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 (config.MEMPOOL.ADVANCED_GBT_AUDIT) {
|
if (separateAudit) {
|
||||||
projectedBlocks = await mempoolBlocks.makeBlockTemplates(auditMempool, false);
|
auditMempool = deepClone(_memPool);
|
||||||
|
if (config.MEMPOOL.ADVANCED_GBT_AUDIT) {
|
||||||
|
projectedBlocks = await mempoolBlocks.$makeBlockTemplates(auditMempool, false);
|
||||||
|
} else {
|
||||||
|
projectedBlocks = mempoolBlocks.updateMempoolBlocks(auditMempool, false);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
projectedBlocks = mempoolBlocks.updateMempoolBlocks(auditMempool, false);
|
projectedBlocks = mempoolBlocks.getMempoolBlocksWithTransactions();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Common.indexingEnabled() && memPool.isInSync()) {
|
if (Common.indexingEnabled() && memPool.isInSync()) {
|
||||||
@ -477,16 +599,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);
|
||||||
}
|
}
|
||||||
@ -496,6 +616,19 @@ class WebsocketHandler {
|
|||||||
const da = difficultyAdjustment.getDifficultyAdjustment();
|
const da = difficultyAdjustment.getDifficultyAdjustment();
|
||||||
const fees = feeApi.getRecommendedFee();
|
const fees = feeApi.getRecommendedFee();
|
||||||
|
|
||||||
|
// update init data
|
||||||
|
this.updateInitData();
|
||||||
|
|
||||||
|
const responseCache = { ...this.initData };
|
||||||
|
function getCachedResponse(key, data): string {
|
||||||
|
if (!responseCache[key]) {
|
||||||
|
responseCache[key] = JSON.stringify(data);
|
||||||
|
}
|
||||||
|
return responseCache[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
const mempoolInfo = memPool.getMempoolInfo();
|
||||||
|
|
||||||
this.wss.clients.forEach((client) => {
|
this.wss.clients.forEach((client) => {
|
||||||
if (client.readyState !== WebSocket.OPEN) {
|
if (client.readyState !== WebSocket.OPEN) {
|
||||||
return;
|
return;
|
||||||
@ -505,19 +638,29 @@ class WebsocketHandler {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = {
|
const response = {};
|
||||||
'block': block,
|
response['block'] = getCachedResponse('block', block);
|
||||||
'mempoolInfo': memPool.getMempoolInfo(),
|
response['mempoolInfo'] = getCachedResponse('mempoolInfo', mempoolInfo);
|
||||||
'da': da?.previousTime ? da : undefined,
|
response['da'] = getCachedResponse('da', da?.previousTime ? da : undefined);
|
||||||
'fees': fees,
|
response['fees'] = getCachedResponse('fees', fees);
|
||||||
};
|
|
||||||
|
|
||||||
if (mBlocks && client['want-mempool-blocks']) {
|
if (mBlocks && client['want-mempool-blocks']) {
|
||||||
response['mempool-blocks'] = mBlocks;
|
response['mempool-blocks'] = getCachedResponse('mempool-blocks', mBlocks);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (client['track-tx'] && txIds.indexOf(client['track-tx']) > -1) {
|
if (client['track-tx']) {
|
||||||
response['txConfirmed'] = true;
|
const trackTxid = client['track-tx'];
|
||||||
|
if (txIds.indexOf(trackTxid) > -1) {
|
||||||
|
response['txConfirmed'] = 'true';
|
||||||
|
} else {
|
||||||
|
const mempoolTx = _memPool[trackTxid];
|
||||||
|
if (mempoolTx && mempoolTx.position) {
|
||||||
|
response['txPosition'] = JSON.stringify({
|
||||||
|
txid: trackTxid,
|
||||||
|
position: mempoolTx.position,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (client['track-address']) {
|
if (client['track-address']) {
|
||||||
@ -543,7 +686,7 @@ class WebsocketHandler {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
response['block-transactions'] = foundTransactions;
|
response['block-transactions'] = JSON.stringify(foundTransactions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -580,23 +723,37 @@ class WebsocketHandler {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
response['block-transactions'] = foundTransactions;
|
response['block-transactions'] = JSON.stringify(foundTransactions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (client['track-mempool-block'] >= 0) {
|
if (client['track-mempool-block'] >= 0) {
|
||||||
const index = client['track-mempool-block'];
|
const index = client['track-mempool-block'];
|
||||||
if (mBlockDeltas && mBlockDeltas[index]) {
|
if (mBlockDeltas && mBlockDeltas[index]) {
|
||||||
response['projected-block-transactions'] = {
|
response['projected-block-transactions'] = getCachedResponse(`projected-block-transactions-${index}`, {
|
||||||
index: index,
|
index: index,
|
||||||
delta: mBlockDeltas[index],
|
delta: mBlockDeltas[index],
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
client.send(JSON.stringify(response));
|
const serializedResponse = '{'
|
||||||
|
+ Object.keys(response).map(key => `"${key}": ${response[key]}`).join(', ')
|
||||||
|
+ '}';
|
||||||
|
client.send(serializedResponse);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
@ -37,6 +37,8 @@ interface IConfig {
|
|||||||
};
|
};
|
||||||
ESPLORA: {
|
ESPLORA: {
|
||||||
REST_API_URL: string;
|
REST_API_URL: string;
|
||||||
|
UNIX_SOCKET_PATH: string | void | null;
|
||||||
|
RETRY_UNIX_SOCKET_AFTER: number;
|
||||||
};
|
};
|
||||||
LIGHTNING: {
|
LIGHTNING: {
|
||||||
ENABLED: boolean;
|
ENABLED: boolean;
|
||||||
@ -84,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;
|
||||||
@ -163,6 +166,8 @@ 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,
|
||||||
|
'RETRY_UNIX_SOCKET_AFTER': 30000,
|
||||||
},
|
},
|
||||||
'ELECTRUM': {
|
'ELECTRUM': {
|
||||||
'HOST': '127.0.0.1',
|
'HOST': '127.0.0.1',
|
||||||
@ -190,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,
|
||||||
|
@ -33,8 +33,32 @@ 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();
|
||||||
const pool = await this.getPool();
|
let hardTimeout;
|
||||||
return pool.query(query, params);
|
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();
|
||||||
|
return pool.query(query, params);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async checkDbConnection() {
|
public async checkDbConnection() {
|
||||||
|
@ -2,6 +2,7 @@ import express from 'express';
|
|||||||
import { Application, Request, Response, NextFunction } from 'express';
|
import { Application, Request, Response, NextFunction } from 'express';
|
||||||
import * as http from 'http';
|
import * as http from 'http';
|
||||||
import * as WebSocket from 'ws';
|
import * as WebSocket from 'ws';
|
||||||
|
import bitcoinApi from './api/bitcoin/bitcoin-api-factory';
|
||||||
import cluster from 'cluster';
|
import cluster from 'cluster';
|
||||||
import DB from './database';
|
import DB from './database';
|
||||||
import config from './config';
|
import config from './config';
|
||||||
@ -45,7 +46,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 +122,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 +180,26 @@ class Server {
|
|||||||
logger.debug(msg);
|
logger.debug(msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
memPool.deleteExpiredTransactions();
|
const newMempool = await bitcoinApi.$getRawMempool();
|
||||||
await blocks.$updateBlocks();
|
const numHandledBlocks = await blocks.$updateBlocks();
|
||||||
await memPool.$updateMempool();
|
if (numHandledBlocks === 0) {
|
||||||
|
await memPool.$updateMempool(newMempool);
|
||||||
|
}
|
||||||
indexer.$run();
|
indexer.$run();
|
||||||
|
|
||||||
setTimeout(this.runMainUpdateLoop.bind(this), config.MEMPOOL.POLL_RATE_MS);
|
// rerun immediately if we skipped the mempool update, otherwise wait POLL_RATE_MS
|
||||||
this.currentBackendRetryInterval = 5;
|
setTimeout(this.runMainUpdateLoop.bind(this), numHandledBlocks > 0 ? 1 : config.MEMPOOL.POLL_RATE_MS);
|
||||||
|
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 +209,8 @@ 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;
|
} finally {
|
||||||
this.currentBackendRetryInterval = Math.min(this.currentBackendRetryInterval, 60);
|
diskCache.unlock();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -238,7 +243,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));
|
||||||
@ -276,7 +281,7 @@ class Server {
|
|||||||
|
|
||||||
if (!this.warnedHeapCritical && this.maxHeapSize > warnThreshold) {
|
if (!this.warnedHeapCritical && this.maxHeapSize > warnThreshold) {
|
||||||
this.warnedHeapCritical = true;
|
this.warnedHeapCritical = true;
|
||||||
logger.warn(`Used ${(this.maxHeapSize / stats.heap_size_limit).toFixed(2)}% of heap limit (${formatBytes(this.maxHeapSize, byteUnits, true)} / ${formatBytes(stats.heap_size_limit, byteUnits)})!`);
|
logger.warn(`Used ${(this.maxHeapSize / stats.heap_size_limit * 100).toFixed(2)}% of heap limit (${formatBytes(this.maxHeapSize, byteUnits, true)} / ${formatBytes(stats.heap_size_limit, byteUnits)})!`);
|
||||||
}
|
}
|
||||||
if (this.lastHeapLogTime === null || (now - this.lastHeapLogTime) > (this.heapLogInterval * 1000)) {
|
if (this.lastHeapLogTime === null || (now - this.lastHeapLogTime) > (this.heapLogInterval * 1000)) {
|
||||||
logger.debug(`Memory usage: ${formatBytes(this.maxHeapSize, byteUnits)} / ${formatBytes(stats.heap_size_limit, byteUnits)}`);
|
logger.debug(`Memory usage: ${formatBytes(this.maxHeapSize, byteUnits)} / ${formatBytes(stats.heap_size_limit, byteUnits)}`);
|
||||||
|
@ -58,6 +58,7 @@ export interface MempoolBlockWithTransactions extends MempoolBlock {
|
|||||||
export interface MempoolBlockDelta {
|
export interface MempoolBlockDelta {
|
||||||
added: TransactionStripped[];
|
added: TransactionStripped[];
|
||||||
removed: string[];
|
removed: string[];
|
||||||
|
changed: { txid: string, rate: number | undefined }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VinStrippedToScriptsig {
|
interface VinStrippedToScriptsig {
|
||||||
@ -79,18 +80,22 @@ export interface TransactionExtended extends IEsploraApi.Transaction {
|
|||||||
descendants?: Ancestor[];
|
descendants?: Ancestor[];
|
||||||
bestDescendant?: BestDescendant | null;
|
bestDescendant?: BestDescendant | null;
|
||||||
cpfpChecked?: boolean;
|
cpfpChecked?: boolean;
|
||||||
deleteAfter?: number;
|
position?: {
|
||||||
|
block: number,
|
||||||
|
vsize: number,
|
||||||
|
};
|
||||||
|
uid?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuditTransaction {
|
export interface AuditTransaction {
|
||||||
txid: string;
|
uid: number;
|
||||||
fee: number;
|
fee: number;
|
||||||
weight: number;
|
weight: number;
|
||||||
feePerVsize: number;
|
feePerVsize: number;
|
||||||
effectiveFeePerVsize: number;
|
effectiveFeePerVsize: number;
|
||||||
vin: string[];
|
inputs: number[];
|
||||||
relativesSet: boolean;
|
relativesSet: boolean;
|
||||||
ancestorMap: Map<string, AuditTransaction>;
|
ancestorMap: Map<number, AuditTransaction>;
|
||||||
children: Set<AuditTransaction>;
|
children: Set<AuditTransaction>;
|
||||||
ancestorFee: number;
|
ancestorFee: number;
|
||||||
ancestorWeight: number;
|
ancestorWeight: number;
|
||||||
@ -100,13 +105,25 @@ export interface AuditTransaction {
|
|||||||
modifiedNode: HeapNode<AuditTransaction>;
|
modifiedNode: HeapNode<AuditTransaction>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CompactThreadTransaction {
|
||||||
|
uid: number;
|
||||||
|
fee: number;
|
||||||
|
weight: number;
|
||||||
|
feePerVsize: number;
|
||||||
|
effectiveFeePerVsize?: number;
|
||||||
|
inputs: number[];
|
||||||
|
cpfpRoot?: string;
|
||||||
|
cpfpChecked?: boolean;
|
||||||
|
dirty?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ThreadTransaction {
|
export interface ThreadTransaction {
|
||||||
txid: string;
|
txid: string;
|
||||||
fee: number;
|
fee: number;
|
||||||
weight: number;
|
weight: number;
|
||||||
feePerVsize: number;
|
feePerVsize: number;
|
||||||
effectiveFeePerVsize?: number;
|
effectiveFeePerVsize?: number;
|
||||||
vin: string[];
|
inputs: number[];
|
||||||
cpfpRoot?: string;
|
cpfpRoot?: string;
|
||||||
cpfpChecked?: boolean;
|
cpfpChecked?: boolean;
|
||||||
}
|
}
|
||||||
@ -145,6 +162,7 @@ export interface TransactionStripped {
|
|||||||
fee: number;
|
fee: number;
|
||||||
vsize: number;
|
vsize: number;
|
||||||
value: number;
|
value: number;
|
||||||
|
rate?: number; // effective fee rate
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BlockExtension {
|
export interface BlockExtension {
|
||||||
|
@ -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,37 +527,13 @@ 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get one block by hash
|
|
||||||
*/
|
|
||||||
public async $getBlockByHash(hash: string): Promise<object | null> {
|
|
||||||
try {
|
|
||||||
const query = `
|
|
||||||
SELECT ${BLOCK_DB_FIELDS}
|
|
||||||
FROM blocks
|
|
||||||
JOIN pools ON blocks.pool_id = pools.id
|
|
||||||
WHERE hash = ?;
|
|
||||||
`;
|
|
||||||
const [rows]: any[] = await DB.query(query, [hash]);
|
|
||||||
|
|
||||||
if (rows.length <= 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return await this.formatDbBlockIntoExtendedBlock(rows[0]);
|
|
||||||
} catch (e) {
|
|
||||||
logger.err(`Cannot get indexed block ${hash}. Reason: ` + (e instanceof Error ? e.message : e));
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return blocks difficulty
|
* Return blocks difficulty
|
||||||
*/
|
*/
|
||||||
@ -599,7 +643,6 @@ class BlocksRepository {
|
|||||||
if (blocks[idx].previous_block_hash !== blocks[idx - 1].hash) {
|
if (blocks[idx].previous_block_hash !== blocks[idx - 1].hash) {
|
||||||
logger.warn(`Chain divergence detected at block ${blocks[idx - 1].height}`);
|
logger.warn(`Chain divergence detected at block ${blocks[idx - 1].height}`);
|
||||||
await this.$deleteBlocksFrom(blocks[idx - 1].height);
|
await this.$deleteBlocksFrom(blocks[idx - 1].height);
|
||||||
await BlocksSummariesRepository.$deleteBlocksFrom(blocks[idx - 1].height);
|
|
||||||
await HashratesRepository.$deleteHashratesFromTimestamp(blocks[idx - 1].timestamp - 604800);
|
await HashratesRepository.$deleteHashratesFromTimestamp(blocks[idx - 1].timestamp - 604800);
|
||||||
await DifficultyAdjustmentsRepository.$deleteAdjustementsFromHeight(blocks[idx - 1].height);
|
await DifficultyAdjustmentsRepository.$deleteAdjustementsFromHeight(blocks[idx - 1].height);
|
||||||
return false;
|
return false;
|
||||||
@ -619,7 +662,7 @@ class BlocksRepository {
|
|||||||
* Delete blocks from the database from blockHeight
|
* Delete blocks from the database from blockHeight
|
||||||
*/
|
*/
|
||||||
public async $deleteBlocksFrom(blockHeight: number) {
|
public async $deleteBlocksFrom(blockHeight: number) {
|
||||||
logger.info(`Delete newer blocks from height ${blockHeight} from the database`);
|
logger.info(`Delete newer blocks from height ${blockHeight} from the database`, logger.tags.mining);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await DB.query(`DELETE FROM blocks where height >= ${blockHeight}`);
|
await DB.query(`DELETE FROM blocks where height >= ${blockHeight}`);
|
||||||
@ -933,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> = {};
|
||||||
|
|
||||||
@ -997,6 +1040,7 @@ class BlocksRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If we're missing block summary related field, check if we can populate them on the fly now
|
// If we're missing block summary related field, check if we can populate them on the fly now
|
||||||
|
// This is for example triggered upon re-org
|
||||||
if (Common.blocksSummariesIndexingEnabled() &&
|
if (Common.blocksSummariesIndexingEnabled() &&
|
||||||
(extras.medianFeeAmt === null || extras.feePercentiles === null))
|
(extras.medianFeeAmt === null || extras.feePercentiles === null))
|
||||||
{
|
{
|
||||||
@ -1004,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.$saveSummary({ height: block.height, mined: summary });
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import DB from '../database';
|
import DB from '../database';
|
||||||
import logger from '../logger';
|
import logger from '../logger';
|
||||||
import { BlockSummary } from '../mempool.interfaces';
|
import { BlockSummary, TransactionStripped } from '../mempool.interfaces';
|
||||||
|
|
||||||
class BlocksSummariesRepository {
|
class BlocksSummariesRepository {
|
||||||
public async $getByBlockId(id: string): Promise<BlockSummary | undefined> {
|
public async $getByBlockId(id: string): Promise<BlockSummary | undefined> {
|
||||||
@ -17,23 +17,17 @@ class BlocksSummariesRepository {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async $saveSummary(params: { height: number, mined?: BlockSummary}) {
|
public async $saveTransactions(blockHeight: number, blockId: string, transactions: TransactionStripped[]): Promise<void> {
|
||||||
const blockId = params.mined?.id;
|
|
||||||
try {
|
try {
|
||||||
const transactions = JSON.stringify(params.mined?.transactions || []);
|
const transactionsStr = JSON.stringify(transactions);
|
||||||
await DB.query(`
|
await DB.query(`
|
||||||
INSERT INTO blocks_summaries (height, id, transactions, template)
|
INSERT INTO blocks_summaries
|
||||||
VALUE (?, ?, ?, ?)
|
SET height = ?, transactions = ?, id = ?
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE transactions = ?`,
|
||||||
transactions = ?
|
[blockHeight, transactionsStr, blockId, transactionsStr]);
|
||||||
`, [params.height, blockId, transactions, '[]', transactions]);
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e.errno === 1062) { // ER_DUP_ENTRY - This scenario is possible upon node backend restart
|
logger.debug(`Cannot save block summary transactions for ${blockId}. Reason: ${e instanceof Error ? e.message : e}`);
|
||||||
logger.debug(`Cannot save block summary for ${blockId} because it has already been indexed, ignoring`);
|
throw e;
|
||||||
} else {
|
|
||||||
logger.debug(`Cannot save block summary for ${blockId}. Reason: ${e instanceof Error ? e.message : e}`);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -68,19 +62,6 @@ class BlocksSummariesRepository {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete blocks from the database from blockHeight
|
|
||||||
*/
|
|
||||||
public async $deleteBlocksFrom(blockHeight: number) {
|
|
||||||
logger.info(`Delete newer blocks summary from height ${blockHeight} from the database`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await DB.query(`DELETE FROM blocks_summaries where height >= ${blockHeight}`);
|
|
||||||
} catch (e) {
|
|
||||||
logger.err('Cannot delete indexed blocks summaries. Reason: ' + (e instanceof Error ? e.message : e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the fee percentiles if the block has already been indexed, [] otherwise
|
* Get the fee percentiles if the block has already been indexed, [] otherwise
|
||||||
*
|
*
|
||||||
|
@ -220,7 +220,7 @@ class HashratesRepository {
|
|||||||
* Delete hashrates from the database from timestamp
|
* Delete hashrates from the database from timestamp
|
||||||
*/
|
*/
|
||||||
public async $deleteHashratesFromTimestamp(timestamp: number) {
|
public async $deleteHashratesFromTimestamp(timestamp: number) {
|
||||||
logger.info(`Delete newer hashrates from timestamp ${new Date(timestamp * 1000).toUTCString()} from the database`);
|
logger.info(`Delete newer hashrates from timestamp ${new Date(timestamp * 1000).toUTCString()} from the database`, logger.tags.mining);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await DB.query(`DELETE FROM hashrates WHERE hashrate_timestamp >= FROM_UNIXTIME(?)`, [timestamp]);
|
await DB.query(`DELETE FROM hashrates WHERE hashrate_timestamp >= FROM_UNIXTIME(?)`, [timestamp]);
|
||||||
|
@ -160,7 +160,7 @@ class PricesRepository {
|
|||||||
|
|
||||||
// Compute fiat exchange rates
|
// Compute fiat exchange rates
|
||||||
let latestPrice = rates[0] as ApiPrice;
|
let latestPrice = rates[0] as ApiPrice;
|
||||||
if (latestPrice.USD === -1) {
|
if (!latestPrice || latestPrice.USD === -1) {
|
||||||
latestPrice = priceUpdater.getEmptyPricesObj();
|
latestPrice = priceUpdater.getEmptyPricesObj();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -27,7 +27,7 @@ class ForensicsService {
|
|||||||
|
|
||||||
private async $runTasks(): Promise<void> {
|
private async $runTasks(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
logger.info(`Running forensics scans`);
|
logger.debug(`Running forensics scans`);
|
||||||
|
|
||||||
if (config.MEMPOOL.BACKEND === 'esplora') {
|
if (config.MEMPOOL.BACKEND === 'esplora') {
|
||||||
await this.$runClosedChannelsForensics(false);
|
await this.$runClosedChannelsForensics(false);
|
||||||
@ -73,7 +73,7 @@ class ForensicsService {
|
|||||||
let progress = 0;
|
let progress = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
logger.info(`Started running closed channel forensics...`);
|
logger.debug(`Started running closed channel forensics...`);
|
||||||
let channels;
|
let channels;
|
||||||
if (onlyNewChannels) {
|
if (onlyNewChannels) {
|
||||||
channels = await channelsApi.$getClosedChannelsWithoutReason();
|
channels = await channelsApi.$getClosedChannelsWithoutReason();
|
||||||
@ -152,11 +152,11 @@ 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logger.info(`Closed channels forensics scan complete.`);
|
logger.debug(`Closed channels forensics scan complete.`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.err('$runClosedChannelsForensics() error: ' + (e instanceof Error ? e.message : e));
|
logger.err('$runClosedChannelsForensics() error: ' + (e instanceof Error ? e.message : e));
|
||||||
}
|
}
|
||||||
@ -217,7 +217,7 @@ class ForensicsService {
|
|||||||
let progress = 0;
|
let progress = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
logger.info(`Started running open channel forensics...`);
|
logger.debug(`Started running open channel forensics...`);
|
||||||
const channels = await channelsApi.$getChannelsWithoutSourceChecked();
|
const channels = await channelsApi.$getChannelsWithoutSourceChecked();
|
||||||
|
|
||||||
for (const openChannel of channels) {
|
for (const openChannel of channels) {
|
||||||
@ -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();
|
||||||
}
|
}
|
||||||
@ -266,7 +266,7 @@ class ForensicsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Open channels forensics scan complete.`);
|
logger.debug(`Open channels forensics scan complete.`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.err('$runOpenedChannelsForensics() error: ' + (e instanceof Error ? e.message : e));
|
logger.err('$runOpenedChannelsForensics() error: ' + (e instanceof Error ? e.message : e));
|
||||||
} finally {
|
} finally {
|
||||||
|
@ -287,7 +287,7 @@ class NetworkSyncService {
|
|||||||
} else {
|
} else {
|
||||||
log += ` for the first time`;
|
log += ` for the first time`;
|
||||||
}
|
}
|
||||||
logger.info(`${log}`, logger.tags.ln);
|
logger.debug(`${log}`, logger.tags.ln);
|
||||||
|
|
||||||
const channels = await channelsApi.$getChannelsByStatus([0, 1]);
|
const channels = await channelsApi.$getChannelsByStatus([0, 1]);
|
||||||
for (const channel of channels) {
|
for (const channel of channels) {
|
||||||
@ -304,7 +304,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -15,16 +15,20 @@ class LightningStatsImporter {
|
|||||||
topologiesFolder = config.LIGHTNING.TOPOLOGY_FOLDER;
|
topologiesFolder = config.LIGHTNING.TOPOLOGY_FOLDER;
|
||||||
|
|
||||||
async $run(): Promise<void> {
|
async $run(): Promise<void> {
|
||||||
const [channels]: any[] = await DB.query('SELECT short_id from channels;');
|
try {
|
||||||
logger.info(`Caching funding txs for currently existing channels`, logger.tags.ln);
|
const [channels]: any[] = await DB.query('SELECT short_id from channels;');
|
||||||
await fundingTxFetcher.$fetchChannelsFundingTxs(channels.map(channel => channel.short_id));
|
logger.info(`Caching funding txs for currently existing channels`, logger.tags.ln);
|
||||||
|
await fundingTxFetcher.$fetchChannelsFundingTxs(channels.map(channel => channel.short_id));
|
||||||
|
|
||||||
if (config.MEMPOOL.NETWORK !== 'mainnet' || config.DATABASE.ENABLED === false) {
|
if (config.MEMPOOL.NETWORK !== 'mainnet' || config.DATABASE.ENABLED === false) {
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.$importHistoricalLightningStats();
|
||||||
|
await this.$cleanupIncorrectSnapshot();
|
||||||
|
} catch (e) {
|
||||||
|
logger.err(`Exception in LightningStatsImporter::$run(). ${e}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.$importHistoricalLightningStats();
|
|
||||||
await this.$cleanupIncorrectSnapshot();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -62,7 +62,7 @@ class PoolsUpdater {
|
|||||||
if (this.currentSha === null) {
|
if (this.currentSha === null) {
|
||||||
logger.info(`Downloading pools-v2.json for the first time from ${this.poolsUrl} over ${network}`, logger.tags.mining);
|
logger.info(`Downloading pools-v2.json for the first time from ${this.poolsUrl} over ${network}`, logger.tags.mining);
|
||||||
} else {
|
} else {
|
||||||
logger.warn(`pools-v2.json is outdated, fetch latest from ${this.poolsUrl} over ${network}`, logger.tags.mining);
|
logger.warn(`pools-v2.json is outdated, fetching latest from ${this.poolsUrl} over ${network}`, logger.tags.mining);
|
||||||
}
|
}
|
||||||
const poolsJson = await this.query(this.poolsUrl);
|
const poolsJson = await this.query(this.poolsUrl);
|
||||||
if (poolsJson === undefined) {
|
if (poolsJson === undefined) {
|
||||||
|
@ -222,7 +222,7 @@ class PriceUpdater {
|
|||||||
private async $insertMissingRecentPrices(type: 'hour' | 'day'): Promise<void> {
|
private async $insertMissingRecentPrices(type: 'hour' | 'day'): Promise<void> {
|
||||||
const existingPriceTimes = await PricesRepository.$getPricesTimes();
|
const existingPriceTimes = await PricesRepository.$getPricesTimes();
|
||||||
|
|
||||||
logger.info(`Fetching ${type === 'day' ? 'dai' : 'hour'}ly price history from exchanges and saving missing ones into the database`, logger.tags.mining);
|
logger.debug(`Fetching ${type === 'day' ? 'dai' : 'hour'}ly price history from exchanges and saving missing ones into the database`, logger.tags.mining);
|
||||||
|
|
||||||
const historicalPrices: PriceHistory[] = [];
|
const historicalPrices: PriceHistory[] = [];
|
||||||
|
|
||||||
|
@ -5,6 +5,7 @@
|
|||||||
"types": ["node", "jest"],
|
"types": ["node", "jest"],
|
||||||
"lib": ["es2019", "dom"],
|
"lib": ["es2019", "dom"],
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
"noImplicitAny": false,
|
"noImplicitAny": false,
|
||||||
"sourceMap": false,
|
"sourceMap": false,
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
|
@ -204,7 +204,9 @@ Corresponding `docker-compose.yml` overrides:
|
|||||||
`mempool-config.json`:
|
`mempool-config.json`:
|
||||||
```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",
|
||||||
|
"RETRY_UNIX_SOCKET_AFTER": 30000
|
||||||
},
|
},
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -213,6 +215,8 @@ Corresponding `docker-compose.yml` overrides:
|
|||||||
api:
|
api:
|
||||||
environment:
|
environment:
|
||||||
ESPLORA_REST_API_URL: ""
|
ESPLORA_REST_API_URL: ""
|
||||||
|
ESPLORA_UNIX_SOCKET_PATH: ""
|
||||||
|
ESPLORA_RETRY_UNIX_SOCKET_AFTER: ""
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -265,6 +269,7 @@ Corresponding `docker-compose.yml` overrides:
|
|||||||
DATABASE_DATABASE: ""
|
DATABASE_DATABASE: ""
|
||||||
DATABASE_USERNAME: ""
|
DATABASE_USERNAME: ""
|
||||||
DATABASE_PASSWORD: ""
|
DATABASE_PASSWORD: ""
|
||||||
|
DATABASE_TIMEOUT: ""
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
@ -42,7 +42,9 @@
|
|||||||
"TLS_ENABLED": __ELECTRUM_TLS_ENABLED__
|
"TLS_ENABLED": __ELECTRUM_TLS_ENABLED__
|
||||||
},
|
},
|
||||||
"ESPLORA": {
|
"ESPLORA": {
|
||||||
"REST_API_URL": "__ESPLORA_REST_API_URL__"
|
"REST_API_URL": "__ESPLORA_REST_API_URL__",
|
||||||
|
"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__",
|
||||||
@ -58,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__,
|
||||||
|
@ -46,6 +46,8 @@ __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_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}
|
||||||
@ -62,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}
|
||||||
@ -166,6 +169,8 @@ sed -i "s/__ELECTRUM_PORT__/${__ELECTRUM_PORT__}/g" mempool-config.json
|
|||||||
sed -i "s/__ELECTRUM_TLS_ENABLED__/${__ELECTRUM_TLS_ENABLED__}/g" mempool-config.json
|
sed -i "s/__ELECTRUM_TLS_ENABLED__/${__ELECTRUM_TLS_ENABLED__}/g" mempool-config.json
|
||||||
|
|
||||||
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_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
|
||||||
|
@ -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)
|
||||||
|
@ -127,7 +127,7 @@ describe('Mainnet', () => {
|
|||||||
|
|
||||||
cy.get('.search-box-container > .form-control').type('S').then(() => {
|
cy.get('.search-box-container > .form-control').type('S').then(() => {
|
||||||
cy.wait('@search-1wizS');
|
cy.wait('@search-1wizS');
|
||||||
cy.get('app-search-results button.dropdown-item').should('have.length', 5);
|
cy.get('app-search-results button.dropdown-item').should('have.length', 6);
|
||||||
});
|
});
|
||||||
|
|
||||||
cy.get('.search-box-container > .form-control').type('A').then(() => {
|
cy.get('.search-box-container > .form-control').type('A').then(() => {
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -4,6 +4,8 @@ import { AppPreloadingStrategy } from './app.preloading-strategy'
|
|||||||
import { StartComponent } from './components/start/start.component';
|
import { StartComponent } from './components/start/start.component';
|
||||||
import { TransactionComponent } from './components/transaction/transaction.component';
|
import { TransactionComponent } from './components/transaction/transaction.component';
|
||||||
import { BlockComponent } from './components/block/block.component';
|
import { BlockComponent } from './components/block/block.component';
|
||||||
|
import { ClockMinedComponent as ClockMinedComponent } from './components/clock/clock-mined.component';
|
||||||
|
import { ClockMempoolComponent as ClockMempoolComponent } from './components/clock/clock-mempool.component';
|
||||||
import { AddressComponent } from './components/address/address.component';
|
import { AddressComponent } from './components/address/address.component';
|
||||||
import { MasterPageComponent } from './components/master-page/master-page.component';
|
import { MasterPageComponent } from './components/master-page/master-page.component';
|
||||||
import { AboutComponent } from './components/about/about.component';
|
import { AboutComponent } from './components/about/about.component';
|
||||||
@ -14,6 +16,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 +59,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 +169,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 +275,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
|
||||||
@ -342,6 +357,14 @@ let routes: Routes = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'clock-mined',
|
||||||
|
component: ClockMinedComponent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'clock-mempool',
|
||||||
|
component: ClockMempoolComponent,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'status',
|
path: 'status',
|
||||||
data: { networks: ['bitcoin', 'liquid'] },
|
data: { networks: ['bitcoin', 'liquid'] },
|
||||||
|
@ -29,6 +29,14 @@ export const mempoolFeeColors = [
|
|||||||
'ba3243',
|
'ba3243',
|
||||||
'b92b48',
|
'b92b48',
|
||||||
'b9254b',
|
'b9254b',
|
||||||
|
'b8214d',
|
||||||
|
'b71d4f',
|
||||||
|
'b61951',
|
||||||
|
'b41453',
|
||||||
|
'b30e55',
|
||||||
|
'b10857',
|
||||||
|
'b00259',
|
||||||
|
'ae005b',
|
||||||
];
|
];
|
||||||
|
|
||||||
export const chartColors = [
|
export const chartColors = [
|
||||||
@ -69,6 +77,7 @@ export const chartColors = [
|
|||||||
"#3E2723",
|
"#3E2723",
|
||||||
"#212121",
|
"#212121",
|
||||||
"#263238",
|
"#263238",
|
||||||
|
"#801313",
|
||||||
];
|
];
|
||||||
|
|
||||||
export const poolsColor = {
|
export const poolsColor = {
|
||||||
|
@ -107,12 +107,7 @@
|
|||||||
|
|
||||||
<app-language-selector></app-language-selector>
|
<app-language-selector></app-language-selector>
|
||||||
|
|
||||||
<div class="text-small text-center mt-3">
|
<app-global-footer></app-global-footer>
|
||||||
<a [routerLink]="['/terms-of-service']" i18n="shared.terms-of-service|Terms of Service">Terms of Service</a>
|
|
||||||
|
|
|
||||||
<a [routerLink]="['/privacy-policy']" i18n="shared.privacy-policy|Privacy Policy">Privacy Policy</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ng-template #loadingTmpl>
|
<ng-template #loadingTmpl>
|
||||||
|
@ -107,22 +107,7 @@
|
|||||||
<span>Blockstream</span>
|
<span>Blockstream</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://unchained.com/" target="_blank" title="Unchained">
|
<a href="https://unchained.com/" target="_blank" title="Unchained">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" x="0px" y="0px" viewBox="0 0 216 216" class="image" style="enable-background:new 0 0 216 216;">
|
<svg id="Layer_1" width="78" height="78" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 156.68 156.68"><defs><style>.cls-unchained-1{fill:#fff;}</style></defs><path class="cls-unchained-1" d="m78.34,0C35.07,0,0,35.07,0,78.34s35.07,78.34,78.34,78.34,78.34-35.07,78.34-78.34S121.6,0,78.34,0ZM20.23,109.5c-4.99-9.28-7.81-19.89-7.81-31.16C12.42,41.93,41.93,12.42,78.34,12.42c33.15,0,60.58,24.46,65.23,56.32h-37.48c-45.29,0-71.19,20.05-85.85,40.76Zm58.11,34.76c-12.42,0-24.04-3.44-33.96-9.41,3.94-8.85,9.11-18.7,15.84-28.9,20.99-31.8,52.2-31.19,76.49-31.19h7.45c.06,1.18.1,2.38.1,3.58,0,36.41-29.51,65.92-65.92,65.92Z"/><path class="cls-unchained-1" d="m91.98,42.4l-3.62-1.18c-3.94-1.29-7.03-4.38-8.32-8.32l-1.18-3.63c-.13-.39-.68-.39-.81,0l-1.18,3.63c-1.29,3.94-4.38,7.03-8.32,8.32l-3.62,1.18c-.39.13-.39.68,0,.81l3.62,1.18c3.94,1.29,7.03,4.38,8.32,8.32l1.18,3.63c.13.39.68.39.81,0l1.18-3.63c1.29-3.94,4.38-7.03,8.32-8.32l3.62-1.18c.39-.13.39-.68,0-.81Z"/></svg>
|
||||||
<style type="text/css">
|
|
||||||
.ucst0{fill:#002248;}
|
|
||||||
.ucst1{opacity:0.5;fill:#FFFFFF;}
|
|
||||||
.ucst2{fill:#FFFFFF;}
|
|
||||||
.ucst3{opacity:0.75;fill:#FFFFFF;}
|
|
||||||
</style>
|
|
||||||
<rect class="ucst0" width="216" height="216"/>
|
|
||||||
<g>
|
|
||||||
<g>
|
|
||||||
<path class="ucst1" d="M108,39.5V108l59.3,34.2V73.8L108,39.5z M126.9,95.4c0,2,1.1,3.8,2.8,4.8l27.9,16l0,10.8L125,108.2c-4.6-2.6-7.4-7.5-7.4-12.8l-0.1-22.7c0-1.9,0.5-3.7,1.4-5.3c0.9-1.5,2.2-2.9,3.8-3.8c3.3-1.9,7.2-1.9,10.5,0l24.5,14.2l-0.2,10.7l-29-16.8c-0.5-0.3-0.9-0.2-1.2,0c-0.3,0.2-0.6,0.5-0.6,1L126.9,95.4z"/>
|
|
||||||
<path class="ucst2" d="M108,39.5L48.7,73.8v68.5L108,108V39.5z M99.7,93.1c0,5.3-2.8,10.2-7.4,12.8l-19.6,11.4c-1.7,1-3.5,1.4-5.3,1.5c-1.8,0-3.6-0.5-5.2-1.4c-3.3-1.9-5.3-5.3-5.3-9.1V80l9.4-5.2l-0.1,33.5c0,0.6,0.3,0.9,0.6,1c0.3,0.2,0.7,0.3,1.2,0l19.6-11.4c1.7-1,2.8-2.8,2.8-4.8L90.3,61l9.4-5.4L99.7,93.1z"/>
|
|
||||||
<path class="ucst3" d="M108,108l-59.3,34.2l59.3,34.2l59.3-34.2L108,108z M133.8,152l-24.5,14.2l-9.2-5.5l29.1-16.7c0.5-0.3,0.6-0.7,0.6-1c0-0.3-0.1-0.7-0.6-1l-19.7-11.2c-1.7-1-3.8-1-5.5,0l-27.8,16.1l-9.4-5.4l32.6-18.7c4.6-2.6,10.2-2.6,14.8,0l19.7,11.2c1.7,0.9,3,2.3,3.9,3.9c0.9,1.5,1.4,3.3,1.4,5.2C139.1,146.7,137.1,150.1,133.8,152z"/>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
<span>Unchained</span>
|
<span>Unchained</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://gemini.com/" target="_blank" title="Gemini">
|
<a href="https://gemini.com/" target="_blank" title="Gemini">
|
||||||
@ -204,9 +189,9 @@
|
|||||||
<img class="image" src="/resources/profile/raspiblitz.svg" />
|
<img class="image" src="/resources/profile/raspiblitz.svg" />
|
||||||
<span>RaspiBlitz</span>
|
<span>RaspiBlitz</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/mynodebtc/mynode" target="_blank" title="MyNode">
|
<a href="https://github.com/mynodebtc/mynode" target="_blank" title="myNode">
|
||||||
<img class="image" src="/resources/profile/mynodebtc.jpg" />
|
<img class="image" src="/resources/profile/mynodebtc.png" />
|
||||||
<span>MyNode</span>
|
<span>myNode</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/RoninDojo/RoninDojo" target="_blank" title="RoninDojo">
|
<a href="https://github.com/RoninDojo/RoninDojo" target="_blank" title="RoninDojo">
|
||||||
<img class="image" src="/resources/profile/ronindojo.png" />
|
<img class="image" src="/resources/profile/ronindojo.png" />
|
||||||
@ -253,7 +238,7 @@
|
|||||||
<span>Sparrow</span>
|
<span>Sparrow</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/ACINQ/phoenix" target="_blank" title="Phoenix Wallet by ACINQ">
|
<a href="https://github.com/ACINQ/phoenix" target="_blank" title="Phoenix Wallet by ACINQ">
|
||||||
<img class="image" src="/resources/profile/phoenix.jpg" />
|
<img class="image not-rounded" src="/resources/profile/phoenix.svg" />
|
||||||
<span>Phoenix</span>
|
<span>Phoenix</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/lnbits/lnbits-legend" target="_blank" title="LNbits">
|
<a href="https://github.com/lnbits/lnbits-legend" target="_blank" title="LNbits">
|
||||||
@ -408,7 +393,6 @@
|
|||||||
|
|
||||||
<div class="footer-links">
|
<div class="footer-links">
|
||||||
<a href="/3rdpartylicenses.txt">Third-party Licenses</a>
|
<a href="/3rdpartylicenses.txt">Third-party Licenses</a>
|
||||||
<a [routerLink]="['/terms-of-service']" i18n="shared.terms-of-service|Terms of Service">Terms of Service</a>
|
|
||||||
<div class="social-icons">
|
<div class="social-icons">
|
||||||
<a target="_blank" href="https://github.com/mempool/mempool">
|
<a target="_blank" href="https://github.com/mempool/mempool">
|
||||||
<svg aria-hidden="true" focusable="false" data-prefix="fab" data-icon="github" class="svg-inline--fa fa-github fa-w-16 fa-2x" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512"><path fill="currentColor" d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"></path></svg>
|
<svg aria-hidden="true" focusable="false" data-prefix="fab" data-icon="github" class="svg-inline--fa fa-github fa-w-16 fa-2x" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512"><path fill="currentColor" d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"></path></svg>
|
||||||
@ -432,9 +416,12 @@
|
|||||||
{{ (backendInfo$ | async)?.hostname }} (v{{ (backendInfo$ | async )?.version }}) [<a href="https://github.com/mempool/mempool/commit/{{ (backendInfo$ | async )?.gitCommit | slice:0:8 }}">{{ (backendInfo$ | async )?.gitCommit | slice:0:8 }}</a>]
|
{{ (backendInfo$ | async)?.hostname }} (v{{ (backendInfo$ | async )?.version }}) [<a href="https://github.com/mempool/mempool/commit/{{ (backendInfo$ | async )?.gitCommit | slice:0:8 }}">{{ (backendInfo$ | async )?.gitCommit | slice:0:8 }}</a>]
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<br>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<ng-template #loadingSponsors>
|
<ng-template #loadingSponsors>
|
||||||
<br>
|
<br>
|
||||||
<div class="spinner-border text-light"></div>
|
<div class="spinner-border text-light"></div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
|
@ -4,6 +4,9 @@
|
|||||||
@media (min-width: 465px) {
|
@media (min-width: 465px) {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
|
@media (min-width: 992px) {
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-title {
|
.main-title {
|
||||||
@ -18,17 +21,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.full-container {
|
.full-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
padding: 0px 15px;
|
padding: 0px 15px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 500px;
|
height: calc(100vh - 250px);
|
||||||
height: calc(100% - 150px);
|
@media (min-width: 992px) {
|
||||||
@media (max-width: 992px) {
|
height: calc(100vh - 150px);
|
||||||
padding-bottom: 100px;
|
}
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart {
|
.chart {
|
||||||
width: 100%;
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding-bottom: 20px;
|
padding-bottom: 20px;
|
||||||
padding-right: 10px;
|
padding-right: 10px;
|
||||||
|
@ -4,6 +4,9 @@
|
|||||||
@media (min-width: 465px) {
|
@media (min-width: 465px) {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
|
@media (min-width: 992px) {
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-title {
|
.main-title {
|
||||||
@ -18,18 +21,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.full-container {
|
.full-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
padding: 0px 15px;
|
padding: 0px 15px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 500px;
|
height: calc(100vh - 250px);
|
||||||
height: calc(100% - 150px);
|
@media (min-width: 992px) {
|
||||||
@media (max-width: 992px) {
|
height: calc(100vh - 150px);
|
||||||
padding-bottom: 100px;
|
}
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart {
|
.chart {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
|
||||||
padding-bottom: 20px;
|
padding-bottom: 20px;
|
||||||
padding-right: 10px;
|
padding-right: 10px;
|
||||||
@media (max-width: 992px) {
|
@media (max-width: 992px) {
|
||||||
|
@ -23,7 +23,8 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
|
|||||||
@Input() unavailable: boolean = false;
|
@Input() unavailable: boolean = false;
|
||||||
@Input() auditHighlighting: boolean = false;
|
@Input() auditHighlighting: boolean = false;
|
||||||
@Input() blockConversion: Price;
|
@Input() blockConversion: Price;
|
||||||
@Output() txClickEvent = new EventEmitter<TransactionStripped>();
|
@Input() pixelAlign: boolean = false;
|
||||||
|
@Output() txClickEvent = new EventEmitter<{ tx: TransactionStripped, keyModifier: boolean}>();
|
||||||
@Output() txHoverEvent = new EventEmitter<string>();
|
@Output() txHoverEvent = new EventEmitter<string>();
|
||||||
@Output() readyEvent = new EventEmitter();
|
@Output() readyEvent = new EventEmitter();
|
||||||
|
|
||||||
@ -132,9 +133,9 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
update(add: TransactionStripped[], remove: string[], direction: string = 'left', resetLayout: boolean = false): void {
|
update(add: TransactionStripped[], remove: string[], change: { txid: string, rate: number | undefined }[], direction: string = 'left', resetLayout: boolean = false): void {
|
||||||
if (this.scene) {
|
if (this.scene) {
|
||||||
this.scene.update(add, remove, direction, resetLayout);
|
this.scene.update(add, remove, change, direction, resetLayout);
|
||||||
this.start();
|
this.start();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -201,7 +202,8 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
|
|||||||
this.start();
|
this.start();
|
||||||
} else {
|
} else {
|
||||||
this.scene = new BlockScene({ width: this.displayWidth, height: this.displayHeight, resolution: this.resolution,
|
this.scene = new BlockScene({ width: this.displayWidth, height: this.displayHeight, resolution: this.resolution,
|
||||||
blockLimit: this.blockLimit, orientation: this.orientation, flip: this.flip, vertexArray: this.vertexArray, highlighting: this.auditHighlighting });
|
blockLimit: this.blockLimit, orientation: this.orientation, flip: this.flip, vertexArray: this.vertexArray,
|
||||||
|
highlighting: this.auditHighlighting, pixelAlign: this.pixelAlign });
|
||||||
this.start();
|
this.start();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -326,7 +328,9 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
|
|||||||
if (event.target === this.canvas.nativeElement && event.pointerType === 'touch') {
|
if (event.target === this.canvas.nativeElement && event.pointerType === 'touch') {
|
||||||
this.setPreviewTx(event.offsetX, event.offsetY, true);
|
this.setPreviewTx(event.offsetX, event.offsetY, true);
|
||||||
} else if (event.target === this.canvas.nativeElement) {
|
} else if (event.target === this.canvas.nativeElement) {
|
||||||
this.onTxClick(event.offsetX, event.offsetY);
|
const keyMod = event.shiftKey || event.ctrlKey || event.metaKey;
|
||||||
|
const middleClick = event.which === 2 || event.button === 1;
|
||||||
|
this.onTxClick(event.offsetX, event.offsetY, keyMod || middleClick);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -409,12 +413,12 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onTxClick(cssX: number, cssY: number) {
|
onTxClick(cssX: number, cssY: number, keyModifier: boolean = false) {
|
||||||
const x = cssX * window.devicePixelRatio;
|
const x = cssX * window.devicePixelRatio;
|
||||||
const y = cssY * window.devicePixelRatio;
|
const y = cssY * window.devicePixelRatio;
|
||||||
const selected = this.scene.getTxAt({ x, y });
|
const selected = this.scene.getTxAt({ x, y });
|
||||||
if (selected && selected.txid) {
|
if (selected && selected.txid) {
|
||||||
this.txClickEvent.emit(selected);
|
this.txClickEvent.emit({ tx: selected, keyModifier });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -15,6 +15,7 @@ export default class BlockScene {
|
|||||||
gridWidth: number;
|
gridWidth: number;
|
||||||
gridHeight: number;
|
gridHeight: number;
|
||||||
gridSize: number;
|
gridSize: number;
|
||||||
|
pixelAlign: boolean;
|
||||||
vbytesPerUnit: number;
|
vbytesPerUnit: number;
|
||||||
unitPadding: number;
|
unitPadding: number;
|
||||||
unitWidth: number;
|
unitWidth: number;
|
||||||
@ -23,19 +24,24 @@ export default class BlockScene {
|
|||||||
animateUntil = 0;
|
animateUntil = 0;
|
||||||
dirty: boolean;
|
dirty: boolean;
|
||||||
|
|
||||||
constructor({ width, height, resolution, blockLimit, orientation, flip, vertexArray, highlighting }:
|
constructor({ width, height, resolution, blockLimit, orientation, flip, vertexArray, highlighting, pixelAlign }:
|
||||||
{ width: number, height: number, resolution: number, blockLimit: number,
|
{ width: number, height: number, resolution: number, blockLimit: number,
|
||||||
orientation: string, flip: boolean, vertexArray: FastVertexArray, highlighting: boolean }
|
orientation: string, flip: boolean, vertexArray: FastVertexArray, highlighting: boolean, pixelAlign: boolean }
|
||||||
) {
|
) {
|
||||||
this.init({ width, height, resolution, blockLimit, orientation, flip, vertexArray, highlighting });
|
this.init({ width, height, resolution, blockLimit, orientation, flip, vertexArray, highlighting, pixelAlign });
|
||||||
}
|
}
|
||||||
|
|
||||||
resize({ width = this.width, height = this.height, animate = true }: { width?: number, height?: number, animate: boolean }): void {
|
resize({ width = this.width, height = this.height, animate = true }: { width?: number, height?: number, animate: boolean }): void {
|
||||||
this.width = width;
|
this.width = width;
|
||||||
this.height = height;
|
this.height = height;
|
||||||
this.gridSize = this.width / this.gridWidth;
|
this.gridSize = this.width / this.gridWidth;
|
||||||
this.unitPadding = width / 500;
|
if (this.pixelAlign) {
|
||||||
this.unitWidth = this.gridSize - (this.unitPadding * 2);
|
this.unitPadding = Math.max(1, Math.floor(this.gridSize / 2.5));
|
||||||
|
this.unitWidth = this.gridSize - (this.unitPadding);
|
||||||
|
} else {
|
||||||
|
this.unitPadding = width / 500;
|
||||||
|
this.unitWidth = this.gridSize - (this.unitPadding * 2);
|
||||||
|
}
|
||||||
|
|
||||||
this.dirty = true;
|
this.dirty = true;
|
||||||
if (this.initialised && this.scene) {
|
if (this.initialised && this.scene) {
|
||||||
@ -150,7 +156,7 @@ export default class BlockScene {
|
|||||||
this.updateAll(startTime, 200, direction);
|
this.updateAll(startTime, 200, direction);
|
||||||
}
|
}
|
||||||
|
|
||||||
update(add: TransactionStripped[], remove: string[], direction: string = 'left', resetLayout: boolean = false): void {
|
update(add: TransactionStripped[], remove: string[], change: { txid: string, rate: number | undefined }[], direction: string = 'left', resetLayout: boolean = false): void {
|
||||||
const startTime = performance.now();
|
const startTime = performance.now();
|
||||||
const removed = this.removeBatch(remove, startTime, direction);
|
const removed = this.removeBatch(remove, startTime, direction);
|
||||||
|
|
||||||
@ -172,6 +178,15 @@ export default class BlockScene {
|
|||||||
this.place(tx);
|
this.place(tx);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
// update effective rates
|
||||||
|
change.forEach(tx => {
|
||||||
|
if (this.txs[tx.txid]) {
|
||||||
|
this.txs[tx.txid].feerate = tx.rate || (this.txs[tx.txid].fee / this.txs[tx.txid].vsize);
|
||||||
|
this.txs[tx.txid].rate = tx.rate;
|
||||||
|
this.txs[tx.txid].dirty = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// try to insert new txs directly
|
// try to insert new txs directly
|
||||||
const remaining = [];
|
const remaining = [];
|
||||||
add.map(tx => new TxView(tx, this)).sort(feeRateDescending).forEach(tx => {
|
add.map(tx => new TxView(tx, this)).sort(feeRateDescending).forEach(tx => {
|
||||||
@ -200,14 +215,15 @@ export default class BlockScene {
|
|||||||
this.animateUntil = Math.max(this.animateUntil, tx.setHover(value));
|
this.animateUntil = Math.max(this.animateUntil, tx.setHover(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
private init({ width, height, resolution, blockLimit, orientation, flip, vertexArray, highlighting }:
|
private init({ width, height, resolution, blockLimit, orientation, flip, vertexArray, highlighting, pixelAlign }:
|
||||||
{ width: number, height: number, resolution: number, blockLimit: number,
|
{ width: number, height: number, resolution: number, blockLimit: number,
|
||||||
orientation: string, flip: boolean, vertexArray: FastVertexArray, highlighting: boolean }
|
orientation: string, flip: boolean, vertexArray: FastVertexArray, highlighting: boolean, pixelAlign: boolean }
|
||||||
): void {
|
): void {
|
||||||
this.orientation = orientation;
|
this.orientation = orientation;
|
||||||
this.flip = flip;
|
this.flip = flip;
|
||||||
this.vertexArray = vertexArray;
|
this.vertexArray = vertexArray;
|
||||||
this.highlightingEnabled = highlighting;
|
this.highlightingEnabled = highlighting;
|
||||||
|
this.pixelAlign = pixelAlign;
|
||||||
|
|
||||||
this.scene = {
|
this.scene = {
|
||||||
count: 0,
|
count: 0,
|
||||||
@ -333,7 +349,12 @@ export default class BlockScene {
|
|||||||
private gridToScreen(position: Square | void): Square {
|
private gridToScreen(position: Square | void): Square {
|
||||||
if (position) {
|
if (position) {
|
||||||
const slotSize = (position.s * this.gridSize);
|
const slotSize = (position.s * this.gridSize);
|
||||||
const squareSize = slotSize - (this.unitPadding * 2);
|
let squareSize;
|
||||||
|
if (this.pixelAlign) {
|
||||||
|
squareSize = slotSize - (this.unitPadding);
|
||||||
|
} else {
|
||||||
|
squareSize = slotSize - (this.unitPadding * 2);
|
||||||
|
}
|
||||||
|
|
||||||
// The grid is laid out notionally left-to-right, bottom-to-top,
|
// The grid is laid out notionally left-to-right, bottom-to-top,
|
||||||
// so we rotate and/or flip the y axis to match the target configuration.
|
// so we rotate and/or flip the y axis to match the target configuration.
|
||||||
|
@ -36,6 +36,7 @@ export default class TxView implements TransactionStripped {
|
|||||||
vsize: number;
|
vsize: number;
|
||||||
value: number;
|
value: number;
|
||||||
feerate: number;
|
feerate: number;
|
||||||
|
rate?: number;
|
||||||
status?: 'found' | 'missing' | 'fresh' | 'added' | 'censored' | 'selected';
|
status?: 'found' | 'missing' | 'fresh' | 'added' | 'censored' | 'selected';
|
||||||
context?: 'projected' | 'actual';
|
context?: 'projected' | 'actual';
|
||||||
scene?: BlockScene;
|
scene?: BlockScene;
|
||||||
@ -58,7 +59,8 @@ export default class TxView implements TransactionStripped {
|
|||||||
this.fee = tx.fee;
|
this.fee = tx.fee;
|
||||||
this.vsize = tx.vsize;
|
this.vsize = tx.vsize;
|
||||||
this.value = tx.value;
|
this.value = tx.value;
|
||||||
this.feerate = tx.fee / tx.vsize;
|
this.feerate = tx.rate || (tx.fee / tx.vsize); // sort by effective fee rate where available
|
||||||
|
this.rate = tx.rate;
|
||||||
this.status = tx.status;
|
this.status = tx.status;
|
||||||
this.initialised = false;
|
this.initialised = false;
|
||||||
this.vertexArray = scene.vertexArray;
|
this.vertexArray = scene.vertexArray;
|
||||||
@ -157,7 +159,8 @@ export default class TxView implements TransactionStripped {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getColor(): Color {
|
getColor(): Color {
|
||||||
const feeLevelIndex = feeLevels.findIndex((feeLvl) => Math.max(1, this.feerate) < feeLvl) - 1;
|
const rate = this.fee / this.vsize; // color by simple single-tx fee rate
|
||||||
|
const feeLevelIndex = feeLevels.findIndex((feeLvl) => Math.max(1, rate) < feeLvl) - 1;
|
||||||
const feeLevelColor = feeColors[feeLevelIndex] || feeColors[mempoolFeeColors.length - 1];
|
const feeLevelColor = feeColors[feeLevelIndex] || feeColors[mempoolFeeColors.length - 1];
|
||||||
// Normal mode
|
// Normal mode
|
||||||
if (!this.scene?.highlightingEnabled) {
|
if (!this.scene?.highlightingEnabled) {
|
||||||
|
@ -28,6 +28,12 @@
|
|||||||
{{ feeRate | feeRounding }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span>
|
{{ feeRate | feeRounding }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr *ngIf="effectiveRate && effectiveRate !== feeRate">
|
||||||
|
<td class="td-width" i18n="transaction.effective-fee-rate|Effective transaction fee rate">Effective fee rate</td>
|
||||||
|
<td>
|
||||||
|
{{ effectiveRate | feeRounding }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="td-width" i18n="transaction.vsize|Transaction Virtual Size">Virtual size</td>
|
<td class="td-width" i18n="transaction.vsize|Transaction Virtual Size">Virtual size</td>
|
||||||
<td [innerHTML]="'‎' + (vsize | vbytes: 2)"></td>
|
<td [innerHTML]="'‎' + (vsize | vbytes: 2)"></td>
|
||||||
|
@ -20,6 +20,7 @@ export class BlockOverviewTooltipComponent implements OnChanges {
|
|||||||
value = 0;
|
value = 0;
|
||||||
vsize = 1;
|
vsize = 1;
|
||||||
feeRate = 0;
|
feeRate = 0;
|
||||||
|
effectiveRate;
|
||||||
|
|
||||||
tooltipPosition: Position = { x: 0, y: 0 };
|
tooltipPosition: Position = { x: 0, y: 0 };
|
||||||
|
|
||||||
@ -51,6 +52,7 @@ export class BlockOverviewTooltipComponent implements OnChanges {
|
|||||||
this.value = tx.value || 0;
|
this.value = tx.value || 0;
|
||||||
this.vsize = tx.vsize || 1;
|
this.vsize = tx.vsize || 1;
|
||||||
this.feeRate = this.fee / this.vsize;
|
this.feeRate = this.fee / this.vsize;
|
||||||
|
this.effectiveRate = tx.rate;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,6 +4,9 @@
|
|||||||
@media (min-width: 465px) {
|
@media (min-width: 465px) {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
|
@media (min-width: 992px) {
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-title {
|
.main-title {
|
||||||
@ -18,17 +21,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.full-container {
|
.full-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
padding: 0px 15px;
|
padding: 0px 15px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 500px;
|
height: calc(100vh - 250px);
|
||||||
height: calc(100% - 150px);
|
@media (min-width: 992px) {
|
||||||
@media (max-width: 992px) {
|
height: calc(100vh - 150px);
|
||||||
padding-bottom: 100px;
|
}
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart {
|
.chart {
|
||||||
width: 100%;
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding-bottom: 20px;
|
padding-bottom: 20px;
|
||||||
padding-right: 10px;
|
padding-right: 10px;
|
||||||
|
@ -4,6 +4,9 @@
|
|||||||
@media (min-width: 465px) {
|
@media (min-width: 465px) {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
|
@media (min-width: 992px) {
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-title {
|
.main-title {
|
||||||
@ -18,17 +21,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.full-container {
|
.full-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
padding: 0px 15px;
|
padding: 0px 15px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 500px;
|
height: calc(100vh - 250px);
|
||||||
height: calc(100% - 150px);
|
@media (min-width: 992px) {
|
||||||
@media (max-width: 992px) {
|
height: calc(100vh - 150px);
|
||||||
padding-bottom: 100px;
|
}
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart {
|
.chart {
|
||||||
width: 100%;
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding-bottom: 20px;
|
padding-bottom: 20px;
|
||||||
padding-right: 10px;
|
padding-right: 10px;
|
||||||
|
@ -4,6 +4,9 @@
|
|||||||
@media (min-width: 465px) {
|
@media (min-width: 465px) {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
|
@media (min-width: 992px) {
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-title {
|
.main-title {
|
||||||
@ -18,17 +21,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.full-container {
|
.full-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
padding: 0px 15px;
|
padding: 0px 15px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 500px;
|
height: calc(100vh - 250px);
|
||||||
height: calc(100% - 150px);
|
@media (min-width: 992px) {
|
||||||
@media (max-width: 992px) {
|
height: calc(100vh - 150px);
|
||||||
padding-bottom: 100px;
|
}
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart {
|
.chart {
|
||||||
width: 100%;
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding-bottom: 20px;
|
padding-bottom: 20px;
|
||||||
padding-right: 10px;
|
padding-right: 10px;
|
||||||
|
@ -612,9 +612,13 @@ export class BlockComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onTxClick(event: TransactionStripped): void {
|
onTxClick(event: { tx: TransactionStripped, keyModifier: boolean }): void {
|
||||||
const url = new RelativeUrlPipe(this.stateService).transform(`/tx/${event.txid}`);
|
const url = new RelativeUrlPipe(this.stateService).transform(`/tx/${event.tx.txid}`);
|
||||||
this.router.navigate([url]);
|
if (!event.keyModifier) {
|
||||||
|
this.router.navigate([url]);
|
||||||
|
} else {
|
||||||
|
window.open(url, '_blank');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onTxHover(txid: string): void {
|
onTxHover(txid: string): void {
|
||||||
|
@ -1,53 +1,61 @@
|
|||||||
<div class="blocks-container blockchain-blocks-container" [class.time-ltr]="timeLtr"
|
<div class="blocks-container blockchain-blocks-container" [class.time-ltr]="timeLtr" [class.minimal]="minimal"
|
||||||
[style.left]="static ? (offset || 0) + 'px' : null"
|
[style.left]="static ? (offset || 0) + 'px' : null" [style.--block-size]="blockWidth+'px'"
|
||||||
*ngIf="static || (loadingBlocks$ | async) === false; else loadingBlocksTemplate">
|
*ngIf="static || (loadingBlocks$ | async) === false; else loadingBlocksTemplate">
|
||||||
<div *ngFor="let block of blocks; let i = index; trackBy: trackByBlocksFn">
|
<div *ngFor="let block of blocks; let i = index; trackBy: trackByBlocksFn">
|
||||||
<ng-container *ngIf="connected && block && !block.loading && !block.placeholder; else placeholderBlock">
|
<ng-container *ngIf="connected && block && !block.loading && !block.placeholder; else placeholderBlock">
|
||||||
|
<div
|
||||||
|
*ngIf="minimal && spotlight < 0 && chainTip + spotlight + 1 === block.height"
|
||||||
|
class="spotlight-bottom"
|
||||||
|
[style.left]="blockStyles[i].left"
|
||||||
|
></div>
|
||||||
<div [attr.data-cy]="'bitcoin-block-offset-' + offset + '-index-' + i"
|
<div [attr.data-cy]="'bitcoin-block-offset-' + offset + '-index-' + i"
|
||||||
class="text-center bitcoin-block mined-block blockchain-blocks-offset-{{ offset }}-index-{{ i }}"
|
class="text-center bitcoin-block mined-block blockchain-blocks-offset-{{ offset }}-index-{{ i }}"
|
||||||
|
[class.offscreen]="!static && count && i >= count"
|
||||||
id="bitcoin-block-{{ block.height }}" [ngStyle]="blockStyles[i]"
|
id="bitcoin-block-{{ block.height }}" [ngStyle]="blockStyles[i]"
|
||||||
[class.blink-bg]="isSpecial(block.height)">
|
[class.blink-bg]="isSpecial(block.height)">
|
||||||
<a draggable="false" [routerLink]="['/block/' | relativeUrl, block.id]" [state]="{ data: { block: block } }"
|
<a draggable="false" [routerLink]="['/block/' | relativeUrl, block.id]" [state]="{ data: { block: block } }"
|
||||||
class="blockLink" [ngClass]="{'disabled': (this.stateService.blockScrolling$ | async)}"> </a>
|
class="blockLink" [ngClass]="{'disabled': (this.stateService.blockScrolling$ | async)}"> </a>
|
||||||
<div [attr.data-cy]="'bitcoin-block-' + i + '-height'" class="block-height">
|
<div *ngIf="!minimal" [attr.data-cy]="'bitcoin-block-' + i + '-height'" class="block-height">
|
||||||
<a [routerLink]="['/block/' | relativeUrl, block.id]" [state]="{ data: { block: block } }">{{ block.height
|
<a [routerLink]="['/block/' | relativeUrl, block.id]" [state]="{ data: { block: block } }">{{ block.height
|
||||||
}}</a>
|
}}</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="block-body">
|
<div class="block-body">
|
||||||
<div *ngIf="block?.extras; else emptyfees" [attr.data-cy]="'bitcoin-block-offset=' + offset + '-index-' + i + '-fees'" class="fees">
|
<ng-container *ngIf="!minimal">
|
||||||
~{{ block?.extras?.medianFee | number:feeRounding }} <ng-container
|
<div *ngIf="block?.extras; else emptyfees" [attr.data-cy]="'bitcoin-block-offset=' + offset + '-index-' + i + '-fees'" class="fees">
|
||||||
i18n="shared.sat-vbyte|sat/vB">sat/vB</ng-container>
|
~{{ block?.extras?.medianFee | number:feeRounding }} <ng-container
|
||||||
</div>
|
i18n="shared.sat-vbyte|sat/vB">sat/vB</ng-container>
|
||||||
<ng-template #emptyfees>
|
|
||||||
<div [attr.data-cy]="'bitcoin-block-offset=' + offset + '-index-' + i + '-fees'" class="fees">
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
<ng-template #emptyfees>
|
||||||
<div [attr.data-cy]="'bitcoin-block-' + offset + '-index-' + i + '-fee-span'" class="fee-span"
|
<div [attr.data-cy]="'bitcoin-block-offset=' + offset + '-index-' + i + '-fees'" class="fees">
|
||||||
*ngIf="block?.extras?.feeRange; else emptyfeespan">
|
|
||||||
{{ block?.extras?.feeRange?.[0] | number:feeRounding }} - {{
|
</div>
|
||||||
block?.extras?.feeRange[block?.extras?.feeRange?.length - 1] | number:feeRounding }} <ng-container
|
</ng-template>
|
||||||
i18n="shared.sat-vbyte|sat/vB">sat/vB</ng-container>
|
<div [attr.data-cy]="'bitcoin-block-' + offset + '-index-' + i + '-fee-span'" class="fee-span"
|
||||||
</div>
|
*ngIf="block?.extras?.feeRange; else emptyfeespan">
|
||||||
<ng-template #emptyfeespan>
|
{{ block?.extras?.feeRange?.[0] | number:feeRounding }} - {{
|
||||||
<div [attr.data-cy]="'bitcoin-block-offset=' + offset + '-index-' + i + '-fees'" class="fee-span">
|
block?.extras?.feeRange[block?.extras?.feeRange?.length - 1] | number:feeRounding }} <ng-container
|
||||||
|
i18n="shared.sat-vbyte|sat/vB">sat/vB</ng-container>
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
<ng-template #emptyfeespan>
|
||||||
<div [attr.data-cy]="'bitcoin-block-' + offset + '-index-' + i + '-total-fees'" *ngIf="showMiningInfo"
|
<div [attr.data-cy]="'bitcoin-block-offset=' + offset + '-index-' + i + '-fees'" class="fee-span">
|
||||||
class="block-size">
|
|
||||||
<app-amount [satoshis]="block.extras?.totalFees ?? 0" digitsInfo="1.2-3" [noFiat]="true"></app-amount>
|
</div>
|
||||||
</div>
|
</ng-template>
|
||||||
<div [attr.data-cy]="'bitcoin-block-' + offset + '-index-' + i + 'block-size'" *ngIf="!showMiningInfo"
|
<div [attr.data-cy]="'bitcoin-block-' + offset + '-index-' + i + '-total-fees'" *ngIf="showMiningInfo"
|
||||||
class="block-size" [innerHTML]="'‎' + (block.size | bytes: 2)"></div>
|
class="block-size">
|
||||||
<div [attr.data-cy]="'bitcoin-block-' + i + '-transactions'" class="transaction-count">
|
<app-amount [satoshis]="block.extras?.totalFees ?? 0" digitsInfo="1.2-3" [noFiat]="true"></app-amount>
|
||||||
<ng-container
|
</div>
|
||||||
*ngTemplateOutlet="block.tx_count === 1 ? transactionsSingular : transactionsPlural; context: {$implicit: block.tx_count | number}"></ng-container>
|
<div [attr.data-cy]="'bitcoin-block-' + offset + '-index-' + i + 'block-size'" *ngIf="!showMiningInfo"
|
||||||
<ng-template #transactionsSingular let-i i18n="shared.transaction-count.singular">{{ i }} transaction</ng-template>
|
class="block-size" [innerHTML]="'‎' + (block.size | bytes: 2)"></div>
|
||||||
<ng-template #transactionsPlural let-i i18n="shared.transaction-count.plural">{{ i }} transactions</ng-template>
|
<div [attr.data-cy]="'bitcoin-block-' + i + '-transactions'" class="transaction-count">
|
||||||
</div>
|
<ng-container
|
||||||
<div [attr.data-cy]="'bitcoin-block-' + offset + '-index-' + i + '-time'" class="time-difference">
|
*ngTemplateOutlet="block.tx_count === 1 ? transactionsSingular : transactionsPlural; context: {$implicit: block.tx_count | number}"></ng-container>
|
||||||
<app-time kind="since" [time]="block.timestamp" [fastRender]="true"></app-time></div>
|
<ng-template #transactionsSingular let-i i18n="shared.transaction-count.singular">{{ i }} transaction</ng-template>
|
||||||
|
<ng-template #transactionsPlural let-i i18n="shared.transaction-count.plural">{{ i }} transactions</ng-template>
|
||||||
|
</div>
|
||||||
|
<div [attr.data-cy]="'bitcoin-block-' + offset + '-index-' + i + '-time'" class="time-difference">
|
||||||
|
<app-time kind="since" [time]="block.timestamp" [fastRender]="true"></app-time></div>
|
||||||
|
</ng-container>
|
||||||
</div>
|
</div>
|
||||||
<div class="animated" [class]="showMiningInfo ? 'show' : 'hide'" *ngIf="block.extras?.pool != undefined">
|
<div class="animated" [class]="showMiningInfo ? 'show' : 'hide'" *ngIf="block.extras?.pool != undefined">
|
||||||
<a [attr.data-cy]="'bitcoin-block-' + offset + '-index-' + i + '-pool'" class="badge badge-primary"
|
<a [attr.data-cy]="'bitcoin-block-' + offset + '-index-' + i + '-pool'" class="badge badge-primary"
|
||||||
@ -79,11 +87,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ng-template #loadingBlocksTemplate>
|
<ng-template #loadingBlocksTemplate>
|
||||||
<div class="blocks-container" [class.time-ltr]="timeLtr">
|
<div class="blocks-container" [class.time-ltr]="timeLtr" [style.--block-size]="blockWidth+'px'">
|
||||||
<div class="flashing">
|
<div class="flashing">
|
||||||
<div *ngFor="let block of emptyBlocks; let i = index; trackBy: trackByBlocksFn">
|
<div *ngFor="let block of emptyBlocks; let i = index; trackBy: trackByBlocksFn">
|
||||||
<div class="text-center bitcoin-block mined-block" id="bitcoin-block-{{ block.height }}"
|
<div class="text-center bitcoin-block mined-block" id="bitcoin-block-{{ block.height }}"
|
||||||
[ngStyle]="emptyBlockStyles[i]"></div>
|
[ngStyle]="emptyBlockStyles[i]" [class.offscreen]="!static && count && i >= count"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
.bitcoin-block {
|
.bitcoin-block {
|
||||||
width: 125px;
|
width: var(--block-size);
|
||||||
height: 125px;
|
height: var(--block-size);
|
||||||
}
|
}
|
||||||
|
|
||||||
.blockLink {
|
.blockLink {
|
||||||
@ -22,7 +22,11 @@
|
|||||||
.mined-block {
|
.mined-block {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0px;
|
top: 0px;
|
||||||
transition: background 2s, left 2s, transform 1s;
|
transition: background 2s, left 2s, transform 1s, opacity 1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mined-block.offscreen {
|
||||||
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mined-block.placeholder-block {
|
.mined-block.placeholder-block {
|
||||||
@ -35,9 +39,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.blocks-container {
|
.blocks-container {
|
||||||
|
--block-size: 125px;
|
||||||
|
--block-offset: calc(0.32 * var(--block-size));
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0px;
|
top: 0px;
|
||||||
left: 40px;
|
left: var(--block-offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
.block-body {
|
.block-body {
|
||||||
@ -77,11 +83,11 @@
|
|||||||
|
|
||||||
.bitcoin-block::after {
|
.bitcoin-block::after {
|
||||||
content: '';
|
content: '';
|
||||||
width: 125px;
|
width: var(--block-size);
|
||||||
height: 24px;
|
height: calc(0.192 * var(--block-size));
|
||||||
position:absolute;
|
position:absolute;
|
||||||
top: -24px;
|
top: calc(-0.192 * var(--block-size));
|
||||||
left: -20px;
|
left: calc(-0.16 * var(--block-size));
|
||||||
background-color: #232838;
|
background-color: #232838;
|
||||||
transform:skew(40deg);
|
transform:skew(40deg);
|
||||||
transform-origin:top;
|
transform-origin:top;
|
||||||
@ -89,11 +95,11 @@
|
|||||||
|
|
||||||
.bitcoin-block::before {
|
.bitcoin-block::before {
|
||||||
content: '';
|
content: '';
|
||||||
width: 20px;
|
width: calc(0.16 * var(--block-size));
|
||||||
height: 125px;
|
height: var(--block-size);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -12px;
|
top: calc(-0.096 * var(--block-size));
|
||||||
left: -20px;
|
left: calc(-0.16 * var(--block-size));
|
||||||
background-color: #191c27;
|
background-color: #191c27;
|
||||||
|
|
||||||
transform: skewY(50deg);
|
transform: skewY(50deg);
|
||||||
@ -168,4 +174,16 @@
|
|||||||
.bitcoin-block {
|
.bitcoin-block {
|
||||||
transform: scaleX(-1);
|
transform: scaleX(-1);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.spotlight-bottom {
|
||||||
|
position: absolute;
|
||||||
|
width: calc(0.6 * var(--block-size));
|
||||||
|
height: calc(0.25 * var(--block-size));
|
||||||
|
border-left: solid calc(0.3 * var(--block-size)) transparent;
|
||||||
|
border-bottom: solid calc(0.3 * var(--block-size)) white;
|
||||||
|
border-right: solid calc(0.3 * var(--block-size)) transparent;
|
||||||
|
transform: translate(calc(0.2 * var(--block-size)), calc(1.1 * var(--block-size)));
|
||||||
|
border-radius: 2px;
|
||||||
|
z-index: -1;
|
||||||
}
|
}
|
@ -24,6 +24,9 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
@Input() count: number = 8; // number of blocks in this chunk (dynamic blocks only)
|
@Input() count: number = 8; // number of blocks in this chunk (dynamic blocks only)
|
||||||
@Input() loadingTip: boolean = false;
|
@Input() loadingTip: boolean = false;
|
||||||
@Input() connected: boolean = true;
|
@Input() connected: boolean = true;
|
||||||
|
@Input() minimal: boolean = false;
|
||||||
|
@Input() blockWidth: number = 125;
|
||||||
|
@Input() spotlight: number = 0;
|
||||||
|
|
||||||
specialBlocks = specialBlocks;
|
specialBlocks = specialBlocks;
|
||||||
network = '';
|
network = '';
|
||||||
@ -51,6 +54,10 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
timeLtrSubscription: Subscription;
|
timeLtrSubscription: Subscription;
|
||||||
timeLtr: boolean;
|
timeLtr: boolean;
|
||||||
|
|
||||||
|
blockOffset: number = 155;
|
||||||
|
dividerBlockOffset: number = 205;
|
||||||
|
blockPadding: number = 30;
|
||||||
|
|
||||||
gradientColors = {
|
gradientColors = {
|
||||||
'': ['#9339f4', '#105fb0'],
|
'': ['#9339f4', '#105fb0'],
|
||||||
bisq: ['#9339f4', '#105fb0'],
|
bisq: ['#9339f4', '#105fb0'],
|
||||||
@ -118,7 +125,7 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
|
|
||||||
this.blockStyles = [];
|
this.blockStyles = [];
|
||||||
if (this.blocksFilled && block.height > this.chainTip) {
|
if (this.blocksFilled && block.height > this.chainTip) {
|
||||||
this.blocks.forEach((b, i) => this.blockStyles.push(this.getStyleForBlock(b, i, i ? -155 : -205)));
|
this.blocks.forEach((b, i) => this.blockStyles.push(this.getStyleForBlock(b, i, i ? -this.blockOffset : -this.dividerBlockOffset)));
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.blockStyles = [];
|
this.blockStyles = [];
|
||||||
this.blocks.forEach((b, i) => this.blockStyles.push(this.getStyleForBlock(b, i)));
|
this.blocks.forEach((b, i) => this.blockStyles.push(this.getStyleForBlock(b, i)));
|
||||||
@ -159,6 +166,13 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnChanges(changes: SimpleChanges): void {
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
|
if (changes.blockWidth && this.blockWidth) {
|
||||||
|
this.blockPadding = 0.24 * this.blockWidth;
|
||||||
|
this.blockOffset = this.blockWidth + this.blockPadding;
|
||||||
|
this.dividerBlockOffset = this.blockOffset + (0.4 * this.blockWidth);
|
||||||
|
this.blockStyles = [];
|
||||||
|
this.blocks.forEach((b, i) => this.blockStyles.push(this.getStyleForBlock(b, i)));
|
||||||
|
}
|
||||||
if (this.static) {
|
if (this.static) {
|
||||||
const animateSlide = changes.height && (changes.height.currentValue === changes.height.previousValue + 1);
|
const animateSlide = changes.height && (changes.height.currentValue === changes.height.previousValue + 1);
|
||||||
this.updateStaticBlocks(animateSlide);
|
this.updateStaticBlocks(animateSlide);
|
||||||
@ -191,14 +205,14 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
}
|
}
|
||||||
this.arrowVisible = true;
|
this.arrowVisible = true;
|
||||||
if (newBlockFromLeft) {
|
if (newBlockFromLeft) {
|
||||||
this.arrowLeftPx = blockindex * 155 + 30 - 205;
|
this.arrowLeftPx = blockindex * this.blockOffset + this.blockPadding - this.dividerBlockOffset;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.arrowTransition = '2s';
|
this.arrowTransition = '2s';
|
||||||
this.arrowLeftPx = blockindex * 155 + 30;
|
this.arrowLeftPx = blockindex * this.blockOffset + this.blockPadding;
|
||||||
this.cd.markForCheck();
|
this.cd.markForCheck();
|
||||||
}, 50);
|
}, 50);
|
||||||
} else {
|
} else {
|
||||||
this.arrowLeftPx = blockindex * 155 + 30;
|
this.arrowLeftPx = blockindex * this.blockOffset + this.blockPadding;
|
||||||
if (!animate) {
|
if (!animate) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.arrowTransition = '2s';
|
this.arrowTransition = '2s';
|
||||||
@ -245,7 +259,7 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
}
|
}
|
||||||
this.blocks = this.blocks.slice(0, this.count);
|
this.blocks = this.blocks.slice(0, this.count);
|
||||||
this.blockStyles = [];
|
this.blockStyles = [];
|
||||||
this.blocks.forEach((b, i) => this.blockStyles.push(this.getStyleForBlock(b, i, animateSlide ? -155 : 0)));
|
this.blocks.forEach((b, i) => this.blockStyles.push(this.getStyleForBlock(b, i, animateSlide ? -this.blockOffset : 0)));
|
||||||
this.cd.markForCheck();
|
this.cd.markForCheck();
|
||||||
if (animateSlide) {
|
if (animateSlide) {
|
||||||
// animate blocks slide right
|
// animate blocks slide right
|
||||||
@ -287,7 +301,7 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
left: addLeft + 155 * index + 'px',
|
left: addLeft + this.blockOffset * index + 'px',
|
||||||
background: `repeating-linear-gradient(
|
background: `repeating-linear-gradient(
|
||||||
#2d3348,
|
#2d3348,
|
||||||
#2d3348 ${greenBackgroundHeight}%,
|
#2d3348 ${greenBackgroundHeight}%,
|
||||||
@ -309,7 +323,7 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
const addLeft = animateEnterFrom || 0;
|
const addLeft = animateEnterFrom || 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
left: addLeft + (155 * index) + 'px',
|
left: addLeft + (this.blockOffset * index) + 'px',
|
||||||
background: "#2d3348",
|
background: "#2d3348",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -317,7 +331,7 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
getStyleForPlaceholderBlock(index: number, animateEnterFrom: number = 0) {
|
getStyleForPlaceholderBlock(index: number, animateEnterFrom: number = 0) {
|
||||||
const addLeft = animateEnterFrom || 0;
|
const addLeft = animateEnterFrom || 0;
|
||||||
return {
|
return {
|
||||||
left: addLeft + (155 * index) + 'px',
|
left: addLeft + (this.blockOffset * index) + 'px',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -325,7 +339,7 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
const addLeft = animateEnterFrom || 0;
|
const addLeft = animateEnterFrom || 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
left: addLeft + 155 * this.emptyBlocks.indexOf(block) + 'px',
|
left: addLeft + this.blockOffset * this.emptyBlocks.indexOf(block) + 'px',
|
||||||
background: "#2d3348",
|
background: "#2d3348",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,42 @@
|
|||||||
|
<div class="clock-face" [style]="faceStyle">
|
||||||
|
<ng-content></ng-content>
|
||||||
|
<svg
|
||||||
|
class="cut-out"
|
||||||
|
width="384"
|
||||||
|
height="384"
|
||||||
|
viewBox="0 0 384 384"
|
||||||
|
>
|
||||||
|
<g>
|
||||||
|
<path
|
||||||
|
class="face"
|
||||||
|
d="M 0,0 V 384 H 384 V 0 Z M 192,15 A 177,177 0 0 1 369,192 177,177 0 0 1 192,369 177,177 0 0 1 15,192 177,177 0 0 1 192,15 Z"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
class="demo-dial"
|
||||||
|
width="384"
|
||||||
|
height="384"
|
||||||
|
viewBox="0 0 384 384"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<pattern id="dial-gradient" patternUnits="userSpaceOnUse" width="384" height="384">
|
||||||
|
<image class="dial-gradient-img" href="/resources/clock/gradient.png" x="0" y="0" width="384" height="384" [style.transform]="'rotate(' + (minutes * 6) + 'deg)'" />
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<path *ngFor="let angle of minorTicks" class="tick minor" d="M 192,27 v 10" [style.transform]="'rotate(' + angle + 'deg)'"/>
|
||||||
|
<path *ngFor="let angle of majorTicks" class="tick major" d="M 192,27 v 18" [style.transform]="'rotate(' + angle + 'deg)'"/>
|
||||||
|
|
||||||
|
<ng-container *ngFor="let segment of segments; trackBy: trackBySegment">
|
||||||
|
<path class="block-segment" [attr.d]="segment.path" />
|
||||||
|
<!-- <circle class="segment-mark start" [attr.cx]="segment.start.x" [attr.cy]="segment.start.y" r="2" style="fill:green;stroke:white;stroke-width:1px;" />
|
||||||
|
<circle class="segment-mark end" [attr.cx]="segment.end.x" [attr.cy]="segment.end.y" r="2" style="fill:red;stroke:white;stroke-width:1px;" /> -->
|
||||||
|
</ng-container>
|
||||||
|
<!-- <polyline points="468.750,82.031 468.750,35 " id="polyline322" style="fill:none;stroke:#ffffff;stroke-width:4.84839;stroke-dasharray:none;stroke-opacity:1" transform="matrix(0.41250847,0,0,0.93092534,-1.3627708,-32.692008)" /> -->
|
||||||
|
<path class="tick very major" d="M 192,0 v 45" />
|
||||||
|
<path id="hour" class="gnomon hour" d="M 178,3 206,3 192,40 Z" [style.transform]="'rotate(' + (hours * 30) + 'deg)'" />
|
||||||
|
<path id="minute" class="gnomon minute" d="M 180,4 204,4 192,38 Z" [style.transform]="'rotate(' + (minutes * 6) + 'deg)'" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
@ -0,0 +1,69 @@
|
|||||||
|
.clock-face {
|
||||||
|
position: relative;
|
||||||
|
height: 84.375%;
|
||||||
|
margin: auto;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.cut-out, .demo-dial {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
.face {
|
||||||
|
fill: #11131f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.gnomon {
|
||||||
|
transform-origin: center;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
|
||||||
|
&.minute {
|
||||||
|
fill:#80C2E1;
|
||||||
|
stroke:#80C2E1;
|
||||||
|
stroke-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.hour {
|
||||||
|
fill: #105fb0;
|
||||||
|
stroke: #105fb0;
|
||||||
|
stroke-width: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tick {
|
||||||
|
transform-origin: center;
|
||||||
|
fill: none;
|
||||||
|
stroke: white;
|
||||||
|
stroke-width: 2px;
|
||||||
|
stroke-linecap: butt;
|
||||||
|
|
||||||
|
&.minor {
|
||||||
|
stroke-opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.very.major {
|
||||||
|
stroke-width: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-segment {
|
||||||
|
fill: none;
|
||||||
|
stroke: url(#dial-gradient);
|
||||||
|
stroke-width: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dial-segment {
|
||||||
|
fill: none;
|
||||||
|
stroke: white;
|
||||||
|
stroke-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dial-gradient-img {
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
}
|
148
frontend/src/app/components/clock-face/clock-face.component.ts
Normal file
148
frontend/src/app/components/clock-face/clock-face.component.ts
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges, OnDestroy, OnInit } from '@angular/core';
|
||||||
|
import { Subscription, tap, timer } from 'rxjs';
|
||||||
|
import { WebsocketService } from '../../services/websocket.service';
|
||||||
|
import { StateService } from '../../services/state.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-clock-face',
|
||||||
|
templateUrl: './clock-face.component.html',
|
||||||
|
styleUrls: ['./clock-face.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class ClockFaceComponent implements OnInit, OnChanges, OnDestroy {
|
||||||
|
@Input() size: number = 300;
|
||||||
|
|
||||||
|
blocksSubscription: Subscription;
|
||||||
|
timeSubscription: Subscription;
|
||||||
|
|
||||||
|
faceStyle;
|
||||||
|
dialPath;
|
||||||
|
blockTimes = [];
|
||||||
|
segments = [];
|
||||||
|
hours: number = 0;
|
||||||
|
minutes: number = 0;
|
||||||
|
minorTicks: number[] = [];
|
||||||
|
majorTicks: number[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public stateService: StateService,
|
||||||
|
private websocketService: WebsocketService,
|
||||||
|
private cd: ChangeDetectorRef
|
||||||
|
) {
|
||||||
|
this.updateTime();
|
||||||
|
this.makeTicks();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.timeSubscription = timer(0, 250).pipe(
|
||||||
|
tap(() => {
|
||||||
|
this.updateTime();
|
||||||
|
})
|
||||||
|
).subscribe();
|
||||||
|
this.blocksSubscription = this.stateService.blocks$
|
||||||
|
.subscribe(([block]) => {
|
||||||
|
if (block) {
|
||||||
|
this.blockTimes.push([block.height, new Date(block.timestamp * 1000)]);
|
||||||
|
// using block-reported times, so ensure they are sorted chronologically
|
||||||
|
this.blockTimes = this.blockTimes.sort((a, b) => a[1].getTime() - b[1].getTime());
|
||||||
|
this.updateSegments();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnChanges(): void {
|
||||||
|
this.faceStyle = {
|
||||||
|
width: `${this.size}px`,
|
||||||
|
height: `${this.size}px`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.timeSubscription.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTime(): void {
|
||||||
|
const now = new Date();
|
||||||
|
const seconds = now.getSeconds() + (now.getMilliseconds() / 1000);
|
||||||
|
this.minutes = (now.getMinutes() + (seconds / 60)) % 60;
|
||||||
|
this.hours = now.getHours() + (this.minutes / 60);
|
||||||
|
this.updateSegments();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSegments(): void {
|
||||||
|
const now = new Date();
|
||||||
|
this.blockTimes = this.blockTimes.filter(time => (now.getTime() - time[1].getTime()) <= 3600000);
|
||||||
|
const tail = new Date(now.getTime() - 3600000);
|
||||||
|
const hourStart = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours());
|
||||||
|
|
||||||
|
const times = [
|
||||||
|
['start', tail],
|
||||||
|
...this.blockTimes,
|
||||||
|
['end', now],
|
||||||
|
];
|
||||||
|
const minuteTimes = times.map(time => {
|
||||||
|
return [time[0], (time[1].getTime() - hourStart.getTime()) / 60000];
|
||||||
|
});
|
||||||
|
this.segments = [];
|
||||||
|
const r = 174;
|
||||||
|
const cx = 192;
|
||||||
|
const cy = cx;
|
||||||
|
for (let i = 1; i < minuteTimes.length; i++) {
|
||||||
|
const arc = this.getArc(minuteTimes[i-1][1], minuteTimes[i][1], r, cx, cy);
|
||||||
|
if (arc) {
|
||||||
|
arc.id = minuteTimes[i][0];
|
||||||
|
this.segments.push(arc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const arc = this.getArc(minuteTimes[0][1], minuteTimes[1][1], r, cx, cy);
|
||||||
|
if (arc) {
|
||||||
|
this.dialPath = arc.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cd.markForCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
getArc(startTime, endTime, r, cx, cy): any {
|
||||||
|
const startDegrees = (startTime + 0.2) * 6;
|
||||||
|
const endDegrees = (endTime - 0.2) * 6;
|
||||||
|
const start = this.getPointOnCircle(startDegrees, r, cx, cy);
|
||||||
|
const end = this.getPointOnCircle(endDegrees, r, cx, cy);
|
||||||
|
const arcLength = endDegrees - startDegrees;
|
||||||
|
// merge gaps and omit lines shorter than 1 degree
|
||||||
|
if (arcLength >= 1) {
|
||||||
|
const path = `M ${start.x} ${start.y} A ${r} ${r} 0 ${arcLength > 180 ? 1 : 0} 1 ${end.x} ${end.y}`;
|
||||||
|
return {
|
||||||
|
path,
|
||||||
|
start,
|
||||||
|
end
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getPointOnCircle(deg, r, cx, cy) {
|
||||||
|
const modDeg = ((deg % 360) + 360) % 360;
|
||||||
|
const rad = (modDeg * Math.PI) / 180;
|
||||||
|
return {
|
||||||
|
x: cx + (r * Math.sin(rad)),
|
||||||
|
y: cy - (r * Math.cos(rad)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
makeTicks() {
|
||||||
|
this.minorTicks = [];
|
||||||
|
this.majorTicks = [];
|
||||||
|
for (let i = 1; i < 60; i++) {
|
||||||
|
if (i % 5 === 0) {
|
||||||
|
this.majorTicks.push(i * 6);
|
||||||
|
} else {
|
||||||
|
this.minorTicks.push(i * 6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trackBySegment(index: number, segment) {
|
||||||
|
return segment.id;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1 @@
|
|||||||
|
<app-clock mode="mempool"></app-clock>
|
@ -0,0 +1,7 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-clock-mempool',
|
||||||
|
templateUrl: './clock-mempool.component.html',
|
||||||
|
})
|
||||||
|
export class ClockMempoolComponent {}
|
@ -0,0 +1 @@
|
|||||||
|
<app-clock mode="block"></app-clock>
|
@ -0,0 +1,7 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-clock-mined',
|
||||||
|
templateUrl: './clock-mined.component.html',
|
||||||
|
})
|
||||||
|
export class ClockMinedComponent {}
|
67
frontend/src/app/components/clock/clock.component.html
Normal file
67
frontend/src/app/components/clock/clock.component.html
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
<div class="clock-wrapper" [style]="wrapperStyle">
|
||||||
|
<div class="clockchain-bar" [style.height]="chainHeight + 'px'">
|
||||||
|
<div class="clockchain">
|
||||||
|
<app-clockchain [width]="chainWidth" [height]="chainHeight" [mode]="mode"></app-clockchain>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="clock-face">
|
||||||
|
<app-clock-face [size]="clockSize">
|
||||||
|
<div class="block-wrapper">
|
||||||
|
<ng-container *ngIf="block && block.height >= 0">
|
||||||
|
<ng-container *ngIf="mode === 'block'; else mempoolMode;">
|
||||||
|
<div class="block-cube">
|
||||||
|
<div class="side top"></div>
|
||||||
|
<div class="side bottom"></div>
|
||||||
|
<div class="side right" [style]="blockStyle"></div>
|
||||||
|
<div class="side left" [style]="blockStyle"></div>
|
||||||
|
<div class="side front" [style]="blockStyle"></div>
|
||||||
|
<div class="side back" [style]="blockStyle"></div>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
<ng-template #mempoolMode>
|
||||||
|
<div class="block-sizer" [style]="blockSizerStyle">
|
||||||
|
<app-mempool-block-overview [index]="0" [pixelAlign]="true"></app-mempool-block-overview>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
<div class="fader"></div>
|
||||||
|
<div class="title-wrapper">
|
||||||
|
<h1 class="block-height">{{ block.height }}</h1>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
</app-clock-face>
|
||||||
|
</div>
|
||||||
|
<ng-container *ngIf="!hideStats">
|
||||||
|
<div class="stats top left">
|
||||||
|
<p class="label" i18n="clock.fiat-price">fiat price</p>
|
||||||
|
<p>
|
||||||
|
<app-fiat [value]="100000000" digitsInfo="1.2-2" colorClass="white-color"></app-fiat>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="stats top right">
|
||||||
|
<p class="label" i18n="clock.priority-rate|priority fee rate">priority rate</p>
|
||||||
|
<p *ngIf="recommendedFees$ | async as recommendedFees;" i18n="shared.sat-vbyte|sat/vB">{{ recommendedFees.fastestFee + 300 }} sat/vB</p>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="mode !== 'mempool' && block" class="stats bottom left">
|
||||||
|
<p [innerHTML]="block.size | bytes: 2"></p>
|
||||||
|
<p class="label" i18n="clock.block-size">block size</p>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="mode !== 'mempool' && block" class="stats bottom right">
|
||||||
|
<p class="force-wrap">
|
||||||
|
<ng-container *ngTemplateOutlet="block.tx_count === 1 ? transactionsSingular : transactionsPlural; context: {$implicit: block.tx_count | number}"></ng-container>
|
||||||
|
<ng-template #transactionsSingular let-i i18n="shared.transaction-count.singular">{{ i }} <span class="label">transaction</span></ng-template>
|
||||||
|
<ng-template #transactionsPlural let-i i18n="shared.transaction-count.plural">{{ i }} <span class="label">transactions</span></ng-template>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ng-container *ngIf="mempoolInfo$ | async as mempoolInfo;">
|
||||||
|
<div *ngIf="mode === 'mempool'" class="stats bottom left">
|
||||||
|
<p [innerHTML]="mempoolInfo.usage | bytes: 0"></p>
|
||||||
|
<p class="label" i18n="dashboard.memory-usage|Memory usage">memory usage</p>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="mode === 'mempool'" class="stats bottom right">
|
||||||
|
<p>{{ mempoolInfo.size | number }}</p>
|
||||||
|
<p class="label" i18n="dashboard.unconfirmed|Unconfirmed count">unconfirmed</p>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
190
frontend/src/app/components/clock/clock.component.scss
Normal file
190
frontend/src/app/components/clock/clock.component.scss
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
.clock-wrapper {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
|
||||||
|
--chain-height: 60px;
|
||||||
|
--clock-width: 300px;
|
||||||
|
|
||||||
|
.clockchain-bar, .clock-face {
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-grow: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clockchain-bar {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 15.625%;
|
||||||
|
z-index: 2;
|
||||||
|
// overflow: hidden;
|
||||||
|
// background: #1d1f31;
|
||||||
|
// box-shadow: 0 0 15px #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-face {
|
||||||
|
position: relative;
|
||||||
|
height: 84.375%;
|
||||||
|
margin: auto;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 3;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: calc(0.055 * var(--clock-width));
|
||||||
|
line-height: calc(0.05 * var(--clock-width));
|
||||||
|
opacity: 0.8;
|
||||||
|
|
||||||
|
&.force-wrap {
|
||||||
|
word-spacing: 10000px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::ng-deep .symbol {
|
||||||
|
font-size: inherit;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: calc(0.04 * var(--clock-width));
|
||||||
|
line-height: calc(0.05 * var(--clock-width));
|
||||||
|
}
|
||||||
|
|
||||||
|
&.top {
|
||||||
|
top: calc(var(--chain-height) + 2%);
|
||||||
|
}
|
||||||
|
&.bottom {
|
||||||
|
bottom: 2%;
|
||||||
|
}
|
||||||
|
&.left {
|
||||||
|
left: 5%;
|
||||||
|
}
|
||||||
|
&.right {
|
||||||
|
right: 5%;
|
||||||
|
text-align: end;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-wrapper {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.block-height {
|
||||||
|
font-size: calc(0.2 * var(--clock-width));
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
background: radial-gradient(rgba(0,0,0,0.5), transparent 67%);
|
||||||
|
padding: calc(0.05 * var(--clock-width)) calc(0.15 * var(--clock-width));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-wrapper {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
.block-sizer {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fader {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: radial-gradient(transparent 0%, transparent 44%, #11131f 58%, #11131f 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-cube {
|
||||||
|
--side-width: calc(0.4 * var(--clock-width));
|
||||||
|
--half-side: calc(0.2 * var(--clock-width));
|
||||||
|
--neg-half-side: calc(-0.2 * var(--clock-width));
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
animation: block-spin 60s infinite linear;
|
||||||
|
position: absolute;
|
||||||
|
z-index: -1;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: var(--side-width);
|
||||||
|
height: var(--side-width);
|
||||||
|
|
||||||
|
.side {
|
||||||
|
width: var(--side-width);
|
||||||
|
height: var(--side-width);
|
||||||
|
line-height: 100px;
|
||||||
|
text-align: center;
|
||||||
|
background: #232838;
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side.top {
|
||||||
|
transform: rotateX(90deg);
|
||||||
|
margin-top: var(--neg-half-side);
|
||||||
|
}
|
||||||
|
|
||||||
|
.side.bottom {
|
||||||
|
background: #105fb0;
|
||||||
|
transform: rotateX(-90deg);
|
||||||
|
margin-top: var(--half-side);
|
||||||
|
}
|
||||||
|
|
||||||
|
.side.right {
|
||||||
|
transform: rotateY(90deg);
|
||||||
|
margin-left: var(--half-side);
|
||||||
|
}
|
||||||
|
|
||||||
|
.side.left {
|
||||||
|
transform: rotateY(-90deg);
|
||||||
|
margin-left: var(--neg-half-side);
|
||||||
|
}
|
||||||
|
|
||||||
|
.side.front {
|
||||||
|
transform: translateZ(var(--half-side));
|
||||||
|
}
|
||||||
|
|
||||||
|
.side.back {
|
||||||
|
transform: translateZ(var(--neg-half-side));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes block-spin {
|
||||||
|
0% {transform: translate(-50%, -50%) rotateX(-20deg) rotateY(0deg);}
|
||||||
|
100% {transform: translate(-50%, -50%) rotateX(-20deg) rotateY(-360deg);}
|
||||||
|
}
|
105
frontend/src/app/components/clock/clock.component.ts
Normal file
105
frontend/src/app/components/clock/clock.component.ts
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, HostListener, Input, OnInit } from '@angular/core';
|
||||||
|
import { Observable, Subscription } from 'rxjs';
|
||||||
|
import { StateService } from '../../services/state.service';
|
||||||
|
import { BlockExtended } from '../../interfaces/node-api.interface';
|
||||||
|
import { WebsocketService } from '../../services/websocket.service';
|
||||||
|
import { MempoolInfo, Recommendedfees } from '../../interfaces/websocket.interface';
|
||||||
|
import { ActivatedRoute } from '@angular/router';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-clock',
|
||||||
|
templateUrl: './clock.component.html',
|
||||||
|
styleUrls: ['./clock.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class ClockComponent implements OnInit {
|
||||||
|
@Input() mode: 'block' | 'mempool' = 'block';
|
||||||
|
hideStats: boolean = false;
|
||||||
|
blocksSubscription: Subscription;
|
||||||
|
recommendedFees$: Observable<Recommendedfees>;
|
||||||
|
mempoolInfo$: Observable<MempoolInfo>;
|
||||||
|
block: BlockExtended;
|
||||||
|
clockSize: number = 300;
|
||||||
|
chainWidth: number = 384;
|
||||||
|
chainHeight: number = 60;
|
||||||
|
blockStyle;
|
||||||
|
blockSizerStyle;
|
||||||
|
wrapperStyle;
|
||||||
|
limitWidth: number;
|
||||||
|
limitHeight: number;
|
||||||
|
|
||||||
|
gradientColors = {
|
||||||
|
'': ['#9339f4', '#105fb0'],
|
||||||
|
bisq: ['#9339f4', '#105fb0'],
|
||||||
|
liquid: ['#116761', '#183550'],
|
||||||
|
'liquidtestnet': ['#494a4a', '#272e46'],
|
||||||
|
testnet: ['#1d486f', '#183550'],
|
||||||
|
signet: ['#6f1d5d', '#471850'],
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public stateService: StateService,
|
||||||
|
private websocketService: WebsocketService,
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
private cd: ChangeDetectorRef,
|
||||||
|
) {
|
||||||
|
this.route.queryParams.subscribe((params) => {
|
||||||
|
this.hideStats = params && params.stats === 'false';
|
||||||
|
this.limitWidth = Number.parseInt(params.width) || null;
|
||||||
|
this.limitHeight = Number.parseInt(params.height) || null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.resizeCanvas();
|
||||||
|
this.websocketService.want(['blocks']);
|
||||||
|
|
||||||
|
this.blocksSubscription = this.stateService.blocks$
|
||||||
|
.subscribe(([block]) => {
|
||||||
|
if (block) {
|
||||||
|
this.block = block;
|
||||||
|
this.blockStyle = this.getStyleForBlock(this.block);
|
||||||
|
this.cd.markForCheck();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.recommendedFees$ = this.stateService.recommendedFees$;
|
||||||
|
this.mempoolInfo$ = this.stateService.mempoolInfo$;
|
||||||
|
}
|
||||||
|
|
||||||
|
getStyleForBlock(block: BlockExtended) {
|
||||||
|
const greenBackgroundHeight = 100 - (block.weight / this.stateService.env.BLOCK_WEIGHT_UNITS) * 100;
|
||||||
|
|
||||||
|
return {
|
||||||
|
background: `repeating-linear-gradient(
|
||||||
|
#2d3348,
|
||||||
|
#2d3348 ${greenBackgroundHeight}%,
|
||||||
|
${this.gradientColors[''][0]} ${Math.max(greenBackgroundHeight, 0)}%,
|
||||||
|
${this.gradientColors[''][1]} 100%
|
||||||
|
)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('window:resize', ['$event'])
|
||||||
|
resizeCanvas(): void {
|
||||||
|
const windowWidth = this.limitWidth || window.innerWidth;
|
||||||
|
const windowHeight = this.limitHeight || window.innerHeight;
|
||||||
|
this.chainWidth = windowWidth;
|
||||||
|
this.chainHeight = Math.max(60, windowHeight / 8);
|
||||||
|
this.clockSize = Math.min(800, windowWidth, windowHeight - (1.4 * this.chainHeight));
|
||||||
|
const size = Math.ceil(this.clockSize / 75) * 75;
|
||||||
|
const margin = (this.clockSize - size) / 2;
|
||||||
|
this.blockSizerStyle = {
|
||||||
|
transform: `translate(${margin}px, ${margin}px)`,
|
||||||
|
width: `${size}px`,
|
||||||
|
height: `${size}px`,
|
||||||
|
};
|
||||||
|
this.wrapperStyle = {
|
||||||
|
'--clock-width': `${this.clockSize}px`,
|
||||||
|
'--chain-height': `${this.chainHeight}px`,
|
||||||
|
'width': this.limitWidth ? `${this.limitWidth}px` : undefined,
|
||||||
|
'height': this.limitHeight ? `${this.limitHeight}px` : undefined,
|
||||||
|
};
|
||||||
|
this.cd.markForCheck();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,28 @@
|
|||||||
|
<div
|
||||||
|
class="text-center" class="blockchain-wrapper" [class.time-ltr]="timeLtr" #container
|
||||||
|
[class.ltr-transition]="ltrTransitionEnabled" [style.width]="width + 'px'" [style.height]="height + 'px'"
|
||||||
|
>
|
||||||
|
<div class="position-container" [ngClass]="network ? network : ''" [style.top]="(height / 3) + 'px'">
|
||||||
|
<span>
|
||||||
|
<div class="blocks-wrapper">
|
||||||
|
<app-mempool-blocks [minimal]="true" [count]="mempoolBlocks" [blockWidth]="blockWidth" [spotlight]="mode === 'mempool' ? 1 : 0"></app-mempool-blocks>
|
||||||
|
<app-blockchain-blocks [minimal]="true" [count]="blockchainBlocks" [blockWidth]="blockWidth" [spotlight]="mode === 'block' ? -1 : 0"></app-blockchain-blocks>
|
||||||
|
</div>
|
||||||
|
<div class="divider" [style.top]="-(height / 6) + 'px'">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 2 175"
|
||||||
|
[style.width]="'2px'"
|
||||||
|
[style.height]="(5 * height / 6) + 'px'"
|
||||||
|
>
|
||||||
|
<line
|
||||||
|
class="divider-line"
|
||||||
|
x0="0"
|
||||||
|
x1="0"
|
||||||
|
y0="0"
|
||||||
|
y1="175px"
|
||||||
|
></line>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -0,0 +1,94 @@
|
|||||||
|
.divider {
|
||||||
|
position: absolute;
|
||||||
|
left: -0.5px;
|
||||||
|
top: 0;
|
||||||
|
.divider-line {
|
||||||
|
stroke: white;
|
||||||
|
stroke-width: 4px;
|
||||||
|
stroke-linecap: butt;
|
||||||
|
stroke-dasharray: 25px 25px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.blockchain-wrapper {
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
-webkit-user-select: none; /* Safari */
|
||||||
|
-moz-user-select: none; /* Firefox */
|
||||||
|
-ms-user-select: none; /* IE10+/Edge */
|
||||||
|
user-select: none; /* Standard */
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-container {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.black-background {
|
||||||
|
background-color: #11131f;
|
||||||
|
z-index: 100;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-spacer {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-block {
|
||||||
|
position: absolute;
|
||||||
|
text-align: center;
|
||||||
|
margin: auto;
|
||||||
|
width: 300px;
|
||||||
|
left: -150px;
|
||||||
|
top: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-toggle {
|
||||||
|
color: white;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
position: absolute;
|
||||||
|
bottom: -1.8em;
|
||||||
|
left: 1px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blockchain-wrapper.ltr-transition .blocks-wrapper,
|
||||||
|
.blockchain-wrapper.ltr-transition .position-container,
|
||||||
|
.blockchain-wrapper.ltr-transition .time-toggle {
|
||||||
|
transition: transform 1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blockchain-wrapper.time-ltr {
|
||||||
|
.blocks-wrapper {
|
||||||
|
transform: scaleX(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-toggle {
|
||||||
|
transform: translateX(-50%) scaleX(-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(.ltr-layout) {
|
||||||
|
.blockchain-wrapper.time-ltr .blocks-wrapper,
|
||||||
|
.blockchain-wrapper .blocks-wrapper {
|
||||||
|
direction: ltr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(.rtl-layout) {
|
||||||
|
.blockchain-wrapper.time-ltr .blocks-wrapper,
|
||||||
|
.blockchain-wrapper .blocks-wrapper {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,73 @@
|
|||||||
|
import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, Input, OnChanges, ChangeDetectorRef } from '@angular/core';
|
||||||
|
import { firstValueFrom, Subscription } from 'rxjs';
|
||||||
|
import { StateService } from '../../services/state.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-clockchain',
|
||||||
|
templateUrl: './clockchain.component.html',
|
||||||
|
styleUrls: ['./clockchain.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class ClockchainComponent implements OnInit, OnChanges, OnDestroy {
|
||||||
|
@Input() width: number = 300;
|
||||||
|
@Input() height: number = 60;
|
||||||
|
@Input() mode: 'mempool' | 'block';
|
||||||
|
|
||||||
|
mempoolBlocks: number = 3;
|
||||||
|
blockchainBlocks: number = 6;
|
||||||
|
blockWidth: number = 50;
|
||||||
|
dividerStyle;
|
||||||
|
|
||||||
|
network: string;
|
||||||
|
timeLtrSubscription: Subscription;
|
||||||
|
timeLtr: boolean = this.stateService.timeLtr.value;
|
||||||
|
ltrTransitionEnabled = false;
|
||||||
|
connectionStateSubscription: Subscription;
|
||||||
|
loadingTip: boolean = true;
|
||||||
|
connected: boolean = true;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public stateService: StateService,
|
||||||
|
private cd: ChangeDetectorRef,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.ngOnChanges();
|
||||||
|
|
||||||
|
this.network = this.stateService.network;
|
||||||
|
this.timeLtrSubscription = this.stateService.timeLtr.subscribe((ltr) => {
|
||||||
|
this.timeLtr = !!ltr;
|
||||||
|
});
|
||||||
|
this.connectionStateSubscription = this.stateService.connectionState$.subscribe(state => {
|
||||||
|
this.connected = (state === 2);
|
||||||
|
});
|
||||||
|
firstValueFrom(this.stateService.chainTip$).then(() => {
|
||||||
|
this.loadingTip = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnChanges() {
|
||||||
|
this.blockWidth = Math.floor(7 * this.height / 12);
|
||||||
|
this.mempoolBlocks = Math.floor(((this.width / 2) - (this.blockWidth * 0.32)) / (1.24 * this.blockWidth));
|
||||||
|
this.blockchainBlocks = this.mempoolBlocks;
|
||||||
|
this.dividerStyle = {
|
||||||
|
width: '2px',
|
||||||
|
height: `${this.height}px`,
|
||||||
|
};
|
||||||
|
this.cd.markForCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy() {
|
||||||
|
this.timeLtrSubscription.unsubscribe();
|
||||||
|
this.connectionStateSubscription.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
trackByPageFn(index: number, item: { index: number }) {
|
||||||
|
return item.index;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleTimeDirection() {
|
||||||
|
this.ltrTransitionEnabled = true;
|
||||||
|
this.stateService.timeLtr.next(!this.timeLtr);
|
||||||
|
}
|
||||||
|
}
|
@ -14,7 +14,7 @@
|
|||||||
<a [routerLink]="['/block' | relativeUrl, diffChange.height]">{{ diffChange.height }}</a>
|
<a [routerLink]="['/block' | relativeUrl, diffChange.height]">{{ diffChange.height }}</a>
|
||||||
</td>
|
</td>
|
||||||
<td class="date text-left">
|
<td class="date text-left">
|
||||||
<app-time kind="since" [time]="diffChange.timestamp" [fastRender]="true"></app-time>
|
<app-time kind="since" [time]="diffChange.timestamp" [fastRender]="true" [precision]="1"></app-time>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-right">{{ diffChange.difficultyShorten }}</td>
|
<td class="text-right">{{ diffChange.difficultyShorten }}</td>
|
||||||
<td class="text-right" [style]="diffChange.change >= 0 ? 'color: #42B747' : 'color: #B74242'">
|
<td class="text-right" [style]="diffChange.change >= 0 ? 'color: #42B747' : 'color: #B74242'">
|
||||||
|
@ -10,7 +10,7 @@
|
|||||||
<ng-template #blocksPlural let-i i18n="shared.blocks">{{ i }} <span class="shared-block">blocks</span></ng-template>
|
<ng-template #blocksPlural let-i i18n="shared.blocks">{{ i }} <span class="shared-block">blocks</span></ng-template>
|
||||||
<ng-template #blocksSingular let-i i18n="shared.block">{{ i }} <span class="shared-block">block</span></ng-template>
|
<ng-template #blocksSingular let-i i18n="shared.block">{{ i }} <span class="shared-block">block</span></ng-template>
|
||||||
</div>
|
</div>
|
||||||
<div class="symbol"><app-time kind="until" [time]="epochData.estimatedRetargetDate" [fastRender]="true"></app-time></div>
|
<div class="symbol"><app-time kind="until" [time]="epochData.estimatedRetargetDate" [fastRender]="true" [precision]="1"></app-time></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="item">
|
<div class="item">
|
||||||
<h5 class="card-title" i18n="difficulty-box.estimate">Estimate</h5>
|
<h5 class="card-title" i18n="difficulty-box.estimate">Estimate</h5>
|
||||||
@ -54,7 +54,7 @@
|
|||||||
<ng-template #blocksPlural let-i i18n="shared.blocks">{{ i }} <span class="shared-block">blocks</span></ng-template>
|
<ng-template #blocksPlural let-i i18n="shared.blocks">{{ i }} <span class="shared-block">blocks</span></ng-template>
|
||||||
<ng-template #blocksSingular let-i i18n="shared.block">{{ i }} <span class="shared-block">block</span></ng-template>
|
<ng-template #blocksSingular let-i i18n="shared.block">{{ i }} <span class="shared-block">block</span></ng-template>
|
||||||
</div>
|
</div>
|
||||||
<div class="symbol"><app-time kind="until" [time]="epochData.timeUntilHalving" [fastRender]="true"></app-time></div>
|
<div class="symbol"><app-time kind="until" [time]="epochData.timeUntilHalving" [fastRender]="true" [precision]="1"></app-time></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -37,7 +37,7 @@
|
|||||||
<div class="difficulty-stats">
|
<div class="difficulty-stats">
|
||||||
<div class="item">
|
<div class="item">
|
||||||
<div class="card-text">
|
<div class="card-text">
|
||||||
~<app-time [time]="epochData.timeAvg / 1000" [forceFloorOnTimeIntervals]="['minute']" [fractionDigits]="1"></app-time>
|
~<app-time [time]="epochData.timeAvg / 1000" [fractionDigits]="1"></app-time>
|
||||||
</div>
|
</div>
|
||||||
<div class="symbol" i18n="difficulty-box.average-block-time">Average block time</div>
|
<div class="symbol" i18n="difficulty-box.average-block-time">Average block time</div>
|
||||||
</div>
|
</div>
|
||||||
@ -68,7 +68,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="item">
|
<div class="item">
|
||||||
<div class="card-text"><app-time kind="until" [time]="epochData.estimatedRetargetDate" [fastRender]="true"></app-time></div>
|
<div class="card-text"><app-time kind="until" [time]="epochData.estimatedRetargetDate" [fastRender]="true" [precision]="1"></app-time></div>
|
||||||
<div class="symbol">
|
<div class="symbol">
|
||||||
{{ epochData.retargetDateString }}
|
{{ epochData.retargetDateString }}
|
||||||
</div>
|
</div>
|
||||||
|
@ -4,6 +4,9 @@
|
|||||||
@media (min-width: 465px) {
|
@media (min-width: 465px) {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
|
@media (min-width: 992px) {
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-title {
|
.main-title {
|
||||||
@ -18,16 +21,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.full-container {
|
.full-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
padding: 0px 15px;
|
padding: 0px 15px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 500px;
|
height: calc(100vh - 250px);
|
||||||
height: calc(100% - 150px);
|
@media (min-width: 992px) {
|
||||||
@media (max-width: 992px) {
|
height: calc(100vh - 150px);
|
||||||
padding-bottom: 100px;
|
}
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart {
|
.chart {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding-bottom: 20px;
|
padding-bottom: 20px;
|
||||||
|
@ -4,6 +4,9 @@
|
|||||||
@media (min-width: 465px) {
|
@media (min-width: 465px) {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
|
@media (min-width: 992px) {
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-title {
|
.main-title {
|
||||||
@ -18,18 +21,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.full-container {
|
.full-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
padding: 0px 15px;
|
padding: 0px 15px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 500px;
|
height: calc(100vh - 250px);
|
||||||
height: calc(100% - 150px);
|
@media (min-width: 992px) {
|
||||||
@media (max-width: 992px) {
|
height: calc(100vh - 150px);
|
||||||
padding-bottom: 100px;
|
}
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart {
|
.chart {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
|
||||||
padding-bottom: 20px;
|
padding-bottom: 20px;
|
||||||
padding-right: 10px;
|
padding-right: 10px;
|
||||||
@media (max-width: 992px) {
|
@media (max-width: 992px) {
|
||||||
|
@ -90,9 +90,12 @@
|
|||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<app-testnet-alert *ngIf="network.val === 'liquidtestnet'"></app-testnet-alert>
|
||||||
|
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
<router-outlet></router-outlet>
|
<router-outlet></router-outlet>
|
||||||
|
|
||||||
<br>
|
<app-global-footer></app-global-footer>
|
||||||
|
|
||||||
</ng-container>
|
</ng-container>
|
@ -62,10 +62,11 @@
|
|||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<br />
|
<app-testnet-alert *ngIf="network.val === 'testnet' || network.val === 'signet'"></app-testnet-alert>
|
||||||
|
|
||||||
<router-outlet></router-outlet>
|
<main style="margin-top: 24px;">
|
||||||
|
<router-outlet></router-outlet>
|
||||||
<br>
|
</main>
|
||||||
|
|
||||||
|
<app-global-footer></app-global-footer>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
@ -192,4 +192,4 @@ nav {
|
|||||||
margin: 33px 0px 0px -19px;
|
margin: 33px 0px 0px -19px;
|
||||||
font-size: 7px;
|
font-size: 7px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { Component, Inject, OnInit } from '@angular/core';
|
import { Component, OnInit } from '@angular/core';
|
||||||
import { Env, StateService } from '../../services/state.service';
|
import { Env, StateService } from '../../services/state.service';
|
||||||
import { Observable, merge, of } from 'rxjs';
|
import { Observable, merge, of } from 'rxjs';
|
||||||
import { LanguageService } from '../../services/language.service';
|
import { LanguageService } from '../../services/language.service';
|
||||||
|
@ -5,5 +5,6 @@
|
|||||||
[blockLimit]="stateService.blockVSize"
|
[blockLimit]="stateService.blockVSize"
|
||||||
[orientation]="timeLtr ? 'right' : 'left'"
|
[orientation]="timeLtr ? 'right' : 'left'"
|
||||||
[flip]="true"
|
[flip]="true"
|
||||||
|
[pixelAlign]="pixelAlign"
|
||||||
(txClickEvent)="onTxClick($event)"
|
(txClickEvent)="onTxClick($event)"
|
||||||
></app-block-overview-graph>
|
></app-block-overview-graph>
|
||||||
|
@ -16,6 +16,7 @@ import { Router } from '@angular/router';
|
|||||||
})
|
})
|
||||||
export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChanges, AfterViewInit {
|
export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChanges, AfterViewInit {
|
||||||
@Input() index: number;
|
@Input() index: number;
|
||||||
|
@Input() pixelAlign: boolean = false;
|
||||||
@Output() txPreviewEvent = new EventEmitter<TransactionStripped | void>();
|
@Output() txPreviewEvent = new EventEmitter<TransactionStripped | void>();
|
||||||
|
|
||||||
@ViewChild('blockGraph') blockGraph: BlockOverviewGraphComponent;
|
@ViewChild('blockGraph') blockGraph: BlockOverviewGraphComponent;
|
||||||
@ -99,7 +100,7 @@ export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChang
|
|||||||
const direction = (this.blockIndex == null || this.index < this.blockIndex) ? this.poolDirection : this.chainDirection;
|
const direction = (this.blockIndex == null || this.index < this.blockIndex) ? this.poolDirection : this.chainDirection;
|
||||||
this.blockGraph.replace(delta.added, direction);
|
this.blockGraph.replace(delta.added, direction);
|
||||||
} else {
|
} else {
|
||||||
this.blockGraph.update(delta.added, delta.removed, blockMined ? this.chainDirection : this.poolDirection, blockMined);
|
this.blockGraph.update(delta.added, delta.removed, delta.changed || [], blockMined ? this.chainDirection : this.poolDirection, blockMined);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.lastBlockHeight = this.stateService.latestBlockHeight;
|
this.lastBlockHeight = this.stateService.latestBlockHeight;
|
||||||
@ -107,8 +108,12 @@ export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChang
|
|||||||
this.isLoading$.next(false);
|
this.isLoading$.next(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
onTxClick(event: TransactionStripped): void {
|
onTxClick(event: { tx: TransactionStripped, keyModifier: boolean }): void {
|
||||||
const url = new RelativeUrlPipe(this.stateService).transform(`/tx/${event.txid}`);
|
const url = new RelativeUrlPipe(this.stateService).transform(`/tx/${event.tx.txid}`);
|
||||||
this.router.navigate([url]);
|
if (!event.keyModifier) {
|
||||||
|
this.router.navigate([url]);
|
||||||
|
} else {
|
||||||
|
window.open(url, '_blank');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,40 +1,47 @@
|
|||||||
<ng-container *ngIf="(loadingBlocks$ | async) === false; else loadingBlocks">
|
<ng-container *ngIf="(loadingBlocks$ | async) === false; else loadingBlocks" [class.minimal]="minimal">
|
||||||
<div class="mempool-blocks-container" [class.time-ltr]="timeLtr" *ngIf="(difficultyAdjustments$ | async) as da;">
|
<div class="mempool-blocks-container" [class.time-ltr]="timeLtr" [style.--block-size]="blockWidth+'px'" *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
|
||||||
|
*ngIf="minimal && spotlight > 0 && spotlight === i + 1"
|
||||||
|
class="spotlight-bottom"
|
||||||
|
[style.right]="mempoolBlockStyles[i].right"
|
||||||
|
></div>
|
||||||
|
<div @blockEntryTrigger [@.disabled]="i > 0 || !animateEntry" [attr.data-cy]="'mempool-block-' + i" class="bitcoin-block text-center mempool-block" [class.hide-block]="count && i >= count" 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)}"> </a>
|
class="blockLink" [ngClass]="{'disabled': (this.stateService.blockScrolling$ | async)}"> </a>
|
||||||
<div class="block-body">
|
<div class="block-body">
|
||||||
<div [attr.data-cy]="'mempool-block-' + i + '-fees'" class="fees">
|
<ng-container *ngIf="!minimal">
|
||||||
~{{ projectedBlock.medianFee | number:feeRounding }} <span i18n="shared.sat-vbyte|sat/vB">sat/vB</span>
|
<div [attr.data-cy]="'mempool-block-' + i + '-fees'" class="fees">
|
||||||
</div>
|
~{{ projectedBlock.medianFee | number:feeRounding }} <span i18n="shared.sat-vbyte|sat/vB">sat/vB</span>
|
||||||
<div [attr.data-cy]="'mempool-block-' + i + '-fee-span'" class="fee-span">
|
|
||||||
{{ projectedBlock.feeRange[0] | number:feeRounding }} - {{ projectedBlock.feeRange[projectedBlock.feeRange.length - 1] | number:feeRounding }} <span i18n="shared.sat-vbyte|sat/vB">sat/vB</span>
|
|
||||||
</div>
|
|
||||||
<div *ngIf="showMiningInfo" class="block-size">
|
|
||||||
<app-amount [attr.data-cy]="'mempool-block-' + i + '-total-fees'" [satoshis]="projectedBlock.totalFees" digitsInfo="1.2-3" [noFiat]="true"></app-amount>
|
|
||||||
</div>
|
|
||||||
<div *ngIf="!showMiningInfo" class="block-size" [innerHTML]="'‎' + (projectedBlock.blockSize | bytes: 2)"></div>
|
|
||||||
<div [attr.data-cy]="'mempool-block-' + i + '-transaction-count'" class="transaction-count">
|
|
||||||
<ng-container *ngTemplateOutlet="projectedBlock.nTx === 1 ? transactionsSingular : transactionsPlural; context: {$implicit: projectedBlock.nTx | number}"></ng-container>
|
|
||||||
<ng-template #transactionsSingular let-i i18n="shared.transaction-count.singular">{{ i }} transaction</ng-template>
|
|
||||||
<ng-template #transactionsPlural let-i i18n="shared.transaction-count.plural">{{ i }} transactions</ng-template>
|
|
||||||
</div>
|
|
||||||
<div [attr.data-cy]="'mempool-block-' + i + '-time'" class="time-difference" *ngIf="projectedBlock.blockVSize <= stateService.blockVSize; else mergedBlock">
|
|
||||||
<ng-template [ngIf]="network === 'liquid' || network === 'liquidtestnet'" [ngIfElse]="timeDiffMainnet">
|
|
||||||
<app-time kind="until" [time]="(1 * i) + now + 61000" [fastRender]="false" [fixedRender]="true"></app-time>
|
|
||||||
</ng-template>
|
|
||||||
<ng-template #timeDiffMainnet>
|
|
||||||
<app-time kind="until" [time]="da.timeAvg * (i + 1) + now + da.timeOffset" [fastRender]="false" [fixedRender]="true" [forceFloorOnTimeIntervals]="['hour']"></app-time>
|
|
||||||
</ng-template>
|
|
||||||
</div>
|
|
||||||
<ng-template #mergedBlock>
|
|
||||||
<div [attr.data-cy]="'mempool-block-' + i + '-blocks'" class="time-difference">
|
|
||||||
<b>(<ng-container *ngTemplateOutlet="blocksPlural; context: {$implicit: projectedBlock.blockVSize / stateService.blockVSize | ceil }"></ng-container>)</b>
|
|
||||||
<ng-template #blocksPlural let-i i18n="shared.blocks">{{ i }} <span class="shared-block">blocks</span></ng-template>
|
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
<div [attr.data-cy]="'mempool-block-' + i + '-fee-span'" class="fee-span">
|
||||||
|
{{ projectedBlock.feeRange[0] | number:feeRounding }} - {{ projectedBlock.feeRange[projectedBlock.feeRange.length - 1] | number:feeRounding }} <span i18n="shared.sat-vbyte|sat/vB">sat/vB</span>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="showMiningInfo" class="block-size">
|
||||||
|
<app-amount [attr.data-cy]="'mempool-block-' + i + '-total-fees'" [satoshis]="projectedBlock.totalFees" digitsInfo="1.2-3" [noFiat]="true"></app-amount>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="!showMiningInfo" class="block-size" [innerHTML]="'‎' + (projectedBlock.blockSize | bytes: 2)"></div>
|
||||||
|
<div [attr.data-cy]="'mempool-block-' + i + '-transaction-count'" class="transaction-count">
|
||||||
|
<ng-container *ngTemplateOutlet="projectedBlock.nTx === 1 ? transactionsSingular : transactionsPlural; context: {$implicit: projectedBlock.nTx | number}"></ng-container>
|
||||||
|
<ng-template #transactionsSingular let-i i18n="shared.transaction-count.singular">{{ i }} transaction</ng-template>
|
||||||
|
<ng-template #transactionsPlural let-i i18n="shared.transaction-count.plural">{{ i }} transactions</ng-template>
|
||||||
|
</div>
|
||||||
|
<div [attr.data-cy]="'mempool-block-' + i + '-time'" class="time-difference" *ngIf="projectedBlock.blockVSize <= stateService.blockVSize; else mergedBlock">
|
||||||
|
<ng-template [ngIf]="network === 'liquid' || network === 'liquidtestnet'" [ngIfElse]="timeDiffMainnet">
|
||||||
|
<app-time kind="until" [time]="(1 * i) + now + 61000" [fastRender]="false" [fixedRender]="true"></app-time>
|
||||||
|
</ng-template>
|
||||||
|
<ng-template #timeDiffMainnet>
|
||||||
|
<app-time kind="until" [time]="da.timeAvg * (i + 1) + now + da.timeOffset" [fastRender]="false" [fixedRender]="true"></app-time>
|
||||||
|
</ng-template>
|
||||||
|
</div>
|
||||||
|
<ng-template #mergedBlock>
|
||||||
|
<div [attr.data-cy]="'mempool-block-' + i + '-blocks'" class="time-difference">
|
||||||
|
<b>(<ng-container *ngTemplateOutlet="blocksPlural; context: {$implicit: projectedBlock.blockVSize / stateService.blockVSize | ceil }"></ng-container>)</b>
|
||||||
|
<ng-template #blocksPlural let-i i18n="shared.blocks">{{ i }} <span class="shared-block">blocks</span></ng-template>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
</ng-container>
|
||||||
</div>
|
</div>
|
||||||
<span class="animated-border"></span>
|
<span class="animated-border"></span>
|
||||||
</div>
|
</div>
|
||||||
@ -45,10 +52,10 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-template #loadingBlocks>
|
<ng-template #loadingBlocks>
|
||||||
<div class="mempool-blocks-container" [class.time-ltr]="timeLtr">
|
<div class="mempool-blocks-container" [class.time-ltr]="timeLtr" [style.--block-size]="blockWidth+'px'">
|
||||||
<div class="flashing">
|
<div class="flashing">
|
||||||
<ng-template ngFor let-projectedBlock [ngForOf]="mempoolEmptyBlocks" let-i="index" [ngForTrackBy]="trackByFn">
|
<ng-template ngFor let-projectedBlock [ngForOf]="mempoolEmptyBlocks" let-i="index" [ngForTrackBy]="trackByFn">
|
||||||
<div class="bitcoin-block text-center mempool-block" id="mempool-block-{{ i }}" [ngStyle]="mempoolEmptyBlockStyles[i]"></div>
|
<div class="bitcoin-block text-center mempool-block" [class.hide-block]="count && i >= count" id="mempool-block-{{ i }}" [ngStyle]="mempoolEmptyBlockStyles[i]"></div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
.bitcoin-block {
|
.bitcoin-block {
|
||||||
width: 125px;
|
width: var(--block-size);
|
||||||
height: 125px;
|
height: var(--block-size);
|
||||||
transition: background 2s, right 2s, transform 1s;
|
transition: background 2s, right 2s, transform 1s, opacity 1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.block-size {
|
.block-size {
|
||||||
@ -14,6 +14,7 @@
|
|||||||
top: 0px;
|
top: 0px;
|
||||||
right: 0px;
|
right: 0px;
|
||||||
left: 0px;
|
left: 0px;
|
||||||
|
--block-size: 125px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.flashing {
|
.flashing {
|
||||||
@ -66,11 +67,11 @@
|
|||||||
|
|
||||||
.bitcoin-block::after {
|
.bitcoin-block::after {
|
||||||
content: '';
|
content: '';
|
||||||
width: 125px;
|
width: var(--block-size);
|
||||||
height: 24px;
|
height: calc(0.192 * var(--block-size));
|
||||||
position:absolute;
|
position:absolute;
|
||||||
top: -24px;
|
top: calc(-0.192 * var(--block-size));
|
||||||
left: -20px;
|
left: calc(-0.16 * var(--block-size));
|
||||||
background-color: #232838;
|
background-color: #232838;
|
||||||
transform:skew(40deg);
|
transform:skew(40deg);
|
||||||
transform-origin:top;
|
transform-origin:top;
|
||||||
@ -79,11 +80,11 @@
|
|||||||
|
|
||||||
.bitcoin-block::before {
|
.bitcoin-block::before {
|
||||||
content: '';
|
content: '';
|
||||||
width: 20px;
|
width: calc(0.16 * var(--block-size));
|
||||||
height: 125px;
|
height: var(--block-size);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -12px;
|
top: calc(-0.096 * var(--block-size));
|
||||||
left: -20px;
|
left: calc(-0.16 * var(--block-size));
|
||||||
background-color: #191c27;
|
background-color: #191c27;
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
|
|
||||||
@ -100,6 +101,10 @@
|
|||||||
background-color: #2d2825;
|
background-color: #2d2825;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mempool-block.hide-block {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.black-background {
|
.black-background {
|
||||||
background-color: #11131f;
|
background-color: #11131f;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
@ -141,7 +146,7 @@
|
|||||||
|
|
||||||
.bitcoin-block::before {
|
.bitcoin-block::before {
|
||||||
transform: skewY(-50deg);
|
transform: skewY(-50deg);
|
||||||
left: 125px;
|
left: var(--block-size);
|
||||||
}
|
}
|
||||||
.block-body {
|
.block-body {
|
||||||
transform: scaleX(-1);
|
transform: scaleX(-1);
|
||||||
@ -152,4 +157,16 @@
|
|||||||
#arrow-up {
|
#arrow-up {
|
||||||
transform: translateX(70px);
|
transform: translateX(70px);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.spotlight-bottom {
|
||||||
|
position: absolute;
|
||||||
|
width: calc(0.6 * var(--block-size));
|
||||||
|
height: calc(0.25 * var(--block-size));
|
||||||
|
border-left: solid calc(0.3 * var(--block-size)) transparent;
|
||||||
|
border-bottom: solid calc(0.3 * var(--block-size)) white;
|
||||||
|
border-right: solid calc(0.3 * var(--block-size)) transparent;
|
||||||
|
transform: translate(calc(-0.2 * var(--block-size)), calc(1.1 * var(--block-size)));
|
||||||
|
border-radius: 2px;
|
||||||
|
z-index: -1;
|
||||||
}
|
}
|
@ -1,4 +1,4 @@
|
|||||||
import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
|
import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, ChangeDetectorRef, HostListener, Input, OnChanges, SimpleChanges } 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({
|
||||||
@ -23,7 +23,12 @@ import { animate, style, transition, trigger } from '@angular/animations';
|
|||||||
])],
|
])],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class MempoolBlocksComponent implements OnInit, OnDestroy {
|
export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy {
|
||||||
|
@Input() minimal: boolean = false;
|
||||||
|
@Input() blockWidth: number = 125;
|
||||||
|
@Input() count: number = null;
|
||||||
|
@Input() spotlight: number = 0;
|
||||||
|
|
||||||
specialBlocks = specialBlocks;
|
specialBlocks = specialBlocks;
|
||||||
mempoolBlocks: MempoolBlock[] = [];
|
mempoolBlocks: MempoolBlock[] = [];
|
||||||
mempoolEmptyBlocks: MempoolBlock[] = this.mountEmptyBlocks();
|
mempoolEmptyBlocks: MempoolBlock[] = this.mountEmptyBlocks();
|
||||||
@ -48,8 +53,9 @@ export class MempoolBlocksComponent implements OnInit, OnDestroy {
|
|||||||
timeLtr: boolean;
|
timeLtr: boolean;
|
||||||
animateEntry: boolean = false;
|
animateEntry: boolean = false;
|
||||||
|
|
||||||
blockWidth = 125;
|
blockOffset: number = 155;
|
||||||
blockPadding = 30;
|
blockPadding: number = 30;
|
||||||
|
containerOffset: number = 40;
|
||||||
arrowVisible = false;
|
arrowVisible = false;
|
||||||
tabHidden = false;
|
tabHidden = false;
|
||||||
feeRounding = '1.0-0';
|
feeRounding = '1.0-0';
|
||||||
@ -58,6 +64,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 +159,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;
|
||||||
}
|
}
|
||||||
@ -213,6 +224,14 @@ export class MempoolBlocksComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
|
if (changes.blockWidth && this.blockWidth) {
|
||||||
|
this.blockPadding = 0.24 * this.blockWidth;
|
||||||
|
this.containerOffset = 0.32 * this.blockWidth;
|
||||||
|
this.blockOffset = this.blockWidth + this.blockPadding;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
this.markBlocksSubscription.unsubscribe();
|
this.markBlocksSubscription.unsubscribe();
|
||||||
this.blockSubscription.unsubscribe();
|
this.blockSubscription.unsubscribe();
|
||||||
@ -222,23 +241,35 @@ 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[] {
|
||||||
const innerWidth = this.stateService.env.BASE_MODULE !== 'liquid' && window.innerWidth <= 767.98 ? window.innerWidth : window.innerWidth / 2;
|
const innerWidth = this.stateService.env.BASE_MODULE !== 'liquid' && window.innerWidth <= 767.98 ? window.innerWidth : window.innerWidth / 2;
|
||||||
const blocksAmount = Math.min(this.stateService.env.MEMPOOL_BLOCKS_AMOUNT, Math.floor(innerWidth / (this.blockWidth + this.blockPadding)));
|
let blocksAmount;
|
||||||
|
if (this.count) {
|
||||||
|
blocksAmount = 8;
|
||||||
|
} else {
|
||||||
|
blocksAmount = Math.min(this.stateService.env.MEMPOOL_BLOCKS_AMOUNT, Math.floor(innerWidth / (this.blockWidth + this.blockPadding)));
|
||||||
|
}
|
||||||
while (blocks.length > blocksAmount) {
|
while (blocks.length > blocksAmount) {
|
||||||
const block = blocks.pop();
|
const block = blocks.pop();
|
||||||
const lastBlock = blocks[blocks.length - 1];
|
if (!this.count) {
|
||||||
lastBlock.blockSize += block.blockSize;
|
const lastBlock = blocks[blocks.length - 1];
|
||||||
lastBlock.blockVSize += block.blockVSize;
|
lastBlock.blockSize += block.blockSize;
|
||||||
lastBlock.nTx += block.nTx;
|
lastBlock.blockVSize += block.blockVSize;
|
||||||
lastBlock.feeRange = lastBlock.feeRange.concat(block.feeRange);
|
lastBlock.nTx += block.nTx;
|
||||||
lastBlock.feeRange.sort((a, b) => a - b);
|
lastBlock.feeRange = lastBlock.feeRange.concat(block.feeRange);
|
||||||
lastBlock.medianFee = this.median(lastBlock.feeRange);
|
lastBlock.feeRange.sort((a, b) => a - b);
|
||||||
lastBlock.totalFees += block.totalFees;
|
lastBlock.medianFee = this.median(lastBlock.feeRange);
|
||||||
|
lastBlock.totalFees += block.totalFees;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (blocks.length) {
|
if (blocks.length) {
|
||||||
blocks[blocks.length - 1].isStack = blocks[blocks.length - 1].blockVSize > this.stateService.blockVSize;
|
blocks[blocks.length - 1].isStack = blocks[blocks.length - 1].blockVSize > this.stateService.blockVSize;
|
||||||
@ -284,20 +315,20 @@ export class MempoolBlocksComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'right': 40 + index * 155 + 'px',
|
'right': this.containerOffset + index * this.blockOffset + 'px',
|
||||||
'background': backgroundGradients.join(',') + ')'
|
'background': backgroundGradients.join(',') + ')'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
getStyleForMempoolEmptyBlock(index: number) {
|
getStyleForMempoolEmptyBlock(index: number) {
|
||||||
return {
|
return {
|
||||||
'right': 40 + index * 155 + 'px',
|
'right': this.containerOffset + index * this.blockOffset + 'px',
|
||||||
'background': '#554b45',
|
'background': '#554b45',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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,33 +346,43 @@ export class MempoolBlocksComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
this.arrowVisible = true;
|
this.arrowVisible = true;
|
||||||
|
|
||||||
let found = false;
|
if (this.txPosition) {
|
||||||
for (let txInBlockIndex = 0; txInBlockIndex < this.mempoolBlocks.length && !found; txInBlockIndex++) {
|
if (this.txPosition.block >= this.mempoolBlocks.length) {
|
||||||
const block = this.mempoolBlocks[txInBlockIndex];
|
this.rightPosition = ((this.mempoolBlocks.length - 1) * (this.blockWidth + this.blockPadding)) + this.blockWidth;
|
||||||
for (let i = 0; i < block.feeRange.length - 1 && !found; i++) {
|
} else {
|
||||||
if (this.txFeePerVSize < block.feeRange[i + 1] && this.txFeePerVSize >= block.feeRange[i]) {
|
const positionInBlock = Math.min(1, this.txPosition.vsize / this.stateService.blockVSize) * this.blockWidth;
|
||||||
const feeRangeIndex = i;
|
const positionOfBlock = this.txPosition.block * (this.blockWidth + this.blockPadding);
|
||||||
const feeRangeChunkSize = 1 / (block.feeRange.length - 1);
|
this.rightPosition = positionOfBlock + positionInBlock;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let found = false;
|
||||||
|
for (let txInBlockIndex = 0; txInBlockIndex < this.mempoolBlocks.length && !found; txInBlockIndex++) {
|
||||||
|
const block = this.mempoolBlocks[txInBlockIndex];
|
||||||
|
for (let i = 0; i < block.feeRange.length - 1 && !found; i++) {
|
||||||
|
if (this.txFeePerVSize < block.feeRange[i + 1] && this.txFeePerVSize >= block.feeRange[i]) {
|
||||||
|
const feeRangeIndex = i;
|
||||||
|
const feeRangeChunkSize = 1 / (block.feeRange.length - 1);
|
||||||
|
|
||||||
const txFee = this.txFeePerVSize - block.feeRange[i];
|
const txFee = this.txFeePerVSize - block.feeRange[i];
|
||||||
const max = block.feeRange[i + 1] - block.feeRange[i];
|
const max = block.feeRange[i + 1] - block.feeRange[i];
|
||||||
const blockLocation = txFee / max;
|
const blockLocation = txFee / max;
|
||||||
|
|
||||||
const chunkPositionOffset = blockLocation * feeRangeChunkSize;
|
const chunkPositionOffset = blockLocation * feeRangeChunkSize;
|
||||||
const feePosition = feeRangeChunkSize * feeRangeIndex + chunkPositionOffset;
|
const feePosition = feeRangeChunkSize * feeRangeIndex + chunkPositionOffset;
|
||||||
|
|
||||||
const blockedFilledPercentage = (block.blockVSize > this.stateService.blockVSize ? this.stateService.blockVSize : block.blockVSize) / this.stateService.blockVSize;
|
const blockedFilledPercentage = (block.blockVSize > this.stateService.blockVSize ? this.stateService.blockVSize : block.blockVSize) / this.stateService.blockVSize;
|
||||||
const arrowRightPosition = txInBlockIndex * (this.blockWidth + this.blockPadding)
|
const arrowRightPosition = txInBlockIndex * (this.blockWidth + this.blockPadding)
|
||||||
+ ((1 - feePosition) * blockedFilledPercentage * this.blockWidth);
|
+ ((1 - feePosition) * blockedFilledPercentage * this.blockWidth);
|
||||||
|
|
||||||
this.rightPosition = arrowRightPosition;
|
this.rightPosition = arrowRightPosition;
|
||||||
|
found = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.txFeePerVSize >= block.feeRange[block.feeRange.length - 1]) {
|
||||||
|
this.rightPosition = txInBlockIndex * (this.blockWidth + this.blockPadding);
|
||||||
found = true;
|
found = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (this.txFeePerVSize >= block.feeRange[block.feeRange.length - 1]) {
|
|
||||||
this.rightPosition = txInBlockIndex * (this.blockWidth + this.blockPadding);
|
|
||||||
found = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -23,8 +23,7 @@ import { download, formatterXAxis, formatterXAxisLabel } from '../../shared/grap
|
|||||||
})
|
})
|
||||||
export class MempoolGraphComponent implements OnInit, OnChanges {
|
export class MempoolGraphComponent implements OnInit, OnChanges {
|
||||||
@Input() data: any[];
|
@Input() data: any[];
|
||||||
@Input() limitFee = 350;
|
@Input() filterSize = 100000;
|
||||||
@Input() limitFilterFee = 1;
|
|
||||||
@Input() height: number | string = 200;
|
@Input() height: number | string = 200;
|
||||||
@Input() top: number | string = 20;
|
@Input() top: number | string = 20;
|
||||||
@Input() right: number | string = 10;
|
@Input() right: number | string = 10;
|
||||||
@ -99,16 +98,20 @@ export class MempoolGraphComponent implements OnInit, OnChanges {
|
|||||||
}
|
}
|
||||||
|
|
||||||
generateArray(mempoolStats: OptimizedMempoolStats[]) {
|
generateArray(mempoolStats: OptimizedMempoolStats[]) {
|
||||||
const finalArray: number[][][] = [];
|
let finalArray: number[][][] = [];
|
||||||
let feesArray: number[][] = [];
|
let feesArray: number[][] = [];
|
||||||
const limitFeesTemplate = this.template === 'advanced' ? 26 : 20;
|
let maxTier = 0;
|
||||||
for (let index = limitFeesTemplate; index > -1; index--) {
|
for (let index = 37; index > -1; index--) {
|
||||||
feesArray = [];
|
feesArray = [];
|
||||||
mempoolStats.forEach((stats) => {
|
mempoolStats.forEach((stats) => {
|
||||||
|
if (stats.vsizes[index] >= this.filterSize) {
|
||||||
|
maxTier = Math.max(maxTier, index);
|
||||||
|
}
|
||||||
feesArray.push([stats.added * 1000, stats.vsizes[index] ? stats.vsizes[index] : 0]);
|
feesArray.push([stats.added * 1000, stats.vsizes[index] ? stats.vsizes[index] : 0]);
|
||||||
});
|
});
|
||||||
finalArray.push(feesArray);
|
finalArray.push(feesArray);
|
||||||
}
|
}
|
||||||
|
this.feeLimitIndex = maxTier;
|
||||||
finalArray.reverse();
|
finalArray.reverse();
|
||||||
return finalArray;
|
return finalArray;
|
||||||
}
|
}
|
||||||
@ -121,7 +124,7 @@ export class MempoolGraphComponent implements OnInit, OnChanges {
|
|||||||
const newColors = [];
|
const newColors = [];
|
||||||
for (let index = 0; index < series.length; index++) {
|
for (let index = 0; index < series.length; index++) {
|
||||||
const value = series[index];
|
const value = series[index];
|
||||||
if (index >= this.feeLimitIndex) {
|
if (index < this.feeLimitIndex) {
|
||||||
newColors.push(this.chartColorsOrdered[index]);
|
newColors.push(this.chartColorsOrdered[index]);
|
||||||
seriesGraph.push({
|
seriesGraph.push({
|
||||||
zlevel: 0,
|
zlevel: 0,
|
||||||
@ -371,17 +374,21 @@ export class MempoolGraphComponent implements OnInit, OnChanges {
|
|||||||
|
|
||||||
orderLevels() {
|
orderLevels() {
|
||||||
this.feeLevelsOrdered = [];
|
this.feeLevelsOrdered = [];
|
||||||
for (let i = 0; i < feeLevels.length; i++) {
|
let maxIndex = Math.min(feeLevels.length, this.feeLimitIndex);
|
||||||
if (feeLevels[i] === this.limitFilterFee) {
|
for (let i = 0; i < maxIndex; i++) {
|
||||||
this.feeLimitIndex = i;
|
|
||||||
}
|
|
||||||
if (feeLevels[i] <= this.limitFee) {
|
|
||||||
if (this.stateService.network === 'liquid' || this.stateService.network === 'liquidtestnet') {
|
if (this.stateService.network === 'liquid' || this.stateService.network === 'liquidtestnet') {
|
||||||
this.feeLevelsOrdered.push(`${(feeLevels[i] / 10).toFixed(1)} - ${(feeLevels[i + 1] / 10).toFixed(1)}`);
|
if (i === maxIndex - 1) {
|
||||||
|
this.feeLevelsOrdered.push(`${(feeLevels[i] / 10).toFixed(1)}+`);
|
||||||
|
} else {
|
||||||
|
this.feeLevelsOrdered.push(`${(feeLevels[i] / 10).toFixed(1)} - ${(feeLevels[i + 1] / 10).toFixed(1)}`);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
this.feeLevelsOrdered.push(`${feeLevels[i]} - ${feeLevels[i + 1]}`);
|
if (i === maxIndex - 1) {
|
||||||
|
this.feeLevelsOrdered.push(`${feeLevels[i]}+`);
|
||||||
|
} else {
|
||||||
|
this.feeLevelsOrdered.push(`${feeLevels[i]} - ${feeLevels[i + 1]}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
this.chartColorsOrdered = chartColors.slice(0, this.feeLevelsOrdered.length);
|
this.chartColorsOrdered = chartColors.slice(0, this.feeLevelsOrdered.length);
|
||||||
}
|
}
|
||||||
|
@ -73,24 +73,4 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pref-selectors">
|
|
||||||
<div class="selector">
|
|
||||||
<app-language-selector></app-language-selector>
|
|
||||||
</div>
|
|
||||||
<div class="selector">
|
|
||||||
<app-fiat-selector></app-fiat-selector>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="terms-of-service">
|
|
||||||
<a [routerLink]="['/terms-of-service']" i18n="shared.terms-of-service|Terms of Service">Terms of Service</a>
|
|
||||||
|
|
|
||||||
<a [routerLink]="['/privacy-policy']" i18n="shared.privacy-policy|Privacy Policy">Privacy Policy</a>
|
|
||||||
|
|
|
||||||
<a [routerLink]="['/tx/push' | relativeUrl]" i18n="shared.broadcast-transaction|Broadcast Transaction">Broadcast Transaction</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<br>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,10 +1,6 @@
|
|||||||
.dashboard-container {
|
.dashboard-container {
|
||||||
padding-bottom: 60px;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
@media (min-width: 992px) {
|
|
||||||
padding-bottom: 0px;
|
|
||||||
}
|
|
||||||
.col {
|
.col {
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
@ -104,22 +100,3 @@
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.terms-of-service {
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pref-selectors {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
.selector {
|
|
||||||
margin-left: .5em;
|
|
||||||
margin-bottom: .5em;
|
|
||||||
&:first {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -75,7 +75,7 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div [class]="!widget ? 'bottom-padding' : 'pb-0'" class="container pb-lg-0">
|
<div [class]="!widget ? '' : 'pb-0'" class="container pb-lg-0">
|
||||||
<div [class]="widget ? 'chart-widget' : 'chart'" echarts [initOpts]="chartInitOptions" [options]="chartOptions"
|
<div [class]="widget ? 'chart-widget' : 'chart'" echarts [initOpts]="chartInitOptions" [options]="chartOptions"
|
||||||
(chartInit)="onChartInit($event)">
|
(chartInit)="onChartInit($event)">
|
||||||
</div>
|
</div>
|
||||||
@ -136,7 +136,6 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
@ -10,6 +10,7 @@
|
|||||||
padding: 0px 15px;
|
padding: 0px 15px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: calc(100% - 140px);
|
height: calc(100% - 140px);
|
||||||
|
padding-bottom: 20px;
|
||||||
@media (max-width: 992px) {
|
@media (max-width: 992px) {
|
||||||
height: calc(100% - 190px);
|
height: calc(100% - 190px);
|
||||||
};
|
};
|
||||||
@ -33,15 +34,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.bottom-padding {
|
|
||||||
@media (max-width: 992px) {
|
|
||||||
padding-bottom: 65px
|
|
||||||
};
|
|
||||||
@media (max-width: 576px) {
|
|
||||||
padding-bottom: 65px
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 767.98px) {
|
@media (max-width: 767.98px) {
|
||||||
.pools-table th,
|
.pools-table th,
|
||||||
.pools-table td {
|
.pools-table td {
|
||||||
|
46
frontend/src/app/components/rbf-list/rbf-list.component.html
Normal file
46
frontend/src/app/components/rbf-list/rbf-list.component.html
Normal 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>
|
35
frontend/src/app/components/rbf-list/rbf-list.component.scss
Normal file
35
frontend/src/app/components/rbf-list/rbf-list.component.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
81
frontend/src/app/components/rbf-list/rbf-list.component.ts
Normal file
81
frontend/src/app/components/rbf-list/rbf-list.component.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
@ -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]="'‎' + (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>
|
@ -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;
|
||||||
|
}
|
@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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>
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -137,9 +137,11 @@ export class StartComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMouseDown(event: MouseEvent) {
|
onMouseDown(event: MouseEvent) {
|
||||||
this.mouseDragStartX = event.clientX;
|
if (!(event.which > 1 || event.button > 0)) {
|
||||||
this.resetMomentum(event.clientX);
|
this.mouseDragStartX = event.clientX;
|
||||||
this.blockchainScrollLeftInit = this.blockchainContainer.nativeElement.scrollLeft;
|
this.resetMomentum(event.clientX);
|
||||||
|
this.blockchainScrollLeftInit = this.blockchainContainer.nativeElement.scrollLeft;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
onPointerDown(event: PointerEvent) {
|
onPointerDown(event: PointerEvent) {
|
||||||
if (this.isiOS) {
|
if (this.isiOS) {
|
||||||
|
@ -84,8 +84,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="incoming-transactions-graph">
|
<div class="incoming-transactions-graph">
|
||||||
<app-mempool-graph #mempoolgraph dir="ltr" [template]="'advanced'" [limitFee]="500"
|
<app-mempool-graph #mempoolgraph dir="ltr" [template]="'advanced'" [height]="500" [left]="65" [right]="10"
|
||||||
[limitFilterFee]="filterFeeIndex" [height]="500" [left]="65" [right]="10"
|
|
||||||
[data]="mempoolStats && mempoolStats.length ? mempoolStats : null"></app-mempool-graph>
|
[data]="mempoolStats && mempoolStats.length ? mempoolStats : null"></app-mempool-graph>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
.container-graph {
|
.container-graph {
|
||||||
padding: 0px 15px 60px;
|
padding: 0px 15px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-header {
|
.card-header {
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user