Merge branch 'master' into nymkappa/scan-closed-channel-no-mempool
This commit is contained in:
@@ -16,7 +16,7 @@
|
||||
"INITIAL_BLOCKS_AMOUNT": 7,
|
||||
"MEMPOOL_BLOCKS_AMOUNT": 8,
|
||||
"USE_SECOND_NODE_FOR_MINFEE": 10,
|
||||
"EXTERNAL_ASSETS": 11,
|
||||
"EXTERNAL_ASSETS": [],
|
||||
"EXTERNAL_MAX_RETRY": 12,
|
||||
"EXTERNAL_RETRY_INTERVAL": 13,
|
||||
"USER_AGENT": "__MEMPOOL_USER_AGENT__",
|
||||
@@ -24,19 +24,20 @@
|
||||
"INDEXING_BLOCKS_AMOUNT": 14,
|
||||
"POOLS_JSON_TREE_URL": "__POOLS_JSON_TREE_URL__",
|
||||
"POOLS_JSON_URL": "__POOLS_JSON_URL__",
|
||||
"AUDIT": "__MEMPOOL_AUDIT__",
|
||||
"ADVANCED_GBT_AUDIT": "__MEMPOOL_ADVANCED_GBT_AUDIT__",
|
||||
"ADVANCED_GBT_MEMPOOL": "__MEMPOOL_ADVANCED_GBT_MEMPOOL__",
|
||||
"CPFP_INDEXING": "__MEMPOOL_CPFP_INDEXING__",
|
||||
"MAX_BLOCKS_BULK_QUERY": "__MEMPOOL_MAX_BLOCKS_BULK_QUERY__",
|
||||
"DISK_CACHE_BLOCK_INTERVAL": "__MEMPOOL_DISK_CACHE_BLOCK_INTERVAL__"
|
||||
"AUDIT": true,
|
||||
"ADVANCED_GBT_AUDIT": true,
|
||||
"ADVANCED_GBT_MEMPOOL": true,
|
||||
"RUST_GBT": false,
|
||||
"CPFP_INDEXING": true,
|
||||
"MAX_BLOCKS_BULK_QUERY": 999,
|
||||
"DISK_CACHE_BLOCK_INTERVAL": 999
|
||||
},
|
||||
"CORE_RPC": {
|
||||
"HOST": "__CORE_RPC_HOST__",
|
||||
"PORT": 15,
|
||||
"USERNAME": "__CORE_RPC_USERNAME__",
|
||||
"PASSWORD": "__CORE_RPC_PASSWORD__",
|
||||
"TIMEOUT": "__CORE_RPC_TIMEOUT__"
|
||||
"TIMEOUT": 1000
|
||||
},
|
||||
"ELECTRUM": {
|
||||
"HOST": "__ELECTRUM_HOST__",
|
||||
@@ -46,14 +47,14 @@
|
||||
"ESPLORA": {
|
||||
"REST_API_URL": "__ESPLORA_REST_API_URL__",
|
||||
"UNIX_SOCKET_PATH": "__ESPLORA_UNIX_SOCKET_PATH__",
|
||||
"RETRY_UNIX_SOCKET_AFTER": "__ESPLORA_RETRY_UNIX_SOCKET_AFTER__"
|
||||
"RETRY_UNIX_SOCKET_AFTER": 888
|
||||
},
|
||||
"SECOND_CORE_RPC": {
|
||||
"HOST": "__SECOND_CORE_RPC_HOST__",
|
||||
"PORT": 17,
|
||||
"USERNAME": "__SECOND_CORE_RPC_USERNAME__",
|
||||
"PASSWORD": "__SECOND_CORE_RPC_PASSWORD__",
|
||||
"TIMEOUT": "__SECOND_CORE_RPC_TIMEOUT__"
|
||||
"TIMEOUT": 2000
|
||||
},
|
||||
"DATABASE": {
|
||||
"ENABLED": false,
|
||||
@@ -63,7 +64,7 @@
|
||||
"DATABASE": "__DATABASE_DATABASE__",
|
||||
"USERNAME": "__DATABASE_USERNAME__",
|
||||
"PASSWORD": "__DATABASE_PASSWORD__",
|
||||
"TIMEOUT": "__DATABASE_TIMEOUT__"
|
||||
"TIMEOUT": 3000
|
||||
},
|
||||
"SYSLOG": {
|
||||
"ENABLED": false,
|
||||
@@ -101,14 +102,14 @@
|
||||
"BISQ_ONION": "__EXTERNAL_DATA_SERVER_BISQ_ONION__"
|
||||
},
|
||||
"LIGHTNING": {
|
||||
"ENABLED": "__LIGHTNING_ENABLED__",
|
||||
"ENABLED": true,
|
||||
"BACKEND": "__LIGHTNING_BACKEND__",
|
||||
"TOPOLOGY_FOLDER": "__LIGHTNING_TOPOLOGY_FOLDER__",
|
||||
"STATS_REFRESH_INTERVAL": 600,
|
||||
"GRAPH_REFRESH_INTERVAL": 600,
|
||||
"LOGGER_UPDATE_INTERVAL": 30,
|
||||
"FORENSICS_INTERVAL": 43200,
|
||||
"FORENSICS_RATE_LIMIT": "__FORENSICS_RATE_LIMIT__"
|
||||
"FORENSICS_RATE_LIMIT": 1234
|
||||
},
|
||||
"LND": {
|
||||
"TLS_CERT_PATH": "",
|
||||
@@ -119,4 +120,4 @@
|
||||
"CLIGHTNING": {
|
||||
"SOCKET": "__CLIGHTNING_SOCKET__"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,11 +14,11 @@ describe('Mempool Difficulty Adjustment', () => {
|
||||
750134, // Current block height
|
||||
0.6280047707459726, // Previous retarget % (Passed through)
|
||||
'mainnet', // Network (if testnet, next value is non-zero)
|
||||
0, // If not testnet, not used
|
||||
0, // Latest block timestamp in seconds (only used if difficulty already locked in)
|
||||
],
|
||||
{ // Expected Result
|
||||
progressPercent: 9.027777777777777,
|
||||
difficultyChange: 12.562233927411782,
|
||||
difficultyChange: 13.180707740199772,
|
||||
estimatedRetargetDate: 1661895424692,
|
||||
remainingBlocks: 1834,
|
||||
remainingTime: 977591692,
|
||||
@@ -41,7 +41,7 @@ describe('Mempool Difficulty Adjustment', () => {
|
||||
],
|
||||
{ // Expected Result is same other than timeOffset
|
||||
progressPercent: 9.027777777777777,
|
||||
difficultyChange: 12.562233927411782,
|
||||
difficultyChange: 13.180707740199772,
|
||||
estimatedRetargetDate: 1661895424692,
|
||||
remainingBlocks: 1834,
|
||||
remainingTime: 977591692,
|
||||
@@ -54,6 +54,29 @@ describe('Mempool Difficulty Adjustment', () => {
|
||||
expectedBlocks: 161.68833333333333,
|
||||
},
|
||||
],
|
||||
[ // Vector 3 (mainnet lock-in (epoch ending 788255))
|
||||
[ // Inputs
|
||||
dt('2023-04-20T09:57:33.000Z'), // Last DA time (in seconds)
|
||||
dt('2023-05-04T14:54:09.000Z'), // Current time (now) (in seconds)
|
||||
788255, // Current block height
|
||||
1.7220298879531821, // Previous retarget % (Passed through)
|
||||
'mainnet', // Network (if testnet, next value is non-zero)
|
||||
dt('2023-05-04T14:54:26.000Z'), // Latest block timestamp in seconds
|
||||
],
|
||||
{ // Expected Result
|
||||
progressPercent: 99.95039682539682,
|
||||
difficultyChange: -1.4512637555574193,
|
||||
estimatedRetargetDate: 1683212658129,
|
||||
remainingBlocks: 1,
|
||||
remainingTime: 609129,
|
||||
previousRetarget: 1.7220298879531821,
|
||||
previousTime: 1681984653,
|
||||
nextRetargetHeight: 788256,
|
||||
timeAvg: 609129,
|
||||
timeOffset: 0,
|
||||
expectedBlocks: 2045.66,
|
||||
},
|
||||
],
|
||||
] as [[number, number, number, number, string, number], DifficultyAdjustment][];
|
||||
|
||||
for (const vector of vectors) {
|
||||
|
||||
@@ -40,6 +40,7 @@ describe('Mempool Backend Config', () => {
|
||||
AUDIT: false,
|
||||
ADVANCED_GBT_AUDIT: false,
|
||||
ADVANCED_GBT_MEMPOOL: false,
|
||||
RUST_GBT: false,
|
||||
CPFP_INDEXING: false,
|
||||
MAX_BLOCKS_BULK_QUERY: 0,
|
||||
DISK_CACHE_BLOCK_INTERVAL: 6,
|
||||
@@ -152,4 +153,94 @@ describe('Mempool Backend Config', () => {
|
||||
expect(config.EXTERNAL_DATA_SERVER).toStrictEqual(fixture.EXTERNAL_DATA_SERVER);
|
||||
});
|
||||
});
|
||||
|
||||
test('should ensure the docker start.sh script has default values', () => {
|
||||
jest.isolateModules(() => {
|
||||
const startSh = fs.readFileSync(`${__dirname}/../../../docker/backend/start.sh`, 'utf-8');
|
||||
const fixture = JSON.parse(fs.readFileSync(`${__dirname}/../__fixtures__/mempool-config.template.json`, 'utf8'));
|
||||
|
||||
function parseJson(jsonObj, root?) {
|
||||
for (const [key, value] of Object.entries(jsonObj)) {
|
||||
// We have a few cases where we can't follow the pattern
|
||||
if (root === 'MEMPOOL' && key === 'HTTP_PORT') {
|
||||
console.log('skipping check for MEMPOOL_HTTP_PORT');
|
||||
return;
|
||||
}
|
||||
switch (typeof value) {
|
||||
case 'object': {
|
||||
if (Array.isArray(value)) {
|
||||
return;
|
||||
} else {
|
||||
parseJson(value, key);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
//The flattened string, i.e, __MEMPOOL_ENABLED__
|
||||
const replaceStr = `${root ? '__' + root + '_' : '__'}${key}__`;
|
||||
|
||||
//The string used as the environment variable, i.e, MEMPOOL_ENABLED
|
||||
const envVarStr = `${root ? root : ''}_${key}`;
|
||||
|
||||
//The string used as the default value, to be checked as a regex, i.e, __MEMPOOL_ENABLED__=${MEMPOOL_ENABLED:=(.*)}
|
||||
const defaultEntry = replaceStr + '=' + '\\${' + envVarStr + ':=(.*)' + '}';
|
||||
|
||||
console.log(`looking for ${defaultEntry} in the start.sh script`);
|
||||
const re = new RegExp(defaultEntry);
|
||||
expect(startSh).toMatch(re);
|
||||
|
||||
//The string that actually replaces the values in the config file
|
||||
const sedStr = 'sed -i "s!' + replaceStr + '!${' + replaceStr + '}!g" mempool-config.json';
|
||||
console.log(`looking for ${sedStr} in the start.sh script`);
|
||||
expect(startSh).toContain(sedStr);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
parseJson(fixture);
|
||||
});
|
||||
});
|
||||
|
||||
test('should ensure that the mempool-config.json Docker template has all the keys', () => {
|
||||
jest.isolateModules(() => {
|
||||
const fixture = JSON.parse(fs.readFileSync(`${__dirname}/../__fixtures__/mempool-config.template.json`, 'utf8'));
|
||||
const dockerJson = fs.readFileSync(`${__dirname}/../../../docker/backend/mempool-config.json`, 'utf-8');
|
||||
|
||||
function parseJson(jsonObj, root?) {
|
||||
for (const [key, value] of Object.entries(jsonObj)) {
|
||||
switch (typeof value) {
|
||||
case 'object': {
|
||||
if (Array.isArray(value)) {
|
||||
// numbers, arrays and booleans won't be enclosed by quotes
|
||||
const replaceStr = `${root ? '__' + root + '_' : '__'}${key}__`;
|
||||
expect(dockerJson).toContain(`"${key}": ${replaceStr}`);
|
||||
break;
|
||||
} else {
|
||||
//Check for top level config keys
|
||||
expect(dockerJson).toContain(`"${key}"`);
|
||||
parseJson(value, key);
|
||||
break;
|
||||
}
|
||||
}
|
||||
case 'string': {
|
||||
// strings should be enclosed by quotes
|
||||
const replaceStr = `${root ? '__' + root + '_' : '__'}${key}__`;
|
||||
expect(dockerJson).toContain(`"${key}": "${replaceStr}"`);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// numbers, arrays and booleans won't be enclosed by quotes
|
||||
const replaceStr = `${root ? '__' + root + '_' : '__'}${key}__`;
|
||||
expect(dockerJson).toContain(`"${key}": ${replaceStr}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
parseJson(fixture);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
68
backend/src/__tests__/gbt/gbt-tests.ts
Normal file
68
backend/src/__tests__/gbt/gbt-tests.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import fs from 'fs';
|
||||
import { GbtGenerator, ThreadTransaction } from '../../../rust-gbt';
|
||||
import path from 'path';
|
||||
|
||||
const baseline = require('./test-data/target-template.json');
|
||||
const testVector = require('./test-data/test-data-ids.json');
|
||||
const vectorUidMap: Map<number, string> = new Map(testVector.map(x => [x[0], x[1]]));
|
||||
const vectorTxidMap: Map<string, number> = new Map(testVector.map(x => [x[1], x[0]]));
|
||||
// Note that this test buffer is specially constructed
|
||||
// such that uids are assigned in numerical txid order
|
||||
// so that ties break the same way as in Core's implementation
|
||||
const vectorBuffer: Buffer = fs.readFileSync(path.join(__dirname, './', './test-data/test-buffer.bin'));
|
||||
|
||||
describe('Rust GBT', () => {
|
||||
test('should produce the same template as getBlockTemplate from Bitcoin Core', async () => {
|
||||
const rustGbt = new GbtGenerator();
|
||||
const { mempool, maxUid } = mempoolFromArrayBuffer(vectorBuffer.buffer);
|
||||
const result = await rustGbt.make(mempool, maxUid);
|
||||
|
||||
const blocks: [string, number][][] = result.blocks.map(block => {
|
||||
return block.map(uid => [vectorUidMap.get(uid) || 'missing', uid]);
|
||||
});
|
||||
const template = baseline.map(tx => [tx.txid, vectorTxidMap.get(tx.txid)]);
|
||||
|
||||
expect(blocks[0].length).toEqual(baseline.length);
|
||||
expect(blocks[0]).toEqual(template);
|
||||
});
|
||||
});
|
||||
|
||||
function mempoolFromArrayBuffer(buf: ArrayBuffer): { mempool: ThreadTransaction[], maxUid: number } {
|
||||
let maxUid = 0;
|
||||
const view = new DataView(buf);
|
||||
const count = view.getUint32(0, false);
|
||||
const txs: ThreadTransaction[] = [];
|
||||
let offset = 4;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const uid = view.getUint32(offset, false);
|
||||
maxUid = Math.max(maxUid, uid);
|
||||
const tx: ThreadTransaction = {
|
||||
uid,
|
||||
order: txidToOrdering(vectorUidMap.get(uid) as string),
|
||||
fee: view.getFloat64(offset + 4, false),
|
||||
weight: view.getUint32(offset + 12, false),
|
||||
sigops: view.getUint32(offset + 16, false),
|
||||
// feePerVsize: view.getFloat64(offset + 20, false),
|
||||
effectiveFeePerVsize: view.getFloat64(offset + 28, false),
|
||||
inputs: [],
|
||||
};
|
||||
const numInputs = view.getUint32(offset + 36, false);
|
||||
offset += 40;
|
||||
for (let j = 0; j < numInputs; j++) {
|
||||
tx.inputs.push(view.getUint32(offset, false));
|
||||
offset += 4;
|
||||
}
|
||||
txs.push(tx);
|
||||
}
|
||||
return { mempool: txs, maxUid };
|
||||
}
|
||||
|
||||
function txidToOrdering(txid: string): number {
|
||||
return parseInt(
|
||||
txid.substr(62, 2) +
|
||||
txid.substr(60, 2) +
|
||||
txid.substr(58, 2) +
|
||||
txid.substr(56, 2),
|
||||
16
|
||||
);
|
||||
}
|
||||
7070
backend/src/__tests__/gbt/test-data/target-template.json
Normal file
7070
backend/src/__tests__/gbt/test-data/target-template.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
backend/src/__tests__/gbt/test-data/test-buffer.bin
Normal file
BIN
backend/src/__tests__/gbt/test-data/test-buffer.bin
Normal file
Binary file not shown.
1
backend/src/__tests__/gbt/test-data/test-data-ids.json
Normal file
1
backend/src/__tests__/gbt/test-data/test-data-ids.json
Normal file
File diff suppressed because one or more lines are too long
@@ -1,19 +1,21 @@
|
||||
import config from '../config';
|
||||
import logger from '../logger';
|
||||
import { TransactionExtended, MempoolBlockWithTransactions } from '../mempool.interfaces';
|
||||
import rbfCache from './rbf-cache';
|
||||
|
||||
const PROPAGATION_MARGIN = 180; // in seconds, time since a transaction is first seen after which it is assumed to have propagated to all miners
|
||||
|
||||
class Audit {
|
||||
auditBlock(transactions: TransactionExtended[], projectedBlocks: MempoolBlockWithTransactions[], mempool: { [txId: string]: TransactionExtended })
|
||||
: { censored: string[], added: string[], fresh: string[], score: number, similarity: number } {
|
||||
: { censored: string[], added: string[], fresh: string[], sigop: string[], fullrbf: string[], score: number, similarity: number } {
|
||||
if (!projectedBlocks?.[0]?.transactionIds || !mempool) {
|
||||
return { censored: [], added: [], fresh: [], score: 0, similarity: 1 };
|
||||
return { censored: [], added: [], fresh: [], sigop: [], fullrbf: [], score: 0, similarity: 1 };
|
||||
}
|
||||
|
||||
const matches: string[] = []; // present in both mined block and template
|
||||
const added: string[] = []; // present in mined block, not in template
|
||||
const fresh: string[] = []; // missing, but firstSeen within PROPAGATION_MARGIN
|
||||
const fullrbf: string[] = []; // either missing or present, and part of a fullrbf replacement
|
||||
const isCensored = {}; // missing, without excuse
|
||||
const isDisplaced = {};
|
||||
let displacedWeight = 0;
|
||||
@@ -35,7 +37,9 @@ class Audit {
|
||||
for (const txid of projectedBlocks[0].transactionIds) {
|
||||
if (!inBlock[txid]) {
|
||||
// tx is recent, may have reached the miner too late for inclusion
|
||||
if (mempool[txid]?.firstSeen != null && (now - (mempool[txid]?.firstSeen || 0)) <= PROPAGATION_MARGIN) {
|
||||
if (rbfCache.isFullRbf(txid)) {
|
||||
fullrbf.push(txid);
|
||||
} else if (mempool[txid]?.firstSeen != null && (now - (mempool[txid]?.firstSeen || 0)) <= PROPAGATION_MARGIN) {
|
||||
fresh.push(txid);
|
||||
} else {
|
||||
isCensored[txid] = true;
|
||||
@@ -91,7 +95,9 @@ class Audit {
|
||||
if (inTemplate[tx.txid]) {
|
||||
matches.push(tx.txid);
|
||||
} else {
|
||||
if (!isDisplaced[tx.txid]) {
|
||||
if (rbfCache.isFullRbf(tx.txid)) {
|
||||
fullrbf.push(tx.txid);
|
||||
} else if (!isDisplaced[tx.txid]) {
|
||||
added.push(tx.txid);
|
||||
}
|
||||
overflowWeight += tx.weight;
|
||||
@@ -137,6 +143,8 @@ class Audit {
|
||||
censored: Object.keys(isCensored),
|
||||
added,
|
||||
fresh,
|
||||
sigop: [],
|
||||
fullrbf,
|
||||
score,
|
||||
similarity,
|
||||
};
|
||||
|
||||
@@ -29,6 +29,7 @@ class BitcoinApi implements AbstractBitcoinApi {
|
||||
weight: block.weight,
|
||||
previousblockhash: block.previousblockhash,
|
||||
mediantime: block.mediantime,
|
||||
stale: block.confirmations === -1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -415,12 +416,38 @@ class BitcoinApi implements AbstractBitcoinApi {
|
||||
vin.inner_witnessscript_asm = this.convertScriptSigAsm(witnessScript);
|
||||
}
|
||||
|
||||
if (vin.prevout.scriptpubkey_type === 'v1_p2tr' && vin.witness && vin.witness.length > 1) {
|
||||
const witnessScript = vin.witness[vin.witness.length - 2];
|
||||
vin.inner_witnessscript_asm = this.convertScriptSigAsm(witnessScript);
|
||||
if (vin.prevout.scriptpubkey_type === 'v1_p2tr' && vin.witness) {
|
||||
const witnessScript = this.witnessToP2TRScript(vin.witness);
|
||||
if (witnessScript !== null) {
|
||||
vin.inner_witnessscript_asm = this.convertScriptSigAsm(witnessScript);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This function must only be called when we know the witness we are parsing
|
||||
* is a taproot witness.
|
||||
* @param witness An array of hex strings that represents the witness stack of
|
||||
* the input.
|
||||
* @returns null if the witness is not a script spend, and the hex string of
|
||||
* the script item if it is a script spend.
|
||||
*/
|
||||
private witnessToP2TRScript(witness: string[]): string | null {
|
||||
if (witness.length < 2) return null;
|
||||
// Note: see BIP341 for parsing details of witness stack
|
||||
|
||||
// If there are at least two witness elements, and the first byte of the
|
||||
// last element is 0x50, this last element is called annex a and
|
||||
// is removed from the witness stack.
|
||||
const hasAnnex = witness[witness.length - 1].substring(0, 2) === '50';
|
||||
// If there are at least two witness elements left, script path spending is used.
|
||||
// Call the second-to-last stack element s, the script.
|
||||
// (Note: this phrasing from BIP341 assumes we've *removed* the annex from the stack)
|
||||
if (hasAnnex && witness.length < 3) return null;
|
||||
const positionOfScript = hasAnnex ? witness.length - 3 : witness.length - 2;
|
||||
return witness[positionOfScript];
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default BitcoinApi;
|
||||
|
||||
@@ -211,6 +211,8 @@ class BitcoinRoutes {
|
||||
bestDescendant: tx.bestDescendant || null,
|
||||
descendants: tx.descendants || null,
|
||||
effectiveFeePerVsize: tx.effectiveFeePerVsize || null,
|
||||
sigops: tx.sigops,
|
||||
adjustedVsize: tx.adjustedVsize,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -222,7 +224,12 @@ class BitcoinRoutes {
|
||||
} else {
|
||||
let cpfpInfo;
|
||||
if (config.DATABASE.ENABLED) {
|
||||
cpfpInfo = await transactionRepository.$getCpfpInfo(req.params.txId);
|
||||
try {
|
||||
cpfpInfo = await transactionRepository.$getCpfpInfo(req.params.txId);
|
||||
} catch (e) {
|
||||
res.status(500).send('failed to get CPFP info');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (cpfpInfo) {
|
||||
res.json(cpfpInfo);
|
||||
@@ -392,9 +399,13 @@ class BitcoinRoutes {
|
||||
|
||||
private async getBlockAuditSummary(req: Request, res: Response) {
|
||||
try {
|
||||
const transactions = await blocks.$getBlockAuditSummary(req.params.hash);
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24 * 30).toUTCString());
|
||||
res.json(transactions);
|
||||
const auditSummary = await blocks.$getBlockAuditSummary(req.params.hash);
|
||||
if (auditSummary) {
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24 * 30).toUTCString());
|
||||
res.json(auditSummary);
|
||||
} else {
|
||||
return res.status(404).send(`audit not available`);
|
||||
}
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
|
||||
@@ -89,6 +89,7 @@ export namespace IEsploraApi {
|
||||
weight: number;
|
||||
previousblockhash: string;
|
||||
mediantime: number;
|
||||
stale: boolean;
|
||||
}
|
||||
|
||||
export interface Address {
|
||||
|
||||
@@ -2,7 +2,7 @@ import config from '../config';
|
||||
import bitcoinApi, { bitcoinCoreApi } from './bitcoin/bitcoin-api-factory';
|
||||
import logger from '../logger';
|
||||
import memPool from './mempool';
|
||||
import { BlockExtended, BlockExtension, BlockSummary, PoolTag, TransactionExtended, TransactionStripped, TransactionMinerInfo, CpfpSummary } from '../mempool.interfaces';
|
||||
import { BlockExtended, BlockExtension, BlockSummary, PoolTag, TransactionExtended, TransactionStripped, TransactionMinerInfo, CpfpSummary, MempoolTransactionExtended } from '../mempool.interfaces';
|
||||
import { Common } from './common';
|
||||
import diskCache from './disk-cache';
|
||||
import transactionUtils from './transaction-utils';
|
||||
@@ -34,7 +34,7 @@ class Blocks {
|
||||
private lastDifficultyAdjustmentTime = 0;
|
||||
private previousDifficultyRetarget = 0;
|
||||
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: MempoolTransactionExtended[]) => Promise<void>)[] = [];
|
||||
|
||||
private mainLoopTimeout: number = 120000;
|
||||
|
||||
@@ -60,7 +60,7 @@ class Blocks {
|
||||
this.newBlockCallbacks.push(fn);
|
||||
}
|
||||
|
||||
public setNewAsyncBlockCallback(fn: (block: BlockExtended, txIds: string[], transactions: TransactionExtended[]) => Promise<void>) {
|
||||
public setNewAsyncBlockCallback(fn: (block: BlockExtended, txIds: string[], transactions: MempoolTransactionExtended[]) => Promise<void>) {
|
||||
this.newAsyncBlockCallbacks.push(fn);
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ class Blocks {
|
||||
blockHeight: number,
|
||||
onlyCoinbase: boolean,
|
||||
quiet: boolean = false,
|
||||
addMempoolData: boolean = false,
|
||||
): Promise<TransactionExtended[]> {
|
||||
const transactions: TransactionExtended[] = [];
|
||||
const txIds: string[] = await bitcoinApi.$getTxIdsForBlock(blockHash);
|
||||
@@ -96,14 +97,14 @@ class Blocks {
|
||||
logger.debug(`Indexing tx ${i + 1} of ${txIds.length} in block #${blockHeight}`);
|
||||
}
|
||||
try {
|
||||
const tx = await transactionUtils.$getTransactionExtended(txIds[i]);
|
||||
const tx = await transactionUtils.$getTransactionExtended(txIds[i], false, false, false, addMempoolData);
|
||||
transactions.push(tx);
|
||||
transactionsFetched++;
|
||||
} catch (e) {
|
||||
try {
|
||||
if (config.MEMPOOL.BACKEND === 'esplora') {
|
||||
// Try again with core
|
||||
const tx = await transactionUtils.$getTransactionExtended(txIds[i], false, false, true);
|
||||
const tx = await transactionUtils.$getTransactionExtended(txIds[i], false, false, true, addMempoolData);
|
||||
transactions.push(tx);
|
||||
transactionsFetched++;
|
||||
} else {
|
||||
@@ -126,12 +127,6 @@ class Blocks {
|
||||
}
|
||||
}
|
||||
|
||||
transactions.forEach((tx) => {
|
||||
if (!tx.cpfpChecked) {
|
||||
Common.setRelativesAndGetCpfpInfo(tx, mempool); // Child Pay For Parent
|
||||
}
|
||||
});
|
||||
|
||||
if (!quiet) {
|
||||
logger.debug(`${transactionsFound} of ${txIds.length} found in mempool. ${transactionsFetched} fetched through backend service.`);
|
||||
}
|
||||
@@ -163,6 +158,13 @@ class Blocks {
|
||||
};
|
||||
}
|
||||
|
||||
public summarizeBlockTransactions(hash: string, transactions: TransactionExtended[]): BlockSummary {
|
||||
return {
|
||||
id: hash,
|
||||
transactions: Common.stripTransactions(transactions),
|
||||
};
|
||||
}
|
||||
|
||||
private convertLiquidFees(block: IBitcoinApi.VerboseBlock): IBitcoinApi.VerboseBlock {
|
||||
block.tx.forEach(tx => {
|
||||
tx.fee = Object.values(tx.fee || {}).reduce((total, output) => total + output, 0);
|
||||
@@ -279,10 +281,14 @@ class Blocks {
|
||||
}
|
||||
|
||||
extras.matchRate = null;
|
||||
extras.expectedFees = null;
|
||||
extras.expectedWeight = null;
|
||||
if (config.MEMPOOL.AUDIT) {
|
||||
const auditScore = await BlocksAuditsRepository.$getBlockAuditScore(block.id);
|
||||
if (auditScore != null) {
|
||||
extras.matchRate = auditScore.matchRate;
|
||||
extras.expectedFees = auditScore.expectedFees;
|
||||
extras.expectedWeight = auditScore.expectedWeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -306,7 +312,7 @@ class Blocks {
|
||||
}
|
||||
|
||||
const asciiScriptSig = transactionUtils.hex2ascii(txMinerInfo.vin[0].scriptsig);
|
||||
const address = txMinerInfo.vout[0].scriptpubkey_address;
|
||||
const addresses = txMinerInfo.vout.map((vout) => vout.scriptpubkey_address).filter((address) => address);
|
||||
|
||||
let pools: PoolTag[] = [];
|
||||
if (config.DATABASE.ENABLED === true) {
|
||||
@@ -316,11 +322,13 @@ class Blocks {
|
||||
}
|
||||
|
||||
for (let i = 0; i < pools.length; ++i) {
|
||||
if (address !== undefined) {
|
||||
const addresses: string[] = typeof pools[i].addresses === 'string' ?
|
||||
if (addresses.length) {
|
||||
const poolAddresses: string[] = typeof pools[i].addresses === 'string' ?
|
||||
JSON.parse(pools[i].addresses) : pools[i].addresses;
|
||||
if (addresses.indexOf(address) !== -1) {
|
||||
return pools[i];
|
||||
for (let y = 0; y < poolAddresses.length; y++) {
|
||||
if (addresses.indexOf(poolAddresses[y]) !== -1) {
|
||||
return pools[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,6 +458,46 @@ class Blocks {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [INDEXING] Index expected fees & weight for all audited blocks
|
||||
*/
|
||||
public async $generateAuditStats(): Promise<void> {
|
||||
const blockIds = await BlocksAuditsRepository.$getBlocksWithoutSummaries();
|
||||
if (!blockIds?.length) {
|
||||
return;
|
||||
}
|
||||
let timer = Date.now();
|
||||
let indexedThisRun = 0;
|
||||
let indexedTotal = 0;
|
||||
logger.debug(`Indexing ${blockIds.length} block audit details`);
|
||||
for (const hash of blockIds) {
|
||||
const summary = await BlocksSummariesRepository.$getTemplate(hash);
|
||||
let totalFees = 0;
|
||||
let totalWeight = 0;
|
||||
for (const tx of summary?.transactions || []) {
|
||||
totalFees += tx.fee;
|
||||
totalWeight += (tx.vsize * 4);
|
||||
}
|
||||
await BlocksAuditsRepository.$setSummary(hash, totalFees, totalWeight);
|
||||
const cachedBlock = this.blocks.find(block => block.id === hash);
|
||||
if (cachedBlock) {
|
||||
cachedBlock.extras.expectedFees = totalFees;
|
||||
cachedBlock.extras.expectedWeight = totalWeight;
|
||||
}
|
||||
|
||||
indexedThisRun++;
|
||||
indexedTotal++;
|
||||
const elapsedSeconds = (Date.now() - timer) / 1000;
|
||||
if (elapsedSeconds > 5) {
|
||||
const blockPerSeconds = indexedThisRun / elapsedSeconds;
|
||||
logger.debug(`Indexed ${indexedTotal} / ${blockIds.length} block audit details (${blockPerSeconds.toFixed(1)}/s)`);
|
||||
timer = Date.now();
|
||||
indexedThisRun = 0;
|
||||
}
|
||||
}
|
||||
logger.debug(`Indexing block audit details completed`);
|
||||
}
|
||||
|
||||
/**
|
||||
* [INDEXING] Index all blocks metadata for the mining dashboard
|
||||
*/
|
||||
@@ -594,16 +642,20 @@ class Blocks {
|
||||
const verboseBlock = await bitcoinClient.getBlock(blockHash, 2);
|
||||
const block = BitcoinApi.convertBlock(verboseBlock);
|
||||
const txIds: string[] = await bitcoinApi.$getTxIdsForBlock(blockHash);
|
||||
const transactions = await this.$getTransactionsExtended(blockHash, block.height, false);
|
||||
const transactions = await this.$getTransactionsExtended(blockHash, block.height, false, false, true) as MempoolTransactionExtended[];
|
||||
if (config.MEMPOOL.BACKEND !== 'esplora') {
|
||||
// fill in missing transaction fee data from verboseBlock
|
||||
for (let i = 0; i < transactions.length; i++) {
|
||||
if (!transactions[i].fee && transactions[i].txid === verboseBlock.tx[i].txid) {
|
||||
transactions[i].fee = verboseBlock.tx[i].fee * 100_000_000;
|
||||
}
|
||||
}
|
||||
}
|
||||
const cpfpSummary: CpfpSummary = Common.calculateCpfp(block.height, transactions);
|
||||
const blockExtended: BlockExtended = await this.$getBlockExtended(block, cpfpSummary.transactions);
|
||||
const blockSummary: BlockSummary = this.summarizeBlock(verboseBlock);
|
||||
const blockSummary: BlockSummary = this.summarizeBlockTransactions(block.id, cpfpSummary.transactions);
|
||||
this.updateTimerProgress(timer, `got block data for ${this.currentBlockHeight}`);
|
||||
|
||||
// start async callbacks
|
||||
this.updateTimerProgress(timer, `starting async callbacks for ${this.currentBlockHeight}`);
|
||||
const callbackPromises = this.newAsyncBlockCallbacks.map((cb) => cb(blockExtended, txIds, transactions));
|
||||
|
||||
if (Common.indexingEnabled()) {
|
||||
if (!fastForwarded) {
|
||||
const lastBlock = await blocksRepository.$getBlockByHeight(blockExtended.height - 1);
|
||||
@@ -615,16 +667,19 @@ class Blocks {
|
||||
await BlocksRepository.$deleteBlocksFrom(lastBlock.height - 10);
|
||||
await HashratesRepository.$deleteLastEntries();
|
||||
await cpfpRepository.$deleteClustersFrom(lastBlock.height - 10);
|
||||
this.blocks = this.blocks.slice(0, -10);
|
||||
this.updateTimerProgress(timer, `rolled back chain divergence from ${this.currentBlockHeight}`);
|
||||
for (let i = 10; i >= 0; --i) {
|
||||
const newBlock = await this.$indexBlock(lastBlock.height - i);
|
||||
this.blocks.push(newBlock);
|
||||
this.updateTimerProgress(timer, `reindexed block`);
|
||||
await this.$getStrippedBlockTransactions(newBlock.id, true, true);
|
||||
this.updateTimerProgress(timer, `reindexed block summary`);
|
||||
let cpfpSummary;
|
||||
if (config.MEMPOOL.CPFP_INDEXING) {
|
||||
await this.$indexCPFP(newBlock.id, lastBlock.height - i);
|
||||
cpfpSummary = await this.$indexCPFP(newBlock.id, lastBlock.height - i);
|
||||
this.updateTimerProgress(timer, `reindexed block cpfp`);
|
||||
}
|
||||
await this.$getStrippedBlockTransactions(newBlock.id, true, true, cpfpSummary, newBlock.height);
|
||||
this.updateTimerProgress(timer, `reindexed block summary`);
|
||||
}
|
||||
await mining.$indexDifficultyAdjustments();
|
||||
await DifficultyAdjustmentsRepository.$deleteLastAdjustment();
|
||||
@@ -632,9 +687,12 @@ class Blocks {
|
||||
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();
|
||||
}
|
||||
await blocksRepository.$saveBlockInDatabase(blockExtended);
|
||||
this.updateTimerProgress(timer, `saved ${this.currentBlockHeight} to database`);
|
||||
}
|
||||
|
||||
await blocksRepository.$saveBlockInDatabase(blockExtended);
|
||||
this.updateTimerProgress(timer, `saved ${this.currentBlockHeight} to database`);
|
||||
|
||||
if (!fastForwarded) {
|
||||
const lastestPriceId = await PricesRepository.$getLatestPriceId();
|
||||
this.updateTimerProgress(timer, `got latest price id ${this.currentBlockHeight}`);
|
||||
if (priceUpdater.historyInserted === true && lastestPriceId !== null) {
|
||||
@@ -652,7 +710,7 @@ class Blocks {
|
||||
|
||||
// Save blocks summary for visualization if it's enabled
|
||||
if (Common.blocksSummariesIndexingEnabled() === true) {
|
||||
await this.$getStrippedBlockTransactions(blockExtended.id, true);
|
||||
await this.$getStrippedBlockTransactions(blockExtended.id, true, false, cpfpSummary, blockExtended.height);
|
||||
this.updateTimerProgress(timer, `saved block summary for ${this.currentBlockHeight}`);
|
||||
}
|
||||
if (config.MEMPOOL.CPFP_INDEXING) {
|
||||
@@ -662,6 +720,10 @@ class Blocks {
|
||||
}
|
||||
}
|
||||
|
||||
// start async callbacks
|
||||
this.updateTimerProgress(timer, `starting async callbacks for ${this.currentBlockHeight}`);
|
||||
const callbackPromises = this.newAsyncBlockCallbacks.map((cb) => cb(blockExtended, txIds, transactions));
|
||||
|
||||
if (block.height % 2016 === 0) {
|
||||
if (Common.indexingEnabled()) {
|
||||
await DifficultyAdjustmentsRepository.$saveAdjustments({
|
||||
@@ -678,6 +740,11 @@ class Blocks {
|
||||
this.currentDifficulty = block.difficulty;
|
||||
}
|
||||
|
||||
// wait for pending async callbacks to finish
|
||||
this.updateTimerProgress(timer, `waiting for async callbacks to complete for ${this.currentBlockHeight}`);
|
||||
await Promise.all(callbackPromises);
|
||||
this.updateTimerProgress(timer, `async callbacks completed for ${this.currentBlockHeight}`);
|
||||
|
||||
this.blocks.push(blockExtended);
|
||||
if (this.blocks.length > config.MEMPOOL.INITIAL_BLOCKS_AMOUNT * 4) {
|
||||
this.blocks = this.blocks.slice(-config.MEMPOOL.INITIAL_BLOCKS_AMOUNT * 4);
|
||||
@@ -694,11 +761,6 @@ class Blocks {
|
||||
diskCache.$saveCacheToDisk();
|
||||
}
|
||||
|
||||
// wait for pending async callbacks to finish
|
||||
this.updateTimerProgress(timer, `waiting for async callbacks to complete for ${this.currentBlockHeight}`);
|
||||
await Promise.all(callbackPromises);
|
||||
this.updateTimerProgress(timer, `async callbacks completed for ${this.currentBlockHeight}`);
|
||||
|
||||
handledBlocks++;
|
||||
}
|
||||
|
||||
@@ -754,6 +816,16 @@ class Blocks {
|
||||
return blockExtended;
|
||||
}
|
||||
|
||||
public async $indexStaleBlock(hash: string): Promise<BlockExtended> {
|
||||
const block: IEsploraApi.Block = await bitcoinCoreApi.$getBlock(hash);
|
||||
const transactions = await this.$getTransactionsExtended(hash, block.height, true);
|
||||
const blockExtended = await this.$getBlockExtended(block, transactions);
|
||||
|
||||
blockExtended.canonical = await bitcoinApi.$getBlockHash(block.height);
|
||||
|
||||
return blockExtended;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get one block by its hash
|
||||
*/
|
||||
@@ -771,11 +843,15 @@ class Blocks {
|
||||
|
||||
// Bitcoin network, add our custom data on top
|
||||
const block: IEsploraApi.Block = await bitcoinCoreApi.$getBlock(hash);
|
||||
return await this.$indexBlock(block.height);
|
||||
if (block.stale) {
|
||||
return await this.$indexStaleBlock(hash);
|
||||
} else {
|
||||
return await this.$indexBlock(block.height);
|
||||
}
|
||||
}
|
||||
|
||||
public async $getStrippedBlockTransactions(hash: string, skipMemoryCache = false,
|
||||
skipDBLookup = false): Promise<TransactionStripped[]>
|
||||
skipDBLookup = false, cpfpSummary?: CpfpSummary, blockHeight?: number): Promise<TransactionStripped[]>
|
||||
{
|
||||
if (skipMemoryCache === false) {
|
||||
// Check the memory cache
|
||||
@@ -793,13 +869,35 @@ class Blocks {
|
||||
}
|
||||
}
|
||||
|
||||
// Call Core RPC
|
||||
const block = await bitcoinClient.getBlock(hash, 2);
|
||||
const summary = this.summarizeBlock(block);
|
||||
let height = blockHeight;
|
||||
let summary: BlockSummary;
|
||||
if (cpfpSummary) {
|
||||
summary = {
|
||||
id: hash,
|
||||
transactions: cpfpSummary.transactions.map(tx => {
|
||||
return {
|
||||
txid: tx.txid,
|
||||
fee: tx.fee,
|
||||
vsize: tx.vsize,
|
||||
value: Math.round(tx.vout.reduce((acc, vout) => acc + (vout.value ? vout.value : 0), 0)),
|
||||
rate: tx.effectiveFeePerVsize
|
||||
};
|
||||
}),
|
||||
};
|
||||
} else {
|
||||
// Call Core RPC
|
||||
const block = await bitcoinClient.getBlock(hash, 2);
|
||||
summary = this.summarizeBlock(block);
|
||||
height = block.height;
|
||||
}
|
||||
if (height == null) {
|
||||
const block = await bitcoinApi.$getBlock(hash);
|
||||
height = block.height;
|
||||
}
|
||||
|
||||
// Index the response if needed
|
||||
if (Common.blocksSummariesIndexingEnabled() === true) {
|
||||
await BlocksSummariesRepository.$saveTransactions(block.height, block.hash, summary.transactions);
|
||||
await BlocksSummariesRepository.$saveTransactions(height, hash, summary.transactions);
|
||||
}
|
||||
|
||||
return summary.transactions;
|
||||
@@ -955,19 +1053,11 @@ class Blocks {
|
||||
}
|
||||
|
||||
public async $getBlockAuditSummary(hash: string): Promise<any> {
|
||||
let summary;
|
||||
if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK)) {
|
||||
summary = await BlocksAuditsRepository.$getBlockAudit(hash);
|
||||
return BlocksAuditsRepository.$getBlockAudit(hash);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
// fallback to non-audited transaction summary
|
||||
if (!summary?.transactions?.length) {
|
||||
const strippedTransactions = await this.$getStrippedBlockTransactions(hash);
|
||||
summary = {
|
||||
transactions: strippedTransactions
|
||||
};
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
public getLastDifficultyAdjustmentTime(): number {
|
||||
@@ -998,9 +1088,13 @@ class Blocks {
|
||||
}
|
||||
|
||||
public async $saveCpfp(hash: string, height: number, cpfpSummary: CpfpSummary): Promise<void> {
|
||||
const result = await cpfpRepository.$batchSaveClusters(cpfpSummary.clusters);
|
||||
if (!result) {
|
||||
await cpfpRepository.$insertProgressMarker(height);
|
||||
try {
|
||||
const result = await cpfpRepository.$batchSaveClusters(cpfpSummary.clusters);
|
||||
if (!result) {
|
||||
await cpfpRepository.$insertProgressMarker(height);
|
||||
}
|
||||
} catch (e) {
|
||||
// not a fatal error, we'll try again next time the indexer runs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Ancestor, CpfpInfo, CpfpSummary, EffectiveFeeStats, MempoolBlockWithTransactions, TransactionExtended, TransactionStripped } from '../mempool.interfaces';
|
||||
import { Ancestor, CpfpInfo, CpfpSummary, CpfpCluster, EffectiveFeeStats, MempoolBlockWithTransactions, TransactionExtended, MempoolTransactionExtended, TransactionStripped, WorkingEffectiveFeeStats } from '../mempool.interfaces';
|
||||
import config from '../config';
|
||||
import { NodeSocket } from '../repositories/NodesSocketsRepository';
|
||||
import { isIP } from 'net';
|
||||
@@ -57,15 +57,15 @@ export class Common {
|
||||
return arr;
|
||||
}
|
||||
|
||||
static findRbfTransactions(added: TransactionExtended[], deleted: TransactionExtended[]): { [txid: string]: TransactionExtended[] } {
|
||||
const matches: { [txid: string]: TransactionExtended[] } = {};
|
||||
static findRbfTransactions(added: MempoolTransactionExtended[], deleted: MempoolTransactionExtended[]): { [txid: string]: MempoolTransactionExtended[] } {
|
||||
const matches: { [txid: string]: MempoolTransactionExtended[] } = {};
|
||||
added
|
||||
.forEach((addedTx) => {
|
||||
const foundMatches = deleted.filter((deletedTx) => {
|
||||
// The new tx must, absolutely speaking, pay at least as much fee as the replaced tx.
|
||||
return addedTx.fee > deletedTx.fee
|
||||
// The new transaction must pay more fee per kB than the replaced tx.
|
||||
&& addedTx.feePerVsize > deletedTx.feePerVsize
|
||||
&& addedTx.adjustedFeePerVsize > deletedTx.adjustedFeePerVsize
|
||||
// Spends one or more of the same inputs
|
||||
&& deletedTx.vin.some((deletedVin) =>
|
||||
addedTx.vin.some((vin) => vin.txid === deletedVin.txid && vin.vout === deletedVin.vout));
|
||||
@@ -77,6 +77,32 @@ export class Common {
|
||||
return matches;
|
||||
}
|
||||
|
||||
static findMinedRbfTransactions(minedTransactions: TransactionExtended[], spendMap: Map<string, MempoolTransactionExtended>): { [txid: string]: { replaced: MempoolTransactionExtended[], replacedBy: TransactionExtended }} {
|
||||
const matches: { [txid: string]: { replaced: MempoolTransactionExtended[], replacedBy: TransactionExtended }} = {};
|
||||
for (const tx of minedTransactions) {
|
||||
const replaced: Set<MempoolTransactionExtended> = new Set();
|
||||
for (let i = 0; i < tx.vin.length; i++) {
|
||||
const vin = tx.vin[i];
|
||||
const match = spendMap.get(`${vin.txid}:${vin.vout}`);
|
||||
if (match && match.txid !== tx.txid) {
|
||||
replaced.add(match);
|
||||
}
|
||||
}
|
||||
if (replaced.size) {
|
||||
matches[tx.txid] = { replaced: Array.from(replaced), replacedBy: tx };
|
||||
}
|
||||
// remove this tx from the spendMap
|
||||
// prevents the same tx being replaced more than once
|
||||
for (const vin of tx.vin) {
|
||||
const key = `${vin.txid}:${vin.vout}`;
|
||||
if (spendMap.get(key)?.txid === tx.txid) {
|
||||
spendMap.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
static stripTransaction(tx: TransactionExtended): TransactionStripped {
|
||||
return {
|
||||
txid: tx.txid,
|
||||
@@ -87,6 +113,10 @@ export class Common {
|
||||
};
|
||||
}
|
||||
|
||||
static stripTransactions(txs: TransactionExtended[]): TransactionStripped[] {
|
||||
return txs.map(this.stripTransaction);
|
||||
}
|
||||
|
||||
static sleep$(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
@@ -102,18 +132,18 @@ export class Common {
|
||||
}
|
||||
}
|
||||
|
||||
static setRelativesAndGetCpfpInfo(tx: TransactionExtended, memPool: { [txid: string]: TransactionExtended }): CpfpInfo {
|
||||
static setRelativesAndGetCpfpInfo(tx: MempoolTransactionExtended, memPool: { [txid: string]: MempoolTransactionExtended }): CpfpInfo {
|
||||
const parents = this.findAllParents(tx, memPool);
|
||||
const lowerFeeParents = parents.filter((parent) => parent.feePerVsize < tx.effectiveFeePerVsize);
|
||||
const lowerFeeParents = parents.filter((parent) => parent.adjustedFeePerVsize < tx.effectiveFeePerVsize);
|
||||
|
||||
let totalWeight = tx.weight + lowerFeeParents.reduce((prev, val) => prev + val.weight, 0);
|
||||
let totalWeight = (tx.adjustedVsize * 4) + lowerFeeParents.reduce((prev, val) => prev + (val.adjustedVsize * 4), 0);
|
||||
let totalFees = tx.fee + lowerFeeParents.reduce((prev, val) => prev + val.fee, 0);
|
||||
|
||||
tx.ancestors = parents
|
||||
.map((t) => {
|
||||
return {
|
||||
txid: t.txid,
|
||||
weight: t.weight,
|
||||
weight: (t.adjustedVsize * 4),
|
||||
fee: t.fee,
|
||||
};
|
||||
});
|
||||
@@ -134,8 +164,8 @@ export class Common {
|
||||
}
|
||||
|
||||
|
||||
private static findAllParents(tx: TransactionExtended, memPool: { [txid: string]: TransactionExtended }): TransactionExtended[] {
|
||||
let parents: TransactionExtended[] = [];
|
||||
private static findAllParents(tx: MempoolTransactionExtended, memPool: { [txid: string]: MempoolTransactionExtended }): MempoolTransactionExtended[] {
|
||||
let parents: MempoolTransactionExtended[] = [];
|
||||
tx.vin.forEach((parent) => {
|
||||
if (parents.find((p) => p.txid === parent.txid)) {
|
||||
return;
|
||||
@@ -143,17 +173,17 @@ export class Common {
|
||||
|
||||
const parentTx = memPool[parent.txid];
|
||||
if (parentTx) {
|
||||
if (tx.bestDescendant && tx.bestDescendant.fee / (tx.bestDescendant.weight / 4) > parentTx.feePerVsize) {
|
||||
if (tx.bestDescendant && tx.bestDescendant.fee / (tx.bestDescendant.weight / 4) > parentTx.adjustedFeePerVsize) {
|
||||
if (parentTx.bestDescendant && parentTx.bestDescendant.fee < tx.fee + tx.bestDescendant.fee) {
|
||||
parentTx.bestDescendant = {
|
||||
weight: tx.weight + tx.bestDescendant.weight,
|
||||
weight: (tx.adjustedVsize * 4) + tx.bestDescendant.weight,
|
||||
fee: tx.fee + tx.bestDescendant.fee,
|
||||
txid: tx.txid,
|
||||
};
|
||||
}
|
||||
} else if (tx.feePerVsize > parentTx.feePerVsize) {
|
||||
} else if (tx.adjustedFeePerVsize > parentTx.adjustedFeePerVsize) {
|
||||
parentTx.bestDescendant = {
|
||||
weight: tx.weight,
|
||||
weight: (tx.adjustedVsize * 4),
|
||||
fee: tx.fee,
|
||||
txid: tx.txid
|
||||
};
|
||||
@@ -348,40 +378,80 @@ export class Common {
|
||||
}
|
||||
|
||||
static calculateCpfp(height: number, transactions: TransactionExtended[]): CpfpSummary {
|
||||
const clusters: { root: string, height: number, txs: Ancestor[], effectiveFeePerVsize: number }[] = [];
|
||||
let cluster: TransactionExtended[] = [];
|
||||
let ancestors: { [txid: string]: boolean } = {};
|
||||
const clusters: CpfpCluster[] = []; // list of all cpfp clusters in this block
|
||||
const clusterMap: { [txid: string]: CpfpCluster } = {}; // map transactions to their cpfp cluster
|
||||
let clusterTxs: TransactionExtended[] = []; // working list of elements of the current cluster
|
||||
let ancestors: { [txid: string]: boolean } = {}; // working set of ancestors of the current cluster root
|
||||
const txMap = {};
|
||||
// initialize the txMap
|
||||
for (const tx of transactions) {
|
||||
txMap[tx.txid] = tx;
|
||||
}
|
||||
// reverse pass to identify CPFP clusters
|
||||
for (let i = transactions.length - 1; i >= 0; i--) {
|
||||
const tx = transactions[i];
|
||||
txMap[tx.txid] = tx;
|
||||
if (!ancestors[tx.txid]) {
|
||||
let totalFee = 0;
|
||||
let totalVSize = 0;
|
||||
cluster.forEach(tx => {
|
||||
clusterTxs.forEach(tx => {
|
||||
totalFee += tx?.fee || 0;
|
||||
totalVSize += (tx.weight / 4);
|
||||
});
|
||||
const effectiveFeePerVsize = totalFee / totalVSize;
|
||||
if (cluster.length > 1) {
|
||||
clusters.push({
|
||||
root: cluster[0].txid,
|
||||
let cluster: CpfpCluster;
|
||||
if (clusterTxs.length > 1) {
|
||||
cluster = {
|
||||
root: clusterTxs[0].txid,
|
||||
height,
|
||||
txs: cluster.map(tx => { return { txid: tx.txid, weight: tx.weight, fee: tx.fee || 0 }; }),
|
||||
txs: clusterTxs.map(tx => { return { txid: tx.txid, weight: tx.weight, fee: tx.fee || 0 }; }),
|
||||
effectiveFeePerVsize,
|
||||
});
|
||||
};
|
||||
clusters.push(cluster);
|
||||
}
|
||||
cluster.forEach(tx => {
|
||||
clusterTxs.forEach(tx => {
|
||||
txMap[tx.txid].effectiveFeePerVsize = effectiveFeePerVsize;
|
||||
if (cluster) {
|
||||
clusterMap[tx.txid] = cluster;
|
||||
}
|
||||
});
|
||||
cluster = [];
|
||||
// reset working vars
|
||||
clusterTxs = [];
|
||||
ancestors = {};
|
||||
}
|
||||
cluster.push(tx);
|
||||
clusterTxs.push(tx);
|
||||
tx.vin.forEach(vin => {
|
||||
ancestors[vin.txid] = true;
|
||||
});
|
||||
}
|
||||
// forward pass to enforce ancestor rate caps
|
||||
for (const tx of transactions) {
|
||||
let minAncestorRate = tx.effectiveFeePerVsize;
|
||||
for (const vin of tx.vin) {
|
||||
if (txMap[vin.txid]?.effectiveFeePerVsize) {
|
||||
minAncestorRate = Math.min(minAncestorRate, txMap[vin.txid].effectiveFeePerVsize);
|
||||
}
|
||||
}
|
||||
// check rounded values to skip cases with almost identical fees
|
||||
const roundedMinAncestorRate = Math.ceil(minAncestorRate);
|
||||
const roundedEffectiveFeeRate = Math.floor(tx.effectiveFeePerVsize);
|
||||
if (roundedMinAncestorRate < roundedEffectiveFeeRate) {
|
||||
tx.effectiveFeePerVsize = minAncestorRate;
|
||||
if (!clusterMap[tx.txid]) {
|
||||
// add a single-tx cluster to record the dependent rate
|
||||
const cluster = {
|
||||
root: tx.txid,
|
||||
height,
|
||||
txs: [{ txid: tx.txid, weight: tx.weight, fee: tx.fee || 0 }],
|
||||
effectiveFeePerVsize: minAncestorRate,
|
||||
};
|
||||
clusterMap[tx.txid] = cluster;
|
||||
clusters.push(cluster);
|
||||
} else {
|
||||
// update the existing cluster with the dependent rate
|
||||
clusterMap[tx.txid].effectiveFeePerVsize = minAncestorRate;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
transactions,
|
||||
clusters,
|
||||
@@ -442,3 +512,119 @@ export class Common {
|
||||
return sortedDistribution[Math.floor((sortedDistribution.length - 1) * (n / 100))];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Class to calculate average fee rates of a list of transactions
|
||||
* at certain weight percentiles, in a single pass
|
||||
*
|
||||
* init with:
|
||||
* maxWeight - the total weight to measure percentiles relative to (e.g. 4MW for a single block)
|
||||
* percentileBandWidth - how many weight units to average over for each percentile (as a % of maxWeight)
|
||||
* percentiles - an array of weight percentiles to compute, in %
|
||||
*
|
||||
* then call .processNext(tx) for each transaction, in descending order
|
||||
*
|
||||
* retrieve the final results with .getFeeStats()
|
||||
*/
|
||||
export class OnlineFeeStatsCalculator {
|
||||
private maxWeight: number;
|
||||
private percentiles = [10,25,50,75,90];
|
||||
|
||||
private bandWidthPercent = 2;
|
||||
private bandWidth: number = 0;
|
||||
private bandIndex = 0;
|
||||
private leftBound = 0;
|
||||
private rightBound = 0;
|
||||
private inBand = false;
|
||||
private totalBandFee = 0;
|
||||
private totalBandWeight = 0;
|
||||
private minBandRate = Infinity;
|
||||
private maxBandRate = 0;
|
||||
|
||||
private feeRange: { avg: number, min: number, max: number }[] = [];
|
||||
private totalWeight: number = 0;
|
||||
|
||||
constructor (maxWeight: number, percentileBandWidth?: number, percentiles?: number[]) {
|
||||
this.maxWeight = maxWeight;
|
||||
if (percentiles && percentiles.length) {
|
||||
this.percentiles = percentiles;
|
||||
}
|
||||
if (percentileBandWidth != null) {
|
||||
this.bandWidthPercent = percentileBandWidth;
|
||||
}
|
||||
this.bandWidth = this.maxWeight * (this.bandWidthPercent / 100);
|
||||
// add min/max percentiles aligned to the ends of the range
|
||||
this.percentiles.unshift(this.bandWidthPercent / 2);
|
||||
this.percentiles.push(100 - (this.bandWidthPercent / 2));
|
||||
this.setNextBounds();
|
||||
}
|
||||
|
||||
processNext(tx: { weight: number, fee: number, effectiveFeePerVsize?: number, feePerVsize?: number, rate?: number, txid: string }): void {
|
||||
let left = this.totalWeight;
|
||||
const right = this.totalWeight + tx.weight;
|
||||
if (!this.inBand && right <= this.leftBound) {
|
||||
this.totalWeight += tx.weight;
|
||||
return;
|
||||
}
|
||||
|
||||
while (left < right) {
|
||||
if (right > this.leftBound) {
|
||||
this.inBand = true;
|
||||
const txRate = (tx.rate || tx.effectiveFeePerVsize || tx.feePerVsize || 0);
|
||||
const weight = Math.min(right, this.rightBound) - Math.max(left, this.leftBound);
|
||||
this.totalBandFee += (txRate * weight);
|
||||
this.totalBandWeight += weight;
|
||||
this.maxBandRate = Math.max(this.maxBandRate, txRate);
|
||||
this.minBandRate = Math.min(this.minBandRate, txRate);
|
||||
}
|
||||
left = Math.min(right, this.rightBound);
|
||||
|
||||
if (left >= this.rightBound) {
|
||||
this.inBand = false;
|
||||
const avgBandFeeRate = this.totalBandWeight ? (this.totalBandFee / this.totalBandWeight) : 0;
|
||||
this.feeRange.unshift({ avg: avgBandFeeRate, min: this.minBandRate, max: this.maxBandRate });
|
||||
this.bandIndex++;
|
||||
this.setNextBounds();
|
||||
this.totalBandFee = 0;
|
||||
this.totalBandWeight = 0;
|
||||
this.minBandRate = Infinity;
|
||||
this.maxBandRate = 0;
|
||||
}
|
||||
}
|
||||
this.totalWeight += tx.weight;
|
||||
}
|
||||
|
||||
private setNextBounds(): void {
|
||||
const nextPercentile = this.percentiles[this.bandIndex];
|
||||
if (nextPercentile != null) {
|
||||
this.leftBound = ((nextPercentile / 100) * this.maxWeight) - (this.bandWidth / 2);
|
||||
this.rightBound = this.leftBound + this.bandWidth;
|
||||
} else {
|
||||
this.leftBound = Infinity;
|
||||
this.rightBound = Infinity;
|
||||
}
|
||||
}
|
||||
|
||||
getRawFeeStats(): WorkingEffectiveFeeStats {
|
||||
if (this.totalBandWeight > 0) {
|
||||
const avgBandFeeRate = this.totalBandWeight ? (this.totalBandFee / this.totalBandWeight) : 0;
|
||||
this.feeRange.unshift({ avg: avgBandFeeRate, min: this.minBandRate, max: this.maxBandRate });
|
||||
}
|
||||
while (this.feeRange.length < this.percentiles.length) {
|
||||
this.feeRange.unshift({ avg: 0, min: 0, max: 0 });
|
||||
}
|
||||
return {
|
||||
minFee: this.feeRange[0].min,
|
||||
medianFee: this.feeRange[Math.floor(this.feeRange.length / 2)].avg,
|
||||
maxFee: this.feeRange[this.feeRange.length - 1].max,
|
||||
feeRange: this.feeRange.map(f => f.avg),
|
||||
};
|
||||
}
|
||||
|
||||
getFeeStats(): EffectiveFeeStats {
|
||||
const stats = this.getRawFeeStats();
|
||||
stats.feeRange[0] = stats.minFee;
|
||||
stats.feeRange[stats.feeRange.length - 1] = stats.maxFee;
|
||||
return stats;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import cpfpRepository from '../repositories/CpfpRepository';
|
||||
import { RowDataPacket } from 'mysql2';
|
||||
|
||||
class DatabaseMigration {
|
||||
private static currentVersion = 59;
|
||||
private static currentVersion = 63;
|
||||
private queryTimeout = 3600_000;
|
||||
private statisticsAddedIndexed = false;
|
||||
private uniqueLogs: string[] = [];
|
||||
@@ -516,6 +516,33 @@ class DatabaseMigration {
|
||||
// https://github.com/mempool/mempool/issues/3360
|
||||
await this.$executeQuery(`TRUNCATE prices`);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 60 && isBitcoin === true) {
|
||||
await this.$executeQuery('ALTER TABLE `blocks_audits` ADD sigop_txs JSON DEFAULT "[]"');
|
||||
await this.updateToSchemaVersion(60);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 61 && isBitcoin === true) {
|
||||
// Break block templates into their own table
|
||||
if (! await this.$checkIfTableExists('blocks_templates')) {
|
||||
await this.$executeQuery('CREATE TABLE blocks_templates AS SELECT id, template FROM blocks_summaries WHERE template != "[]"');
|
||||
}
|
||||
await this.$executeQuery('ALTER TABLE blocks_templates MODIFY template JSON DEFAULT "[]"');
|
||||
await this.$executeQuery('ALTER TABLE blocks_templates ADD PRIMARY KEY (id)');
|
||||
await this.$executeQuery('ALTER TABLE blocks_summaries DROP COLUMN template');
|
||||
await this.updateToSchemaVersion(61);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 62 && isBitcoin === true) {
|
||||
await this.$executeQuery('ALTER TABLE `blocks_audits` ADD expected_fees BIGINT UNSIGNED DEFAULT NULL');
|
||||
await this.$executeQuery('ALTER TABLE `blocks_audits` ADD expected_weight BIGINT UNSIGNED DEFAULT NULL');
|
||||
await this.updateToSchemaVersion(62);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 63 && isBitcoin === true) {
|
||||
await this.$executeQuery('ALTER TABLE `blocks_audits` ADD fullrbf_txs JSON DEFAULT "[]"');
|
||||
await this.updateToSchemaVersion(63);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1034,7 +1061,7 @@ class DatabaseMigration {
|
||||
}
|
||||
|
||||
public async $blocksReindexingTruncate(): Promise<void> {
|
||||
logger.warn(`Truncating pools, blocks and hashrates for re-indexing (using '--reindex-blocks'). You can cancel this command within 5 seconds`);
|
||||
logger.warn(`Truncating pools, blocks, hashrates and difficulty_adjustments tables for re-indexing (using '--reindex-blocks'). You can cancel this command within 5 seconds`);
|
||||
await Common.sleep$(5000);
|
||||
|
||||
await this.$executeQuery(`TRUNCATE blocks`);
|
||||
|
||||
@@ -34,11 +34,12 @@ export function calcDifficultyAdjustment(
|
||||
const remainingBlocks = EPOCH_BLOCK_LENGTH - blocksInEpoch;
|
||||
const nextRetargetHeight = (blockHeight >= 0) ? blockHeight + remainingBlocks : 0;
|
||||
const expectedBlocks = diffSeconds / BLOCK_SECONDS_TARGET;
|
||||
const actualTimespan = (blocksInEpoch === 2015 ? latestBlockTimestamp : nowSeconds) - DATime;
|
||||
|
||||
let difficultyChange = 0;
|
||||
let timeAvgSecs = blocksInEpoch ? diffSeconds / blocksInEpoch : BLOCK_SECONDS_TARGET;
|
||||
|
||||
difficultyChange = (BLOCK_SECONDS_TARGET / timeAvgSecs - 1) * 100;
|
||||
difficultyChange = (BLOCK_SECONDS_TARGET / (actualTimespan / (blocksInEpoch + 1)) - 1) * 100;
|
||||
// Max increase is x4 (+300%)
|
||||
if (difficultyChange > 300) {
|
||||
difficultyChange = 300;
|
||||
|
||||
@@ -21,6 +21,7 @@ class DiskCache {
|
||||
private static RBF_FILE_NAME = config.MEMPOOL.CACHE_DIR + '/rbfcache.json';
|
||||
private static CHUNK_FILES = 25;
|
||||
private isWritingCache = false;
|
||||
private ignoreBlocksCache = false;
|
||||
|
||||
private semaphore: { resume: (() => void)[], locks: number } = {
|
||||
resume: [],
|
||||
@@ -194,6 +195,7 @@ class DiskCache {
|
||||
|
||||
if (data.mempoolArray) {
|
||||
for (const tx of data.mempoolArray) {
|
||||
delete tx.uid;
|
||||
data.mempool[tx.txid] = tx;
|
||||
}
|
||||
}
|
||||
@@ -206,6 +208,7 @@ class DiskCache {
|
||||
const cacheData2 = JSON.parse(fs.readFileSync(fileName, 'utf8'));
|
||||
if (cacheData2.mempoolArray) {
|
||||
for (const tx of cacheData2.mempoolArray) {
|
||||
delete tx.uid;
|
||||
data.mempool[tx.txid] = tx;
|
||||
}
|
||||
} else {
|
||||
@@ -218,8 +221,13 @@ class DiskCache {
|
||||
}
|
||||
|
||||
await memPool.$setMempool(data.mempool);
|
||||
blocks.setBlocks(data.blocks);
|
||||
blocks.setBlockSummaries(data.blockSummaries || []);
|
||||
if (!this.ignoreBlocksCache) {
|
||||
blocks.setBlocks(data.blocks);
|
||||
blocks.setBlockSummaries(data.blockSummaries || []);
|
||||
} else {
|
||||
logger.info('Re-saving cache with empty recent blocks data');
|
||||
await this.$saveCacheToDisk(true);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('Failed to parse mempoool and blocks cache. Skipping. Reason: ' + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
@@ -273,6 +281,10 @@ class DiskCache {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public setIgnoreBlocksCache(): void {
|
||||
this.ignoreBlocksCache = true;
|
||||
}
|
||||
}
|
||||
|
||||
export default new DiskCache();
|
||||
|
||||
@@ -373,7 +373,7 @@ class NodesApi {
|
||||
|
||||
public async $searchNodeByPublicKeyOrAlias(search: string) {
|
||||
try {
|
||||
const publicKeySearch = search.replace('%', '') + '%';
|
||||
const publicKeySearch = search.replace(/[^a-zA-Z0-9]/g, '') + '%';
|
||||
const aliasSearch = search
|
||||
.replace(/[-_.]/g, ' ') // Replace all -_. characters with empty space. Eg: "ln.nicehash" becomes "ln nicehash".
|
||||
.replace(/[^a-zA-Z0-9 ]/g, '') // Remove all special characters and keep just A to Z, 0 to 9.
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
import { GbtGenerator, GbtResult, ThreadTransaction as RustThreadTransaction } from '../../rust-gbt';
|
||||
import logger from '../logger';
|
||||
import { MempoolBlock, TransactionExtended, TransactionStripped, MempoolBlockWithTransactions, MempoolBlockDelta, Ancestor, CompactThreadTransaction } from '../mempool.interfaces';
|
||||
import { Common } from './common';
|
||||
import { MempoolBlock, MempoolTransactionExtended, TransactionStripped, MempoolBlockWithTransactions, MempoolBlockDelta, Ancestor, CompactThreadTransaction, EffectiveFeeStats } from '../mempool.interfaces';
|
||||
import { Common, OnlineFeeStatsCalculator } from './common';
|
||||
import config from '../config';
|
||||
import { Worker } from 'worker_threads';
|
||||
import path from 'path';
|
||||
|
||||
const MAX_UINT32 = Math.pow(2, 32) - 1;
|
||||
|
||||
class MempoolBlocks {
|
||||
private mempoolBlocks: MempoolBlockWithTransactions[] = [];
|
||||
private mempoolBlockDeltas: MempoolBlockDelta[] = [];
|
||||
private txSelectionWorker: Worker | null = null;
|
||||
private rustInitialized: boolean = false;
|
||||
private rustGbtGenerator: GbtGenerator = new GbtGenerator();
|
||||
|
||||
private nextUid: number = 1;
|
||||
private uidMap: Map<number, string> = new Map(); // map short numerical uids to full txids
|
||||
|
||||
constructor() {}
|
||||
|
||||
public getMempoolBlocks(): MempoolBlock[] {
|
||||
return this.mempoolBlocks.map((block) => {
|
||||
return {
|
||||
@@ -36,13 +39,11 @@ class MempoolBlocks {
|
||||
return this.mempoolBlockDeltas;
|
||||
}
|
||||
|
||||
public updateMempoolBlocks(memPool: { [txid: string]: TransactionExtended }, saveResults: boolean = false): MempoolBlockWithTransactions[] {
|
||||
public updateMempoolBlocks(memPool: { [txid: string]: MempoolTransactionExtended }, saveResults: boolean = false): MempoolBlockWithTransactions[] {
|
||||
const latestMempool = memPool;
|
||||
const memPoolArray: TransactionExtended[] = [];
|
||||
const memPoolArray: MempoolTransactionExtended[] = [];
|
||||
for (const i in latestMempool) {
|
||||
if (latestMempool.hasOwnProperty(i)) {
|
||||
memPoolArray.push(latestMempool[i]);
|
||||
}
|
||||
memPoolArray.push(latestMempool[i]);
|
||||
}
|
||||
const start = new Date().getTime();
|
||||
|
||||
@@ -52,17 +53,17 @@ class MempoolBlocks {
|
||||
tx.ancestors = [];
|
||||
tx.cpfpChecked = false;
|
||||
if (!tx.effectiveFeePerVsize) {
|
||||
tx.effectiveFeePerVsize = tx.feePerVsize;
|
||||
tx.effectiveFeePerVsize = tx.adjustedFeePerVsize;
|
||||
}
|
||||
});
|
||||
|
||||
// First sort
|
||||
memPoolArray.sort((a, b) => {
|
||||
if (a.feePerVsize === b.feePerVsize) {
|
||||
if (a.adjustedFeePerVsize === b.adjustedFeePerVsize) {
|
||||
// tie-break by lexicographic txid order for stability
|
||||
return a.txid < b.txid ? -1 : 1;
|
||||
} else {
|
||||
return b.feePerVsize - a.feePerVsize;
|
||||
return b.adjustedFeePerVsize - a.adjustedFeePerVsize;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -102,16 +103,18 @@ class MempoolBlocks {
|
||||
return blocks;
|
||||
}
|
||||
|
||||
private calculateMempoolBlocks(transactionsSorted: TransactionExtended[]): MempoolBlockWithTransactions[] {
|
||||
private calculateMempoolBlocks(transactionsSorted: MempoolTransactionExtended[]): MempoolBlockWithTransactions[] {
|
||||
const mempoolBlocks: MempoolBlockWithTransactions[] = [];
|
||||
let feeStatsCalculator: OnlineFeeStatsCalculator = new OnlineFeeStatsCalculator(config.MEMPOOL.BLOCK_WEIGHT_UNITS);
|
||||
let onlineStats = false;
|
||||
let blockSize = 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[] = [];
|
||||
transactionsSorted.forEach((tx) => {
|
||||
let transactions: MempoolTransactionExtended[] = [];
|
||||
transactionsSorted.forEach((tx, index) => {
|
||||
if (blockWeight + tx.weight <= config.MEMPOOL.BLOCK_WEIGHT_UNITS
|
||||
|| mempoolBlocks.length === config.MEMPOOL.MEMPOOL_BLOCKS_AMOUNT - 1) {
|
||||
tx.position = {
|
||||
@@ -126,6 +129,9 @@ class MempoolBlocks {
|
||||
transactions.push(tx);
|
||||
}
|
||||
transactionIds.push(tx.txid);
|
||||
if (onlineStats) {
|
||||
feeStatsCalculator.processNext(tx);
|
||||
}
|
||||
} else {
|
||||
mempoolBlocks.push(this.dataToMempoolBlocks(transactionIds, transactions, blockSize, blockWeight, blockFees));
|
||||
blockVsize = 0;
|
||||
@@ -133,6 +139,16 @@ class MempoolBlocks {
|
||||
block: mempoolBlocks.length,
|
||||
vsize: blockVsize + (tx.vsize / 2),
|
||||
};
|
||||
|
||||
if (mempoolBlocks.length === config.MEMPOOL.MEMPOOL_BLOCKS_AMOUNT - 1) {
|
||||
const stackWeight = transactionsSorted.slice(index).reduce((total, tx) => total + (tx.weight || 0), 0);
|
||||
if (stackWeight > config.MEMPOOL.BLOCK_WEIGHT_UNITS) {
|
||||
onlineStats = true;
|
||||
feeStatsCalculator = new OnlineFeeStatsCalculator(stackWeight, 0.5, [10, 20, 30, 40, 50, 60, 70, 80, 90]);
|
||||
feeStatsCalculator.processNext(tx);
|
||||
}
|
||||
}
|
||||
|
||||
blockVsize += tx.vsize;
|
||||
blockWeight = tx.weight;
|
||||
blockSize = tx.size;
|
||||
@@ -142,7 +158,8 @@ class MempoolBlocks {
|
||||
}
|
||||
});
|
||||
if (transactions.length) {
|
||||
mempoolBlocks.push(this.dataToMempoolBlocks(transactionIds, transactions, blockSize, blockWeight, blockFees));
|
||||
const feeStats = onlineStats ? feeStatsCalculator.getRawFeeStats() : undefined;
|
||||
mempoolBlocks.push(this.dataToMempoolBlocks(transactionIds, transactions, blockSize, blockWeight, blockFees, feeStats));
|
||||
}
|
||||
|
||||
return mempoolBlocks;
|
||||
@@ -189,7 +206,7 @@ class MempoolBlocks {
|
||||
return mempoolBlockDeltas;
|
||||
}
|
||||
|
||||
public async $makeBlockTemplates(newMempool: { [txid: string]: TransactionExtended }, saveResults: boolean = false): Promise<MempoolBlockWithTransactions[]> {
|
||||
public async $makeBlockTemplates(newMempool: { [txid: string]: MempoolTransactionExtended }, saveResults: boolean = false): Promise<MempoolBlockWithTransactions[]> {
|
||||
const start = Date.now();
|
||||
|
||||
// reset mempool short ids
|
||||
@@ -202,15 +219,17 @@ class MempoolBlocks {
|
||||
// to reduce the overhead of passing this data to the worker thread
|
||||
const strippedMempool: Map<number, CompactThreadTransaction> = new Map();
|
||||
Object.values(newMempool).forEach(entry => {
|
||||
if (entry.uid != null) {
|
||||
strippedMempool.set(entry.uid, {
|
||||
if (entry.uid !== null && entry.uid !== undefined) {
|
||||
const stripped = {
|
||||
uid: entry.uid,
|
||||
fee: entry.fee,
|
||||
weight: entry.weight,
|
||||
feePerVsize: entry.fee / (entry.weight / 4),
|
||||
effectiveFeePerVsize: entry.effectiveFeePerVsize || (entry.fee / (entry.weight / 4)),
|
||||
inputs: entry.vin.map(v => this.getUid(newMempool[v.txid])).filter(uid => uid != null) as number[],
|
||||
});
|
||||
weight: (entry.adjustedVsize * 4),
|
||||
sigops: entry.sigops,
|
||||
feePerVsize: entry.adjustedFeePerVsize || entry.feePerVsize,
|
||||
effectiveFeePerVsize: entry.effectiveFeePerVsize || entry.adjustedFeePerVsize || entry.feePerVsize,
|
||||
inputs: entry.vin.map(v => this.getUid(newMempool[v.txid])).filter(uid => (uid !== null && uid !== undefined)) as number[],
|
||||
};
|
||||
strippedMempool.set(entry.uid, stripped);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -243,8 +262,10 @@ class MempoolBlocks {
|
||||
// clean up thread error listener
|
||||
this.txSelectionWorker?.removeListener('error', threadErrorListener);
|
||||
|
||||
const processed = this.processBlockTemplates(newMempool, blocks, rates, clusters, saveResults);
|
||||
const processed = this.processBlockTemplates(newMempool, blocks, null, Object.entries(rates), Object.values(clusters), saveResults);
|
||||
|
||||
logger.debug(`makeBlockTemplates completed in ${(Date.now() - start)/1000} seconds`);
|
||||
|
||||
return processed;
|
||||
} catch (e) {
|
||||
logger.err('makeBlockTemplates failed. ' + (e instanceof Error ? e.message : e));
|
||||
@@ -252,7 +273,7 @@ class MempoolBlocks {
|
||||
return this.mempoolBlocks;
|
||||
}
|
||||
|
||||
public async $updateBlockTemplates(newMempool: { [txid: string]: TransactionExtended }, added: TransactionExtended[], removed: TransactionExtended[], saveResults: boolean = false): Promise<void> {
|
||||
public async $updateBlockTemplates(newMempool: { [txid: string]: MempoolTransactionExtended }, added: MempoolTransactionExtended[], removed: MempoolTransactionExtended[], saveResults: boolean = false): Promise<void> {
|
||||
if (!this.txSelectionWorker) {
|
||||
// need to reset the worker
|
||||
await this.$makeBlockTemplates(newMempool, saveResults);
|
||||
@@ -262,19 +283,20 @@ class MempoolBlocks {
|
||||
const start = Date.now();
|
||||
|
||||
for (const tx of Object.values(added)) {
|
||||
this.setUid(tx);
|
||||
this.setUid(tx, true);
|
||||
}
|
||||
const removedUids = removed.map(tx => this.getUid(tx)).filter(uid => uid != null) as number[];
|
||||
const removedUids = removed.map(tx => this.getUid(tx)).filter(uid => (uid !== null && uid !== undefined)) as number[];
|
||||
// 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
|
||||
const addedStripped: CompactThreadTransaction[] = added.filter(entry => entry.uid != null).map(entry => {
|
||||
const addedStripped: CompactThreadTransaction[] = added.filter(entry => (entry.uid !== null && entry.uid !== undefined)).map(entry => {
|
||||
return {
|
||||
uid: entry.uid || 0,
|
||||
fee: entry.fee,
|
||||
weight: entry.weight,
|
||||
feePerVsize: entry.fee / (entry.weight / 4),
|
||||
effectiveFeePerVsize: entry.effectiveFeePerVsize || (entry.fee / (entry.weight / 4)),
|
||||
inputs: entry.vin.map(v => this.getUid(newMempool[v.txid])).filter(uid => uid != null) as number[],
|
||||
weight: (entry.adjustedVsize * 4),
|
||||
sigops: entry.sigops,
|
||||
feePerVsize: entry.adjustedFeePerVsize || entry.feePerVsize,
|
||||
effectiveFeePerVsize: entry.effectiveFeePerVsize || entry.adjustedFeePerVsize || entry.feePerVsize,
|
||||
inputs: entry.vin.map(v => this.getUid(newMempool[v.txid])).filter(uid => (uid !== null && uid !== undefined)) as number[],
|
||||
};
|
||||
});
|
||||
|
||||
@@ -296,34 +318,165 @@ class MempoolBlocks {
|
||||
// clean up thread error listener
|
||||
this.txSelectionWorker?.removeListener('error', threadErrorListener);
|
||||
|
||||
this.processBlockTemplates(newMempool, blocks, rates, clusters, saveResults);
|
||||
this.processBlockTemplates(newMempool, blocks, null, Object.entries(rates), Object.values(clusters), saveResults);
|
||||
logger.debug(`updateBlockTemplates completed in ${(Date.now() - start) / 1000} seconds`);
|
||||
} catch (e) {
|
||||
logger.err('updateBlockTemplates failed. ' + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
}
|
||||
|
||||
private processBlockTemplates(mempool, blocks: string[][], rates: { [root: string]: number }, clusters: { [root: string]: string[] }, saveResults): MempoolBlockWithTransactions[] {
|
||||
for (const txid of Object.keys(rates)) {
|
||||
private resetRustGbt(): void {
|
||||
this.rustInitialized = false;
|
||||
this.rustGbtGenerator = new GbtGenerator();
|
||||
}
|
||||
|
||||
private async $rustMakeBlockTemplates(newMempool: { [txid: string]: MempoolTransactionExtended }, saveResults: boolean = false): Promise<MempoolBlockWithTransactions[]> {
|
||||
const start = Date.now();
|
||||
|
||||
// reset mempool short ids
|
||||
if (saveResults) {
|
||||
this.resetUids();
|
||||
}
|
||||
// set missing short ids
|
||||
for (const tx of Object.values(newMempool)) {
|
||||
this.setUid(tx, !saveResults);
|
||||
}
|
||||
// set short ids for transaction inputs
|
||||
for (const tx of Object.values(newMempool)) {
|
||||
tx.inputs = tx.vin.map(v => this.getUid(newMempool[v.txid])).filter(uid => (uid !== null && uid !== undefined)) as number[];
|
||||
}
|
||||
|
||||
// run the block construction algorithm in a separate thread, and wait for a result
|
||||
const rustGbt = saveResults ? this.rustGbtGenerator : new GbtGenerator();
|
||||
try {
|
||||
const { blocks, blockWeights, rates, clusters } = this.convertNapiResultTxids(
|
||||
await rustGbt.make(Object.values(newMempool) as RustThreadTransaction[], this.nextUid),
|
||||
);
|
||||
if (saveResults) {
|
||||
this.rustInitialized = true;
|
||||
}
|
||||
const processed = this.processBlockTemplates(newMempool, blocks, blockWeights, rates, clusters, saveResults);
|
||||
logger.debug(`RUST makeBlockTemplates completed in ${(Date.now() - start)/1000} seconds`);
|
||||
return processed;
|
||||
} catch (e) {
|
||||
logger.err('RUST makeBlockTemplates failed. ' + (e instanceof Error ? e.message : e));
|
||||
if (saveResults) {
|
||||
this.resetRustGbt();
|
||||
}
|
||||
}
|
||||
return this.mempoolBlocks;
|
||||
}
|
||||
|
||||
public async $oneOffRustBlockTemplates(newMempool: { [txid: string]: MempoolTransactionExtended }): Promise<MempoolBlockWithTransactions[]> {
|
||||
return this.$rustMakeBlockTemplates(newMempool, false);
|
||||
}
|
||||
|
||||
public async $rustUpdateBlockTemplates(newMempool: { [txid: string]: MempoolTransactionExtended }, mempoolSize: number, added: MempoolTransactionExtended[], removed: MempoolTransactionExtended[]): Promise<void> {
|
||||
// GBT optimization requires that uids never get too sparse
|
||||
// as a sanity check, we should also explicitly prevent uint32 uid overflow
|
||||
if (this.nextUid + added.length >= Math.min(Math.max(262144, 2 * mempoolSize), MAX_UINT32)) {
|
||||
this.resetRustGbt();
|
||||
}
|
||||
if (!this.rustInitialized) {
|
||||
// need to reset the worker
|
||||
await this.$rustMakeBlockTemplates(newMempool, true);
|
||||
return;
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
// set missing short ids
|
||||
for (const tx of added) {
|
||||
this.setUid(tx, true);
|
||||
}
|
||||
// set short ids for transaction inputs
|
||||
for (const tx of added) {
|
||||
tx.inputs = tx.vin.map(v => this.getUid(newMempool[v.txid])).filter(uid => (uid !== null && uid !== undefined)) as number[];
|
||||
}
|
||||
const removedUids = removed.map(tx => this.getUid(tx)).filter(uid => (uid !== null && uid !== undefined)) as number[];
|
||||
|
||||
// run the block construction algorithm in a separate thread, and wait for a result
|
||||
try {
|
||||
const { blocks, blockWeights, rates, clusters } = this.convertNapiResultTxids(
|
||||
await this.rustGbtGenerator.update(
|
||||
added as RustThreadTransaction[],
|
||||
removedUids,
|
||||
this.nextUid,
|
||||
),
|
||||
);
|
||||
const resultMempoolSize = blocks.reduce((total, block) => total + block.length, 0);
|
||||
if (mempoolSize !== resultMempoolSize) {
|
||||
throw new Error('GBT returned wrong number of transactions, cache is probably out of sync');
|
||||
} else {
|
||||
this.processBlockTemplates(newMempool, blocks, blockWeights, rates, clusters, true);
|
||||
}
|
||||
this.removeUids(removedUids);
|
||||
logger.debug(`RUST updateBlockTemplates completed in ${(Date.now() - start)/1000} seconds`);
|
||||
} catch (e) {
|
||||
logger.err('RUST updateBlockTemplates failed. ' + (e instanceof Error ? e.message : e));
|
||||
this.resetRustGbt();
|
||||
}
|
||||
}
|
||||
|
||||
private processBlockTemplates(mempool: { [txid: string]: MempoolTransactionExtended }, blocks: string[][], blockWeights: number[] | null, rates: [string, number][], clusters: string[][], saveResults): MempoolBlockWithTransactions[] {
|
||||
for (const [txid, rate] of rates) {
|
||||
if (txid in mempool) {
|
||||
mempool[txid].effectiveFeePerVsize = rates[txid];
|
||||
mempool[txid].effectiveFeePerVsize = rate;
|
||||
mempool[txid].cpfpChecked = false;
|
||||
}
|
||||
}
|
||||
|
||||
const lastBlockIndex = blocks.length - 1;
|
||||
let hasBlockStack = blocks.length >= 8;
|
||||
let stackWeight;
|
||||
let feeStatsCalculator: OnlineFeeStatsCalculator | void;
|
||||
if (hasBlockStack) {
|
||||
if (blockWeights && blockWeights[7] !== null) {
|
||||
stackWeight = blockWeights[7];
|
||||
} else {
|
||||
stackWeight = blocks[lastBlockIndex].reduce((total, tx) => total + (mempool[tx]?.weight || 0), 0);
|
||||
}
|
||||
hasBlockStack = stackWeight > config.MEMPOOL.BLOCK_WEIGHT_UNITS;
|
||||
feeStatsCalculator = new OnlineFeeStatsCalculator(stackWeight, 0.5, [10, 20, 30, 40, 50, 60, 70, 80, 90]);
|
||||
}
|
||||
|
||||
for (const cluster of clusters) {
|
||||
for (const memberTxid of cluster) {
|
||||
const mempoolTx = mempool[memberTxid];
|
||||
if (mempoolTx) {
|
||||
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].adjustedVsize * 4),
|
||||
};
|
||||
if (matched) {
|
||||
descendants.push(relative);
|
||||
} else {
|
||||
ancestors.push(relative);
|
||||
}
|
||||
}
|
||||
});
|
||||
Object.assign(mempoolTx, {ancestors, descendants, bestDescendant: null, cpfpChecked: true});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const readyBlocks: { transactionIds, transactions, totalSize, totalWeight, totalFees }[] = [];
|
||||
const sizeLimit = (config.MEMPOOL.BLOCK_WEIGHT_UNITS / 4) * 1.2;
|
||||
// update this thread's mempool with the results
|
||||
for (let blockIndex = 0; blockIndex < blocks.length; blockIndex++) {
|
||||
const block: string[] = blocks[blockIndex];
|
||||
let txid: string;
|
||||
let mempoolTx: TransactionExtended;
|
||||
let mempoolTx: MempoolTransactionExtended;
|
||||
const mempoolBlocks: MempoolBlockWithTransactions[] = blocks.map((block, blockIndex) => {
|
||||
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];
|
||||
const transactions: MempoolTransactionExtended[] = [];
|
||||
for (const txid of block) {
|
||||
if (txid) {
|
||||
mempoolTx = mempool[txid];
|
||||
// save position in projected blocks
|
||||
@@ -331,7 +484,21 @@ class MempoolBlocks {
|
||||
block: blockIndex,
|
||||
vsize: totalVsize + (mempoolTx.vsize / 2),
|
||||
};
|
||||
mempoolTx.cpfpChecked = true;
|
||||
if (!mempoolTx.cpfpChecked) {
|
||||
if (mempoolTx.ancestors?.length) {
|
||||
mempoolTx.ancestors = [];
|
||||
}
|
||||
if (mempoolTx.descendants?.length) {
|
||||
mempoolTx.descendants = [];
|
||||
}
|
||||
mempoolTx.bestDescendant = null;
|
||||
mempoolTx.cpfpChecked = true;
|
||||
}
|
||||
|
||||
// online calculation of stack-of-blocks fee stats
|
||||
if (hasBlockStack && blockIndex === lastBlockIndex && feeStatsCalculator) {
|
||||
feeStatsCalculator.processNext(mempoolTx);
|
||||
}
|
||||
|
||||
totalSize += mempoolTx.size;
|
||||
totalVsize += mempoolTx.vsize;
|
||||
@@ -343,46 +510,15 @@ class MempoolBlocks {
|
||||
}
|
||||
}
|
||||
}
|
||||
readyBlocks.push({
|
||||
transactionIds: block,
|
||||
return this.dataToMempoolBlocks(
|
||||
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));
|
||||
totalFees,
|
||||
(hasBlockStack && blockIndex === lastBlockIndex && feeStatsCalculator) ? feeStatsCalculator.getRawFeeStats() : undefined,
|
||||
);
|
||||
});
|
||||
|
||||
if (saveResults) {
|
||||
const deltas = this.calculateMempoolDeltas(this.mempoolBlocks, mempoolBlocks);
|
||||
@@ -393,8 +529,10 @@ class MempoolBlocks {
|
||||
return mempoolBlocks;
|
||||
}
|
||||
|
||||
private dataToMempoolBlocks(transactionIds: string[], transactions: TransactionExtended[], totalSize: number, totalWeight: number, totalFees: number): MempoolBlockWithTransactions {
|
||||
const feeStats = Common.calcEffectiveFeeStatistics(transactions);
|
||||
private dataToMempoolBlocks(transactionIds: string[], transactions: MempoolTransactionExtended[], totalSize: number, totalWeight: number, totalFees: number, feeStats?: EffectiveFeeStats ): MempoolBlockWithTransactions {
|
||||
if (!feeStats) {
|
||||
feeStats = Common.calcEffectiveFeeStatistics(transactions);
|
||||
}
|
||||
return {
|
||||
blockSize: totalSize,
|
||||
blockVSize: (totalWeight / 4), // fractional vsize to avoid rounding errors
|
||||
@@ -412,16 +550,20 @@ class MempoolBlocks {
|
||||
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 setUid(tx: MempoolTransactionExtended, skipSet = false): number {
|
||||
if (tx.uid === null || tx.uid === undefined || !skipSet) {
|
||||
const uid = this.nextUid;
|
||||
this.nextUid++;
|
||||
this.uidMap.set(uid, tx.txid);
|
||||
tx.uid = uid;
|
||||
return uid;
|
||||
} else {
|
||||
return tx.uid;
|
||||
}
|
||||
}
|
||||
|
||||
private getUid(tx: TransactionExtended): number | void {
|
||||
if (tx?.uid != null && this.uidMap.has(tx.uid)) {
|
||||
private getUid(tx: MempoolTransactionExtended): number | void {
|
||||
if (tx?.uid !== null && tx?.uid !== undefined && this.uidMap.has(tx.uid)) {
|
||||
return tx.uid;
|
||||
}
|
||||
}
|
||||
@@ -456,6 +598,28 @@ class MempoolBlocks {
|
||||
}
|
||||
return { blocks: convertedBlocks, rates: convertedRates, clusters: convertedClusters } as { blocks: string[][], rates: { [root: string]: number }, clusters: { [root: string]: string[] }};
|
||||
}
|
||||
|
||||
private convertNapiResultTxids({ blocks, blockWeights, rates, clusters }: GbtResult)
|
||||
: { blocks: string[][], blockWeights: number[], rates: [string, number][], clusters: string[][] } {
|
||||
const convertedBlocks: string[][] = blocks.map(block => block.map(uid => {
|
||||
const txid = this.uidMap.get(uid);
|
||||
if (txid !== undefined) {
|
||||
return txid;
|
||||
} else {
|
||||
throw new Error('GBT returned a block containing a transaction with unknown uid');
|
||||
}
|
||||
}));
|
||||
const convertedRates: [string, number][] = [];
|
||||
for (const [rateUid, rate] of rates) {
|
||||
const rateTxid = this.uidMap.get(rateUid) as string;
|
||||
convertedRates.push([rateTxid, rate]);
|
||||
}
|
||||
const convertedClusters: string[][] = [];
|
||||
for (const cluster of clusters) {
|
||||
convertedClusters.push(cluster.map(uid => this.uidMap.get(uid)) as string[]);
|
||||
}
|
||||
return { blocks: convertedBlocks, blockWeights, rates: convertedRates, clusters: convertedClusters };
|
||||
}
|
||||
}
|
||||
|
||||
export default new MempoolBlocks();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import config from '../config';
|
||||
import bitcoinApi from './bitcoin/bitcoin-api-factory';
|
||||
import { TransactionExtended, VbytesPerSecond } from '../mempool.interfaces';
|
||||
import { MempoolTransactionExtended, TransactionExtended, VbytesPerSecond } from '../mempool.interfaces';
|
||||
import logger from '../logger';
|
||||
import { Common } from './common';
|
||||
import transactionUtils from './transaction-utils';
|
||||
@@ -13,13 +13,14 @@ import rbfCache from './rbf-cache';
|
||||
class Mempool {
|
||||
private inSync: boolean = false;
|
||||
private mempoolCacheDelta: number = -1;
|
||||
private mempoolCache: { [txId: string]: TransactionExtended } = {};
|
||||
private mempoolCache: { [txId: string]: MempoolTransactionExtended } = {};
|
||||
private spendMap = new Map<string, MempoolTransactionExtended>();
|
||||
private mempoolInfo: IBitcoinApi.MempoolInfo = { loaded: false, size: 0, bytes: 0, usage: 0, total_fee: 0,
|
||||
maxmempool: 300000000, mempoolminfee: 0.00001000, minrelaytxfee: 0.00001000 };
|
||||
private mempoolChangedCallback: ((newMempool: {[txId: string]: TransactionExtended; }, newTransactions: TransactionExtended[],
|
||||
deletedTransactions: TransactionExtended[]) => void) | undefined;
|
||||
private $asyncMempoolChangedCallback: ((newMempool: {[txId: string]: TransactionExtended; }, newTransactions: TransactionExtended[],
|
||||
deletedTransactions: TransactionExtended[]) => Promise<void>) | undefined;
|
||||
private mempoolChangedCallback: ((newMempool: {[txId: string]: MempoolTransactionExtended; }, newTransactions: MempoolTransactionExtended[],
|
||||
deletedTransactions: MempoolTransactionExtended[]) => void) | undefined;
|
||||
private $asyncMempoolChangedCallback: ((newMempool: {[txId: string]: MempoolTransactionExtended; }, mempoolSize: number, newTransactions: MempoolTransactionExtended[],
|
||||
deletedTransactions: MempoolTransactionExtended[]) => Promise<void>) | undefined;
|
||||
|
||||
private txPerSecondArray: number[] = [];
|
||||
private txPerSecond: number = 0;
|
||||
@@ -63,28 +64,43 @@ class Mempool {
|
||||
return this.latestTransactions;
|
||||
}
|
||||
|
||||
public setMempoolChangedCallback(fn: (newMempool: { [txId: string]: TransactionExtended; },
|
||||
newTransactions: TransactionExtended[], deletedTransactions: TransactionExtended[]) => void) {
|
||||
public setMempoolChangedCallback(fn: (newMempool: { [txId: string]: MempoolTransactionExtended; },
|
||||
newTransactions: MempoolTransactionExtended[], deletedTransactions: MempoolTransactionExtended[]) => void): void {
|
||||
this.mempoolChangedCallback = fn;
|
||||
}
|
||||
|
||||
public setAsyncMempoolChangedCallback(fn: (newMempool: { [txId: string]: TransactionExtended; },
|
||||
newTransactions: TransactionExtended[], deletedTransactions: TransactionExtended[]) => Promise<void>) {
|
||||
public setAsyncMempoolChangedCallback(fn: (newMempool: { [txId: string]: MempoolTransactionExtended; }, mempoolSize: number,
|
||||
newTransactions: MempoolTransactionExtended[], deletedTransactions: MempoolTransactionExtended[]) => Promise<void>): void {
|
||||
this.$asyncMempoolChangedCallback = fn;
|
||||
}
|
||||
|
||||
public getMempool(): { [txid: string]: TransactionExtended } {
|
||||
public getMempool(): { [txid: string]: MempoolTransactionExtended } {
|
||||
return this.mempoolCache;
|
||||
}
|
||||
|
||||
public async $setMempool(mempoolData: { [txId: string]: TransactionExtended }) {
|
||||
public getSpendMap(): Map<string, MempoolTransactionExtended> {
|
||||
return this.spendMap;
|
||||
}
|
||||
|
||||
public async $setMempool(mempoolData: { [txId: string]: MempoolTransactionExtended }) {
|
||||
this.mempoolCache = mempoolData;
|
||||
let count = 0;
|
||||
for (const txid of Object.keys(this.mempoolCache)) {
|
||||
if (this.mempoolCache[txid].sigops == null || this.mempoolCache[txid].effectiveFeePerVsize == null) {
|
||||
this.mempoolCache[txid] = transactionUtils.extendMempoolTransaction(this.mempoolCache[txid]);
|
||||
}
|
||||
if (this.mempoolCache[txid].order == null) {
|
||||
this.mempoolCache[txid].order = transactionUtils.txidToOrdering(txid);
|
||||
}
|
||||
count++;
|
||||
}
|
||||
if (this.mempoolChangedCallback) {
|
||||
this.mempoolChangedCallback(this.mempoolCache, [], []);
|
||||
}
|
||||
if (this.$asyncMempoolChangedCallback) {
|
||||
await this.$asyncMempoolChangedCallback(this.mempoolCache, [], []);
|
||||
await this.$asyncMempoolChangedCallback(this.mempoolCache, count, [], []);
|
||||
}
|
||||
this.addToSpendMap(Object.values(this.mempoolCache));
|
||||
}
|
||||
|
||||
public async $updateMemPoolInfo() {
|
||||
@@ -127,7 +143,7 @@ class Mempool {
|
||||
const currentMempoolSize = Object.keys(this.mempoolCache).length;
|
||||
this.updateTimerProgress(timer, 'got raw mempool');
|
||||
const diff = transactions.length - currentMempoolSize;
|
||||
const newTransactions: TransactionExtended[] = [];
|
||||
const newTransactions: MempoolTransactionExtended[] = [];
|
||||
|
||||
this.mempoolCacheDelta = Math.abs(diff);
|
||||
|
||||
@@ -145,11 +161,11 @@ class Mempool {
|
||||
}
|
||||
};
|
||||
|
||||
let loggerTimer = new Date().getTime() / 1000;
|
||||
let intervalTimer = Date.now();
|
||||
for (const txid of transactions) {
|
||||
if (!this.mempoolCache[txid]) {
|
||||
try {
|
||||
const transaction = await transactionUtils.$getTransactionExtended(txid);
|
||||
const transaction = await transactionUtils.$getMempoolTransactionExtended(txid, false, false, false);
|
||||
this.updateTimerProgress(timer, 'fetched new transaction');
|
||||
this.mempoolCache[txid] = transaction;
|
||||
if (this.inSync) {
|
||||
@@ -168,12 +184,20 @@ class Mempool {
|
||||
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 (elapsedSeconds > 4) {
|
||||
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;
|
||||
|
||||
if (Date.now() - intervalTimer > 5_000) {
|
||||
|
||||
if (this.inSync) {
|
||||
// Break and restart mempool loop if we spend too much time processing
|
||||
// new transactions that may lead to falling behind on block height
|
||||
logger.debug('Breaking mempool loop because the 5s time limit exceeded.');
|
||||
break;
|
||||
} else {
|
||||
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);
|
||||
intervalTimer = Date.now()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,7 +223,7 @@ class Mempool {
|
||||
}, 1000 * 60 * config.MEMPOOL.CLEAR_PROTECTION_MINUTES);
|
||||
}
|
||||
|
||||
const deletedTransactions: TransactionExtended[] = [];
|
||||
const deletedTransactions: MempoolTransactionExtended[] = [];
|
||||
|
||||
if (this.mempoolProtection !== 1) {
|
||||
this.mempoolProtection = 0;
|
||||
@@ -218,23 +242,24 @@ class Mempool {
|
||||
}
|
||||
}
|
||||
|
||||
const newMempoolSize = currentMempoolSize + newTransactions.length - deletedTransactions.length;
|
||||
const newTransactionsStripped = newTransactions.map((tx) => Common.stripTransaction(tx));
|
||||
this.latestTransactions = newTransactionsStripped.concat(this.latestTransactions).slice(0, 6);
|
||||
|
||||
if (!this.inSync && transactions.length === Object.keys(this.mempoolCache).length) {
|
||||
if (!this.inSync && transactions.length === newMempoolSize) {
|
||||
this.inSync = true;
|
||||
logger.notice('The mempool is now in sync!');
|
||||
loadingIndicators.setProgress('mempool', 100);
|
||||
}
|
||||
|
||||
this.mempoolCacheDelta = Math.abs(transactions.length - Object.keys(this.mempoolCache).length);
|
||||
this.mempoolCacheDelta = Math.abs(transactions.length - newMempoolSize);
|
||||
|
||||
if (this.mempoolChangedCallback && (hasChange || deletedTransactions.length)) {
|
||||
this.mempoolChangedCallback(this.mempoolCache, newTransactions, deletedTransactions);
|
||||
}
|
||||
if (this.$asyncMempoolChangedCallback && (hasChange || deletedTransactions.length)) {
|
||||
this.updateTimerProgress(timer, 'running async mempool callback');
|
||||
await this.$asyncMempoolChangedCallback(this.mempoolCache, newTransactions, deletedTransactions);
|
||||
await this.$asyncMempoolChangedCallback(this.mempoolCache, newMempoolSize, newTransactions, deletedTransactions);
|
||||
this.updateTimerProgress(timer, 'completed async mempool callback');
|
||||
}
|
||||
|
||||
@@ -267,7 +292,7 @@ class Mempool {
|
||||
}
|
||||
}
|
||||
|
||||
public handleRbfTransactions(rbfTransactions: { [txid: string]: TransactionExtended[]; }): void {
|
||||
public handleRbfTransactions(rbfTransactions: { [txid: string]: MempoolTransactionExtended[]; }): void {
|
||||
for (const rbfTransaction in rbfTransactions) {
|
||||
if (this.mempoolCache[rbfTransaction] && rbfTransactions[rbfTransaction]?.length) {
|
||||
// Store replaced transactions
|
||||
@@ -276,6 +301,34 @@ class Mempool {
|
||||
}
|
||||
}
|
||||
|
||||
public handleMinedRbfTransactions(rbfTransactions: { [txid: string]: { replaced: MempoolTransactionExtended[], replacedBy: TransactionExtended }}): void {
|
||||
for (const rbfTransaction in rbfTransactions) {
|
||||
if (rbfTransactions[rbfTransaction].replacedBy && rbfTransactions[rbfTransaction]?.replaced?.length) {
|
||||
// Store replaced transactions
|
||||
rbfCache.add(rbfTransactions[rbfTransaction].replaced, transactionUtils.extendMempoolTransaction(rbfTransactions[rbfTransaction].replacedBy));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public addToSpendMap(transactions: MempoolTransactionExtended[]): void {
|
||||
for (const tx of transactions) {
|
||||
for (const vin of tx.vin) {
|
||||
this.spendMap.set(`${vin.txid}:${vin.vout}`, tx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public removeFromSpendMap(transactions: TransactionExtended[]): void {
|
||||
for (const tx of transactions) {
|
||||
for (const vin of tx.vin) {
|
||||
const key = `${vin.txid}:${vin.vout}`;
|
||||
if (this.spendMap.get(key)?.txid === tx.txid) {
|
||||
this.spendMap.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private updateTxPerSecond() {
|
||||
const nowMinusTimeSpan = new Date().getTime() - (1000 * config.STATISTICS.TX_PER_SECOND_SAMPLE_PERIOD);
|
||||
this.txPerSecondArray = this.txPerSecondArray.filter((unixTime) => unixTime > nowMinusTimeSpan);
|
||||
|
||||
@@ -26,7 +26,7 @@ class MiningRoutes {
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/fee-rates/:interval', this.$getHistoricalBlockFeeRates)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/sizes-weights/:interval', this.$getHistoricalBlockSizeAndWeight)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/difficulty-adjustments/:interval', this.$getDifficultyAdjustments)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/predictions/:interval', this.$getHistoricalBlockPrediction)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/predictions/:interval', this.$getHistoricalBlocksHealth)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/audit/scores', this.$getBlockAuditScores)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/audit/scores/:height', this.$getBlockAuditScores)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/audit/score/:hash', this.$getBlockAuditScore)
|
||||
@@ -244,15 +244,15 @@ class MiningRoutes {
|
||||
}
|
||||
}
|
||||
|
||||
private async $getHistoricalBlockPrediction(req: Request, res: Response) {
|
||||
private async $getHistoricalBlocksHealth(req: Request, res: Response) {
|
||||
try {
|
||||
const blockPredictions = await mining.$getBlockPredictionsHistory(req.params.interval);
|
||||
const blockCount = await BlocksAuditsRepository.$getPredictionsCount();
|
||||
const blocksHealth = await mining.$getBlocksHealthHistory(req.params.interval);
|
||||
const blockCount = await BlocksAuditsRepository.$getBlocksHealthCount();
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.header('X-total-count', blockCount.toString());
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
|
||||
res.json(blockPredictions.map(prediction => [prediction.time, prediction.height, prediction.match_rate]));
|
||||
res.json(blocksHealth.map(health => [health.time, health.height, health.match_rate]));
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
|
||||
@@ -19,12 +19,15 @@ class Mining {
|
||||
private blocksPriceIndexingRunning = false;
|
||||
public lastHashrateIndexingDate: number | null = null;
|
||||
public lastWeeklyHashrateIndexingDate: number | null = null;
|
||||
|
||||
public reindexHashrateRequested = false;
|
||||
public reindexDifficultyAdjustmentRequested = false;
|
||||
|
||||
/**
|
||||
* Get historical block predictions match rate
|
||||
* Get historical blocks health
|
||||
*/
|
||||
public async $getBlockPredictionsHistory(interval: string | null = null): Promise<any> {
|
||||
return await BlocksAuditsRepository.$getBlockPredictionsHistory(
|
||||
public async $getBlocksHealthHistory(interval: string | null = null): Promise<any> {
|
||||
return await BlocksAuditsRepository.$getBlocksHealthHistory(
|
||||
this.getTimeRange(interval),
|
||||
Common.getSqlInterval(interval)
|
||||
);
|
||||
@@ -103,6 +106,7 @@ class Mining {
|
||||
emptyBlocks: emptyBlocksCount.length > 0 ? emptyBlocksCount[0]['count'] : 0,
|
||||
slug: poolInfo.slug,
|
||||
avgMatchRate: poolInfo.avgMatchRate !== null ? Math.round(100 * poolInfo.avgMatchRate) / 100 : null,
|
||||
avgFeeDelta: poolInfo.avgFeeDelta,
|
||||
};
|
||||
poolsStats.push(poolStat);
|
||||
});
|
||||
@@ -290,6 +294,14 @@ class Mining {
|
||||
* Generate daily hashrate data
|
||||
*/
|
||||
public async $generateNetworkHashrateHistory(): Promise<void> {
|
||||
// If a re-index was requested, truncate first
|
||||
if (this.reindexHashrateRequested === true) {
|
||||
logger.notice(`hashrates will now be re-indexed`);
|
||||
await database.query(`TRUNCATE hashrates`);
|
||||
this.lastHashrateIndexingDate = 0;
|
||||
this.reindexHashrateRequested = false;
|
||||
}
|
||||
|
||||
// We only run this once a day around midnight
|
||||
const today = new Date().getUTCDate();
|
||||
if (today === this.lastHashrateIndexingDate) {
|
||||
@@ -395,6 +407,13 @@ class Mining {
|
||||
* Index difficulty adjustments
|
||||
*/
|
||||
public async $indexDifficultyAdjustments(): Promise<void> {
|
||||
// If a re-index was requested, truncate first
|
||||
if (this.reindexDifficultyAdjustmentRequested === true) {
|
||||
logger.notice(`difficulty_adjustments will now be re-indexed`);
|
||||
await database.query(`TRUNCATE difficulty_adjustments`);
|
||||
this.reindexDifficultyAdjustmentRequested = false;
|
||||
}
|
||||
|
||||
const indexedHeightsArray = await DifficultyAdjustmentsRepository.$getAdjustmentsHeights();
|
||||
const indexedHeights = {};
|
||||
for (const height of indexedHeightsArray) {
|
||||
@@ -473,11 +492,11 @@ class Mining {
|
||||
}
|
||||
this.blocksPriceIndexingRunning = true;
|
||||
|
||||
let totalInserted = 0;
|
||||
try {
|
||||
const prices: any[] = await PricesRepository.$getPricesTimesAndId();
|
||||
const blocksWithoutPrices: any[] = await BlocksRepository.$getBlocksWithoutPrice();
|
||||
|
||||
let totalInserted = 0;
|
||||
const blocksPrices: BlockPrice[] = [];
|
||||
|
||||
for (const block of blocksWithoutPrices) {
|
||||
@@ -522,7 +541,13 @@ class Mining {
|
||||
}
|
||||
} catch (e) {
|
||||
this.blocksPriceIndexingRunning = false;
|
||||
throw e;
|
||||
logger.err(`Cannot index block prices. ${e}`);
|
||||
}
|
||||
|
||||
if (totalInserted > 0) {
|
||||
logger.info(`Indexing blocks prices completed. Indexed ${totalInserted}`, logger.tags.mining);
|
||||
} else {
|
||||
logger.debug(`Indexing blocks prices completed. Indexed 0.`, logger.tags.mining);
|
||||
}
|
||||
|
||||
this.blocksPriceIndexingRunning = false;
|
||||
|
||||
@@ -4,6 +4,7 @@ import config from '../config';
|
||||
import PoolsRepository from '../repositories/PoolsRepository';
|
||||
import { PoolTag } from '../mempool.interfaces';
|
||||
import diskCache from './disk-cache';
|
||||
import mining from './mining/mining';
|
||||
|
||||
class PoolsParser {
|
||||
miningPools: any[] = [];
|
||||
@@ -41,7 +42,7 @@ class PoolsParser {
|
||||
public async migratePoolsJson(): Promise<void> {
|
||||
// We also need to wipe the backend cache to make sure we don't serve blocks with
|
||||
// the wrong mining pool (usually happen with unknown blocks)
|
||||
diskCache.wipeCache();
|
||||
diskCache.setIgnoreBlocksCache();
|
||||
|
||||
await this.$insertUnknownPool();
|
||||
|
||||
@@ -73,14 +74,12 @@ class PoolsParser {
|
||||
if (JSON.stringify(pool.addresses) !== poolDB.addresses ||
|
||||
JSON.stringify(pool.regexes) !== poolDB.regexes) {
|
||||
// Pool addresses changed or coinbase tags changed
|
||||
logger.notice(`Updating addresses and/or coinbase tags for ${pool.name} mining pool. If 'AUTOMATIC_BLOCK_REINDEXING' is enabled, we will re-index its blocks and 'unknown' blocks`);
|
||||
logger.notice(`Updating addresses and/or coinbase tags for ${pool.name} mining pool.`);
|
||||
await PoolsRepository.$updateMiningPoolTags(poolDB.id, pool.addresses, pool.regexes);
|
||||
await this.$deleteBlocksForPool(poolDB);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Mining pools-v2.json import completed');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -118,10 +117,6 @@ class PoolsParser {
|
||||
* @param pool
|
||||
*/
|
||||
private async $deleteBlocksForPool(pool: PoolTag): Promise<void> {
|
||||
if (config.MEMPOOL.AUTOMATIC_BLOCK_REINDEXING === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get oldest blocks mined by the pool and assume pools-v2.json updates only concern most recent years
|
||||
// Ignore early days of Bitcoin as there were no mining pool yet
|
||||
const [oldestPoolBlock]: any[] = await DB.query(`
|
||||
@@ -132,7 +127,15 @@ class PoolsParser {
|
||||
LIMIT 1`,
|
||||
[pool.id]
|
||||
);
|
||||
const oldestBlockHeight = oldestPoolBlock.length ?? 0 > 0 ? oldestPoolBlock[0].height : 130635;
|
||||
|
||||
let firstKnownBlockPool = 130635; // https://mempool.space/block/0000000000000a067d94ff753eec72830f1205ad3a4c216a08a80c832e551a52
|
||||
if (config.MEMPOOL.NETWORK === 'testnet') {
|
||||
firstKnownBlockPool = 21106; // https://mempool.space/testnet/block/0000000070b701a5b6a1b965f6a38e0472e70b2bb31b973e4638dec400877581
|
||||
} else if (config.MEMPOOL.NETWORK === 'signet') {
|
||||
firstKnownBlockPool = 0;
|
||||
}
|
||||
|
||||
const oldestBlockHeight = oldestPoolBlock.length ?? 0 > 0 ? oldestPoolBlock[0].height : firstKnownBlockPool;
|
||||
const [unknownPool] = await DB.query(`SELECT id from pools where slug = "unknown"`);
|
||||
this.uniqueLog(logger.notice, `Deleting blocks with unknown mining pool from height ${oldestBlockHeight} for re-indexing`);
|
||||
await DB.query(`
|
||||
@@ -146,16 +149,31 @@ class PoolsParser {
|
||||
WHERE pool_id = ?`,
|
||||
[pool.id]
|
||||
);
|
||||
|
||||
// Re-index hashrates and difficulty adjustments later
|
||||
mining.reindexHashrateRequested = true;
|
||||
mining.reindexDifficultyAdjustmentRequested = true;
|
||||
}
|
||||
|
||||
private async $deleteUnknownBlocks(): Promise<void> {
|
||||
let firstKnownBlockPool = 130635; // https://mempool.space/block/0000000000000a067d94ff753eec72830f1205ad3a4c216a08a80c832e551a52
|
||||
if (config.MEMPOOL.NETWORK === 'testnet') {
|
||||
firstKnownBlockPool = 21106; // https://mempool.space/testnet/block/0000000070b701a5b6a1b965f6a38e0472e70b2bb31b973e4638dec400877581
|
||||
} else if (config.MEMPOOL.NETWORK === 'signet') {
|
||||
firstKnownBlockPool = 0;
|
||||
}
|
||||
|
||||
const [unknownPool] = await DB.query(`SELECT id from pools where slug = "unknown"`);
|
||||
this.uniqueLog(logger.notice, `Deleting blocks with unknown mining pool from height 130635 for re-indexing`);
|
||||
this.uniqueLog(logger.notice, `Deleting blocks with unknown mining pool from height ${firstKnownBlockPool} for re-indexing`);
|
||||
await DB.query(`
|
||||
DELETE FROM blocks
|
||||
WHERE pool_id = ? AND height >= 130635`,
|
||||
WHERE pool_id = ? AND height >= ${firstKnownBlockPool}`,
|
||||
[unknownPool[0].id]
|
||||
);
|
||||
|
||||
// Re-index hashrates and difficulty adjustments later
|
||||
mining.reindexHashrateRequested = true;
|
||||
mining.reindexDifficultyAdjustmentRequested = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import logger from "../logger";
|
||||
import { TransactionExtended, TransactionStripped } from "../mempool.interfaces";
|
||||
import { MempoolTransactionExtended, TransactionStripped } from "../mempool.interfaces";
|
||||
import bitcoinApi from './bitcoin/bitcoin-api-factory';
|
||||
import { Common } from "./common";
|
||||
|
||||
@@ -23,20 +23,20 @@ class RbfCache {
|
||||
private rbfTrees: Map<string, RbfTree> = new Map(); // sequences of consecutive replacements
|
||||
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 txs: Map<string, MempoolTransactionExtended> = new Map();
|
||||
private expiring: Map<string, number> = new Map();
|
||||
|
||||
constructor() {
|
||||
setInterval(this.cleanup.bind(this), 1000 * 60 * 10);
|
||||
}
|
||||
|
||||
public add(replaced: TransactionExtended[], newTxExtended: TransactionExtended): void {
|
||||
if (!newTxExtended || !replaced?.length) {
|
||||
public add(replaced: MempoolTransactionExtended[], newTxExtended: MempoolTransactionExtended): void {
|
||||
if (!newTxExtended || !replaced?.length || this.txs.has(newTxExtended.txid)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newTx = Common.stripTransaction(newTxExtended) as RbfTransaction;
|
||||
const newTime = newTxExtended.firstSeen || Date.now();
|
||||
const newTime = newTxExtended.firstSeen || (Date.now() / 1000);
|
||||
newTx.rbf = newTxExtended.vin.some((v) => v.sequence < 0xfffffffe);
|
||||
this.txs.set(newTx.txid, newTxExtended);
|
||||
|
||||
@@ -59,7 +59,7 @@ class RbfCache {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const replacedTime = replacedTxExtended.firstSeen || Date.now();
|
||||
const replacedTime = replacedTxExtended.firstSeen || (Date.now() / 1000);
|
||||
replacedTrees.push({
|
||||
tx: replacedTx,
|
||||
time: replacedTime,
|
||||
@@ -74,7 +74,7 @@ class RbfCache {
|
||||
const treeId = replacedTrees[0].tx.txid;
|
||||
const newTree = {
|
||||
tx: newTx,
|
||||
time: newTxExtended.firstSeen || Date.now(),
|
||||
time: newTime,
|
||||
fullRbf,
|
||||
replaces: replacedTrees
|
||||
};
|
||||
@@ -92,7 +92,7 @@ class RbfCache {
|
||||
return this.replaces.get(txId);
|
||||
}
|
||||
|
||||
public getTx(txId: string): TransactionExtended | undefined {
|
||||
public getTx(txId: string): MempoolTransactionExtended | undefined {
|
||||
return this.txs.get(txId);
|
||||
}
|
||||
|
||||
@@ -169,6 +169,19 @@ class RbfCache {
|
||||
}
|
||||
}
|
||||
|
||||
// is the transaction involved in a full rbf replacement?
|
||||
public isFullRbf(txid: string): boolean {
|
||||
const treeId = this.treeMap.get(txid);
|
||||
if (!treeId) {
|
||||
return false;
|
||||
}
|
||||
const tree = this.rbfTrees.get(treeId);
|
||||
if (!tree) {
|
||||
return false;
|
||||
}
|
||||
return tree?.fullRbf;
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
const now = Date.now();
|
||||
for (const txid of this.expiring.keys()) {
|
||||
@@ -272,7 +285,7 @@ class RbfCache {
|
||||
return deflated;
|
||||
}
|
||||
|
||||
async importTree(root, txid, deflated, txs: Map<string, TransactionExtended>, mined: boolean = false): Promise<RbfTree | void> {
|
||||
async importTree(root, txid, deflated, txs: Map<string, MempoolTransactionExtended>, mined: boolean = false): Promise<RbfTree | void> {
|
||||
const treeInfo = deflated[txid];
|
||||
const replaces: RbfTree[] = [];
|
||||
|
||||
|
||||
@@ -211,7 +211,7 @@ class StatisticsApi {
|
||||
CAST(avg(vsize_1800) as DOUBLE) as vsize_1800,
|
||||
CAST(avg(vsize_2000) as DOUBLE) as vsize_2000 \
|
||||
FROM statistics \
|
||||
WHERE added BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW() \
|
||||
${interval === 'all' ? '' : `WHERE added BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`} \
|
||||
GROUP BY UNIX_TIMESTAMP(added) DIV ${div} \
|
||||
ORDER BY statistics.added DESC;`;
|
||||
}
|
||||
@@ -259,7 +259,7 @@ class StatisticsApi {
|
||||
vsize_1800,
|
||||
vsize_2000 \
|
||||
FROM statistics \
|
||||
WHERE added BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW() \
|
||||
${interval === 'all' ? '' : `WHERE added BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`} \
|
||||
GROUP BY UNIX_TIMESTAMP(added) DIV ${div} \
|
||||
ORDER BY statistics.added DESC;`;
|
||||
}
|
||||
@@ -386,6 +386,17 @@ class StatisticsApi {
|
||||
}
|
||||
}
|
||||
|
||||
public async $listAll(): Promise<OptimizedStatistic[]> {
|
||||
try {
|
||||
const query = this.getQueryForDays(43200, 'all'); // 12h interval
|
||||
const [rows] = await DB.query({ sql: query, timeout: this.queryTimeout });
|
||||
return this.mapStatisticToOptimizedStatistic(rows as Statistic[]);
|
||||
} catch (e) {
|
||||
logger.err('$listAll() error' + (e instanceof Error ? e.message : e));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private mapStatisticToOptimizedStatistic(statistic: Statistic[]): OptimizedStatistic[] {
|
||||
return statistic.map((s) => {
|
||||
return {
|
||||
|
||||
@@ -15,10 +15,11 @@ class StatisticsRoutes {
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'statistics/2y', this.$getStatisticsByTime.bind(this, '2y'))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'statistics/3y', this.$getStatisticsByTime.bind(this, '3y'))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'statistics/4y', this.$getStatisticsByTime.bind(this, '4y'))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'statistics/all', this.$getStatisticsByTime.bind(this, 'all'))
|
||||
;
|
||||
}
|
||||
|
||||
private async $getStatisticsByTime(time: '2h' | '24h' | '1w' | '1m' | '3m' | '6m' | '1y' | '2y' | '3y' | '4y', req: Request, res: Response) {
|
||||
private async $getStatisticsByTime(time: '2h' | '24h' | '1w' | '1m' | '3m' | '6m' | '1y' | '2y' | '3y' | '4y' | 'all', req: Request, res: Response) {
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString());
|
||||
@@ -26,10 +27,6 @@ class StatisticsRoutes {
|
||||
try {
|
||||
let result;
|
||||
switch (time as string) {
|
||||
case '2h':
|
||||
result = await statisticsApi.$list2H();
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
|
||||
break;
|
||||
case '24h':
|
||||
result = await statisticsApi.$list24H();
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
|
||||
@@ -58,8 +55,13 @@ class StatisticsRoutes {
|
||||
case '4y':
|
||||
result = await statisticsApi.$list4Y();
|
||||
break;
|
||||
case 'all':
|
||||
result = await statisticsApi.$listAll();
|
||||
break;
|
||||
default:
|
||||
result = await statisticsApi.$list2H();
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
|
||||
break;
|
||||
}
|
||||
res.json(result);
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { TransactionExtended, TransactionMinerInfo } from '../mempool.interfaces';
|
||||
import { TransactionExtended, MempoolTransactionExtended, TransactionMinerInfo } from '../mempool.interfaces';
|
||||
import { IEsploraApi } from './bitcoin/esplora-api.interface';
|
||||
import { Common } from './common';
|
||||
import bitcoinApi, { bitcoinCoreApi } from './bitcoin/bitcoin-api-factory';
|
||||
import * as bitcoinjs from 'bitcoinjs-lib';
|
||||
|
||||
class TransactionUtils {
|
||||
constructor() { }
|
||||
@@ -22,19 +23,27 @@ class TransactionUtils {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param txId
|
||||
* @param addPrevouts
|
||||
* @param lazyPrevouts
|
||||
* @param txId
|
||||
* @param addPrevouts
|
||||
* @param lazyPrevouts
|
||||
* @param forceCore - See https://github.com/mempool/mempool/issues/2904
|
||||
*/
|
||||
public async $getTransactionExtended(txId: string, addPrevouts = false, lazyPrevouts = false, forceCore = false): Promise<TransactionExtended> {
|
||||
public async $getTransactionExtended(txId: string, addPrevouts = false, lazyPrevouts = false, forceCore = false, addMempoolData = false): Promise<TransactionExtended> {
|
||||
let transaction: IEsploraApi.Transaction;
|
||||
if (forceCore === true) {
|
||||
transaction = await bitcoinCoreApi.$getRawTransaction(txId, true);
|
||||
} else {
|
||||
transaction = await bitcoinApi.$getRawTransaction(txId, false, addPrevouts, lazyPrevouts);
|
||||
}
|
||||
return this.extendTransaction(transaction);
|
||||
if (addMempoolData || !transaction?.status?.confirmed) {
|
||||
return this.extendMempoolTransaction(transaction);
|
||||
} else {
|
||||
return this.extendTransaction(transaction);
|
||||
}
|
||||
}
|
||||
|
||||
public async $getMempoolTransactionExtended(txId: string, addPrevouts = false, lazyPrevouts = false, forceCore = false): Promise<MempoolTransactionExtended> {
|
||||
return (await this.$getTransactionExtended(txId, addPrevouts, lazyPrevouts, forceCore, true)) as MempoolTransactionExtended;
|
||||
}
|
||||
|
||||
private extendTransaction(transaction: IEsploraApi.Transaction): TransactionExtended {
|
||||
@@ -50,8 +59,33 @@ class TransactionUtils {
|
||||
feePerVsize: feePerVbytes,
|
||||
effectiveFeePerVsize: feePerVbytes,
|
||||
}, transaction);
|
||||
if (!transaction.status.confirmed) {
|
||||
transactionExtended.firstSeen = Math.round((new Date().getTime() / 1000));
|
||||
if (!transaction?.status?.confirmed && !transactionExtended.firstSeen) {
|
||||
transactionExtended.firstSeen = Math.round((Date.now() / 1000));
|
||||
}
|
||||
return transactionExtended;
|
||||
}
|
||||
|
||||
public extendMempoolTransaction(transaction: IEsploraApi.Transaction): MempoolTransactionExtended {
|
||||
const vsize = Math.ceil(transaction.weight / 4);
|
||||
const fractionalVsize = (transaction.weight / 4);
|
||||
const sigops = this.countSigops(transaction);
|
||||
// https://github.com/bitcoin/bitcoin/blob/e9262ea32a6e1d364fb7974844fadc36f931f8c6/src/policy/policy.cpp#L295-L298
|
||||
const adjustedVsize = Math.max(fractionalVsize, sigops * 5); // adjusted vsize = Max(weight, sigops * bytes_per_sigop) / witness_scale_factor
|
||||
const feePerVbytes = Math.max(Common.isLiquid() ? 0.1 : 1,
|
||||
(transaction.fee || 0) / fractionalVsize);
|
||||
const adjustedFeePerVsize = Math.max(Common.isLiquid() ? 0.1 : 1,
|
||||
(transaction.fee || 0) / adjustedVsize);
|
||||
const transactionExtended: MempoolTransactionExtended = Object.assign(transaction, {
|
||||
order: this.txidToOrdering(transaction.txid),
|
||||
vsize: Math.round(transaction.weight / 4),
|
||||
adjustedVsize,
|
||||
sigops,
|
||||
feePerVsize: feePerVbytes,
|
||||
adjustedFeePerVsize: adjustedFeePerVsize,
|
||||
effectiveFeePerVsize: adjustedFeePerVsize,
|
||||
});
|
||||
if (!transactionExtended?.status?.confirmed && !transactionExtended.firstSeen) {
|
||||
transactionExtended.firstSeen = Math.round((Date.now() / 1000));
|
||||
}
|
||||
return transactionExtended;
|
||||
}
|
||||
@@ -63,6 +97,75 @@ class TransactionUtils {
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
public countScriptSigops(script: string, isRawScript: boolean = false, witness: boolean = false): number {
|
||||
let sigops = 0;
|
||||
// count OP_CHECKSIG and OP_CHECKSIGVERIFY
|
||||
sigops += (script.match(/OP_CHECKSIG/g)?.length || 0);
|
||||
|
||||
// count OP_CHECKMULTISIG and OP_CHECKMULTISIGVERIFY
|
||||
if (isRawScript) {
|
||||
// in scriptPubKey or scriptSig, always worth 20
|
||||
sigops += 20 * (script.match(/OP_CHECKMULTISIG/g)?.length || 0);
|
||||
} else {
|
||||
// in redeem scripts and witnesses, worth N if preceded by OP_N, 20 otherwise
|
||||
const matches = script.matchAll(/(?:OP_(\d+))? OP_CHECKMULTISIG/g);
|
||||
for (const match of matches) {
|
||||
const n = parseInt(match[1]);
|
||||
if (Number.isInteger(n)) {
|
||||
sigops += n;
|
||||
} else {
|
||||
sigops += 20;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return witness ? sigops : (sigops * 4);
|
||||
}
|
||||
|
||||
public countSigops(transaction: IEsploraApi.Transaction): number {
|
||||
let sigops = 0;
|
||||
|
||||
for (const input of transaction.vin) {
|
||||
if (input.scriptsig_asm) {
|
||||
sigops += this.countScriptSigops(input.scriptsig_asm, true);
|
||||
}
|
||||
if (input.prevout) {
|
||||
switch (true) {
|
||||
case input.prevout.scriptpubkey_type === 'p2sh' && input.witness?.length === 2 && input.scriptsig && input.scriptsig.startsWith('160014'):
|
||||
case input.prevout.scriptpubkey_type === 'v0_p2wpkh':
|
||||
sigops += 1;
|
||||
break;
|
||||
|
||||
case input.prevout?.scriptpubkey_type === 'p2sh' && input.witness?.length && input.scriptsig && input.scriptsig.startsWith('220020'):
|
||||
case input.prevout.scriptpubkey_type === 'v0_p2wsh':
|
||||
if (input.witness?.length) {
|
||||
sigops += this.countScriptSigops(bitcoinjs.script.toASM(Buffer.from(input.witness[input.witness.length - 1], 'hex')), false, true);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const output of transaction.vout) {
|
||||
if (output.scriptpubkey_asm) {
|
||||
sigops += this.countScriptSigops(output.scriptpubkey_asm, true);
|
||||
}
|
||||
}
|
||||
|
||||
return sigops;
|
||||
}
|
||||
|
||||
// returns the most significant 4 bytes of the txid as an integer
|
||||
public txidToOrdering(txid: string): number {
|
||||
return parseInt(
|
||||
txid.substr(62, 2) +
|
||||
txid.substr(60, 2) +
|
||||
txid.substr(58, 2) +
|
||||
txid.substr(56, 2),
|
||||
16
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default new TransactionUtils();
|
||||
|
||||
@@ -48,12 +48,14 @@ function makeBlockTemplates(mempool: Map<number, CompactThreadTransaction>)
|
||||
weight: tx.weight,
|
||||
feePerVsize: tx.feePerVsize,
|
||||
effectiveFeePerVsize: tx.feePerVsize,
|
||||
sigops: tx.sigops,
|
||||
inputs: tx.inputs || [],
|
||||
relativesSet: false,
|
||||
ancestorMap: new Map<number, AuditTransaction>(),
|
||||
children: new Set<AuditTransaction>(),
|
||||
ancestorFee: 0,
|
||||
ancestorWeight: 0,
|
||||
ancestorSigops: 0,
|
||||
score: 0,
|
||||
used: false,
|
||||
modified: false,
|
||||
@@ -83,6 +85,7 @@ function makeBlockTemplates(mempool: Map<number, CompactThreadTransaction>)
|
||||
// (i.e. the package rooted in the transaction with the best ancestor score)
|
||||
const blocks: number[][] = [];
|
||||
let blockWeight = 4000;
|
||||
let blockSigops = 0;
|
||||
let transactions: AuditTransaction[] = [];
|
||||
const modified: PairingHeap<AuditTransaction> = new PairingHeap((a, b): boolean => {
|
||||
if (a.score === b.score) {
|
||||
@@ -118,7 +121,7 @@ function makeBlockTemplates(mempool: Map<number, CompactThreadTransaction>)
|
||||
|
||||
if (nextTx && !nextTx?.used) {
|
||||
// Check if the package fits into this block
|
||||
if (blocks.length >= 7 || (blockWeight + nextTx.ancestorWeight < config.MEMPOOL.BLOCK_WEIGHT_UNITS)) {
|
||||
if (blocks.length >= 7 || ((blockWeight + nextTx.ancestorWeight < config.MEMPOOL.BLOCK_WEIGHT_UNITS) && (blockSigops + nextTx.ancestorSigops <= 80000))) {
|
||||
const ancestors: AuditTransaction[] = Array.from(nextTx.ancestorMap.values());
|
||||
// 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];
|
||||
@@ -127,7 +130,7 @@ function makeBlockTemplates(mempool: Map<number, CompactThreadTransaction>)
|
||||
cpfpClusters.set(nextTx.uid, sortedTxSet.map(tx => tx.uid));
|
||||
isCluster = true;
|
||||
}
|
||||
const effectiveFeeRate = nextTx.ancestorFee / (nextTx.ancestorWeight / 4);
|
||||
const effectiveFeeRate = Math.min(nextTx.dependencyRate || Infinity, nextTx.ancestorFee / (nextTx.ancestorWeight / 4));
|
||||
const used: AuditTransaction[] = [];
|
||||
while (sortedTxSet.length) {
|
||||
const ancestor = sortedTxSet.pop();
|
||||
@@ -155,7 +158,7 @@ function makeBlockTemplates(mempool: Map<number, CompactThreadTransaction>)
|
||||
// remove these as valid package ancestors for any descendants remaining in the mempool
|
||||
if (used.length) {
|
||||
used.forEach(tx => {
|
||||
updateDescendants(tx, auditPool, modified);
|
||||
updateDescendants(tx, auditPool, modified, effectiveFeeRate);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -237,9 +240,11 @@ function setRelatives(
|
||||
};
|
||||
tx.ancestorFee = tx.fee || 0;
|
||||
tx.ancestorWeight = tx.weight || 0;
|
||||
tx.ancestorSigops = tx.sigops || 0;
|
||||
tx.ancestorMap.forEach((ancestor) => {
|
||||
tx.ancestorFee += ancestor.fee;
|
||||
tx.ancestorWeight += ancestor.weight;
|
||||
tx.ancestorSigops += ancestor.sigops;
|
||||
});
|
||||
tx.score = tx.ancestorFee / ((tx.ancestorWeight / 4) || 1);
|
||||
tx.relativesSet = true;
|
||||
@@ -251,6 +256,7 @@ function updateDescendants(
|
||||
rootTx: AuditTransaction,
|
||||
mempool: Map<number, AuditTransaction>,
|
||||
modified: PairingHeap<AuditTransaction>,
|
||||
clusterRate: number,
|
||||
): void {
|
||||
const descendantSet: Set<AuditTransaction> = new Set();
|
||||
// stack of nodes left to visit
|
||||
@@ -270,8 +276,10 @@ function updateDescendants(
|
||||
descendantTx.ancestorMap.delete(rootTx.uid);
|
||||
descendantTx.ancestorFee -= rootTx.fee;
|
||||
descendantTx.ancestorWeight -= rootTx.weight;
|
||||
descendantTx.ancestorSigops -= rootTx.sigops;
|
||||
tmpScore = descendantTx.score;
|
||||
descendantTx.score = descendantTx.ancestorFee / (descendantTx.ancestorWeight / 4);
|
||||
descendantTx.dependencyRate = descendantTx.dependencyRate ? Math.min(descendantTx.dependencyRate, clusterRate) : clusterRate;
|
||||
|
||||
if (!descendantTx.modifiedNode) {
|
||||
descendantTx.modified = true;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logger from '../logger';
|
||||
import * as WebSocket from 'ws';
|
||||
import {
|
||||
BlockExtended, TransactionExtended, WebsocketResponse,
|
||||
BlockExtended, TransactionExtended, MempoolTransactionExtended, WebsocketResponse,
|
||||
OptimizedStatistic, ILoadingIndicators
|
||||
} from '../mempool.interfaces';
|
||||
import blocks from './blocks';
|
||||
@@ -22,6 +22,14 @@ import { deepClone } from '../utils/clone';
|
||||
import priceUpdater from '../tasks/price-updater';
|
||||
import { ApiPrice } from '../repositories/PricesRepository';
|
||||
|
||||
// valid 'want' subscriptions
|
||||
const wantable = [
|
||||
'blocks',
|
||||
'mempool-blocks',
|
||||
'live-2h-chart',
|
||||
'stats',
|
||||
];
|
||||
|
||||
class WebsocketHandler {
|
||||
private wss: WebSocket.Server | undefined;
|
||||
private extraInitProperties = {};
|
||||
@@ -30,7 +38,7 @@ class WebsocketHandler {
|
||||
private numConnected = 0;
|
||||
private numDisconnected = 0;
|
||||
|
||||
private initData: { [key: string]: string } = {};
|
||||
private socketData: { [key: string]: string } = {};
|
||||
private serializedInitData: string = '{}';
|
||||
|
||||
constructor() { }
|
||||
@@ -39,28 +47,28 @@ class WebsocketHandler {
|
||||
this.wss = wss;
|
||||
}
|
||||
|
||||
setExtraInitProperties(property: string, value: any) {
|
||||
setExtraInitData(property: string, value: any) {
|
||||
this.extraInitProperties[property] = value;
|
||||
this.setInitDataFields(this.extraInitProperties);
|
||||
this.updateSocketDataFields(this.extraInitProperties);
|
||||
}
|
||||
|
||||
private setInitDataFields(data: { [property: string]: any }): void {
|
||||
private updateSocketDataFields(data: { [property: string]: any }): void {
|
||||
for (const property of Object.keys(data)) {
|
||||
if (data[property] != null) {
|
||||
this.initData[property] = JSON.stringify(data[property]);
|
||||
this.socketData[property] = JSON.stringify(data[property]);
|
||||
} else {
|
||||
delete this.initData[property];
|
||||
delete this.socketData[property];
|
||||
}
|
||||
}
|
||||
this.serializedInitData = '{'
|
||||
+ Object.keys(this.initData).map(key => `"${key}": ${this.initData[key]}`).join(', ')
|
||||
+ '}';
|
||||
+ Object.keys(this.socketData).map(key => `"${key}": ${this.socketData[key]}`).join(', ')
|
||||
+ '}';
|
||||
}
|
||||
|
||||
private updateInitData(): void {
|
||||
private updateSocketData(): void {
|
||||
const _blocks = blocks.getBlocks().slice(-config.MEMPOOL.INITIAL_BLOCKS_AMOUNT);
|
||||
const da = difficultyAdjustment.getDifficultyAdjustment();
|
||||
this.setInitDataFields({
|
||||
this.updateSocketDataFields({
|
||||
'mempoolInfo': memPool.getMempoolInfo(),
|
||||
'vBytesPerSecond': memPool.getVBytesPerSecond(),
|
||||
'blocks': _blocks,
|
||||
@@ -94,11 +102,33 @@ class WebsocketHandler {
|
||||
const parsedMessage: WebsocketResponse = JSON.parse(message);
|
||||
const response = {};
|
||||
|
||||
if (parsedMessage.action === 'want') {
|
||||
client['want-blocks'] = parsedMessage.data.indexOf('blocks') > -1;
|
||||
client['want-mempool-blocks'] = parsedMessage.data.indexOf('mempool-blocks') > -1;
|
||||
client['want-live-2h-chart'] = parsedMessage.data.indexOf('live-2h-chart') > -1;
|
||||
client['want-stats'] = parsedMessage.data.indexOf('stats') > -1;
|
||||
const wantNow = {};
|
||||
if (parsedMessage && parsedMessage.action === 'want' && Array.isArray(parsedMessage.data)) {
|
||||
for (const sub of wantable) {
|
||||
const key = `want-${sub}`;
|
||||
const wants = parsedMessage.data.includes(sub);
|
||||
if (wants && client['wants'] && !client[key]) {
|
||||
wantNow[key] = true;
|
||||
}
|
||||
client[key] = wants;
|
||||
}
|
||||
client['wants'] = true;
|
||||
}
|
||||
|
||||
// send initial data when a client first starts a subscription
|
||||
if (wantNow['want-blocks'] || (parsedMessage && parsedMessage['refresh-blocks'])) {
|
||||
response['blocks'] = this.socketData['blocks'];
|
||||
}
|
||||
|
||||
if (wantNow['want-mempool-blocks']) {
|
||||
response['mempool-blocks'] = this.socketData['mempool-blocks'];
|
||||
}
|
||||
|
||||
if (wantNow['want-stats']) {
|
||||
response['mempoolInfo'] = this.socketData['mempoolInfo'];
|
||||
response['vBytesPerSecond'] = this.socketData['vBytesPerSecond'];
|
||||
response['fees'] = this.socketData['fees'];
|
||||
response['da'] = this.socketData['da'];
|
||||
}
|
||||
|
||||
if (parsedMessage && parsedMessage['track-tx']) {
|
||||
@@ -109,29 +139,29 @@ class WebsocketHandler {
|
||||
if (parsedMessage['watch-mempool']) {
|
||||
const rbfCacheTxid = rbfCache.getReplacedBy(trackTxid);
|
||||
if (rbfCacheTxid) {
|
||||
response['txReplaced'] = {
|
||||
response['txReplaced'] = JSON.stringify({
|
||||
txid: rbfCacheTxid,
|
||||
};
|
||||
});
|
||||
client['track-tx'] = null;
|
||||
} else {
|
||||
// It might have appeared before we had the time to start watching for it
|
||||
const tx = memPool.getMempool()[trackTxid];
|
||||
if (tx) {
|
||||
if (config.MEMPOOL.BACKEND === 'esplora') {
|
||||
response['tx'] = tx;
|
||||
response['tx'] = JSON.stringify(tx);
|
||||
} else {
|
||||
// tx.prevout is missing from transactions when in bitcoind mode
|
||||
try {
|
||||
const fullTx = await transactionUtils.$getTransactionExtended(tx.txid, true);
|
||||
response['tx'] = fullTx;
|
||||
const fullTx = await transactionUtils.$getMempoolTransactionExtended(tx.txid, true);
|
||||
response['tx'] = JSON.stringify(fullTx);
|
||||
} catch (e) {
|
||||
logger.debug('Error finding transaction: ' + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const fullTx = await transactionUtils.$getTransactionExtended(client['track-tx'], true);
|
||||
response['tx'] = fullTx;
|
||||
const fullTx = await transactionUtils.$getMempoolTransactionExtended(client['track-tx'], true);
|
||||
response['tx'] = JSON.stringify(fullTx);
|
||||
} catch (e) {
|
||||
logger.debug('Error finding transaction. ' + (e instanceof Error ? e.message : e));
|
||||
client['track-mempool-tx'] = parsedMessage['track-tx'];
|
||||
@@ -141,10 +171,10 @@ class WebsocketHandler {
|
||||
}
|
||||
const tx = memPool.getMempool()[trackTxid];
|
||||
if (tx && tx.position) {
|
||||
response['txPosition'] = {
|
||||
response['txPosition'] = JSON.stringify({
|
||||
txid: trackTxid,
|
||||
position: tx.position,
|
||||
};
|
||||
});
|
||||
}
|
||||
} else {
|
||||
client['track-tx'] = null;
|
||||
@@ -177,10 +207,10 @@ class WebsocketHandler {
|
||||
const index = parsedMessage['track-mempool-block'];
|
||||
client['track-mempool-block'] = index;
|
||||
const mBlocksWithTransactions = mempoolBlocks.getMempoolBlocksWithTransactions();
|
||||
response['projected-block-transactions'] = {
|
||||
response['projected-block-transactions'] = JSON.stringify({
|
||||
index: index,
|
||||
blockTransactions: mBlocksWithTransactions[index]?.transactions || [],
|
||||
};
|
||||
});
|
||||
} else {
|
||||
client['track-mempool-block'] = null;
|
||||
}
|
||||
@@ -189,23 +219,24 @@ class WebsocketHandler {
|
||||
if (parsedMessage && parsedMessage['track-rbf'] !== undefined) {
|
||||
if (['all', 'fullRbf'].includes(parsedMessage['track-rbf'])) {
|
||||
client['track-rbf'] = parsedMessage['track-rbf'];
|
||||
response['rbfLatest'] = JSON.stringify(rbfCache.getRbfTrees(parsedMessage['track-rbf'] === 'fullRbf'));
|
||||
} else {
|
||||
client['track-rbf'] = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (parsedMessage.action === 'init') {
|
||||
if (!this.initData['blocks']?.length || !this.initData['da']) {
|
||||
this.updateInitData();
|
||||
if (!this.socketData['blocks']?.length || !this.socketData['da']) {
|
||||
this.updateSocketData();
|
||||
}
|
||||
if (!this.initData['blocks']?.length) {
|
||||
if (!this.socketData['blocks']?.length) {
|
||||
return;
|
||||
}
|
||||
client.send(this.serializedInitData);
|
||||
}
|
||||
|
||||
if (parsedMessage.action === 'ping') {
|
||||
response['pong'] = true;
|
||||
response['pong'] = JSON.stringify(true);
|
||||
}
|
||||
|
||||
if (parsedMessage['track-donation'] && parsedMessage['track-donation'].length === 22) {
|
||||
@@ -221,7 +252,8 @@ class WebsocketHandler {
|
||||
}
|
||||
|
||||
if (Object.keys(response).length) {
|
||||
client.send(JSON.stringify(response));
|
||||
const serializedResponse = this.serializeResponse(response);
|
||||
client.send(serializedResponse);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.debug('Error parsing websocket message: ' + (e instanceof Error ? e.message : e));
|
||||
@@ -250,7 +282,7 @@ class WebsocketHandler {
|
||||
throw new Error('WebSocket.Server is not set');
|
||||
}
|
||||
|
||||
this.setInitDataFields({ 'loadingIndicators': indicators });
|
||||
this.updateSocketDataFields({ 'loadingIndicators': indicators });
|
||||
|
||||
const response = JSON.stringify({ loadingIndicators: indicators });
|
||||
this.wss.clients.forEach((client) => {
|
||||
@@ -266,7 +298,7 @@ class WebsocketHandler {
|
||||
throw new Error('WebSocket.Server is not set');
|
||||
}
|
||||
|
||||
this.setInitDataFields({ 'conversions': conversionRates });
|
||||
this.updateSocketDataFields({ 'conversions': conversionRates });
|
||||
|
||||
const response = JSON.stringify({ conversions: conversionRates });
|
||||
this.wss.clients.forEach((client) => {
|
||||
@@ -301,8 +333,8 @@ class WebsocketHandler {
|
||||
});
|
||||
}
|
||||
|
||||
async $handleMempoolChange(newMempool: { [txid: string]: TransactionExtended },
|
||||
newTransactions: TransactionExtended[], deletedTransactions: TransactionExtended[]): Promise<void> {
|
||||
async $handleMempoolChange(newMempool: { [txid: string]: MempoolTransactionExtended }, mempoolSize: number,
|
||||
newTransactions: MempoolTransactionExtended[], deletedTransactions: MempoolTransactionExtended[]): Promise<void> {
|
||||
if (!this.wss) {
|
||||
throw new Error('WebSocket.Server is not set');
|
||||
}
|
||||
@@ -310,7 +342,11 @@ class WebsocketHandler {
|
||||
this.printLogs();
|
||||
|
||||
if (config.MEMPOOL.ADVANCED_GBT_MEMPOOL) {
|
||||
await mempoolBlocks.$updateBlockTemplates(newMempool, newTransactions, deletedTransactions, true);
|
||||
if (config.MEMPOOL.RUST_GBT) {
|
||||
await mempoolBlocks.$rustUpdateBlockTemplates(newMempool, mempoolSize, newTransactions, deletedTransactions);
|
||||
} else {
|
||||
await mempoolBlocks.$updateBlockTemplates(newMempool, newTransactions, deletedTransactions, true);
|
||||
}
|
||||
} else {
|
||||
mempoolBlocks.updateMempoolBlocks(newMempool, true);
|
||||
}
|
||||
@@ -332,13 +368,25 @@ class WebsocketHandler {
|
||||
for (const deletedTx of deletedTransactions) {
|
||||
rbfCache.evict(deletedTx.txid);
|
||||
}
|
||||
memPool.removeFromSpendMap(deletedTransactions);
|
||||
memPool.addToSpendMap(newTransactions);
|
||||
const recommendedFees = feeApi.getRecommendedFee();
|
||||
|
||||
const latestTransactions = newTransactions.slice(0, 6).map((tx) => Common.stripTransaction(tx));
|
||||
|
||||
// update init data
|
||||
this.updateInitData();
|
||||
this.updateSocketDataFields({
|
||||
'mempoolInfo': mempoolInfo,
|
||||
'vBytesPerSecond': vBytesPerSecond,
|
||||
'mempool-blocks': mBlocks,
|
||||
'transactions': latestTransactions,
|
||||
'loadingIndicators': loadingIndicators.getLoadingIndicators(),
|
||||
'da': da?.previousTime ? da : undefined,
|
||||
'fees': recommendedFees,
|
||||
});
|
||||
|
||||
// cache serialized objects to avoid stringify-ing the same thing for every client
|
||||
const responseCache = { ...this.initData };
|
||||
const responseCache = { ...this.socketData };
|
||||
function getCachedResponse(key: string, data): string {
|
||||
if (!responseCache[key]) {
|
||||
responseCache[key] = JSON.stringify(data);
|
||||
@@ -369,8 +417,6 @@ class WebsocketHandler {
|
||||
}
|
||||
}
|
||||
|
||||
const latestTransactions = newTransactions.slice(0, 6).map((tx) => Common.stripTransaction(tx));
|
||||
|
||||
this.wss.clients.forEach(async (client) => {
|
||||
if (client.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
@@ -397,7 +443,7 @@ class WebsocketHandler {
|
||||
if (tx) {
|
||||
if (config.MEMPOOL.BACKEND !== 'esplora') {
|
||||
try {
|
||||
const fullTx = await transactionUtils.$getTransactionExtended(tx.txid, true);
|
||||
const fullTx = await transactionUtils.$getMempoolTransactionExtended(tx.txid, true);
|
||||
response['tx'] = JSON.stringify(fullTx);
|
||||
} catch (e) {
|
||||
logger.debug('Error finding transaction in mempool: ' + (e instanceof Error ? e.message : e));
|
||||
@@ -417,7 +463,7 @@ class WebsocketHandler {
|
||||
if (someVin) {
|
||||
if (config.MEMPOOL.BACKEND !== 'esplora') {
|
||||
try {
|
||||
const fullTx = await transactionUtils.$getTransactionExtended(tx.txid, true);
|
||||
const fullTx = await transactionUtils.$getMempoolTransactionExtended(tx.txid, true);
|
||||
foundTransactions.push(fullTx);
|
||||
} catch (e) {
|
||||
logger.debug('Error finding transaction in mempool: ' + (e instanceof Error ? e.message : e));
|
||||
@@ -431,7 +477,7 @@ class WebsocketHandler {
|
||||
if (someVout) {
|
||||
if (config.MEMPOOL.BACKEND !== 'esplora') {
|
||||
try {
|
||||
const fullTx = await transactionUtils.$getTransactionExtended(tx.txid, true);
|
||||
const fullTx = await transactionUtils.$getMempoolTransactionExtended(tx.txid, true);
|
||||
foundTransactions.push(fullTx);
|
||||
} catch (e) {
|
||||
logger.debug('Error finding transaction in mempool: ' + (e instanceof Error ? e.message : e));
|
||||
@@ -488,7 +534,7 @@ class WebsocketHandler {
|
||||
if (rbfReplacedBy) {
|
||||
response['rbfTransaction'] = JSON.stringify({
|
||||
txid: rbfReplacedBy,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
const rbfChange = rbfChanges.map[client['track-tx']];
|
||||
@@ -522,15 +568,13 @@ class WebsocketHandler {
|
||||
}
|
||||
|
||||
if (Object.keys(response).length) {
|
||||
const serializedResponse = '{'
|
||||
+ Object.keys(response).map(key => `"${key}": ${response[key]}`).join(', ')
|
||||
+ '}';
|
||||
const serializedResponse = this.serializeResponse(response);
|
||||
client.send(serializedResponse);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async handleNewBlock(block: BlockExtended, txIds: string[], transactions: TransactionExtended[]): Promise<void> {
|
||||
async handleNewBlock(block: BlockExtended, txIds: string[], transactions: MempoolTransactionExtended[]): Promise<void> {
|
||||
if (!this.wss) {
|
||||
throw new Error('WebSocket.Server is not set');
|
||||
}
|
||||
@@ -539,6 +583,10 @@ class WebsocketHandler {
|
||||
|
||||
const _memPool = memPool.getMempool();
|
||||
|
||||
const rbfTransactions = Common.findMinedRbfTransactions(transactions, memPool.getSpendMap());
|
||||
memPool.handleMinedRbfTransactions(rbfTransactions);
|
||||
memPool.removeFromSpendMap(transactions);
|
||||
|
||||
if (config.MEMPOOL.AUDIT) {
|
||||
let projectedBlocks;
|
||||
let auditMempool = _memPool;
|
||||
@@ -548,7 +596,11 @@ class WebsocketHandler {
|
||||
if (separateAudit) {
|
||||
auditMempool = deepClone(_memPool);
|
||||
if (config.MEMPOOL.ADVANCED_GBT_AUDIT) {
|
||||
projectedBlocks = await mempoolBlocks.$makeBlockTemplates(auditMempool, false);
|
||||
if (config.MEMPOOL.RUST_GBT) {
|
||||
projectedBlocks = await mempoolBlocks.$oneOffRustBlockTemplates(auditMempool);
|
||||
} else {
|
||||
projectedBlocks = await mempoolBlocks.$makeBlockTemplates(auditMempool, false);
|
||||
}
|
||||
} else {
|
||||
projectedBlocks = mempoolBlocks.updateMempoolBlocks(auditMempool, false);
|
||||
}
|
||||
@@ -557,23 +609,23 @@ class WebsocketHandler {
|
||||
}
|
||||
|
||||
if (Common.indexingEnabled() && memPool.isInSync()) {
|
||||
const { censored, added, fresh, score, similarity } = Audit.auditBlock(transactions, projectedBlocks, auditMempool);
|
||||
const { censored, added, fresh, sigop, fullrbf, score, similarity } = Audit.auditBlock(transactions, projectedBlocks, auditMempool);
|
||||
const matchRate = Math.round(score * 100 * 100) / 100;
|
||||
|
||||
const stripped = projectedBlocks[0]?.transactions ? projectedBlocks[0].transactions.map((tx) => {
|
||||
return {
|
||||
txid: tx.txid,
|
||||
vsize: tx.vsize,
|
||||
fee: tx.fee ? Math.round(tx.fee) : 0,
|
||||
value: tx.value,
|
||||
};
|
||||
}) : [];
|
||||
const stripped = projectedBlocks[0]?.transactions ? projectedBlocks[0].transactions : [];
|
||||
|
||||
let totalFees = 0;
|
||||
let totalWeight = 0;
|
||||
for (const tx of stripped) {
|
||||
totalFees += tx.fee;
|
||||
totalWeight += (tx.vsize * 4);
|
||||
}
|
||||
|
||||
BlocksSummariesRepository.$saveTemplate({
|
||||
height: block.height,
|
||||
template: {
|
||||
id: block.id,
|
||||
transactions: stripped
|
||||
transactions: stripped,
|
||||
}
|
||||
});
|
||||
|
||||
@@ -584,11 +636,17 @@ class WebsocketHandler {
|
||||
addedTxs: added,
|
||||
missingTxs: censored,
|
||||
freshTxs: fresh,
|
||||
sigopTxs: sigop,
|
||||
fullrbfTxs: fullrbf,
|
||||
matchRate: matchRate,
|
||||
expectedFees: totalFees,
|
||||
expectedWeight: totalWeight,
|
||||
});
|
||||
|
||||
if (block.extras) {
|
||||
block.extras.matchRate = matchRate;
|
||||
block.extras.expectedFees = totalFees;
|
||||
block.extras.expectedWeight = totalWeight;
|
||||
block.extras.similarity = similarity;
|
||||
}
|
||||
}
|
||||
@@ -606,7 +664,11 @@ class WebsocketHandler {
|
||||
}
|
||||
|
||||
if (config.MEMPOOL.ADVANCED_GBT_MEMPOOL) {
|
||||
await mempoolBlocks.$makeBlockTemplates(_memPool, true);
|
||||
if (config.MEMPOOL.RUST_GBT) {
|
||||
await mempoolBlocks.$rustUpdateBlockTemplates(_memPool, Object.keys(_memPool).length, [], transactions);
|
||||
} else {
|
||||
await mempoolBlocks.$makeBlockTemplates(_memPool, true);
|
||||
}
|
||||
} else {
|
||||
mempoolBlocks.updateMempoolBlocks(_memPool, true);
|
||||
}
|
||||
@@ -615,11 +677,19 @@ class WebsocketHandler {
|
||||
|
||||
const da = difficultyAdjustment.getDifficultyAdjustment();
|
||||
const fees = feeApi.getRecommendedFee();
|
||||
const mempoolInfo = memPool.getMempoolInfo();
|
||||
|
||||
// update init data
|
||||
this.updateInitData();
|
||||
this.updateSocketDataFields({
|
||||
'mempoolInfo': mempoolInfo,
|
||||
'blocks': [...blocks.getBlocks(), block].slice(-config.MEMPOOL.INITIAL_BLOCKS_AMOUNT),
|
||||
'mempool-blocks': mBlocks,
|
||||
'loadingIndicators': loadingIndicators.getLoadingIndicators(),
|
||||
'da': da?.previousTime ? da : undefined,
|
||||
'fees': fees,
|
||||
});
|
||||
|
||||
const responseCache = { ...this.initData };
|
||||
const responseCache = { ...this.socketData };
|
||||
function getCachedResponse(key, data): string {
|
||||
if (!responseCache[key]) {
|
||||
responseCache[key] = JSON.stringify(data);
|
||||
@@ -627,22 +697,26 @@ class WebsocketHandler {
|
||||
return responseCache[key];
|
||||
}
|
||||
|
||||
const mempoolInfo = memPool.getMempoolInfo();
|
||||
|
||||
this.wss.clients.forEach((client) => {
|
||||
if (client.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!client['want-blocks']) {
|
||||
return;
|
||||
const response = {};
|
||||
|
||||
if (client['want-blocks']) {
|
||||
response['block'] = getCachedResponse('block', block);
|
||||
}
|
||||
|
||||
const response = {};
|
||||
response['block'] = getCachedResponse('block', block);
|
||||
response['mempoolInfo'] = getCachedResponse('mempoolInfo', mempoolInfo);
|
||||
response['da'] = getCachedResponse('da', da?.previousTime ? da : undefined);
|
||||
response['fees'] = getCachedResponse('fees', fees);
|
||||
if (client['want-stats']) {
|
||||
response['mempoolInfo'] = getCachedResponse('mempoolInfo', mempoolInfo);
|
||||
response['vBytesPerSecond'] = getCachedResponse('vBytesPerSecond', memPool.getVBytesPerSecond());
|
||||
response['fees'] = getCachedResponse('fees', fees);
|
||||
|
||||
if (da?.previousTime) {
|
||||
response['da'] = getCachedResponse('da', da);
|
||||
}
|
||||
}
|
||||
|
||||
if (mBlocks && client['want-mempool-blocks']) {
|
||||
response['mempool-blocks'] = getCachedResponse('mempool-blocks', mBlocks);
|
||||
@@ -650,8 +724,8 @@ class WebsocketHandler {
|
||||
|
||||
if (client['track-tx']) {
|
||||
const trackTxid = client['track-tx'];
|
||||
if (txIds.indexOf(trackTxid) > -1) {
|
||||
response['txConfirmed'] = 'true';
|
||||
if (trackTxid && txIds.indexOf(trackTxid) > -1) {
|
||||
response['txConfirmed'] = JSON.stringify(trackTxid);
|
||||
} else {
|
||||
const mempoolTx = _memPool[trackTxid];
|
||||
if (mempoolTx && mempoolTx.position) {
|
||||
@@ -737,11 +811,19 @@ class WebsocketHandler {
|
||||
}
|
||||
}
|
||||
|
||||
const serializedResponse = '{'
|
||||
if (Object.keys(response).length) {
|
||||
const serializedResponse = this.serializeResponse(response);
|
||||
client.send(serializedResponse);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// takes a dictionary of JSON serialized values
|
||||
// and zips it together into a valid JSON object
|
||||
private serializeResponse(response): string {
|
||||
return '{'
|
||||
+ Object.keys(response).map(key => `"${key}": ${response[key]}`).join(', ')
|
||||
+ '}';
|
||||
client.send(serializedResponse);
|
||||
});
|
||||
}
|
||||
|
||||
private printLogs(): void {
|
||||
|
||||
@@ -31,6 +31,7 @@ interface IConfig {
|
||||
AUDIT: boolean;
|
||||
ADVANCED_GBT_AUDIT: boolean;
|
||||
ADVANCED_GBT_MEMPOOL: boolean;
|
||||
RUST_GBT: boolean;
|
||||
CPFP_INDEXING: boolean;
|
||||
MAX_BLOCKS_BULK_QUERY: number;
|
||||
DISK_CACHE_BLOCK_INTERVAL: number;
|
||||
@@ -160,6 +161,7 @@ const defaults: IConfig = {
|
||||
'AUDIT': false,
|
||||
'ADVANCED_GBT_AUDIT': false,
|
||||
'ADVANCED_GBT_MEMPOOL': false,
|
||||
'RUST_GBT': false,
|
||||
'CPFP_INDEXING': false,
|
||||
'MAX_BLOCKS_BULK_QUERY': 0,
|
||||
'DISK_CACHE_BLOCK_INTERVAL': 6,
|
||||
|
||||
@@ -30,7 +30,7 @@ import { FieldPacket, OkPacket, PoolOptions, ResultSetHeader, RowDataPacket } fr
|
||||
}
|
||||
|
||||
public async query<T extends RowDataPacket[][] | RowDataPacket[] | OkPacket |
|
||||
OkPacket[] | ResultSetHeader>(query, params?): Promise<[T, FieldPacket[]]>
|
||||
OkPacket[] | ResultSetHeader>(query, params?, connection?: PoolConnection): Promise<[T, FieldPacket[]]>
|
||||
{
|
||||
this.checkDBFlag();
|
||||
let hardTimeout;
|
||||
@@ -45,7 +45,9 @@ import { FieldPacket, OkPacket, PoolOptions, ResultSetHeader, RowDataPacket } fr
|
||||
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 => {
|
||||
// Use a specific connection if provided, otherwise delegate to the pool
|
||||
const connectionPromise = connection ? Promise.resolve(connection) : this.getPool();
|
||||
connectionPromise.then((pool: PoolConnection | Pool) => {
|
||||
return pool.query(query, params) as Promise<[T, FieldPacket[]]>;
|
||||
}).then(result => {
|
||||
resolve(result);
|
||||
@@ -61,6 +63,33 @@ import { FieldPacket, OkPacket, PoolOptions, ResultSetHeader, RowDataPacket } fr
|
||||
}
|
||||
}
|
||||
|
||||
public async $atomicQuery<T extends RowDataPacket[][] | RowDataPacket[] | OkPacket |
|
||||
OkPacket[] | ResultSetHeader>(queries: { query, params }[]): Promise<[T, FieldPacket[]][]>
|
||||
{
|
||||
const pool = await this.getPool();
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
|
||||
const results: [T, FieldPacket[]][] = [];
|
||||
for (const query of queries) {
|
||||
const result = await this.query(query.query, query.params, connection) as [T, FieldPacket[]];
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
await connection.commit();
|
||||
|
||||
return results;
|
||||
} catch (e) {
|
||||
logger.err('Could not complete db transaction, rolling back: ' + (e instanceof Error ? e.message : e));
|
||||
connection.rollback();
|
||||
connection.release();
|
||||
throw e;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
|
||||
public async checkDbConnection() {
|
||||
this.checkDBFlag();
|
||||
try {
|
||||
|
||||
@@ -150,7 +150,7 @@ class Server {
|
||||
|
||||
if (config.BISQ.ENABLED) {
|
||||
bisq.startBisqService();
|
||||
bisq.setPriceCallbackFunction((price) => websocketHandler.setExtraInitProperties('bsq-price', price));
|
||||
bisq.setPriceCallbackFunction((price) => websocketHandler.setExtraInitData('bsq-price', price));
|
||||
blocks.setNewBlockCallback(bisq.handleNewBitcoinBlock.bind(bisq));
|
||||
bisqMarkets.startBisqService();
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import logger from './logger';
|
||||
import bitcoinClient from './api/bitcoin/bitcoin-client';
|
||||
import priceUpdater from './tasks/price-updater';
|
||||
import PricesRepository from './repositories/PricesRepository';
|
||||
import config from './config';
|
||||
|
||||
export interface CoreIndex {
|
||||
name: string;
|
||||
@@ -72,7 +73,7 @@ class Indexer {
|
||||
return;
|
||||
}
|
||||
|
||||
if (task === 'blocksPrices' && !this.tasksRunning.includes(task)) {
|
||||
if (task === 'blocksPrices' && !this.tasksRunning.includes(task) && !['testnet', 'signet'].includes(config.MEMPOOL.NETWORK)) {
|
||||
this.tasksRunning.push(task);
|
||||
const lastestPriceId = await PricesRepository.$getLatestPriceId();
|
||||
if (priceUpdater.historyInserted === false || lastestPriceId === null) {
|
||||
@@ -134,6 +135,7 @@ class Indexer {
|
||||
await mining.$generatePoolHashrateHistory();
|
||||
await blocks.$generateBlocksSummariesDatabase();
|
||||
await blocks.$generateCPFPDatabase();
|
||||
await blocks.$generateAuditStats();
|
||||
} catch (e) {
|
||||
this.indexerRunning = false;
|
||||
logger.err(`Indexer failed, trying again in 10 seconds. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface PoolInfo {
|
||||
blockCount: number;
|
||||
slug: string;
|
||||
avgMatchRate: number | null;
|
||||
avgFeeDelta: number | null;
|
||||
}
|
||||
|
||||
export interface PoolStats extends PoolInfo {
|
||||
@@ -32,13 +33,19 @@ export interface BlockAudit {
|
||||
hash: string,
|
||||
missingTxs: string[],
|
||||
freshTxs: string[],
|
||||
sigopTxs: string[],
|
||||
fullrbfTxs: string[],
|
||||
addedTxs: string[],
|
||||
matchRate: number,
|
||||
expectedFees?: number,
|
||||
expectedWeight?: number,
|
||||
}
|
||||
|
||||
export interface AuditScore {
|
||||
hash: string,
|
||||
matchRate?: number,
|
||||
expectedFees?: number
|
||||
expectedWeight?: number
|
||||
}
|
||||
|
||||
export interface MempoolBlock {
|
||||
@@ -87,32 +94,44 @@ export interface TransactionExtended extends IEsploraApi.Transaction {
|
||||
uid?: number;
|
||||
}
|
||||
|
||||
export interface MempoolTransactionExtended extends TransactionExtended {
|
||||
order: number;
|
||||
sigops: number;
|
||||
adjustedVsize: number;
|
||||
adjustedFeePerVsize: number;
|
||||
inputs?: number[];
|
||||
}
|
||||
|
||||
export interface AuditTransaction {
|
||||
uid: number;
|
||||
fee: number;
|
||||
weight: number;
|
||||
feePerVsize: number;
|
||||
effectiveFeePerVsize: number;
|
||||
sigops: number;
|
||||
inputs: number[];
|
||||
relativesSet: boolean;
|
||||
ancestorMap: Map<number, AuditTransaction>;
|
||||
children: Set<AuditTransaction>;
|
||||
ancestorFee: number;
|
||||
ancestorWeight: number;
|
||||
ancestorSigops: number;
|
||||
score: number;
|
||||
used: boolean;
|
||||
modified: boolean;
|
||||
modifiedNode: HeapNode<AuditTransaction>;
|
||||
dependencyRate?: number;
|
||||
}
|
||||
|
||||
export interface CompactThreadTransaction {
|
||||
uid: number;
|
||||
fee: number;
|
||||
weight: number;
|
||||
sigops: number;
|
||||
feePerVsize: number;
|
||||
effectiveFeePerVsize?: number;
|
||||
effectiveFeePerVsize: number;
|
||||
inputs: number[];
|
||||
cpfpRoot?: string;
|
||||
cpfpRoot?: number;
|
||||
cpfpChecked?: boolean;
|
||||
dirty?: boolean;
|
||||
}
|
||||
@@ -171,6 +190,8 @@ export interface BlockExtension {
|
||||
feeRange: number[]; // fee rate percentiles
|
||||
reward: number;
|
||||
matchRate: number | null;
|
||||
expectedFees: number | null;
|
||||
expectedWeight: number | null;
|
||||
similarity?: number;
|
||||
pool: {
|
||||
id: number; // Note - This is the `unique_id`, not to mix with the auto increment `id`
|
||||
@@ -207,6 +228,7 @@ export interface BlockExtension {
|
||||
*/
|
||||
export interface BlockExtended extends IEsploraApi.Block {
|
||||
extras: BlockExtension;
|
||||
canonical?: string;
|
||||
}
|
||||
|
||||
export interface BlockSummary {
|
||||
@@ -237,9 +259,21 @@ export interface EffectiveFeeStats {
|
||||
feeRange: number[]; // 2nd, 10th, 25th, 50th, 75th, 90th, 98th percentiles
|
||||
}
|
||||
|
||||
export interface WorkingEffectiveFeeStats extends EffectiveFeeStats {
|
||||
minFee: number;
|
||||
maxFee: number;
|
||||
}
|
||||
|
||||
export interface CpfpCluster {
|
||||
root: string,
|
||||
height: number,
|
||||
txs: Ancestor[],
|
||||
effectiveFeePerVsize: number,
|
||||
}
|
||||
|
||||
export interface CpfpSummary {
|
||||
transactions: TransactionExtended[];
|
||||
clusters: { root: string, height: number, txs: Ancestor[], effectiveFeePerVsize: number }[];
|
||||
clusters: CpfpCluster[];
|
||||
}
|
||||
|
||||
export interface Statistic {
|
||||
|
||||
@@ -6,20 +6,32 @@ import { BlockAudit, AuditScore } from '../mempool.interfaces';
|
||||
class BlocksAuditRepositories {
|
||||
public async $saveAudit(audit: BlockAudit): Promise<void> {
|
||||
try {
|
||||
await DB.query(`INSERT INTO blocks_audits(time, height, hash, missing_txs, added_txs, fresh_txs, match_rate)
|
||||
VALUE (FROM_UNIXTIME(?), ?, ?, ?, ?, ?, ?)`, [audit.time, audit.height, audit.hash, JSON.stringify(audit.missingTxs),
|
||||
JSON.stringify(audit.addedTxs), JSON.stringify(audit.freshTxs), audit.matchRate]);
|
||||
await DB.query(`INSERT INTO blocks_audits(time, height, hash, missing_txs, added_txs, fresh_txs, sigop_txs, fullrbf_txs, match_rate, expected_fees, expected_weight)
|
||||
VALUE (FROM_UNIXTIME(?), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [audit.time, audit.height, audit.hash, JSON.stringify(audit.missingTxs),
|
||||
JSON.stringify(audit.addedTxs), JSON.stringify(audit.freshTxs), JSON.stringify(audit.sigopTxs), JSON.stringify(audit.fullrbfTxs), audit.matchRate, audit.expectedFees, audit.expectedWeight]);
|
||||
} catch (e: any) {
|
||||
if (e.errno === 1062) { // ER_DUP_ENTRY - This scenario is possible upon node backend restart
|
||||
logger.debug(`Cannot save block audit for block ${audit.hash} because it has already been indexed, ignoring`);
|
||||
} else {
|
||||
logger.err(`Cannot save block audit into db. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async $getBlockPredictionsHistory(div: number, interval: string | null): Promise<any> {
|
||||
public async $setSummary(hash: string, expectedFees: number, expectedWeight: number) {
|
||||
try {
|
||||
await DB.query(`
|
||||
UPDATE blocks_audits SET
|
||||
expected_fees = ?,
|
||||
expected_weight = ?
|
||||
WHERE hash = ?
|
||||
`, [expectedFees, expectedWeight, hash]);
|
||||
} catch (e: any) {
|
||||
logger.err(`Cannot update block audit in db. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
}
|
||||
|
||||
public async $getBlocksHealthHistory(div: number, interval: string | null): Promise<any> {
|
||||
try {
|
||||
let query = `SELECT UNIX_TIMESTAMP(time) as time, height, match_rate FROM blocks_audits`;
|
||||
|
||||
@@ -32,17 +44,17 @@ class BlocksAuditRepositories {
|
||||
const [rows] = await DB.query(query);
|
||||
return rows;
|
||||
} catch (e: any) {
|
||||
logger.err(`Cannot fetch block prediction history. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
logger.err(`Cannot fetch blocks health history. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async $getPredictionsCount(): Promise<number> {
|
||||
public async $getBlocksHealthCount(): Promise<number> {
|
||||
try {
|
||||
const [rows] = await DB.query(`SELECT count(hash) as count FROM blocks_audits`);
|
||||
return rows[0].count;
|
||||
} catch (e: any) {
|
||||
logger.err(`Cannot fetch block prediction history. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
logger.err(`Cannot fetch blocks health count. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -50,12 +62,18 @@ class BlocksAuditRepositories {
|
||||
public async $getBlockAudit(hash: string): Promise<any> {
|
||||
try {
|
||||
const [rows]: any[] = await DB.query(
|
||||
`SELECT blocks.height, blocks.hash as id, UNIX_TIMESTAMP(blocks.blockTimestamp) as timestamp, blocks.size,
|
||||
blocks.weight, blocks.tx_count,
|
||||
transactions, template, missing_txs as missingTxs, added_txs as addedTxs, fresh_txs as freshTxs, match_rate as matchRate
|
||||
`SELECT blocks_audits.height, blocks_audits.hash as id, UNIX_TIMESTAMP(blocks_audits.time) as timestamp,
|
||||
template,
|
||||
missing_txs as missingTxs,
|
||||
added_txs as addedTxs,
|
||||
fresh_txs as freshTxs,
|
||||
sigop_txs as sigopTxs,
|
||||
fullrbf_txs as fullrbfTxs,
|
||||
match_rate as matchRate,
|
||||
expected_fees as expectedFees,
|
||||
expected_weight as expectedWeight
|
||||
FROM blocks_audits
|
||||
JOIN blocks ON blocks.hash = blocks_audits.hash
|
||||
JOIN blocks_summaries ON blocks_summaries.id = blocks_audits.hash
|
||||
JOIN blocks_templates ON blocks_templates.id = blocks_audits.hash
|
||||
WHERE blocks_audits.hash = "${hash}"
|
||||
`);
|
||||
|
||||
@@ -63,12 +81,11 @@ class BlocksAuditRepositories {
|
||||
rows[0].missingTxs = JSON.parse(rows[0].missingTxs);
|
||||
rows[0].addedTxs = JSON.parse(rows[0].addedTxs);
|
||||
rows[0].freshTxs = JSON.parse(rows[0].freshTxs);
|
||||
rows[0].transactions = JSON.parse(rows[0].transactions);
|
||||
rows[0].sigopTxs = JSON.parse(rows[0].sigopTxs);
|
||||
rows[0].fullrbfTxs = JSON.parse(rows[0].fullrbfTxs);
|
||||
rows[0].template = JSON.parse(rows[0].template);
|
||||
|
||||
if (rows[0].transactions.length) {
|
||||
return rows[0];
|
||||
}
|
||||
return rows[0];
|
||||
}
|
||||
return null;
|
||||
} catch (e: any) {
|
||||
@@ -80,7 +97,7 @@ class BlocksAuditRepositories {
|
||||
public async $getBlockAuditScore(hash: string): Promise<AuditScore> {
|
||||
try {
|
||||
const [rows]: any[] = await DB.query(
|
||||
`SELECT hash, match_rate as matchRate
|
||||
`SELECT hash, match_rate as matchRate, expected_fees as expectedFees, expected_weight as expectedWeight
|
||||
FROM blocks_audits
|
||||
WHERE blocks_audits.hash = "${hash}"
|
||||
`);
|
||||
@@ -94,7 +111,7 @@ class BlocksAuditRepositories {
|
||||
public async $getBlockAuditScores(maxHeight: number, minHeight: number): Promise<AuditScore[]> {
|
||||
try {
|
||||
const [rows]: any[] = await DB.query(
|
||||
`SELECT hash, match_rate as matchRate
|
||||
`SELECT hash, match_rate as matchRate, expected_fees as expectedFees, expected_weight as expectedWeight
|
||||
FROM blocks_audits
|
||||
WHERE blocks_audits.height BETWEEN ? AND ?
|
||||
`, [minHeight, maxHeight]);
|
||||
@@ -104,6 +121,32 @@ class BlocksAuditRepositories {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async $getBlocksWithoutSummaries(): Promise<string[]> {
|
||||
try {
|
||||
const [fromRows]: any[] = await DB.query(`
|
||||
SELECT height
|
||||
FROM blocks_audits
|
||||
WHERE expected_fees IS NULL
|
||||
ORDER BY height DESC
|
||||
LIMIT 1
|
||||
`);
|
||||
if (!fromRows?.length) {
|
||||
return [];
|
||||
}
|
||||
const fromHeight = fromRows[0].height;
|
||||
const [idRows]: any[] = await DB.query(`
|
||||
SELECT hash
|
||||
FROM blocks_audits
|
||||
WHERE height <= ?
|
||||
ORDER BY height DESC
|
||||
`, [fromHeight]);
|
||||
return idRows.map(row => row.hash);
|
||||
} catch (e: any) {
|
||||
logger.err(`Cannot fetch block audit from db. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new BlocksAuditRepositories();
|
||||
|
||||
@@ -577,19 +577,6 @@ class BlocksRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return blocks height
|
||||
*/
|
||||
public async $getBlocksHeightsAndTimestamp(): Promise<object[]> {
|
||||
try {
|
||||
const [rows]: any[] = await DB.query(`SELECT height, blockTimestamp as timestamp FROM blocks`);
|
||||
return rows;
|
||||
} catch (e) {
|
||||
logger.err('Cannot get blocks height and timestamp from the db. Reason: ' + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get general block stats
|
||||
*/
|
||||
@@ -877,7 +864,7 @@ class BlocksRepository {
|
||||
/**
|
||||
* Get all blocks which have not be linked to a price yet
|
||||
*/
|
||||
public async $getBlocksWithoutPrice(): Promise<object[]> {
|
||||
public async $getBlocksWithoutPrice(): Promise<object[]> {
|
||||
try {
|
||||
const [rows]: any[] = await DB.query(`
|
||||
SELECT UNIX_TIMESTAMP(blocks.blockTimestamp) as timestamp, blocks.height
|
||||
@@ -889,7 +876,7 @@ class BlocksRepository {
|
||||
return rows;
|
||||
} catch (e) {
|
||||
logger.err('Cannot get blocks height and timestamp from the db. Reason: ' + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -909,7 +896,6 @@ class BlocksRepository {
|
||||
logger.debug(`Cannot save blocks prices for blocks [${blockPrices[0].height} to ${blockPrices[blockPrices.length - 1].height}] because it has already been indexed, ignoring`);
|
||||
} else {
|
||||
logger.err(`Cannot save blocks prices for blocks [${blockPrices[0].height} to ${blockPrices[blockPrices.length - 1].height}] into db. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -928,7 +914,7 @@ class BlocksRepository {
|
||||
return blocks;
|
||||
} catch (e) {
|
||||
logger.err(`Cannot get blocks with missing coinstatsindex. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1032,10 +1018,14 @@ class BlocksRepository {
|
||||
|
||||
// Match rate is not part of the blocks table, but it is part of APIs so we must include it
|
||||
extras.matchRate = null;
|
||||
extras.expectedFees = null;
|
||||
extras.expectedWeight = null;
|
||||
if (config.MEMPOOL.AUDIT) {
|
||||
const auditScore = await BlocksAuditsRepository.$getBlockAuditScore(dbBlk.id);
|
||||
if (auditScore != null) {
|
||||
extras.matchRate = auditScore.matchRate;
|
||||
extras.expectedFees = auditScore.expectedFees;
|
||||
extras.expectedWeight = auditScore.expectedWeight;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,21 +36,35 @@ class BlocksSummariesRepository {
|
||||
try {
|
||||
const transactions = JSON.stringify(params.template?.transactions || []);
|
||||
await DB.query(`
|
||||
INSERT INTO blocks_summaries (height, id, transactions, template)
|
||||
VALUE (?, ?, ?, ?)
|
||||
INSERT INTO blocks_templates (id, template)
|
||||
VALUE (?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
template = ?
|
||||
`, [params.height, blockId, '[]', transactions, transactions]);
|
||||
`, [blockId, transactions, transactions]);
|
||||
} catch (e: any) {
|
||||
if (e.errno === 1062) { // ER_DUP_ENTRY - This scenario is possible upon node backend restart
|
||||
logger.debug(`Cannot save block template for ${blockId} because it has already been indexed, ignoring`);
|
||||
} else {
|
||||
logger.debug(`Cannot save block template for ${blockId}. Reason: ${e instanceof Error ? e.message : e}`);
|
||||
throw e;
|
||||
logger.warn(`Cannot save block template for ${blockId}. Reason: ${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async $getTemplate(id: string): Promise<BlockSummary | undefined> {
|
||||
try {
|
||||
const [templates]: any[] = await DB.query(`SELECT * from blocks_templates WHERE id = ?`, [id]);
|
||||
if (templates.length > 0) {
|
||||
return {
|
||||
id: templates[0].id,
|
||||
transactions: JSON.parse(templates[0].template),
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
logger.err(`Cannot get block template for block id ${id}. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public async $getIndexedSummariesId(): Promise<string[]> {
|
||||
try {
|
||||
const [rows]: any[] = await DB.query(`SELECT id from blocks_summaries`);
|
||||
|
||||
@@ -1,62 +1,19 @@
|
||||
import cluster, { Cluster } from 'cluster';
|
||||
import { RowDataPacket } from 'mysql2';
|
||||
import DB from '../database';
|
||||
import logger from '../logger';
|
||||
import { Ancestor } from '../mempool.interfaces';
|
||||
import { Ancestor, CpfpCluster } from '../mempool.interfaces';
|
||||
import transactionRepository from '../repositories/TransactionRepository';
|
||||
|
||||
class CpfpRepository {
|
||||
public async $saveCluster(clusterRoot: string, height: number, txs: Ancestor[], effectiveFeePerVsize: number): Promise<boolean> {
|
||||
if (!txs[0]) {
|
||||
return false;
|
||||
}
|
||||
// skip clusters of transactions with the same fees
|
||||
const roundedEffectiveFee = Math.round(effectiveFeePerVsize * 100) / 100;
|
||||
const equalFee = txs.reduce((acc, tx) => {
|
||||
return (acc && Math.round(((tx.fee || 0) / (tx.weight / 4)) * 100) / 100 === roundedEffectiveFee);
|
||||
}, true);
|
||||
if (equalFee) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const packedTxs = Buffer.from(this.pack(txs));
|
||||
await DB.query(
|
||||
`
|
||||
INSERT INTO compact_cpfp_clusters(root, height, txs, fee_rate)
|
||||
VALUE (UNHEX(?), ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
height = ?,
|
||||
txs = ?,
|
||||
fee_rate = ?
|
||||
`,
|
||||
[clusterRoot, height, packedTxs, effectiveFeePerVsize, height, packedTxs, effectiveFeePerVsize]
|
||||
);
|
||||
const maxChunk = 10;
|
||||
let chunkIndex = 0;
|
||||
while (chunkIndex < txs.length) {
|
||||
const chunk = txs.slice(chunkIndex, chunkIndex + maxChunk).map(tx => {
|
||||
return { txid: tx.txid, cluster: clusterRoot };
|
||||
});
|
||||
await transactionRepository.$batchSetCluster(chunk);
|
||||
chunkIndex += maxChunk;
|
||||
}
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
logger.err(`Cannot save cpfp cluster into db. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async $batchSaveClusters(clusters: { root: string, height: number, txs: Ancestor[], effectiveFeePerVsize: number }[]): Promise<boolean> {
|
||||
try {
|
||||
const clusterValues: any[] = [];
|
||||
const txs: any[] = [];
|
||||
const clusterValues: [string, number, Buffer, number][] = [];
|
||||
const txs: { txid: string, cluster: string }[] = [];
|
||||
|
||||
for (const cluster of clusters) {
|
||||
if (cluster.txs?.length > 1) {
|
||||
if (cluster.txs?.length) {
|
||||
const roundedEffectiveFee = Math.round(cluster.effectiveFeePerVsize * 100) / 100;
|
||||
const equalFee = cluster.txs.reduce((acc, tx) => {
|
||||
const equalFee = cluster.txs.length > 1 && cluster.txs.reduce((acc, tx) => {
|
||||
return (acc && Math.round(((tx.fee || 0) / (tx.weight / 4)) * 100) / 100 === roundedEffectiveFee);
|
||||
}, true);
|
||||
if (!equalFee) {
|
||||
@@ -77,16 +34,10 @@ class CpfpRepository {
|
||||
return false;
|
||||
}
|
||||
|
||||
const queries: { query, params }[] = [];
|
||||
|
||||
const maxChunk = 100;
|
||||
let chunkIndex = 0;
|
||||
// insert transactions in batches of up to 100 rows
|
||||
while (chunkIndex < txs.length) {
|
||||
const chunk = txs.slice(chunkIndex, chunkIndex + maxChunk);
|
||||
await transactionRepository.$batchSetCluster(chunk);
|
||||
chunkIndex += maxChunk;
|
||||
}
|
||||
|
||||
chunkIndex = 0;
|
||||
// insert clusters in batches of up to 100 rows
|
||||
while (chunkIndex < clusterValues.length) {
|
||||
const chunk = clusterValues.slice(chunkIndex, chunkIndex + maxChunk);
|
||||
@@ -98,12 +49,23 @@ class CpfpRepository {
|
||||
return (' (UNHEX(?), ?, ?, ?)');
|
||||
}) + ';';
|
||||
const values = chunk.flat();
|
||||
await DB.query(
|
||||
queries.push({
|
||||
query,
|
||||
values
|
||||
);
|
||||
params: values,
|
||||
});
|
||||
chunkIndex += maxChunk;
|
||||
}
|
||||
|
||||
chunkIndex = 0;
|
||||
// insert transactions in batches of up to 100 rows
|
||||
while (chunkIndex < txs.length) {
|
||||
const chunk = txs.slice(chunkIndex, chunkIndex + maxChunk);
|
||||
queries.push(transactionRepository.buildBatchSetQuery(chunk));
|
||||
chunkIndex += maxChunk;
|
||||
}
|
||||
|
||||
await DB.$atomicQuery(queries);
|
||||
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
logger.err(`Cannot save cpfp clusters into db. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
@@ -111,7 +73,7 @@ class CpfpRepository {
|
||||
}
|
||||
}
|
||||
|
||||
public async $getCluster(clusterRoot: string): Promise<Cluster | void> {
|
||||
public async $getCluster(clusterRoot: string): Promise<CpfpCluster | void> {
|
||||
const [clusterRows]: any = await DB.query(
|
||||
`
|
||||
SELECT *
|
||||
@@ -122,6 +84,7 @@ class CpfpRepository {
|
||||
);
|
||||
const cluster = clusterRows[0];
|
||||
if (cluster?.txs) {
|
||||
cluster.effectiveFeePerVsize = cluster.fee_rate;
|
||||
cluster.txs = this.unpack(cluster.txs);
|
||||
return cluster;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,8 @@ class PoolsRepository {
|
||||
pools.name AS name,
|
||||
pools.link AS link,
|
||||
slug,
|
||||
AVG(blocks_audits.match_rate) AS avgMatchRate
|
||||
AVG(blocks_audits.match_rate) AS avgMatchRate,
|
||||
AVG((CAST(blocks.fees as SIGNED) - CAST(blocks_audits.expected_fees as SIGNED)) / NULLIF(CAST(blocks_audits.expected_fees as SIGNED), 0)) AS avgFeeDelta
|
||||
FROM blocks
|
||||
JOIN pools on pools.id = pool_id
|
||||
LEFT JOIN blocks_audits ON blocks_audits.height = blocks.height
|
||||
|
||||
@@ -25,9 +25,8 @@ class TransactionRepository {
|
||||
}
|
||||
}
|
||||
|
||||
public async $batchSetCluster(txs): Promise<void> {
|
||||
try {
|
||||
let query = `
|
||||
public buildBatchSetQuery(txs: { txid: string, cluster: string }[]): { query, params } {
|
||||
let query = `
|
||||
INSERT IGNORE INTO compact_transactions
|
||||
(
|
||||
txid,
|
||||
@@ -35,13 +34,22 @@ class TransactionRepository {
|
||||
)
|
||||
VALUES
|
||||
`;
|
||||
query += txs.map(tx => {
|
||||
return (' (UNHEX(?), UNHEX(?))');
|
||||
}) + ';';
|
||||
const values = txs.map(tx => [tx.txid, tx.cluster]).flat();
|
||||
query += txs.map(tx => {
|
||||
return (' (UNHEX(?), UNHEX(?))');
|
||||
}) + ';';
|
||||
const values = txs.map(tx => [tx.txid, tx.cluster]).flat();
|
||||
return {
|
||||
query,
|
||||
params: values,
|
||||
};
|
||||
}
|
||||
|
||||
public async $batchSetCluster(txs): Promise<void> {
|
||||
try {
|
||||
const query = this.buildBatchSetQuery(txs);
|
||||
await DB.query(
|
||||
query,
|
||||
values
|
||||
query.query,
|
||||
query.params,
|
||||
);
|
||||
} catch (e: any) {
|
||||
logger.err(`Cannot save cpfp transactions into db. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
@@ -105,6 +113,7 @@ class TransactionRepository {
|
||||
return {
|
||||
descendants,
|
||||
ancestors,
|
||||
effectiveFeePerVsize: cluster.effectiveFeePerVsize,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ class PoolsUpdater {
|
||||
poolsParser.setMiningPools(poolsJson);
|
||||
|
||||
if (config.DATABASE.ENABLED === false) { // Don't run db operations
|
||||
logger.info('Mining pools-v2.json import completed (no database)');
|
||||
logger.info(`Mining pools-v2.json (${githubSha}) import completed (no database)`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ class PoolsUpdater {
|
||||
logger.err(`Could not migrate mining pools, rolling back. Exception: ${JSON.stringify(e)}`, logger.tags.mining);
|
||||
await DB.query('ROLLBACK;');
|
||||
}
|
||||
logger.info('PoolsUpdater completed');
|
||||
logger.info(`Mining pools-v2.json (${githubSha}) import completed`);
|
||||
|
||||
} catch (e) {
|
||||
this.lastRun = now - (oneWeek - oneDay); // Try again in 24h instead of waiting next week
|
||||
|
||||
Reference in New Issue
Block a user