mempool/backend/src/api/rbf-cache.ts

484 lines
14 KiB
TypeScript
Raw Normal View History

2023-05-12 16:31:01 -06:00
import config from "../config";
2023-05-04 22:59:34 -04:00
import logger from "../logger";
import { MempoolTransactionExtended, TransactionStripped } from "../mempool.interfaces";
2023-03-05 03:02:46 -06:00
import bitcoinApi from './bitcoin/bitcoin-api-factory';
import { Common } from "./common";
2023-05-12 16:31:01 -06:00
import redisCache from "./redis-cache";
2023-05-12 16:31:01 -06:00
export interface RbfTransaction extends TransactionStripped {
rbf?: boolean;
2022-12-17 09:39:06 -06:00
mined?: boolean;
2023-07-14 16:08:57 +09:00
fullRbf?: boolean;
}
2023-05-12 16:31:01 -06:00
export interface RbfTree {
2022-12-17 09:39:06 -06:00
tx: RbfTransaction;
time: number;
interval?: number;
mined?: boolean;
fullRbf: boolean;
replaces: RbfTree[];
}
2022-12-14 08:49:35 -06:00
2023-07-14 16:08:57 +09:00
export interface ReplacementInfo {
mined: boolean;
fullRbf: boolean;
txid: string;
oldFee: number;
oldVsize: number;
newFee: number;
newVsize: number;
}
2023-05-12 16:31:01 -06:00
enum CacheOp {
Remove = 0,
Add = 1,
Change = 2,
}
interface CacheEvent {
op: CacheOp;
type: 'tx' | 'tree' | 'exp';
txid: string,
value?: any,
}
2022-03-08 14:49:25 +01:00
class RbfCache {
2022-12-14 08:49:35 -06:00
private replacedBy: Map<string, string> = new Map();
private replaces: Map<string, string[]> = new Map();
2022-12-17 09:39:06 -06:00
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, MempoolTransactionExtended> = new Map();
2023-05-04 22:59:34 -04:00
private expiring: Map<string, number> = new Map();
2023-05-12 16:31:01 -06:00
private cacheQueue: CacheEvent[] = [];
2022-03-08 14:49:25 +01:00
constructor() {
2023-05-04 22:59:34 -04:00
setInterval(this.cleanup.bind(this), 1000 * 60 * 10);
2022-03-08 14:49:25 +01:00
}
2023-05-12 16:31:01 -06:00
private addTx(txid: string, tx: MempoolTransactionExtended): void {
this.txs.set(txid, tx);
this.cacheQueue.push({ op: CacheOp.Add, type: 'tx', txid });
}
private addTree(txid: string, tree: RbfTree): void {
this.rbfTrees.set(txid, tree);
this.dirtyTrees.add(txid);
this.cacheQueue.push({ op: CacheOp.Add, type: 'tree', txid });
}
private addExpiration(txid: string, expiry: number): void {
this.expiring.set(txid, expiry);
this.cacheQueue.push({ op: CacheOp.Add, type: 'exp', txid, value: expiry });
}
private removeTx(txid: string): void {
this.txs.delete(txid);
this.cacheQueue.push({ op: CacheOp.Remove, type: 'tx', txid });
}
private removeTree(txid: string): void {
this.rbfTrees.delete(txid);
this.cacheQueue.push({ op: CacheOp.Remove, type: 'tree', txid });
}
private removeExpiration(txid: string): void {
this.expiring.delete(txid);
this.cacheQueue.push({ op: CacheOp.Remove, type: 'exp', txid });
}
public add(replaced: MempoolTransactionExtended[], newTxExtended: MempoolTransactionExtended): void {
2023-05-18 09:51:41 -04:00
if (!newTxExtended || !replaced?.length || this.txs.has(newTxExtended.txid)) {
2022-12-17 09:39:06 -06:00
return;
}
const newTx = Common.stripTransaction(newTxExtended) as RbfTransaction;
const newTime = newTxExtended.firstSeen || (Date.now() / 1000);
newTx.rbf = newTxExtended.vin.some((v) => v.sequence < 0xfffffffe);
2023-05-12 16:31:01 -06:00
this.addTx(newTx.txid, newTxExtended);
2022-12-17 09:39:06 -06:00
// maintain rbf trees
2023-07-14 16:08:57 +09:00
let txFullRbf = false;
let treeFullRbf = false;
2022-12-17 09:39:06 -06:00
const replacedTrees: RbfTree[] = [];
for (const replacedTxExtended of replaced) {
const replacedTx = Common.stripTransaction(replacedTxExtended) as RbfTransaction;
replacedTx.rbf = replacedTxExtended.vin.some((v) => v.sequence < 0xfffffffe);
2023-07-14 16:08:57 +09:00
if (!replacedTx.rbf) {
txFullRbf = true;
}
2022-12-17 09:39:06 -06:00
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);
2023-05-12 16:31:01 -06:00
this.removeTree(treeId);
2022-12-17 09:39:06 -06:00
if (tree) {
tree.interval = newTime - tree?.time;
replacedTrees.push(tree);
2023-07-14 16:08:57 +09:00
treeFullRbf = treeFullRbf || tree.fullRbf || !tree.tx.rbf;
2022-12-17 09:39:06 -06:00
}
}
} else {
const replacedTime = replacedTxExtended.firstSeen || (Date.now() / 1000);
2022-12-17 09:39:06 -06:00
replacedTrees.push({
tx: replacedTx,
time: replacedTime,
interval: newTime - replacedTime,
fullRbf: !replacedTx.rbf,
replaces: [],
});
2023-07-14 16:08:57 +09:00
treeFullRbf = treeFullRbf || !replacedTx.rbf;
2023-05-12 16:31:01 -06:00
this.addTx(replacedTx.txid, replacedTxExtended);
2022-12-17 09:39:06 -06:00
}
}
2023-07-14 16:08:57 +09:00
newTx.fullRbf = txFullRbf;
2022-12-17 09:39:06 -06:00
const treeId = replacedTrees[0].tx.txid;
const newTree = {
tx: newTx,
time: newTime,
2023-07-14 16:08:57 +09:00
fullRbf: treeFullRbf,
2022-12-17 09:39:06 -06:00
replaces: replacedTrees
};
2023-05-12 16:31:01 -06:00
this.addTree(treeId, newTree);
2022-12-17 09:39:06 -06:00
this.updateTreeMap(treeId, newTree);
this.replaces.set(newTx.txid, replacedTrees.map(tree => tree.tx.txid));
2022-03-08 14:49:25 +01:00
}
public has(txId: string): boolean {
return this.txs.has(txId);
}
public anyInSameTree(txId: string, predicate: (tx: RbfTransaction) => boolean): boolean {
const tree = this.getRbfTree(txId);
if (!tree) {
return false;
}
const txs = this.getTransactionsInTree(tree);
for (const tx of txs) {
if (predicate(tx)) {
return true;
}
}
return false;
}
public getReplacedBy(txId: string): string | undefined {
2022-12-14 08:49:35 -06:00
return this.replacedBy.get(txId);
}
public getReplaces(txId: string): string[] | undefined {
2022-12-14 08:49:35 -06:00
return this.replaces.get(txId);
}
public getTx(txId: string): MempoolTransactionExtended | undefined {
2022-12-14 08:49:35 -06:00
return this.txs.get(txId);
2022-03-08 14:49:25 +01:00
}
2022-12-17 09:39:06 -06:00
public getRbfTree(txId: string): RbfTree | void {
return this.rbfTrees.get(this.treeMap.get(txId) || '');
}
2022-12-17 09:39:06 -06:00
// get a paginated list of RbfTrees
2022-12-14 16:51:53 -06:00
// ordered by most recent replacement time
2022-12-17 09:39:06 -06:00
public getRbfTrees(onlyFullRbf: boolean, after?: string): RbfTree[] {
2022-12-14 16:51:53 -06:00
const limit = 25;
2022-12-17 09:39:06 -06:00
const trees: RbfTree[] = [];
2022-12-14 16:51:53 -06:00
const used = new Set<string>();
const replacements: string[][] = Array.from(this.replacedBy).reverse();
2022-12-17 09:39:06 -06:00
const afterTree = after ? this.treeMap.get(after) : null;
let ready = !afterTree;
for (let i = 0; i < replacements.length && trees.length <= limit - 1; i++) {
2022-12-14 16:51:53 -06:00
const txid = replacements[i][1];
2022-12-17 09:39:06 -06:00
const treeId = this.treeMap.get(txid) || '';
if (treeId === afterTree) {
2022-12-14 16:51:53 -06:00
ready = true;
} else if (ready) {
2022-12-17 09:39:06 -06:00
if (!used.has(treeId)) {
const tree = this.rbfTrees.get(treeId);
used.add(treeId);
if (tree && (!onlyFullRbf || tree.fullRbf)) {
trees.push(tree);
2022-12-14 16:51:53 -06:00
}
}
}
}
2022-12-17 09:39:06 -06:00
return trees;
2022-12-14 16:51:53 -06:00
}
2022-12-17 09:39:06 -06:00
// 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: {},
2022-12-14 08:49:35 -06:00
map: {},
};
2022-12-17 09:39:06 -06:00
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;
2022-12-14 08:49:35 -06:00
});
}
});
2022-12-17 09:39:06 -06:00
this.dirtyTrees = new Set();
2022-12-14 08:49:35 -06:00
return changes;
}
2022-12-14 16:51:53 -06:00
public mined(txid): void {
2023-05-04 22:59:34 -04:00
if (!this.txs.has(txid)) {
return;
}
2022-12-17 09:39:06 -06:00
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);
2023-05-12 16:31:01 -06:00
this.cacheQueue.push({ op: CacheOp.Change, type: 'tree', txid: treeId });
2022-12-14 16:51:53 -06:00
}
}
this.evict(txid);
}
2022-12-14 08:49:35 -06:00
// flag a transaction as removed from the mempool
2023-05-04 19:10:53 -04:00
public evict(txid: string, fast: boolean = false): void {
2023-05-04 22:59:34 -04:00
if (this.txs.has(txid) && (fast || !this.expiring.has(txid))) {
2023-05-12 16:31:01 -06:00
const expiryTime = fast ? Date.now() + (1000 * 60 * 10) : Date.now() + (1000 * 86400); // 24 hours
this.addExpiration(txid, expiryTime);
2023-05-04 22:59:34 -04:00
}
}
// is the transaction involved in a full rbf replacement?
public isFullRbf(txid: string): boolean {
const treeId = this.treeMap.get(txid);
if (!treeId) {
return false;
}
const tree = this.rbfTrees.get(treeId);
if (!tree) {
return false;
}
return tree?.fullRbf;
}
2022-03-08 14:49:25 +01:00
private cleanup(): void {
2023-05-04 22:59:34 -04:00
const now = Date.now();
for (const txid of this.expiring.keys()) {
if ((this.expiring.get(txid) || 0) < now) {
2023-05-12 16:31:01 -06:00
this.removeExpiration(txid);
this.remove(txid);
}
}
2023-05-12 16:31:01 -06:00
logger.debug(`rbf cache contains ${this.txs.size} txs, ${this.rbfTrees.size} trees, ${this.expiring.size} due to expire`);
}
// remove a transaction & all previous versions from the cache
private remove(txid): void {
// don't remove a transaction if a newer version remains in the mempool
2022-12-14 08:49:35 -06:00
if (!this.replacedBy.has(txid)) {
const replaces = this.replaces.get(txid);
this.replaces.delete(txid);
2022-12-17 09:39:06 -06:00
this.treeMap.delete(txid);
2023-05-12 16:31:01 -06:00
this.removeTx(txid);
this.removeExpiration(txid);
2022-12-14 08:49:35 -06:00
for (const tx of (replaces || [])) {
// recursively remove prior versions from the cache
2022-12-14 08:49:35 -06:00
this.replacedBy.delete(tx);
2022-12-17 09:39:06 -06:00
// if this is the id of a tree, remove that too
if (this.treeMap.get(tx) === tx) {
2023-05-12 16:31:01 -06:00
this.removeTree(tx);
}
this.remove(tx);
2022-03-08 14:49:25 +01:00
}
}
}
2022-12-17 09:39:06 -06:00
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);
});
}
}
2023-03-05 03:02:46 -06:00
2023-05-12 16:31:01 -06:00
public async updateCache(): Promise<void> {
if (!config.REDIS.ENABLED) {
return;
}
// Update the Redis cache by replaying queued events
for (const e of this.cacheQueue) {
if (e.op === CacheOp.Add || e.op === CacheOp.Change) {
let value = e.value;
switch(e.type) {
case 'tx': {
value = this.txs.get(e.txid);
} break;
case 'tree': {
const tree = this.rbfTrees.get(e.txid);
value = tree ? this.exportTree(tree) : null;
} break;
}
if (value != null) {
await redisCache.$setRbfEntry(e.type, e.txid, value);
}
} else if (e.op === CacheOp.Remove) {
await redisCache.$removeRbfEntry(e.type, e.txid);
}
}
this.cacheQueue = [];
}
2023-03-05 03:02:46 -06:00
public dump(): any {
const trees = Array.from(this.rbfTrees.values()).map((tree: RbfTree) => { return this.exportTree(tree); });
return {
txs: Array.from(this.txs.entries()),
trees,
expiring: Array.from(this.expiring.entries()),
};
}
public async load({ txs, trees, expiring }): Promise<void> {
txs.forEach(txEntry => {
this.txs.set(txEntry.key, txEntry.value);
2023-03-05 03:02:46 -06:00
});
for (const deflatedTree of trees) {
await this.importTree(deflatedTree.root, deflatedTree.root, deflatedTree, this.txs);
}
expiring.forEach(expiringEntry => {
if (this.txs.has(expiringEntry.key)) {
this.expiring.set(expiringEntry.key, new Date(expiringEntry.value).getTime());
2023-05-04 22:59:34 -04:00
}
2023-03-05 03:02:46 -06:00
});
this.cleanup();
}
exportTree(tree: RbfTree, deflated: any = null) {
if (!deflated) {
deflated = {
root: tree.tx.txid,
};
}
deflated[tree.tx.txid] = {
tx: tree.tx.txid,
txMined: tree.tx.mined,
time: tree.time,
interval: tree.interval,
mined: tree.mined,
fullRbf: tree.fullRbf,
replaces: tree.replaces.map(child => child.tx.txid),
};
tree.replaces.forEach(child => {
this.exportTree(child, deflated);
});
return deflated;
}
async importTree(root, txid, deflated, txs: Map<string, MempoolTransactionExtended>, mined: boolean = false): Promise<RbfTree | void> {
2023-03-05 03:02:46 -06:00
const treeInfo = deflated[txid];
const replaces: RbfTree[] = [];
// check if any transactions in this tree have already been confirmed
mined = mined || treeInfo.mined;
2023-05-04 22:59:34 -04:00
let exists = mined;
2023-03-05 03:02:46 -06:00
if (!mined) {
try {
const apiTx = await bitcoinApi.$getRawTransaction(txid);
2023-05-04 22:59:34 -04:00
if (apiTx) {
exists = true;
}
2023-03-05 03:02:46 -06:00
if (apiTx?.status?.confirmed) {
mined = true;
2023-05-04 22:59:34 -04:00
treeInfo.txMined = true;
this.evict(txid, true);
2023-03-05 03:02:46 -06:00
}
} catch (e) {
// most transactions do not exist
}
}
2023-05-04 22:59:34 -04:00
// if the root tx is not in the mempool or the blockchain
// evict this tree as soon as possible
if (root === txid && !exists) {
this.evict(txid, true);
}
2023-03-05 03:02:46 -06:00
// recursively reconstruct child trees
for (const childId of treeInfo.replaces) {
const replaced = await this.importTree(root, childId, deflated, txs, mined);
if (replaced) {
this.replacedBy.set(replaced.tx.txid, txid);
replaces.push(replaced);
if (replaced.mined) {
mined = true;
}
}
}
this.replaces.set(txid, replaces.map(t => t.tx.txid));
const tx = txs.get(txid);
if (!tx) {
return;
}
const strippedTx = Common.stripTransaction(tx) as RbfTransaction;
strippedTx.rbf = tx.vin.some((v) => v.sequence < 0xfffffffe);
strippedTx.mined = treeInfo.txMined;
const tree = {
tx: strippedTx,
time: treeInfo.time,
interval: treeInfo.interval,
mined: mined,
fullRbf: treeInfo.fullRbf,
replaces,
};
this.treeMap.set(txid, root);
if (root === txid) {
2023-05-12 16:31:01 -06:00
this.addTree(root, tree);
2023-03-05 03:02:46 -06:00
}
return tree;
}
2023-07-14 16:08:57 +09:00
public getLatestRbfSummary(): ReplacementInfo[] {
const rbfList = this.getRbfTrees(false);
return rbfList.slice(0, 6).map(rbfTree => {
let oldFee = 0;
let oldVsize = 0;
for (const replaced of rbfTree.replaces) {
oldFee += replaced.tx.fee;
oldVsize += replaced.tx.vsize;
}
return {
txid: rbfTree.tx.txid,
mined: !!rbfTree.tx.mined,
fullRbf: !!rbfTree.tx.fullRbf,
oldFee,
oldVsize,
newFee: rbfTree.tx.fee,
newVsize: rbfTree.tx.vsize,
};
});
}
2022-03-08 14:49:25 +01:00
}
export default new RbfCache();