Optimize main thread processing of GBT updates

This commit is contained in:
Mononaut 2023-05-08 19:03:39 -06:00
parent be5882edb3
commit 033e78c0a7
No known key found for this signature in database
GPG Key ID: A3F058E41374C04E
3 changed files with 144 additions and 115 deletions

View File

@ -1,5 +1,5 @@
import logger from '../logger'; import logger from '../logger';
import { MempoolBlock, TransactionExtended, ThreadTransaction, TransactionStripped, MempoolBlockWithTransactions, MempoolBlockDelta, Ancestor, CompactThreadTransaction } from '../mempool.interfaces'; import { MempoolBlock, TransactionExtended, TransactionStripped, MempoolBlockWithTransactions, MempoolBlockDelta, Ancestor, CompactThreadTransaction } from '../mempool.interfaces';
import { Common } from './common'; import { Common } from './common';
import config from '../config'; import config from '../config';
import { Worker } from 'worker_threads'; import { Worker } from 'worker_threads';
@ -104,8 +104,12 @@ class MempoolBlocks {
private calculateMempoolBlocks(transactionsSorted: TransactionExtended[]): MempoolBlockWithTransactions[] { private calculateMempoolBlocks(transactionsSorted: TransactionExtended[]): MempoolBlockWithTransactions[] {
const mempoolBlocks: MempoolBlockWithTransactions[] = []; const mempoolBlocks: MempoolBlockWithTransactions[] = [];
let blockSize = 0;
let blockWeight = 0; let blockWeight = 0;
let blockVsize = 0; let blockVsize = 0;
let blockFees = 0;
const sizeLimit = (config.MEMPOOL.BLOCK_WEIGHT_UNITS / 4) * 1.2;
let transactionIds: string[] = [];
let transactions: TransactionExtended[] = []; let transactions: TransactionExtended[] = [];
transactionsSorted.forEach((tx) => { transactionsSorted.forEach((tx) => {
if (blockWeight + tx.weight <= config.MEMPOOL.BLOCK_WEIGHT_UNITS if (blockWeight + tx.weight <= config.MEMPOOL.BLOCK_WEIGHT_UNITS
@ -116,9 +120,14 @@ class MempoolBlocks {
}; };
blockWeight += tx.weight; blockWeight += tx.weight;
blockVsize += tx.vsize; blockVsize += tx.vsize;
transactions.push(tx); blockSize += tx.size;
blockFees += tx.fee;
if (blockVsize <= sizeLimit) {
transactions.push(tx);
}
transactionIds.push(tx.txid);
} else { } else {
mempoolBlocks.push(this.dataToMempoolBlocks(transactions)); mempoolBlocks.push(this.dataToMempoolBlocks(transactionIds, transactions, blockSize, blockWeight, blockFees));
blockVsize = 0; blockVsize = 0;
tx.position = { tx.position = {
block: mempoolBlocks.length, block: mempoolBlocks.length,
@ -126,11 +135,14 @@ class MempoolBlocks {
}; };
blockVsize += tx.vsize; blockVsize += tx.vsize;
blockWeight = tx.weight; blockWeight = tx.weight;
blockSize = tx.size;
blockFees = tx.fee;
transactionIds = [tx.txid];
transactions = [tx]; transactions = [tx];
} }
}); });
if (transactions.length) { if (transactions.length) {
mempoolBlocks.push(this.dataToMempoolBlocks(transactions)); mempoolBlocks.push(this.dataToMempoolBlocks(transactionIds, transactions, blockSize, blockWeight, blockFees));
} }
return mempoolBlocks; return mempoolBlocks;
@ -178,6 +190,8 @@ class MempoolBlocks {
} }
public async $makeBlockTemplates(newMempool: { [txid: string]: TransactionExtended }, saveResults: boolean = false): Promise<MempoolBlockWithTransactions[]> { public async $makeBlockTemplates(newMempool: { [txid: string]: TransactionExtended }, saveResults: boolean = false): Promise<MempoolBlockWithTransactions[]> {
const start = Date.now();
// reset mempool short ids // reset mempool short ids
this.resetUids(); this.resetUids();
for (const tx of Object.values(newMempool)) { for (const tx of Object.values(newMempool)) {
@ -194,7 +208,7 @@ class MempoolBlocks {
fee: entry.fee, fee: entry.fee,
weight: entry.weight, weight: entry.weight,
feePerVsize: entry.fee / (entry.weight / 4), feePerVsize: entry.fee / (entry.weight / 4),
effectiveFeePerVsize: entry.fee / (entry.weight / 4), effectiveFeePerVsize: entry.effectiveFeePerVsize || (entry.fee / (entry.weight / 4)),
inputs: entry.vin.map(v => this.getUid(newMempool[v.txid])).filter(uid => uid != null) as number[], inputs: entry.vin.map(v => this.getUid(newMempool[v.txid])).filter(uid => uid != null) as number[],
}); });
} }
@ -216,7 +230,7 @@ class MempoolBlocks {
// run the block construction algorithm in a separate thread, and wait for a result // run the block construction algorithm in a separate thread, and wait for a result
let threadErrorListener; let threadErrorListener;
try { try {
const workerResultPromise = new Promise<{ blocks: CompactThreadTransaction[][], clusters: Map<number, number[]> }>((resolve, reject) => { const workerResultPromise = new Promise<{ blocks: number[][], rates: Map<number, number>, clusters: Map<number, number[]> }>((resolve, reject) => {
threadErrorListener = reject; threadErrorListener = reject;
this.txSelectionWorker?.once('message', (result): void => { this.txSelectionWorker?.once('message', (result): void => {
resolve(result); resolve(result);
@ -224,19 +238,14 @@ class MempoolBlocks {
this.txSelectionWorker?.once('error', reject); this.txSelectionWorker?.once('error', reject);
}); });
this.txSelectionWorker.postMessage({ type: 'set', mempool: strippedMempool }); this.txSelectionWorker.postMessage({ type: 'set', mempool: strippedMempool });
let { blocks, clusters } = this.convertResultTxids(await workerResultPromise); const { blocks, rates, clusters } = this.convertResultTxids(await workerResultPromise);
// filter out stale transactions
const unfilteredCount = blocks.reduce((total, block) => { return total + block.length; }, 0);
blocks = blocks.map(block => block.filter(tx => (tx.txid && tx.txid in newMempool)));
const filteredCount = blocks.reduce((total, block) => { return total + block.length; }, 0);
if (filteredCount < unfilteredCount) {
logger.warn(`tx selection worker thread returned ${unfilteredCount - filteredCount} stale transactions from makeBlockTemplates`);
}
// clean up thread error listener // clean up thread error listener
this.txSelectionWorker?.removeListener('error', threadErrorListener); this.txSelectionWorker?.removeListener('error', threadErrorListener);
return this.processBlockTemplates(newMempool, blocks, clusters, saveResults); const processed = this.processBlockTemplates(newMempool, blocks, rates, clusters, saveResults);
logger.debug(`makeBlockTemplates completed in ${(Date.now() - start)/1000} seconds`);
return processed;
} catch (e) { } catch (e) {
logger.err('makeBlockTemplates failed. ' + (e instanceof Error ? e.message : e)); logger.err('makeBlockTemplates failed. ' + (e instanceof Error ? e.message : e));
} }
@ -250,6 +259,8 @@ class MempoolBlocks {
return; return;
} }
const start = Date.now();
for (const tx of Object.values(added)) { for (const tx of Object.values(added)) {
this.setUid(tx); this.setUid(tx);
} }
@ -262,7 +273,7 @@ class MempoolBlocks {
fee: entry.fee, fee: entry.fee,
weight: entry.weight, weight: entry.weight,
feePerVsize: entry.fee / (entry.weight / 4), feePerVsize: entry.fee / (entry.weight / 4),
effectiveFeePerVsize: entry.fee / (entry.weight / 4), effectiveFeePerVsize: entry.effectiveFeePerVsize || (entry.fee / (entry.weight / 4)),
inputs: entry.vin.map(v => this.getUid(newMempool[v.txid])).filter(uid => uid != null) as number[], inputs: entry.vin.map(v => this.getUid(newMempool[v.txid])).filter(uid => uid != null) as number[],
}; };
}); });
@ -270,7 +281,7 @@ class MempoolBlocks {
// run the block construction algorithm in a separate thread, and wait for a result // run the block construction algorithm in a separate thread, and wait for a result
let threadErrorListener; let threadErrorListener;
try { try {
const workerResultPromise = new Promise<{ blocks: CompactThreadTransaction[][], clusters: Map<number, number[]> }>((resolve, reject) => { const workerResultPromise = new Promise<{ blocks: number[][], rates: Map<number, number>, clusters: Map<number, number[]> }>((resolve, reject) => {
threadErrorListener = reject; threadErrorListener = reject;
this.txSelectionWorker?.once('message', (result): void => { this.txSelectionWorker?.once('message', (result): void => {
resolve(result); resolve(result);
@ -278,84 +289,100 @@ class MempoolBlocks {
this.txSelectionWorker?.once('error', reject); this.txSelectionWorker?.once('error', reject);
}); });
this.txSelectionWorker.postMessage({ type: 'update', added: addedStripped, removed: removedUids }); this.txSelectionWorker.postMessage({ type: 'update', added: addedStripped, removed: removedUids });
let { blocks, clusters } = this.convertResultTxids(await workerResultPromise); const { blocks, rates, clusters } = this.convertResultTxids(await workerResultPromise);
// filter out stale transactions
const unfilteredCount = blocks.reduce((total, block) => { return total + block.length; }, 0);
blocks = blocks.map(block => block.filter(tx => (tx.txid && tx.txid in newMempool)));
const filteredCount = blocks.reduce((total, block) => { return total + block.length; }, 0);
if (filteredCount < unfilteredCount) {
logger.warn(`tx selection worker thread returned ${unfilteredCount - filteredCount} stale transactions from updateBlockTemplates`);
}
this.removeUids(removedUids); this.removeUids(removedUids);
// clean up thread error listener // clean up thread error listener
this.txSelectionWorker?.removeListener('error', threadErrorListener); this.txSelectionWorker?.removeListener('error', threadErrorListener);
this.processBlockTemplates(newMempool, blocks, clusters, saveResults); this.processBlockTemplates(newMempool, blocks, rates, clusters, saveResults);
logger.debug(`updateBlockTemplates completed in ${(Date.now() - start) / 1000} seconds`);
} catch (e) { } catch (e) {
logger.err('updateBlockTemplates failed. ' + (e instanceof Error ? e.message : e)); logger.err('updateBlockTemplates failed. ' + (e instanceof Error ? e.message : e));
} }
} }
private processBlockTemplates(mempool, blocks: ThreadTransaction[][], clusters, saveResults): MempoolBlockWithTransactions[] { private processBlockTemplates(mempool, blocks: string[][], rates: { [root: string]: number }, clusters: { [root: string]: string[] }, saveResults): MempoolBlockWithTransactions[] {
for (const txid of Object.keys(rates)) {
if (txid in mempool) {
mempool[txid].effectiveFeePerVsize = rates[txid];
}
}
const readyBlocks: { transactionIds, transactions, totalSize, totalWeight, totalFees }[] = [];
const sizeLimit = (config.MEMPOOL.BLOCK_WEIGHT_UNITS / 4) * 1.2;
// update this thread's mempool with the results // update this thread's mempool with the results
blocks.forEach((block, blockIndex) => { for (let blockIndex = 0; blockIndex < blocks.length; blockIndex++) {
let runningVsize = 0; const block: string[] = blocks[blockIndex];
block.forEach(tx => { let txid: string;
if (tx.txid && tx.txid in mempool) { let mempoolTx: TransactionExtended;
let totalSize = 0;
let totalVsize = 0;
let totalWeight = 0;
let totalFees = 0;
const transactions: TransactionExtended[] = [];
for (let txIndex = 0; txIndex < block.length; txIndex++) {
txid = block[txIndex];
if (txid) {
mempoolTx = mempool[txid];
// save position in projected blocks // save position in projected blocks
mempool[tx.txid].position = { mempoolTx.position = {
block: blockIndex, block: blockIndex,
vsize: runningVsize + (mempool[tx.txid].vsize / 2), vsize: totalVsize + (mempoolTx.vsize / 2),
}; };
runningVsize += mempool[tx.txid].vsize; mempoolTx.cpfpChecked = true;
if (tx.effectiveFeePerVsize != null) { totalSize += mempoolTx.size;
mempool[tx.txid].effectiveFeePerVsize = tx.effectiveFeePerVsize; totalVsize += mempoolTx.vsize;
totalWeight += mempoolTx.weight;
totalFees += mempoolTx.fee;
if (totalVsize <= sizeLimit) {
transactions.push(mempoolTx);
} }
if (tx.cpfpRoot && tx.cpfpRoot in clusters) {
const ancestors: Ancestor[] = [];
const descendants: Ancestor[] = [];
const cluster = clusters[tx.cpfpRoot];
let matched = false;
cluster.forEach(txid => {
if (!txid || !mempool[txid]) {
logger.warn('projected transaction ancestor missing from mempool cache');
return;
}
if (txid === tx.txid) {
matched = true;
} else {
const relative = {
txid: txid,
fee: mempool[txid].fee,
weight: mempool[txid].weight,
};
if (matched) {
descendants.push(relative);
} else {
ancestors.push(relative);
}
}
});
mempool[tx.txid].ancestors = ancestors;
mempool[tx.txid].descendants = descendants;
mempool[tx.txid].bestDescendant = null;
}
mempool[tx.txid].cpfpChecked = tx.cpfpChecked;
} else {
logger.warn('projected transaction missing from mempool cache');
} }
}
readyBlocks.push({
transactionIds: block,
transactions,
totalSize,
totalWeight,
totalFees
}); });
}); }
// unpack the condensed blocks into proper mempool blocks for (const cluster of Object.values(clusters)) {
const mempoolBlocks = blocks.map((transactions) => { for (const memberTxid of cluster) {
return this.dataToMempoolBlocks(transactions.map(tx => { if (memberTxid in mempool) {
return mempool[tx.txid] || null; const mempoolTx = mempool[memberTxid];
}).filter(tx => !!tx)); const ancestors: Ancestor[] = [];
}); const descendants: Ancestor[] = [];
let matched = false;
cluster.forEach(txid => {
if (txid === memberTxid) {
matched = true;
} else {
const relative = {
txid: txid,
fee: mempool[txid].fee,
weight: mempool[txid].weight,
};
if (matched) {
descendants.push(relative);
} else {
ancestors.push(relative);
}
}
});
mempoolTx.ancestors = ancestors;
mempoolTx.descendants = descendants;
mempoolTx.bestDescendant = null;
}
}
}
const mempoolBlocks = readyBlocks.map(b => this.dataToMempoolBlocks(b.transactionIds, b.transactions, b.totalSize, b.totalWeight, b.totalFees));
if (saveResults) { if (saveResults) {
const deltas = this.calculateMempoolDeltas(this.mempoolBlocks, mempoolBlocks); const deltas = this.calculateMempoolDeltas(this.mempoolBlocks, mempoolBlocks);
@ -366,27 +393,17 @@ class MempoolBlocks {
return mempoolBlocks; return mempoolBlocks;
} }
private dataToMempoolBlocks(transactions: TransactionExtended[]): MempoolBlockWithTransactions { private dataToMempoolBlocks(transactionIds: string[], transactions: TransactionExtended[], totalSize: number, totalWeight: number, totalFees: number): MempoolBlockWithTransactions {
let totalSize = 0;
let totalWeight = 0;
const fitTransactions: TransactionExtended[] = [];
transactions.forEach(tx => {
totalSize += tx.size;
totalWeight += tx.weight;
if ((totalWeight + tx.weight) <= config.MEMPOOL.BLOCK_WEIGHT_UNITS * 1.2) {
fitTransactions.push(tx);
}
});
const feeStats = Common.calcEffectiveFeeStatistics(transactions); const feeStats = Common.calcEffectiveFeeStatistics(transactions);
return { return {
blockSize: totalSize, blockSize: totalSize,
blockVSize: totalWeight / 4, blockVSize: (totalWeight / 4), // fractional vsize to avoid rounding errors
nTx: transactions.length, nTx: transactionIds.length,
totalFees: transactions.reduce((acc, cur) => acc + cur.fee, 0), totalFees: totalFees,
medianFee: feeStats.medianFee, // Common.percentile(transactions.map((tx) => tx.effectiveFeePerVsize), config.MEMPOOL.RECOMMENDED_FEE_PERCENTILE), medianFee: feeStats.medianFee, // Common.percentile(transactions.map((tx) => tx.effectiveFeePerVsize), config.MEMPOOL.RECOMMENDED_FEE_PERCENTILE),
feeRange: feeStats.feeRange, //Common.getFeesInRange(transactions, rangeLength), feeRange: feeStats.feeRange, //Common.getFeesInRange(transactions, rangeLength),
transactionIds: transactions.map((tx) => tx.txid), transactionIds: transactionIds,
transactions: fitTransactions.map((tx) => Common.stripTransaction(tx)), transactions: transactions.map((tx) => Common.stripTransaction(tx)),
}; };
} }
@ -415,14 +432,16 @@ class MempoolBlocks {
} }
} }
private convertResultTxids({ blocks, clusters }: { blocks: any[][], clusters: Map<number, number[]>}) private convertResultTxids({ blocks, rates, clusters }: { blocks: number[][], rates: Map<number, number>, clusters: Map<number, number[]>})
: { blocks: ThreadTransaction[][], clusters: { [root: string]: string[] }} { : { blocks: string[][], rates: { [root: string]: number }, clusters: { [root: string]: string[] }} {
for (const block of blocks) { const convertedBlocks: string[][] = blocks.map(block => block.map(uid => {
for (const tx of block) { return this.uidMap.get(uid) || '';
tx.txid = this.uidMap.get(tx.uid); }));
if (tx.cpfpRoot) { const convertedRates = {};
tx.cpfpRoot = this.uidMap.get(tx.cpfpRoot); for (const rateUid of rates.keys()) {
} const rateTxid = this.uidMap.get(rateUid);
if (rateTxid) {
convertedRates[rateTxid] = rates.get(rateUid);
} }
} }
const convertedClusters = {}; const convertedClusters = {};
@ -435,7 +454,7 @@ class MempoolBlocks {
convertedClusters[rootTxid] = members; convertedClusters[rootTxid] = members;
} }
} }
return { blocks, clusters: convertedClusters } as { blocks: ThreadTransaction[][], clusters: { [root: string]: string[] }}; return { blocks: convertedBlocks, rates: convertedRates, clusters: convertedClusters } as { blocks: string[][], rates: { [root: string]: number }, clusters: { [root: string]: string[] }};
} }
} }

View File

@ -1,6 +1,6 @@
import config from '../config'; import config from '../config';
import logger from '../logger'; import logger from '../logger';
import { CompactThreadTransaction, MempoolBlockWithTransactions, AuditTransaction } from '../mempool.interfaces'; import { CompactThreadTransaction, AuditTransaction } from '../mempool.interfaces';
import { PairingHeap } from '../utils/pairing-heap'; import { PairingHeap } from '../utils/pairing-heap';
import { parentPort } from 'worker_threads'; import { parentPort } from 'worker_threads';
@ -19,11 +19,11 @@ if (parentPort) {
}); });
} }
const { blocks, clusters } = makeBlockTemplates(mempool); const { blocks, rates, clusters } = makeBlockTemplates(mempool);
// return the result to main thread. // return the result to main thread.
if (parentPort) { if (parentPort) {
parentPort.postMessage({ blocks, clusters }); parentPort.postMessage({ blocks, rates, clusters });
} }
}); });
} }
@ -33,14 +33,14 @@ if (parentPort) {
* (see BlockAssembler in https://github.com/bitcoin/bitcoin/blob/master/src/node/miner.cpp) * (see BlockAssembler in https://github.com/bitcoin/bitcoin/blob/master/src/node/miner.cpp)
*/ */
function makeBlockTemplates(mempool: Map<number, CompactThreadTransaction>) function makeBlockTemplates(mempool: Map<number, CompactThreadTransaction>)
: { blocks: CompactThreadTransaction[][], clusters: Map<number, number[]> } { : { blocks: number[][], rates: Map<number, number>, clusters: Map<number, number[]> } {
const start = Date.now(); const start = Date.now();
const auditPool: Map<number, AuditTransaction> = new Map(); const auditPool: Map<number, AuditTransaction> = new Map();
const mempoolArray: AuditTransaction[] = []; const mempoolArray: AuditTransaction[] = [];
const restOfArray: CompactThreadTransaction[] = [];
const cpfpClusters: Map<number, number[]> = new Map(); const cpfpClusters: Map<number, number[]> = new Map();
mempool.forEach(tx => { mempool.forEach(tx => {
tx.dirty = false;
// initializing everything up front helps V8 optimize property access later // initializing everything up front helps V8 optimize property access later
auditPool.set(tx.uid, { auditPool.set(tx.uid, {
uid: tx.uid, uid: tx.uid,
@ -81,9 +81,8 @@ function makeBlockTemplates(mempool: Map<number, CompactThreadTransaction>)
// Build blocks by greedily choosing the highest feerate package // Build blocks by greedily choosing the highest feerate package
// (i.e. the package rooted in the transaction with the best ancestor score) // (i.e. the package rooted in the transaction with the best ancestor score)
const blocks: CompactThreadTransaction[][] = []; const blocks: number[][] = [];
let blockWeight = 4000; let blockWeight = 4000;
let blockSize = 0;
let transactions: AuditTransaction[] = []; let transactions: AuditTransaction[] = [];
const modified: PairingHeap<AuditTransaction> = new PairingHeap((a, b): boolean => { const modified: PairingHeap<AuditTransaction> = new PairingHeap((a, b): boolean => {
if (a.score === b.score) { if (a.score === b.score) {
@ -139,13 +138,16 @@ function makeBlockTemplates(mempool: Map<number, CompactThreadTransaction>)
ancestor.used = true; ancestor.used = true;
ancestor.usedBy = nextTx.uid; ancestor.usedBy = nextTx.uid;
// update original copy of this tx with effective fee rate & relatives data // update original copy of this tx with effective fee rate & relatives data
mempoolTx.effectiveFeePerVsize = effectiveFeeRate; if (mempoolTx.effectiveFeePerVsize !== effectiveFeeRate) {
if (isCluster) { mempoolTx.effectiveFeePerVsize = effectiveFeeRate;
mempoolTx.cpfpRoot = nextTx.uid; mempoolTx.dirty = true;
}
if (mempoolTx.cpfpRoot !== nextTx.uid) {
mempoolTx.cpfpRoot = isCluster ? nextTx.uid : null;
mempoolTx.dirty;
} }
mempoolTx.cpfpChecked = true; mempoolTx.cpfpChecked = true;
transactions.push(ancestor); transactions.push(ancestor);
blockSize += ancestor.size;
blockWeight += ancestor.weight; blockWeight += ancestor.weight;
used.push(ancestor); used.push(ancestor);
} }
@ -171,11 +173,10 @@ function makeBlockTemplates(mempool: Map<number, CompactThreadTransaction>)
if ((exceededPackageTries || queueEmpty) && blocks.length < 7) { if ((exceededPackageTries || queueEmpty) && blocks.length < 7) {
// construct this block // construct this block
if (transactions.length) { if (transactions.length) {
blocks.push(transactions.map(t => mempool.get(t.uid) as CompactThreadTransaction)); blocks.push(transactions.map(t => t.uid));
} }
// reset for the next block // reset for the next block
transactions = []; transactions = [];
blockSize = 0;
blockWeight = 4000; blockWeight = 4000;
// 'overflow' packages didn't fit in this block, but are valid candidates for the next // 'overflow' packages didn't fit in this block, but are valid candidates for the next
@ -196,14 +197,22 @@ function makeBlockTemplates(mempool: Map<number, CompactThreadTransaction>)
} }
// add the final unbounded block if it contains any transactions // add the final unbounded block if it contains any transactions
if (transactions.length > 0) { if (transactions.length > 0) {
blocks.push(transactions.map(t => mempool.get(t.uid) as CompactThreadTransaction)); blocks.push(transactions.map(t => t.uid));
}
// get map of dirty transactions
const rates = new Map<number, number>();
for (const tx of mempool.values()) {
if (tx?.dirty) {
rates.set(tx.uid, tx.effectiveFeePerVsize || tx.feePerVsize);
}
} }
const end = Date.now(); const end = Date.now();
const time = end - start; const time = end - start;
logger.debug('Mempool templates calculated in ' + time / 1000 + ' seconds'); logger.debug('Mempool templates calculated in ' + time / 1000 + ' seconds');
return { blocks, clusters: cpfpClusters }; return { blocks, rates, clusters: cpfpClusters };
} }
// traverse in-mempool ancestors // traverse in-mempool ancestors

View File

@ -114,6 +114,7 @@ export interface CompactThreadTransaction {
inputs: number[]; inputs: number[];
cpfpRoot?: string; cpfpRoot?: string;
cpfpChecked?: boolean; cpfpChecked?: boolean;
dirty?: boolean;
} }
export interface ThreadTransaction { export interface ThreadTransaction {