support trees of RBF replacements
This commit is contained in:
@@ -644,7 +644,7 @@ class BitcoinRoutes {
|
||||
|
||||
private async getRbfHistory(req: Request, res: Response) {
|
||||
try {
|
||||
const replacements = rbfCache.getRbfChain(req.params.txId) || [];
|
||||
const replacements = rbfCache.getRbfTree(req.params.txId) || null;
|
||||
const replaces = rbfCache.getReplaces(req.params.txId) || null;
|
||||
res.json({
|
||||
replacements,
|
||||
@@ -657,7 +657,7 @@ class BitcoinRoutes {
|
||||
|
||||
private async getRbfReplacements(req: Request, res: Response) {
|
||||
try {
|
||||
const result = rbfCache.getRbfChains(false);
|
||||
const result = rbfCache.getRbfTrees(false);
|
||||
res.json(result);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
@@ -666,7 +666,7 @@ class BitcoinRoutes {
|
||||
|
||||
private async getFullRbfReplacements(req: Request, res: Response) {
|
||||
try {
|
||||
const result = rbfCache.getRbfChains(true);
|
||||
const result = rbfCache.getRbfTrees(true);
|
||||
res.json(result);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
|
||||
@@ -57,11 +57,11 @@ export class Common {
|
||||
return arr;
|
||||
}
|
||||
|
||||
static findRbfTransactions(added: TransactionExtended[], deleted: TransactionExtended[]): { [txid: string]: TransactionExtended } {
|
||||
const matches: { [txid: string]: TransactionExtended } = {};
|
||||
deleted
|
||||
.forEach((deletedTx) => {
|
||||
const foundMatches = added.find((addedTx) => {
|
||||
static findRbfTransactions(added: TransactionExtended[], deleted: TransactionExtended[]): { [txid: string]: TransactionExtended[] } {
|
||||
const matches: { [txid: string]: TransactionExtended[] } = {};
|
||||
added
|
||||
.forEach((addedTx) => {
|
||||
const foundMatches = deleted.filter((deletedTx) => {
|
||||
// The new tx must, absolutely speaking, pay at least as much fee as the replaced tx.
|
||||
return addedTx.fee > deletedTx.fee
|
||||
// The new transaction must pay more fee per kB than the replaced tx.
|
||||
@@ -70,8 +70,8 @@ export class Common {
|
||||
&& deletedTx.vin.some((deletedVin) =>
|
||||
addedTx.vin.some((vin) => vin.txid === deletedVin.txid && vin.vout === deletedVin.vout));
|
||||
});
|
||||
if (foundMatches) {
|
||||
matches[deletedTx.txid] = foundMatches;
|
||||
if (foundMatches?.length) {
|
||||
matches[addedTx.txid] = foundMatches;
|
||||
}
|
||||
});
|
||||
return matches;
|
||||
|
||||
@@ -265,13 +265,15 @@ class Mempool {
|
||||
}
|
||||
}
|
||||
|
||||
public handleRbfTransactions(rbfTransactions: { [txid: string]: TransactionExtended; }) {
|
||||
public handleRbfTransactions(rbfTransactions: { [txid: string]: TransactionExtended[]; }): void {
|
||||
for (const rbfTransaction in rbfTransactions) {
|
||||
if (this.mempoolCache[rbfTransaction]) {
|
||||
if (this.mempoolCache[rbfTransaction] && rbfTransactions[rbfTransaction]?.length) {
|
||||
// Store replaced transactions
|
||||
rbfCache.add(this.mempoolCache[rbfTransaction], rbfTransactions[rbfTransaction]);
|
||||
rbfCache.add(rbfTransactions[rbfTransaction], this.mempoolCache[rbfTransaction]);
|
||||
// Erase the replaced transactions from the local mempool
|
||||
delete this.mempoolCache[rbfTransaction];
|
||||
for (const replaced of rbfTransactions[rbfTransaction]) {
|
||||
delete this.mempoolCache[replaced.txid];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
import { runInNewContext } from "vm";
|
||||
import { TransactionExtended, TransactionStripped } from "../mempool.interfaces";
|
||||
import { Common } from "./common";
|
||||
|
||||
interface RbfTransaction extends TransactionStripped {
|
||||
rbf?: boolean;
|
||||
mined?: boolean;
|
||||
}
|
||||
|
||||
type RbfChain = {
|
||||
tx: RbfTransaction,
|
||||
time: number,
|
||||
mined?: boolean,
|
||||
}[];
|
||||
interface RbfTree {
|
||||
tx: RbfTransaction;
|
||||
time: number;
|
||||
interval?: number;
|
||||
mined?: boolean;
|
||||
fullRbf: boolean;
|
||||
replaces: RbfTree[];
|
||||
}
|
||||
|
||||
class RbfCache {
|
||||
private replacedBy: Map<string, string> = new Map();
|
||||
private replaces: Map<string, string[]> = new Map();
|
||||
private rbfChains: Map<string, RbfChain> = new Map(); // sequences of consecutive replacements
|
||||
private dirtyChains: Set<string> = new Set();
|
||||
private chainMap: Map<string, string> = new Map(); // map of txids to sequence ids
|
||||
private rbfTrees: Map<string, RbfTree> = new Map(); // sequences of consecutive replacements
|
||||
private dirtyTrees: Set<string> = new Set();
|
||||
private treeMap: Map<string, string> = new Map(); // map of txids to sequence ids
|
||||
private txs: Map<string, TransactionExtended> = new Map();
|
||||
private expiring: Map<string, Date> = new Map();
|
||||
|
||||
@@ -24,37 +29,58 @@ class RbfCache {
|
||||
setInterval(this.cleanup.bind(this), 1000 * 60 * 60);
|
||||
}
|
||||
|
||||
public add(replacedTxExtended: TransactionExtended, newTxExtended: TransactionExtended): void {
|
||||
const replacedTx = Common.stripTransaction(replacedTxExtended) as RbfTransaction;
|
||||
replacedTx.rbf = replacedTxExtended.vin.some((v) => v.sequence < 0xfffffffe);
|
||||
public add(replaced: TransactionExtended[], newTxExtended: TransactionExtended): void {
|
||||
if (!newTxExtended || !replaced?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newTx = Common.stripTransaction(newTxExtended) as RbfTransaction;
|
||||
const newTime = newTxExtended.firstSeen || Date.now();
|
||||
newTx.rbf = newTxExtended.vin.some((v) => v.sequence < 0xfffffffe);
|
||||
|
||||
this.replacedBy.set(replacedTx.txid, newTx.txid);
|
||||
this.txs.set(replacedTx.txid, replacedTxExtended);
|
||||
this.txs.set(newTx.txid, newTxExtended);
|
||||
if (!this.replaces.has(newTx.txid)) {
|
||||
this.replaces.set(newTx.txid, []);
|
||||
}
|
||||
this.replaces.get(newTx.txid)?.push(replacedTx.txid);
|
||||
|
||||
// maintain rbf chains
|
||||
if (this.chainMap.has(replacedTx.txid)) {
|
||||
// add to an existing chain
|
||||
const chainRoot = this.chainMap.get(replacedTx.txid) || '';
|
||||
this.rbfChains.get(chainRoot)?.push({ tx: newTx, time: newTxExtended.firstSeen || Date.now() });
|
||||
this.chainMap.set(newTx.txid, chainRoot);
|
||||
this.dirtyChains.add(chainRoot);
|
||||
} else {
|
||||
// start a new chain
|
||||
this.rbfChains.set(replacedTx.txid, [
|
||||
{ tx: replacedTx, time: replacedTxExtended.firstSeen || Date.now() },
|
||||
{ tx: newTx, time: newTxExtended.firstSeen || Date.now() },
|
||||
]);
|
||||
this.chainMap.set(replacedTx.txid, replacedTx.txid);
|
||||
this.chainMap.set(newTx.txid, replacedTx.txid);
|
||||
this.dirtyChains.add(replacedTx.txid);
|
||||
// maintain rbf trees
|
||||
let fullRbf = false;
|
||||
const replacedTrees: RbfTree[] = [];
|
||||
for (const replacedTxExtended of replaced) {
|
||||
const replacedTx = Common.stripTransaction(replacedTxExtended) as RbfTransaction;
|
||||
replacedTx.rbf = replacedTxExtended.vin.some((v) => v.sequence < 0xfffffffe);
|
||||
this.replacedBy.set(replacedTx.txid, newTx.txid);
|
||||
if (this.treeMap.has(replacedTx.txid)) {
|
||||
const treeId = this.treeMap.get(replacedTx.txid);
|
||||
if (treeId) {
|
||||
const tree = this.rbfTrees.get(treeId);
|
||||
this.rbfTrees.delete(treeId);
|
||||
if (tree) {
|
||||
tree.interval = newTime - tree?.time;
|
||||
replacedTrees.push(tree);
|
||||
fullRbf = fullRbf || tree.fullRbf;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const replacedTime = replacedTxExtended.firstSeen || Date.now();
|
||||
replacedTrees.push({
|
||||
tx: replacedTx,
|
||||
time: replacedTime,
|
||||
interval: newTime - replacedTime,
|
||||
fullRbf: !replacedTx.rbf,
|
||||
replaces: [],
|
||||
});
|
||||
fullRbf = fullRbf || !replacedTx.rbf;
|
||||
this.txs.set(replacedTx.txid, replacedTxExtended);
|
||||
}
|
||||
}
|
||||
const treeId = replacedTrees[0].tx.txid;
|
||||
const newTree = {
|
||||
tx: newTx,
|
||||
time: newTxExtended.firstSeen || Date.now(),
|
||||
fullRbf,
|
||||
replaces: replacedTrees
|
||||
};
|
||||
this.rbfTrees.set(treeId, newTree);
|
||||
this.updateTreeMap(treeId, newTree);
|
||||
this.replaces.set(newTx.txid, replacedTrees.map(tree => tree.tx.txid));
|
||||
this.dirtyTrees.add(treeId);
|
||||
}
|
||||
|
||||
public getReplacedBy(txId: string): string | undefined {
|
||||
@@ -69,66 +95,64 @@ class RbfCache {
|
||||
return this.txs.get(txId);
|
||||
}
|
||||
|
||||
public getRbfChain(txId: string): RbfChain {
|
||||
return this.rbfChains.get(this.chainMap.get(txId) || '') || [];
|
||||
public getRbfTree(txId: string): RbfTree | void {
|
||||
return this.rbfTrees.get(this.treeMap.get(txId) || '');
|
||||
}
|
||||
|
||||
// get a paginated list of RbfChains
|
||||
// get a paginated list of RbfTrees
|
||||
// ordered by most recent replacement time
|
||||
public getRbfChains(onlyFullRbf: boolean, after?: string): RbfChain[] {
|
||||
public getRbfTrees(onlyFullRbf: boolean, after?: string): RbfTree[] {
|
||||
const limit = 25;
|
||||
const chains: RbfChain[] = [];
|
||||
const trees: RbfTree[] = [];
|
||||
const used = new Set<string>();
|
||||
const replacements: string[][] = Array.from(this.replacedBy).reverse();
|
||||
const afterChain = after ? this.chainMap.get(after) : null;
|
||||
let ready = !afterChain;
|
||||
for (let i = 0; i < replacements.length && chains.length <= limit - 1; i++) {
|
||||
const afterTree = after ? this.treeMap.get(after) : null;
|
||||
let ready = !afterTree;
|
||||
for (let i = 0; i < replacements.length && trees.length <= limit - 1; i++) {
|
||||
const txid = replacements[i][1];
|
||||
const chainRoot = this.chainMap.get(txid) || '';
|
||||
if (chainRoot === afterChain) {
|
||||
const treeId = this.treeMap.get(txid) || '';
|
||||
if (treeId === afterTree) {
|
||||
ready = true;
|
||||
} else if (ready) {
|
||||
if (!used.has(chainRoot)) {
|
||||
const chain = this.rbfChains.get(chainRoot);
|
||||
used.add(chainRoot);
|
||||
if (chain && (!onlyFullRbf || chain.slice(0, -1).some(entry => !entry.tx.rbf))) {
|
||||
chains.push(chain);
|
||||
if (!used.has(treeId)) {
|
||||
const tree = this.rbfTrees.get(treeId);
|
||||
used.add(treeId);
|
||||
if (tree && (!onlyFullRbf || tree.fullRbf)) {
|
||||
trees.push(tree);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return chains;
|
||||
return trees;
|
||||
}
|
||||
|
||||
// get map of rbf chains that have been updated since the last call
|
||||
public getRbfChanges(): { chains: {[root: string]: RbfChain }, map: { [txid: string]: string }} {
|
||||
const changes: { chains: {[root: string]: RbfChain }, map: { [txid: string]: string }} = {
|
||||
chains: {},
|
||||
// get map of rbf trees that have been updated since the last call
|
||||
public getRbfChanges(): { trees: {[id: string]: RbfTree }, map: { [txid: string]: string }} {
|
||||
const changes: { trees: {[id: string]: RbfTree }, map: { [txid: string]: string }} = {
|
||||
trees: {},
|
||||
map: {},
|
||||
};
|
||||
this.dirtyChains.forEach(root => {
|
||||
const chain = this.rbfChains.get(root);
|
||||
if (chain) {
|
||||
changes.chains[root] = chain;
|
||||
chain.forEach(entry => {
|
||||
changes.map[entry.tx.txid] = root;
|
||||
this.dirtyTrees.forEach(id => {
|
||||
const tree = this.rbfTrees.get(id);
|
||||
if (tree) {
|
||||
changes.trees[id] = tree;
|
||||
this.getTransactionsInTree(tree).forEach(tx => {
|
||||
changes.map[tx.txid] = id;
|
||||
});
|
||||
}
|
||||
});
|
||||
this.dirtyChains = new Set();
|
||||
this.dirtyTrees = new Set();
|
||||
return changes;
|
||||
}
|
||||
|
||||
public mined(txid): void {
|
||||
const chainRoot = this.chainMap.get(txid)
|
||||
if (chainRoot && this.rbfChains.has(chainRoot)) {
|
||||
const chain = this.rbfChains.get(chainRoot);
|
||||
if (chain) {
|
||||
const chainEntry = chain.find(entry => entry.tx.txid === txid);
|
||||
if (chainEntry) {
|
||||
chainEntry.mined = true;
|
||||
}
|
||||
this.dirtyChains.add(chainRoot);
|
||||
const treeId = this.treeMap.get(txid);
|
||||
if (treeId && this.rbfTrees.has(treeId)) {
|
||||
const tree = this.rbfTrees.get(treeId);
|
||||
if (tree) {
|
||||
this.setTreeMined(tree, txid);
|
||||
tree.mined = true;
|
||||
this.dirtyTrees.add(treeId);
|
||||
}
|
||||
}
|
||||
this.evict(txid);
|
||||
@@ -155,20 +179,45 @@ class RbfCache {
|
||||
if (!this.replacedBy.has(txid)) {
|
||||
const replaces = this.replaces.get(txid);
|
||||
this.replaces.delete(txid);
|
||||
this.chainMap.delete(txid);
|
||||
this.treeMap.delete(txid);
|
||||
this.txs.delete(txid);
|
||||
this.expiring.delete(txid);
|
||||
for (const tx of (replaces || [])) {
|
||||
// recursively remove prior versions from the cache
|
||||
this.replacedBy.delete(tx);
|
||||
// if this is the root of a chain, remove that too
|
||||
if (this.chainMap.get(tx) === tx) {
|
||||
this.rbfChains.delete(tx);
|
||||
// if this is the id of a tree, remove that too
|
||||
if (this.treeMap.get(tx) === tx) {
|
||||
this.rbfTrees.delete(tx);
|
||||
}
|
||||
this.remove(tx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private updateTreeMap(newId: string, tree: RbfTree): void {
|
||||
this.treeMap.set(tree.tx.txid, newId);
|
||||
tree.replaces.forEach(subtree => {
|
||||
this.updateTreeMap(newId, subtree);
|
||||
});
|
||||
}
|
||||
|
||||
private getTransactionsInTree(tree: RbfTree, txs: RbfTransaction[] = []): RbfTransaction[] {
|
||||
txs.push(tree.tx);
|
||||
tree.replaces.forEach(subtree => {
|
||||
this.getTransactionsInTree(subtree, txs);
|
||||
});
|
||||
return txs;
|
||||
}
|
||||
|
||||
private setTreeMined(tree: RbfTree, txid: string): void {
|
||||
if (tree.tx.txid === txid) {
|
||||
tree.tx.mined = true;
|
||||
} else {
|
||||
tree.replaces.forEach(subtree => {
|
||||
this.setTreeMined(subtree, txid);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new RbfCache();
|
||||
|
||||
@@ -289,9 +289,9 @@ class WebsocketHandler {
|
||||
const rbfChanges = rbfCache.getRbfChanges();
|
||||
let rbfReplacements;
|
||||
let fullRbfReplacements;
|
||||
if (Object.keys(rbfChanges.chains).length) {
|
||||
rbfReplacements = rbfCache.getRbfChains(false);
|
||||
fullRbfReplacements = rbfCache.getRbfChains(true);
|
||||
if (Object.keys(rbfChanges.trees).length) {
|
||||
rbfReplacements = rbfCache.getRbfTrees(false);
|
||||
fullRbfReplacements = rbfCache.getRbfTrees(true);
|
||||
}
|
||||
const recommendedFees = feeApi.getRecommendedFee();
|
||||
|
||||
@@ -415,20 +415,16 @@ class WebsocketHandler {
|
||||
response['utxoSpent'] = outspends;
|
||||
}
|
||||
|
||||
if (rbfTransactions[client['track-tx']]) {
|
||||
for (const rbfTransaction in rbfTransactions) {
|
||||
if (client['track-tx'] === rbfTransaction) {
|
||||
response['rbfTransaction'] = {
|
||||
txid: rbfTransactions[rbfTransaction].txid,
|
||||
};
|
||||
break;
|
||||
}
|
||||
const rbfReplacedBy = rbfCache.getReplacedBy(client['track-tx']);
|
||||
if (rbfReplacedBy) {
|
||||
response['rbfTransaction'] = {
|
||||
txid: rbfReplacedBy,
|
||||
}
|
||||
}
|
||||
|
||||
const rbfChange = rbfChanges.map[client['track-tx']];
|
||||
if (rbfChange) {
|
||||
response['rbfInfo'] = rbfChanges.chains[rbfChange];
|
||||
response['rbfInfo'] = rbfChanges.trees[rbfChange];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user