diff --git a/backend/src/api/bitcoin/bitcoin.routes.ts b/backend/src/api/bitcoin/bitcoin.routes.ts index 298ae3715..18d688e9b 100644 --- a/backend/src/api/bitcoin/bitcoin.routes.ts +++ b/backend/src/api/bitcoin/bitcoin.routes.ts @@ -32,8 +32,10 @@ class BitcoinRoutes { .get(config.MEMPOOL.API_URL_PREFIX + 'backend-info', this.getBackendInfo) .get(config.MEMPOOL.API_URL_PREFIX + 'init-data', this.getInitData) .get(config.MEMPOOL.API_URL_PREFIX + 'validate-address/:address', this.validateAddress) - .get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/replaces', this.getRbfHistory) + .get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/rbf', this.getRbfHistory) .get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/cached', this.getCachedTx) + .get(config.MEMPOOL.API_URL_PREFIX + 'replacements', this.getRbfReplacements) + .get(config.MEMPOOL.API_URL_PREFIX + 'fullrbf/replacements', this.getFullRbfReplacements) .post(config.MEMPOOL.API_URL_PREFIX + 'tx/push', this.$postTransactionForm) .get(config.MEMPOOL.API_URL_PREFIX + 'donations', async (req, res) => { try { @@ -642,8 +644,30 @@ class BitcoinRoutes { private async getRbfHistory(req: Request, res: Response) { try { - const result = rbfCache.getReplaces(req.params.txId); - res.json(result || []); + const replacements = rbfCache.getRbfTree(req.params.txId) || null; + const replaces = rbfCache.getReplaces(req.params.txId) || null; + res.json({ + replacements, + replaces + }); + } catch (e) { + res.status(500).send(e instanceof Error ? e.message : e); + } + } + + private async getRbfReplacements(req: Request, res: Response) { + try { + const result = rbfCache.getRbfTrees(false); + res.json(result); + } catch (e) { + res.status(500).send(e instanceof Error ? e.message : e); + } + } + + private async getFullRbfReplacements(req: Request, res: Response) { + try { + const result = rbfCache.getRbfTrees(true); + res.json(result); } catch (e) { res.status(500).send(e instanceof Error ? e.message : e); } diff --git a/backend/src/api/common.ts b/backend/src/api/common.ts index 1d3b11d66..8bae655e3 100644 --- a/backend/src/api/common.ts +++ b/backend/src/api/common.ts @@ -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; diff --git a/backend/src/api/disk-cache.ts b/backend/src/api/disk-cache.ts index f053180b0..7a38e7da0 100644 --- a/backend/src/api/disk-cache.ts +++ b/backend/src/api/disk-cache.ts @@ -7,14 +7,18 @@ import logger from '../logger'; import config from '../config'; import { TransactionExtended } from '../mempool.interfaces'; import { Common } from './common'; +import rbfCache from './rbf-cache'; class DiskCache { private cacheSchemaVersion = 3; + private rbfCacheSchemaVersion = 1; private static TMP_FILE_NAME = config.MEMPOOL.CACHE_DIR + '/tmp-cache.json'; private static TMP_FILE_NAMES = config.MEMPOOL.CACHE_DIR + '/tmp-cache{number}.json'; private static FILE_NAME = config.MEMPOOL.CACHE_DIR + '/cache.json'; private static FILE_NAMES = config.MEMPOOL.CACHE_DIR + '/cache{number}.json'; + private static TMP_RBF_FILE_NAME = config.MEMPOOL.CACHE_DIR + '/tmp-rbfcache.json'; + private static RBF_FILE_NAME = config.MEMPOOL.CACHE_DIR + '/rbfcache.json'; private static CHUNK_FILES = 25; private isWritingCache = false; @@ -100,6 +104,32 @@ class DiskCache { logger.warn('Error writing to cache file: ' + (e instanceof Error ? e.message : e)); this.isWritingCache = false; } + + try { + logger.debug('Writing rbf data to disk cache (async)...'); + this.isWritingCache = true; + const rbfData = rbfCache.dump(); + if (sync) { + fs.writeFileSync(DiskCache.TMP_RBF_FILE_NAME, JSON.stringify({ + network: config.MEMPOOL.NETWORK, + rbfCacheSchemaVersion: this.rbfCacheSchemaVersion, + rbf: rbfData, + }), { flag: 'w' }); + fs.renameSync(DiskCache.TMP_RBF_FILE_NAME, DiskCache.RBF_FILE_NAME); + } else { + await fsPromises.writeFile(DiskCache.TMP_RBF_FILE_NAME, JSON.stringify({ + network: config.MEMPOOL.NETWORK, + rbfCacheSchemaVersion: this.rbfCacheSchemaVersion, + rbf: rbfData, + }), { flag: 'w' }); + await fsPromises.rename(DiskCache.TMP_RBF_FILE_NAME, DiskCache.RBF_FILE_NAME); + } + logger.debug('Rbf data saved to disk cache'); + this.isWritingCache = false; + } catch (e) { + logger.warn('Error writing rbf data to cache file: ' + (e instanceof Error ? e.message : e)); + this.isWritingCache = false; + } } wipeCache(): void { @@ -124,6 +154,18 @@ class DiskCache { } } + wipeRbfCache() { + logger.notice(`Wipping nodejs backend cache/rbfcache.json file`); + + try { + fs.unlinkSync(DiskCache.RBF_FILE_NAME); + } catch (e: any) { + if (e?.code !== 'ENOENT') { + logger.err(`Cannot wipe cache file ${DiskCache.RBF_FILE_NAME}. Exception ${JSON.stringify(e)}`); + } + } + } + async $loadMempoolCache(): Promise { if (!fs.existsSync(DiskCache.FILE_NAME)) { return; @@ -174,6 +216,29 @@ class DiskCache { } catch (e) { logger.warn('Failed to parse mempoool and blocks cache. Skipping. Reason: ' + (e instanceof Error ? e.message : e)); } + + try { + let rbfData: any = {}; + const rbfCacheData = fs.readFileSync(DiskCache.RBF_FILE_NAME, 'utf8'); + if (rbfCacheData) { + logger.info('Restoring rbf data from disk cache'); + rbfData = JSON.parse(rbfCacheData); + if (rbfData.rbfCacheSchemaVersion === undefined || rbfData.rbfCacheSchemaVersion !== this.rbfCacheSchemaVersion) { + logger.notice('Rbf disk cache contains an outdated schema version. Clearing it and skipping the cache loading.'); + return this.wipeRbfCache(); + } + if (rbfData.network && rbfData.network !== config.MEMPOOL.NETWORK) { + logger.notice('Rbf disk cache contains data from a different network. Clearing it and skipping the cache loading.'); + return this.wipeRbfCache(); + } + } + + if (rbfData?.rbf) { + rbfCache.load(rbfData.rbf); + } + } catch (e) { + logger.warn('Failed to parse rbf cache. Skipping. Reason: ' + (e instanceof Error ? e.message : e)); + } } } diff --git a/backend/src/api/mempool.ts b/backend/src/api/mempool.ts index 0d593f1a3..d476d6bca 100644 --- a/backend/src/api/mempool.ts +++ b/backend/src/api/mempool.ts @@ -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].txid); + 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]; + } } } } diff --git a/backend/src/api/rbf-cache.ts b/backend/src/api/rbf-cache.ts index 410239e73..7f910e0f7 100644 --- a/backend/src/api/rbf-cache.ts +++ b/backend/src/api/rbf-cache.ts @@ -1,46 +1,173 @@ -import { TransactionExtended } from "../mempool.interfaces"; +import { TransactionExtended, TransactionStripped } from "../mempool.interfaces"; +import bitcoinApi from './bitcoin/bitcoin-api-factory'; +import { Common } from "./common"; + +interface RbfTransaction extends TransactionStripped { + rbf?: boolean; + mined?: boolean; +} + +interface RbfTree { + tx: RbfTransaction; + time: number; + interval?: number; + mined?: boolean; + fullRbf: boolean; + replaces: RbfTree[]; +} class RbfCache { - private replacedBy: { [txid: string]: string; } = {}; - private replaces: { [txid: string]: string[] } = {}; - private txs: { [txid: string]: TransactionExtended } = {}; - private expiring: { [txid: string]: Date } = {}; + private replacedBy: Map = new Map(); + private replaces: Map = new Map(); + private rbfTrees: Map = new Map(); // sequences of consecutive replacements + private dirtyTrees: Set = new Set(); + private treeMap: Map = new Map(); // map of txids to sequence ids + private txs: Map = new Map(); + private expiring: Map = new Map(); constructor() { setInterval(this.cleanup.bind(this), 1000 * 60 * 60); } - public add(replacedTx: TransactionExtended, newTxId: string): void { - this.replacedBy[replacedTx.txid] = newTxId; - this.txs[replacedTx.txid] = replacedTx; - if (!this.replaces[newTxId]) { - this.replaces[newTxId] = []; + public add(replaced: TransactionExtended[], newTxExtended: TransactionExtended): void { + if (!newTxExtended || !replaced?.length) { + return; } - this.replaces[newTxId].push(replacedTx.txid); + + const newTx = Common.stripTransaction(newTxExtended) as RbfTransaction; + const newTime = newTxExtended.firstSeen || Date.now(); + newTx.rbf = newTxExtended.vin.some((v) => v.sequence < 0xfffffffe); + this.txs.set(newTx.txid, newTxExtended); + + // 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 { - return this.replacedBy[txId]; + return this.replacedBy.get(txId); } public getReplaces(txId: string): string[] | undefined { - return this.replaces[txId]; + return this.replaces.get(txId); } public getTx(txId: string): TransactionExtended | undefined { - return this.txs[txId]; + return this.txs.get(txId); + } + + public getRbfTree(txId: string): RbfTree | void { + return this.rbfTrees.get(this.treeMap.get(txId) || ''); + } + + // get a paginated list of RbfTrees + // ordered by most recent replacement time + public getRbfTrees(onlyFullRbf: boolean, after?: string): RbfTree[] { + const limit = 25; + const trees: RbfTree[] = []; + const used = new Set(); + const replacements: string[][] = Array.from(this.replacedBy).reverse(); + 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 treeId = this.treeMap.get(txid) || ''; + if (treeId === afterTree) { + ready = true; + } else if (ready) { + if (!used.has(treeId)) { + const tree = this.rbfTrees.get(treeId); + used.add(treeId); + if (tree && (!onlyFullRbf || tree.fullRbf)) { + trees.push(tree); + } + } + } + } + return trees; + } + + // 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.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.dirtyTrees = new Set(); + return changes; + } + + public mined(txid): void { + 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); } // flag a transaction as removed from the mempool public evict(txid): void { - this.expiring[txid] = new Date(Date.now() + 1000 * 86400); // 24 hours + this.expiring.set(txid, new Date(Date.now() + 1000 * 86400)); // 24 hours } private cleanup(): void { const currentDate = new Date(); for (const txid in this.expiring) { - if (this.expiring[txid] < currentDate) { - delete this.expiring[txid]; + if ((this.expiring.get(txid) || 0) < currentDate) { + this.expiring.delete(txid); this.remove(txid); } } @@ -48,18 +175,147 @@ class RbfCache { // remove a transaction & all previous versions from the cache private remove(txid): void { - // don't remove a transaction while a newer version remains in the mempool - if (this.replaces[txid] && !this.replacedBy[txid]) { - const replaces = this.replaces[txid]; - delete this.replaces[txid]; - for (const tx of replaces) { + // don't remove a transaction if a newer version remains in the mempool + if (!this.replacedBy.has(txid)) { + const replaces = this.replaces.get(txid); + this.replaces.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 - delete this.replacedBy[tx]; - delete this.txs[tx]; + this.replacedBy.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); + }); + } + } + + 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 { + txs.forEach(txEntry => { + this.txs.set(txEntry[0], txEntry[1]); + }); + for (const deflatedTree of trees) { + await this.importTree(deflatedTree.root, deflatedTree.root, deflatedTree, this.txs); + } + expiring.forEach(expiringEntry => { + this.expiring.set(expiringEntry[0], expiringEntry[1]); + }); + 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, mined: boolean = false): Promise { + const treeInfo = deflated[txid]; + const replaces: RbfTree[] = []; + + // check if any transactions in this tree have already been confirmed + mined = mined || treeInfo.mined; + if (!mined) { + try { + const apiTx = await bitcoinApi.$getRawTransaction(txid); + if (apiTx?.status?.confirmed) { + mined = true; + this.evict(txid); + } + } catch (e) { + // most transactions do not exist + } + } + + // 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) { + this.rbfTrees.set(root, tree); + this.dirtyTrees.add(root); + } + return tree; + } } export default new RbfCache(); diff --git a/backend/src/api/websocket-handler.ts b/backend/src/api/websocket-handler.ts index 865dfe9d6..3f5eb1e02 100644 --- a/backend/src/api/websocket-handler.ts +++ b/backend/src/api/websocket-handler.ts @@ -140,6 +140,14 @@ class WebsocketHandler { } } + if (parsedMessage && parsedMessage['track-rbf'] !== undefined) { + if (['all', 'fullRbf'].includes(parsedMessage['track-rbf'])) { + client['track-rbf'] = parsedMessage['track-rbf']; + } else { + client['track-rbf'] = false; + } + } + if (parsedMessage.action === 'init') { const _blocks = blocks.getBlocks().slice(-config.MEMPOOL.INITIAL_BLOCKS_AMOUNT); if (!_blocks) { @@ -278,6 +286,13 @@ class WebsocketHandler { const rbfTransactions = Common.findRbfTransactions(newTransactions, deletedTransactions); const da = difficultyAdjustment.getDifficultyAdjustment(); memPool.handleRbfTransactions(rbfTransactions); + const rbfChanges = rbfCache.getRbfChanges(); + let rbfReplacements; + let fullRbfReplacements; + if (Object.keys(rbfChanges.trees).length) { + rbfReplacements = rbfCache.getRbfTrees(false); + fullRbfReplacements = rbfCache.getRbfTrees(true); + } const recommendedFees = feeApi.getRecommendedFee(); this.wss.clients.forEach(async (client) => { @@ -400,16 +415,17 @@ 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.trees[rbfChange]; + } } if (client['track-mempool-block'] >= 0) { @@ -422,6 +438,12 @@ class WebsocketHandler { } } + if (client['track-rbf'] === 'all' && rbfReplacements) { + response['rbfLatest'] = rbfReplacements; + } else if (client['track-rbf'] === 'fullRbf' && fullRbfReplacements) { + response['rbfLatest'] = fullRbfReplacements; + } + if (Object.keys(response).length) { client.send(JSON.stringify(response)); } @@ -500,7 +522,7 @@ class WebsocketHandler { // Update mempool to remove transactions included in the new block for (const txId of txIds) { delete _memPool[txId]; - rbfCache.evict(txId); + rbfCache.mined(txId); } if (config.MEMPOOL.ADVANCED_GBT_MEMPOOL) { diff --git a/docker/frontend/entrypoint.sh b/docker/frontend/entrypoint.sh index b6946578b..013b1ce53 100644 --- a/docker/frontend/entrypoint.sh +++ b/docker/frontend/entrypoint.sh @@ -39,6 +39,7 @@ __AUDIT__=${AUDIT:=false} __MAINNET_BLOCK_AUDIT_START_HEIGHT__=${MAINNET_BLOCK_AUDIT_START_HEIGHT:=0} __TESTNET_BLOCK_AUDIT_START_HEIGHT__=${TESTNET_BLOCK_AUDIT_START_HEIGHT:=0} __SIGNET_BLOCK_AUDIT_START_HEIGHT__=${SIGNET_BLOCK_AUDIT_START_HEIGHT:=0} +__FULL_RBF_ENABLED__=${FULL_RBF_ENABLED:=false} __HISTORICAL_PRICE__=${HISTORICAL_PRICE:=true} # Export as environment variables to be used by envsubst @@ -65,6 +66,7 @@ export __AUDIT__ export __MAINNET_BLOCK_AUDIT_START_HEIGHT__ export __TESTNET_BLOCK_AUDIT_START_HEIGHT__ export __SIGNET_BLOCK_AUDIT_START_HEIGHT__ +export __FULL_RBF_ENABLED__ export __HISTORICAL_PRICE__ folder=$(find /var/www/mempool -name "config.js" | xargs dirname) diff --git a/frontend/mempool-frontend-config.sample.json b/frontend/mempool-frontend-config.sample.json index 084cbd0ef..c45425612 100644 --- a/frontend/mempool-frontend-config.sample.json +++ b/frontend/mempool-frontend-config.sample.json @@ -22,5 +22,6 @@ "TESTNET_BLOCK_AUDIT_START_HEIGHT": 0, "SIGNET_BLOCK_AUDIT_START_HEIGHT": 0, "LIGHTNING": false, + "FULL_RBF_ENABLED": false, "HISTORICAL_PRICE": true } diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index 90ea84a82..06334c5b5 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -14,6 +14,7 @@ import { TrademarkPolicyComponent } from './components/trademark-policy/trademar import { BisqMasterPageComponent } from './components/bisq-master-page/bisq-master-page.component'; import { PushTransactionComponent } from './components/push-transaction/push-transaction.component'; import { BlocksList } from './components/blocks-list/blocks-list.component'; +import { RbfList } from './components/rbf-list/rbf-list.component'; import { LiquidMasterPageComponent } from './components/liquid-master-page/liquid-master-page.component'; import { AssetGroupComponent } from './components/assets/asset-group/asset-group.component'; import { AssetsFeaturedComponent } from './components/assets/assets-featured/assets-featured.component'; @@ -56,6 +57,10 @@ let routes: Routes = [ path: 'blocks', component: BlocksList, }, + { + path: 'rbf', + component: RbfList, + }, { path: 'terms-of-service', component: TermsOfServiceComponent @@ -162,6 +167,10 @@ let routes: Routes = [ path: 'blocks', component: BlocksList, }, + { + path: 'rbf', + component: RbfList, + }, { path: 'terms-of-service', component: TermsOfServiceComponent @@ -264,6 +273,10 @@ let routes: Routes = [ path: 'blocks', component: BlocksList, }, + { + path: 'rbf', + component: RbfList, + }, { path: 'terms-of-service', component: TermsOfServiceComponent diff --git a/frontend/src/app/components/rbf-list/rbf-list.component.html b/frontend/src/app/components/rbf-list/rbf-list.component.html new file mode 100644 index 000000000..eebb7e152 --- /dev/null +++ b/frontend/src/app/components/rbf-list/rbf-list.component.html @@ -0,0 +1,46 @@ +
+

RBF Replacements

+
+ +
+
+
+ + +
+
+
+ +
+ +
+ +
+

+ + Mined + Full RBF + + +

+
+ +
+
+ +
+

there are no replacements in the mempool yet!

+
+
+ + +
+ +
diff --git a/frontend/src/app/components/rbf-list/rbf-list.component.scss b/frontend/src/app/components/rbf-list/rbf-list.component.scss new file mode 100644 index 000000000..792bb8836 --- /dev/null +++ b/frontend/src/app/components/rbf-list/rbf-list.component.scss @@ -0,0 +1,35 @@ +.spinner-border { + height: 25px; + width: 25px; + margin-top: 13px; +} + +.rbf-trees { + .info { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: baseline; + margin: 0; + margin-bottom: 0.5em; + + .type { + .badge { + margin-left: .5em; + } + } + } + + .tree { + margin-bottom: 1em; + } + + .timeline-wrapper.mined { + border: solid 4px #1a9436; + } + + .no-replacements { + margin: 1em; + text-align: center; + } +} \ No newline at end of file diff --git a/frontend/src/app/components/rbf-list/rbf-list.component.ts b/frontend/src/app/components/rbf-list/rbf-list.component.ts new file mode 100644 index 000000000..3fe04ba6e --- /dev/null +++ b/frontend/src/app/components/rbf-list/rbf-list.component.ts @@ -0,0 +1,81 @@ +import { Component, OnInit, ChangeDetectionStrategy, OnDestroy } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { BehaviorSubject, EMPTY, merge, Observable, Subscription } from 'rxjs'; +import { catchError, switchMap, tap } from 'rxjs/operators'; +import { WebsocketService } from '../../services/websocket.service'; +import { RbfTree } from '../../interfaces/node-api.interface'; +import { ApiService } from '../../services/api.service'; +import { StateService } from '../../services/state.service'; + +@Component({ + selector: 'app-rbf-list', + templateUrl: './rbf-list.component.html', + styleUrls: ['./rbf-list.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RbfList implements OnInit, OnDestroy { + rbfTrees$: Observable; + nextRbfSubject = new BehaviorSubject(null); + urlFragmentSubscription: Subscription; + fullRbfEnabled: boolean; + fullRbf: boolean; + isLoading = true; + + constructor( + private route: ActivatedRoute, + private router: Router, + private apiService: ApiService, + public stateService: StateService, + private websocketService: WebsocketService, + ) { + this.fullRbfEnabled = stateService.env.FULL_RBF_ENABLED; + } + + ngOnInit(): void { + this.urlFragmentSubscription = this.route.fragment.subscribe((fragment) => { + this.fullRbf = (fragment === 'fullrbf'); + this.websocketService.startTrackRbf(this.fullRbf ? 'fullRbf' : 'all'); + this.nextRbfSubject.next(null); + }); + + this.rbfTrees$ = merge( + this.nextRbfSubject.pipe( + switchMap(() => { + return this.apiService.getRbfList$(this.fullRbf); + }), + catchError((e) => { + return EMPTY; + }) + ), + this.stateService.rbfLatest$ + ) + .pipe( + tap(() => { + this.isLoading = false; + }) + ); + } + + toggleFullRbf(event) { + this.router.navigate([], { + relativeTo: this.route, + fragment: this.fullRbf ? null : 'fullrbf' + }); + } + + isFullRbf(tree: RbfTree): boolean { + return tree.fullRbf; + } + + isMined(tree: RbfTree): boolean { + return tree.mined; + } + + // pageChange(page: number) { + // this.fromTreeSubject.next(this.lastTreeId); + // } + + ngOnDestroy(): void { + this.websocketService.stopTrackRbf(); + } +} \ No newline at end of file diff --git a/frontend/src/app/components/rbf-timeline/rbf-timeline-tooltip.component.html b/frontend/src/app/components/rbf-timeline/rbf-timeline-tooltip.component.html new file mode 100644 index 000000000..1ce224760 --- /dev/null +++ b/frontend/src/app/components/rbf-timeline/rbf-timeline-tooltip.component.html @@ -0,0 +1,38 @@ +
+ + + + + + + + + + + + + + + + + + + + + + + +
Transaction + {{ rbfInfo.tx.txid | shortenString : 16}} +
First seen
Fee{{ rbfInfo.tx.fee | number }} sat
Virtual size
Status + RBF + RBF + Mined +
+
diff --git a/frontend/src/app/components/rbf-timeline/rbf-timeline-tooltip.component.scss b/frontend/src/app/components/rbf-timeline/rbf-timeline-tooltip.component.scss new file mode 100644 index 000000000..cd31aa562 --- /dev/null +++ b/frontend/src/app/components/rbf-timeline/rbf-timeline-tooltip.component.scss @@ -0,0 +1,25 @@ +.rbf-tooltip { + position: fixed; + z-index: 3; + background: rgba(#11131f, 0.95); + border-radius: 4px; + box-shadow: 1px 1px 10px rgba(0,0,0,0.5); + color: #b1b1b1; + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 10px 15px; + text-align: left; + pointer-events: none; + + .badge { + margin-right: 1em; + &:last-child { + margin-right: 0; + } + } +} + +.td-width { + padding-right: 10px; +} \ No newline at end of file diff --git a/frontend/src/app/components/rbf-timeline/rbf-timeline-tooltip.component.ts b/frontend/src/app/components/rbf-timeline/rbf-timeline-tooltip.component.ts new file mode 100644 index 000000000..b9da63c86 --- /dev/null +++ b/frontend/src/app/components/rbf-timeline/rbf-timeline-tooltip.component.ts @@ -0,0 +1,35 @@ +import { Component, ElementRef, ViewChild, Input, OnChanges } from '@angular/core'; +import { RbfInfo } from '../../interfaces/node-api.interface'; + +@Component({ + selector: 'app-rbf-timeline-tooltip', + templateUrl: './rbf-timeline-tooltip.component.html', + styleUrls: ['./rbf-timeline-tooltip.component.scss'], +}) +export class RbfTimelineTooltipComponent implements OnChanges { + @Input() rbfInfo: RbfInfo | void; + @Input() cursorPosition: { x: number, y: number }; + + tooltipPosition = null; + + @ViewChild('tooltip') tooltipElement: ElementRef; + + constructor() {} + + ngOnChanges(changes): void { + if (changes.cursorPosition && changes.cursorPosition.currentValue) { + let x = Math.max(10, changes.cursorPosition.currentValue.x - 50); + let y = changes.cursorPosition.currentValue.y + 20; + if (this.tooltipElement) { + const elementBounds = this.tooltipElement.nativeElement.getBoundingClientRect(); + if ((x + elementBounds.width) > (window.innerWidth - 10)) { + x = Math.max(0, window.innerWidth - elementBounds.width - 10); + } + if (y + elementBounds.height > (window.innerHeight - 20)) { + y = y - elementBounds.height - 20; + } + } + this.tooltipPosition = { x, y }; + } + } +} diff --git a/frontend/src/app/components/rbf-timeline/rbf-timeline.component.html b/frontend/src/app/components/rbf-timeline/rbf-timeline.component.html new file mode 100644 index 000000000..e9d531cb8 --- /dev/null +++ b/frontend/src/app/components/rbf-timeline/rbf-timeline.component.html @@ -0,0 +1,73 @@ +
+
+
+
+ +
+ +
+
+ +
+
+
+
+
+
+ + +
+
+ +
+
+ {{ cell.replacement.tx.fee / (cell.replacement.tx.vsize) | feeRounding }} sat/vB +
+
+ + +
+
+
+
+
+ +
+
+
+
+
+
+
+
+ + +
+
+ + +
+
+ + + + +
diff --git a/frontend/src/app/components/rbf-timeline/rbf-timeline.component.scss b/frontend/src/app/components/rbf-timeline/rbf-timeline.component.scss new file mode 100644 index 000000000..97388b98e --- /dev/null +++ b/frontend/src/app/components/rbf-timeline/rbf-timeline.component.scss @@ -0,0 +1,193 @@ +.rbf-timeline { + position: relative; + width: 100%; + padding: 1em 0; + + &::after, &::before { + content: ''; + display: block; + position: absolute; + top: 0; + bottom: 0; + width: 2em; + z-index: 2; + } + + &::before { + left: 0; + background: linear-gradient(to right, #24273e, #24273e, transparent); + } + + &::after { + right: 0; + background: linear-gradient(to left, #24273e, #24273e, transparent); + } + + .timeline-wrapper { + position: relative; + width: calc(100% - 2em); + margin: auto; + overflow-x: auto; + -ms-overflow-style: none; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + } + + .intervals, .nodes { + min-width: 100%; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: flex-start; + text-align: center; + + .node, .node-spacer, .connector { + width: 6em; + min-width: 6em; + flex-grow: 1; + } + + .interval, .interval-spacer { + width: 8em; + min-width: 5em; + max-width: 8em; + height: 32px; + display: flex; + flex-direction: row; + justify-content: center; + align-items: flex-end; + } + + .interval { + overflow: visible; + } + + .interval-time { + font-size: 12px; + line-height: 16px; + white-space: nowrap; + } + } + + .node, .interval-spacer { + position: relative; + .track { + position: absolute; + height: 10px; + left: -5px; + right: -5px; + top: 0; + transform: translateY(-50%); + background: #105fb0; + border-radius: 5px; + } + &.first-node { + .track { + left: 50%; + } + } + &:last-child { + .track { + right: 50%; + } + } + } + + .nodes { + position: relative; + margin-top: 1em; + .node { + .shape-border { + display: block; + margin: auto; + height: calc(1em + 8px); + width: calc(1em + 8px); + margin-bottom: -8px; + transform: translateY(-50%); + border-radius: 10%; + cursor: pointer; + padding: 4px; + background: transparent; + transition: background-color 300ms, padding 300ms; + + .shape { + width: 100%; + height: 100%; + border-radius: 10%; + background: white; + transition: background-color 300ms, border 300ms; + } + + &.rbf, &.rbf .shape { + border-radius: 50%; + } + } + + .symbol::ng-deep { + display: block; + margin-top: -0.5em; + } + + &.selected { + .shape-border { + background: #9339f4; + } + } + + &.mined { + .shape-border { + background: #1a9436; + } + } + + .shape-border:hover { + padding: 0px; + .shape { + background: #1bd8f4; + } + } + + &.selected.mined { + .shape-border { + background: #1a9436; + height: calc(1em + 16px); + width: calc(1em + 16px); + + .shape { + border: solid 4px #9339f4; + } + + &:hover { + padding: 4px; + .shape { + border-width: 1px; + border-color: #1bd8f4 + } + } + } + } + } + + .connector { + position: relative; + height: 10px; + + .corner, .pipe { + position: absolute; + left: -10px; + width: 20px; + height: 108px; + bottom: 50%; + border-right: solid 10px #105fb0; + } + + .corner { + border-bottom: solid 10px #105fb0; + border-bottom-right-radius: 10px; + } + } + } +} \ No newline at end of file diff --git a/frontend/src/app/components/rbf-timeline/rbf-timeline.component.ts b/frontend/src/app/components/rbf-timeline/rbf-timeline.component.ts new file mode 100644 index 000000000..f02e8ca35 --- /dev/null +++ b/frontend/src/app/components/rbf-timeline/rbf-timeline.component.ts @@ -0,0 +1,191 @@ +import { Component, Input, OnInit, OnChanges, Inject, LOCALE_ID, HostListener } from '@angular/core'; +import { Router } from '@angular/router'; +import { RbfInfo, RbfTree } from '../../interfaces/node-api.interface'; +import { StateService } from '../../services/state.service'; +import { ApiService } from '../../services/api.service'; + +type Connector = 'pipe' | 'corner'; + +interface TimelineCell { + replacement?: RbfInfo, + connector?: Connector, + first?: boolean, +} + +@Component({ + selector: 'app-rbf-timeline', + templateUrl: './rbf-timeline.component.html', + styleUrls: ['./rbf-timeline.component.scss'], +}) +export class RbfTimelineComponent implements OnInit, OnChanges { + @Input() replacements: RbfTree; + @Input() txid: string; + rows: TimelineCell[][] = []; + + hoverInfo: RbfInfo | void = null; + tooltipPosition = null; + + dir: 'rtl' | 'ltr' = 'ltr'; + + constructor( + private router: Router, + private stateService: StateService, + private apiService: ApiService, + @Inject(LOCALE_ID) private locale: string, + ) { + if (this.locale.startsWith('ar') || this.locale.startsWith('fa') || this.locale.startsWith('he')) { + this.dir = 'rtl'; + } + } + + ngOnInit(): void { + this.rows = this.buildTimelines(this.replacements); + } + + ngOnChanges(changes): void { + this.rows = this.buildTimelines(this.replacements); + if (changes.txid) { + setTimeout(() => { this.scrollToSelected(); }); + } + } + + // converts a tree of RBF events into a format that can be more easily rendered in HTML + buildTimelines(tree: RbfTree): TimelineCell[][] { + if (!tree) return []; + + const split = this.splitTimelines(tree); + const timelines = this.prepareTimelines(split); + return this.connectTimelines(timelines); + } + + // splits a tree into N leaf-to-root paths + splitTimelines(tree: RbfTree, tail: RbfInfo[] = []): RbfInfo[][] { + const replacements = [...tail, tree]; + if (tree.replaces.length) { + return [].concat(...tree.replaces.map(subtree => this.splitTimelines(subtree, replacements))); + } else { + return [[...replacements]]; + } + } + + // merges separate leaf-to-root paths into a coherent forking timeline + // represented as a 2D array of Rbf events + prepareTimelines(lines: RbfInfo[][]): RbfInfo[][] { + lines.sort((a, b) => b.length - a.length); + + const rows = lines.map(() => []); + let lineGroups = [lines]; + let done = false; + let column = 0; // sanity check for while loop stopping condition + while (!done && column < 100) { + // iterate over timelines element-by-element + // at each step, group lines which share a common transaction at their head + // (i.e. lines terminating in the same replacement event) + let index = 0; + let emptyCount = 0; + const nextGroups = []; + for (const group of lineGroups) { + const toMerge: { [txid: string]: RbfInfo[][] } = {}; + let emptyInGroup = 0; + let first = true; + for (const line of group) { + const head = line.shift() || null; + if (first) { + // only insert the first instance of the replacement node + rows[index].unshift(head); + first = false; + } else { + // substitute duplicates with empty cells + // (we'll fill these in with connecting lines later) + rows[index].unshift(null); + } + // group the tails of the remaining lines for the next iteration + if (line.length) { + const nextId = line[0].tx.txid; + if (!toMerge[nextId]) { + toMerge[nextId] = []; + } + toMerge[nextId].push(line); + } else { + emptyInGroup++; + } + index++; + } + for (const merged of Object.values(toMerge).sort((a, b) => b.length - a.length)) { + nextGroups.push(merged); + } + for (let i = 0; i < emptyInGroup; i++) { + nextGroups.push([[]]); + } + emptyCount += emptyInGroup; + lineGroups = nextGroups; + done = (emptyCount >= rows.length); + } + column++; + } + return rows; + } + + // annotates a 2D timeline array with info needed to draw connecting lines for multi-replacements + connectTimelines(timelines: RbfInfo[][]): TimelineCell[][] { + const rows: TimelineCell[][] = []; + timelines.forEach((lines, row) => { + rows.push([]); + let started = false; + let finished = false; + lines.forEach((replacement, column) => { + const cell: TimelineCell = {}; + if (replacement) { + cell.replacement = replacement; + } + rows[row].push(cell); + if (replacement) { + if (!started) { + cell.first = true; + started = true; + } + } else if (started && !finished) { + if (column < timelines[row].length) { + let matched = false; + for (let i = row; i >= 0 && !matched; i--) { + const nextCell = rows[i][column]; + if (nextCell.replacement) { + matched = true; + } else if (i === row) { + rows[i][column] = { + connector: 'corner' + }; + } else if (nextCell.connector !== 'corner') { + rows[i][column] = { + connector: 'pipe' + }; + } + } + } + finished = true; + } + }); + }); + return rows; + } + + scrollToSelected() { + const node = document.getElementById('node-' + this.txid); + if (node) { + node.scrollIntoView({ block: 'nearest', inline: 'center' }); + } + } + + @HostListener('pointermove', ['$event']) + onPointerMove(event) { + this.tooltipPosition = { x: event.clientX, y: event.clientY }; + } + + onHover(event, replacement): void { + this.hoverInfo = replacement; + } + + onBlur(event): void { + this.hoverInfo = null; + } +} diff --git a/frontend/src/app/components/transaction/transaction.component.html b/frontend/src/app/components/transaction/transaction.component.html index 04d13b07a..09efaee1c 100644 --- a/frontend/src/app/components/transaction/transaction.component.html +++ b/frontend/src/app/components/transaction/transaction.component.html @@ -1,18 +1,11 @@
-