mirror of
https://github.com/mempool/mempool.git
synced 2026-08-13 12:33:11 +02:00
Merge pull request #6369 from mempool/mononaut/cluster-mempool-frontend
Mempool Clustering Visualization
This commit is contained in:
commit
5e7838f37d
56 changed files with 8222 additions and 373 deletions
|
|
@ -15,7 +15,7 @@ module.exports = {
|
|||
"plugin:@typescript-eslint/recommended",
|
||||
"prettier"
|
||||
],
|
||||
"ignorePatterns": ["dist", "eslint-local-rules", ".eslintrc.js", "testSetup*.ts", "jest.integration.*.ts", "__tests__", "*.config.ts"],
|
||||
"ignorePatterns": ["dist", "eslint-local-rules", ".eslintrc.js", "testSetup*.ts", "jest.integration.*.ts", "__tests__", "__e2e__", "*.config.ts"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["src/__integration_tests__/**/*"],
|
||||
|
|
|
|||
|
|
@ -17,8 +17,10 @@ const config: Config.InitialOptions = {
|
|||
'./testSetup.ts',
|
||||
],
|
||||
testPathIgnorePatterns: [
|
||||
'/dist/',
|
||||
'/node_modules/',
|
||||
'/__integration_tests__/',
|
||||
'test-utils\\.ts$',
|
||||
],
|
||||
};
|
||||
export default config;
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@
|
|||
"AUDIT": false,
|
||||
"RUST_GBT": true,
|
||||
"LIMIT_GBT": false,
|
||||
"CLUSTER_MEMPOOL": false,
|
||||
"CLUSTER_MEMPOOL_INDEXING": false,
|
||||
"CPFP_INDEXING": false,
|
||||
"DISK_CACHE_BLOCK_INTERVAL": 6,
|
||||
"MAX_PUSH_TX_SIZE_WEIGHT": 4000000,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@
|
|||
"AUDIT": false,
|
||||
"RUST_GBT": true,
|
||||
"LIMIT_GBT": false,
|
||||
"CLUSTER_MEMPOOL": false,
|
||||
"CLUSTER_MEMPOOL_INDEXING": false,
|
||||
"CPFP_INDEXING": false,
|
||||
"DISK_CACHE_BLOCK_INTERVAL": 6,
|
||||
"MAX_PUSH_TX_SIZE_WEIGHT": 4000000,
|
||||
|
|
|
|||
137
backend/src/__e2e__/cluster-mempool/harness/rpc-client.ts
Normal file
137
backend/src/__e2e__/cluster-mempool/harness/rpc-client.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import http from 'http';
|
||||
|
||||
export interface RpcConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
user: string;
|
||||
pass: string;
|
||||
}
|
||||
|
||||
interface RpcResponse {
|
||||
result: any;
|
||||
error: { code: number; message: string } | null;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export class RpcClient {
|
||||
private config: RpcConfig;
|
||||
private idCounter = 0;
|
||||
|
||||
constructor(config: RpcConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
private async call(method: string, params: any[] = []): Promise<any> {
|
||||
const id = String(++this.idCounter);
|
||||
const body = JSON.stringify({ jsonrpc: '2.0', id, method, params });
|
||||
const auth = Buffer.from(`${this.config.user}:${this.config.pass}`).toString('base64');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{
|
||||
hostname: this.config.host,
|
||||
port: this.config.port,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Basic ${auth}`,
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => (data += chunk));
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const parsed: RpcResponse = JSON.parse(data);
|
||||
if (parsed.error) {
|
||||
reject(new Error(`RPC error ${parsed.error.code}: ${parsed.error.message}`));
|
||||
} else {
|
||||
resolve(parsed.result);
|
||||
}
|
||||
} catch (e) {
|
||||
reject(new Error(`Failed to parse RPC response: ${data.slice(0, 500)}`));
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async getRawMempool(): Promise<string[]> {
|
||||
return this.call('getrawmempool');
|
||||
}
|
||||
|
||||
async getRawTransaction(txid: string, verbose = true): Promise<any> {
|
||||
return this.call('getrawtransaction', [txid, verbose]);
|
||||
}
|
||||
|
||||
async getBlockTemplate(rules: string[] = ['segwit']): Promise<any> {
|
||||
return this.call('getblocktemplate', [{ rules }]);
|
||||
}
|
||||
|
||||
async getMempoolEntry(txid: string): Promise<any> {
|
||||
return this.call('getmempoolentry', [txid]);
|
||||
}
|
||||
|
||||
async getMempoolCluster(txid: string): Promise<any> {
|
||||
return this.call('getmempoolcluster', [txid]);
|
||||
}
|
||||
|
||||
async getBlockCount(): Promise<number> {
|
||||
return this.call('getblockcount');
|
||||
}
|
||||
|
||||
async batch(calls: { method: string; params: any[] }[]): Promise<any[]> {
|
||||
if (calls.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const bodies = calls.map((c) => ({
|
||||
jsonrpc: '2.0',
|
||||
id: String(++this.idCounter),
|
||||
method: c.method,
|
||||
params: c.params,
|
||||
}));
|
||||
const body = JSON.stringify(bodies);
|
||||
const auth = Buffer.from(`${this.config.user}:${this.config.pass}`).toString('base64');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{
|
||||
hostname: this.config.host,
|
||||
port: this.config.port,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Basic ${auth}`,
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => (data += chunk));
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const parsed: RpcResponse[] = JSON.parse(data);
|
||||
const results = parsed.map((r) => {
|
||||
if (r.error) {
|
||||
return { error: r.error };
|
||||
}
|
||||
return r.result;
|
||||
});
|
||||
resolve(results);
|
||||
} catch (e) {
|
||||
reject(new Error(`Failed to parse batch RPC response: ${data.slice(0, 500)}`));
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
}
|
||||
461
backend/src/__e2e__/cluster-mempool/harness/run-harness.ts
Normal file
461
backend/src/__e2e__/cluster-mempool/harness/run-harness.ts
Normal file
|
|
@ -0,0 +1,461 @@
|
|||
/**
|
||||
* Cluster Mempool Test Harness
|
||||
*
|
||||
* Standalone script that compares our ClusterMempool block template ordering
|
||||
* against Bitcoin Core's getblocktemplate output.
|
||||
*
|
||||
* Uses the backend's own transactionUtils to fetch and convert transactions,
|
||||
* ensuring fields (sigops, adjustedVsize, etc.) match exactly.
|
||||
*
|
||||
* Usage:
|
||||
* npx ts-node src/__tests__/cluster-mempool/harness/run-harness.ts [options]
|
||||
*
|
||||
* Options:
|
||||
* --host <host> Override CORE_RPC host
|
||||
* --port <port> Override CORE_RPC port
|
||||
* --user <user> Override CORE_RPC username
|
||||
* --pass <pass> Override CORE_RPC password
|
||||
* --interval <ms> Comparison interval in ms (default: 30000)
|
||||
* --poll <ms> Mempool poll interval in ms (default: 1000)
|
||||
* --max <n> Max comparisons before exit (0 = unlimited, default: 0)
|
||||
*/
|
||||
|
||||
import { RpcClient, RpcConfig } from './rpc-client';
|
||||
|
||||
// ─── CLI Parsing (must happen before backend imports) ───────────────────────
|
||||
|
||||
interface CliOptions {
|
||||
rpcOverrides: Partial<RpcConfig>;
|
||||
comparisonInterval: number;
|
||||
pollInterval: number;
|
||||
maxComparisons: number;
|
||||
}
|
||||
|
||||
function parseArgs(): CliOptions {
|
||||
const args = process.argv.slice(2);
|
||||
const overrides: Partial<RpcConfig> = {};
|
||||
let comparisonInterval = 30_000;
|
||||
let pollInterval = 1000;
|
||||
let maxComparisons = 0;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
switch (args[i]) {
|
||||
case '--host':
|
||||
overrides.host = args[++i];
|
||||
break;
|
||||
case '--port':
|
||||
overrides.port = parseInt(args[++i], 10);
|
||||
break;
|
||||
case '--user':
|
||||
overrides.user = args[++i];
|
||||
break;
|
||||
case '--pass':
|
||||
overrides.pass = args[++i];
|
||||
break;
|
||||
case '--interval':
|
||||
comparisonInterval = parseInt(args[++i], 10);
|
||||
break;
|
||||
case '--poll':
|
||||
pollInterval = parseInt(args[++i], 10);
|
||||
break;
|
||||
case '--max':
|
||||
maxComparisons = parseInt(args[++i], 10);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { rpcOverrides: overrides, comparisonInterval, pollInterval, maxComparisons };
|
||||
}
|
||||
|
||||
const cliOptions = parseArgs();
|
||||
|
||||
// ─── Backend Module Loading ─────────────────────────────────────────────────
|
||||
// Import config first, apply CLI overrides, then import modules that depend on it.
|
||||
// This works because Node's require() caches modules — when transactionUtils
|
||||
// (via bitcoinClient) reads config, it sees our modified values.
|
||||
|
||||
const config = require('../../../config').default;
|
||||
|
||||
if (cliOptions.rpcOverrides.host) { config.CORE_RPC.HOST = cliOptions.rpcOverrides.host; }
|
||||
if (cliOptions.rpcOverrides.port) { config.CORE_RPC.PORT = cliOptions.rpcOverrides.port; }
|
||||
if (cliOptions.rpcOverrides.user) { config.CORE_RPC.USERNAME = cliOptions.rpcOverrides.user; }
|
||||
if (cliOptions.rpcOverrides.pass) { config.CORE_RPC.PASSWORD = cliOptions.rpcOverrides.pass; }
|
||||
|
||||
// Now load modules that depend on config
|
||||
const bitcoinApi = require('../../../api/bitcoin/bitcoin-api-factory').default;
|
||||
const transactionUtils = require('../../../api/transaction-utils').default;
|
||||
const { ClusterMempool } = require('../../../cluster-mempool/cluster-mempool');
|
||||
|
||||
import { MempoolTransactionExtended } from '../../../mempool.interfaces';
|
||||
import { MempoolDiff } from '../../../cluster-mempool/cluster-mempool';
|
||||
|
||||
// ─── Main Harness ───────────────────────────────────────────────────────────
|
||||
|
||||
const TX_FETCH_BATCH_SIZE = 1000;
|
||||
|
||||
class Harness {
|
||||
private rpc: RpcClient;
|
||||
private clusterMempool: InstanceType<typeof ClusterMempool> | null = null;
|
||||
private mempool: { [txid: string]: MempoolTransactionExtended } = {};
|
||||
private knownTxids = new Set<string>();
|
||||
private lastBlockHeight = -1;
|
||||
private comparisonInterval: number;
|
||||
private pollInterval: number;
|
||||
private maxComparisons: number;
|
||||
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private polling = false;
|
||||
private nextComparisonTime = 0;
|
||||
private stats = {
|
||||
templateMatches: 0,
|
||||
templateMismatches: 0,
|
||||
comparisons: 0,
|
||||
};
|
||||
|
||||
constructor(rpcConfig: RpcConfig, opts: CliOptions) {
|
||||
this.rpc = new RpcClient(rpcConfig);
|
||||
this.comparisonInterval = opts.comparisonInterval;
|
||||
this.pollInterval = opts.pollInterval;
|
||||
this.maxComparisons = opts.maxComparisons;
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
console.log('=== Cluster Mempool Test Harness ===');
|
||||
console.log(`RPC: ${config.CORE_RPC.HOST}:${config.CORE_RPC.PORT}`);
|
||||
console.log(`Backend: ${config.MEMPOOL.BACKEND}`);
|
||||
console.log(`Poll interval: ${this.pollInterval}ms`);
|
||||
console.log(`Comparison interval: ${this.comparisonInterval}ms`);
|
||||
console.log();
|
||||
|
||||
// Step 1: Fetch full mempool
|
||||
console.log('Fetching full mempool...');
|
||||
const txids: string[] = await bitcoinApi.$getRawMempool();
|
||||
console.log(`Got ${txids.length} txids from getrawmempool`);
|
||||
|
||||
const fetchStart = Date.now();
|
||||
await this.fetchTransactions(txids);
|
||||
const fetchTime = Date.now() - fetchStart;
|
||||
console.log(`Fetched ${Object.keys(this.mempool).length} transactions in ${fetchTime}ms`);
|
||||
|
||||
// Step 2: Initialize ClusterMempool
|
||||
console.log('Building cluster mempool...');
|
||||
const buildStart = Date.now();
|
||||
this.clusterMempool = new ClusterMempool(this.mempool);
|
||||
const buildTime = Date.now() - buildStart;
|
||||
console.log(`Cluster mempool built in ${buildTime}ms`);
|
||||
console.log(` Clusters: ${this.clusterMempool.getClusterCount()}`);
|
||||
console.log(` Transactions: ${this.clusterMempool.getTxCount()}`);
|
||||
console.log();
|
||||
|
||||
// Record initial block height
|
||||
this.lastBlockHeight = await this.rpc.getBlockCount();
|
||||
|
||||
// Run initial comparison, then start polling
|
||||
await this.runComparison();
|
||||
this.nextComparisonTime = Date.now() + this.comparisonInterval;
|
||||
this.pollTimer = setInterval(() => this.pollMempool(), this.pollInterval);
|
||||
|
||||
console.log('\nHarness running. Press Ctrl+C to stop.\n');
|
||||
|
||||
process.on('SIGINT', () => this.shutdown());
|
||||
process.on('SIGTERM', () => this.shutdown());
|
||||
}
|
||||
|
||||
private async fetchTransactions(txids: string[]): Promise<MempoolTransactionExtended[]> {
|
||||
const added: MempoolTransactionExtended[] = [];
|
||||
|
||||
for (let offset = 0; offset < txids.length; offset += TX_FETCH_BATCH_SIZE) {
|
||||
let batch = txids.slice(offset, offset + TX_FETCH_BATCH_SIZE);
|
||||
let txs: MempoolTransactionExtended[] = [];
|
||||
let tries = 0;
|
||||
while (batch.length && tries < 20) {
|
||||
try {
|
||||
tries++;
|
||||
txs = txs.concat(await transactionUtils.$getMempoolTransactionsExtended(batch, false, false, false));
|
||||
let missing: string[] = [];
|
||||
console.log(`txs: ${txs.length} of ${batch.length} fetched`);
|
||||
for (const txid of batch) {
|
||||
if (!txs.some(tx => tx.txid === txid)) {
|
||||
console.log(`missing ${txid} at offset ${offset}, retrying`);
|
||||
missing.push(txid);
|
||||
}
|
||||
}
|
||||
batch = missing;
|
||||
if (batch.length) {
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.log(` Fetch batch failed at offset ${offset}: ${err.message}, retrying`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const tx of txs) {
|
||||
this.mempool[tx.txid] = tx;
|
||||
this.knownTxids.add(tx.txid);
|
||||
added.push(tx);
|
||||
}
|
||||
|
||||
if (txids.length > TX_FETCH_BATCH_SIZE && (offset + batch.length) % 5000 < TX_FETCH_BATCH_SIZE) {
|
||||
console.log(` ${offset + batch.length} / ${txids.length} fetched...`);
|
||||
}
|
||||
}
|
||||
|
||||
return added;
|
||||
}
|
||||
|
||||
private async pollMempool(): Promise<void> {
|
||||
if (this.polling || !this.clusterMempool) {
|
||||
return;
|
||||
}
|
||||
this.polling = true;
|
||||
|
||||
try {
|
||||
// Check for new block
|
||||
const height = await this.rpc.getBlockCount();
|
||||
const newBlock = height > this.lastBlockHeight;
|
||||
if (newBlock) {
|
||||
console.log(`\n--- New block detected (height ${height}) ---`);
|
||||
this.lastBlockHeight = height;
|
||||
}
|
||||
|
||||
// Get current mempool
|
||||
const currentTxids = await bitcoinApi.$getRawMempool();
|
||||
const currentSet = new Set(currentTxids);
|
||||
|
||||
// Find added and removed
|
||||
const addedTxids: string[] = [];
|
||||
for (const txid of currentTxids) {
|
||||
if (!this.knownTxids.has(txid)) {
|
||||
addedTxids.push(txid);
|
||||
}
|
||||
}
|
||||
const removedTxids: string[] = [];
|
||||
for (const txid of this.knownTxids) {
|
||||
if (!currentSet.has(txid)) {
|
||||
removedTxids.push(txid);
|
||||
}
|
||||
}
|
||||
|
||||
if (addedTxids.length === 0 && removedTxids.length === 0) {
|
||||
if (newBlock) {
|
||||
await this.runComparison();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const added = await this.fetchTransactions(addedTxids);
|
||||
|
||||
// Apply diff before deleting from cache — processRemovals needs the tx data
|
||||
const diff: MempoolDiff = { added, removed: removedTxids, accelerations: {} };
|
||||
const t0 = Date.now();
|
||||
this.clusterMempool.applyMempoolChange(diff);
|
||||
const dt = Date.now() - t0;
|
||||
for (const txid of removedTxids) {
|
||||
delete this.mempool[txid];
|
||||
this.knownTxids.delete(txid);
|
||||
}
|
||||
console.log(
|
||||
`Applied${newBlock ? ' block' : ''} diff: +${added.length} -${removedTxids.length} txs in ${dt}ms` +
|
||||
` (${this.clusterMempool.getTxCount()} txs, ${this.clusterMempool.getClusterCount()} clusters)`
|
||||
);
|
||||
|
||||
if (newBlock) {
|
||||
await this.runComparison();
|
||||
} else if (Date.now() >= this.nextComparisonTime) {
|
||||
await this.runComparison();
|
||||
this.nextComparisonTime = Date.now() + this.comparisonInterval;
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`Poll error: ${err.message}`);
|
||||
} finally {
|
||||
this.polling = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async runComparison(): Promise<void> {
|
||||
if (!this.clusterMempool) {
|
||||
return;
|
||||
}
|
||||
this.stats.comparisons++;
|
||||
console.log(`\n=== Comparison #${this.stats.comparisons} ===`);
|
||||
|
||||
// Step 1: Get Core's block template
|
||||
let template: any;
|
||||
try {
|
||||
template = await this.rpc.getBlockTemplate(['segwit']);
|
||||
} catch (err: any) {
|
||||
console.log(`getblocktemplate failed: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
const coreTxids: string[] = template.transactions.map((t: any) => t.txid);
|
||||
const coreSet = new Set(coreTxids);
|
||||
|
||||
const missingTxids: string[] = [];
|
||||
for (const txid of coreSet) {
|
||||
if (!this.mempool[txid]) {
|
||||
missingTxids.push(txid);
|
||||
}
|
||||
}
|
||||
if (missingTxids.length) {
|
||||
const added = await this.fetchTransactions(missingTxids);
|
||||
if (added.length) {
|
||||
console.log(`Reconciled: +${added.length} missing txs`);
|
||||
this.clusterMempool.applyMempoolChange({ added, removed: [], accelerations: {} });
|
||||
}
|
||||
}
|
||||
|
||||
const ourBlocks = this.clusterMempool.getBlocks(1, true);
|
||||
const ourTxids: string[] = ourBlocks[0]?.txids || [];
|
||||
|
||||
await this.compareTemplateOrdering(coreTxids, ourTxids);
|
||||
|
||||
this.printStats();
|
||||
|
||||
if (this.maxComparisons > 0 && this.stats.comparisons >= this.maxComparisons) {
|
||||
console.log(`\nReached ${this.maxComparisons} comparisons, stopping.`);
|
||||
this.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
private async compareTemplateOrdering(coreTxids: string[], ourTxids: string[]): Promise<void> {
|
||||
const coreSet = new Set(coreTxids);
|
||||
const ourSet = new Set(ourTxids);
|
||||
|
||||
let inBoth = 0;
|
||||
let onlyCore = 0;
|
||||
let onlyOurs = 0;
|
||||
for (const txid of coreSet) {
|
||||
if (ourSet.has(txid)) {
|
||||
inBoth++;
|
||||
} else {
|
||||
onlyCore++;
|
||||
}
|
||||
}
|
||||
for (const txid of ourSet) {
|
||||
if (!coreSet.has(txid)) {
|
||||
onlyOurs++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Template sets: Core ${coreTxids.length} txs, Ours ${ourTxids.length} txs`);
|
||||
console.log(` In both: ${inBoth}, Only Core: ${onlyCore}, Only ours: ${onlyOurs}`);
|
||||
|
||||
// Build position maps for the intersection
|
||||
const corePos = new Map<string, number>();
|
||||
for (let i = 0; i < coreTxids.length; i++) {
|
||||
if (ourSet.has(coreTxids[i])) {
|
||||
corePos.set(coreTxids[i], i);
|
||||
}
|
||||
}
|
||||
|
||||
// Filter to shared txids in each order
|
||||
const sharedInCoreOrder = coreTxids.filter(t => ourSet.has(t));
|
||||
const sharedInOurOrder = ourTxids.filter(t => coreSet.has(t));
|
||||
|
||||
let exactMatches = 0;
|
||||
let firstDivergence = -1;
|
||||
for (let i = 0; i < sharedInCoreOrder.length; i++) {
|
||||
if (sharedInCoreOrder[i] === sharedInOurOrder[i]) {
|
||||
exactMatches++;
|
||||
} else if (firstDivergence === -1) {
|
||||
firstDivergence = i;
|
||||
}
|
||||
}
|
||||
|
||||
const orderMatchRate = sharedInCoreOrder.length > 0
|
||||
? (exactMatches / sharedInCoreOrder.length * 100).toFixed(1)
|
||||
: 'N/A';
|
||||
console.log(` Ordering: ${orderMatchRate}% exact position match (${exactMatches}/${sharedInCoreOrder.length} shared txs)`);
|
||||
|
||||
if (firstDivergence >= 0) {
|
||||
const coreTx = sharedInCoreOrder[firstDivergence];
|
||||
const ourTx = sharedInOurOrder[firstDivergence];
|
||||
console.log(`\n First divergence at position ${firstDivergence}:`);
|
||||
console.log(` Core wants: ${coreTx}`);
|
||||
console.log(` We placed: ${ourTx} (Core has this at position ${corePos.get(ourTx)})`);
|
||||
|
||||
for (const [label, txid] of [['Core tx', coreTx], ['Our tx', ourTx]] as const) {
|
||||
const inOurMempool = !!this.mempool[txid];
|
||||
const info = this.clusterMempool?.getClusterInfo(txid);
|
||||
console.log(`\n --- ${label}: ${txid} ---`);
|
||||
console.log(` In our mempool: ${inOurMempool}`);
|
||||
if (inOurMempool) {
|
||||
const tx = this.mempool[txid];
|
||||
console.log(` fee=${tx.fee} weight=${tx.weight} vsize=${tx.vsize} sigops=${tx.sigops} adjustedVsize=${tx.adjustedVsize}`);
|
||||
}
|
||||
if (info) {
|
||||
console.log(` clusterId=${info.clusterId} chunkIndex=${info.chunkIndex} chunkFeerate=${info.chunkFeerate.toFixed(6)}`);
|
||||
const cluster = this.clusterMempool?.getCluster(info.clusterId);
|
||||
if (cluster) {
|
||||
console.log(` Cluster has ${cluster.chunks.length} chunk(s):`);
|
||||
for (let ci = 0; ci < cluster.chunks.length; ci++) {
|
||||
const c = cluster.chunks[ci];
|
||||
console.log(` chunk[${ci}]: ${c.txs.length} txs, feerate=${c.feerate.toFixed(6)}`);
|
||||
if (ci === info.chunkIndex) {
|
||||
const chunkTxids = c.txs.map((idx: number) => cluster.txs[idx]?.txid).filter(Boolean);
|
||||
for (const tid of chunkTxids) {
|
||||
const t = this.mempool[tid];
|
||||
const parents = t?.vin
|
||||
?.filter(v => !v.is_coinbase && this.mempool[v.txid] && chunkTxids.includes(v.txid))
|
||||
.map(v => v.txid.substring(0, 12)) || [];
|
||||
console.log(` ${tid.substring(0, 12)} fee=${t?.fee} size=${(t?.weight || 0) / 4} sigops=${t?.sigops} parents=[${parents.join(', ')}]`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(` NOT in our cluster mempool`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [label, txid] of [['Core', coreTx], ['Our', ourTx]] as const) {
|
||||
try {
|
||||
const coreCluster = await this.rpc.getMempoolCluster(txid);
|
||||
console.log(`\n --- Core cluster for ${label} tx ${txid.substring(0, 12)} ---`);
|
||||
console.log(` Raw response: ${JSON.stringify(coreCluster).substring(0, 2000)}`);
|
||||
} catch (e: any) {
|
||||
console.log(` Failed to get Core cluster for ${label} tx: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.stats.templateMismatches++;
|
||||
} else if (sharedInCoreOrder.length === coreTxids.length && sharedInCoreOrder.length === ourTxids.length) {
|
||||
console.log(` PERFECT MATCH`);
|
||||
this.stats.templateMatches++;
|
||||
} else {
|
||||
console.log(` Ordering matches for shared txs, but sets differ`);
|
||||
this.stats.templateMatches++;
|
||||
}
|
||||
}
|
||||
|
||||
private printStats(): void {
|
||||
console.log('\n--- Cumulative Stats ---');
|
||||
const total = this.stats.templateMatches + this.stats.templateMismatches;
|
||||
console.log(`Template comparisons: ${total} (${this.stats.templateMatches} perfect, ${this.stats.templateMismatches} divergent)`);
|
||||
}
|
||||
|
||||
private shutdown(): void {
|
||||
console.log('\nShutting down...');
|
||||
if (this.pollTimer) {
|
||||
clearInterval(this.pollTimer);
|
||||
}
|
||||
this.printStats();
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Entry Point ────────────────────────────────────────────────────────────
|
||||
|
||||
const rpcConfig: RpcConfig = {
|
||||
host: config.CORE_RPC.HOST,
|
||||
port: config.CORE_RPC.PORT,
|
||||
user: config.CORE_RPC.USERNAME,
|
||||
pass: config.CORE_RPC.PASSWORD,
|
||||
};
|
||||
|
||||
console.log(`Connecting to Bitcoin Core at ${rpcConfig.host}:${rpcConfig.port}`);
|
||||
|
||||
const harness = new Harness(rpcConfig, cliOptions);
|
||||
harness.run().catch((err) => {
|
||||
console.error('Harness failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -32,6 +32,8 @@
|
|||
"AUDIT": true,
|
||||
"RUST_GBT": false,
|
||||
"LIMIT_GBT": false,
|
||||
"CLUSTER_MEMPOOL": false,
|
||||
"CLUSTER_MEMPOOL_INDEXING": false,
|
||||
"CPFP_INDEXING": true,
|
||||
"MAX_BLOCKS_BULK_QUERY": 999,
|
||||
"DISK_CACHE_BLOCK_INTERVAL": 999,
|
||||
|
|
|
|||
477
backend/src/__tests__/cluster-mempool/cluster-mempool.test.ts
Normal file
477
backend/src/__tests__/cluster-mempool/cluster-mempool.test.ts
Normal file
|
|
@ -0,0 +1,477 @@
|
|||
import { ClusterMempool } from '../../cluster-mempool/cluster-mempool';
|
||||
import { makeTx, txid } from './test-utils';
|
||||
import { MempoolTransactionExtended } from '../../mempool.interfaces';
|
||||
|
||||
function buildMempool(txs: MempoolTransactionExtended[]): { [txid: string]: MempoolTransactionExtended } {
|
||||
const mempool: { [txid: string]: MempoolTransactionExtended } = {};
|
||||
for (const tx of txs) {
|
||||
mempool[tx.txid] = tx;
|
||||
}
|
||||
return mempool;
|
||||
}
|
||||
|
||||
describe('ClusterMempool', () => {
|
||||
describe('constructor', () => {
|
||||
it('should build clusters from mempool', () => {
|
||||
const parentId = txid('a1');
|
||||
const childId = txid('a2');
|
||||
const mempool = buildMempool([
|
||||
makeTx(parentId, 100, 100),
|
||||
makeTx(childId, 5000, 100, [parentId]),
|
||||
]);
|
||||
const cm = new ClusterMempool(mempool);
|
||||
expect(cm.getClusterCount()).toBe(1);
|
||||
expect(cm.getTxCount()).toBe(2);
|
||||
});
|
||||
|
||||
it('should create separate clusters for unrelated txs', () => {
|
||||
const mempool = buildMempool([
|
||||
makeTx(txid('a1'), 100, 100),
|
||||
makeTx(txid('b1'), 200, 100),
|
||||
]);
|
||||
const cm = new ClusterMempool(mempool);
|
||||
expect(cm.getClusterCount()).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getClusterInfo', () => {
|
||||
it('should return cluster info for a known tx', () => {
|
||||
const parentId = txid('a1');
|
||||
const childId = txid('a2');
|
||||
const mempool = buildMempool([
|
||||
makeTx(parentId, 100, 100),
|
||||
makeTx(childId, 5000, 100, [parentId]),
|
||||
]);
|
||||
const cm = new ClusterMempool(mempool);
|
||||
const info = cm.getClusterInfo(parentId);
|
||||
expect(info).not.toBeNull();
|
||||
expect(info?.chunkFeerate).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should return null for unknown tx', () => {
|
||||
const mempool = buildMempool([makeTx(txid('a1'), 100, 100)]);
|
||||
const cm = new ClusterMempool(mempool);
|
||||
expect(cm.getClusterInfo(txid('zz'))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCluster', () => {
|
||||
it('should return cluster data with correct topology', () => {
|
||||
const parentId = txid('a1');
|
||||
const childId = txid('a2');
|
||||
const mempool = buildMempool([
|
||||
makeTx(parentId, 100, 100),
|
||||
makeTx(childId, 5000, 100, [parentId]),
|
||||
]);
|
||||
const cm = new ClusterMempool(mempool);
|
||||
const info = cm.getClusterInfo(parentId);
|
||||
expect(info).not.toBeNull();
|
||||
const data = cm.getCluster(info!.clusterId);
|
||||
expect(data).not.toBeNull();
|
||||
expect(data!.txs.length).toBe(2);
|
||||
expect(data!.chunks.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should return null for unknown cluster id', () => {
|
||||
const mempool = buildMempool([makeTx(txid('a1'), 100, 100)]);
|
||||
const cm = new ClusterMempool(mempool);
|
||||
expect(cm.getCluster(9999)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyMempoolChange', () => {
|
||||
it('should handle adding a new singleton tx', () => {
|
||||
const mempool = buildMempool([makeTx(txid('a1'), 100, 100)]);
|
||||
const cm = new ClusterMempool(mempool);
|
||||
const initialCount = cm.getClusterCount();
|
||||
|
||||
cm.applyMempoolChange({
|
||||
added: [makeTx(txid('b1'), 200, 100)],
|
||||
removed: [],
|
||||
accelerations: {},
|
||||
});
|
||||
|
||||
expect(cm.getClusterCount()).toBe(initialCount + 1);
|
||||
expect(cm.getTxCount()).toBe(2);
|
||||
});
|
||||
|
||||
it('should handle removing a tx', () => {
|
||||
const parentId = txid('a1');
|
||||
const childId = txid('a2');
|
||||
const mempool = buildMempool([
|
||||
makeTx(parentId, 100, 100),
|
||||
makeTx(childId, 5000, 100, [parentId]),
|
||||
]);
|
||||
const cm = new ClusterMempool(mempool);
|
||||
|
||||
cm.applyMempoolChange({
|
||||
added: [],
|
||||
removed: [childId],
|
||||
accelerations: {},
|
||||
});
|
||||
|
||||
expect(cm.getTxCount()).toBe(1);
|
||||
expect(cm.getClusterInfo(childId)).toBeNull();
|
||||
expect(cm.getClusterInfo(parentId)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should split cluster when middle tx is removed', () => {
|
||||
const a = txid('a1');
|
||||
const b = txid('b1');
|
||||
const c = txid('c1');
|
||||
const mempool = buildMempool([
|
||||
makeTx(a, 100, 100),
|
||||
makeTx(b, 200, 100, [a]),
|
||||
makeTx(c, 300, 100, [b]),
|
||||
]);
|
||||
const cm = new ClusterMempool(mempool);
|
||||
expect(cm.getClusterCount()).toBe(1);
|
||||
|
||||
cm.applyMempoolChange({
|
||||
added: [],
|
||||
removed: [b],
|
||||
accelerations: {},
|
||||
});
|
||||
|
||||
expect(cm.getTxCount()).toBe(2);
|
||||
const infoA = cm.getClusterInfo(a);
|
||||
const infoC = cm.getClusterInfo(c);
|
||||
expect(infoA).not.toBeNull();
|
||||
expect(infoC).not.toBeNull();
|
||||
expect(infoA!.clusterId).not.toBe(infoC!.clusterId);
|
||||
});
|
||||
|
||||
it('should handle fee changes via acceleration', () => {
|
||||
const parentId = txid('a1');
|
||||
const childId = txid('a2');
|
||||
const mempool = buildMempool([
|
||||
makeTx(parentId, 100, 100),
|
||||
makeTx(childId, 100, 100, [parentId]),
|
||||
]);
|
||||
const cm = new ClusterMempool(mempool);
|
||||
const infoBefore = cm.getClusterInfo(childId);
|
||||
|
||||
cm.applyMempoolChange({
|
||||
added: [],
|
||||
removed: [],
|
||||
accelerations: { [childId]: { feeDelta: 49900 } },
|
||||
});
|
||||
|
||||
const infoAfter = cm.getClusterInfo(childId);
|
||||
expect(infoAfter).not.toBeNull();
|
||||
expect(infoAfter!.clusterId).not.toBe(infoBefore!.clusterId);
|
||||
});
|
||||
|
||||
it('should merge clusters when new tx connects them', () => {
|
||||
const a = txid('a1');
|
||||
const b = txid('b1');
|
||||
const mempool = buildMempool([
|
||||
makeTx(a, 100, 100),
|
||||
makeTx(b, 200, 100),
|
||||
]);
|
||||
const cm = new ClusterMempool(mempool);
|
||||
expect(cm.getClusterCount()).toBe(2);
|
||||
|
||||
const bridgeTx = makeTx(txid('c1'), 300, 100, [a, b]);
|
||||
cm.applyMempoolChange({
|
||||
added: [bridgeTx],
|
||||
removed: [],
|
||||
accelerations: {},
|
||||
});
|
||||
|
||||
expect(cm.getClusterCount()).toBe(1);
|
||||
expect(cm.getTxCount()).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cluster merging', () => {
|
||||
it('should merge 3 separate clusters when new tx bridges them', () => {
|
||||
const a = txid('a1');
|
||||
const b = txid('b1');
|
||||
const c = txid('c1');
|
||||
const mempool = buildMempool([
|
||||
makeTx(a, 100, 100),
|
||||
makeTx(b, 200, 100),
|
||||
makeTx(c, 300, 100),
|
||||
]);
|
||||
const cm = new ClusterMempool(mempool);
|
||||
expect(cm.getClusterCount()).toBe(3);
|
||||
|
||||
const bridge = makeTx(txid('d1'), 400, 100, [a, b, c]);
|
||||
mempool[bridge.txid] = bridge;
|
||||
cm.applyMempoolChange({ added: [bridge], removed: [], accelerations: {} });
|
||||
|
||||
expect(cm.getClusterCount()).toBe(1);
|
||||
expect(cm.getTxCount()).toBe(4);
|
||||
});
|
||||
|
||||
it('should grow cluster by 1 when new tx has parents in same cluster', () => {
|
||||
const a = txid('a1');
|
||||
const b = txid('b1');
|
||||
const mempool = buildMempool([
|
||||
makeTx(a, 100, 100),
|
||||
makeTx(b, 200, 100, [a]),
|
||||
]);
|
||||
const cm = new ClusterMempool(mempool);
|
||||
expect(cm.getClusterCount()).toBe(1);
|
||||
|
||||
const c = makeTx(txid('c1'), 300, 100, [a]);
|
||||
mempool[c.txid] = c;
|
||||
cm.applyMempoolChange({ added: [c], removed: [], accelerations: {} });
|
||||
|
||||
expect(cm.getClusterCount()).toBe(1);
|
||||
expect(cm.getTxCount()).toBe(3);
|
||||
});
|
||||
|
||||
it('should grow chain incrementally', () => {
|
||||
const a = txid('a1');
|
||||
const mempool = buildMempool([makeTx(a, 100, 100)]);
|
||||
const cm = new ClusterMempool(mempool);
|
||||
|
||||
const b = makeTx(txid('b1'), 200, 100, [a]);
|
||||
mempool[b.txid] = b;
|
||||
cm.applyMempoolChange({ added: [b], removed: [], accelerations: {} });
|
||||
|
||||
const c = makeTx(txid('c1'), 300, 100, [txid('b1')]);
|
||||
mempool[c.txid] = c;
|
||||
cm.applyMempoolChange({ added: [c], removed: [], accelerations: {} });
|
||||
|
||||
expect(cm.getClusterCount()).toBe(1);
|
||||
expect(cm.getTxCount()).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cluster splitting', () => {
|
||||
it('should split star into singletons when center is removed', () => {
|
||||
const center = txid('center');
|
||||
const leaves = Array.from({ length: 5 }, (_, i) => txid(`leaf${i}`));
|
||||
const centerTx = makeTx(center, 100, 100);
|
||||
for (let i = 1; i < 5; i++) {
|
||||
centerTx.vout.push({
|
||||
scriptpubkey: '', scriptpubkey_asm: '', scriptpubkey_type: 'v0_p2wpkh', value: 50000,
|
||||
});
|
||||
}
|
||||
const leafTxs = leaves.map((l, i) => {
|
||||
const tx = makeTx(l, 200, 100, [center]);
|
||||
tx.vin[0].vout = i;
|
||||
return tx;
|
||||
});
|
||||
const mempool = buildMempool([centerTx, ...leafTxs]);
|
||||
const cm = new ClusterMempool(mempool);
|
||||
expect(cm.getClusterCount()).toBe(1);
|
||||
|
||||
cm.applyMempoolChange({ added: [], removed: [center], accelerations: {} });
|
||||
|
||||
expect(cm.getTxCount()).toBe(5);
|
||||
expect(cm.getClusterCount()).toBe(5);
|
||||
});
|
||||
|
||||
it('should shrink cluster without splitting when leaf is removed', () => {
|
||||
const a = txid('a1');
|
||||
const b = txid('b1');
|
||||
const c = txid('c1');
|
||||
const aTx = makeTx(a, 100, 100);
|
||||
aTx.vout.push({ scriptpubkey: '', scriptpubkey_asm: '', scriptpubkey_type: 'v0_p2wpkh', value: 50000 });
|
||||
const bTx = makeTx(b, 200, 100, [a]);
|
||||
bTx.vin[0].vout = 0;
|
||||
const cTx = makeTx(c, 300, 100, [a]);
|
||||
cTx.vin[0].vout = 1;
|
||||
const mempool = buildMempool([aTx, bTx, cTx]);
|
||||
const cm = new ClusterMempool(mempool);
|
||||
expect(cm.getClusterCount()).toBe(1);
|
||||
|
||||
cm.applyMempoolChange({ added: [], removed: [c], accelerations: {} });
|
||||
|
||||
expect(cm.getClusterCount()).toBe(1);
|
||||
expect(cm.getTxCount()).toBe(2);
|
||||
});
|
||||
|
||||
it('should produce singleton when tx is removed from 2-tx cluster', () => {
|
||||
const a = txid('a1');
|
||||
const b = txid('b1');
|
||||
const mempool = buildMempool([
|
||||
makeTx(a, 100, 100),
|
||||
makeTx(b, 200, 100, [a]),
|
||||
]);
|
||||
const cm = new ClusterMempool(mempool);
|
||||
|
||||
cm.applyMempoolChange({ added: [], removed: [b], accelerations: {} });
|
||||
|
||||
expect(cm.getClusterCount()).toBe(1);
|
||||
expect(cm.getTxCount()).toBe(1);
|
||||
expect(cm.getClusterInfo(a)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should create 3+ components when removing a tx that bridges multiple subgraphs', () => {
|
||||
const hub = txid('hub');
|
||||
const a = txid('a1');
|
||||
const b = txid('b1');
|
||||
const c = txid('c1');
|
||||
const hubTx = makeTx(hub, 100, 100);
|
||||
hubTx.vout.push(
|
||||
{ scriptpubkey: '', scriptpubkey_asm: '', scriptpubkey_type: 'v0_p2wpkh', value: 50000 },
|
||||
{ scriptpubkey: '', scriptpubkey_asm: '', scriptpubkey_type: 'v0_p2wpkh', value: 50000 },
|
||||
);
|
||||
const txA = makeTx(a, 200, 100, [hub]);
|
||||
txA.vin[0].vout = 0;
|
||||
const txB = makeTx(b, 300, 100, [hub]);
|
||||
txB.vin[0].vout = 1;
|
||||
const txC = makeTx(c, 400, 100, [hub]);
|
||||
txC.vin[0].vout = 2;
|
||||
const mempool = buildMempool([hubTx, txA, txB, txC]);
|
||||
const cm = new ClusterMempool(mempool);
|
||||
expect(cm.getClusterCount()).toBe(1);
|
||||
|
||||
cm.applyMempoolChange({ added: [], removed: [hub], accelerations: {} });
|
||||
|
||||
expect(cm.getClusterCount()).toBe(3);
|
||||
expect(cm.getTxCount()).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fee changes via accelerations', () => {
|
||||
it('should increase chunk feerate when acceleration added', () => {
|
||||
const a = txid('a1');
|
||||
const mempool = buildMempool([makeTx(a, 100, 100)]);
|
||||
const cm = new ClusterMempool(mempool);
|
||||
const infoBefore = cm.getClusterInfo(a);
|
||||
expect(infoBefore).not.toBeNull();
|
||||
|
||||
cm.applyMempoolChange({
|
||||
added: [],
|
||||
removed: [],
|
||||
accelerations: { [a]: { feeDelta: 9900 } },
|
||||
});
|
||||
|
||||
const infoAfter = cm.getClusterInfo(a);
|
||||
expect(infoAfter).not.toBeNull();
|
||||
expect(infoAfter!.chunkFeerate).toBeGreaterThan(infoBefore!.chunkFeerate);
|
||||
});
|
||||
|
||||
it('should decrease chunk feerate when acceleration removed', () => {
|
||||
const a = txid('a1');
|
||||
const mempool = buildMempool([makeTx(a, 100, 100)]);
|
||||
const cm = new ClusterMempool(mempool, { [a]: { feeDelta: 9900 } });
|
||||
const infoBefore = cm.getClusterInfo(a);
|
||||
expect(infoBefore).not.toBeNull();
|
||||
|
||||
cm.applyMempoolChange({
|
||||
added: [],
|
||||
removed: [],
|
||||
accelerations: {},
|
||||
});
|
||||
|
||||
const infoAfter = cm.getClusterInfo(a);
|
||||
expect(infoAfter).not.toBeNull();
|
||||
expect(infoAfter!.chunkFeerate).toBeLessThan(infoBefore!.chunkFeerate);
|
||||
});
|
||||
|
||||
it('should reorder chunks when acceleration shifts priorities', () => {
|
||||
const a = txid('a1');
|
||||
const b = txid('b1');
|
||||
const mempool = buildMempool([
|
||||
makeTx(a, 100, 100),
|
||||
makeTx(b, 200, 100, [a]),
|
||||
]);
|
||||
const cm = new ClusterMempool(mempool);
|
||||
const infoBBefore = cm.getClusterInfo(b);
|
||||
expect(infoBBefore).not.toBeNull();
|
||||
|
||||
cm.applyMempoolChange({
|
||||
added: [],
|
||||
removed: [],
|
||||
accelerations: { [b]: { feeDelta: 49800 } },
|
||||
});
|
||||
|
||||
const infoBAfter = cm.getClusterInfo(b);
|
||||
expect(infoBAfter).not.toBeNull();
|
||||
expect(infoBAfter!.chunkFeerate).toBeGreaterThan(infoBBefore!.chunkFeerate);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBlocks', () => {
|
||||
it('should return projected blocks', () => {
|
||||
const txs: MempoolTransactionExtended[] = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
txs.push(makeTx(txid(`t${i}`), 1000 * (i + 1), 100));
|
||||
}
|
||||
const mempool = buildMempool(txs);
|
||||
const cm = new ClusterMempool(mempool);
|
||||
const blocks = cm.getBlocks(3);
|
||||
expect(blocks.length).toBeGreaterThan(0);
|
||||
expect(blocks[0].txids.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should return empty array for empty mempool', () => {
|
||||
const cm = new ClusterMempool({});
|
||||
const blocks = cm.getBlocks(3);
|
||||
expect(blocks.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should respect chunk ordering for single-cluster mempool', () => {
|
||||
const a = txid('a1');
|
||||
const b = txid('b1');
|
||||
const c = txid('c1');
|
||||
const mempool = buildMempool([
|
||||
makeTx(a, 3000, 100),
|
||||
makeTx(b, 200, 100, [a]),
|
||||
makeTx(c, 100, 100, [b]),
|
||||
]);
|
||||
const cm = new ClusterMempool(mempool);
|
||||
const blocks = cm.getBlocks(1);
|
||||
expect(blocks.length).toBe(1);
|
||||
const txids = blocks[0].txids;
|
||||
expect(txids.indexOf(a)).toBeLessThan(txids.indexOf(b));
|
||||
expect(txids.indexOf(b)).toBeLessThan(txids.indexOf(c));
|
||||
});
|
||||
|
||||
it('should maintain topological validity within blocks', () => {
|
||||
const a = txid('a1');
|
||||
const b = txid('b1');
|
||||
const c = txid('c1');
|
||||
const d = txid('d1');
|
||||
const aTx = makeTx(a, 1000, 100);
|
||||
aTx.vout.push({ scriptpubkey: '', scriptpubkey_asm: '', scriptpubkey_type: 'v0_p2wpkh', value: 50000 });
|
||||
const bTx = makeTx(b, 500, 100, [a]);
|
||||
bTx.vin[0].vout = 0;
|
||||
const cTx = makeTx(c, 500, 100, [a]);
|
||||
cTx.vin[0].vout = 1;
|
||||
const mempool = buildMempool([aTx, bTx, cTx, makeTx(d, 200, 100, [b, c])]);
|
||||
const cm = new ClusterMempool(mempool);
|
||||
const blocks = cm.getBlocks(1);
|
||||
const txids = blocks[0].txids;
|
||||
|
||||
expect(txids.indexOf(a)).toBeLessThan(txids.indexOf(b));
|
||||
expect(txids.indexOf(a)).toBeLessThan(txids.indexOf(c));
|
||||
expect(txids.indexOf(b)).toBeLessThan(txids.indexOf(d));
|
||||
expect(txids.indexOf(c)).toBeLessThan(txids.indexOf(d));
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty and degenerate cases', () => {
|
||||
it('should handle empty diff with no changes', () => {
|
||||
const mempool = buildMempool([makeTx(txid('a1'), 100, 100)]);
|
||||
const cm = new ClusterMempool(mempool);
|
||||
const countBefore = cm.getClusterCount();
|
||||
const txCountBefore = cm.getTxCount();
|
||||
|
||||
cm.applyMempoolChange({ added: [], removed: [], accelerations: {} });
|
||||
|
||||
expect(cm.getClusterCount()).toBe(countBefore);
|
||||
expect(cm.getTxCount()).toBe(txCountBefore);
|
||||
});
|
||||
|
||||
it('should not crash when removing nonexistent tx', () => {
|
||||
const mempool = buildMempool([makeTx(txid('a1'), 100, 100)]);
|
||||
const cm = new ClusterMempool(mempool);
|
||||
|
||||
cm.applyMempoolChange({
|
||||
added: [],
|
||||
removed: [txid('nonexistent')],
|
||||
accelerations: {},
|
||||
});
|
||||
|
||||
expect(cm.getTxCount()).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
494
backend/src/__tests__/cluster-mempool/depgraph.test.ts
Normal file
494
backend/src/__tests__/cluster-mempool/depgraph.test.ts
Normal file
|
|
@ -0,0 +1,494 @@
|
|||
import { DepGraph, sortTopological, subgraph } from '../../cluster-mempool/depgraph';
|
||||
import { buildChain, buildFanOut, buildDiamond, buildStar } from './test-utils';
|
||||
|
||||
describe('DepGraph', () => {
|
||||
describe('addTransaction', () => {
|
||||
it('should add a transaction and return a ClusterTx', () => {
|
||||
const dg = new DepGraph();
|
||||
const tx = dg.addTransaction('tx0', 1000, 100);
|
||||
expect(dg.size).toBe(1);
|
||||
expect(tx.effectiveFee).toBe(1000);
|
||||
expect(tx.weight).toBe(100);
|
||||
expect(tx.txid).toBe('tx0');
|
||||
});
|
||||
|
||||
it('should assign distinct ClusterTx objects', () => {
|
||||
const dg = new DepGraph();
|
||||
const a = dg.addTransaction('a', 100, 10);
|
||||
const b = dg.addTransaction('b', 200, 20);
|
||||
const c = dg.addTransaction('c', 300, 30);
|
||||
expect(a).not.toBe(b);
|
||||
expect(b).not.toBe(c);
|
||||
expect(dg.size).toBe(3);
|
||||
});
|
||||
|
||||
it('should include self in ancestors and descendants', () => {
|
||||
const dg = new DepGraph();
|
||||
const tx = dg.addTransaction('tx0', 100, 10);
|
||||
expect(tx.ancestors.has(tx)).toBe(true);
|
||||
expect(tx.descendants.has(tx)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle large clusters', () => {
|
||||
const dg = new DepGraph();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
dg.addTransaction(`tx${i}`, 100, 10);
|
||||
}
|
||||
expect(dg.size).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addDependency', () => {
|
||||
it('should establish parent-child relationship', () => {
|
||||
const dg = new DepGraph();
|
||||
const parent = dg.addTransaction('parent', 100, 10);
|
||||
const child = dg.addTransaction('child', 200, 20);
|
||||
dg.addDependency(parent, child);
|
||||
|
||||
expect(child.ancestors.has(parent)).toBe(true);
|
||||
expect(parent.descendants.has(child)).toBe(true);
|
||||
expect(child.parents.has(parent)).toBe(true);
|
||||
expect(parent.children.has(child)).toBe(true);
|
||||
});
|
||||
|
||||
it('should propagate ancestors transitively', () => {
|
||||
const dg = new DepGraph();
|
||||
const a = dg.addTransaction('a', 100, 10);
|
||||
const b = dg.addTransaction('b', 200, 20);
|
||||
const c = dg.addTransaction('c', 300, 30);
|
||||
dg.addDependency(a, b);
|
||||
dg.addDependency(b, c);
|
||||
|
||||
expect(c.ancestors.has(a)).toBe(true);
|
||||
expect(c.ancestors.has(b)).toBe(true);
|
||||
expect(c.ancestors.has(c)).toBe(true);
|
||||
|
||||
expect(a.descendants.has(a)).toBe(true);
|
||||
expect(a.descendants.has(b)).toBe(true);
|
||||
expect(a.descendants.has(c)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle diamond dependencies', () => {
|
||||
const dg = new DepGraph();
|
||||
const a = dg.addTransaction('a', 100, 10);
|
||||
const b = dg.addTransaction('b', 200, 20);
|
||||
const c = dg.addTransaction('c', 300, 30);
|
||||
const d = dg.addTransaction('d', 400, 40);
|
||||
dg.addDependency(a, b);
|
||||
dg.addDependency(a, c);
|
||||
dg.addDependency(b, d);
|
||||
dg.addDependency(c, d);
|
||||
|
||||
expect(d.ancestors.size).toBe(4);
|
||||
expect(a.descendants.size).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeTransactions', () => {
|
||||
it('should remove transactions and update sets', () => {
|
||||
const dg = new DepGraph();
|
||||
const a = dg.addTransaction('a', 100, 10);
|
||||
const b = dg.addTransaction('b', 200, 20);
|
||||
const c = dg.addTransaction('c', 300, 30);
|
||||
dg.addDependency(a, b);
|
||||
dg.addDependency(b, c);
|
||||
|
||||
dg.removeTransactions(new Set([b]));
|
||||
expect(dg.size).toBe(2);
|
||||
expect(dg.hasTx(b)).toBe(false);
|
||||
expect(c.ancestors.has(b)).toBe(false);
|
||||
expect(a.descendants.has(b)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle slot reuse after removal', () => {
|
||||
const dg = new DepGraph();
|
||||
const a = dg.addTransaction('a', 100, 10);
|
||||
dg.addTransaction('b', 200, 20);
|
||||
dg.removeTransactions(new Set([a]));
|
||||
const c = dg.addTransaction('c', 300, 30);
|
||||
expect(dg.size).toBe(2);
|
||||
expect(c.txid).toBe('c');
|
||||
});
|
||||
});
|
||||
|
||||
describe('dependsOn (via ancestors)', () => {
|
||||
it('should correctly identify dependencies', () => {
|
||||
const dg = new DepGraph();
|
||||
const a = dg.addTransaction('a', 100, 10);
|
||||
const b = dg.addTransaction('b', 200, 20);
|
||||
const c = dg.addTransaction('c', 300, 30);
|
||||
dg.addDependency(a, b);
|
||||
|
||||
expect(b.ancestors.has(a)).toBe(true);
|
||||
expect(a.ancestors.has(b)).toBe(false);
|
||||
expect(c.ancestors.has(a)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findConnectedComponents', () => {
|
||||
it('should find a single component', () => {
|
||||
const dg = new DepGraph();
|
||||
const a = dg.addTransaction('a', 100, 10);
|
||||
const b = dg.addTransaction('b', 200, 20);
|
||||
dg.addDependency(a, b);
|
||||
|
||||
const components = dg.findConnectedComponents();
|
||||
expect(components.length).toBe(1);
|
||||
expect(components[0].size).toBe(2);
|
||||
});
|
||||
|
||||
it('should find multiple disconnected components', () => {
|
||||
const dg = new DepGraph();
|
||||
const a = dg.addTransaction('a', 100, 10);
|
||||
const b = dg.addTransaction('b', 200, 20);
|
||||
const c = dg.addTransaction('c', 300, 30);
|
||||
const d = dg.addTransaction('d', 400, 40);
|
||||
dg.addDependency(a, b);
|
||||
dg.addDependency(c, d);
|
||||
|
||||
const components = dg.findConnectedComponents();
|
||||
expect(components.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should handle isolated transactions', () => {
|
||||
const dg = new DepGraph();
|
||||
dg.addTransaction('a', 100, 10);
|
||||
dg.addTransaction('b', 200, 20);
|
||||
dg.addTransaction('c', 300, 30);
|
||||
|
||||
const components = dg.findConnectedComponents();
|
||||
expect(components.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parents / children (direct)', () => {
|
||||
it('should return only direct parents, not transitive', () => {
|
||||
const dg = new DepGraph();
|
||||
const a = dg.addTransaction('a', 100, 10);
|
||||
const b = dg.addTransaction('b', 200, 20);
|
||||
const c = dg.addTransaction('c', 300, 30);
|
||||
dg.addDependency(a, b);
|
||||
dg.addDependency(b, c);
|
||||
|
||||
expect(c.parents.has(b)).toBe(true);
|
||||
expect(c.parents.has(a)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return only direct children', () => {
|
||||
const dg = new DepGraph();
|
||||
const a = dg.addTransaction('a', 100, 10);
|
||||
const b = dg.addTransaction('b', 200, 20);
|
||||
const c = dg.addTransaction('c', 300, 30);
|
||||
dg.addDependency(a, b);
|
||||
dg.addDependency(b, c);
|
||||
|
||||
expect(a.children.has(b)).toBe(true);
|
||||
expect(a.children.has(c)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('appendTopo', () => {
|
||||
it('should output in topological order', () => {
|
||||
const dg = new DepGraph();
|
||||
const a = dg.addTransaction('a', 100, 10);
|
||||
const b = dg.addTransaction('b', 200, 20);
|
||||
const c = dg.addTransaction('c', 300, 30);
|
||||
dg.addDependency(a, b);
|
||||
dg.addDependency(b, c);
|
||||
|
||||
const output = sortTopological(new Set([c, a, b]));
|
||||
expect(output.indexOf(a)).toBeLessThan(output.indexOf(b));
|
||||
expect(output.indexOf(b)).toBeLessThan(output.indexOf(c));
|
||||
});
|
||||
});
|
||||
|
||||
describe('restrict', () => {
|
||||
it('should create a subgraph with correct deps', () => {
|
||||
const dg = new DepGraph();
|
||||
const a = dg.addTransaction('a', 100, 10);
|
||||
const b = dg.addTransaction('b', 200, 20);
|
||||
dg.addTransaction('c', 300, 30);
|
||||
dg.addDependency(a, b);
|
||||
|
||||
const { depgraph: sub, txMap } = subgraph(new Set([a, b]));
|
||||
expect(sub.size).toBe(2);
|
||||
const newA = txMap.get(a)!;
|
||||
const newB = txMap.get(b)!;
|
||||
expect(newB.ancestors.has(newA)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('graceful error handling', () => {
|
||||
it('should no-op addDependency with non-member txs', () => {
|
||||
const dg = new DepGraph();
|
||||
const a = dg.addTransaction('a', 100, 10);
|
||||
const dg2 = new DepGraph();
|
||||
const foreign = dg2.addTransaction('foreign', 200, 20);
|
||||
dg.addDependency(a, foreign);
|
||||
dg.addDependency(foreign, a);
|
||||
expect(a.ancestors.size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deep chain topology', () => {
|
||||
it('should track ancestors and descendants at each depth', () => {
|
||||
const { depgraph, txs } = buildChain(20, 100, 10);
|
||||
expect(depgraph.size).toBe(20);
|
||||
|
||||
expect(txs[19].ancestors.size).toBe(20);
|
||||
expect(txs[0].descendants.size).toBe(20);
|
||||
|
||||
expect(txs[10].ancestors.size).toBe(11);
|
||||
expect(txs[10].descendants.size).toBe(10);
|
||||
|
||||
expect(txs[10].parents.size).toBe(1);
|
||||
expect(txs[10].parents.has(txs[9])).toBe(true);
|
||||
expect(txs[10].children.size).toBe(1);
|
||||
expect(txs[10].children.has(txs[11])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('wide fan-out topology', () => {
|
||||
it('should track parent-children relationships for 1 parent with 10 children', () => {
|
||||
const { depgraph, parent, children } = buildFanOut(10, 100, 10, 50, 10);
|
||||
expect(depgraph.size).toBe(11);
|
||||
expect(parent.children.size).toBe(10);
|
||||
expect(parent.descendants.size).toBe(11);
|
||||
|
||||
for (const child of children) {
|
||||
expect(child.ancestors.size).toBe(2);
|
||||
expect(child.ancestors.has(parent)).toBe(true);
|
||||
expect(child.parents.size).toBe(1);
|
||||
expect(child.parents.has(parent)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('wide fan-in topology', () => {
|
||||
it('should track many parents converging to one child', () => {
|
||||
const dg = new DepGraph();
|
||||
const parents: any[] = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
parents.push(dg.addTransaction(`p${i}`, 100, 10));
|
||||
}
|
||||
const child = dg.addTransaction('child', 500, 50);
|
||||
for (const p of parents) {
|
||||
dg.addDependency(p, child);
|
||||
}
|
||||
|
||||
expect(child.parents.size).toBe(10);
|
||||
expect(child.ancestors.size).toBe(11);
|
||||
for (const p of parents) {
|
||||
expect(p.descendants.has(child)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple diamonds in sequence', () => {
|
||||
it('should handle A→B,C→D→E,F→G topology', () => {
|
||||
const dg = new DepGraph();
|
||||
const a = dg.addTransaction('a', 100, 10);
|
||||
const b = dg.addTransaction('b', 100, 10);
|
||||
const c = dg.addTransaction('c', 100, 10);
|
||||
const d = dg.addTransaction('d', 100, 10);
|
||||
const e = dg.addTransaction('e', 100, 10);
|
||||
const f = dg.addTransaction('f', 100, 10);
|
||||
const g = dg.addTransaction('g', 100, 10);
|
||||
dg.addDependency(a, b);
|
||||
dg.addDependency(a, c);
|
||||
dg.addDependency(b, d);
|
||||
dg.addDependency(c, d);
|
||||
dg.addDependency(d, e);
|
||||
dg.addDependency(d, f);
|
||||
dg.addDependency(e, g);
|
||||
dg.addDependency(f, g);
|
||||
|
||||
expect(g.ancestors.size).toBe(7);
|
||||
expect(a.descendants.size).toBe(7);
|
||||
expect(d.parents.size).toBe(2);
|
||||
expect(d.children.size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('disconnected subgraphs', () => {
|
||||
it('should coexist in one DepGraph', () => {
|
||||
const dg = new DepGraph();
|
||||
const a = dg.addTransaction('a', 100, 10);
|
||||
const b = dg.addTransaction('b', 200, 20);
|
||||
dg.addDependency(a, b);
|
||||
|
||||
const c = dg.addTransaction('c', 300, 30);
|
||||
const d = dg.addTransaction('d', 400, 40);
|
||||
dg.addDependency(c, d);
|
||||
|
||||
const e = dg.addTransaction('e', 500, 50);
|
||||
|
||||
expect(dg.size).toBe(5);
|
||||
expect(b.ancestors.has(c)).toBe(false);
|
||||
expect(a.descendants.has(d)).toBe(false);
|
||||
expect(e.ancestors.size).toBe(1);
|
||||
expect(dg.findConnectedComponents().length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeTransactions edge cases', () => {
|
||||
it('should remove a leaf without affecting siblings', () => {
|
||||
const { depgraph, parent, children } = buildFanOut(3, 100, 10, 50, 10);
|
||||
depgraph.removeTransactions(new Set([children[2]]));
|
||||
expect(depgraph.size).toBe(3);
|
||||
expect(parent.children.size).toBe(2);
|
||||
expect(depgraph.hasTx(children[0])).toBe(true);
|
||||
expect(depgraph.hasTx(children[1])).toBe(true);
|
||||
});
|
||||
|
||||
it('should remove a root without affecting unrelated txs', () => {
|
||||
const dg = new DepGraph();
|
||||
const a = dg.addTransaction('a', 100, 10);
|
||||
const b = dg.addTransaction('b', 200, 20);
|
||||
dg.addDependency(a, b);
|
||||
dg.removeTransactions(new Set([a]));
|
||||
expect(dg.size).toBe(1);
|
||||
expect(dg.hasTx(a)).toBe(false);
|
||||
expect(b.ancestors.size).toBe(1);
|
||||
expect(b.ancestors.has(b)).toBe(true);
|
||||
});
|
||||
|
||||
it('should break transitive edges when middle of chain is removed', () => {
|
||||
const { depgraph, txs } = buildChain(5, 100, 10);
|
||||
depgraph.removeTransactions(new Set([txs[2]]));
|
||||
expect(depgraph.size).toBe(4);
|
||||
expect(txs[0].descendants.has(txs[3])).toBe(false);
|
||||
expect(txs[0].descendants.has(txs[1])).toBe(true);
|
||||
expect(txs[3].ancestors.has(txs[0])).toBe(false);
|
||||
expect(txs[3].descendants.has(txs[4])).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle batch removal of multiple txs', () => {
|
||||
const { depgraph, txs } = buildChain(5, 100, 10);
|
||||
depgraph.removeTransactions(new Set([txs[1], txs[3]]));
|
||||
expect(depgraph.size).toBe(3);
|
||||
expect(depgraph.hasTx(txs[0])).toBe(true);
|
||||
expect(depgraph.hasTx(txs[2])).toBe(true);
|
||||
expect(depgraph.hasTx(txs[4])).toBe(true);
|
||||
});
|
||||
|
||||
it('should result in empty graph when all txs removed', () => {
|
||||
const { depgraph, txs } = buildChain(3, 100, 10);
|
||||
depgraph.removeTransactions(new Set(txs));
|
||||
expect(depgraph.size).toBe(0);
|
||||
expect(depgraph.getTxs().size).toBe(0);
|
||||
});
|
||||
|
||||
it('should produce clean state when new tx is added after removal', () => {
|
||||
const dg = new DepGraph();
|
||||
const a = dg.addTransaction('a', 100, 10);
|
||||
const b = dg.addTransaction('b', 200, 20);
|
||||
dg.addDependency(a, b);
|
||||
dg.removeTransactions(new Set([a]));
|
||||
|
||||
const c = dg.addTransaction('c', 300, 30);
|
||||
expect(c.ancestors.size).toBe(1);
|
||||
expect(c.descendants.size).toBe(1);
|
||||
expect(c.ancestors.has(c)).toBe(true);
|
||||
expect(b.ancestors.has(c)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findConnectedComponents after removal', () => {
|
||||
it('should split into 2 components when bridge tx is removed', () => {
|
||||
const dg = new DepGraph();
|
||||
const a = dg.addTransaction('a', 100, 10);
|
||||
const b = dg.addTransaction('b', 200, 20);
|
||||
const c = dg.addTransaction('c', 300, 30);
|
||||
const d = dg.addTransaction('d', 400, 40);
|
||||
dg.addDependency(a, b);
|
||||
dg.addDependency(b, c);
|
||||
dg.addDependency(d, c);
|
||||
|
||||
dg.removeTransactions(new Set([b]));
|
||||
const components = dg.findConnectedComponents();
|
||||
expect(components.length).toBe(2);
|
||||
const sizes = components.map(comp => comp.size).sort((x, y) => x - y);
|
||||
expect(sizes).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('should produce N singletons when center of star is removed', () => {
|
||||
const { depgraph, center, leaves } = buildStar(5, 100, 10, 50, 10);
|
||||
depgraph.removeTransactions(new Set([center]));
|
||||
const components = depgraph.findConnectedComponents();
|
||||
expect(components.length).toBe(5);
|
||||
for (const comp of components) {
|
||||
expect(comp.size).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('should remain 1 component when non-bridge tx is removed', () => {
|
||||
const { depgraph, txs } = buildDiamond([100, 200, 300, 400], [10, 20, 30, 40]);
|
||||
depgraph.removeTransactions(new Set([txs[1]]));
|
||||
const components = depgraph.findConnectedComponents();
|
||||
expect(components.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('restrict edge cases', () => {
|
||||
it('should restrict to a single tx', () => {
|
||||
const { depgraph, txs } = buildChain(3, 100, 10);
|
||||
const { depgraph: sub } = subgraph(new Set([txs[1]]));
|
||||
expect(sub.size).toBe(1);
|
||||
});
|
||||
|
||||
it('should preserve edges within partial chain subset', () => {
|
||||
const { depgraph, txs } = buildChain(5, 100, 10);
|
||||
const subset = new Set([txs[0], txs[1], txs[2]]);
|
||||
const { depgraph: sub, txMap } = subgraph(subset);
|
||||
expect(sub.size).toBe(3);
|
||||
|
||||
const newA = txMap.get(txs[0]);
|
||||
const newB = txMap.get(txs[1]);
|
||||
const newC = txMap.get(txs[2]);
|
||||
if (!newA || !newB || !newC) {
|
||||
throw new Error('txMap missing entries');
|
||||
}
|
||||
expect(newB.ancestors.has(newA)).toBe(true);
|
||||
expect(newC.ancestors.has(newB)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortTopological edge cases', () => {
|
||||
it('should handle subset with no internal dependencies', () => {
|
||||
const dg = new DepGraph();
|
||||
const a = dg.addTransaction('a', 100, 10);
|
||||
const b = dg.addTransaction('b', 200, 20);
|
||||
const c = dg.addTransaction('c', 300, 30);
|
||||
|
||||
const output = sortTopological(new Set([a, b, c]));
|
||||
expect(output.length).toBe(3);
|
||||
expect(new Set(output).size).toBe(3);
|
||||
});
|
||||
|
||||
it('should handle single-tx subset', () => {
|
||||
const { txs } = buildChain(3, 100, 10);
|
||||
const output = sortTopological(new Set([txs[1]]));
|
||||
expect(output).toEqual([txs[1]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addDependency edge cases', () => {
|
||||
it('should be idempotent when adding the same dependency twice', () => {
|
||||
const dg = new DepGraph();
|
||||
const a = dg.addTransaction('a', 100, 10);
|
||||
const b = dg.addTransaction('b', 200, 20);
|
||||
dg.addDependency(a, b);
|
||||
dg.addDependency(a, b);
|
||||
expect(b.ancestors.size).toBe(2);
|
||||
expect(a.descendants.size).toBe(2);
|
||||
});
|
||||
|
||||
it('should handle redundant edge when parent is already transitive ancestor', () => {
|
||||
const { depgraph, txs } = buildChain(3, 100, 10);
|
||||
depgraph.addDependency(txs[0], txs[2]);
|
||||
expect(txs[2].ancestors.size).toBe(3);
|
||||
expect(txs[2].parents.size).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
485
backend/src/__tests__/cluster-mempool/linearize.test.ts
Normal file
485
backend/src/__tests__/cluster-mempool/linearize.test.ts
Normal file
|
|
@ -0,0 +1,485 @@
|
|||
import { DepGraph } from '../../cluster-mempool/depgraph';
|
||||
import { chunkify, postLinearize, spanningForestLinearize, linearizeCluster } from '../../cluster-mempool/linearize';
|
||||
import { buildChain, buildFanOut, buildStar, verifyLinearization, verifyTopologicalOrder } from './test-utils';
|
||||
|
||||
describe('chunkify', () => {
|
||||
it('should create one chunk per tx when feerates are decreasing', () => {
|
||||
const dg = new DepGraph();
|
||||
const a = dg.addTransaction('a', 300, 10);
|
||||
const b = dg.addTransaction('b', 200, 10);
|
||||
const c = dg.addTransaction('c', 100, 10);
|
||||
|
||||
const chunks = chunkify([a, b, c]);
|
||||
expect(chunks.length).toBe(3);
|
||||
expect(chunks[0].txs).toEqual([a]);
|
||||
expect(chunks[1].txs).toEqual([b]);
|
||||
expect(chunks[2].txs).toEqual([c]);
|
||||
});
|
||||
|
||||
it('should merge all into one chunk when feerates are increasing', () => {
|
||||
const dg = new DepGraph();
|
||||
const a = dg.addTransaction('a', 100, 10);
|
||||
const b = dg.addTransaction('b', 200, 10);
|
||||
const c = dg.addTransaction('c', 300, 10);
|
||||
|
||||
const chunks = chunkify([a, b, c]);
|
||||
expect(chunks.length).toBe(1);
|
||||
expect(chunks[0].txs).toEqual([a, b, c]);
|
||||
expect(chunks[0].fee).toBe(600);
|
||||
expect(chunks[0].weight).toBe(30);
|
||||
});
|
||||
|
||||
it('should NOT merge equal feerates (matching Core behavior)', () => {
|
||||
const dg = new DepGraph();
|
||||
const a = dg.addTransaction('a', 100, 10);
|
||||
const b = dg.addTransaction('b', 100, 10);
|
||||
|
||||
const chunks = chunkify([a, b]);
|
||||
expect(chunks.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should handle single transaction', () => {
|
||||
const dg = new DepGraph();
|
||||
const a = dg.addTransaction('a', 500, 50);
|
||||
|
||||
const chunks = chunkify([a]);
|
||||
expect(chunks.length).toBe(1);
|
||||
expect(chunks[0].fee).toBe(500);
|
||||
expect(chunks[0].weight).toBe(50);
|
||||
});
|
||||
|
||||
it('should handle empty linearization', () => {
|
||||
const chunks = chunkify([]);
|
||||
expect(chunks.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should produce decreasing chunk feerates', () => {
|
||||
const dg = new DepGraph();
|
||||
const a = dg.addTransaction('a', 100, 10);
|
||||
const b = dg.addTransaction('b', 50, 10);
|
||||
const c = dg.addTransaction('c', 200, 10);
|
||||
const d = dg.addTransaction('d', 30, 10);
|
||||
|
||||
const chunks = chunkify([a, c, b, d]);
|
||||
for (let i = 1; i < chunks.length; i++) {
|
||||
const prevRate = chunks[i - 1].fee / chunks[i - 1].weight;
|
||||
const curRate = chunks[i].fee / chunks[i].weight;
|
||||
expect(prevRate).toBeGreaterThanOrEqual(curRate);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('postLinearize', () => {
|
||||
it('should improve a bad linearization', () => {
|
||||
const dg = new DepGraph();
|
||||
const a = dg.addTransaction('a', 100, 100);
|
||||
const b = dg.addTransaction('b', 500, 100);
|
||||
|
||||
const result = postLinearize([a, b]);
|
||||
expect(result[0]).toBe(b);
|
||||
expect(result[1]).toBe(a);
|
||||
});
|
||||
|
||||
it('should not violate dependencies', () => {
|
||||
const dg = new DepGraph();
|
||||
const parent = dg.addTransaction('parent', 100, 100);
|
||||
const child = dg.addTransaction('child', 500, 100);
|
||||
dg.addDependency(parent, child);
|
||||
|
||||
const result = postLinearize([parent, child]);
|
||||
expect(result.indexOf(parent)).toBeLessThan(result.indexOf(child));
|
||||
});
|
||||
|
||||
it('should handle already-optimal ordering', () => {
|
||||
const dg = new DepGraph();
|
||||
const a = dg.addTransaction('a', 500, 100);
|
||||
const b = dg.addTransaction('b', 100, 100);
|
||||
|
||||
const result = postLinearize([a, b]);
|
||||
expect(result).toEqual([a, b]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('spanningForestLinearize', () => {
|
||||
it('should sort independent transactions by feerate', () => {
|
||||
const dg = new DepGraph();
|
||||
const a = dg.addTransaction('a', 100, 100);
|
||||
const b = dg.addTransaction('b', 500, 100);
|
||||
const c = dg.addTransaction('c', 300, 100);
|
||||
|
||||
const result = spanningForestLinearize(dg.getTxs(), 75000);
|
||||
expect(result[0]).toBe(b);
|
||||
expect(result[1]).toBe(c);
|
||||
expect(result[2]).toBe(a);
|
||||
});
|
||||
|
||||
it('should respect dependencies', () => {
|
||||
const dg = new DepGraph();
|
||||
const parent = dg.addTransaction('parent', 100, 100);
|
||||
const child = dg.addTransaction('child', 500, 100);
|
||||
dg.addDependency(parent, child);
|
||||
|
||||
const result = spanningForestLinearize(dg.getTxs(), 75000);
|
||||
expect(result.indexOf(parent)).toBeLessThan(result.indexOf(child));
|
||||
});
|
||||
|
||||
it('should handle CPFP pattern', () => {
|
||||
const dg = new DepGraph();
|
||||
const a = dg.addTransaction('a', 100, 100);
|
||||
const b = dg.addTransaction('b', 10000, 100);
|
||||
dg.addDependency(a, b);
|
||||
|
||||
const { chunks } = linearizeCluster(dg.getTxs(), 75000);
|
||||
expect(chunks.length).toBe(1);
|
||||
expect(chunks[0].txs.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should separate high and low feerate independent txs', () => {
|
||||
const dg = new DepGraph();
|
||||
const a = dg.addTransaction('a', 1000, 100);
|
||||
const b = dg.addTransaction('b', 100, 100);
|
||||
|
||||
const { chunks } = linearizeCluster(dg.getTxs(), 75000);
|
||||
expect(chunks.length).toBe(2);
|
||||
expect(chunks[0].txs).toContain(a);
|
||||
expect(chunks[1].txs).toContain(b);
|
||||
});
|
||||
|
||||
it('should handle single transaction', () => {
|
||||
const dg = new DepGraph();
|
||||
const a = dg.addTransaction('a', 1000, 100);
|
||||
|
||||
const result = spanningForestLinearize(dg.getTxs(), 75000);
|
||||
expect(result).toEqual([a]);
|
||||
});
|
||||
|
||||
it('should handle empty graph', () => {
|
||||
const dg = new DepGraph();
|
||||
const result = spanningForestLinearize(dg.getTxs(), 75000);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('minimize', () => {
|
||||
it('should keep equal-feerate chain as individual chunks', () => {
|
||||
const dg = new DepGraph();
|
||||
const txs: any[] = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
txs.push(dg.addTransaction(`tx${i}`, 19, 140));
|
||||
}
|
||||
for (let i = 1; i < 5; i++) {
|
||||
dg.addDependency(txs[i - 1], txs[i]);
|
||||
}
|
||||
|
||||
const { chunks } = linearizeCluster(dg.getTxs(), 75000);
|
||||
expect(chunks.length).toBe(5);
|
||||
for (const chunk of chunks) {
|
||||
expect(chunk.txs.length).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('should merge chain where child has strictly higher feerate', () => {
|
||||
const dg = new DepGraph();
|
||||
const parent = dg.addTransaction('parent', 100, 200);
|
||||
const child = dg.addTransaction('child', 900, 100);
|
||||
dg.addDependency(parent, child);
|
||||
|
||||
const { chunks } = linearizeCluster(dg.getTxs(), 75000);
|
||||
expect(chunks.length).toBe(1);
|
||||
expect(chunks[0].txs.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should split parent-child with equal feerate', () => {
|
||||
const dg = new DepGraph();
|
||||
const parent = dg.addTransaction('parent', 1720, 344);
|
||||
const child = dg.addTransaction('child', 1240, 248);
|
||||
dg.addDependency(parent, child);
|
||||
|
||||
const { chunks } = linearizeCluster(dg.getTxs(), 75000);
|
||||
expect(chunks.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should split disconnected equal-feerate components', () => {
|
||||
const dg = new DepGraph();
|
||||
dg.addTransaction('a', 100, 100);
|
||||
dg.addTransaction('b', 100, 100);
|
||||
|
||||
const { chunks } = linearizeCluster(dg.getTxs(), 75000);
|
||||
expect(chunks.length).toBe(2);
|
||||
expect(chunks[0].txs.length).toBe(1);
|
||||
expect(chunks[1].txs.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('chunkify edge cases', () => {
|
||||
it('should produce N separate chunks when all feerates are equal', () => {
|
||||
const dg = new DepGraph();
|
||||
const txs: any[] = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
txs.push(dg.addTransaction(`tx${i}`, 100, 10));
|
||||
}
|
||||
const chunks = chunkify(txs);
|
||||
expect(chunks.length).toBe(5);
|
||||
});
|
||||
|
||||
it('should handle alternating high/low feerates', () => {
|
||||
const dg = new DepGraph();
|
||||
const a = dg.addTransaction('a', 1000, 10);
|
||||
const b = dg.addTransaction('b', 100, 10);
|
||||
const c = dg.addTransaction('c', 1000, 10);
|
||||
const d = dg.addTransaction('d', 100, 10);
|
||||
|
||||
const chunks = chunkify([a, b, c, d]);
|
||||
for (let i = 1; i < chunks.length; i++) {
|
||||
const prevRate = chunks[i - 1].fee * chunks[i].weight;
|
||||
const curRate = chunks[i].fee * chunks[i - 1].weight;
|
||||
expect(prevRate).toBeGreaterThanOrEqual(curRate);
|
||||
}
|
||||
});
|
||||
|
||||
it('should merge all when single very high feerate tx is at the end', () => {
|
||||
const dg = new DepGraph();
|
||||
const a = dg.addTransaction('a', 10, 10);
|
||||
const b = dg.addTransaction('b', 10, 10);
|
||||
const c = dg.addTransaction('c', 10, 10);
|
||||
const d = dg.addTransaction('d', 10000, 10);
|
||||
|
||||
const chunks = chunkify([a, b, c, d]);
|
||||
expect(chunks.length).toBe(1);
|
||||
expect(chunks[0].txs.length).toBe(4);
|
||||
});
|
||||
|
||||
it('should maintain non-increasing feerates for 50+ tx linearization', () => {
|
||||
const dg = new DepGraph();
|
||||
const txs: any[] = [];
|
||||
for (let i = 0; i < 50; i++) {
|
||||
txs.push(dg.addTransaction(`tx${i}`, 5000 - i * 100, 100));
|
||||
}
|
||||
const chunks = chunkify(txs);
|
||||
for (let i = 1; i < chunks.length; i++) {
|
||||
const prevRate = chunks[i - 1].fee * chunks[i].weight;
|
||||
const curRate = chunks[i].fee * chunks[i - 1].weight;
|
||||
expect(prevRate).toBeGreaterThanOrEqual(curRate);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle zero-fee transaction', () => {
|
||||
const dg = new DepGraph();
|
||||
const a = dg.addTransaction('a', 1000, 100);
|
||||
const b = dg.addTransaction('b', 0, 100);
|
||||
|
||||
const chunks = chunkify([a, b]);
|
||||
expect(chunks.length).toBe(2);
|
||||
expect(chunks[1].fee).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('postLinearize edge cases', () => {
|
||||
it('should sort three independent txs by feerate', () => {
|
||||
const dg = new DepGraph();
|
||||
const a = dg.addTransaction('a', 100, 100);
|
||||
const b = dg.addTransaction('b', 500, 100);
|
||||
const c = dg.addTransaction('c', 300, 100);
|
||||
|
||||
const result = postLinearize([a, c, b]);
|
||||
expect(result[0]).toBe(b);
|
||||
expect(result[2]).toBe(a);
|
||||
});
|
||||
|
||||
it('should respect parent-child dependency even when child has higher feerate', () => {
|
||||
const dg = new DepGraph();
|
||||
const parent = dg.addTransaction('parent', 100, 100);
|
||||
const child = dg.addTransaction('child', 1000, 100);
|
||||
dg.addDependency(parent, child);
|
||||
|
||||
const result = postLinearize([parent, child]);
|
||||
expect(result[0]).toBe(parent);
|
||||
expect(result[1]).toBe(child);
|
||||
});
|
||||
|
||||
it('should handle chain A→B→C with CPFP-like feerates', () => {
|
||||
const dg = new DepGraph();
|
||||
const a = dg.addTransaction('a', 100, 100);
|
||||
const b = dg.addTransaction('b', 200, 100);
|
||||
const c = dg.addTransaction('c', 10000, 100);
|
||||
dg.addDependency(a, b);
|
||||
dg.addDependency(b, c);
|
||||
|
||||
const result = postLinearize([a, b, c]);
|
||||
verifyTopologicalOrder(result);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SFL adversarial topologies', () => {
|
||||
it('should handle comb pattern: one root with many children at different feerates', () => {
|
||||
const dg = new DepGraph();
|
||||
const root = dg.addTransaction('root', 100, 100);
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const child = dg.addTransaction(`child${i}`, (i + 1) * 500, 100);
|
||||
dg.addDependency(root, child);
|
||||
}
|
||||
|
||||
const { linearization, chunks } = linearizeCluster(dg.getTxs(), 75000);
|
||||
verifyLinearization(dg.getTxs(), linearization, chunks);
|
||||
});
|
||||
|
||||
it('should handle inverted tree: many leaves → intermediates → root', () => {
|
||||
const dg = new DepGraph();
|
||||
const root = dg.addTransaction('root', 100, 100);
|
||||
const mid1 = dg.addTransaction('mid1', 200, 100);
|
||||
const mid2 = dg.addTransaction('mid2', 300, 100);
|
||||
dg.addDependency(root, mid1);
|
||||
dg.addDependency(root, mid2);
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const leaf = dg.addTransaction(`leaf${i}`, 5000, 100);
|
||||
dg.addDependency(i < 2 ? mid1 : mid2, leaf);
|
||||
}
|
||||
|
||||
const { linearization, chunks } = linearizeCluster(dg.getTxs(), 75000);
|
||||
verifyLinearization(dg.getTxs(), linearization, chunks);
|
||||
});
|
||||
|
||||
it('should handle two parallel chains with shared root', () => {
|
||||
const dg = new DepGraph();
|
||||
const root = dg.addTransaction('root', 100, 100);
|
||||
|
||||
let prev1: any = root;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const tx = dg.addTransaction(`chain1_${i}`, 200, 100);
|
||||
dg.addDependency(prev1, tx);
|
||||
prev1 = tx;
|
||||
}
|
||||
|
||||
let prev2: any = root;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const tx = dg.addTransaction(`chain2_${i}`, 300, 100);
|
||||
dg.addDependency(prev2, tx);
|
||||
prev2 = tx;
|
||||
}
|
||||
|
||||
const { linearization, chunks } = linearizeCluster(dg.getTxs(), 75000);
|
||||
verifyLinearization(dg.getTxs(), linearization, chunks);
|
||||
});
|
||||
|
||||
it('should handle deep CPFP: low-fee chain with high-fee tip', () => {
|
||||
const { depgraph, txs } = buildChain(6, 10, 100);
|
||||
txs[5].effectiveFee = 50000;
|
||||
|
||||
const { linearization, chunks } = linearizeCluster(depgraph.getTxs(), 75000);
|
||||
verifyLinearization(depgraph.getTxs(), linearization, chunks);
|
||||
expect(chunks[0].txs.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('should find better result than ancestor-feerate for overlapping high-feerate subsets', () => {
|
||||
const dg = new DepGraph();
|
||||
const a = dg.addTransaction('a', 100, 100);
|
||||
const b = dg.addTransaction('b', 100, 100);
|
||||
const c = dg.addTransaction('c', 10000, 100);
|
||||
dg.addDependency(a, c);
|
||||
dg.addDependency(b, c);
|
||||
|
||||
const { linearization, chunks } = linearizeCluster(dg.getTxs(), 75000 );
|
||||
verifyLinearization(dg.getTxs(), linearization, chunks);
|
||||
const firstChunkFee = chunks[0].fee;
|
||||
const firstChunkSize = chunks[0].weight;
|
||||
expect(firstChunkFee / firstChunkSize).toBeGreaterThan(100 / 100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('linearizeCluster', () => {
|
||||
it('should produce valid topological linearization', () => {
|
||||
const dg = new DepGraph();
|
||||
const a = dg.addTransaction('a', 100, 10);
|
||||
const b = dg.addTransaction('b', 200, 20);
|
||||
const c = dg.addTransaction('c', 300, 30);
|
||||
dg.addDependency(a, b);
|
||||
dg.addDependency(b, c);
|
||||
|
||||
const { linearization } = linearizeCluster(dg.getTxs(), 75000);
|
||||
expect(linearization.indexOf(a)).toBeLessThan(linearization.indexOf(b));
|
||||
expect(linearization.indexOf(b)).toBeLessThan(linearization.indexOf(c));
|
||||
});
|
||||
|
||||
it('should produce monotonically decreasing chunk feerates', () => {
|
||||
const dg = new DepGraph();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
dg.addTransaction(`tx${i}`, Math.floor(Math.random() * 10000) + 100, Math.floor(Math.random() * 500) + 50);
|
||||
}
|
||||
|
||||
const { chunks } = linearizeCluster(dg.getTxs(), 75000);
|
||||
for (let i = 1; i < chunks.length; i++) {
|
||||
const prevRate = chunks[i - 1].fee * chunks[i].weight;
|
||||
const curRate = chunks[i].fee * chunks[i - 1].weight;
|
||||
expect(prevRate).toBeGreaterThanOrEqual(curRate);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle complex diamond dependency graph', () => {
|
||||
const dg = new DepGraph();
|
||||
const a = dg.addTransaction('a', 1000, 100);
|
||||
const b = dg.addTransaction('b', 500, 100);
|
||||
const c = dg.addTransaction('c', 100, 100);
|
||||
const d = dg.addTransaction('d', 300, 100);
|
||||
|
||||
dg.addDependency(a, b);
|
||||
dg.addDependency(a, c);
|
||||
dg.addDependency(b, d);
|
||||
dg.addDependency(c, d);
|
||||
|
||||
const { linearization, chunks } = linearizeCluster(dg.getTxs(), 75000);
|
||||
verifyLinearization(dg.getTxs(), linearization, chunks);
|
||||
});
|
||||
|
||||
it('should produce at-least-as-good result when given suboptimal hint', () => {
|
||||
const dg = new DepGraph();
|
||||
const a = dg.addTransaction('a', 500, 100);
|
||||
const b = dg.addTransaction('b', 100, 100);
|
||||
const c = dg.addTransaction('c', 1000, 100);
|
||||
|
||||
const suboptimal = [b, a, c];
|
||||
const { chunks: hintChunks } = linearizeCluster(dg.getTxs(), 75000, suboptimal);
|
||||
const { chunks: freshChunks } = linearizeCluster(dg.getTxs(), 75000);
|
||||
|
||||
const hintFirstFeerate = hintChunks[0].fee * freshChunks[0].weight;
|
||||
const freshFirstFeerate = freshChunks[0].fee * hintChunks[0].weight;
|
||||
expect(hintFirstFeerate).toBeGreaterThanOrEqual(freshFirstFeerate - 1);
|
||||
});
|
||||
|
||||
it('should preserve an already-optimal linearization', () => {
|
||||
const dg = new DepGraph();
|
||||
const a = dg.addTransaction('a', 1000, 100);
|
||||
const b = dg.addTransaction('b', 500, 100);
|
||||
const c = dg.addTransaction('c', 100, 100);
|
||||
|
||||
const optimal = [a, b, c];
|
||||
const { linearization } = linearizeCluster(dg.getTxs(), 75000, optimal);
|
||||
expect(linearization).toEqual(optimal);
|
||||
});
|
||||
|
||||
it('should produce valid linearizations on repeated calls', () => {
|
||||
const dg = new DepGraph();
|
||||
const a = dg.addTransaction('a', 100, 100);
|
||||
const b = dg.addTransaction('b', 100, 100);
|
||||
const c = dg.addTransaction('c', 100, 100);
|
||||
dg.addDependency(a, c);
|
||||
dg.addDependency(b, c);
|
||||
|
||||
const result1 = linearizeCluster(dg.getTxs(), 75000);
|
||||
const result2 = linearizeCluster(dg.getTxs(), 75000);
|
||||
verifyLinearization(dg.getTxs(), result1.linearization, result1.chunks);
|
||||
verifyLinearization(dg.getTxs(), result2.linearization, result2.chunks);
|
||||
});
|
||||
|
||||
it('should pass invariant checks for fan-out topology', () => {
|
||||
const { depgraph } = buildFanOut(6, 100, 100, 500, 100);
|
||||
const { linearization, chunks } = linearizeCluster(depgraph.getTxs(), 75000);
|
||||
verifyLinearization(depgraph.getTxs(), linearization, chunks);
|
||||
});
|
||||
|
||||
it('should pass invariant checks for star topology', () => {
|
||||
const { depgraph } = buildStar(5, 100, 100, 300, 100);
|
||||
const { linearization, chunks } = linearizeCluster(depgraph.getTxs(), 75000);
|
||||
verifyLinearization(depgraph.getTxs(), linearization, chunks);
|
||||
});
|
||||
});
|
||||
175
backend/src/__tests__/cluster-mempool/test-utils.ts
Normal file
175
backend/src/__tests__/cluster-mempool/test-utils.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import { MempoolTransactionExtended } from '../../mempool.interfaces';
|
||||
import { ClusterTx, DepGraph } from '../../cluster-mempool/depgraph';
|
||||
import { LinearizationChunk } from '../../cluster-mempool/linearize';
|
||||
|
||||
export function makeTx(
|
||||
txid: string,
|
||||
fee: number,
|
||||
vsize: number,
|
||||
parentTxids: string[] = [],
|
||||
): MempoolTransactionExtended {
|
||||
const vin = parentTxids.length > 0
|
||||
? parentTxids.map(ptxid => ({
|
||||
txid: ptxid,
|
||||
vout: 0,
|
||||
is_coinbase: false,
|
||||
scriptsig: '',
|
||||
scriptsig_asm: '',
|
||||
inner_redeemscript_asm: '',
|
||||
inner_witnessscript_asm: '',
|
||||
sequence: 0,
|
||||
witness: [] as string[],
|
||||
prevout: null,
|
||||
}))
|
||||
: [{
|
||||
txid: '0000000000000000000000000000000000000000000000000000000000000000',
|
||||
vout: 0,
|
||||
is_coinbase: false,
|
||||
scriptsig: '',
|
||||
scriptsig_asm: '',
|
||||
inner_redeemscript_asm: '',
|
||||
inner_witnessscript_asm: '',
|
||||
sequence: 0,
|
||||
witness: [] as string[],
|
||||
prevout: null,
|
||||
}];
|
||||
|
||||
return {
|
||||
txid,
|
||||
version: 2,
|
||||
locktime: 0,
|
||||
size: vsize,
|
||||
weight: vsize * 4,
|
||||
fee,
|
||||
vin,
|
||||
vout: [{
|
||||
scriptpubkey: '',
|
||||
scriptpubkey_asm: '',
|
||||
scriptpubkey_type: 'v0_p2wpkh',
|
||||
value: 50000,
|
||||
}],
|
||||
status: { confirmed: false },
|
||||
vsize,
|
||||
feePerVsize: fee / vsize,
|
||||
effectiveFeePerVsize: fee / vsize,
|
||||
order: 0,
|
||||
sigops: 0,
|
||||
adjustedVsize: vsize,
|
||||
adjustedFeePerVsize: fee / vsize,
|
||||
} as MempoolTransactionExtended;
|
||||
}
|
||||
|
||||
export function txid(short: string): string {
|
||||
return short.padStart(64, '0');
|
||||
}
|
||||
|
||||
export function buildChain(
|
||||
n: number,
|
||||
baseFee: number,
|
||||
baseSize: number,
|
||||
): { depgraph: DepGraph; txs: ClusterTx[] } {
|
||||
const depgraph = new DepGraph();
|
||||
const txs: ClusterTx[] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
txs.push(depgraph.addTransaction(`chain_${i}`, baseFee, baseSize));
|
||||
}
|
||||
for (let i = 1; i < n; i++) {
|
||||
depgraph.addDependency(txs[i - 1], txs[i]);
|
||||
}
|
||||
return { depgraph, txs };
|
||||
}
|
||||
|
||||
export function buildFanOut(
|
||||
nChildren: number,
|
||||
parentFee: number,
|
||||
parentSize: number,
|
||||
childFee: number,
|
||||
childSize: number,
|
||||
): { depgraph: DepGraph; parent: ClusterTx; children: ClusterTx[] } {
|
||||
const depgraph = new DepGraph();
|
||||
const parent = depgraph.addTransaction('fanout_parent', parentFee, parentSize);
|
||||
const children: ClusterTx[] = [];
|
||||
for (let i = 0; i < nChildren; i++) {
|
||||
const child = depgraph.addTransaction(`fanout_child_${i}`, childFee, childSize);
|
||||
depgraph.addDependency(parent, child);
|
||||
children.push(child);
|
||||
}
|
||||
return { depgraph, parent, children };
|
||||
}
|
||||
|
||||
export function buildDiamond(
|
||||
fees: [number, number, number, number],
|
||||
sizes: [number, number, number, number],
|
||||
): { depgraph: DepGraph; txs: [ClusterTx, ClusterTx, ClusterTx, ClusterTx] } {
|
||||
const depgraph = new DepGraph();
|
||||
const a = depgraph.addTransaction('diamond_a', fees[0], sizes[0]);
|
||||
const b = depgraph.addTransaction('diamond_b', fees[1], sizes[1]);
|
||||
const c = depgraph.addTransaction('diamond_c', fees[2], sizes[2]);
|
||||
const d = depgraph.addTransaction('diamond_d', fees[3], sizes[3]);
|
||||
depgraph.addDependency(a, b);
|
||||
depgraph.addDependency(a, c);
|
||||
depgraph.addDependency(b, d);
|
||||
depgraph.addDependency(c, d);
|
||||
return { depgraph, txs: [a, b, c, d] };
|
||||
}
|
||||
|
||||
export function buildStar(
|
||||
nLeaves: number,
|
||||
centerFee: number,
|
||||
centerSize: number,
|
||||
leafFee: number,
|
||||
leafSize: number,
|
||||
): { depgraph: DepGraph; center: ClusterTx; leaves: ClusterTx[] } {
|
||||
const depgraph = new DepGraph();
|
||||
const center = depgraph.addTransaction('star_center', centerFee, centerSize);
|
||||
const leaves: ClusterTx[] = [];
|
||||
for (let i = 0; i < nLeaves; i++) {
|
||||
const leaf = depgraph.addTransaction(`star_leaf_${i}`, leafFee, leafSize);
|
||||
depgraph.addDependency(center, leaf);
|
||||
leaves.push(leaf);
|
||||
}
|
||||
return { depgraph, center, leaves };
|
||||
}
|
||||
|
||||
export function verifyTopologicalOrder(ordering: ClusterTx[]): void {
|
||||
const positionMap = new Map<ClusterTx, number>();
|
||||
for (let i = 0; i < ordering.length; i++) {
|
||||
positionMap.set(ordering[i], i);
|
||||
}
|
||||
for (const tx of ordering) {
|
||||
for (const parent of tx.parents) {
|
||||
const parentPos = positionMap.get(parent);
|
||||
const childPos = positionMap.get(tx);
|
||||
if (parentPos !== undefined && childPos !== undefined) {
|
||||
expect(parentPos).toBeLessThan(childPos);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function verifyLinearization(
|
||||
txs: Set<ClusterTx>,
|
||||
linearization: ClusterTx[],
|
||||
chunks: LinearizationChunk[],
|
||||
): void {
|
||||
expect(linearization.length).toBe(txs.size);
|
||||
const linSet = new Set(linearization);
|
||||
expect(linSet.size).toBe(linearization.length);
|
||||
for (const tx of txs) {
|
||||
expect(linSet.has(tx)).toBe(true);
|
||||
}
|
||||
|
||||
verifyTopologicalOrder(linearization);
|
||||
|
||||
for (let i = 1; i < chunks.length; i++) {
|
||||
const prevRate = chunks[i - 1].fee * chunks[i].weight;
|
||||
const curRate = chunks[i].fee * chunks[i - 1].weight;
|
||||
expect(prevRate).toBeGreaterThanOrEqual(curRate);
|
||||
}
|
||||
|
||||
const chunkTxs = chunks.flatMap(c => c.txs);
|
||||
expect(chunkTxs.length).toBe(linearization.length);
|
||||
for (let i = 0; i < chunkTxs.length; i++) {
|
||||
expect(chunkTxs[i]).toBe(linearization[i]);
|
||||
}
|
||||
}
|
||||
|
|
@ -45,6 +45,8 @@ describe('Mempool Backend Config', () => {
|
|||
AUDIT: false,
|
||||
RUST_GBT: true,
|
||||
LIMIT_GBT: false,
|
||||
CLUSTER_MEMPOOL: false,
|
||||
CLUSTER_MEMPOOL_INDEXING: false,
|
||||
CPFP_INDEXING: false,
|
||||
MAX_BLOCKS_BULK_QUERY: 0,
|
||||
DISK_CACHE_BLOCK_INTERVAL: 6,
|
||||
|
|
|
|||
|
|
@ -6,11 +6,28 @@ import transactionUtils from './transaction-utils';
|
|||
|
||||
const PROPAGATION_MARGIN = 180; // in seconds, time since a transaction is first seen after which it is assumed to have propagated to all miners
|
||||
|
||||
export interface AuditResult {
|
||||
unseen: string[];
|
||||
censored: string[];
|
||||
added: string[];
|
||||
prioritized: string[];
|
||||
fresh: string[];
|
||||
sigop: string[];
|
||||
fullrbf: string[];
|
||||
accelerated: string[];
|
||||
matchRate: number;
|
||||
similarity: number;
|
||||
}
|
||||
|
||||
class Audit {
|
||||
auditBlock(height: number, transactions: MempoolTransactionExtended[], projectedBlocks: MempoolBlockWithTransactions[], mempool: { [txId: string]: MempoolTransactionExtended })
|
||||
: { unseen: string[], censored: string[], added: string[], prioritized: string[], fresh: string[], sigop: string[], fullrbf: string[], accelerated: string[], score: number, similarity: number } {
|
||||
auditBlock(
|
||||
height: number,
|
||||
transactions: MempoolTransactionExtended[],
|
||||
projectedBlocks: MempoolBlockWithTransactions[],
|
||||
mempool: { [txId: string]: MempoolTransactionExtended }
|
||||
): AuditResult {
|
||||
if (!projectedBlocks?.[0]?.transactionIds || !mempool) {
|
||||
return { unseen: [], censored: [], added: [], prioritized: [], fresh: [], sigop: [], fullrbf: [], accelerated: [], score: 1, similarity: 1 };
|
||||
return { unseen: [], censored: [], added: [], prioritized: [], fresh: [], sigop: [], fullrbf: [], accelerated: [], matchRate: 100, similarity: 1 };
|
||||
}
|
||||
|
||||
const matches: string[] = []; // present in both mined block and template
|
||||
|
|
@ -176,6 +193,8 @@ class Audit {
|
|||
}
|
||||
const similarity = projectedWeight ? matchedWeight / projectedWeight : 1;
|
||||
|
||||
const matchRate = Math.round(score * 100 * 100) / 100;
|
||||
|
||||
return {
|
||||
unseen,
|
||||
censored: Object.keys(isCensored),
|
||||
|
|
@ -185,7 +204,7 @@ class Audit {
|
|||
sigop: [],
|
||||
fullrbf: rbf,
|
||||
accelerated,
|
||||
score,
|
||||
matchRate,
|
||||
similarity,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import backendInfo from '../backend-info';
|
|||
import transactionUtils from '../transaction-utils';
|
||||
import { IEsploraApi } from './esplora-api.interface';
|
||||
import loadingIndicators from '../loading-indicators';
|
||||
import { TransactionExtended } from '../../mempool.interfaces';
|
||||
import { CpfpInfo, TransactionExtended } from '../../mempool.interfaces';
|
||||
import logger from '../../logger';
|
||||
import blocks from '../blocks';
|
||||
import bitcoinClient from './bitcoin-client';
|
||||
|
|
@ -191,11 +191,11 @@ class BitcoinRoutes {
|
|||
const tx = mempool.getMempool()[req.params.txId];
|
||||
if (tx) {
|
||||
if (tx?.cpfpChecked) {
|
||||
res.json({
|
||||
ancestors: tx.ancestors,
|
||||
const response: CpfpInfo & { acceleratedBy?: number[], acceleratedAt?: number, feeDelta?: number } = {
|
||||
ancestors: tx.ancestors || [],
|
||||
bestDescendant: tx.bestDescendant || null,
|
||||
descendants: tx.descendants || null,
|
||||
effectiveFeePerVsize: tx.effectiveFeePerVsize || null,
|
||||
descendants: tx.descendants,
|
||||
effectiveFeePerVsize: tx.effectiveFeePerVsize,
|
||||
sigops: tx.sigops,
|
||||
fee: tx.fee,
|
||||
adjustedVsize: tx.adjustedVsize,
|
||||
|
|
@ -203,7 +203,14 @@ class BitcoinRoutes {
|
|||
acceleratedBy: tx.acceleratedBy || undefined,
|
||||
acceleratedAt: tx.acceleratedAt || undefined,
|
||||
feeDelta: tx.feeDelta || undefined,
|
||||
});
|
||||
};
|
||||
if (config.MEMPOOL.CLUSTER_MEMPOOL && tx.clusterId != null) {
|
||||
const cluster = mempool.clusterMempool?.getClusterForApi(req.params.txId);
|
||||
if (cluster) {
|
||||
response.cluster = cluster;
|
||||
}
|
||||
}
|
||||
res.json(response);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
262
backend/src/api/block-processor.ts
Normal file
262
backend/src/api/block-processor.ts
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
import config from '../config';
|
||||
import logger from '../logger';
|
||||
import {
|
||||
BlockExtended,
|
||||
BlockSummary,
|
||||
PoolTag,
|
||||
MempoolTransactionExtended,
|
||||
CpfpSummary,
|
||||
TemplateAlgorithm,
|
||||
MempoolBlockWithTransactions,
|
||||
} from '../mempool.interfaces';
|
||||
import { IEsploraApi } from './bitcoin/esplora-api.interface';
|
||||
import { Acceleration } from './services/acceleration';
|
||||
import { calculateGoodBlockCpfp, calculateClusterMempoolBlockCpfp, calculateFastBlockCpfp, BlockCpfpData } from './cpfp';
|
||||
import mempoolBlocks from './mempool-blocks';
|
||||
import memPool from './mempool';
|
||||
import Audit, { AuditResult } from './audit';
|
||||
import blocks from './blocks';
|
||||
import transactionUtils from './transaction-utils';
|
||||
import { ClusterMempool } from '../cluster-mempool/cluster-mempool';
|
||||
import { Common } from './common';
|
||||
import accelerationApi from './services/acceleration';
|
||||
|
||||
interface ProcessedAudit extends AuditResult {
|
||||
expectedFees: number;
|
||||
expectedWeight: number;
|
||||
projectedBlocks: MempoolBlockWithTransactions[];
|
||||
}
|
||||
|
||||
export interface BlockProcessingResult {
|
||||
templateAlgorithm: TemplateAlgorithm;
|
||||
cpfpSummary: CpfpSummary;
|
||||
blockExtended: BlockExtended;
|
||||
blockSummary: BlockSummary;
|
||||
auditResult?: ProcessedAudit;
|
||||
}
|
||||
|
||||
const CM_ACTIVATION_HEIGHT: { [network: string]: number } = {
|
||||
'mainnet': 940000,
|
||||
'testnet': 4860000,
|
||||
'testnet4': 125000,
|
||||
'signet': 294000,
|
||||
'regtest': 0,
|
||||
};
|
||||
|
||||
class BlockProcessor {
|
||||
|
||||
/** @asyncUnsafe */
|
||||
public async $processNewBlock(
|
||||
block: IEsploraApi.Block,
|
||||
transactions: MempoolTransactionExtended[],
|
||||
pool: PoolTag,
|
||||
accelerations: Record<string, Acceleration>
|
||||
): Promise<BlockProcessingResult> {
|
||||
const poolAccelerations = Object.values(accelerations)
|
||||
.filter(a => a.pools.includes(pool.uniqueId))
|
||||
.map(a => ({ txid: a.txid, max_bid: a.feeDelta }));
|
||||
|
||||
const { templateAlgorithm, cpfpSummary } = detectTemplateAlgorithm(
|
||||
block.height,
|
||||
transactions,
|
||||
poolAccelerations
|
||||
);
|
||||
|
||||
|
||||
const blockExtended = await blocks.$getBlockExtended(block, cpfpSummary.transactions, pool);
|
||||
const blockSummary = blocks.summarizeBlockTransactions(block.id, block.height, cpfpSummary.transactions);
|
||||
|
||||
let auditResult: ProcessedAudit | undefined;
|
||||
if (config.MEMPOOL.AUDIT && memPool.isInSync()) {
|
||||
auditResult = await this.$runAudit(
|
||||
blockExtended,
|
||||
transactions,
|
||||
templateAlgorithm,
|
||||
pool,
|
||||
accelerations
|
||||
);
|
||||
|
||||
if (blockExtended.extras) {
|
||||
blockExtended.extras.matchRate = auditResult.matchRate;
|
||||
blockExtended.extras.expectedFees = auditResult.expectedFees;
|
||||
blockExtended.extras.expectedWeight = auditResult.expectedWeight;
|
||||
blockExtended.extras.similarity = auditResult.similarity;
|
||||
}
|
||||
} else if (blockExtended.extras) {
|
||||
const mBlocks = mempoolBlocks.getMempoolBlocksWithTransactions();
|
||||
if (mBlocks?.length && mBlocks[0].transactions) {
|
||||
blockExtended.extras.similarity = Common.getSimilarity(mBlocks[0], transactions);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
templateAlgorithm,
|
||||
cpfpSummary,
|
||||
blockExtended,
|
||||
blockSummary,
|
||||
auditResult,
|
||||
};
|
||||
}
|
||||
|
||||
private async $runAudit(
|
||||
block: BlockExtended,
|
||||
transactions: MempoolTransactionExtended[],
|
||||
templateAlgorithm: TemplateAlgorithm,
|
||||
pool: PoolTag,
|
||||
accelerations: Record<string, Acceleration>
|
||||
): Promise<ProcessedAudit> {
|
||||
const auditMempool = memPool.getMempool();
|
||||
const isAccelerated = accelerationApi.isAcceleratedBlock(block, Object.values(accelerations));
|
||||
|
||||
const candidateTxs = memPool.getMempoolCandidates();
|
||||
const candidates = (memPool.limitGBT && candidateTxs)
|
||||
? { txs: candidateTxs, added: [], removed: [] }
|
||||
: undefined;
|
||||
const transactionIds = (memPool.limitGBT)
|
||||
? Object.keys(candidates?.txs || {})
|
||||
: Object.keys(auditMempool);
|
||||
|
||||
let projectedBlocks: MempoolBlockWithTransactions[];
|
||||
|
||||
if (templateAlgorithm === TemplateAlgorithm.clusterMempool) {
|
||||
const clusterMempool = memPool.clusterMempool ?? new ClusterMempool(auditMempool, accelerations, true, 75000);
|
||||
const cmBlocks = clusterMempool.getBlocks(config.MEMPOOL.MEMPOOL_BLOCKS_AMOUNT) ?? [];
|
||||
projectedBlocks = mempoolBlocks.processClusterMempoolBlocks(
|
||||
cmBlocks,
|
||||
auditMempool,
|
||||
accelerations,
|
||||
false,
|
||||
pool.uniqueId
|
||||
);
|
||||
} else if (config.MEMPOOL.RUST_GBT) {
|
||||
const added = memPool.limitGBT ? (candidates?.added || []) : [];
|
||||
const removed = memPool.limitGBT ? (candidates?.removed || []) : [];
|
||||
projectedBlocks = await mempoolBlocks.$rustUpdateBlockTemplates(
|
||||
transactionIds,
|
||||
auditMempool,
|
||||
added,
|
||||
removed,
|
||||
candidates,
|
||||
isAccelerated,
|
||||
pool.uniqueId,
|
||||
true
|
||||
);
|
||||
} else {
|
||||
projectedBlocks = await mempoolBlocks.$makeBlockTemplates(
|
||||
transactionIds,
|
||||
auditMempool,
|
||||
candidates,
|
||||
false,
|
||||
isAccelerated,
|
||||
pool.uniqueId
|
||||
);
|
||||
}
|
||||
|
||||
const auditResult = Audit.auditBlock(block.height, transactions, projectedBlocks, auditMempool);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
return {
|
||||
...auditResult,
|
||||
expectedFees: totalFees,
|
||||
expectedWeight: totalWeight,
|
||||
projectedBlocks,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function saveCpfpDataToTransactions(transactions: MempoolTransactionExtended[], cpfpData: BlockCpfpData): void {
|
||||
for (const tx of transactions) {
|
||||
if (cpfpData.txs[tx.txid]) {
|
||||
Object.assign(tx, cpfpData.txs[tx.txid]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function saveCpfpDataToCpfpSummary(transactions: MempoolTransactionExtended[], cpfpData: BlockCpfpData): CpfpSummary {
|
||||
saveCpfpDataToTransactions(transactions, cpfpData);
|
||||
return {
|
||||
transactions,
|
||||
clusters: cpfpData.clusters,
|
||||
version: cpfpData.version,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param height
|
||||
* @param blockTransactions
|
||||
* @param poolAccelerations
|
||||
* @param fast
|
||||
*
|
||||
* saves effective fee rates from detected algorithm to blockTransactions
|
||||
*/
|
||||
export function detectTemplateAlgorithm(
|
||||
height: number,
|
||||
blockTransactions: MempoolTransactionExtended[],
|
||||
poolAccelerations: { txid: string; max_bid: number }[],
|
||||
fast: boolean = false
|
||||
): { templateAlgorithm: TemplateAlgorithm; cpfpSummary: CpfpSummary } {
|
||||
|
||||
const legacyCpfpData = fast ? calculateFastBlockCpfp(
|
||||
height,
|
||||
blockTransactions,
|
||||
) : calculateGoodBlockCpfp(
|
||||
height,
|
||||
blockTransactions,
|
||||
poolAccelerations
|
||||
);
|
||||
|
||||
if (!config.MEMPOOL.CLUSTER_MEMPOOL_INDEXING) {
|
||||
return {
|
||||
templateAlgorithm: TemplateAlgorithm.legacy,
|
||||
cpfpSummary: saveCpfpDataToCpfpSummary(blockTransactions, legacyCpfpData),
|
||||
};
|
||||
}
|
||||
|
||||
const network = config.MEMPOOL.NETWORK || 'mainnet';
|
||||
const activationHeight = CM_ACTIVATION_HEIGHT[network] ?? Infinity;
|
||||
|
||||
if (height < activationHeight) {
|
||||
return {
|
||||
templateAlgorithm: TemplateAlgorithm.legacy,
|
||||
cpfpSummary: saveCpfpDataToCpfpSummary(blockTransactions, legacyCpfpData),
|
||||
};
|
||||
}
|
||||
|
||||
const clusterCpfpData = calculateClusterMempoolBlockCpfp(
|
||||
height,
|
||||
blockTransactions,
|
||||
poolAccelerations
|
||||
);
|
||||
|
||||
const clusterTxs = blockTransactions.map(tx => ({ txid: tx.txid, rate: clusterCpfpData.txs[tx.txid].effectiveFeePerVsize ?? tx.effectiveFeePerVsize }));
|
||||
const legacyTxs = blockTransactions.map(tx => ({ txid: tx.txid, rate: legacyCpfpData.txs[tx.txid].effectiveFeePerVsize ?? tx.effectiveFeePerVsize }));
|
||||
const clusterPrioritization = transactionUtils.identifyPrioritizedTransactions(clusterTxs, 'rate');
|
||||
const legacyPrioritization = transactionUtils.identifyPrioritizedTransactions(legacyTxs, 'rate');
|
||||
|
||||
const clusterCount = clusterPrioritization.prioritized.length + clusterPrioritization.deprioritized.length;
|
||||
const legacyCount = legacyPrioritization.prioritized.length + legacyPrioritization.deprioritized.length;
|
||||
|
||||
if (clusterCount < legacyCount) {
|
||||
saveCpfpDataToTransactions(blockTransactions, clusterCpfpData);
|
||||
return {
|
||||
templateAlgorithm: TemplateAlgorithm.clusterMempool,
|
||||
cpfpSummary: saveCpfpDataToCpfpSummary(blockTransactions, clusterCpfpData),
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
templateAlgorithm: TemplateAlgorithm.legacy,
|
||||
cpfpSummary: saveCpfpDataToCpfpSummary(blockTransactions, legacyCpfpData),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default new BlockProcessor();
|
||||
|
|
@ -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, TransactionMinerInfo, CpfpSummary, MempoolTransactionExtended, TransactionClassified, BlockAudit, TransactionAudit } from '../mempool.interfaces';
|
||||
import { BlockExtended, BlockExtension, BlockSummary, PoolTag, TransactionExtended, TransactionMinerInfo, CpfpSummary, MempoolTransactionExtended, TransactionClassified, BlockAudit, TransactionAudit, TemplateAlgorithm } from '../mempool.interfaces';
|
||||
import { Common } from './common';
|
||||
import diskCache from './disk-cache';
|
||||
import transactionUtils from './transaction-utils';
|
||||
|
|
@ -28,9 +28,13 @@ import chainTips from './chain-tips';
|
|||
import websocketHandler from './websocket-handler';
|
||||
import redisCache from './redis-cache';
|
||||
import rbfCache from './rbf-cache';
|
||||
import bitcoinSecondClient from './bitcoin/bitcoin-second-client';
|
||||
import mempoolBlocks from './mempool-blocks';
|
||||
import statistics from './statistics/statistics';
|
||||
import { calcBitsDifference } from './difficulty-adjustment';
|
||||
import AccelerationRepository from '../repositories/AccelerationRepository';
|
||||
import { calculateFastBlockCpfp, calculateGoodBlockCpfp } from './cpfp';
|
||||
import { calculateGoodBlockCpfp } from './cpfp';
|
||||
import blockProcessor, { BlockProcessingResult, detectTemplateAlgorithm, saveCpfpDataToCpfpSummary } from './block-processor';
|
||||
import mempool from './mempool';
|
||||
import CpfpRepository from '../repositories/CpfpRepository';
|
||||
import { parseDATUMTemplateCreator } from '../utils/bitcoin-script';
|
||||
|
|
@ -46,7 +50,6 @@ class Blocks {
|
|||
private previousDifficultyRetarget = 0;
|
||||
private quarterEpochBlockTime: number | null = null;
|
||||
private newBlockCallbacks: ((block: BlockExtended, txIds: string[], transactions: TransactionExtended[]) => void)[] = [];
|
||||
private newAsyncBlockCallbacks: ((block: BlockExtended, txIds: string[], transactions: MempoolTransactionExtended[]) => Promise<void>)[] = [];
|
||||
private classifyingBlocks: boolean = false;
|
||||
private oldestCoreLogTimestamp: number | undefined | null = undefined;
|
||||
|
||||
|
|
@ -74,10 +77,6 @@ class Blocks {
|
|||
this.newBlockCallbacks.push(fn);
|
||||
}
|
||||
|
||||
public setNewAsyncBlockCallback(fn: (block: BlockExtended, txIds: string[], transactions: MempoolTransactionExtended[]) => Promise<void>) {
|
||||
this.newAsyncBlockCallbacks.push(fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the list of transaction for a block
|
||||
* @param blockHash
|
||||
|
|
@ -252,7 +251,7 @@ class Blocks {
|
|||
*
|
||||
* @asyncUnsafe
|
||||
*/
|
||||
private async $getBlockExtended(block: IEsploraApi.Block, transactions: TransactionExtended[]): Promise<BlockExtended> {
|
||||
public async $getBlockExtended(block: IEsploraApi.Block, transactions: TransactionExtended[], providedPool?: PoolTag): Promise<BlockExtended> {
|
||||
const coinbaseTx = transactionUtils.stripCoinbaseTransaction(transactions[0]);
|
||||
|
||||
const blk: Partial<BlockExtended> = Object.assign({}, block);
|
||||
|
|
@ -335,7 +334,9 @@ class Blocks {
|
|||
|
||||
if (['mainnet', 'testnet', 'signet', 'testnet4', 'regtest'].includes(config.MEMPOOL.NETWORK)) {
|
||||
let pool: PoolTag;
|
||||
if (coinbaseTx !== undefined) {
|
||||
if (providedPool) {
|
||||
pool = providedPool;
|
||||
} else if (coinbaseTx !== undefined) {
|
||||
pool = await this.$findBlockMiner(coinbaseTx);
|
||||
} else {
|
||||
if (config.DATABASE.ENABLED === true) {
|
||||
|
|
@ -386,7 +387,7 @@ class Blocks {
|
|||
return <BlockExtended>blk;
|
||||
}
|
||||
|
||||
private async $getBlockStats(block: IEsploraApi.Block, transactions: TransactionExtended[]): Promise<IBitcoinApi.BlockStats> {
|
||||
public async $getBlockStats(block: IEsploraApi.Block, transactions: TransactionExtended[]): Promise<IBitcoinApi.BlockStats> {
|
||||
if (!block.stale) {
|
||||
return bitcoinClient.getBlockStats(block.id);
|
||||
}
|
||||
|
|
@ -496,6 +497,128 @@ class Blocks {
|
|||
}
|
||||
}
|
||||
|
||||
/** @asyncUnsafe */
|
||||
private async $applyBlockTransactionsToMempool(
|
||||
txIds: string[],
|
||||
transactions: MempoolTransactionExtended[]
|
||||
): Promise<{ rbfTransactions: { [txid: string]: { replaced: MempoolTransactionExtended[], replacedBy: TransactionExtended }}}> {
|
||||
const _memPool = memPool.getMempool();
|
||||
|
||||
const rbfTransactions = Common.findMinedRbfTransactions(transactions, memPool.getSpendMap());
|
||||
memPool.handleRbfTransactions(rbfTransactions);
|
||||
memPool.removeFromSpendMap(transactions);
|
||||
|
||||
if (config.MEMPOOL.CLUSTER_MEMPOOL) {
|
||||
memPool.clusterMempool?.applyMempoolChange({
|
||||
added: [],
|
||||
removed: txIds,
|
||||
accelerations: mempool.getAccelerations(),
|
||||
});
|
||||
}
|
||||
|
||||
for (const txId of txIds) {
|
||||
delete _memPool[txId];
|
||||
rbfCache.mined(txId);
|
||||
}
|
||||
|
||||
let candidates;
|
||||
let transactionIds: string[];
|
||||
|
||||
if (memPool.limitGBT) {
|
||||
const minFeeMempool = await bitcoinSecondClient.getRawMemPool();
|
||||
const minFeeTip = await bitcoinSecondClient.getBlockCount();
|
||||
candidates = memPool.getNextCandidates(minFeeMempool, minFeeTip, transactions);
|
||||
transactionIds = Object.keys(candidates?.txs || {});
|
||||
} else {
|
||||
candidates = undefined;
|
||||
transactionIds = Object.keys(memPool.getMempool());
|
||||
}
|
||||
|
||||
if (config.MEMPOOL.CLUSTER_MEMPOOL) {
|
||||
const cmBlocks = mempool.clusterMempool?.getBlocks(config.MEMPOOL.MEMPOOL_BLOCKS_AMOUNT) ?? [];
|
||||
mempoolBlocks.processClusterMempoolBlocks(cmBlocks, _memPool, mempool.getAccelerations());
|
||||
} else if (config.MEMPOOL.RUST_GBT) {
|
||||
const added = memPool.limitGBT ? (candidates?.added || []) : [];
|
||||
const removed = memPool.limitGBT ? (candidates?.removed || []) : transactions;
|
||||
await mempoolBlocks.$rustUpdateBlockTemplates(transactionIds, _memPool, added, removed, candidates, true);
|
||||
} else {
|
||||
await mempoolBlocks.$makeBlockTemplates(transactionIds, _memPool, candidates, true, true);
|
||||
}
|
||||
|
||||
return { rbfTransactions };
|
||||
}
|
||||
|
||||
/** @asyncUnsafe */
|
||||
private async $saveBlockData(
|
||||
processingResult: BlockProcessingResult,
|
||||
timer: number
|
||||
): Promise<void> {
|
||||
const blockExtended = processingResult.blockExtended;
|
||||
const cpfpSummary = processingResult.cpfpSummary;
|
||||
|
||||
let latestPriceId;
|
||||
try {
|
||||
latestPriceId = await PricesRepository.$getLatestPriceId();
|
||||
this.updateTimerProgress(timer, `got latest price id ${this.currentBlockHeight}`);
|
||||
} catch (e) {
|
||||
logger.debug('failed to fetch latest price id from db: ' + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
if (priceUpdater.historyInserted === true && latestPriceId !== null) {
|
||||
await blocksRepository.$saveBlockPrices([{
|
||||
height: blockExtended.height,
|
||||
priceId: latestPriceId,
|
||||
}]);
|
||||
this.updateTimerProgress(timer, `saved prices for ${this.currentBlockHeight}`);
|
||||
} else {
|
||||
logger.debug(`Cannot save block price for ${blockExtended.height} because the price updater hasnt completed yet. Trying again in 10 seconds.`, logger.tags.mining);
|
||||
indexer.scheduleSingleTask('blocksPrices', 10000);
|
||||
}
|
||||
|
||||
if (Common.blocksSummariesIndexingEnabled() === true) {
|
||||
// indexes the summary as a side effect
|
||||
await this.$getStrippedBlockTransactions(blockExtended.id, true, false, cpfpSummary, blockExtended.height);
|
||||
this.updateTimerProgress(timer, `saved block summary for ${this.currentBlockHeight}`);
|
||||
}
|
||||
|
||||
if (config.MEMPOOL.CPFP_INDEXING) {
|
||||
// can be slow, and isn't critical, so don't await
|
||||
void this.$saveCpfp(blockExtended.id, this.currentBlockHeight, cpfpSummary);
|
||||
this.updateTimerProgress(timer, `saved cpfp for ${this.currentBlockHeight}`);
|
||||
}
|
||||
|
||||
if (processingResult.auditResult) {
|
||||
void BlocksSummariesRepository.$saveTemplate({
|
||||
height: blockExtended.height,
|
||||
template: {
|
||||
id: blockExtended.id,
|
||||
transactions: processingResult.auditResult.projectedBlocks[0].transactions,
|
||||
},
|
||||
version: 1,
|
||||
});
|
||||
this.updateTimerProgress(timer, `saved audit template for ${this.currentBlockHeight}`);
|
||||
|
||||
void BlocksAuditsRepository.$saveAudit({
|
||||
version: 1,
|
||||
templateAlgorithm: processingResult.templateAlgorithm,
|
||||
time: blockExtended.timestamp,
|
||||
height: blockExtended.height,
|
||||
hash: blockExtended.id,
|
||||
unseenTxs: processingResult.auditResult.unseen,
|
||||
addedTxs: processingResult.auditResult.added,
|
||||
prioritizedTxs: processingResult.auditResult.prioritized,
|
||||
missingTxs: processingResult.auditResult.censored,
|
||||
freshTxs: processingResult.auditResult.fresh,
|
||||
sigopTxs: processingResult.auditResult.sigop,
|
||||
fullrbfTxs: processingResult.auditResult.fullrbf,
|
||||
acceleratedTxs: processingResult.auditResult.accelerated,
|
||||
matchRate: processingResult.auditResult.matchRate,
|
||||
expectedFees: processingResult.auditResult.expectedFees,
|
||||
expectedWeight: processingResult.auditResult.expectedWeight,
|
||||
});
|
||||
this.updateTimerProgress(timer, `saved audit results for ${this.currentBlockHeight}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [INDEXING] Index all blocks summaries for the block txs visualization
|
||||
*/
|
||||
|
|
@ -728,7 +851,8 @@ class Blocks {
|
|||
// fetch transactions
|
||||
txs = (await bitcoinApi.$getTxsForBlock(blockHash, true)).map(tx => transactionUtils.extendMempoolTransaction(tx)) || [];
|
||||
// add CPFP
|
||||
const cpfpSummary = calculateGoodBlockCpfp(height, txs, []);
|
||||
const blockCpfpData = calculateGoodBlockCpfp(height, txs, []);
|
||||
const cpfpSummary = saveCpfpDataToCpfpSummary(txs, blockCpfpData);
|
||||
// classify
|
||||
const { transactions: classifiedTxs } = this.summarizeBlockTransactions(blockHash, height, cpfpSummary.transactions);
|
||||
await BlocksSummariesRepository.$saveTransactions(height, blockHash, classifiedTxs, 2);
|
||||
|
|
@ -765,7 +889,8 @@ class Blocks {
|
|||
}
|
||||
templateTxs.push(tx || templateTx);
|
||||
}
|
||||
const cpfpSummary = calculateGoodBlockCpfp(height, templateTxs?.filter(tx => tx['effectiveFeePerVsize'] != null) as MempoolTransactionExtended[], []);
|
||||
const blockCpfpData = calculateGoodBlockCpfp(height, templateTxs?.filter(tx => tx['effectiveFeePerVsize'] != null) as MempoolTransactionExtended[], []);
|
||||
const cpfpSummary = saveCpfpDataToCpfpSummary(templateTxs as MempoolTransactionExtended[], blockCpfpData);
|
||||
// classify
|
||||
const { transactions: classifiedTxs } = this.summarizeBlockTransactions(blockHash, height, cpfpSummary.transactions);
|
||||
const classifiedTxMap: { [txid: string]: TransactionClassified } = {};
|
||||
|
|
@ -1054,59 +1179,55 @@ class Blocks {
|
|||
}
|
||||
}
|
||||
|
||||
let accelerations = Object.values(mempool.getAccelerations());
|
||||
if (accelerations?.length > 0) {
|
||||
const pool = await this.$findBlockMiner(transactionUtils.stripCoinbaseTransaction(transactions[0]));
|
||||
accelerations = accelerations.filter(a => a.pools.includes(pool.uniqueId));
|
||||
}
|
||||
const cpfpSummary: CpfpSummary = calculateGoodBlockCpfp(block.height, transactions, accelerations.map(a => ({ txid: a.txid, max_bid: a.feeDelta })));
|
||||
const blockExtended: BlockExtended = await this.$getBlockExtended(block, cpfpSummary.transactions);
|
||||
const blockSummary: BlockSummary = this.summarizeBlockTransactions(block.id, block.height, cpfpSummary.transactions);
|
||||
const pool = await this.$findBlockMiner(transactionUtils.stripCoinbaseTransaction(transactions[0]));
|
||||
const accelerations = mempool.getAccelerations();
|
||||
|
||||
const processingResult = await blockProcessor.$processNewBlock(
|
||||
block,
|
||||
transactions,
|
||||
pool,
|
||||
accelerations
|
||||
);
|
||||
|
||||
const blockExtended = processingResult.blockExtended;
|
||||
const blockSummary = processingResult.blockSummary;
|
||||
const cpfpSummary = processingResult.cpfpSummary;
|
||||
this.updateTimerProgress(timer, `got block data for ${this.currentBlockHeight}`);
|
||||
|
||||
if (Common.indexingEnabled()) {
|
||||
if (!fastForwarded) {
|
||||
await this.$handleReorgs(blockExtended, timer);
|
||||
}
|
||||
if (config.STATISTICS.ENABLED && config.DATABASE.ENABLED) {
|
||||
await statistics.runStatistics();
|
||||
}
|
||||
|
||||
const { rbfTransactions } = await this.$applyBlockTransactionsToMempool(txIds, cpfpSummary.transactions);
|
||||
this.updateTimerProgress(timer, `applied mempool changes for ${this.currentBlockHeight}`);
|
||||
|
||||
if (config.STATISTICS.ENABLED && config.DATABASE.ENABLED) {
|
||||
await statistics.runStatistics();
|
||||
}
|
||||
|
||||
if (Common.indexingEnabled() && !fastForwarded) {
|
||||
await this.$handleReorgs(blockExtended, timer);
|
||||
}
|
||||
|
||||
await websocketHandler.handleNewBlock(blockExtended, txIds, cpfpSummary.transactions, rbfTransactions);
|
||||
this.updateTimerProgress(timer, `sent websocket updates for ${this.currentBlockHeight}`);
|
||||
|
||||
if (Common.indexingEnabled()) {
|
||||
await blocksRepository.$saveBlockInDatabase(blockExtended);
|
||||
this.updateTimerProgress(timer, `saved ${this.currentBlockHeight} to database`);
|
||||
|
||||
if (!fastForwarded) {
|
||||
let lastestPriceId;
|
||||
try {
|
||||
lastestPriceId = await PricesRepository.$getLatestPriceId();
|
||||
this.updateTimerProgress(timer, `got latest price id ${this.currentBlockHeight}`);
|
||||
} catch (e) {
|
||||
logger.debug('failed to fetch latest price id from db: ' + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
if (priceUpdater.historyInserted === true && lastestPriceId !== null) {
|
||||
await blocksRepository.$saveBlockPrices([{
|
||||
height: blockExtended.height,
|
||||
priceId: lastestPriceId,
|
||||
}]);
|
||||
this.updateTimerProgress(timer, `saved prices for ${this.currentBlockHeight}`);
|
||||
} else {
|
||||
logger.debug(`Cannot save block price for ${blockExtended.height} because the price updater hasnt completed yet. Trying again in 10 seconds.`, logger.tags.mining);
|
||||
indexer.scheduleSingleTask('blocksPrices', 10000);
|
||||
}
|
||||
await AccelerationRepository.$indexAccelerationsForBlock(
|
||||
blockExtended,
|
||||
Object.values(accelerations),
|
||||
cpfpSummary.transactions
|
||||
);
|
||||
this.updateTimerProgress(timer, `indexed accelerations for ${this.currentBlockHeight}`);
|
||||
|
||||
// Save blocks summary for visualization if it's enabled
|
||||
if (Common.blocksSummariesIndexingEnabled() === 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) {
|
||||
void this.$saveCpfp(blockExtended.id, this.currentBlockHeight, cpfpSummary);
|
||||
this.updateTimerProgress(timer, `saved cpfp for ${this.currentBlockHeight}`);
|
||||
}
|
||||
if (!fastForwarded) {
|
||||
await this.$saveBlockData(processingResult, timer);
|
||||
}
|
||||
}
|
||||
|
||||
// start async callbacks
|
||||
this.updateTimerProgress(timer, `starting async callbacks for ${this.currentBlockHeight}`);
|
||||
const callbackPromises = this.newAsyncBlockCallbacks.map((cb) => cb(blockExtended, txIds, cpfpSummary.transactions));
|
||||
|
||||
if (block.height % 2016 === 0) {
|
||||
if (Common.indexingEnabled()) {
|
||||
let adjustment;
|
||||
|
|
@ -1144,11 +1265,6 @@ class Blocks {
|
|||
await chainTips.updateOrphanedBlocks();
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
|
@ -1648,16 +1764,16 @@ class Blocks {
|
|||
}
|
||||
|
||||
if (transactions?.length != null) {
|
||||
const summary = calculateFastBlockCpfp(height, transactions);
|
||||
const { cpfpSummary } = await detectTemplateAlgorithm(height, transactions, [], true);
|
||||
|
||||
if (!stale) {
|
||||
await this.$saveCpfp(hash, height, summary);
|
||||
await this.$saveCpfp(hash, height, cpfpSummary);
|
||||
}
|
||||
|
||||
const effectiveFeeStats = Common.calcEffectiveFeeStatistics(summary.transactions);
|
||||
const effectiveFeeStats = Common.calcEffectiveFeeStatistics(cpfpSummary.transactions);
|
||||
await blocksRepository.$saveEffectiveFeeStats(hash, effectiveFeeStats);
|
||||
|
||||
return summary;
|
||||
return cpfpSummary;
|
||||
} else {
|
||||
logger.err(`Cannot index CPFP for block ${height} - missing transaction data`);
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -1,20 +1,30 @@
|
|||
import { Ancestor, CpfpCluster, CpfpInfo, CpfpSummary, MempoolTransactionExtended, TransactionExtended } from '../mempool.interfaces';
|
||||
import { Ancestor, CpfpCluster, CpfpInfo, MempoolTransactionExtended, TemplateAlgorithm, TransactionExtended } from '../mempool.interfaces';
|
||||
import { GraphTx, convertToGraphTx, expandRelativesGraph, initializeRelatives, makeBlockTemplate, mempoolComparator, removeAncestors, setAncestorScores } from './mini-miner';
|
||||
import memPool from './mempool';
|
||||
import { Acceleration } from './acceleration/acceleration';
|
||||
import { ClusterMempool } from '../cluster-mempool/cluster-mempool';
|
||||
|
||||
const CPFP_UPDATE_INTERVAL = 60_000; // update CPFP info at most once per 60s per transaction
|
||||
const MAX_CLUSTER_ITERATIONS = 100;
|
||||
|
||||
export function calculateFastBlockCpfp(height: number, transactions: MempoolTransactionExtended[], saveRelatives: boolean = false): CpfpSummary {
|
||||
type TransactionCpfpData = Partial<CpfpInfo & { cpfpDirty?: boolean, clusterId?: number, chunkIndex?: number }>;
|
||||
export interface BlockCpfpData {
|
||||
txs: Record<string, TransactionCpfpData>,
|
||||
clusters: CpfpCluster[];
|
||||
version: number;
|
||||
}
|
||||
|
||||
export function calculateFastBlockCpfp(height: number, transactions: MempoolTransactionExtended[], saveRelatives: boolean = false): BlockCpfpData {
|
||||
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: { [txid: string]: TransactionExtended } = {};
|
||||
const cpfpData: Record<string, TransactionCpfpData> = {};
|
||||
// initialize the txMap
|
||||
for (const tx of transactions) {
|
||||
txMap[tx.txid] = tx;
|
||||
cpfpData[tx.txid] = {};
|
||||
}
|
||||
// reverse pass to identify CPFP clusters
|
||||
for (let i = transactions.length - 1; i >= 0; i--) {
|
||||
|
|
@ -38,7 +48,9 @@ export function calculateFastBlockCpfp(height: number, transactions: MempoolTran
|
|||
clusters.push(cluster);
|
||||
}
|
||||
clusterTxs.forEach(tx => {
|
||||
txMap[tx.txid].effectiveFeePerVsize = effectiveFeePerVsize;
|
||||
cpfpData[tx.txid] = {
|
||||
effectiveFeePerVsize
|
||||
};
|
||||
if (cluster) {
|
||||
clusterMap[tx.txid] = cluster;
|
||||
}
|
||||
|
|
@ -54,17 +66,19 @@ export function calculateFastBlockCpfp(height: number, transactions: MempoolTran
|
|||
}
|
||||
// forward pass to enforce ancestor rate caps
|
||||
for (const tx of transactions) {
|
||||
let minAncestorRate = tx.effectiveFeePerVsize;
|
||||
const txRate = cpfpData[tx.txid]?.effectiveFeePerVsize ?? tx.effectiveFeePerVsize;
|
||||
let minAncestorRate = txRate;
|
||||
for (const vin of tx.vin) {
|
||||
if (txMap[vin.txid]?.effectiveFeePerVsize) {
|
||||
minAncestorRate = Math.min(minAncestorRate, txMap[vin.txid].effectiveFeePerVsize);
|
||||
const vinRate = cpfpData[vin.txid]?.effectiveFeePerVsize ?? txMap[vin.txid]?.effectiveFeePerVsize;
|
||||
if (vinRate) {
|
||||
minAncestorRate = Math.min(minAncestorRate, vinRate);
|
||||
}
|
||||
}
|
||||
// check rounded values to skip cases with almost identical fees
|
||||
const roundedMinAncestorRate = Math.ceil(minAncestorRate);
|
||||
const roundedEffectiveFeeRate = Math.floor(tx.effectiveFeePerVsize);
|
||||
const roundedEffectiveFeeRate = Math.floor(txRate);
|
||||
if (roundedMinAncestorRate < roundedEffectiveFeeRate) {
|
||||
tx.effectiveFeePerVsize = minAncestorRate;
|
||||
cpfpData[tx.txid].effectiveFeePerVsize = minAncestorRate;
|
||||
if (!clusterMap[tx.txid]) {
|
||||
// add a single-tx cluster to record the dependent rate
|
||||
const cluster = {
|
||||
|
|
@ -84,23 +98,25 @@ export function calculateFastBlockCpfp(height: number, transactions: MempoolTran
|
|||
if (saveRelatives) {
|
||||
for (const cluster of clusters) {
|
||||
cluster.txs.forEach((member, index) => {
|
||||
txMap[member.txid].descendants = cluster.txs.slice(0, index).reverse();
|
||||
txMap[member.txid].ancestors = cluster.txs.slice(index + 1).reverse();
|
||||
txMap[member.txid].effectiveFeePerVsize = cluster.effectiveFeePerVsize;
|
||||
cpfpData[member.txid].descendants = cluster.txs.slice(0, index).reverse();
|
||||
cpfpData[member.txid].ancestors = cluster.txs.slice(index + 1).reverse();
|
||||
cpfpData[member.txid].effectiveFeePerVsize = cluster.effectiveFeePerVsize;
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
transactions,
|
||||
txs: cpfpData,
|
||||
clusters,
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateGoodBlockCpfp(height: number, transactions: MempoolTransactionExtended[], accelerations: Acceleration[]): CpfpSummary {
|
||||
export function calculateGoodBlockCpfp(height: number, transactions: MempoolTransactionExtended[], accelerations: Acceleration[]): BlockCpfpData {
|
||||
const txMap: { [txid: string]: MempoolTransactionExtended } = {};
|
||||
const cpfpData: Record<string, TransactionCpfpData> = {};
|
||||
for (const tx of transactions) {
|
||||
txMap[tx.txid] = tx;
|
||||
cpfpData[tx.txid] = {};
|
||||
}
|
||||
const template = makeBlockTemplate(transactions, accelerations, 1, Infinity, Infinity);
|
||||
const clusters = new Map<string, string[]>();
|
||||
|
|
@ -110,15 +126,15 @@ export function calculateGoodBlockCpfp(height: number, transactions: MempoolTran
|
|||
if (cluster.length > 1 && root && !clusters.has(root)) {
|
||||
clusters.set(root, cluster);
|
||||
}
|
||||
txMap[tx.txid].effectiveFeePerVsize = tx.effectiveFeePerVsize;
|
||||
cpfpData[tx.txid].effectiveFeePerVsize = tx.effectiveFeePerVsize;
|
||||
}
|
||||
|
||||
const clusterArray: CpfpCluster[] = [];
|
||||
|
||||
for (const cluster of clusters.values()) {
|
||||
for (const txid of cluster) {
|
||||
const mempoolTx = txMap[txid];
|
||||
if (mempoolTx) {
|
||||
const mempoolTxCpfpData = cpfpData[txid];
|
||||
if (mempoolTxCpfpData) {
|
||||
const ancestors: Ancestor[] = [];
|
||||
const descendants: Ancestor[] = [];
|
||||
let matched = false;
|
||||
|
|
@ -138,10 +154,10 @@ export function calculateGoodBlockCpfp(height: number, transactions: MempoolTran
|
|||
}
|
||||
}
|
||||
});
|
||||
if (mempoolTx.ancestors?.length !== ancestors.length || mempoolTx.descendants?.length !== descendants.length) {
|
||||
mempoolTx.cpfpDirty = true;
|
||||
if (mempoolTxCpfpData.ancestors?.length !== ancestors.length || mempoolTxCpfpData.descendants?.length !== descendants.length) {
|
||||
mempoolTxCpfpData.cpfpDirty = true;
|
||||
}
|
||||
Object.assign(mempoolTx, { ancestors, descendants, bestDescendant: null, cpfpChecked: true });
|
||||
Object.assign(mempoolTxCpfpData, { ancestors, descendants, bestDescendant: null, cpfpChecked: true });
|
||||
}
|
||||
}
|
||||
const root = cluster[cluster.length - 1];
|
||||
|
|
@ -153,17 +169,75 @@ export function calculateGoodBlockCpfp(height: number, transactions: MempoolTran
|
|||
fee: txMap[txid].fee,
|
||||
weight: (txMap[txid].adjustedVsize * 4) || txMap[txid].weight,
|
||||
})),
|
||||
effectiveFeePerVsize: txMap[root].effectiveFeePerVsize,
|
||||
effectiveFeePerVsize: cpfpData[root].effectiveFeePerVsize ?? txMap[root].effectiveFeePerVsize,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
transactions: transactions.map(tx => txMap[tx.txid]),
|
||||
txs: cpfpData,
|
||||
clusters: clusterArray,
|
||||
version: 2,
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateClusterMempoolBlockCpfp(height: number, transactions: MempoolTransactionExtended[], accelerations: Acceleration[]): BlockCpfpData {
|
||||
const txMap: { [txid: string]: MempoolTransactionExtended } = {};
|
||||
const cpfpData: Record<string, TransactionCpfpData> = {};
|
||||
for (const tx of transactions) {
|
||||
txMap[tx.txid] = tx;
|
||||
cpfpData[tx.txid] = {};
|
||||
}
|
||||
|
||||
const accelMap: { [txid: string]: { feeDelta: number } } = {};
|
||||
for (const acc of accelerations) {
|
||||
accelMap[acc.txid] = { feeDelta: acc.max_bid };
|
||||
}
|
||||
|
||||
const cm = new ClusterMempool(txMap, accelMap, false, 25000);
|
||||
|
||||
const seenClusters = new Set<number>();
|
||||
const clusters: CpfpCluster[] = [];
|
||||
|
||||
for (const txid in cpfpData) {
|
||||
const txCpfpData = cm.getCpfpDataForTx(txid);
|
||||
if (!txCpfpData) {
|
||||
continue;
|
||||
}
|
||||
cpfpData[txid].effectiveFeePerVsize = txCpfpData.effectiveFeePerVsize;
|
||||
cpfpData[txid].clusterId = txCpfpData.clusterId;
|
||||
cpfpData[txid].chunkIndex = txCpfpData.chunkIndex;
|
||||
cpfpData[txid].ancestors = txCpfpData.ancestors;
|
||||
cpfpData[txid].descendants = txCpfpData.descendants;
|
||||
if (txCpfpData.clusterId !== undefined && !seenClusters.has(txCpfpData.clusterId)) {
|
||||
seenClusters.add(txCpfpData.clusterId);
|
||||
|
||||
const clusterData = cm.getCluster(txCpfpData.clusterId);
|
||||
if (clusterData && clusterData.txs.length > 1) {
|
||||
let totalFee = 0;
|
||||
let totalWeight = 0;
|
||||
for (const t of clusterData.txs) {
|
||||
totalFee += t.fee;
|
||||
totalWeight += t.weight;
|
||||
}
|
||||
clusters.push({
|
||||
root: clusterData.txs[0].txid,
|
||||
height,
|
||||
txs: clusterData.txs.map(t => ({ txid: t.txid, weight: t.weight, fee: t.fee })),
|
||||
effectiveFeePerVsize: totalFee / (totalWeight / 4),
|
||||
templateAlgorithm: TemplateAlgorithm.clusterMempool,
|
||||
clusterData,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
txs: cpfpData,
|
||||
clusters,
|
||||
version: 3,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a mempool transaction and a copy of the current mempool, and calculates the CPFP data for
|
||||
* that transaction (and all others in the same cluster)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import cpfpRepository from '../repositories/CpfpRepository';
|
|||
import { RowDataPacket } from 'mysql2';
|
||||
|
||||
class DatabaseMigration {
|
||||
private static currentVersion = 109;
|
||||
private static currentVersion = 111;
|
||||
private queryTimeout = 3600_000;
|
||||
private statisticsAddedIndexed = false;
|
||||
private uniqueLogs: string[] = [];
|
||||
|
|
@ -1246,6 +1246,15 @@ class DatabaseMigration {
|
|||
`);
|
||||
await this.updateToSchemaVersion(109);
|
||||
}
|
||||
if (databaseSchemaVersion < 110 && isBitcoin === true) {
|
||||
await this.$executeQuery('ALTER TABLE `blocks_audits` ADD template_algo TINYINT UNSIGNED NOT NULL DEFAULT 0');
|
||||
await this.updateToSchemaVersion(110);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 111 && isBitcoin === true) {
|
||||
await this.$executeQuery('ALTER TABLE `compact_cpfp_clusters` ADD template_algo TINYINT UNSIGNED NOT NULL DEFAULT 0');
|
||||
await this.updateToSchemaVersion(111);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import path from 'path';
|
|||
import mempool from './mempool';
|
||||
import { Acceleration } from './services/acceleration';
|
||||
import PoolsRepository from '../repositories/PoolsRepository';
|
||||
import { ProjectedBlock } from '../cluster-mempool/cluster-mempool';
|
||||
|
||||
const MAX_UINT32 = Math.pow(2, 32) - 1;
|
||||
|
||||
|
|
@ -238,7 +239,7 @@ class MempoolBlocks {
|
|||
}
|
||||
|
||||
/** @asyncSafe */
|
||||
public async $rustMakeBlockTemplates(txids: string[], newMempool: { [txid: string]: MempoolTransactionExtended }, candidates: GbtCandidates | undefined, saveResults: boolean = false, useAccelerations: boolean = false, accelerationPool?: number): Promise<MempoolBlockWithTransactions[]> {
|
||||
public async $rustMakeBlockTemplates(txids: string[], newMempool: { [txid: string]: MempoolTransactionExtended }, candidates: GbtCandidates | undefined, saveResults: boolean = false, useAccelerations: boolean = false, accelerationPool?: number, dryRun = false): Promise<MempoolBlockWithTransactions[]> {
|
||||
const start = Date.now();
|
||||
|
||||
// reset mempool short ids
|
||||
|
|
@ -278,7 +279,7 @@ class MempoolBlocks {
|
|||
const expectedSize = transactions.length;
|
||||
const resultMempoolSize = blocks.reduce((total, block) => total + block.length, 0) + overflow.length;
|
||||
logger.debug(`RUST updateBlockTemplates returned ${resultMempoolSize} txs out of ${expectedSize} in the mempool, ${overflow.length} were unmineable`);
|
||||
const processed = this.processBlockTemplates(newMempool, blocks, blockWeights, rates, clusters, candidates, accelerations, accelerationPool, saveResults);
|
||||
const processed = this.processBlockTemplates(newMempool, blocks, blockWeights, rates, clusters, candidates, accelerations, accelerationPool, saveResults, dryRun);
|
||||
logger.debug(`RUST makeBlockTemplates completed in ${(Date.now() - start)/1000} seconds`);
|
||||
return processed;
|
||||
} catch (e) {
|
||||
|
|
@ -296,16 +297,15 @@ class MempoolBlocks {
|
|||
}
|
||||
|
||||
/** @asyncSafe */
|
||||
public async $rustUpdateBlockTemplates(transactions: string[], newMempool: { [txid: string]: MempoolTransactionExtended }, added: MempoolTransactionExtended[], removed: MempoolTransactionExtended[], candidates: GbtCandidates | undefined, useAccelerations: boolean, accelerationPool?: number): Promise<MempoolBlockWithTransactions[]> {
|
||||
public async $rustUpdateBlockTemplates(transactions: string[], newMempool: { [txid: string]: MempoolTransactionExtended }, added: MempoolTransactionExtended[], removed: MempoolTransactionExtended[], candidates: GbtCandidates | undefined, useAccelerations: boolean, accelerationPool?: number, dryRun = false): Promise<MempoolBlockWithTransactions[]> {
|
||||
// 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 * transactions.length), MAX_UINT32)) {
|
||||
this.resetRustGbt();
|
||||
}
|
||||
|
||||
if (!this.rustInitialized) {
|
||||
// need to reset the worker
|
||||
return this.$rustMakeBlockTemplates(transactions, newMempool, candidates, true, useAccelerations, accelerationPool);
|
||||
if (!this.rustInitialized || dryRun) {
|
||||
return this.$rustMakeBlockTemplates(transactions, newMempool, candidates, !dryRun, useAccelerations, accelerationPool, dryRun);
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
|
|
@ -344,7 +344,7 @@ class MempoolBlocks {
|
|||
if (transactions.length !== resultMempoolSize) {
|
||||
throw new Error(`GBT returned wrong number of transactions ${transactions.length} vs ${resultMempoolSize}, cache is probably out of sync`);
|
||||
} else {
|
||||
const processed = this.processBlockTemplates(newMempool, blocks, blockWeights, rates, clusters, candidates, accelerations, accelerationPool, true);
|
||||
const processed = this.processBlockTemplates(newMempool, blocks, blockWeights, rates, clusters, candidates, accelerations, accelerationPool, !dryRun, dryRun);
|
||||
this.removeUids(removedTxs);
|
||||
logger.debug(`RUST updateBlockTemplates completed in ${(Date.now() - start)/1000} seconds`);
|
||||
return processed;
|
||||
|
|
@ -356,20 +356,22 @@ class MempoolBlocks {
|
|||
}
|
||||
}
|
||||
|
||||
private processBlockTemplates(mempool: { [txid: string]: MempoolTransactionExtended }, blocks: string[][], blockWeights: number[] | null, rates: [string, number][], clusters: string[][], candidates: GbtCandidates | undefined, accelerations: { [txid: string]: Acceleration }, accelerationPool, saveResults): MempoolBlockWithTransactions[] {
|
||||
for (const txid of Object.keys(candidates?.txs ?? mempool)) {
|
||||
if (txid in mempool) {
|
||||
mempool[txid].cpfpDirty = false;
|
||||
mempool[txid].ancestors = [];
|
||||
mempool[txid].descendants = [];
|
||||
mempool[txid].bestDescendant = null;
|
||||
private processBlockTemplates(mempool: { [txid: string]: MempoolTransactionExtended }, blocks: string[][], blockWeights: number[] | null, rates: [string, number][], clusters: string[][], candidates: GbtCandidates | undefined, accelerations: { [txid: string]: Acceleration }, accelerationPool, saveResults, dryRun = false): MempoolBlockWithTransactions[] {
|
||||
if (!dryRun) {
|
||||
for (const txid of Object.keys(candidates?.txs ?? mempool)) {
|
||||
if (txid in mempool) {
|
||||
mempool[txid].cpfpDirty = false;
|
||||
mempool[txid].ancestors = [];
|
||||
mempool[txid].descendants = [];
|
||||
mempool[txid].bestDescendant = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [txid, rate] of rates) {
|
||||
if (txid in mempool) {
|
||||
mempool[txid].cpfpDirty = (rate !== mempool[txid].effectiveFeePerVsize);
|
||||
mempool[txid].effectiveFeePerVsize = rate;
|
||||
mempool[txid].cpfpChecked = true;
|
||||
for (const [txid, rate] of rates) {
|
||||
if (txid in mempool) {
|
||||
mempool[txid].cpfpDirty = (rate !== mempool[txid].effectiveFeePerVsize);
|
||||
mempool[txid].effectiveFeePerVsize = rate;
|
||||
mempool[txid].cpfpChecked = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -387,58 +389,60 @@ class MempoolBlocks {
|
|||
feeStatsCalculator = new OnlineFeeStatsCalculator(stackWeight, 0.5, [10, 20, 30, 40, 50, 60, 70, 80, 90]);
|
||||
}
|
||||
|
||||
const ancestors: Ancestor[] = [];
|
||||
const descendants: Ancestor[] = [];
|
||||
let ancestor: MempoolTransactionExtended;
|
||||
for (const cluster of clusters) {
|
||||
for (const memberTxid of cluster) {
|
||||
const mempoolTx = mempool[memberTxid];
|
||||
if (mempoolTx) {
|
||||
// ugly micro-optimization to avoid allocating new arrays
|
||||
ancestors.length = 0;
|
||||
descendants.length = 0;
|
||||
let matched = false;
|
||||
cluster.forEach(txid => {
|
||||
ancestor = mempool[txid];
|
||||
if (txid === memberTxid) {
|
||||
matched = true;
|
||||
} else {
|
||||
if (!ancestor) {
|
||||
console.log('txid missing from mempool! ', txid, candidates?.txs[txid]);
|
||||
return;
|
||||
}
|
||||
const relative = {
|
||||
txid: txid,
|
||||
fee: ancestor.fee,
|
||||
weight: (ancestor.adjustedVsize * 4),
|
||||
};
|
||||
if (matched) {
|
||||
descendants.push(relative);
|
||||
if (!mempoolTx.lastBoosted || (ancestor.firstSeen && ancestor.firstSeen > mempoolTx.lastBoosted)) {
|
||||
mempoolTx.lastBoosted = ancestor.firstSeen;
|
||||
}
|
||||
if (!dryRun) {
|
||||
const ancestors: Ancestor[] = [];
|
||||
const descendants: Ancestor[] = [];
|
||||
let ancestor: MempoolTransactionExtended;
|
||||
for (const cluster of clusters) {
|
||||
for (const memberTxid of cluster) {
|
||||
const mempoolTx = mempool[memberTxid];
|
||||
if (mempoolTx) {
|
||||
// ugly micro-optimization to avoid allocating new arrays
|
||||
ancestors.length = 0;
|
||||
descendants.length = 0;
|
||||
let matched = false;
|
||||
cluster.forEach(txid => {
|
||||
ancestor = mempool[txid];
|
||||
if (txid === memberTxid) {
|
||||
matched = true;
|
||||
} else {
|
||||
ancestors.push(relative);
|
||||
if (!ancestor) {
|
||||
console.log('txid missing from mempool! ', txid, candidates?.txs[txid]);
|
||||
return;
|
||||
}
|
||||
const relative = {
|
||||
txid: txid,
|
||||
fee: ancestor.fee,
|
||||
weight: (ancestor.adjustedVsize * 4),
|
||||
};
|
||||
if (matched) {
|
||||
descendants.push(relative);
|
||||
if (!mempoolTx.lastBoosted || (ancestor.firstSeen && ancestor.firstSeen > mempoolTx.lastBoosted)) {
|
||||
mempoolTx.lastBoosted = ancestor.firstSeen;
|
||||
}
|
||||
} else {
|
||||
ancestors.push(relative);
|
||||
}
|
||||
}
|
||||
});
|
||||
if (mempoolTx.ancestors?.length !== ancestors.length || mempoolTx.descendants?.length !== descendants.length) {
|
||||
mempoolTx.cpfpDirty = true;
|
||||
}
|
||||
});
|
||||
if (mempoolTx.ancestors?.length !== ancestors.length || mempoolTx.descendants?.length !== descendants.length) {
|
||||
mempoolTx.cpfpDirty = true;
|
||||
// ugly micro-optimization to avoid allocating new arrays or objects
|
||||
if (mempoolTx.ancestors) {
|
||||
mempoolTx.ancestors.length = 0;
|
||||
} else {
|
||||
mempoolTx.ancestors = [];
|
||||
}
|
||||
if (mempoolTx.descendants) {
|
||||
mempoolTx.descendants.length = 0;
|
||||
} else {
|
||||
mempoolTx.descendants = [];
|
||||
}
|
||||
mempoolTx.ancestors.push(...ancestors);
|
||||
mempoolTx.descendants.push(...descendants);
|
||||
mempoolTx.cpfpChecked = true;
|
||||
}
|
||||
// ugly micro-optimization to avoid allocating new arrays or objects
|
||||
if (mempoolTx.ancestors) {
|
||||
mempoolTx.ancestors.length = 0;
|
||||
} else {
|
||||
mempoolTx.ancestors = [];
|
||||
}
|
||||
if (mempoolTx.descendants) {
|
||||
mempoolTx.descendants.length = 0;
|
||||
} else {
|
||||
mempoolTx.descendants = [];
|
||||
}
|
||||
mempoolTx.ancestors.push(...ancestors);
|
||||
mempoolTx.descendants.push(...descendants);
|
||||
mempoolTx.cpfpChecked = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -471,34 +475,42 @@ class MempoolBlocks {
|
|||
const txid = block[i];
|
||||
if (txid in mempool) {
|
||||
mempoolTx = mempool[txid];
|
||||
// save position in projected blocks
|
||||
mempoolTx.position = {
|
||||
block: blockIndex,
|
||||
vsize: totalVsize + (mempoolTx.vsize / 2),
|
||||
};
|
||||
|
||||
if (txid in accelerations) {
|
||||
acceleration = accelerations[txid];
|
||||
if (isAcceleratedBy[txid] || (acceleration && (!accelerationPool || acceleration.pools.includes(accelerationPool)))) {
|
||||
if (!mempoolTx.acceleration) {
|
||||
mempoolTx.cpfpDirty = true;
|
||||
}
|
||||
mempoolTx.acceleration = true;
|
||||
mempoolTx.acceleratedBy = isAcceleratedBy[txid] || acceleration?.pools;
|
||||
mempoolTx.acceleratedAt = acceleration?.added;
|
||||
mempoolTx.feeDelta = acceleration?.feeDelta;
|
||||
for (const ancestor of mempoolTx.ancestors || []) {
|
||||
if (!(ancestor.txid in mempool)) {
|
||||
continue;
|
||||
if (!dryRun) {
|
||||
// save position in projected blocks
|
||||
mempoolTx.position = {
|
||||
block: blockIndex,
|
||||
vsize: totalVsize + (mempoolTx.vsize / 2),
|
||||
};
|
||||
|
||||
if (txid in accelerations) {
|
||||
acceleration = accelerations[txid];
|
||||
if (isAcceleratedBy[txid] || (acceleration && (!accelerationPool || acceleration.pools.includes(accelerationPool)))) {
|
||||
if (!mempoolTx.acceleration) {
|
||||
mempoolTx.cpfpDirty = true;
|
||||
}
|
||||
if (!mempool[ancestor.txid].acceleration) {
|
||||
mempool[ancestor.txid].cpfpDirty = true;
|
||||
mempoolTx.acceleration = true;
|
||||
mempoolTx.acceleratedBy = isAcceleratedBy[txid] || acceleration?.pools;
|
||||
mempoolTx.acceleratedAt = acceleration?.added;
|
||||
mempoolTx.feeDelta = acceleration?.feeDelta;
|
||||
for (const ancestor of mempoolTx.ancestors || []) {
|
||||
if (!(ancestor.txid in mempool)) {
|
||||
continue;
|
||||
}
|
||||
if (!mempool[ancestor.txid].acceleration) {
|
||||
mempool[ancestor.txid].cpfpDirty = true;
|
||||
}
|
||||
mempool[ancestor.txid].acceleration = true;
|
||||
mempool[ancestor.txid].acceleratedBy = mempoolTx.acceleratedBy;
|
||||
mempool[ancestor.txid].acceleratedAt = mempoolTx.acceleratedAt;
|
||||
mempool[ancestor.txid].feeDelta = mempoolTx.feeDelta;
|
||||
isAcceleratedBy[ancestor.txid] = mempoolTx.acceleratedBy;
|
||||
}
|
||||
} else {
|
||||
if (mempoolTx.acceleration) {
|
||||
mempoolTx.cpfpDirty = true;
|
||||
delete mempoolTx.acceleration;
|
||||
}
|
||||
mempool[ancestor.txid].acceleration = true;
|
||||
mempool[ancestor.txid].acceleratedBy = mempoolTx.acceleratedBy;
|
||||
mempool[ancestor.txid].acceleratedAt = mempoolTx.acceleratedAt;
|
||||
mempool[ancestor.txid].feeDelta = mempoolTx.feeDelta;
|
||||
isAcceleratedBy[ancestor.txid] = mempoolTx.acceleratedBy;
|
||||
}
|
||||
} else {
|
||||
if (mempoolTx.acceleration) {
|
||||
|
|
@ -506,11 +518,6 @@ class MempoolBlocks {
|
|||
delete mempoolTx.acceleration;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (mempoolTx.acceleration) {
|
||||
mempoolTx.cpfpDirty = true;
|
||||
delete mempoolTx.acceleration;
|
||||
}
|
||||
}
|
||||
|
||||
// online calculation of stack-of-blocks fee stats
|
||||
|
|
@ -548,6 +555,115 @@ class MempoolBlocks {
|
|||
return mempoolBlocks;
|
||||
}
|
||||
|
||||
public processClusterMempoolBlocks(projectedBlocks: ProjectedBlock[], newMempool: { [txid: string]: MempoolTransactionExtended }, accelerations: { [txid: string]: Acceleration }, saveResults = true, accelerationPool?: number): MempoolBlockWithTransactions[] {
|
||||
const lastBlockIndex = projectedBlocks.length - 1;
|
||||
let hasBlockStack = projectedBlocks.length >= 8;
|
||||
let stackWeight = 0;
|
||||
let feeStatsCalculator: OnlineFeeStatsCalculator | null = null;
|
||||
if (hasBlockStack) {
|
||||
stackWeight = projectedBlocks[lastBlockIndex].weight;
|
||||
hasBlockStack = stackWeight > config.MEMPOOL.BLOCK_WEIGHT_UNITS;
|
||||
feeStatsCalculator = new OnlineFeeStatsCalculator(stackWeight, 0.5, [10, 20, 30, 40, 50, 60, 70, 80, 90]);
|
||||
}
|
||||
|
||||
const isAcceleratedBy: { [txid: string]: number[] | false } = {};
|
||||
|
||||
const sizeLimit = (config.MEMPOOL.BLOCK_WEIGHT_UNITS / 4) * 1.2;
|
||||
let mempoolTx: MempoolTransactionExtended;
|
||||
let acceleration: Acceleration;
|
||||
const mempoolBlocks: MempoolBlockWithTransactions[] = [];
|
||||
|
||||
for (let blockIndex = 0; blockIndex < projectedBlocks.length; blockIndex++) {
|
||||
const projected = projectedBlocks[blockIndex];
|
||||
let totalSize = 0;
|
||||
let totalVsize = 0;
|
||||
let totalWeight = 0;
|
||||
let totalFees = 0;
|
||||
const transactions: MempoolTransactionExtended[] = [];
|
||||
const validTxids: string[] = [];
|
||||
|
||||
for (const txid of projected.txids) {
|
||||
if (txid in newMempool) {
|
||||
mempoolTx = newMempool[txid];
|
||||
validTxids.push(txid);
|
||||
|
||||
// save position in projected blocks
|
||||
mempoolTx.position = {
|
||||
block: blockIndex,
|
||||
vsize: totalVsize + (mempoolTx.vsize / 2),
|
||||
};
|
||||
|
||||
if (txid in accelerations) {
|
||||
acceleration = accelerations[txid];
|
||||
if (isAcceleratedBy[txid] || (acceleration && (!accelerationPool || acceleration.pools.includes(accelerationPool)))) {
|
||||
if (!mempoolTx.acceleration) {
|
||||
mempoolTx.cpfpDirty = true;
|
||||
}
|
||||
mempoolTx.acceleration = true;
|
||||
mempoolTx.acceleratedBy = isAcceleratedBy[txid] || acceleration?.pools;
|
||||
mempoolTx.acceleratedAt = acceleration?.added;
|
||||
mempoolTx.feeDelta = acceleration?.feeDelta;
|
||||
for (const ancestor of mempoolTx.ancestors || []) {
|
||||
if (!(ancestor.txid in newMempool)) {
|
||||
continue;
|
||||
}
|
||||
if (!newMempool[ancestor.txid].acceleration) {
|
||||
newMempool[ancestor.txid].cpfpDirty = true;
|
||||
}
|
||||
newMempool[ancestor.txid].acceleration = true;
|
||||
newMempool[ancestor.txid].acceleratedBy = mempoolTx.acceleratedBy;
|
||||
newMempool[ancestor.txid].acceleratedAt = mempoolTx.acceleratedAt;
|
||||
newMempool[ancestor.txid].feeDelta = mempoolTx.feeDelta;
|
||||
isAcceleratedBy[ancestor.txid] = mempoolTx.acceleratedBy;
|
||||
}
|
||||
} else {
|
||||
if (mempoolTx.acceleration) {
|
||||
mempoolTx.cpfpDirty = true;
|
||||
delete mempoolTx.acceleration;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (mempoolTx.acceleration) {
|
||||
mempoolTx.cpfpDirty = true;
|
||||
delete mempoolTx.acceleration;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasBlockStack && blockIndex === lastBlockIndex && feeStatsCalculator) {
|
||||
feeStatsCalculator.processNext(mempoolTx);
|
||||
}
|
||||
|
||||
totalSize += mempoolTx.size;
|
||||
totalVsize += mempoolTx.vsize;
|
||||
totalWeight += mempoolTx.weight;
|
||||
totalFees += mempoolTx.fee;
|
||||
|
||||
if (totalVsize <= sizeLimit) {
|
||||
transactions.push(mempoolTx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mempoolBlocks[blockIndex] = this.dataToMempoolBlocks(
|
||||
validTxids,
|
||||
transactions,
|
||||
totalSize,
|
||||
totalWeight,
|
||||
totalFees,
|
||||
(hasBlockStack && blockIndex === lastBlockIndex && feeStatsCalculator) ? feeStatsCalculator.getRawFeeStats() : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
if (saveResults) {
|
||||
const deltas = this.calculateMempoolDeltas(this.mempoolBlocks, mempoolBlocks);
|
||||
this.mempoolBlocks = mempoolBlocks;
|
||||
this.mempoolBlockDeltas = deltas;
|
||||
this.updateAccelerationPositions(newMempool, accelerations, mempoolBlocks);
|
||||
}
|
||||
|
||||
return mempoolBlocks;
|
||||
}
|
||||
|
||||
private dataToMempoolBlocks(transactionIds: string[], transactions: MempoolTransactionExtended[], totalSize: number, totalWeight: number, totalFees: number, feeStats?: EffectiveFeeStats ): MempoolBlockWithTransactions {
|
||||
if (!feeStats) {
|
||||
feeStats = Common.calcEffectiveFeeStatistics(transactions);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { Acceleration } from './services/acceleration';
|
|||
import accelerationApi from './services/acceleration';
|
||||
import redisCache from './redis-cache';
|
||||
import blocks from './blocks';
|
||||
import { ClusterMempool } from '../cluster-mempool/cluster-mempool';
|
||||
|
||||
class Mempool {
|
||||
private inSync: boolean = false;
|
||||
|
|
@ -22,6 +23,7 @@ class Mempool {
|
|||
private spendMap = new Map<string, MempoolTransactionExtended>();
|
||||
private recentlyDeleted: MempoolTransactionExtended[][] = []; // buffer of transactions deleted in recent mempool updates
|
||||
private mempoolInfo: IBitcoinApi.MempoolInfo;
|
||||
public clusterMempool: ClusterMempool | null = null;
|
||||
private mempoolChangedCallback: ((newMempool: {[txId: string]: MempoolTransactionExtended; }, newTransactions: MempoolTransactionExtended[],
|
||||
deletedTransactions: MempoolTransactionExtended[][], accelerationDelta: string[]) => void) | undefined;
|
||||
private $asyncMempoolChangedCallback: ((newMempool: {[txId: string]: MempoolTransactionExtended; }, mempoolSize: number, newTransactions: MempoolTransactionExtended[],
|
||||
|
|
@ -62,6 +64,9 @@ class Mempool {
|
|||
minrelaytxfee: isLiquid ? 0.00000100 : 0.00001000
|
||||
};
|
||||
this.txPerSecondInterval = setInterval(this.updateTxPerSecond.bind(this), 1000);
|
||||
if (config.MEMPOOL.CLUSTER_MEMPOOL) {
|
||||
this.clusterMempool = new ClusterMempool(this.mempoolCache, this.accelerations);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -153,6 +158,9 @@ class Mempool {
|
|||
await redisCache.$flushTransactions();
|
||||
logger.debug(`Finished migrating cache transactions in ${((Date.now() - redisTimer) / 1000).toFixed(2)} seconds`);
|
||||
}
|
||||
if (config.MEMPOOL.CLUSTER_MEMPOOL) {
|
||||
this.clusterMempool = new ClusterMempool(this.mempoolCache, this.accelerations);
|
||||
}
|
||||
if (this.mempoolChangedCallback) {
|
||||
this.mempoolChangedCallback(this.mempoolCache, [], [], []);
|
||||
}
|
||||
|
|
@ -387,6 +395,14 @@ class Mempool {
|
|||
hasChange = true;
|
||||
}
|
||||
|
||||
if (config.MEMPOOL.CLUSTER_MEMPOOL && (newTransactions.length || deletedTransactions.length || accelerationDelta.length)) {
|
||||
this.clusterMempool?.applyMempoolChange({
|
||||
added: newTransactions,
|
||||
removed: deletedTransactions.map(tx => tx.txid),
|
||||
accelerations: this.getAccelerations(),
|
||||
});
|
||||
}
|
||||
|
||||
this.mempoolCacheDelta = Math.abs(transactions.length - newMempoolSize);
|
||||
|
||||
const candidatesChanged = candidates?.added?.length || candidates?.removed?.length;
|
||||
|
|
|
|||
|
|
@ -256,13 +256,14 @@ class TransactionUtils {
|
|||
|
||||
// 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
|
||||
);
|
||||
// Parse last 4 bytes of txid as little-endian uint32, without string allocation
|
||||
let result = 0;
|
||||
for (let i = 62; i >= 56; i -= 2) {
|
||||
const hi = txid.charCodeAt(i);
|
||||
const lo = txid.charCodeAt(i + 1);
|
||||
result = result * 256 + (hi < 58 ? hi - 48 : hi - 87) * 16 + (lo < 58 ? lo - 48 : lo - 87);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public addInnerScriptsToVin(vin: IEsploraApi.Vin): void {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import * as WebSocket from 'ws';
|
|||
import {
|
||||
BlockExtended, TransactionExtended, MempoolTransactionExtended, WebsocketResponse,
|
||||
OptimizedStatistic, ILoadingIndicators, GbtCandidates, TxTrackingInfo,
|
||||
MempoolDelta, MempoolDeltaTxids
|
||||
MempoolDelta, MempoolDeltaTxids, CpfpInfo
|
||||
} from '../mempool.interfaces';
|
||||
import blocks from './blocks';
|
||||
import memPool from './mempool';
|
||||
|
|
@ -16,16 +16,12 @@ import transactionUtils from './transaction-utils';
|
|||
import rbfCache, { ReplacementInfo } from './rbf-cache';
|
||||
import difficultyAdjustment from './difficulty-adjustment';
|
||||
import feeApi from './fee-api';
|
||||
import BlocksAuditsRepository from '../repositories/BlocksAuditsRepository';
|
||||
import BlocksSummariesRepository from '../repositories/BlocksSummariesRepository';
|
||||
import Audit from './audit';
|
||||
import priceUpdater from '../tasks/price-updater';
|
||||
import { ApiPrice } from '../repositories/PricesRepository';
|
||||
import { Acceleration } from './services/acceleration';
|
||||
import accelerationApi from './services/acceleration';
|
||||
import mempool from './mempool';
|
||||
import statistics from './statistics/statistics';
|
||||
import accelerationRepository from '../repositories/AccelerationRepository';
|
||||
import bitcoinApi from './bitcoin/bitcoin-api-factory';
|
||||
import walletApi from './services/wallets';
|
||||
|
||||
|
|
@ -649,7 +645,10 @@ class WebsocketHandler {
|
|||
removed = candidates?.removed || [];
|
||||
}
|
||||
|
||||
if (config.MEMPOOL.RUST_GBT) {
|
||||
if (config.MEMPOOL.CLUSTER_MEMPOOL) {
|
||||
const cmBlocks = mempool.clusterMempool?.getBlocks(config.MEMPOOL.MEMPOOL_BLOCKS_AMOUNT) ?? [];
|
||||
mempoolBlocks.processClusterMempoolBlocks(cmBlocks, newMempool, mempool.getAccelerations());
|
||||
} else if (config.MEMPOOL.RUST_GBT) {
|
||||
await mempoolBlocks.$rustUpdateBlockTemplates(transactionIds, newMempool, added, removed, candidates, true);
|
||||
} else {
|
||||
await mempoolBlocks.$updateBlockTemplates(transactionIds, newMempool, added, removed, candidates, accelerationDelta, true, true);
|
||||
|
|
@ -778,6 +777,8 @@ class WebsocketHandler {
|
|||
removed: websocketAccelerationDelta.filter(txid => !accelerations[txid]),
|
||||
};
|
||||
|
||||
const cpfpUpdatesSent = new Set<string>();
|
||||
|
||||
// TODO - Fix indentation after PR is merged
|
||||
for (const server of this.webSocketServers) {
|
||||
server.clients.forEach(async (client) => {
|
||||
|
|
@ -946,15 +947,23 @@ class WebsocketHandler {
|
|||
calculateMempoolTxCpfp(mempoolTx, newMempool);
|
||||
}
|
||||
if (mempoolTx.cpfpDirty) {
|
||||
positionData['cpfp'] = {
|
||||
ancestors: mempoolTx.ancestors,
|
||||
const cpfp: CpfpInfo = {
|
||||
ancestors: mempoolTx.ancestors || [],
|
||||
bestDescendant: mempoolTx.bestDescendant || null,
|
||||
descendants: mempoolTx.descendants || null,
|
||||
effectiveFeePerVsize: mempoolTx.effectiveFeePerVsize || null,
|
||||
descendants: mempoolTx.descendants,
|
||||
effectiveFeePerVsize: mempoolTx.effectiveFeePerVsize,
|
||||
sigops: mempoolTx.sigops,
|
||||
adjustedVsize: mempoolTx.adjustedVsize,
|
||||
acceleration: mempoolTx.acceleration,
|
||||
};
|
||||
if (config.MEMPOOL.CLUSTER_MEMPOOL && mempoolTx.clusterId != null) {
|
||||
const cluster = mempool.clusterMempool?.getClusterForApi(mempoolTx.txid);
|
||||
if (cluster) {
|
||||
cpfp.cluster = cluster;
|
||||
}
|
||||
}
|
||||
positionData['cpfp'] = cpfp;
|
||||
cpfpUpdatesSent.add(trackTxid);
|
||||
}
|
||||
response['txPosition'] = JSON.stringify(positionData);
|
||||
}
|
||||
|
|
@ -992,11 +1001,18 @@ class WebsocketHandler {
|
|||
txInfo.cpfp = {
|
||||
ancestors: mempoolTx.ancestors,
|
||||
bestDescendant: mempoolTx.bestDescendant || null,
|
||||
descendants: mempoolTx.descendants || null,
|
||||
effectiveFeePerVsize: mempoolTx.effectiveFeePerVsize || null,
|
||||
descendants: mempoolTx.descendants,
|
||||
effectiveFeePerVsize: mempoolTx.effectiveFeePerVsize,
|
||||
sigops: mempoolTx.sigops,
|
||||
adjustedVsize: mempoolTx.adjustedVsize,
|
||||
};
|
||||
if (config.MEMPOOL.CLUSTER_MEMPOOL && mempoolTx.clusterId != null) {
|
||||
const cluster = mempool.clusterMempool?.getClusterForApi(mempoolTx.txid);
|
||||
if (cluster) {
|
||||
(txInfo.cpfp as CpfpInfo).cluster = cluster;
|
||||
}
|
||||
}
|
||||
cpfpUpdatesSent.add(txid);
|
||||
}
|
||||
txHasInfo = true;
|
||||
}
|
||||
|
|
@ -1047,129 +1063,33 @@ class WebsocketHandler {
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const txid of cpfpUpdatesSent) {
|
||||
if (newMempool[txid]) {
|
||||
newMempool[txid].cpfpDirty = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @asyncUnsafe */
|
||||
async handleNewBlock(block: BlockExtended, txIds: string[], transactions: MempoolTransactionExtended[]): Promise<void> {
|
||||
/** @asyncSafe */
|
||||
async handleNewBlock(
|
||||
block: BlockExtended,
|
||||
txIds: string[],
|
||||
transactions: MempoolTransactionExtended[],
|
||||
rbfTransactions: { [txid: string]: { replaced: MempoolTransactionExtended[], replacedBy: TransactionExtended }}
|
||||
): Promise<void> {
|
||||
if (!this.webSocketServers.length) {
|
||||
throw new Error('No WebSocket.Server have been set');
|
||||
}
|
||||
|
||||
const blockTransactions = structuredClone(transactions);
|
||||
|
||||
this.printLogs();
|
||||
if (config.STATISTICS.ENABLED && config.DATABASE.ENABLED) {
|
||||
await statistics.runStatistics();
|
||||
}
|
||||
|
||||
const _memPool = memPool.getMempool();
|
||||
const candidateTxs = memPool.getMempoolCandidates();
|
||||
let candidates: GbtCandidates | undefined = (memPool.limitGBT && candidateTxs) ? { txs: candidateTxs, added: [], removed: [] } : undefined;
|
||||
let transactionIds: string[] = (memPool.limitGBT) ? Object.keys(candidates?.txs || {}) : Object.keys(_memPool);
|
||||
|
||||
if (config.DATABASE.ENABLED) {
|
||||
const accelerations = Object.values(mempool.getAccelerations());
|
||||
await accelerationRepository.$indexAccelerationsForBlock(block, accelerations, structuredClone(transactions));
|
||||
}
|
||||
|
||||
const rbfTransactions = Common.findMinedRbfTransactions(transactions, memPool.getSpendMap());
|
||||
memPool.handleRbfTransactions(rbfTransactions);
|
||||
memPool.removeFromSpendMap(transactions);
|
||||
|
||||
if (config.MEMPOOL.AUDIT && memPool.isInSync()) {
|
||||
let projectedBlocks;
|
||||
const auditMempool = _memPool;
|
||||
const isAccelerated = accelerationApi.isAcceleratedBlock(block, Object.values(mempool.getAccelerations()));
|
||||
|
||||
if (config.MEMPOOL.RUST_GBT) {
|
||||
const added = memPool.limitGBT ? (candidates?.added || []) : [];
|
||||
const removed = memPool.limitGBT ? (candidates?.removed || []) : [];
|
||||
projectedBlocks = await mempoolBlocks.$rustUpdateBlockTemplates(transactionIds, auditMempool, added, removed, candidates, isAccelerated, block.extras.pool.id);
|
||||
} else {
|
||||
projectedBlocks = await mempoolBlocks.$makeBlockTemplates(transactionIds, auditMempool, candidates, false, isAccelerated, block.extras.pool.id);
|
||||
}
|
||||
|
||||
if (Common.indexingEnabled()) {
|
||||
const { unseen, censored, added, prioritized, fresh, sigop, fullrbf, accelerated, score, similarity } = Audit.auditBlock(block.height, blockTransactions, projectedBlocks, auditMempool);
|
||||
const matchRate = Math.round(score * 100 * 100) / 100;
|
||||
|
||||
const 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);
|
||||
}
|
||||
|
||||
void BlocksSummariesRepository.$saveTemplate({
|
||||
height: block.height,
|
||||
template: {
|
||||
id: block.id,
|
||||
transactions: stripped,
|
||||
},
|
||||
version: 1,
|
||||
});
|
||||
|
||||
void BlocksAuditsRepository.$saveAudit({
|
||||
version: 1,
|
||||
time: block.timestamp,
|
||||
height: block.height,
|
||||
hash: block.id,
|
||||
unseenTxs: unseen,
|
||||
addedTxs: added,
|
||||
prioritizedTxs: prioritized,
|
||||
missingTxs: censored,
|
||||
freshTxs: fresh,
|
||||
sigopTxs: sigop,
|
||||
fullrbfTxs: fullrbf,
|
||||
acceleratedTxs: accelerated,
|
||||
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;
|
||||
}
|
||||
}
|
||||
} else if (block.extras) {
|
||||
const mBlocks = mempoolBlocks.getMempoolBlocksWithTransactions();
|
||||
if (mBlocks?.length && mBlocks[0].transactions) {
|
||||
block.extras.similarity = Common.getSimilarity(mBlocks[0], transactions);
|
||||
}
|
||||
}
|
||||
|
||||
const confirmedTxids: { [txid: string]: boolean } = {};
|
||||
|
||||
// Update mempool to remove transactions included in the new block
|
||||
for (const txId of txIds) {
|
||||
delete _memPool[txId];
|
||||
rbfCache.mined(txId);
|
||||
confirmedTxids[txId] = true;
|
||||
}
|
||||
|
||||
if (memPool.limitGBT) {
|
||||
const minFeeMempool = memPool.limitGBT ? await bitcoinSecondClient.getRawMemPool() : null;
|
||||
const minFeeTip = memPool.limitGBT ? await bitcoinSecondClient.getBlockCount() : -1;
|
||||
candidates = memPool.getNextCandidates(minFeeMempool, minFeeTip, transactions);
|
||||
transactionIds = Object.keys(candidates?.txs || {});
|
||||
} else {
|
||||
candidates = undefined;
|
||||
transactionIds = Object.keys(memPool.getMempool());
|
||||
}
|
||||
|
||||
|
||||
if (config.MEMPOOL.RUST_GBT) {
|
||||
const added = memPool.limitGBT ? (candidates?.added || []) : [];
|
||||
const removed = memPool.limitGBT ? (candidates?.removed || []) : transactions;
|
||||
await mempoolBlocks.$rustUpdateBlockTemplates(transactionIds, _memPool, added, removed, candidates, true);
|
||||
} else {
|
||||
await mempoolBlocks.$makeBlockTemplates(transactionIds, _memPool, candidates, true, true);
|
||||
}
|
||||
const mBlocks = mempoolBlocks.getMempoolBlocks();
|
||||
const mBlockDeltas = mempoolBlocks.getMempoolBlockDeltas();
|
||||
|
||||
|
|
@ -1434,10 +1354,6 @@ class WebsocketHandler {
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (config.STATISTICS.ENABLED && config.DATABASE.ENABLED) {
|
||||
await statistics.runStatistics();
|
||||
}
|
||||
}
|
||||
|
||||
public handleNewStratumJob(job: StratumJob): void {
|
||||
|
|
|
|||
165
backend/src/cluster-mempool/block-builder.ts
Normal file
165
backend/src/cluster-mempool/block-builder.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import { PairingHeap } from '../utils/pairing-heap';
|
||||
import { LinearizationChunk } from './linearize';
|
||||
import { MempoolTransactionExtended } from '../mempool.interfaces';
|
||||
import { Cluster } from './cluster-mempool';
|
||||
|
||||
export interface ProjectedBlock {
|
||||
txids: string[];
|
||||
weight: number;
|
||||
sigops: number;
|
||||
}
|
||||
|
||||
interface ChunkHeapEntry {
|
||||
fee: number;
|
||||
weight: number;
|
||||
sigops: number;
|
||||
equalFeeratePrefixWeight: number;
|
||||
maxOrder: number;
|
||||
clusterId: number;
|
||||
chunkIndex: number;
|
||||
}
|
||||
|
||||
const BLOCK_WEIGHT_UNITS = 4_000_000;
|
||||
const MAX_BLOCK_SIGOPS_COST = 80_000;
|
||||
const COINBASE_RESERVED_WEIGHT = 8000;
|
||||
const MAX_CONSECUTIVE_FAILURES = 1000;
|
||||
const MAX_WASTED_WEIGHT = 4000;
|
||||
|
||||
function chunkHeapHigherPriority(a: ChunkHeapEntry, b: ChunkHeapEntry): boolean {
|
||||
const feerateDiff = a.fee * b.weight - b.fee * a.weight;
|
||||
if (feerateDiff !== 0) {
|
||||
return feerateDiff > 0;
|
||||
}
|
||||
if (a.equalFeeratePrefixWeight !== b.equalFeeratePrefixWeight) {
|
||||
return a.equalFeeratePrefixWeight < b.equalFeeratePrefixWeight;
|
||||
}
|
||||
return a.maxOrder < b.maxOrder;
|
||||
}
|
||||
|
||||
function equalFeerate(a: LinearizationChunk, b: LinearizationChunk): boolean {
|
||||
return a.fee * b.weight === b.fee * a.weight;
|
||||
}
|
||||
|
||||
function makeChunkHeapEntry(cluster: Cluster, chunkIndex: number, mempool: { [txid: string]: MempoolTransactionExtended }): ChunkHeapEntry {
|
||||
const chunk = cluster.chunks[chunkIndex];
|
||||
let maxOrder = 0;
|
||||
let sigops = 0;
|
||||
for (const tx of chunk.txs) {
|
||||
if (tx.order > maxOrder) {
|
||||
maxOrder = tx.order;
|
||||
}
|
||||
const mempoolTx = mempool[tx.txid];
|
||||
if (mempoolTx) {
|
||||
sigops += mempoolTx.sigops || 0;
|
||||
}
|
||||
}
|
||||
let prefixWeight = chunk.weight;
|
||||
for (let i = chunkIndex - 1; i >= 0; i--) {
|
||||
if (equalFeerate(cluster.chunks[i], chunk)) {
|
||||
prefixWeight += cluster.chunks[i].weight;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return {
|
||||
fee: chunk.fee,
|
||||
weight: chunk.weight,
|
||||
sigops,
|
||||
equalFeeratePrefixWeight: prefixWeight,
|
||||
maxOrder,
|
||||
clusterId: cluster.id,
|
||||
chunkIndex,
|
||||
};
|
||||
}
|
||||
|
||||
function buildChunkHeap(clusters: Map<number, Cluster>, mempool: { [txid: string]: MempoolTransactionExtended }): PairingHeap<ChunkHeapEntry> {
|
||||
const heap = new PairingHeap<ChunkHeapEntry>(chunkHeapHigherPriority);
|
||||
for (const cluster of clusters.values()) {
|
||||
if (cluster.chunks.length > 0) {
|
||||
heap.add(makeChunkHeapEntry(cluster, 0, mempool));
|
||||
}
|
||||
}
|
||||
return heap;
|
||||
}
|
||||
|
||||
export function assembleBlocks(
|
||||
n: number,
|
||||
clusters: Map<number, Cluster>,
|
||||
mempool: { [txid: string]: MempoolTransactionExtended },
|
||||
enforceLimit: boolean,
|
||||
): ProjectedBlock[] {
|
||||
const heap = buildChunkHeap(clusters, mempool);
|
||||
const blocks: ProjectedBlock[] = [];
|
||||
for (let blockIdx = 0; blockIdx < n; blockIdx++) {
|
||||
const limited = enforceLimit || blockIdx < n - 1;
|
||||
const maxWeight = limited ? BLOCK_WEIGHT_UNITS : Infinity;
|
||||
const maxSigops = limited ? MAX_BLOCK_SIGOPS_COST : Infinity;
|
||||
const block = fillBlock(heap, clusters, mempool, maxWeight, maxSigops);
|
||||
if (block.txids.length === 0) {
|
||||
break;
|
||||
}
|
||||
blocks.push(block);
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function fillBlock(
|
||||
heap: PairingHeap<ChunkHeapEntry>,
|
||||
clusters: Map<number, Cluster>,
|
||||
mempool: { [txid: string]: MempoolTransactionExtended },
|
||||
maxWeight: number,
|
||||
maxSigops: number,
|
||||
): ProjectedBlock {
|
||||
const block: ProjectedBlock = { txids: [], weight: COINBASE_RESERVED_WEIGHT, sigops: 0 };
|
||||
const deferred: ChunkHeapEntry[] = [];
|
||||
let consecutiveFailed = 0;
|
||||
let full = false;
|
||||
|
||||
while (!heap.isEmpty() && !full) {
|
||||
const entry = heap.pop() as ChunkHeapEntry;
|
||||
const cluster = clusters.get(entry.clusterId);
|
||||
const chunk = cluster?.chunks[entry.chunkIndex];
|
||||
|
||||
if (!cluster || !chunk) {
|
||||
// stale entry
|
||||
} else if (block.weight + entry.weight < maxWeight
|
||||
&& block.sigops + entry.sigops < maxSigops) {
|
||||
consecutiveFailed = 0;
|
||||
block.weight += chunkWeight(chunk, mempool);
|
||||
block.sigops += entry.sigops;
|
||||
for (const tx of chunk.txs) {
|
||||
block.txids.push(tx.txid);
|
||||
}
|
||||
if (entry.chunkIndex + 1 < cluster.chunks.length) {
|
||||
heap.add(makeChunkHeapEntry(cluster, entry.chunkIndex + 1, mempool));
|
||||
}
|
||||
} else {
|
||||
deferred.push(entry);
|
||||
consecutiveFailed++;
|
||||
if (consecutiveFailed > MAX_CONSECUTIVE_FAILURES
|
||||
&& block.weight + MAX_WASTED_WEIGHT > maxWeight) {
|
||||
full = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of deferred) {
|
||||
heap.add(entry);
|
||||
}
|
||||
|
||||
return block;
|
||||
}
|
||||
|
||||
function chunkWeight(
|
||||
chunk: LinearizationChunk,
|
||||
mempool: { [txid: string]: MempoolTransactionExtended },
|
||||
): number {
|
||||
let weight = 0;
|
||||
for (const clusterTx of chunk.txs) {
|
||||
const mempoolTx = mempool[clusterTx.txid];
|
||||
if (mempoolTx) {
|
||||
weight += mempoolTx.weight;
|
||||
}
|
||||
}
|
||||
return weight;
|
||||
}
|
||||
703
backend/src/cluster-mempool/cluster-mempool.ts
Normal file
703
backend/src/cluster-mempool/cluster-mempool.ts
Normal file
|
|
@ -0,0 +1,703 @@
|
|||
import { ClusterTx, DepGraph, sortTopological, subgraph } from './depgraph';
|
||||
import { linearizeCluster, LinearizationChunk } from './linearize';
|
||||
import { ProjectedBlock, assembleBlocks } from './block-builder';
|
||||
import { Ancestor, CpfpClusterData, CpfpClusterTx, MempoolTransactionExtended } from '../mempool.interfaces';
|
||||
import logger from '../logger';
|
||||
|
||||
export interface MempoolDiff {
|
||||
added: MempoolTransactionExtended[];
|
||||
removed: string[];
|
||||
accelerations: { [txid: string]: { feeDelta: number } };
|
||||
}
|
||||
|
||||
export interface ClusterInfo {
|
||||
clusterId: number;
|
||||
chunkIndex: number;
|
||||
chunkFeerate: number;
|
||||
}
|
||||
|
||||
export { ProjectedBlock };
|
||||
|
||||
export interface Cluster {
|
||||
id: number;
|
||||
depgraph: DepGraph;
|
||||
txs: Map<string, ClusterTx>;
|
||||
linearization: ClusterTx[];
|
||||
chunks: LinearizationChunk[];
|
||||
dirty: boolean;
|
||||
}
|
||||
|
||||
export interface TxCpfpData {
|
||||
effectiveFeePerVsize: number;
|
||||
clusterId: number;
|
||||
chunkIndex: number;
|
||||
cpfpDirty: boolean;
|
||||
cpfpChecked: boolean;
|
||||
ancestors: Ancestor[];
|
||||
descendants: Ancestor[];
|
||||
}
|
||||
|
||||
const DEFAULT_COST_BUDGET = 75000;
|
||||
|
||||
export class ClusterMempool {
|
||||
private clusters = new Map<number, Cluster>();
|
||||
private txToCluster = new Map<string, number>();
|
||||
private parentMap = new Map<string, Set<string>>();
|
||||
private spentBy = new Map<string, string>();
|
||||
private mempool: Readonly<{ [txid: string]: MempoolTransactionExtended }>;
|
||||
private accelerations: { [txid: string]: { feeDelta: number } } = {};
|
||||
private nextClusterId = 0;
|
||||
private modifyTxs: boolean;
|
||||
private costBudget: number = DEFAULT_COST_BUDGET;
|
||||
|
||||
constructor(mempool: { [txid: string]: MempoolTransactionExtended }, accelerations?: { [txid: string]: { feeDelta: number } }, modifyTxs: boolean = true, costBudget: number = DEFAULT_COST_BUDGET) {
|
||||
this.mempool = mempool;
|
||||
if (accelerations) {
|
||||
this.accelerations = accelerations;
|
||||
}
|
||||
this.modifyTxs = modifyTxs;
|
||||
this.costBudget = costBudget;
|
||||
this.buildFromMempool();
|
||||
}
|
||||
|
||||
applyMempoolChange(diff: MempoolDiff): void {
|
||||
this.processRemovals(diff.removed);
|
||||
this.splitDisconnectedClusters();
|
||||
this.processAccelerationChanges(diff.accelerations);
|
||||
this.processAdditions(diff.added);
|
||||
this.relinearizeDirtyClusters();
|
||||
}
|
||||
|
||||
getBlocks(n: number, enforceLimit = false): ProjectedBlock[] {
|
||||
return assembleBlocks(n, this.clusters, this.mempool, enforceLimit);
|
||||
}
|
||||
|
||||
getCluster(clusterId: number): CpfpClusterData | null {
|
||||
const cluster = this.clusters.get(clusterId);
|
||||
if (!cluster) {
|
||||
return null;
|
||||
}
|
||||
return this.buildClusterData(cluster);
|
||||
}
|
||||
|
||||
getClusterInfo(txid: string): ClusterInfo | null {
|
||||
const match = this.getClusterForTx(txid);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return this.findChunkInfo(match.cluster, match.clusterTx);
|
||||
}
|
||||
|
||||
getClusterForApi(txid: string): (CpfpClusterData & { chunkIndex: number }) | null {
|
||||
const info = this.getClusterInfo(txid);
|
||||
if (!info) {
|
||||
return null;
|
||||
}
|
||||
const cluster = this.getCluster(info.clusterId);
|
||||
if (!cluster || cluster.txs.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
return { ...cluster, chunkIndex: info.chunkIndex };
|
||||
}
|
||||
|
||||
getCpfpDataForTx(txid: string): TxCpfpData | null {
|
||||
const clusterInfo = this.getClusterForTx(txid);
|
||||
if (!clusterInfo) {
|
||||
return null;
|
||||
}
|
||||
const chunkInfo = this.findChunkInfo(clusterInfo.cluster, clusterInfo.clusterTx);
|
||||
if (!chunkInfo) {
|
||||
return null;
|
||||
}
|
||||
const chunk = clusterInfo.cluster.chunks[chunkInfo?.chunkIndex];
|
||||
const chunkSet = chunk.txs.length > 1 ? new Set(chunk.txs) : null;
|
||||
return {
|
||||
effectiveFeePerVsize: chunkInfo.chunkFeerate,
|
||||
clusterId: clusterInfo.cluster.id,
|
||||
chunkIndex: chunkInfo.chunkIndex,
|
||||
cpfpDirty: true,
|
||||
cpfpChecked: true,
|
||||
ancestors: chunkSet ? this.getChunkRelatives(clusterInfo.clusterTx, chunkSet, 'ancestors') : [],
|
||||
descendants: chunkSet ? this.getChunkRelatives(clusterInfo.clusterTx, chunkSet, 'descendants') : [],
|
||||
};
|
||||
}
|
||||
|
||||
getClusterCount(): number {
|
||||
return this.clusters.size;
|
||||
}
|
||||
|
||||
getTxCount(): number {
|
||||
return this.txToCluster.size;
|
||||
}
|
||||
|
||||
private getClusterForTx(txid: string): { cluster: Cluster; clusterTx: ClusterTx } | null {
|
||||
const clusterId = this.txToCluster.get(txid);
|
||||
if (clusterId === undefined) {
|
||||
return null;
|
||||
}
|
||||
const cluster = this.clusters.get(clusterId);
|
||||
if (!cluster) {
|
||||
return null;
|
||||
}
|
||||
const clusterTx = cluster.txs.get(txid);
|
||||
if (!clusterTx) {
|
||||
return null;
|
||||
}
|
||||
return { cluster, clusterTx };
|
||||
}
|
||||
|
||||
private buildFromMempool(): void {
|
||||
this.buildRelativeMaps();
|
||||
const components = this.findMempoolComponents();
|
||||
for (const component of components) {
|
||||
this.createClusterFromTxids(component);
|
||||
}
|
||||
}
|
||||
|
||||
private buildRelativeMaps(): void {
|
||||
this.parentMap.clear();
|
||||
this.spentBy.clear();
|
||||
for (const txid in this.mempool) {
|
||||
const tx = this.mempool[txid];
|
||||
const txParents = new Set<string>();
|
||||
for (const vin of tx.vin) {
|
||||
if (!vin.is_coinbase && this.mempool[vin.txid]) {
|
||||
txParents.add(vin.txid);
|
||||
this.spentBy.set(`${vin.txid}:${vin.vout}`, txid);
|
||||
}
|
||||
}
|
||||
if (txParents.size > 0) {
|
||||
this.parentMap.set(txid, txParents);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private findMempoolComponents(): Set<string>[] {
|
||||
const visited = new Set<string>();
|
||||
const components: Set<string>[] = [];
|
||||
|
||||
for (const txid in this.mempool) {
|
||||
if (!visited.has(txid)) {
|
||||
const component = this.dfsComponent(txid, visited);
|
||||
components.push(component);
|
||||
}
|
||||
}
|
||||
return components;
|
||||
}
|
||||
|
||||
private dfsComponent(
|
||||
startTxid: string,
|
||||
visited: Set<string>
|
||||
): Set<string> {
|
||||
const component = new Set<string>();
|
||||
const stack = [startTxid];
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
if (current !== undefined && !visited.has(current)) {
|
||||
visited.add(current);
|
||||
component.add(current);
|
||||
|
||||
const txParents = this.parentMap.get(current);
|
||||
if (txParents) {
|
||||
for (const p of txParents) {
|
||||
if (!visited.has(p)) {
|
||||
stack.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const tx = this.mempool[current];
|
||||
if (tx) {
|
||||
for (let vout = 0; vout < tx.vout.length; vout++) {
|
||||
const child = this.spentBy.get(`${current}:${vout}`);
|
||||
if (child && !visited.has(child)) {
|
||||
stack.push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return component;
|
||||
}
|
||||
|
||||
private effectiveFee(txid: string, tx: MempoolTransactionExtended): number {
|
||||
return tx.fee + (this.accelerations[txid]?.feeDelta || 0);
|
||||
}
|
||||
|
||||
private adjustedWeight(tx: MempoolTransactionExtended): number {
|
||||
return Math.max(tx.weight, (tx.sigops || 0) * 20);
|
||||
}
|
||||
|
||||
private createClusterFromTxids(
|
||||
txids: Set<string>
|
||||
): Cluster | null {
|
||||
const clusterId = this.nextClusterId++;
|
||||
const depgraph = new DepGraph();
|
||||
const txMap = new Map<string, ClusterTx>();
|
||||
|
||||
for (const txid of txids) {
|
||||
const tx = this.mempool[txid];
|
||||
if (!tx) {
|
||||
logger.warn(`Warning: missing mempool tx ${txid} during cluster creation, skipping`);
|
||||
return null;
|
||||
}
|
||||
const clusterTx = depgraph.addTransaction(txid, this.effectiveFee(txid, tx), this.adjustedWeight(tx), tx.order ?? 0);
|
||||
txMap.set(txid, clusterTx);
|
||||
}
|
||||
|
||||
for (const txid of txids) {
|
||||
const txParents = this.parentMap.get(txid);
|
||||
if (txParents) {
|
||||
for (const parentTxid of txParents) {
|
||||
if (txids.has(parentTxid)) {
|
||||
const parentTx = txMap.get(parentTxid);
|
||||
const childTx = txMap.get(txid);
|
||||
if (parentTx && childTx) {
|
||||
depgraph.addDependency(parentTx, childTx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { linearization, chunks } = linearizeCluster(depgraph.getTxs(), this.costBudget);
|
||||
|
||||
const cluster: Cluster = {
|
||||
id: clusterId,
|
||||
depgraph,
|
||||
txs: txMap,
|
||||
linearization,
|
||||
chunks,
|
||||
dirty: false,
|
||||
};
|
||||
|
||||
this.clusters.set(clusterId, cluster);
|
||||
for (const txid of txids) {
|
||||
this.txToCluster.set(txid, clusterId);
|
||||
}
|
||||
|
||||
if (this.modifyTxs) {
|
||||
this.writeBackCluster(cluster);
|
||||
}
|
||||
return cluster;
|
||||
}
|
||||
|
||||
public writeBackClusters(): void {
|
||||
for (const cluster of this.clusters.values()) {
|
||||
this.writeBackCluster(cluster);
|
||||
}
|
||||
}
|
||||
|
||||
private writeBackCluster(cluster: Cluster): void {
|
||||
for (let chunkIdx = 0; chunkIdx < cluster.chunks.length; chunkIdx++) {
|
||||
const chunk = cluster.chunks[chunkIdx];
|
||||
const chunkFeerate = chunk.weight > 0 ? (chunk.fee * 4) / chunk.weight : 0;
|
||||
const chunkSet = chunk.txs.length > 1 ? new Set(chunk.txs) : null;
|
||||
for (const clusterTx of chunk.txs) {
|
||||
this.writeBackTx(cluster, clusterTx, chunkIdx, chunkFeerate, chunkSet);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private writeBackTx(
|
||||
cluster: Cluster,
|
||||
clusterTx: ClusterTx,
|
||||
chunkIdx: number,
|
||||
chunkFeerate: number,
|
||||
chunkSet: Set<ClusterTx> | null
|
||||
): void {
|
||||
const txid = clusterTx.txid;
|
||||
if (!this.mempool[txid]) {
|
||||
logger.warn(`ClusterMempool.writeBackTx: ${txid} missing from mempool (cluster ${cluster.id})`);
|
||||
return;
|
||||
}
|
||||
const tx = this.mempool[txid];
|
||||
if (tx.effectiveFeePerVsize !== chunkFeerate || tx.clusterId !== cluster.id) {
|
||||
tx.cpfpDirty = true;
|
||||
}
|
||||
tx.effectiveFeePerVsize = chunkFeerate;
|
||||
tx.clusterId = cluster.id;
|
||||
tx.chunkIndex = chunkIdx;
|
||||
tx.cpfpChecked = true;
|
||||
|
||||
if (chunkSet) {
|
||||
tx.ancestors = this.getChunkRelatives(clusterTx, chunkSet, 'ancestors');
|
||||
tx.descendants = this.getChunkRelatives(clusterTx, chunkSet, 'descendants');
|
||||
} else {
|
||||
tx.ancestors = [];
|
||||
tx.descendants = [];
|
||||
}
|
||||
}
|
||||
|
||||
private getChunkRelatives(
|
||||
clusterTx: ClusterTx,
|
||||
chunkSet: Set<ClusterTx>,
|
||||
direction: 'ancestors' | 'descendants'
|
||||
): { txid: string; fee: number; weight: number }[] {
|
||||
const relatives: { txid: string; fee: number; weight: number }[] = [];
|
||||
const related = direction === 'ancestors' ? clusterTx.ancestors : clusterTx.descendants;
|
||||
for (const rel of related) {
|
||||
if (rel !== clusterTx && chunkSet.has(rel)) {
|
||||
const mempoolTx = this.mempool[rel.txid];
|
||||
if (mempoolTx) {
|
||||
relatives.push({ txid: rel.txid, fee: mempoolTx.fee, weight: mempoolTx.weight });
|
||||
} else {
|
||||
logger.warn(`ClusterMempool.getChunkRelatives: ${rel.txid} missing from mempool`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return relatives;
|
||||
}
|
||||
|
||||
private processRemovals(removed: string[]): void {
|
||||
for (const txid of removed) {
|
||||
const tx = this.mempool[txid];
|
||||
if (tx) {
|
||||
for (const vin of tx.vin) {
|
||||
if (!vin.is_coinbase) {
|
||||
this.spentBy.delete(`${vin.txid}:${vin.vout}`);
|
||||
}
|
||||
}
|
||||
} else if (this.txToCluster.has(txid)) {
|
||||
logger.warn(`ClusterMempool.processRemovals: ${txid} missing from mempool, spentBy cleanup skipped`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const txid of removed) {
|
||||
const match = this.getClusterForTx(txid);
|
||||
if (match) {
|
||||
match.cluster.depgraph.removeTransactions(new Set([match.clusterTx]));
|
||||
match.cluster.txs.delete(txid);
|
||||
match.cluster.linearization = match.cluster.linearization.filter(t => t !== match.clusterTx);
|
||||
this.txToCluster.delete(txid);
|
||||
match.cluster.dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private splitDisconnectedClusters(): void {
|
||||
for (const [clusterId, cluster] of this.clusters.entries()) {
|
||||
if (cluster.dirty) {
|
||||
if (cluster.depgraph.size === 0) {
|
||||
this.clusters.delete(clusterId);
|
||||
} else {
|
||||
const components = cluster.depgraph.findConnectedComponents();
|
||||
if (components.length > 1) {
|
||||
this.clusters.delete(clusterId);
|
||||
for (const component of components) {
|
||||
this.splitComponentToCluster(cluster, component);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private splitComponentToCluster(sourceCluster: Cluster, component: Set<ClusterTx>): void {
|
||||
const newClusterId = this.nextClusterId++;
|
||||
const { depgraph: newDepgraph, txMap } = subgraph(component);
|
||||
|
||||
const newTxs = new Map<string, ClusterTx>();
|
||||
for (const oldTx of component) {
|
||||
const newTx = txMap.get(oldTx);
|
||||
if (newTx) {
|
||||
newTxs.set(oldTx.txid, newTx);
|
||||
this.txToCluster.set(oldTx.txid, newClusterId);
|
||||
}
|
||||
}
|
||||
|
||||
const newLinearization: ClusterTx[] = [];
|
||||
for (const oldTx of sourceCluster.linearization) {
|
||||
if (component.has(oldTx)) {
|
||||
const newTx = txMap.get(oldTx);
|
||||
if (newTx) {
|
||||
newLinearization.push(newTx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newCluster: Cluster = {
|
||||
id: newClusterId,
|
||||
dirty: true,
|
||||
depgraph: newDepgraph,
|
||||
txs: newTxs,
|
||||
linearization: newLinearization,
|
||||
chunks: [],
|
||||
};
|
||||
this.clusters.set(newClusterId, newCluster);
|
||||
}
|
||||
|
||||
private processAdditions(added: MempoolTransactionExtended[]): void {
|
||||
for (const tx of added) {
|
||||
const txid = tx.txid;
|
||||
|
||||
for (const vin of tx.vin) {
|
||||
if (!vin.is_coinbase) {
|
||||
this.spentBy.set(`${vin.txid}:${vin.vout}`, txid);
|
||||
}
|
||||
}
|
||||
|
||||
const { relatedClusterIds, parentTxids, childTxids } = this.findRelatedClusters(tx);
|
||||
|
||||
if (relatedClusterIds.size === 0) {
|
||||
this.addSingletonCluster(tx);
|
||||
} else if (relatedClusterIds.size === 1) {
|
||||
this.addToExistingCluster(tx, relatedClusterIds, parentTxids, childTxids);
|
||||
} else {
|
||||
this.mergeAndAddToCluster(tx, relatedClusterIds, parentTxids, childTxids);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private findRelatedClusters(tx: MempoolTransactionExtended): {
|
||||
relatedClusterIds: Set<number>;
|
||||
parentTxids: string[];
|
||||
childTxids: string[];
|
||||
} {
|
||||
const relatedClusterIds = new Set<number>();
|
||||
const parentTxids: string[] = [];
|
||||
const childTxids: string[] = [];
|
||||
|
||||
for (const vin of tx.vin) {
|
||||
if (!vin.is_coinbase && this.mempool[vin.txid]) {
|
||||
const parentCluster = this.txToCluster.get(vin.txid);
|
||||
if (parentCluster !== undefined) {
|
||||
relatedClusterIds.add(parentCluster);
|
||||
parentTxids.push(vin.txid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let vout = 0; vout < tx.vout.length; vout++) {
|
||||
const childTxid = this.spentBy.get(`${tx.txid}:${vout}`);
|
||||
if (childTxid && this.mempool[childTxid]) {
|
||||
const childCluster = this.txToCluster.get(childTxid);
|
||||
if (childCluster !== undefined) {
|
||||
relatedClusterIds.add(childCluster);
|
||||
childTxids.push(childTxid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { relatedClusterIds, parentTxids, childTxids };
|
||||
}
|
||||
|
||||
private addSingletonCluster(tx: MempoolTransactionExtended): void {
|
||||
const txid = tx.txid;
|
||||
const clusterId = this.nextClusterId++;
|
||||
const depgraph = new DepGraph();
|
||||
const clusterTx = depgraph.addTransaction(txid, this.effectiveFee(txid, tx), this.adjustedWeight(tx), tx.order ?? 0);
|
||||
|
||||
const cluster: Cluster = {
|
||||
id: clusterId,
|
||||
dirty: true,
|
||||
depgraph,
|
||||
txs: new Map([[txid, clusterTx]]),
|
||||
linearization: [clusterTx],
|
||||
chunks: [],
|
||||
};
|
||||
this.clusters.set(clusterId, cluster);
|
||||
this.txToCluster.set(txid, clusterId);
|
||||
}
|
||||
|
||||
private addToExistingCluster(
|
||||
tx: MempoolTransactionExtended,
|
||||
relatedClusterIds: Set<number>,
|
||||
parentTxids: string[],
|
||||
childTxids: string[],
|
||||
): void {
|
||||
const txid = tx.txid;
|
||||
const clusterId = relatedClusterIds.values().next().value;
|
||||
const cluster = this.clusters.get(clusterId);
|
||||
if (!cluster) {
|
||||
return;
|
||||
}
|
||||
const clusterTx = cluster.depgraph.addTransaction(txid, this.effectiveFee(txid, tx), this.adjustedWeight(tx), tx.order ?? 0);
|
||||
cluster.txs.set(txid, clusterTx);
|
||||
cluster.linearization.push(clusterTx);
|
||||
this.txToCluster.set(txid, clusterId);
|
||||
|
||||
this.addParentDeps(cluster, clusterTx, parentTxids);
|
||||
this.addChildDeps(cluster, clusterTx, childTxids);
|
||||
cluster.dirty = true;
|
||||
}
|
||||
|
||||
private mergeAndAddToCluster(
|
||||
tx: MempoolTransactionExtended,
|
||||
relatedClusterIds: Set<number>,
|
||||
parentTxids: string[],
|
||||
childTxids: string[],
|
||||
): void {
|
||||
const clusterIterator = relatedClusterIds.values();
|
||||
const primaryId: number = clusterIterator.next().value;
|
||||
const primary = this.clusters.get(primaryId);
|
||||
if (!primary) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const clusterId of clusterIterator) {
|
||||
const other = this.clusters.get(clusterId);
|
||||
if (other) {
|
||||
this.mergeClusterInto(primary, other);
|
||||
this.clusters.delete(clusterId);
|
||||
}
|
||||
}
|
||||
|
||||
const clusterTx = primary.depgraph.addTransaction(tx.txid, this.effectiveFee(tx.txid, tx), this.adjustedWeight(tx), tx.order ?? 0);
|
||||
primary.txs.set(tx.txid, clusterTx);
|
||||
primary.linearization.push(clusterTx);
|
||||
this.txToCluster.set(tx.txid, primaryId);
|
||||
|
||||
this.addParentDeps(primary, clusterTx, parentTxids);
|
||||
this.addChildDeps(primary, clusterTx, childTxids);
|
||||
primary.dirty = true;
|
||||
}
|
||||
|
||||
private addParentDeps(cluster: Cluster, childTx: ClusterTx, parentTxids: string[]): void {
|
||||
for (const parentTxid of parentTxids) {
|
||||
const parentTx = cluster.txs.get(parentTxid);
|
||||
if (parentTx) {
|
||||
cluster.depgraph.addDependency(parentTx, childTx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private addChildDeps(cluster: Cluster, parentTx: ClusterTx, childTxids: string[]): void {
|
||||
for (const childTxid of childTxids) {
|
||||
const childTx = cluster.txs.get(childTxid);
|
||||
if (childTx) {
|
||||
cluster.depgraph.addDependency(parentTx, childTx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private mergeClusterInto(primary: Cluster, other: Cluster): void {
|
||||
for (const [txid, otherTx] of other.txs) {
|
||||
const newTx = primary.depgraph.addTransaction(txid, otherTx.effectiveFee, otherTx.weight, otherTx.order);
|
||||
primary.txs.set(txid, newTx);
|
||||
this.txToCluster.set(txid, primary.id);
|
||||
}
|
||||
|
||||
for (const otherTx of other.depgraph.getTxs()) {
|
||||
for (const parent of otherTx.parents) {
|
||||
const newChild = primary.txs.get(otherTx.txid);
|
||||
const newParent = primary.txs.get(parent.txid);
|
||||
if (newChild && newParent) {
|
||||
primary.depgraph.addDependency(newParent, newChild);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const otherTx of other.linearization) {
|
||||
const newTx = primary.txs.get(otherTx.txid);
|
||||
if (newTx) {
|
||||
primary.linearization.push(newTx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private processAccelerationChanges(newAccelerations: { [txid: string]: { feeDelta: number } }): void {
|
||||
const changed = new Set<string>();
|
||||
for (const txid in newAccelerations) {
|
||||
if ((newAccelerations[txid]?.feeDelta || 0) !== (this.accelerations[txid]?.feeDelta || 0)) {
|
||||
changed.add(txid);
|
||||
}
|
||||
}
|
||||
for (const txid in this.accelerations) {
|
||||
if (!newAccelerations[txid]) {
|
||||
changed.add(txid);
|
||||
}
|
||||
}
|
||||
this.accelerations = newAccelerations;
|
||||
for (const txid of changed) {
|
||||
const tx = this.mempool[txid];
|
||||
if (!tx) {
|
||||
continue;
|
||||
}
|
||||
const match = this.getClusterForTx(txid);
|
||||
if (match) {
|
||||
match.clusterTx.effectiveFee = this.effectiveFee(txid, tx);
|
||||
match.cluster.dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private relinearizeDirtyClusters(): void {
|
||||
for (const [clusterId, cluster] of this.clusters.entries()) {
|
||||
if (cluster.dirty) {
|
||||
cluster.dirty = false;
|
||||
const newId = this.nextClusterId++;
|
||||
this.clusters.delete(clusterId);
|
||||
cluster.id = newId;
|
||||
this.clusters.set(newId, cluster);
|
||||
for (const txid of cluster.txs.keys()) {
|
||||
this.txToCluster.set(txid, newId);
|
||||
}
|
||||
|
||||
const { linearization, chunks } = linearizeCluster(
|
||||
cluster.depgraph.getTxs(),
|
||||
this.costBudget,
|
||||
cluster.linearization,
|
||||
);
|
||||
cluster.linearization = linearization;
|
||||
cluster.chunks = chunks;
|
||||
|
||||
if (this.modifyTxs) {
|
||||
this.writeBackCluster(cluster);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private buildClusterData(cluster: Cluster): CpfpClusterData {
|
||||
const txs: CpfpClusterTx[] = [];
|
||||
const txToFlatIdx = new Map<ClusterTx, number>();
|
||||
|
||||
for (const chunk of cluster.chunks) {
|
||||
const ordered = sortTopological(new Set(chunk.txs));
|
||||
for (const clusterTx of ordered) {
|
||||
if (this.mempool[clusterTx.txid]) {
|
||||
txToFlatIdx.set(clusterTx, txs.length);
|
||||
const parents: number[] = [];
|
||||
for (const parentTx of clusterTx.parents) {
|
||||
const flatIdx = txToFlatIdx.get(parentTx);
|
||||
if (flatIdx !== undefined) {
|
||||
parents.push(flatIdx);
|
||||
}
|
||||
}
|
||||
const mempoolTx = this.mempool[clusterTx.txid];
|
||||
txs.push({ txid: clusterTx.txid, fee: mempoolTx.fee, weight: mempoolTx.weight, parents });
|
||||
} else {
|
||||
logger.warn(`ClusterMempool.buildClusterData: ${clusterTx.txid} missing from mempool (cluster ${cluster.id})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let offset = 0;
|
||||
const chunks = cluster.chunks.map(chunk => {
|
||||
const count = chunk.txs.length;
|
||||
const chunkEntry = {
|
||||
txs: Array.from({ length: count }, (_, i) => offset + i),
|
||||
feerate: chunk.weight > 0 ? (chunk.fee * 4) / chunk.weight : 0,
|
||||
};
|
||||
offset += count;
|
||||
return chunkEntry;
|
||||
});
|
||||
|
||||
return { txs, chunks };
|
||||
}
|
||||
|
||||
private findChunkInfo(cluster: Cluster, tx: ClusterTx): ClusterInfo | null {
|
||||
for (let chunkIdx = 0; chunkIdx < cluster.chunks.length; chunkIdx++) {
|
||||
const chunk = cluster.chunks[chunkIdx];
|
||||
if (chunk.txs.includes(tx)) {
|
||||
return {
|
||||
clusterId: cluster.id,
|
||||
chunkIndex: chunkIdx,
|
||||
chunkFeerate: chunk.weight > 0 ? (chunk.fee * 4) / chunk.weight : 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
165
backend/src/cluster-mempool/depgraph.ts
Normal file
165
backend/src/cluster-mempool/depgraph.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import logger from '../logger';
|
||||
|
||||
export class ClusterTx {
|
||||
txid: string;
|
||||
effectiveFee: number;
|
||||
weight: number;
|
||||
order: number;
|
||||
ancestors: Set<ClusterTx>;
|
||||
descendants: Set<ClusterTx>;
|
||||
parents: Set<ClusterTx>;
|
||||
children: Set<ClusterTx>;
|
||||
|
||||
constructor(txid: string, effectiveFee: number, weight: number, order: number) {
|
||||
this.txid = txid;
|
||||
this.effectiveFee = effectiveFee;
|
||||
this.weight = weight;
|
||||
this.order = order;
|
||||
this.ancestors = new Set([this]);
|
||||
this.descendants = new Set([this]);
|
||||
this.parents = new Set();
|
||||
this.children = new Set();
|
||||
}
|
||||
}
|
||||
|
||||
export class DepGraph {
|
||||
private txs: Set<ClusterTx> = new Set();
|
||||
|
||||
get size(): number {
|
||||
return this.txs.size;
|
||||
}
|
||||
|
||||
addTransaction(txid: string, fee: number, weight: number, order: number = 0): ClusterTx {
|
||||
const tx = new ClusterTx(txid, fee, weight, order);
|
||||
this.txs.add(tx);
|
||||
return tx;
|
||||
}
|
||||
|
||||
addDependency(parent: ClusterTx, child: ClusterTx): void {
|
||||
if (!this.txs.has(parent) || !this.txs.has(child)) {
|
||||
logger.warn(`Warning: invalid dependency, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
parent.children.add(child);
|
||||
child.parents.add(parent);
|
||||
|
||||
if (child.ancestors.has(parent)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const descendant of child.descendants) {
|
||||
for (const ancestor of parent.ancestors) {
|
||||
descendant.ancestors.add(ancestor);
|
||||
ancestor.descendants.add(descendant);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeTransactions(toRemove: Set<ClusterTx>): void {
|
||||
for (const tx of toRemove) {
|
||||
for (const parent of tx.parents) {
|
||||
parent.children.delete(tx);
|
||||
}
|
||||
for (const child of tx.children) {
|
||||
child.parents.delete(tx);
|
||||
}
|
||||
this.txs.delete(tx);
|
||||
}
|
||||
|
||||
for (const tx of this.txs) {
|
||||
for (const removed of toRemove) {
|
||||
tx.ancestors.delete(removed);
|
||||
tx.descendants.delete(removed);
|
||||
}
|
||||
}
|
||||
|
||||
this.rederiveAncestorsDescendants();
|
||||
}
|
||||
|
||||
private rederiveAncestorsDescendants(): void {
|
||||
const ordered = [...this.txs].sort((a, b) => a.ancestors.size - b.ancestors.size);
|
||||
for (const tx of this.txs) {
|
||||
tx.ancestors = new Set([tx]);
|
||||
tx.descendants = new Set([tx]);
|
||||
}
|
||||
for (const tx of ordered) {
|
||||
for (const parent of tx.parents) {
|
||||
for (const ancestor of parent.ancestors) {
|
||||
tx.ancestors.add(ancestor);
|
||||
}
|
||||
}
|
||||
for (const ancestor of tx.ancestors) {
|
||||
ancestor.descendants.add(tx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hasTx(tx: ClusterTx): boolean {
|
||||
return this.txs.has(tx);
|
||||
}
|
||||
|
||||
getTxs(): Set<ClusterTx> {
|
||||
return this.txs;
|
||||
}
|
||||
|
||||
findConnectedComponents(): Set<ClusterTx>[] {
|
||||
const visited = new Set<ClusterTx>();
|
||||
const components: Set<ClusterTx>[] = [];
|
||||
|
||||
for (const tx of this.txs) {
|
||||
if (!visited.has(tx)) {
|
||||
const component = new Set<ClusterTx>();
|
||||
const stack: ClusterTx[] = [tx];
|
||||
while (stack.length > 0) {
|
||||
const node = stack.pop();
|
||||
if (node && !visited.has(node)) {
|
||||
visited.add(node);
|
||||
component.add(node);
|
||||
for (const a of node.ancestors) {
|
||||
if (!visited.has(a) && this.txs.has(a)) {
|
||||
stack.push(a);
|
||||
}
|
||||
}
|
||||
for (const d of node.descendants) {
|
||||
if (!visited.has(d) && this.txs.has(d)) {
|
||||
stack.push(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
components.push(component);
|
||||
}
|
||||
}
|
||||
return components;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export function sortTopological(subset: Set<ClusterTx>): ClusterTx[] {
|
||||
return [...subset].sort((a, b) => a.ancestors.size - b.ancestors.size);
|
||||
}
|
||||
|
||||
export function subgraph(txSubset: Set<ClusterTx>): { depgraph: DepGraph; txMap: Map<ClusterTx, ClusterTx> } {
|
||||
const newGraph = new DepGraph();
|
||||
const txMap = new Map<ClusterTx, ClusterTx>();
|
||||
|
||||
for (const oldTx of txSubset) {
|
||||
const newTx = newGraph.addTransaction(oldTx.txid, oldTx.effectiveFee, oldTx.weight, oldTx.order);
|
||||
txMap.set(oldTx, newTx);
|
||||
}
|
||||
|
||||
for (const oldTx of txSubset) {
|
||||
for (const parent of oldTx.parents) {
|
||||
if (txSubset.has(parent)) {
|
||||
const newChild = txMap.get(oldTx);
|
||||
const newParent = txMap.get(parent);
|
||||
if (newChild && newParent) {
|
||||
newGraph.addDependency(newParent, newChild);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { depgraph: newGraph, txMap };
|
||||
}
|
||||
1314
backend/src/cluster-mempool/linearize.ts
Normal file
1314
backend/src/cluster-mempool/linearize.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -34,6 +34,8 @@ interface IConfig {
|
|||
POOLS_JSON_TREE_URL: string,
|
||||
POOLS_UPDATE_DELAY: number,
|
||||
AUDIT: boolean;
|
||||
CLUSTER_MEMPOOL: boolean;
|
||||
CLUSTER_MEMPOOL_INDEXING: boolean;
|
||||
RUST_GBT: boolean;
|
||||
LIMIT_GBT: boolean;
|
||||
CPFP_INDEXING: boolean;
|
||||
|
|
@ -205,6 +207,8 @@ const defaults: IConfig = {
|
|||
'POOLS_JSON_TREE_URL': 'https://api.github.com/repos/mempool/mining-pools/git/trees/master',
|
||||
'POOLS_UPDATE_DELAY': 604800, // in seconds, default is one week
|
||||
'AUDIT': false,
|
||||
'CLUSTER_MEMPOOL': false,
|
||||
'CLUSTER_MEMPOOL_INDEXING': false,
|
||||
'RUST_GBT': true,
|
||||
'LIMIT_GBT': false,
|
||||
'CPFP_INDEXING': false,
|
||||
|
|
|
|||
|
|
@ -334,7 +334,6 @@ class Server {
|
|||
if (config.MEMPOOL.ENABLED) {
|
||||
statistics.setNewStatisticsEntryCallback(websocketHandler.handleNewStatistic.bind(websocketHandler));
|
||||
memPool.setAsyncMempoolChangedCallback(websocketHandler.$handleMempoolChange.bind(websocketHandler));
|
||||
blocks.setNewAsyncBlockCallback(websocketHandler.handleNewBlock.bind(websocketHandler));
|
||||
}
|
||||
if (config.FIAT_PRICE.ENABLED) {
|
||||
priceUpdater.setRatesChangedCallback(websocketHandler.handleNewConversionRates.bind(websocketHandler));
|
||||
|
|
|
|||
|
|
@ -139,12 +139,12 @@ class Indexer {
|
|||
switch (task) {
|
||||
case 'blocksPrices': {
|
||||
if (!['testnet', 'signet', 'testnet4', 'regtest'].includes(config.MEMPOOL.NETWORK) && config.FIAT_PRICE.ENABLED) {
|
||||
let lastestPriceId;
|
||||
let latestPriceId;
|
||||
try {
|
||||
lastestPriceId = await PricesRepository.$getLatestPriceId();
|
||||
latestPriceId = await PricesRepository.$getLatestPriceId();
|
||||
} catch (e) {
|
||||
logger.debug('failed to fetch latest price id from db: ' + (e instanceof Error ? e.message : e));
|
||||
} if (priceUpdater.historyInserted === false || lastestPriceId === null) {
|
||||
} if (priceUpdater.historyInserted === false || latestPriceId === null) {
|
||||
logger.debug(`Blocks prices indexer is waiting for the price updater to complete`, logger.tags.mining);
|
||||
this.scheduleSingleTask(task, 10000);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,11 @@ export interface PoolStats extends PoolInfo {
|
|||
emptyBlocks: number;
|
||||
}
|
||||
|
||||
export enum TemplateAlgorithm {
|
||||
legacy = 0,
|
||||
clusterMempool = 1,
|
||||
}
|
||||
|
||||
export interface BlockAudit {
|
||||
version: number,
|
||||
time: number,
|
||||
|
|
@ -45,6 +50,7 @@ export interface BlockAudit {
|
|||
expectedFees?: number,
|
||||
expectedWeight?: number,
|
||||
template?: any[];
|
||||
templateAlgorithm?: TemplateAlgorithm,
|
||||
}
|
||||
|
||||
export interface TransactionAudit {
|
||||
|
|
@ -132,6 +138,8 @@ export interface TransactionExtended extends IEsploraApi.Transaction {
|
|||
replacement?: boolean;
|
||||
uid?: number;
|
||||
flags?: number;
|
||||
clusterId?: number;
|
||||
chunkIndex?: number;
|
||||
}
|
||||
|
||||
export interface MempoolTransactionExtended extends TransactionExtended {
|
||||
|
|
@ -227,6 +235,7 @@ export interface CpfpInfo {
|
|||
adjustedVsize?: number,
|
||||
acceleration?: boolean,
|
||||
fee?: number;
|
||||
cluster?: CpfpClusterData & { chunkIndex: number };
|
||||
}
|
||||
|
||||
export interface TransactionStripped {
|
||||
|
|
@ -388,6 +397,25 @@ export interface CpfpCluster {
|
|||
height: number,
|
||||
txs: Ancestor[],
|
||||
effectiveFeePerVsize: number,
|
||||
templateAlgorithm?: TemplateAlgorithm,
|
||||
clusterData?: CpfpClusterData,
|
||||
}
|
||||
|
||||
export interface CpfpClusterTx {
|
||||
txid: string;
|
||||
fee: number;
|
||||
weight: number;
|
||||
parents: number[];
|
||||
}
|
||||
|
||||
export interface CpfpClusterChunk {
|
||||
txs: number[];
|
||||
feerate: number;
|
||||
}
|
||||
|
||||
export interface CpfpClusterData {
|
||||
txs: CpfpClusterTx[];
|
||||
chunks: CpfpClusterChunk[];
|
||||
}
|
||||
|
||||
export interface CpfpSummary {
|
||||
|
|
|
|||
|
|
@ -114,6 +114,7 @@ class AuditReplication {
|
|||
});
|
||||
await blocksAuditsRepository.$saveAudit({
|
||||
version: auditSummary.version || 0,
|
||||
templateAlgorithm: auditSummary.templateAlgorithm ?? 0,
|
||||
hash: blockHash,
|
||||
height: auditSummary.height,
|
||||
time: auditSummary.timestamp || auditSummary.time,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import DB from '../database';
|
||||
import logger from '../logger';
|
||||
import bitcoinApi from '../api/bitcoin/bitcoin-api-factory';
|
||||
import { BlockAudit, AuditScore, TransactionAudit, TransactionStripped } from '../mempool.interfaces';
|
||||
import { BlockAudit, AuditScore, TransactionAudit, TransactionStripped, TemplateAlgorithm } from '../mempool.interfaces';
|
||||
|
||||
interface MigrationAudit {
|
||||
version: number,
|
||||
|
|
@ -18,8 +18,8 @@ class BlocksAuditRepositories {
|
|||
/** @asyncSafe */
|
||||
public async $saveAudit(audit: BlockAudit): Promise<void> {
|
||||
try {
|
||||
await DB.query(`INSERT INTO blocks_audits(version, time, height, hash, unseen_txs, missing_txs, added_txs, prioritized_txs, fresh_txs, sigop_txs, fullrbf_txs, accelerated_txs, match_rate, expected_fees, expected_weight)
|
||||
VALUE (?, FROM_UNIXTIME(?), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [audit.version, audit.time, audit.height, audit.hash, JSON.stringify(audit.unseenTxs), JSON.stringify(audit.missingTxs),
|
||||
await DB.query(`INSERT INTO blocks_audits(version, template_algo, time, height, hash, unseen_txs, missing_txs, added_txs, prioritized_txs, fresh_txs, sigop_txs, fullrbf_txs, accelerated_txs, match_rate, expected_fees, expected_weight)
|
||||
VALUE (?, ?, FROM_UNIXTIME(?), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [audit.version, audit.templateAlgorithm ?? 0, audit.time, audit.height, audit.hash, JSON.stringify(audit.unseenTxs), JSON.stringify(audit.missingTxs),
|
||||
JSON.stringify(audit.addedTxs), JSON.stringify(audit.prioritizedTxs), JSON.stringify(audit.freshTxs), JSON.stringify(audit.sigopTxs), JSON.stringify(audit.fullrbfTxs), JSON.stringify(audit.acceleratedTxs), audit.matchRate, audit.expectedFees, audit.expectedWeight]);
|
||||
} catch (e: any) {
|
||||
if (e.errno === 1062) { // ER_DUP_ENTRY - This scenario is possible upon node backend restart
|
||||
|
|
@ -80,6 +80,7 @@ class BlocksAuditRepositories {
|
|||
const [rows]: any[] = await DB.query(
|
||||
`SELECT
|
||||
blocks_audits.version,
|
||||
blocks_audits.template_algo as templateAlgorithm,
|
||||
blocks_audits.height,
|
||||
blocks_audits.hash as id,
|
||||
UNIX_TIMESTAMP(blocks_audits.time) as timestamp,
|
||||
|
|
@ -120,6 +121,23 @@ class BlocksAuditRepositories {
|
|||
}
|
||||
}
|
||||
|
||||
/** @asyncSafe */
|
||||
public async $getBlockTemplateAlgo(hash: string): Promise<TemplateAlgorithm | null> {
|
||||
try {
|
||||
const [rows]: any[] = await DB.query(
|
||||
`SELECT template_algo FROM blocks_audits WHERE hash = ?`,
|
||||
[hash]
|
||||
);
|
||||
if (rows.length) {
|
||||
return rows[0].template_algo as TemplateAlgorithm;
|
||||
}
|
||||
return null;
|
||||
} catch (e: any) {
|
||||
logger.err(`Cannot fetch block template algo from db. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** @asyncSafe */
|
||||
public async $getBlockTxAudit(hash: string, txid: string): Promise<TransactionAudit | null> {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,31 +1,47 @@
|
|||
import { RowDataPacket } from 'mysql2';
|
||||
import DB from '../database';
|
||||
import logger from '../logger';
|
||||
import { Ancestor, CpfpCluster } from '../mempool.interfaces';
|
||||
import { Ancestor, CpfpCluster, CpfpClusterData, CpfpClusterTx, TemplateAlgorithm } from '../mempool.interfaces';
|
||||
import transactionRepository from '../repositories/TransactionRepository';
|
||||
|
||||
class CpfpRepository {
|
||||
public async $batchSaveClusters(clusters: { root: string, height: number, txs: Ancestor[], effectiveFeePerVsize: number }[]): Promise<boolean> {
|
||||
public async $batchSaveClusters(clusters: CpfpCluster[]): Promise<boolean> {
|
||||
try {
|
||||
const clusterValues: [string, number, Buffer, number][] = [];
|
||||
const clusterValues: [string, number, Buffer, number, number][] = [];
|
||||
const txs: { txid: string, cluster: string }[] = [];
|
||||
|
||||
for (const cluster of clusters) {
|
||||
if (cluster.txs?.length) {
|
||||
const roundedEffectiveFee = Math.round(cluster.effectiveFeePerVsize * 100) / 100;
|
||||
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) {
|
||||
const isCM = cluster.templateAlgorithm === TemplateAlgorithm.clusterMempool;
|
||||
|
||||
if (isCM && cluster.clusterData) {
|
||||
clusterValues.push([
|
||||
cluster.root,
|
||||
cluster.height,
|
||||
Buffer.from(this.pack(cluster.txs)),
|
||||
cluster.effectiveFeePerVsize
|
||||
Buffer.from(this.packCM(cluster.clusterData)),
|
||||
0,
|
||||
TemplateAlgorithm.clusterMempool,
|
||||
]);
|
||||
for (const tx of cluster.txs) {
|
||||
for (const tx of cluster.clusterData.txs) {
|
||||
txs.push({ txid: tx.txid, cluster: cluster.root });
|
||||
}
|
||||
} else {
|
||||
const roundedEffectiveFee = Math.round(cluster.effectiveFeePerVsize * 100) / 100;
|
||||
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) {
|
||||
clusterValues.push([
|
||||
cluster.root,
|
||||
cluster.height,
|
||||
Buffer.from(this.pack(cluster.txs)),
|
||||
cluster.effectiveFeePerVsize,
|
||||
TemplateAlgorithm.legacy,
|
||||
]);
|
||||
for (const tx of cluster.txs) {
|
||||
txs.push({ txid: tx.txid, cluster: cluster.root });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -42,11 +58,11 @@ class CpfpRepository {
|
|||
while (chunkIndex < clusterValues.length) {
|
||||
const chunk = clusterValues.slice(chunkIndex, chunkIndex + maxChunk);
|
||||
let query = `
|
||||
INSERT IGNORE INTO compact_cpfp_clusters(root, height, txs, fee_rate)
|
||||
INSERT IGNORE INTO compact_cpfp_clusters(root, height, txs, fee_rate, template_algo)
|
||||
VALUES
|
||||
`;
|
||||
query += chunk.map(chunk => {
|
||||
return (' (UNHEX(?), ?, ?, ?)');
|
||||
return (' (UNHEX(?), ?, ?, ?, ?)');
|
||||
}) + ';';
|
||||
const values = chunk.flat();
|
||||
queries.push({
|
||||
|
|
@ -86,8 +102,16 @@ class CpfpRepository {
|
|||
);
|
||||
const cluster = clusterRows[0];
|
||||
if (cluster?.txs) {
|
||||
cluster.effectiveFeePerVsize = cluster.fee_rate;
|
||||
cluster.txs = this.unpack(cluster.txs);
|
||||
if (cluster.template_algo === TemplateAlgorithm.clusterMempool) {
|
||||
cluster.templateAlgorithm = TemplateAlgorithm.clusterMempool;
|
||||
cluster.clusterData = this.unpackCM(cluster.txs);
|
||||
cluster.txs = cluster.clusterData.txs.map(tx => ({ txid: tx.txid, weight: tx.weight, fee: tx.fee }));
|
||||
cluster.effectiveFeePerVsize = 0;
|
||||
} else {
|
||||
cluster.templateAlgorithm = TemplateAlgorithm.legacy;
|
||||
cluster.effectiveFeePerVsize = cluster.fee_rate;
|
||||
cluster.txs = this.unpack(cluster.txs);
|
||||
}
|
||||
return cluster;
|
||||
}
|
||||
return;
|
||||
|
|
@ -105,8 +129,16 @@ class CpfpRepository {
|
|||
);
|
||||
return clusterRows.map(cluster => {
|
||||
if (cluster?.txs) {
|
||||
cluster.effectiveFeePerVsize = cluster.fee_rate;
|
||||
cluster.txs = this.unpack(cluster.txs);
|
||||
if (cluster.template_algo === TemplateAlgorithm.clusterMempool) {
|
||||
cluster.templateAlgorithm = TemplateAlgorithm.clusterMempool;
|
||||
cluster.clusterData = this.unpackCM(cluster.txs);
|
||||
cluster.txs = cluster.clusterData.txs.map(tx => ({ txid: tx.txid, weight: tx.weight, fee: tx.fee }));
|
||||
cluster.effectiveFeePerVsize = 0;
|
||||
} else {
|
||||
cluster.templateAlgorithm = TemplateAlgorithm.legacy;
|
||||
cluster.effectiveFeePerVsize = cluster.fee_rate;
|
||||
cluster.txs = this.unpack(cluster.txs);
|
||||
}
|
||||
return cluster;
|
||||
} else {
|
||||
return null;
|
||||
|
|
@ -119,16 +151,16 @@ class CpfpRepository {
|
|||
try {
|
||||
const [rows] = await DB.query(
|
||||
`
|
||||
SELECT txs, height, root from compact_cpfp_clusters
|
||||
SELECT txs, height, root, template_algo from compact_cpfp_clusters
|
||||
WHERE height >= ?
|
||||
`,
|
||||
[height]
|
||||
) as RowDataPacket[][];
|
||||
if (rows?.length) {
|
||||
for (const clusterToDelete of rows) {
|
||||
const txs = this.unpack(clusterToDelete?.txs);
|
||||
for (const tx of txs) {
|
||||
await transactionRepository.$removeTransaction(tx.txid);
|
||||
const txids = this.extractTxids(clusterToDelete);
|
||||
for (const txid of txids) {
|
||||
await transactionRepository.$removeTransaction(txid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -150,16 +182,16 @@ class CpfpRepository {
|
|||
try {
|
||||
const [rows] = await DB.query(
|
||||
`
|
||||
SELECT txs, height, root from compact_cpfp_clusters
|
||||
SELECT txs, height, root, template_algo from compact_cpfp_clusters
|
||||
WHERE height = ?
|
||||
`,
|
||||
[height]
|
||||
) as RowDataPacket[][];
|
||||
if (rows?.length) {
|
||||
for (const clusterToDelete of rows) {
|
||||
const txs = this.unpack(clusterToDelete?.txs);
|
||||
for (const tx of txs) {
|
||||
await transactionRepository.$removeTransaction(tx.txid);
|
||||
const txids = this.extractTxids(clusterToDelete);
|
||||
for (const txid of txids) {
|
||||
await transactionRepository.$removeTransaction(txid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -176,6 +208,13 @@ class CpfpRepository {
|
|||
}
|
||||
}
|
||||
|
||||
private extractTxids(row: any): string[] {
|
||||
if (row.template_algo === TemplateAlgorithm.clusterMempool) {
|
||||
return this.unpackCM(row.txs).txs.map(tx => tx.txid);
|
||||
}
|
||||
return this.unpack(row.txs).map(tx => tx.txid);
|
||||
}
|
||||
|
||||
// insert a dummy row to mark that we've indexed as far as this block
|
||||
public async $insertProgressMarker(height: number): Promise<void> {
|
||||
try {
|
||||
|
|
@ -245,6 +284,117 @@ class CpfpRepository {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pack cluster mempool data into binary format:
|
||||
* [num_chunks: uint16]
|
||||
* Per chunk: [num_txs: uint16]
|
||||
* Per tx (in linearization order, grouped by chunk):
|
||||
* [txid: 32 bytes LE] [weight: uint32] [fee: uint64] [num_parents: uint8] [parent_indices: uint8 each]
|
||||
*/
|
||||
public packCM(clusterData: CpfpClusterData): ArrayBuffer {
|
||||
const headerSize = 2;
|
||||
const chunkHeadersSize = clusterData.chunks.length * 2;
|
||||
let txDataSize = 0;
|
||||
for (const tx of clusterData.txs) {
|
||||
txDataSize += 32 + 4 + 8 + 1 + tx.parents.length;
|
||||
}
|
||||
const totalSize = headerSize + chunkHeadersSize + txDataSize;
|
||||
|
||||
const buf = new ArrayBuffer(totalSize);
|
||||
const view = new DataView(buf);
|
||||
let offset = 0;
|
||||
|
||||
view.setUint16(offset, clusterData.chunks.length);
|
||||
offset += 2;
|
||||
|
||||
for (const chunk of clusterData.chunks) {
|
||||
view.setUint16(offset, chunk.txs.length);
|
||||
offset += 2;
|
||||
}
|
||||
|
||||
for (const tx of clusterData.txs) {
|
||||
for (let x = 0; x < 32; x++) {
|
||||
view.setUint8(offset + (31 - x), parseInt(tx.txid.slice(x * 2, (x * 2) + 2), 16));
|
||||
}
|
||||
offset += 32;
|
||||
view.setUint32(offset, tx.weight);
|
||||
offset += 4;
|
||||
view.setBigUint64(offset, BigInt(Math.round(tx.fee)));
|
||||
offset += 8;
|
||||
view.setUint8(offset, tx.parents.length);
|
||||
offset += 1;
|
||||
for (const parentIdx of tx.parents) {
|
||||
view.setUint8(offset, parentIdx);
|
||||
offset += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return buf;
|
||||
}
|
||||
|
||||
public unpackCM(buf: Buffer): CpfpClusterData {
|
||||
if (!buf) {
|
||||
return { txs: [], chunks: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
const arrayBuffer = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
|
||||
const view = new DataView(arrayBuffer);
|
||||
let offset = 0;
|
||||
|
||||
const numChunks = view.getUint16(offset);
|
||||
offset += 2;
|
||||
|
||||
const chunkSizes: number[] = [];
|
||||
for (let i = 0; i < numChunks; i++) {
|
||||
chunkSizes.push(view.getUint16(offset));
|
||||
offset += 2;
|
||||
}
|
||||
|
||||
const txs: CpfpClusterTx[] = [];
|
||||
const chunks: { txs: number[], feerate: number }[] = [];
|
||||
let txIndex = 0;
|
||||
|
||||
for (let chunkIdx = 0; chunkIdx < numChunks; chunkIdx++) {
|
||||
const chunkTxIndices: number[] = [];
|
||||
let chunkFee = 0;
|
||||
let chunkWeight = 0;
|
||||
|
||||
for (let t = 0; t < chunkSizes[chunkIdx]; t++) {
|
||||
const txid = Array.from(new Uint8Array(arrayBuffer, offset, 32)).reverse().map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
offset += 32;
|
||||
const weight = view.getUint32(offset);
|
||||
offset += 4;
|
||||
const fee = Number(view.getBigUint64(offset));
|
||||
offset += 8;
|
||||
const numParents = view.getUint8(offset);
|
||||
offset += 1;
|
||||
const parents: number[] = [];
|
||||
for (let p = 0; p < numParents; p++) {
|
||||
parents.push(view.getUint8(offset));
|
||||
offset += 1;
|
||||
}
|
||||
|
||||
txs.push({ txid, fee, weight, parents });
|
||||
chunkTxIndices.push(txIndex);
|
||||
chunkFee += fee;
|
||||
chunkWeight += weight;
|
||||
txIndex++;
|
||||
}
|
||||
|
||||
chunks.push({
|
||||
txs: chunkTxIndices,
|
||||
feerate: chunkWeight > 0 ? (chunkFee * 4) / chunkWeight : 0,
|
||||
});
|
||||
}
|
||||
|
||||
return { txs, chunks };
|
||||
} catch (e) {
|
||||
logger.warn(`Failed to unpack CM CPFP cluster. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
return { txs: [], chunks: [] };
|
||||
}
|
||||
}
|
||||
|
||||
// returns `true` if two sets of CPFP clusters are deeply identical
|
||||
public compareClusters(clustersA: CpfpCluster[], clustersB: CpfpCluster[]): boolean {
|
||||
if (clustersA.length !== clustersB.length) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import DB from '../database';
|
||||
import logger from '../logger';
|
||||
import { Ancestor, CpfpInfo } from '../mempool.interfaces';
|
||||
import { Ancestor, CpfpCluster, CpfpInfo, TemplateAlgorithm } from '../mempool.interfaces';
|
||||
import cpfpRepository from './CpfpRepository';
|
||||
|
||||
class TransactionRepository {
|
||||
|
|
@ -72,6 +72,9 @@ class TransactionRepository {
|
|||
const clusterId = txRows[0].root.toLowerCase();
|
||||
const cluster = await cpfpRepository.$getCluster(clusterId);
|
||||
if (cluster) {
|
||||
if (cluster.templateAlgorithm === TemplateAlgorithm.clusterMempool && cluster.clusterData) {
|
||||
return this.convertCpfpCM(txid, cluster);
|
||||
}
|
||||
return this.convertCpfp(txid, cluster);
|
||||
}
|
||||
}
|
||||
|
|
@ -116,6 +119,95 @@ class TransactionRepository {
|
|||
effectiveFeePerVsize: cluster.effectiveFeePerVsize,
|
||||
};
|
||||
}
|
||||
|
||||
private convertCpfpCM(txid: string, cluster: CpfpCluster): CpfpInfo {
|
||||
const clusterData = cluster.clusterData;
|
||||
if (!clusterData) {
|
||||
return { ancestors: [], descendants: [], effectiveFeePerVsize: 0 };
|
||||
}
|
||||
|
||||
// Find which chunk this tx belongs to
|
||||
let txFlatIdx = -1;
|
||||
let txChunkIndex = -1;
|
||||
for (let i = 0; i < clusterData.txs.length; i++) {
|
||||
if (clusterData.txs[i].txid === txid) {
|
||||
txFlatIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Find the chunk containing this tx
|
||||
for (let chunkIdx = 0; chunkIdx < clusterData.chunks.length; chunkIdx++) {
|
||||
if (clusterData.chunks[chunkIdx].txs.includes(txFlatIdx)) {
|
||||
txChunkIndex = chunkIdx;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Derive ancestors/descendants from in-chunk depgraph parents
|
||||
// For CM, ancestors are the tx's depgraph parents within the cluster,
|
||||
// descendants are txs that depend on this tx
|
||||
const ancestors: Ancestor[] = [];
|
||||
const descendants: Ancestor[] = [];
|
||||
|
||||
if (txFlatIdx >= 0) {
|
||||
// Build child map
|
||||
const childMap = new Map<number, number[]>();
|
||||
for (let i = 0; i < clusterData.txs.length; i++) {
|
||||
for (const parentIdx of clusterData.txs[i].parents) {
|
||||
let children = childMap.get(parentIdx);
|
||||
if (!children) {
|
||||
children = [];
|
||||
childMap.set(parentIdx, children);
|
||||
}
|
||||
children.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
const ancestorSet = new Set<number>();
|
||||
const stack = [...clusterData.txs[txFlatIdx].parents];
|
||||
while (stack.length) {
|
||||
const idx = stack.pop();
|
||||
if (idx === undefined || ancestorSet.has(idx)) {
|
||||
continue;
|
||||
}
|
||||
ancestorSet.add(idx);
|
||||
stack.push(...clusterData.txs[idx].parents);
|
||||
}
|
||||
|
||||
const descendantSet = new Set<number>();
|
||||
const dStack = [...(childMap.get(txFlatIdx) || [])];
|
||||
while (dStack.length) {
|
||||
const idx = dStack.pop();
|
||||
if (idx === undefined || descendantSet.has(idx)) {
|
||||
continue;
|
||||
}
|
||||
descendantSet.add(idx);
|
||||
dStack.push(...(childMap.get(idx) || []));
|
||||
}
|
||||
|
||||
for (const idx of ancestorSet) {
|
||||
const tx = clusterData.txs[idx];
|
||||
ancestors.push({ txid: tx.txid, weight: tx.weight, fee: tx.fee });
|
||||
}
|
||||
for (const idx of descendantSet) {
|
||||
const tx = clusterData.txs[idx];
|
||||
descendants.push({ txid: tx.txid, weight: tx.weight, fee: tx.fee });
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveFeePerVsize = txChunkIndex >= 0 ? clusterData.chunks[txChunkIndex].feerate : 0;
|
||||
|
||||
return {
|
||||
ancestors,
|
||||
descendants,
|
||||
effectiveFeePerVsize,
|
||||
cluster: {
|
||||
...clusterData,
|
||||
chunkIndex: txChunkIndex,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default new TransactionRepository();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"extends": "./tsconfig",
|
||||
"exclude": ["**/*.test.*", "**/__mocks__/*", "**/__tests__/*"],
|
||||
"exclude": ["**/*.test.*", "**/__mocks__/*", "**/__tests__/*", "**/__e2e__/*"],
|
||||
"compilerOptions": {
|
||||
"types": ["node"]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@
|
|||
"AUDIT": __MEMPOOL_AUDIT__,
|
||||
"RUST_GBT": __MEMPOOL_RUST_GBT__,
|
||||
"LIMIT_GBT": __MEMPOOL_LIMIT_GBT__,
|
||||
"CLUSTER_MEMPOOL": __MEMPOOL_CLUSTER_MEMPOOL__,
|
||||
"CLUSTER_MEMPOOL_INDEXING": __MEMPOOL_CLUSTER_MEMPOOL_INDEXING__,
|
||||
"CPFP_INDEXING": __MEMPOOL_CPFP_INDEXING__,
|
||||
"MAX_BLOCKS_BULK_QUERY": __MEMPOOL_MAX_BLOCKS_BULK_QUERY__,
|
||||
"DISK_CACHE_BLOCK_INTERVAL": __MEMPOOL_DISK_CACHE_BLOCK_INTERVAL__,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ __MEMPOOL_POOLS_UPDATE_DELAY__=${MEMPOOL_POOLS_UPDATE_DELAY:=604800}
|
|||
__MEMPOOL_AUDIT__=${MEMPOOL_AUDIT:=false}
|
||||
__MEMPOOL_RUST_GBT__=${MEMPOOL_RUST_GBT:=true}
|
||||
__MEMPOOL_LIMIT_GBT__=${MEMPOOL_LIMIT_GBT:=false}
|
||||
__MEMPOOL_CLUSTER_MEMPOOL__=${MEMPOOL_CLUSTER_MEMPOOL:=false}
|
||||
__MEMPOOL_CLUSTER_MEMPOOL_INDEXING__=${MEMPOOL_CLUSTER_MEMPOOL_INDEXING:=false}
|
||||
__MEMPOOL_CPFP_INDEXING__=${MEMPOOL_CPFP_INDEXING:=false}
|
||||
__MEMPOOL_MAX_BLOCKS_BULK_QUERY__=${MEMPOOL_MAX_BLOCKS_BULK_QUERY:=0}
|
||||
__MEMPOOL_DISK_CACHE_BLOCK_INTERVAL__=${MEMPOOL_DISK_CACHE_BLOCK_INTERVAL:=6}
|
||||
|
|
@ -197,6 +199,8 @@ sed -i "s!__MEMPOOL_POOLS_UPDATE_DELAY__!${__MEMPOOL_POOLS_UPDATE_DELAY__}!g" me
|
|||
sed -i "s!__MEMPOOL_AUDIT__!${__MEMPOOL_AUDIT__}!g" mempool-config.json
|
||||
sed -i "s!__MEMPOOL_RUST_GBT__!${__MEMPOOL_RUST_GBT__}!g" mempool-config.json
|
||||
sed -i "s!__MEMPOOL_LIMIT_GBT__!${__MEMPOOL_LIMIT_GBT__}!g" mempool-config.json
|
||||
sed -i "s!__MEMPOOL_CLUSTER_MEMPOOL__!${__MEMPOOL_CLUSTER_MEMPOOL__}!g" mempool-config.json
|
||||
sed -i "s!__MEMPOOL_CLUSTER_MEMPOOL_INDEXING__!${__MEMPOOL_CLUSTER_MEMPOOL_INDEXING__}!g" mempool-config.json
|
||||
sed -i "s!__MEMPOOL_CPFP_INDEXING__!${__MEMPOOL_CPFP_INDEXING__}!g" mempool-config.json
|
||||
sed -i "s!__MEMPOOL_MAX_BLOCKS_BULK_QUERY__!${__MEMPOOL_MAX_BLOCKS_BULK_QUERY__}!g" mempool-config.json
|
||||
sed -i "s!__MEMPOOL_DISK_CACHE_BLOCK_INTERVAL__!${__MEMPOOL_DISK_CACHE_BLOCK_INTERVAL__}!g" mempool-config.json
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
<div class="graph-container" #graphContainer>
|
||||
<svg [attr.width]="svgWidth" [attr.height]="svgHeight" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient *ngFor="let edge of edges; trackBy: trackByEdgeId"
|
||||
[attr.id]="edge.gradientId"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
[attr.x1]="edge.x1" [attr.y1]="edge.y1"
|
||||
[attr.x2]="edge.x2" [attr.y2]="edge.y2">
|
||||
<stop offset="0%" [attr.stop-color]="edge.parentColor" [attr.stop-opacity]="edge.parentInactive ? 0.45 : 1" />
|
||||
<stop offset="100%" [attr.stop-color]="edge.childColor" [attr.stop-opacity]="edge.childInactive ? 0.45 : 1" />
|
||||
</linearGradient>
|
||||
<marker *ngFor="let edge of edges; trackBy: trackByEdgeId"
|
||||
[attr.id]="edge.markerId"
|
||||
markerUnits="userSpaceOnUse"
|
||||
markerWidth="8" markerHeight="8"
|
||||
refX="5" refY="4"
|
||||
orient="auto">
|
||||
<path d="M 0,0.5 L 7.5,4 L 0,7.5 Z"
|
||||
[attr.fill]="edge.childColor"
|
||||
[attr.fill-opacity]="edge.childInactive ? 0.45 : 1" />
|
||||
</marker>
|
||||
<marker id="edge-arrow-highlight"
|
||||
markerUnits="userSpaceOnUse"
|
||||
markerWidth="8" markerHeight="8"
|
||||
refX="5" refY="4"
|
||||
orient="auto">
|
||||
<path d="M 0,0.5 L 7.5,4 L 0,7.5 Z" fill="white" />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<path *ngFor="let outline of chunkOutlines; trackBy: trackByChunkIndex"
|
||||
class="chunk-border"
|
||||
[class.inactive]="outline.chunkIndex !== activeChunkIndex"
|
||||
[attr.d]="outline.path" />
|
||||
|
||||
<text *ngFor="let outline of chunkOutlines; trackBy: trackByChunkIndex"
|
||||
class="chunk-label"
|
||||
[class.inactive]="outline.chunkIndex !== activeChunkIndex"
|
||||
[attr.x]="outline.labelX" [attr.y]="outline.labelY"
|
||||
text-anchor="middle">
|
||||
{{ outline.feerate | feeRounding }} sat/vB
|
||||
</text>
|
||||
|
||||
<g *ngFor="let edge of edges; let i = index; trackBy: trackByEdgeId">
|
||||
<path class="edge-hitarea"
|
||||
[attr.d]="edge.path"
|
||||
fill="none"
|
||||
stroke="transparent"
|
||||
stroke-width="12"
|
||||
(mouseenter)="onEdgeEnter(i)"
|
||||
(mouseleave)="onEdgeLeave()" />
|
||||
<path class="edge-line"
|
||||
[class.highlighted]="edge.highlighted"
|
||||
[attr.d]="edge.path"
|
||||
[attr.stroke]="edge.highlighted ? 'white' : 'url(#' + edge.gradientId + ')'"
|
||||
[attr.marker-end]="edge.highlighted ? 'url(#edge-arrow-highlight)' : 'url(#' + edge.markerId + ')'"
|
||||
fill="none" />
|
||||
</g>
|
||||
|
||||
<g *ngFor="let node of nodes; trackBy: trackByNodeIndex"
|
||||
class="node-group"
|
||||
[class.inactive-fill]="node.inactive"
|
||||
(mouseenter)="onNodeEnter(node, $event)"
|
||||
(mousemove)="onNodeMove($event)"
|
||||
(mouseleave)="onNodeLeave()"
|
||||
(click)="onNodeClick(node)">
|
||||
<rect class="node-rect"
|
||||
[class.current]="node.isCurrent"
|
||||
[class.hovered]="node.hovered"
|
||||
[class.related]="node.related"
|
||||
[attr.x]="node.rectX" [attr.y]="node.rectY"
|
||||
[attr.width]="nodeW" [attr.height]="nodeH"
|
||||
[attr.rx]="nodeRx"
|
||||
[attr.fill]="node.color" />
|
||||
<text class="node-label"
|
||||
[attr.x]="node.x" [attr.y]="node.y"
|
||||
text-anchor="middle" dominant-baseline="central">
|
||||
{{ node.feerate | feeRounding }}
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<div #tooltip
|
||||
class="cluster-tooltip"
|
||||
*ngIf="hoverNode"
|
||||
[style.left.px]="tooltipPosition.x"
|
||||
[style.top.px]="tooltipPosition.y">
|
||||
<p>{{ hoverNode.tx.txid | shortenString }}</p>
|
||||
<p>{{ hoverNode.tx.fee | number }} sat</p>
|
||||
<p [innerHTML]="hoverNode.tx.weight / 4 | vbytes: 2"></p>
|
||||
<p>{{ hoverNode.feerate | feeRounding }} sat/vB</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
.graph-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
background: var(--stat-box-bg);
|
||||
padding: 10px 0;
|
||||
overflow-x: auto;
|
||||
text-align: center;
|
||||
|
||||
svg {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
.chunk-border {
|
||||
stroke-dasharray: 6 4;
|
||||
stroke: var(--fg);
|
||||
stroke-width: 1.5;
|
||||
stroke-linejoin: round;
|
||||
fill: none;
|
||||
|
||||
&.inactive {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
|
||||
.chunk-label {
|
||||
font-size: 11px;
|
||||
fill: var(--fg);
|
||||
|
||||
&.inactive {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
.edge-hitarea {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.edge-line {
|
||||
stroke-width: 2;
|
||||
transition: stroke 150ms;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.node-group {
|
||||
cursor: pointer;
|
||||
|
||||
&.inactive-fill {
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
|
||||
.node-rect {
|
||||
stroke: transparent;
|
||||
stroke-width: 4;
|
||||
transition: stroke 150ms;
|
||||
|
||||
&.current {
|
||||
stroke: var(--mainnet-alt);
|
||||
}
|
||||
|
||||
&.related {
|
||||
stroke: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
&.hovered {
|
||||
stroke: white;
|
||||
}
|
||||
}
|
||||
|
||||
.node-label {
|
||||
font-size: 12px;
|
||||
fill: white;
|
||||
pointer-events: none;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cluster-tooltip {
|
||||
position: absolute;
|
||||
background: color-mix(in srgb, var(--active-bg) 95%, transparent);
|
||||
border-radius: 4px;
|
||||
box-shadow: 1px 1px 10px rgba(0, 0, 0, 0.5);
|
||||
color: var(--tooltip-grey);
|
||||
padding: 10px 15px;
|
||||
text-align: left;
|
||||
pointer-events: none;
|
||||
max-width: 350px;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,212 @@
|
|||
import { Component, Input, OnChanges, SimpleChanges, ChangeDetectionStrategy, ElementRef, ViewChild, ChangeDetectorRef, HostListener } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { CpfpClusterTx, CpfpClusterChunk } from '@app/interfaces/node-api.interface';
|
||||
import { ThemeService } from '@app/services/theme.service';
|
||||
import { StateService } from '@app/services/state.service';
|
||||
import { feeLevels } from '@app/app.constants';
|
||||
import { computeGridLayout, GridLayout } from './cluster-layout';
|
||||
import { renderLayout, RenderedNode, RenderedEdge, RenderedChunkOutline, NODE_W, NODE_H } from './cluster-renderer';
|
||||
|
||||
const NODE_RX = 6;
|
||||
const RESIZE_DEBOUNCE_MS = 100;
|
||||
|
||||
@Component({
|
||||
selector: 'app-cluster-diagram',
|
||||
templateUrl: './cluster-diagram.component.html',
|
||||
styleUrls: ['./cluster-diagram.component.scss'],
|
||||
standalone: false,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ClusterDiagramComponent implements OnChanges {
|
||||
@Input() cluster: { txs: CpfpClusterTx[]; chunks: CpfpClusterChunk[]; chunkIndex: number };
|
||||
@Input() txid: string;
|
||||
|
||||
@ViewChild('graphContainer', { static: true }) graphContainer: ElementRef;
|
||||
@ViewChild('tooltip') tooltipElement: ElementRef;
|
||||
|
||||
txs: CpfpClusterTx[] = [];
|
||||
chunks: CpfpClusterChunk[] = [];
|
||||
gridLayout: GridLayout | null = null;
|
||||
nodes: RenderedNode[] = [];
|
||||
edges: RenderedEdge[] = [];
|
||||
chunkOutlines: RenderedChunkOutline[] = [];
|
||||
svgWidth = 0;
|
||||
svgHeight = 0;
|
||||
activeChunkIndex = 0;
|
||||
|
||||
hoverNode: RenderedNode | null = null;
|
||||
tooltipPosition = { x: 0, y: 0 };
|
||||
|
||||
readonly nodeW = NODE_W;
|
||||
readonly nodeH = NODE_H;
|
||||
readonly nodeRx = NODE_RX;
|
||||
|
||||
private resizeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
private themeService: ThemeService,
|
||||
private stateService: StateService,
|
||||
private cd: ChangeDetectorRef,
|
||||
) {}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (!this.cluster?.txs?.length) { return; }
|
||||
this.activeChunkIndex = this.cluster.chunkIndex;
|
||||
const clusterChanged = changes['cluster'];
|
||||
if (clusterChanged) {
|
||||
this.computeLayout();
|
||||
}
|
||||
this.renderLayout();
|
||||
}
|
||||
|
||||
private computeLayout(): void {
|
||||
this.txs = this.cluster.txs;
|
||||
this.chunks = this.cluster.chunks;
|
||||
this.gridLayout = computeGridLayout(this.txs, this.chunks);
|
||||
}
|
||||
|
||||
private renderLayout(): void {
|
||||
if (!this.gridLayout) {
|
||||
return;
|
||||
}
|
||||
const containerWidth = this.graphContainer?.nativeElement?.clientWidth || 600;
|
||||
const colors = this.themeService.mempoolFeeColors;
|
||||
const getColor = (feerate: number): string => {
|
||||
let index = feeLevels.findIndex((level: number) => feerate < level);
|
||||
if (index < 0) { index = feeLevels.length; }
|
||||
index = Math.min(index, colors.length - 1);
|
||||
return '#' + colors[index];
|
||||
};
|
||||
|
||||
const result = renderLayout(this.gridLayout, {
|
||||
containerWidth,
|
||||
activeChunkIndex: this.activeChunkIndex,
|
||||
currentTxid: this.txid,
|
||||
txFees: this.txs.map(tx => tx.fee),
|
||||
txWeights: this.txs.map(tx => tx.weight),
|
||||
txids: this.txs.map(tx => tx.txid),
|
||||
chunkFeerates: this.chunks.map(c => c.feerate),
|
||||
getColor,
|
||||
});
|
||||
|
||||
this.nodes = result.nodes;
|
||||
this.edges = result.edges;
|
||||
this.chunkOutlines = result.chunkOutlines;
|
||||
this.svgWidth = result.svgWidth;
|
||||
this.svgHeight = result.svgHeight;
|
||||
}
|
||||
|
||||
onNodeEnter(node: RenderedNode, event: MouseEvent): void {
|
||||
this.hoverNode = node;
|
||||
this.clearHighlights();
|
||||
node.hovered = true;
|
||||
for (const edge of this.edges) {
|
||||
if (edge.parentIndex === node.index) {
|
||||
this.nodes[edge.childIndex].related = true;
|
||||
edge.highlighted = true;
|
||||
} else if (edge.childIndex === node.index) {
|
||||
this.nodes[edge.parentIndex].related = true;
|
||||
edge.highlighted = true;
|
||||
}
|
||||
}
|
||||
this.updateTooltipPosition(event);
|
||||
this.cd.markForCheck();
|
||||
}
|
||||
|
||||
onNodeMove(event: MouseEvent): void {
|
||||
this.updateTooltipPosition(event);
|
||||
this.cd.markForCheck();
|
||||
}
|
||||
|
||||
onNodeLeave(): void {
|
||||
this.hoverNode = null;
|
||||
this.clearHighlights();
|
||||
this.cd.markForCheck();
|
||||
}
|
||||
|
||||
onEdgeEnter(edgeIndex: number): void {
|
||||
this.clearHighlights();
|
||||
const edge = this.edges[edgeIndex];
|
||||
edge.highlighted = true;
|
||||
this.nodes[edge.parentIndex].related = true;
|
||||
this.nodes[edge.childIndex].related = true;
|
||||
this.cd.markForCheck();
|
||||
}
|
||||
|
||||
onEdgeLeave(): void {
|
||||
this.clearHighlights();
|
||||
this.cd.markForCheck();
|
||||
}
|
||||
|
||||
onNodeClick(node: RenderedNode): void {
|
||||
const network = this.stateService.network;
|
||||
const prefix = network && network !== 'mainnet' ? `/${network}` : '';
|
||||
this.router.navigate([prefix + '/tx/', node.tx.txid]);
|
||||
}
|
||||
|
||||
private clearHighlights(): void {
|
||||
for (const node of this.nodes) {
|
||||
node.hovered = false;
|
||||
node.related = false;
|
||||
}
|
||||
for (const edge of this.edges) {
|
||||
edge.highlighted = false;
|
||||
}
|
||||
}
|
||||
|
||||
private updateTooltipPosition(event: MouseEvent): void {
|
||||
if (!this.graphContainer) { return; }
|
||||
const container = this.graphContainer.nativeElement;
|
||||
const rect = container.getBoundingClientRect();
|
||||
let x = event.clientX - rect.left + container.scrollLeft + 15;
|
||||
let y = event.clientY - rect.top + container.scrollTop + 15;
|
||||
|
||||
if (this.tooltipElement) {
|
||||
const tipRect = this.tooltipElement.nativeElement.getBoundingClientRect();
|
||||
const visibleLeft = container.scrollLeft;
|
||||
const visibleRight = visibleLeft + rect.width;
|
||||
const visibleTop = container.scrollTop;
|
||||
const visibleBottom = visibleTop + rect.height;
|
||||
|
||||
if (x + tipRect.width > visibleRight) {
|
||||
x = Math.max(visibleLeft, visibleRight - tipRect.width - 10);
|
||||
}
|
||||
if (x < visibleLeft) {
|
||||
x = visibleLeft;
|
||||
}
|
||||
if (y + tipRect.height > visibleBottom) {
|
||||
y = y - tipRect.height - 30;
|
||||
}
|
||||
if (y < visibleTop) {
|
||||
y = visibleTop;
|
||||
}
|
||||
}
|
||||
|
||||
this.tooltipPosition = { x, y };
|
||||
}
|
||||
|
||||
trackByNodeIndex(_index: number, node: RenderedNode): number {
|
||||
return node.index;
|
||||
}
|
||||
|
||||
trackByEdgeId(_index: number, edge: RenderedEdge): string {
|
||||
return edge.gradientId;
|
||||
}
|
||||
|
||||
trackByChunkIndex(_index: number, outline: RenderedChunkOutline): number {
|
||||
return outline.chunkIndex;
|
||||
}
|
||||
|
||||
@HostListener('window:resize')
|
||||
onResize(): void {
|
||||
if (this.resizeTimer !== null) {
|
||||
clearTimeout(this.resizeTimer);
|
||||
}
|
||||
this.resizeTimer = setTimeout(() => {
|
||||
this.resizeTimer = null;
|
||||
this.renderLayout();
|
||||
this.cd.markForCheck();
|
||||
}, RESIZE_DEBOUNCE_MS);
|
||||
}
|
||||
}
|
||||
1245
frontend/src/app/components/cluster-diagram/cluster-layout.ts
Normal file
1245
frontend/src/app/components/cluster-diagram/cluster-layout.ts
Normal file
File diff suppressed because it is too large
Load diff
640
frontend/src/app/components/cluster-diagram/cluster-renderer.ts
Normal file
640
frontend/src/app/components/cluster-diagram/cluster-renderer.ts
Normal file
|
|
@ -0,0 +1,640 @@
|
|||
import { GridLayout, GridNode, GridEdge, ChunkRegion, cellKey, cellCol, cellRow } from './cluster-layout';
|
||||
|
||||
export interface RenderedNode {
|
||||
index: number;
|
||||
tx: { txid: string; fee: number; weight: number };
|
||||
x: number;
|
||||
y: number;
|
||||
rectX: number;
|
||||
rectY: number;
|
||||
color: string;
|
||||
chunkIndex: number;
|
||||
feerate: number;
|
||||
inactive: boolean;
|
||||
isCurrent: boolean;
|
||||
hovered: boolean;
|
||||
related: boolean;
|
||||
}
|
||||
|
||||
export interface RenderedEdge {
|
||||
parentIndex: number;
|
||||
childIndex: number;
|
||||
path: string;
|
||||
gradientId: string;
|
||||
markerId: string;
|
||||
parentColor: string;
|
||||
childColor: string;
|
||||
parentInactive: boolean;
|
||||
childInactive: boolean;
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
highlighted: boolean;
|
||||
}
|
||||
|
||||
export interface RenderedChunkOutline {
|
||||
chunkIndex: number;
|
||||
path: string;
|
||||
feerate: number;
|
||||
labelX: number;
|
||||
labelY: number;
|
||||
}
|
||||
|
||||
export interface RenderParams {
|
||||
containerWidth: number;
|
||||
activeChunkIndex: number;
|
||||
currentTxid: string;
|
||||
txFees: number[];
|
||||
txWeights: number[];
|
||||
txids: string[];
|
||||
chunkFeerates: number[];
|
||||
getColor: (feerate: number) => string;
|
||||
}
|
||||
|
||||
interface RenderResult {
|
||||
nodes: RenderedNode[];
|
||||
edges: RenderedEdge[];
|
||||
chunkOutlines: RenderedChunkOutline[];
|
||||
svgWidth: number;
|
||||
svgHeight: number;
|
||||
}
|
||||
|
||||
interface GridGeometry {
|
||||
colLeftX: number[];
|
||||
rowTopY: number[];
|
||||
rowHeights: number[];
|
||||
gutterWidths: number[];
|
||||
cellW: number;
|
||||
totalCols: number;
|
||||
totalRows: number;
|
||||
}
|
||||
|
||||
export const NODE_W = 64;
|
||||
export const NODE_H = 30;
|
||||
const MIN_CELL_W = 80;
|
||||
const MAX_CELL_W = 120;
|
||||
const CELL_PAD_Y = 16;
|
||||
const MARGIN_X = 20;
|
||||
const MARGIN_Y = 30;
|
||||
const LANE_SPACING = 6;
|
||||
const MIN_GUTTER_W = 3 * LANE_SPACING;
|
||||
const MIN_GUTTER_H = 3 * LANE_SPACING;
|
||||
const OUTLINE_PAD = 6;
|
||||
|
||||
export function renderLayout(layout: GridLayout, params: RenderParams): RenderResult {
|
||||
if (layout.nodes.length === 0) {
|
||||
return { nodes: [], edges: [], chunkOutlines: [], svgWidth: 0, svgHeight: 0 };
|
||||
}
|
||||
|
||||
const cellH = NODE_H + CELL_PAD_Y;
|
||||
|
||||
const rowHeights: number[] = [];
|
||||
for (let r = 0; r < layout.rows; r++) {
|
||||
const laneCount = (layout.rowLaneCounts && layout.rowLaneCounts[r]) || 0;
|
||||
if (r % 2 === 0) {
|
||||
rowHeights.push(Math.max(cellH, (laneCount + 1) * LANE_SPACING));
|
||||
} else {
|
||||
rowHeights.push(Math.max(MIN_GUTTER_H, (laneCount + 1) * LANE_SPACING));
|
||||
}
|
||||
}
|
||||
|
||||
const rowTopY: number[] = [MARGIN_Y];
|
||||
for (let r = 1; r < layout.rows; r++) {
|
||||
rowTopY.push(rowTopY[r - 1] + rowHeights[r - 1]);
|
||||
}
|
||||
|
||||
const gutterWidths: number[] = [];
|
||||
for (let c = 0; c < layout.cols - 1; c++) {
|
||||
const laneCount = (layout.colLaneCounts && layout.colLaneCounts[c]) || 0;
|
||||
gutterWidths.push(Math.max(MIN_GUTTER_W, (laneCount + 1) * LANE_SPACING));
|
||||
}
|
||||
const totalGutterW = gutterWidths.reduce((a, b) => a + b, 0);
|
||||
|
||||
const fixedWidth = MARGIN_X * 2 + totalGutterW;
|
||||
const cellW = layout.cols > 0
|
||||
? Math.max(MIN_CELL_W, Math.min(MAX_CELL_W, (params.containerWidth - fixedWidth) / layout.cols))
|
||||
: MIN_CELL_W;
|
||||
|
||||
const colLeftX: number[] = [MARGIN_X];
|
||||
for (let c = 1; c < layout.cols; c++) {
|
||||
colLeftX.push(colLeftX[c - 1] + cellW + gutterWidths[c - 1]);
|
||||
}
|
||||
|
||||
const geo: GridGeometry = {
|
||||
colLeftX, rowTopY, rowHeights, gutterWidths, cellW,
|
||||
totalCols: layout.cols, totalRows: layout.rows,
|
||||
};
|
||||
|
||||
const nodes = renderNodes(layout.nodes, params, geo);
|
||||
const edges = renderEdges(layout.edges, nodes, geo);
|
||||
const chunkOutlines = renderChunkOutlines(layout.chunks, params, geo);
|
||||
|
||||
const svgWidth = MARGIN_X * 2 + layout.cols * cellW + totalGutterW;
|
||||
const svgHeight = rowTopY[layout.rows - 1] + rowHeights[layout.rows - 1] + MARGIN_Y;
|
||||
|
||||
return { nodes, edges, chunkOutlines, svgWidth, svgHeight };
|
||||
}
|
||||
|
||||
function geoCellX(geo: GridGeometry, col: number): number {
|
||||
return geo.colLeftX[col] + geo.cellW / 2;
|
||||
}
|
||||
|
||||
function geoCellY(geo: GridGeometry, row: number): number {
|
||||
if (row < 0 || row >= geo.totalRows) { return MARGIN_Y; }
|
||||
return geo.rowTopY[row] + geo.rowHeights[row] / 2;
|
||||
}
|
||||
|
||||
function geoGutterCenterX(geo: GridGeometry, col: number): number {
|
||||
return geo.colLeftX[col] + geo.cellW + geo.gutterWidths[col] / 2;
|
||||
}
|
||||
|
||||
function geoCellLeft(geo: GridGeometry, col: number): number {
|
||||
return geo.colLeftX[col];
|
||||
}
|
||||
|
||||
function geoCellTop(geo: GridGeometry, row: number): number {
|
||||
if (row < 0 || row >= geo.totalRows) { return MARGIN_Y; }
|
||||
return geo.rowTopY[row];
|
||||
}
|
||||
|
||||
function geoCellRight(geo: GridGeometry, col: number): number {
|
||||
return geo.colLeftX[col] + geo.cellW;
|
||||
}
|
||||
|
||||
function geoCellBottom(geo: GridGeometry, row: number): number {
|
||||
if (row < 0 || row >= geo.totalRows) { return MARGIN_Y; }
|
||||
return geo.rowTopY[row] + geo.rowHeights[row];
|
||||
}
|
||||
|
||||
function borderX(geo: GridGeometry, b: number): number {
|
||||
if (b <= 0) { return geoCellLeft(geo, 0) - OUTLINE_PAD; }
|
||||
if (b >= geo.totalCols) { return geoCellRight(geo, geo.totalCols - 1) + OUTLINE_PAD; }
|
||||
return (geoCellRight(geo, b - 1) + geoCellLeft(geo, b)) / 2;
|
||||
}
|
||||
|
||||
function borderY(geo: GridGeometry, b: number): number {
|
||||
if (b <= 0) { return geoCellTop(geo, 0) - OUTLINE_PAD; }
|
||||
if (b >= geo.totalRows) { return geoCellBottom(geo, geo.totalRows - 1) + OUTLINE_PAD; }
|
||||
return (geoCellBottom(geo, b - 1) + geoCellTop(geo, b)) / 2;
|
||||
}
|
||||
|
||||
function renderNodes(gridNodes: GridNode[], params: RenderParams, geo: GridGeometry): RenderedNode[] {
|
||||
return gridNodes.map(gn => {
|
||||
const x = geoCellX(geo, gn.col);
|
||||
const y = geoCellY(geo, gn.row);
|
||||
const feerate = params.txFees[gn.index] / (params.txWeights[gn.index] / 4);
|
||||
const color = params.getColor(feerate);
|
||||
const inactive = gn.chunkIndex !== params.activeChunkIndex;
|
||||
|
||||
return {
|
||||
index: gn.index,
|
||||
tx: {
|
||||
txid: params.txids[gn.index],
|
||||
fee: params.txFees[gn.index],
|
||||
weight: params.txWeights[gn.index],
|
||||
},
|
||||
x, y,
|
||||
rectX: x - NODE_W / 2,
|
||||
rectY: y - NODE_H / 2,
|
||||
color,
|
||||
chunkIndex: gn.chunkIndex,
|
||||
feerate,
|
||||
inactive,
|
||||
isCurrent: params.txids[gn.index] === params.currentTxid,
|
||||
hovered: false,
|
||||
related: false,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
interface FanInfo {
|
||||
nodeEdgeOffset: number;
|
||||
fanDist: number;
|
||||
}
|
||||
|
||||
function computeFanInfos(
|
||||
gridEdges: GridEdge[],
|
||||
side: 'exit' | 'entry',
|
||||
): (FanInfo | undefined)[] {
|
||||
const nodeHalfH = NODE_H / 2;
|
||||
const nodeKey = side === 'exit' ? 'parent' : 'child';
|
||||
|
||||
const groups = new Map<number, number[]>();
|
||||
for (let i = 0; i < gridEdges.length; i++) {
|
||||
const nodeIdx = gridEdges[i][nodeKey];
|
||||
let group = groups.get(nodeIdx);
|
||||
if (!group) { group = []; groups.set(nodeIdx, group); }
|
||||
group.push(i);
|
||||
}
|
||||
|
||||
const fanInfos: (FanInfo | undefined)[] = new Array(gridEdges.length);
|
||||
|
||||
for (const [, edgeIndices] of groups) {
|
||||
edgeIndices.sort((a, b) => {
|
||||
const sa = side === 'exit' ? gridEdges[a].exitSlot : gridEdges[a].entrySlot;
|
||||
const sb = side === 'exit' ? gridEdges[b].exitSlot : gridEdges[b].entrySlot;
|
||||
return sa - sb;
|
||||
});
|
||||
|
||||
let anyNeedsFan = false;
|
||||
for (const ei of edgeIndices) {
|
||||
const slot = side === 'exit' ? gridEdges[ei].exitSlot : gridEdges[ei].entrySlot;
|
||||
const count = side === 'exit' ? gridEdges[ei].exitCount : gridEdges[ei].entryCount;
|
||||
if (Math.abs(slotToOffset(slot, count)) > nodeHalfH) {
|
||||
anyNeedsFan = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!anyNeedsFan) { continue; }
|
||||
|
||||
const nodeCount = edgeIndices.length;
|
||||
const edgeSpacing = (NODE_H - 2) / Math.max(1, nodeCount - 1);
|
||||
|
||||
let maxDy = 0;
|
||||
const nodeEdgeOffsets: number[] = [];
|
||||
for (let k = 0; k < edgeIndices.length; k++) {
|
||||
const slot = side === 'exit' ? gridEdges[edgeIndices[k]].exitSlot : gridEdges[edgeIndices[k]].entrySlot;
|
||||
const count = side === 'exit' ? gridEdges[edgeIndices[k]].exitCount : gridEdges[edgeIndices[k]].entryCount;
|
||||
const laneOffset = slotToOffset(slot, count);
|
||||
const nodeEdgeOffset = (k - (nodeCount - 1) / 2) * edgeSpacing;
|
||||
nodeEdgeOffsets.push(nodeEdgeOffset);
|
||||
maxDy = Math.max(maxDy, Math.abs(laneOffset - nodeEdgeOffset));
|
||||
}
|
||||
|
||||
const fanDist = Math.min(20, Math.max(10, maxDy * 1.5));
|
||||
|
||||
for (let k = 0; k < edgeIndices.length; k++) {
|
||||
fanInfos[edgeIndices[k]] = { nodeEdgeOffset: nodeEdgeOffsets[k], fanDist };
|
||||
}
|
||||
}
|
||||
|
||||
return fanInfos;
|
||||
}
|
||||
|
||||
function renderEdges(
|
||||
gridEdges: GridEdge[], renderedNodes: RenderedNode[], geo: GridGeometry,
|
||||
): RenderedEdge[] {
|
||||
const exitFanInfos = computeFanInfos(gridEdges, 'exit');
|
||||
const entryFanInfos = computeFanInfos(gridEdges, 'entry');
|
||||
|
||||
return gridEdges.map((ge, idx) => {
|
||||
const pNode = renderedNodes[ge.parent];
|
||||
const cNode = renderedNodes[ge.child];
|
||||
|
||||
const points = waypointsToPixels(ge, geo, exitFanInfos[idx], entryFanInfos[idx]);
|
||||
const path = buildEdgePath(points);
|
||||
|
||||
const first = points[0];
|
||||
const last = points[points.length - 1];
|
||||
|
||||
return {
|
||||
parentIndex: ge.parent,
|
||||
childIndex: ge.child,
|
||||
path,
|
||||
gradientId: `edge-grad-${idx}`,
|
||||
markerId: `edge-arrow-${idx}`,
|
||||
parentColor: pNode.color,
|
||||
childColor: cNode.color,
|
||||
parentInactive: pNode.inactive,
|
||||
childInactive: cNode.inactive,
|
||||
x1: first.x, y1: first.y,
|
||||
x2: last.x, y2: last.y,
|
||||
highlighted: false,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
interface PathPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
sigmoid?: boolean;
|
||||
}
|
||||
|
||||
function waypointsToPixels(
|
||||
edge: GridEdge, geo: GridGeometry,
|
||||
exitFan?: FanInfo, entryFan?: FanInfo,
|
||||
): PathPoint[] {
|
||||
const wp = edge.waypoints;
|
||||
if (wp.length < 2) { return []; }
|
||||
|
||||
const exitOffset = slotToOffset(edge.exitSlot, edge.exitCount);
|
||||
const entryOffset = slotToOffset(edge.entrySlot, edge.entryCount);
|
||||
const points: PathPoint[] = [];
|
||||
|
||||
const startNodeX = geoCellX(geo, wp[0].col) + NODE_W / 2;
|
||||
const cy0 = geoCellY(geo, wp[0].row);
|
||||
|
||||
if (exitFan) {
|
||||
points.push({ x: startNodeX, y: cy0 + exitFan.nodeEdgeOffset });
|
||||
points.push({ x: startNodeX + exitFan.fanDist, y: cy0 + exitOffset, sigmoid: true });
|
||||
} else {
|
||||
points.push({ x: startNodeX, y: cy0 + exitOffset });
|
||||
}
|
||||
|
||||
let curY = cy0 + exitOffset;
|
||||
for (let i = 0; i < wp.length - 1; i++) {
|
||||
const vInfo = edge.verticalSlots[i];
|
||||
const gx = geoGutterCenterX(geo, wp[i].col) + slotToOffset(vInfo.slot, vInfo.count);
|
||||
const isLast = i === wp.length - 2;
|
||||
|
||||
let nextY: number;
|
||||
if (isLast) {
|
||||
nextY = geoCellY(geo, wp[i + 1].row) + entryOffset;
|
||||
} else {
|
||||
const hInfo = edge.horizontalSlots[i];
|
||||
nextY = geoCellY(geo, wp[i + 1].row) + slotToOffset(hInfo.slot, hInfo.count);
|
||||
}
|
||||
|
||||
if (points[points.length - 1].x !== gx) {
|
||||
points.push({ x: gx, y: curY });
|
||||
}
|
||||
|
||||
if (curY !== nextY) {
|
||||
points.push({ x: gx, y: nextY });
|
||||
curY = nextY;
|
||||
}
|
||||
|
||||
if (!isLast) {
|
||||
const nextVInfo = edge.verticalSlots[i + 1];
|
||||
const nextGx = geoGutterCenterX(geo, wp[i + 1].col) + slotToOffset(nextVInfo.slot, nextVInfo.count);
|
||||
points.push({ x: nextGx, y: curY });
|
||||
}
|
||||
}
|
||||
|
||||
const endNodeX = geoCellX(geo, wp[wp.length - 1].col) - NODE_W / 2;
|
||||
const cyLast = geoCellY(geo, wp[wp.length - 1].row);
|
||||
|
||||
if (entryFan) {
|
||||
points.push({ x: endNodeX - entryFan.fanDist, y: curY });
|
||||
points.push({ x: endNodeX, y: cyLast + entryFan.nodeEdgeOffset, sigmoid: true });
|
||||
} else {
|
||||
points.push({ x: endNodeX, y: curY });
|
||||
}
|
||||
|
||||
return points;
|
||||
}
|
||||
|
||||
function slotToOffset(slot: number, count: number): number {
|
||||
if (count <= 1) { return 0; }
|
||||
return (slot - (count - 1) / 2) * LANE_SPACING;
|
||||
}
|
||||
|
||||
function buildEdgePath(points: PathPoint[]): string {
|
||||
if (points.length === 0) { return ''; }
|
||||
const parts = [`M ${points[0].x} ${points[0].y}`];
|
||||
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const prev = points[i - 1];
|
||||
const curr = points[i];
|
||||
const next = points[i + 1];
|
||||
|
||||
if (curr.sigmoid) {
|
||||
const mx = (prev.x + curr.x) / 2;
|
||||
parts.push(`C ${mx} ${prev.y} ${mx} ${curr.y} ${curr.x} ${curr.y}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (next && next.sigmoid) {
|
||||
parts.push(`L ${curr.x} ${curr.y}`);
|
||||
} else if (next && isCorner(prev, curr, next)) {
|
||||
const segLen1 = Math.abs(curr.x - prev.x) + Math.abs(curr.y - prev.y);
|
||||
const segLen2 = Math.abs(next.x - curr.x) + Math.abs(next.y - curr.y);
|
||||
const r = Math.min(5, segLen1 / 2, segLen2 / 2);
|
||||
|
||||
const dx1 = Math.sign(curr.x - prev.x);
|
||||
const dy1 = Math.sign(curr.y - prev.y);
|
||||
const dx2 = Math.sign(next.x - curr.x);
|
||||
const dy2 = Math.sign(next.y - curr.y);
|
||||
|
||||
parts.push(`L ${curr.x - dx1 * r} ${curr.y - dy1 * r}`);
|
||||
parts.push(`Q ${curr.x} ${curr.y} ${curr.x + dx2 * r} ${curr.y + dy2 * r}`);
|
||||
} else {
|
||||
parts.push(`L ${curr.x} ${curr.y}`);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
function isCorner(
|
||||
a: { x: number; y: number },
|
||||
b: { x: number; y: number },
|
||||
c: { x: number; y: number },
|
||||
): boolean {
|
||||
const dx1 = Math.sign(b.x - a.x);
|
||||
const dy1 = Math.sign(b.y - a.y);
|
||||
const dx2 = Math.sign(c.x - b.x);
|
||||
const dy2 = Math.sign(c.y - b.y);
|
||||
return dx1 !== dx2 || dy1 !== dy2;
|
||||
}
|
||||
|
||||
function renderChunkOutlines(
|
||||
chunks: ChunkRegion[], params: RenderParams, geo: GridGeometry,
|
||||
): RenderedChunkOutline[] {
|
||||
return chunks.filter(ch => ch.cells.size > 1).map(ch => {
|
||||
const segments = collectBoundarySegments(ch, geo);
|
||||
const loops = connectSegments(segments);
|
||||
const path = loops.map(loop => loopToPath(loop, 4)).join(' ');
|
||||
|
||||
let labelX = Infinity, labelY = Infinity;
|
||||
for (const cell of ch.cells) {
|
||||
const c = cellCol(cell), r = cellRow(cell);
|
||||
const cx = (geoCellLeft(geo, c) + geoCellRight(geo, c)) / 2;
|
||||
const ty = borderY(geo, r);
|
||||
if (ty < labelY || (ty === labelY && cx < labelX)) {
|
||||
labelX = cx;
|
||||
labelY = ty - 4;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
chunkIndex: ch.chunkIndex,
|
||||
path,
|
||||
feerate: params.chunkFeerates[ch.chunkIndex],
|
||||
labelX,
|
||||
labelY,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
interface Segment {
|
||||
x1: number; y1: number;
|
||||
x2: number; y2: number;
|
||||
}
|
||||
|
||||
function collectBoundarySegments(chunk: ChunkRegion, geo: GridGeometry): Segment[] {
|
||||
const segments: Segment[] = [];
|
||||
|
||||
for (const cell of chunk.cells) {
|
||||
const c = cellCol(cell), r = cellRow(cell);
|
||||
const left = borderX(geo, c);
|
||||
const right = borderX(geo, c + 1);
|
||||
const top = borderY(geo, r);
|
||||
const bottom = borderY(geo, r + 1);
|
||||
|
||||
const hasAbove = chunk.cells.has(cellKey(c, r - 1));
|
||||
const hasBelow = chunk.cells.has(cellKey(c, r + 1));
|
||||
const hasLeft = chunk.cells.has(cellKey(c - 1, r));
|
||||
const hasRight = chunk.cells.has(cellKey(c + 1, r));
|
||||
|
||||
if (!hasAbove) { segments.push({ x1: left, y1: top, x2: right, y2: top }); }
|
||||
if (!hasBelow) { segments.push({ x1: right, y1: bottom, x2: left, y2: bottom }); }
|
||||
if (!hasLeft) { segments.push({ x1: left, y1: bottom, x2: left, y2: top }); }
|
||||
if (!hasRight) { segments.push({ x1: right, y1: top, x2: right, y2: bottom }); }
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
function endpointKey(x: number, y: number): string {
|
||||
return `${Math.round(x)}:${Math.round(y)}`;
|
||||
}
|
||||
|
||||
function connectSegments(segments: Segment[]): { x: number; y: number }[][] {
|
||||
if (segments.length === 0) { return []; }
|
||||
|
||||
const endpointMap = new Map<string, number[]>();
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const s = segments[i];
|
||||
const k1 = endpointKey(s.x1, s.y1);
|
||||
const k2 = endpointKey(s.x2, s.y2);
|
||||
let arr1 = endpointMap.get(k1);
|
||||
if (!arr1) { arr1 = []; endpointMap.set(k1, arr1); }
|
||||
arr1.push(i);
|
||||
let arr2 = endpointMap.get(k2);
|
||||
if (!arr2) { arr2 = []; endpointMap.set(k2, arr2); }
|
||||
arr2.push(i);
|
||||
}
|
||||
|
||||
const loops: { x: number; y: number }[][] = [];
|
||||
const used = new Uint8Array(segments.length);
|
||||
|
||||
for (let start = 0; start < segments.length; start++) {
|
||||
if (used[start]) { continue; }
|
||||
used[start] = 1;
|
||||
|
||||
const loop: { x: number; y: number }[] = [
|
||||
{ x: segments[start].x1, y: segments[start].y1 },
|
||||
{ x: segments[start].x2, y: segments[start].y2 },
|
||||
];
|
||||
|
||||
for (let safety = 0; safety < segments.length * 2; safety++) {
|
||||
const tail = loop[loop.length - 1];
|
||||
const head = loop[0];
|
||||
const eps = 0.5;
|
||||
|
||||
if (loop.length > 2 && Math.abs(tail.x - head.x) < eps && Math.abs(tail.y - head.y) < eps) {
|
||||
loop.pop();
|
||||
break;
|
||||
}
|
||||
|
||||
const candidates = endpointMap.get(endpointKey(tail.x, tail.y));
|
||||
let found = false;
|
||||
if (candidates) {
|
||||
for (const i of candidates) {
|
||||
if (used[i]) { continue; }
|
||||
const s = segments[i];
|
||||
if (Math.abs(s.x1 - tail.x) < eps && Math.abs(s.y1 - tail.y) < eps) {
|
||||
used[i] = 1;
|
||||
loop.push({ x: s.x2, y: s.y2 });
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
if (Math.abs(s.x2 - tail.x) < eps && Math.abs(s.y2 - tail.y) < eps) {
|
||||
used[i] = 1;
|
||||
loop.push({ x: s.x1, y: s.y1 });
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) { break; }
|
||||
}
|
||||
|
||||
if (loop.length >= 3) {
|
||||
loops.push(filterCollinear(loop));
|
||||
}
|
||||
}
|
||||
|
||||
return loops;
|
||||
}
|
||||
|
||||
function filterCollinear(points: { x: number; y: number }[]): { x: number; y: number }[] {
|
||||
const result: { x: number; y: number }[] = [];
|
||||
const n = points.length;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const prev = points[(i - 1 + n) % n];
|
||||
const curr = points[i];
|
||||
const next = points[(i + 1) % n];
|
||||
|
||||
const dx1 = Math.sign(curr.x - prev.x);
|
||||
const dy1 = Math.sign(curr.y - prev.y);
|
||||
const dx2 = Math.sign(next.x - curr.x);
|
||||
const dy2 = Math.sign(next.y - curr.y);
|
||||
|
||||
if (dx1 !== dx2 || dy1 !== dy2) {
|
||||
result.push(curr);
|
||||
}
|
||||
}
|
||||
|
||||
return result.length >= 3 ? result : points;
|
||||
}
|
||||
|
||||
function loopToPath(points: { x: number; y: number }[], radius: number): string {
|
||||
const n = points.length;
|
||||
if (n < 3) { return ''; }
|
||||
|
||||
const parts: string[] = [];
|
||||
const firstCorner = computeCornerEntry(points, 0, radius);
|
||||
parts.push(`M ${firstCorner.x} ${firstCorner.y}`);
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const next = (i + 1) % n;
|
||||
const exit = computeCornerExit(points, i, radius);
|
||||
const entry = computeCornerEntry(points, next, radius);
|
||||
|
||||
if (i > 0 || !pointsEqual(firstCorner, exit)) {
|
||||
parts.push(`L ${exit.x} ${exit.y}`);
|
||||
}
|
||||
|
||||
const curr = points[next];
|
||||
parts.push(`Q ${curr.x} ${curr.y} ${entry.x} ${entry.y}`);
|
||||
}
|
||||
|
||||
parts.push('Z');
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
function computeCornerEntry(
|
||||
points: { x: number; y: number }[], idx: number, radius: number,
|
||||
): { x: number; y: number } {
|
||||
const n = points.length;
|
||||
const prev = points[(idx - 1 + n) % n];
|
||||
const curr = points[idx];
|
||||
const dist = Math.abs(curr.x - prev.x) + Math.abs(curr.y - prev.y);
|
||||
const r = Math.min(radius, dist / 2);
|
||||
const dx = Math.sign(curr.x - prev.x);
|
||||
const dy = Math.sign(curr.y - prev.y);
|
||||
return { x: curr.x - dx * r, y: curr.y - dy * r };
|
||||
}
|
||||
|
||||
function computeCornerExit(
|
||||
points: { x: number; y: number }[], idx: number, radius: number,
|
||||
): { x: number; y: number } {
|
||||
const n = points.length;
|
||||
const curr = points[idx];
|
||||
const next = points[(idx + 1) % n];
|
||||
const dist = Math.abs(next.x - curr.x) + Math.abs(next.y - curr.y);
|
||||
const r = Math.min(radius, dist / 2);
|
||||
const dx = Math.sign(next.x - curr.x);
|
||||
const dy = Math.sign(next.y - curr.y);
|
||||
return { x: curr.x + dx * r, y: curr.y + dy * r };
|
||||
}
|
||||
|
||||
function pointsEqual(a: { x: number; y: number }, b: { x: number; y: number }): boolean {
|
||||
return Math.abs(a.x - b.x) < 0.01 && Math.abs(a.y - b.y) < 0.01;
|
||||
}
|
||||
|
|
@ -1,7 +1,3 @@
|
|||
<br>
|
||||
<div class="title">
|
||||
<h2 class="text-start" i18n="transaction.related-transactions|CPFP List">Related Transactions</h2>
|
||||
</div>
|
||||
<div class="box cpfp-details">
|
||||
<table class="table table-fixed table-borderless table-striped">
|
||||
<thead>
|
||||
|
|
|
|||
|
|
@ -90,10 +90,34 @@
|
|||
[hasEffectiveFeeRate]="hasEffectiveFeeRate"
|
||||
[cpfpInfo]="cpfpInfo"
|
||||
[hasCpfp]="hasCpfp"
|
||||
(toggleCpfp$)="this.showCpfpDetails = !this.showCpfpDetails"
|
||||
(toggleCpfp$)="toggleCpfp()"
|
||||
></app-transaction-details>
|
||||
|
||||
<app-cpfp-info *ngIf="showCpfpDetails" [cpfpInfo]="cpfpInfo" [tx]="transaction"></app-cpfp-info>
|
||||
<ng-container *ngIf="cpfpMode && hasCpfp">
|
||||
<br>
|
||||
<div class="subtitle-block">
|
||||
<div class="title">
|
||||
<h2 *ngIf="cpfpInfo?.cluster && cpfpMode === 'advanced'"><span i18n="transaction.cluster-mempool-chunking|Cluster Mempool Chunking">Cluster Mempool Chunking</span><span class="badge badge-pill badge-warning cpfp-beta" i18n="beta">beta</span></h2>
|
||||
<h2 *ngIf="!(cpfpInfo?.cluster && cpfpMode === 'advanced')" i18n="transaction.related-transactions|CPFP List">Related Transactions</h2>
|
||||
</div>
|
||||
<div class="title-buttons" *ngIf="cpfpInfo?.cluster">
|
||||
<button type="button" class="btn btn-outline-info btn-sm" (click)="toggleCpfpView()">
|
||||
<ng-container *ngIf="cpfpMode === 'advanced'" i18n="transaction.cpfp-simple-view|Simple CPFP view">Simple</ng-container>
|
||||
<ng-container *ngIf="cpfpMode !== 'advanced'" i18n="transaction.cpfp-advanced-view|Advanced CPFP view">Advanced</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ng-container *ngIf="cpfpInfo?.cluster && cpfpMode === 'advanced'; else legacyCpfpRaw">
|
||||
<div class="box">
|
||||
<app-cluster-diagram
|
||||
[cluster]="cpfpInfo.cluster" [txid]="transaction.txid">
|
||||
</app-cluster-diagram>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-template #legacyCpfpRaw>
|
||||
<app-cpfp-info [cpfpInfo]="cpfpInfo" [tx]="transaction"></app-cpfp-info>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
<br>
|
||||
|
||||
<ng-container *ngIf="flowEnabled; else flowPlaceholder">
|
||||
|
|
|
|||
|
|
@ -150,6 +150,16 @@
|
|||
}
|
||||
}
|
||||
|
||||
.cpfp-beta {
|
||||
font-size: 10px;
|
||||
padding: 3px 5px;
|
||||
}
|
||||
|
||||
h2 .cpfp-beta {
|
||||
margin-left: 5px;
|
||||
vertical-align: super;
|
||||
}
|
||||
|
||||
.subtitle-block {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ export class TransactionRawComponent implements OnInit, OnDestroy {
|
|||
fetchCpfp: boolean;
|
||||
cpfpInfo: CpfpInfo | null;
|
||||
hasCpfp: boolean = false;
|
||||
showCpfpDetails = false;
|
||||
cpfpMode: 'advanced' | 'simple' | null = null;
|
||||
mempoolBlocksSubscription: Subscription;
|
||||
|
||||
constructor(
|
||||
|
|
@ -90,6 +90,10 @@ export class TransactionRawComponent implements OnInit, OnDestroy {
|
|||
this.seoService.setTitle($localize`:@@d7f92e6fe26fba6fff568cbdae5db4a5c8c6a55c:Preview Transaction`);
|
||||
this.seoService.setDescription($localize`:@@meta.description.preview-tx:Preview a transaction to the Bitcoin${seoDescriptionNetwork(this.stateService.network)} network using the transaction's raw hex data.`);
|
||||
this.websocketService.want(['blocks', 'mempool-blocks']);
|
||||
const cpfpParam = this.route.snapshot.queryParams['cpfp'];
|
||||
if (cpfpParam === 'advanced' || cpfpParam === 'simple') {
|
||||
this.cpfpMode = cpfpParam;
|
||||
}
|
||||
this.pushTxForm = this.formBuilder.group({
|
||||
txRaw: ['', Validators.required],
|
||||
});
|
||||
|
|
@ -334,7 +338,7 @@ export class TransactionRawComponent implements OnInit, OnDestroy {
|
|||
this.isLoadingCpfpInfo = false;
|
||||
this.isLoadingBroadcast = false;
|
||||
this.adjustedVsize = null;
|
||||
this.showCpfpDetails = false;
|
||||
this.cpfpMode = null;
|
||||
this.hasCpfp = false;
|
||||
this.fetchCpfp = false;
|
||||
this.cpfpInfo = null;
|
||||
|
|
@ -389,6 +393,27 @@ export class TransactionRawComponent implements OnInit, OnDestroy {
|
|||
this.stateService.hideFlow.next(!showFlow);
|
||||
}
|
||||
|
||||
toggleCpfp() {
|
||||
const newMode = this.cpfpMode ? null : (this.cpfpInfo?.cluster ? 'advanced' : 'simple');
|
||||
this.updateCpfpMode(newMode);
|
||||
}
|
||||
|
||||
toggleCpfpView() {
|
||||
const newMode = this.cpfpMode === 'advanced' ? 'simple' : 'advanced';
|
||||
this.updateCpfpMode(newMode);
|
||||
}
|
||||
|
||||
private updateCpfpMode(mode: 'advanced' | 'simple' | null) {
|
||||
this.cpfpMode = mode;
|
||||
this.router.navigate([], {
|
||||
relativeTo: this.route,
|
||||
queryParams: { cpfp: mode },
|
||||
queryParamsHandling: 'merge',
|
||||
preserveFragment: true,
|
||||
replaceUrl: true,
|
||||
});
|
||||
}
|
||||
|
||||
setFlowEnabled() {
|
||||
this.flowEnabled = !this.hideFlow;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,14 +73,38 @@
|
|||
[isCached]="isCached"
|
||||
[ETA$]="ETA$"
|
||||
(accelerateClicked)="onAccelerateClicked()"
|
||||
(toggleCpfp$)="this.showCpfpDetails = !this.showCpfpDetails"
|
||||
(toggleCpfp$)="toggleCpfp()"
|
||||
></app-transaction-details>
|
||||
}
|
||||
|
||||
<ng-template [ngIf]="!isLoadingTx && !error">
|
||||
|
||||
<!-- CPFP Details -->
|
||||
<app-cpfp-info *ngIf="showCpfpDetails" [cpfpInfo]="cpfpInfo" [tx]="tx"></app-cpfp-info>
|
||||
<ng-container *ngIf="cpfpMode && hasCpfp">
|
||||
<br>
|
||||
<div class="subtitle-block">
|
||||
<div class="title">
|
||||
<h2 *ngIf="cpfpInfo?.cluster && cpfpMode === 'advanced'"><span i18n="transaction.cluster-mempool-chunking|Cluster Mempool Chunking">Cluster Mempool Chunking</span><span class="badge badge-pill badge-warning cpfp-beta" i18n="beta">beta</span></h2>
|
||||
<h2 *ngIf="!(cpfpInfo?.cluster && cpfpMode === 'advanced')" i18n="transaction.related-transactions|CPFP List">Related Transactions</h2>
|
||||
</div>
|
||||
<div class="title-buttons" *ngIf="cpfpInfo?.cluster">
|
||||
<button type="button" class="btn btn-outline-info btn-sm" (click)="toggleCpfpView()">
|
||||
<ng-container *ngIf="cpfpMode === 'advanced'" i18n="transaction.cpfp-simple-view|Simple CPFP view">Simple</ng-container>
|
||||
<ng-container *ngIf="cpfpMode !== 'advanced'" i18n="transaction.cpfp-advanced-view|Advanced CPFP view">Advanced</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ng-container *ngIf="cpfpInfo?.cluster && cpfpMode === 'advanced'; else legacyCpfp">
|
||||
<div class="box">
|
||||
<app-cluster-diagram
|
||||
[cluster]="cpfpInfo.cluster" [txid]="tx.txid">
|
||||
</app-cluster-diagram>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-template #legacyCpfp>
|
||||
<app-cpfp-info [cpfpInfo]="cpfpInfo" [tx]="tx"></app-cpfp-info>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
|
||||
<!-- Accelerator -->
|
||||
<ng-container *ngIf="!tx?.status?.confirmed && showAccelerationSummary && (ETA$ | async) as eta;">
|
||||
|
|
|
|||
|
|
@ -206,6 +206,16 @@
|
|||
bottom: -13px;
|
||||
}
|
||||
|
||||
.cpfp-beta {
|
||||
font-size: 10px;
|
||||
padding: 3px 5px;
|
||||
}
|
||||
|
||||
h2 .cpfp-beta {
|
||||
margin-left: 5px;
|
||||
vertical-align: super;
|
||||
}
|
||||
|
||||
.subtitle-block {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
isAcceleration: boolean = false;
|
||||
accelerationCanceled: boolean = false;
|
||||
filters: Filter[] = [];
|
||||
showCpfpDetails = false;
|
||||
cpfpMode: 'advanced' | 'simple' | null = null;
|
||||
miningStats: MiningStats;
|
||||
fetchCpfp$ = new Subject<string>();
|
||||
transactionTimes$ = new Subject<string>();
|
||||
|
|
@ -221,6 +221,10 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
ngOnInit() {
|
||||
this.enterpriseService.page();
|
||||
this.isDetailsOpen = this.route.snapshot.queryParams['showDetails'] === 'true';
|
||||
const cpfpParam = this.route.snapshot.queryParams['cpfp'];
|
||||
if (cpfpParam === 'advanced' || cpfpParam === 'simple') {
|
||||
this.cpfpMode = cpfpParam;
|
||||
}
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
this.forceAccelerationSummary = !!urlParams.get('cash_request_id');
|
||||
|
|
@ -1090,7 +1094,6 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
this.rbfInfo = null;
|
||||
this.rbfReplaces = [];
|
||||
this.filters = [];
|
||||
this.showCpfpDetails = false;
|
||||
this.showAccelerationDetails = false;
|
||||
this.accelerationFlowCompleted = false;
|
||||
this.accelerationInfo = null;
|
||||
|
|
@ -1101,6 +1104,8 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
this.auditStatus = null;
|
||||
this.accelerationPositions = null;
|
||||
this.isDetailsOpen = this.route.snapshot.queryParams['showDetails'] === 'true';
|
||||
const cpfpParam = this.route.snapshot.queryParams['cpfp'];
|
||||
this.cpfpMode = (cpfpParam === 'advanced' || cpfpParam === 'simple') ? cpfpParam : null;
|
||||
document.body.scrollTo(0, 0);
|
||||
this.isAcceleration = false;
|
||||
this.isAccelerated$.next(this.isAcceleration);
|
||||
|
|
@ -1118,6 +1123,28 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
this.graphHeight = this.graphExpanded ? this.maxInOut * 15 : Math.min(360, this.maxInOut * 80);
|
||||
}
|
||||
|
||||
toggleCpfp() {
|
||||
// cycle: null (closed) -> 'advanced' (open) -> null (closed)
|
||||
const newMode = this.cpfpMode ? null : (this.cpfpInfo?.cluster ? 'advanced' : 'simple');
|
||||
this.updateCpfpMode(newMode);
|
||||
}
|
||||
|
||||
toggleCpfpView() {
|
||||
const newMode = this.cpfpMode === 'advanced' ? 'simple' : 'advanced';
|
||||
this.updateCpfpMode(newMode);
|
||||
}
|
||||
|
||||
private updateCpfpMode(mode: 'advanced' | 'simple' | null) {
|
||||
this.cpfpMode = mode;
|
||||
this.router.navigate([], {
|
||||
relativeTo: this.route,
|
||||
queryParams: { cpfp: mode },
|
||||
queryParamsHandling: 'merge',
|
||||
preserveFragment: true,
|
||||
replaceUrl: true,
|
||||
});
|
||||
}
|
||||
|
||||
toggleGraph() {
|
||||
const showFlow = !this.flowEnabled;
|
||||
this.stateService.hideFlow.next(!showFlow);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { AccelerateCheckout } from '@components/accelerate-checkout/accelerate-c
|
|||
import { AccelerateFeeGraphComponent } from '@components/accelerate-checkout/accelerate-fee-graph.component';
|
||||
import { TransactionRawComponent } from '@components/transaction/transaction-raw.component';
|
||||
import { CpfpInfoComponent } from '@components/transaction/cpfp-info.component';
|
||||
import { ClusterDiagramComponent } from '@components/cluster-diagram/cluster-diagram.component';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
|
|
@ -57,6 +58,7 @@ export class TransactionRoutingModule { }
|
|||
AccelerateFeeGraphComponent,
|
||||
TransactionRawComponent,
|
||||
CpfpInfoComponent,
|
||||
ClusterDiagramComponent,
|
||||
],
|
||||
exports: [
|
||||
TransactionComponent,
|
||||
|
|
@ -64,6 +66,7 @@ export class TransactionRoutingModule { }
|
|||
AccelerateCheckout,
|
||||
AccelerateFeeGraphComponent,
|
||||
CpfpInfoComponent,
|
||||
ClusterDiagramComponent,
|
||||
]
|
||||
})
|
||||
export class TransactionModule { }
|
||||
|
|
|
|||
|
|
@ -32,6 +32,23 @@ export interface CpfpInfo {
|
|||
acceleratedBy?: number[];
|
||||
acceleratedAt?: number;
|
||||
feeDelta?: number;
|
||||
cluster?: {
|
||||
txs: CpfpClusterTx[];
|
||||
chunks: CpfpClusterChunk[];
|
||||
chunkIndex: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CpfpClusterTx {
|
||||
txid: string;
|
||||
fee: number;
|
||||
weight: number;
|
||||
parents: number[];
|
||||
}
|
||||
|
||||
export interface CpfpClusterChunk {
|
||||
txs: number[];
|
||||
feerate: number;
|
||||
}
|
||||
|
||||
export interface RbfInfo {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@
|
|||
"AUDIT": true,
|
||||
"CPFP_INDEXING": true,
|
||||
"RUST_GBT": true,
|
||||
"CLUSTER_MEMPOOL": true,
|
||||
"CLUSTER_MEMPOOL_INDEXING": true,
|
||||
"USE_SECOND_NODE_FOR_MINFEE": true,
|
||||
"DISK_CACHE_BLOCK_INTERVAL": 1,
|
||||
"MAX_PUSH_TX_SIZE_WEIGHT": 4000000,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@
|
|||
"POOLS_UPDATE_DELAY": 3600,
|
||||
"AUDIT": true,
|
||||
"RUST_GBT": true,
|
||||
"CLUSTER_MEMPOOL": true,
|
||||
"CLUSTER_MEMPOOL_INDEXING": true,
|
||||
"POLL_RATE_MS": 500,
|
||||
"DISK_CACHE_BLOCK_INTERVAL": 1,
|
||||
"MAX_PUSH_TX_SIZE_WEIGHT": 4000000,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@
|
|||
"POOLS_UPDATE_DELAY": 3600,
|
||||
"AUDIT": true,
|
||||
"RUST_GBT": true,
|
||||
"CLUSTER_MEMPOOL": true,
|
||||
"CLUSTER_MEMPOOL_INDEXING": true,
|
||||
"POLL_RATE_MS": 1000,
|
||||
"DISK_CACHE_BLOCK_INTERVAL": 1,
|
||||
"MAX_PUSH_TX_SIZE_WEIGHT": 4000000,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@
|
|||
"POOLS_UPDATE_DELAY": 3600,
|
||||
"AUDIT": true,
|
||||
"RUST_GBT": true,
|
||||
"CLUSTER_MEMPOOL": true,
|
||||
"CLUSTER_MEMPOOL_INDEXING": true,
|
||||
"POLL_RATE_MS": 1000,
|
||||
"DISK_CACHE_BLOCK_INTERVAL": 1,
|
||||
"MAX_PUSH_TX_SIZE_WEIGHT": 4000000,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@
|
|||
"POOLS_UPDATE_DELAY": 3600,
|
||||
"AUDIT": true,
|
||||
"RUST_GBT": true,
|
||||
"CLUSTER_MEMPOOL": true,
|
||||
"CLUSTER_MEMPOOL_INDEXING": true,
|
||||
"POLL_RATE_MS": 1000,
|
||||
"DISK_CACHE_BLOCK_INTERVAL": 1,
|
||||
"MAX_PUSH_TX_SIZE_WEIGHT": 4000000,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue