Merge pull request #2847 from mempool/mononaut/rbf-timeline
RBF Timelines
This commit is contained in:
		
						commit
						12ae940ed6
					
				| @ -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); | ||||
|     } | ||||
|  | ||||
| @ -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; | ||||
|  | ||||
| @ -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<void> { | ||||
|     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)); | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
|  | ||||
| @ -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]; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
| @ -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<string, string> = new Map(); | ||||
|   private replaces: Map<string, string[]> = new Map(); | ||||
|   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(); | ||||
| 
 | ||||
|   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<string>(); | ||||
|     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<void> { | ||||
|     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<string, TransactionExtended>, mined: boolean = false): Promise<RbfTree | void> { | ||||
|     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(); | ||||
|  | ||||
| @ -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) { | ||||
|  | ||||
| @ -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) | ||||
|  | ||||
| @ -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 | ||||
| } | ||||
|  | ||||
| @ -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 | ||||
|  | ||||
							
								
								
									
										46
									
								
								frontend/src/app/components/rbf-list/rbf-list.component.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								frontend/src/app/components/rbf-list/rbf-list.component.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,46 @@ | ||||
| <div class="container-xl" style="min-height: 335px"> | ||||
|   <h1 class="float-left" i18n="page.rbf-replacements">RBF Replacements</h1> | ||||
|   <div *ngIf="isLoading" class="spinner-border ml-3" role="status"></div> | ||||
| 
 | ||||
|   <div class="mode-toggle float-right" *ngIf="fullRbfEnabled"> | ||||
|     <form class="formRadioGroup"> | ||||
|       <div class="btn-group btn-group-toggle" name="radioBasic"> | ||||
|         <label class="btn btn-primary btn-sm" [class.active]="!fullRbf"> | ||||
|           <input type="radio" [value]="'All'" fragment="" [routerLink]="[]"> All | ||||
|         </label> | ||||
|         <label class="btn btn-primary btn-sm" [class.active]="fullRbf"> | ||||
|           <input type="radio" [value]="'Full RBF'" fragment="fullrbf" [routerLink]="[]"> Full RBF | ||||
|         </label> | ||||
|       </div> | ||||
|     </form> | ||||
|   </div> | ||||
| 
 | ||||
|   <div class="clearfix"></div> | ||||
| 
 | ||||
|   <div class="rbf-trees" style="min-height: 295px"> | ||||
|     <ng-container *ngIf="rbfTrees$ | async as trees"> | ||||
|       <div *ngFor="let tree of trees" class="tree"> | ||||
|         <p class="info"> | ||||
|           <span class="type"> | ||||
|             <span *ngIf="isMined(tree)" class="badge badge-success" i18n="transaction.rbf.mined">Mined</span> | ||||
|             <span *ngIf="isFullRbf(tree)" class="badge badge-info" i18n="transaction.full-rbf">Full RBF</span> | ||||
|           </span> | ||||
|           <app-time kind="since" [time]="tree.time"></app-time> | ||||
|         </p> | ||||
|         <div class="timeline-wrapper" [class.mined]="isMined(tree)"> | ||||
|           <app-rbf-timeline [replacements]="tree"></app-rbf-timeline> | ||||
|         </div> | ||||
|       </div> | ||||
| 
 | ||||
|       <div class="no-replacements" *ngIf="!trees?.length"> | ||||
|         <p i18n="rbf.no-replacements-yet">there are no replacements in the mempool yet!</p> | ||||
|       </div> | ||||
|     </ng-container> | ||||
|      | ||||
|     <!-- <ngb-pagination class="pagination-container float-right mt-2" [class]="isLoading ? 'disabled' : ''" | ||||
|       [collectionSize]="blocksCount" [rotate]="true" [maxSize]="maxSize" [pageSize]="15" [(page)]="page" | ||||
|       (pageChange)="pageChange(page)" [boundaryLinks]="true" [ellipses]="false"> | ||||
|     </ngb-pagination> --> | ||||
|   </div> | ||||
|    | ||||
| </div> | ||||
							
								
								
									
										35
									
								
								frontend/src/app/components/rbf-list/rbf-list.component.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								frontend/src/app/components/rbf-list/rbf-list.component.scss
									
									
									
									
									
										Normal file
									
								
							| @ -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; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										81
									
								
								frontend/src/app/components/rbf-list/rbf-list.component.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										81
									
								
								frontend/src/app/components/rbf-list/rbf-list.component.ts
									
									
									
									
									
										Normal file
									
								
							| @ -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<RbfTree[]>; | ||||
|   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(); | ||||
|   } | ||||
| } | ||||
| @ -0,0 +1,38 @@ | ||||
| <div | ||||
|   #tooltip | ||||
|   *ngIf="rbfInfo && tooltipPosition !== null" | ||||
|   class="rbf-tooltip" | ||||
|   [style.left]="tooltipPosition.x + 'px'" | ||||
|   [style.top]="tooltipPosition.y + 'px'" | ||||
| > | ||||
|   <table> | ||||
|     <tbody> | ||||
|       <tr> | ||||
|         <td class="td-width" i18n="shared.transaction">Transaction</td> | ||||
|         <td> | ||||
|           <a [routerLink]="['/tx/' | relativeUrl, rbfInfo.tx.txid]">{{ rbfInfo.tx.txid | shortenString : 16}}</a> | ||||
|         </td> | ||||
|       </tr> | ||||
|       <tr> | ||||
|         <td class="td-width" i18n="transaction.first-seen|Transaction first seen">First seen</td> | ||||
|         <td><i><app-time kind="since" [time]="rbfInfo.time" [fastRender]="true"></app-time></i></td> | ||||
|       </tr> | ||||
|       <tr> | ||||
|         <td class="td-width" i18n="transaction.fee|Transaction fee">Fee</td> | ||||
|         <td>{{ rbfInfo.tx.fee | number }} <span class="symbol" i18n="shared.sat|sat">sat</span></td> | ||||
|       </tr> | ||||
|       <tr> | ||||
|         <td class="td-width" i18n="transaction.vsize|Transaction Virtual Size">Virtual size</td> | ||||
|         <td [innerHTML]="'‎' + (rbfInfo.tx.vsize | vbytes: 2)"></td> | ||||
|       </tr> | ||||
|       <tr> | ||||
|         <td class="td-width" i18n="transaction.status|Transaction Status">Status</td> | ||||
|         <td> | ||||
|           <span *ngIf="rbfInfo.tx.rbf; else rbfDisabled" class="badge badge-success" i18n="rbfInfo-features.tag.rbf|RBF">RBF</span> | ||||
|           <ng-template #rbfDisabled><span class="badge badge-danger mr-1"><del i18n="rbfInfo-features.tag.rbf|RBF">RBF</del></span></ng-template> | ||||
|           <span *ngIf="rbfInfo.tx.mined" class="badge badge-success" i18n="transaction.rbf.mined">Mined</span> | ||||
|         </td> | ||||
|       </tr> | ||||
|     </tbody> | ||||
|   </table> | ||||
| </div> | ||||
| @ -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; | ||||
| } | ||||
| @ -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<HTMLCanvasElement>; | ||||
| 
 | ||||
|   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 }; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @ -0,0 +1,73 @@ | ||||
| <div class="rbf-timeline box" [class.mined]="replacements.mined"> | ||||
|   <div class="timeline-wrapper"> | ||||
|     <div class="timeline" *ngFor="let timeline of rows"> | ||||
|       <div class="intervals"> | ||||
|         <ng-container *ngFor="let cell of timeline; let i = index;"> | ||||
|           <div class="node-spacer"></div> | ||||
|           <ng-container *ngIf="i < timeline.length - 1"> | ||||
|             <div class="interval" *ngIf="cell.replacement?.interval != null; else intervalSpacer"> | ||||
|               <div class="interval-time"> | ||||
|                 <app-time [time]="cell.replacement.interval" [relative]="false"></app-time> | ||||
|               </div> | ||||
|             </div> | ||||
|           </ng-container> | ||||
|         </ng-container> | ||||
|       </div> | ||||
|       <div class="nodes"> | ||||
|         <ng-container *ngFor="let cell of timeline; let i = index;"> | ||||
|           <ng-container *ngIf="cell.replacement; else nonNode"> | ||||
|             <div class="node" | ||||
|               [id]="'node-'+cell.replacement.tx.txid" | ||||
|               [class.selected]="txid === cell.replacement.tx.txid" | ||||
|               [class.mined]="cell.replacement.tx.mined" | ||||
|               [class.first-node]="cell.first" | ||||
|             > | ||||
|               <div class="track"></div> | ||||
|               <a class="shape-border" | ||||
|                 [class.rbf]="cell.replacement.tx.rbf" | ||||
|                 [routerLink]="['/tx/' | relativeUrl, cell.replacement.tx.txid]" | ||||
|                 (pointerover)="onHover($event, cell.replacement);" | ||||
|                 (pointerout)="onBlur($event);" | ||||
|               > | ||||
|                 <div class="shape"></div> | ||||
|               </a> | ||||
|               <span class="fee-rate">{{ cell.replacement.tx.fee / (cell.replacement.tx.vsize) | feeRounding }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span></span> | ||||
|             </div> | ||||
|           </ng-container> | ||||
|           <ng-template #nonNode> | ||||
|             <ng-container [ngSwitch]="cell.connector"> | ||||
|               <div class="connector" *ngSwitchCase="'pipe'"><div class="pipe"></div></div> | ||||
|               <div class="connector" *ngSwitchCase="'corner'"><div class="corner"></div></div> | ||||
|               <div class="node-spacer" *ngSwitchDefault></div> | ||||
|             </ng-container> | ||||
|           </ng-template> | ||||
|           <ng-container *ngIf="i < timeline.length - 1"> | ||||
|             <div class="interval-spacer" *ngIf="cell.replacement?.interval != null; else intervalSpacer"> | ||||
|               <div class="track"></div> | ||||
|             </div> | ||||
|           </ng-container> | ||||
|         </ng-container> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| 
 | ||||
|   <ng-template #nodeSpacer> | ||||
|     <div class="node-spacer"></div> | ||||
|   </ng-template> | ||||
| 
 | ||||
|   <ng-template #intervalSpacer> | ||||
|     <div class="interval-spacer"></div> | ||||
|   </ng-template> | ||||
| 
 | ||||
|   <app-rbf-timeline-tooltip | ||||
|     [rbfInfo]="hoverInfo" | ||||
|     [cursorPosition]="tooltipPosition" | ||||
|   ></app-rbf-timeline-tooltip> | ||||
| 
 | ||||
|   <!-- <app-rbf-timeline-tooltip | ||||
|     *ngIf=[tooltip] | ||||
|     [line]="hoverLine" | ||||
|     [cursorPosition]="tooltipPosition" | ||||
|     [isConnector]="hoverConnector" | ||||
|   ></app-rbf-timeline-tooltip> --> | ||||
| </div> | ||||
| @ -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; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @ -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; | ||||
|   } | ||||
| } | ||||
| @ -1,18 +1,11 @@ | ||||
| <div class="container-xl"> | ||||
| 
 | ||||
|   <div class="title-block"> | ||||
|     <div *ngIf="rbfTransaction" class="alert alert-mempool" role="alert"> | ||||
|     <div *ngIf="rbfTransaction && !tx?.status?.confirmed" class="alert alert-mempool" role="alert"> | ||||
|       <span i18n="transaction.rbf.replacement|RBF replacement">This transaction has been replaced by:</span> | ||||
|       <app-truncate [text]="rbfTransaction.txid" [lastChars]="12" [link]="['/tx/' | relativeUrl, rbfTransaction.txid]"></app-truncate> | ||||
|     </div> | ||||
| 
 | ||||
|     <div *ngIf="rbfReplaces?.length" class="alert alert-mempool" role="alert"> | ||||
|       <span i18n="transaction.rbf.replaced|RBF replaced">This transaction replaced:</span> | ||||
|       <div class="tx-list"> | ||||
|         <app-truncate [text]="replaced" [lastChars]="12" *ngFor="let replaced of rbfReplaces" [link]="['/tx/' | relativeUrl, replaced]"></app-truncate> | ||||
|       </div> | ||||
|     </div> | ||||
| 
 | ||||
|     <ng-container *ngIf="!rbfTransaction || rbfTransaction?.size || tx"> | ||||
|       <h1 i18n="shared.transaction">Transaction</h1> | ||||
| 
 | ||||
| @ -45,7 +38,7 @@ | ||||
| 
 | ||||
|   <ng-template [ngIf]="!isLoadingTx && !error"> | ||||
| 
 | ||||
|     <ng-template [ngIf]="tx.status.confirmed" [ngIfElse]="unconfirmedTemplate"> | ||||
|     <ng-template [ngIf]="tx?.status?.confirmed" [ngIfElse]="unconfirmedTemplate"> | ||||
| 
 | ||||
|       <div class="box"> | ||||
|         <div class="row"> | ||||
| @ -104,7 +97,7 @@ | ||||
|                     </tr> | ||||
|                   </ng-template> | ||||
|                 </ng-template> | ||||
|                 <tr *ngIf="!replaced"> | ||||
|                 <tr *ngIf="!replaced && !isCached"> | ||||
|                   <td class="td-width" i18n="transaction.eta|Transaction ETA">ETA</td> | ||||
|                   <td> | ||||
|                     <ng-template [ngIf]="txInBlockIndex === undefined" [ngIfElse]="estimationTmpl"> | ||||
| @ -197,6 +190,15 @@ | ||||
| 
 | ||||
|     <br> | ||||
| 
 | ||||
|     <ng-container *ngIf="rbfInfo"> | ||||
|       <div class="title float-left"> | ||||
|         <h2 id="rbf" i18n="transaction.rbf-history|RBF History">RBF History</h2> | ||||
|       </div> | ||||
|       <div class="clearfix"></div> | ||||
|       <app-rbf-timeline [txid]="txId" [replacements]="rbfInfo"></app-rbf-timeline> | ||||
|       <br> | ||||
|     </ng-container> | ||||
| 
 | ||||
|     <ng-container *ngIf="flowEnabled; else flowPlaceholder"> | ||||
|       <div class="title float-left"> | ||||
|         <h2 id="flow" i18n="transaction.flow|Transaction flow">Flow</h2> | ||||
| @ -477,7 +479,7 @@ | ||||
|         <td i18n="transaction.fee-rate|Transaction fee rate">Fee rate</td> | ||||
|         <td> | ||||
|           {{ tx.feePerVsize | feeRounding }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span> | ||||
|           <ng-template [ngIf]="tx.status.confirmed"> | ||||
|           <ng-template [ngIf]="tx?.status?.confirmed"> | ||||
|               | ||||
|             <app-tx-fee-rating *ngIf="tx.fee && ((cpfpInfo && !cpfpInfo?.descendants?.length && !cpfpInfo?.bestDescendant && !cpfpInfo?.ancestors?.length) || !cpfpInfo)" [tx]="tx"></app-tx-fee-rating> | ||||
|           </ng-template> | ||||
| @ -488,7 +490,7 @@ | ||||
|         <td> | ||||
|           <div class="effective-fee-container"> | ||||
|             {{ tx.effectiveFeePerVsize | feeRounding }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span> | ||||
|             <ng-template [ngIf]="tx.status.confirmed"> | ||||
|             <ng-template [ngIf]="tx?.status?.confirmed"> | ||||
|               <app-tx-fee-rating class="ml-2 mr-2" *ngIf="tx.fee || tx.effectiveFeePerVsize" [tx]="tx"></app-tx-fee-rating> | ||||
|             </ng-template> | ||||
|           </div> | ||||
|  | ||||
| @ -19,7 +19,7 @@ import { WebsocketService } from '../../services/websocket.service'; | ||||
| import { AudioService } from '../../services/audio.service'; | ||||
| import { ApiService } from '../../services/api.service'; | ||||
| import { SeoService } from '../../services/seo.service'; | ||||
| import { BlockExtended, CpfpInfo } from '../../interfaces/node-api.interface'; | ||||
| import { BlockExtended, CpfpInfo, RbfTree } from '../../interfaces/node-api.interface'; | ||||
| import { LiquidUnblinding } from './liquid-ublinding'; | ||||
| import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; | ||||
| import { Price, PriceService } from '../../services/price.service'; | ||||
| @ -46,6 +46,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { | ||||
|   fetchRbfSubscription: Subscription; | ||||
|   fetchCachedTxSubscription: Subscription; | ||||
|   txReplacedSubscription: Subscription; | ||||
|   txRbfInfoSubscription: Subscription; | ||||
|   blocksSubscription: Subscription; | ||||
|   queryParamsSubscription: Subscription; | ||||
|   urlFragmentSubscription: Subscription; | ||||
| @ -53,6 +54,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { | ||||
|   rbfTransaction: undefined | Transaction; | ||||
|   replaced: boolean = false; | ||||
|   rbfReplaces: string[]; | ||||
|   rbfInfo: RbfTree; | ||||
|   cpfpInfo: CpfpInfo | null; | ||||
|   showCpfpDetails = false; | ||||
|   fetchCpfp$ = new Subject<string>(); | ||||
| @ -168,7 +170,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { | ||||
| 
 | ||||
|         this.tx.effectiveFeePerVsize = totalFees / (totalWeight / 4); | ||||
| 
 | ||||
|         if (!this.tx.status.confirmed) { | ||||
|         if (!this.tx?.status?.confirmed) { | ||||
|           this.stateService.markBlock$.next({ | ||||
|             txFeePerVSize: this.tx.effectiveFeePerVsize, | ||||
|           }); | ||||
| @ -183,10 +185,11 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { | ||||
|           .getRbfHistory$(txId) | ||||
|       ), | ||||
|       catchError(() => { | ||||
|         return of([]); | ||||
|         return of(null); | ||||
|       }) | ||||
|     ).subscribe((replaces) => { | ||||
|       this.rbfReplaces = replaces; | ||||
|     ).subscribe((rbfResponse) => { | ||||
|       this.rbfInfo = rbfResponse?.replacements; | ||||
|       this.rbfReplaces = rbfResponse?.replaces || null; | ||||
|     }); | ||||
| 
 | ||||
|     this.fetchCachedTxSubscription = this.fetchCachedTx$ | ||||
| @ -203,21 +206,27 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { | ||||
|         return; | ||||
|       } | ||||
| 
 | ||||
|       this.tx = tx; | ||||
|       this.setFeatures(); | ||||
|       this.isCached = true; | ||||
|       if (tx.fee === undefined) { | ||||
|         this.tx.fee = 0; | ||||
|       } | ||||
|       this.tx.feePerVsize = tx.fee / (tx.weight / 4); | ||||
|       this.isLoadingTx = false; | ||||
|       this.error = undefined; | ||||
|       this.waitingForTransaction = false; | ||||
|       this.graphExpanded = false; | ||||
|       this.setupGraph(); | ||||
|       if (!this.tx) { | ||||
|         this.tx = tx; | ||||
|         this.setFeatures(); | ||||
|         this.isCached = true; | ||||
|         if (tx.fee === undefined) { | ||||
|           this.tx.fee = 0; | ||||
|         } | ||||
|         this.tx.feePerVsize = tx.fee / (tx.weight / 4); | ||||
|         this.isLoadingTx = false; | ||||
|         this.error = undefined; | ||||
|         this.waitingForTransaction = false; | ||||
|         this.graphExpanded = false; | ||||
|         this.transactionTime = 0; | ||||
|         this.setupGraph(); | ||||
| 
 | ||||
|       if (!this.tx?.status?.confirmed) { | ||||
|         this.fetchRbfHistory$.next(this.tx.txid); | ||||
|         this.txRbfInfoSubscription = this.stateService.txRbfInfo$.subscribe((rbfInfo) => { | ||||
|           if (this.tx) { | ||||
|             this.rbfInfo = rbfInfo; | ||||
|           } | ||||
|         }); | ||||
|       } | ||||
|     }); | ||||
| 
 | ||||
| @ -258,7 +267,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { | ||||
|             of(true), | ||||
|             this.stateService.connectionState$.pipe( | ||||
|               filter( | ||||
|                 (state) => state === 2 && this.tx && !this.tx.status.confirmed | ||||
|                 (state) => state === 2 && this.tx && !this.tx.status?.confirmed | ||||
|               ) | ||||
|             ) | ||||
|           ); | ||||
| @ -295,6 +304,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { | ||||
|       ) | ||||
|       .subscribe((tx: Transaction) => { | ||||
|           if (!tx) { | ||||
|             this.fetchCachedTx$.next(this.txId); | ||||
|             return; | ||||
|           } | ||||
| 
 | ||||
| @ -313,13 +323,17 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { | ||||
|           this.graphExpanded = false; | ||||
|           this.setupGraph(); | ||||
| 
 | ||||
|           if (!tx.status.confirmed && tx.firstSeen) { | ||||
|             this.transactionTime = tx.firstSeen; | ||||
|           if (!tx.status?.confirmed) { | ||||
|             if (tx.firstSeen) { | ||||
|               this.transactionTime = tx.firstSeen; | ||||
|             } else { | ||||
|               this.transactionTime = 0; | ||||
|             } | ||||
|           } else { | ||||
|             this.getTransactionTime(); | ||||
|           } | ||||
| 
 | ||||
|           if (this.tx.status.confirmed) { | ||||
|           if (this.tx?.status?.confirmed) { | ||||
|             this.stateService.markBlock$.next({ | ||||
|               blockHeight: tx.status.block_height, | ||||
|             }); | ||||
| @ -336,10 +350,10 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { | ||||
|             } else { | ||||
|               this.fetchCpfp$.next(this.tx.txid); | ||||
|             } | ||||
|             this.fetchRbfHistory$.next(this.tx.txid); | ||||
|           } | ||||
|           this.fetchRbfHistory$.next(this.tx.txid); | ||||
| 
 | ||||
|           this.priceService.getBlockPrice$(tx.status.block_time, true).pipe( | ||||
|           this.priceService.getBlockPrice$(tx.status?.block_time, true).pipe( | ||||
|             tap((price) => { | ||||
|               this.blockConversion = price; | ||||
|             }) | ||||
| @ -380,6 +394,12 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { | ||||
|       } | ||||
|     }); | ||||
| 
 | ||||
|     this.txRbfInfoSubscription = this.stateService.txRbfInfo$.subscribe((rbfInfo) => { | ||||
|       if (this.tx) { | ||||
|         this.rbfInfo = rbfInfo; | ||||
|       } | ||||
|     }); | ||||
| 
 | ||||
|     this.queryParamsSubscription = this.route.queryParams.subscribe((params) => { | ||||
|       if (params.showFlow === 'false') { | ||||
|         this.overrideFlowPreference = false; | ||||
| @ -460,6 +480,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { | ||||
|     this.replaced = false; | ||||
|     this.transactionTime = -1; | ||||
|     this.cpfpInfo = null; | ||||
|     this.rbfInfo = null; | ||||
|     this.rbfReplaces = []; | ||||
|     this.showCpfpDetails = false; | ||||
|     document.body.scrollTo(0, 0); | ||||
| @ -519,9 +540,13 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { | ||||
|   @HostListener('window:resize', ['$event']) | ||||
|   setGraphSize(): void { | ||||
|     this.isMobile = window.innerWidth < 850; | ||||
|     if (this.graphContainer) { | ||||
|     if (this.graphContainer?.nativeElement) { | ||||
|       setTimeout(() => { | ||||
|         this.graphWidth = this.graphContainer.nativeElement.clientWidth; | ||||
|         if (this.graphContainer?.nativeElement) { | ||||
|           this.graphWidth = this.graphContainer.nativeElement.clientWidth; | ||||
|         } else { | ||||
|           setTimeout(() => { this.setGraphSize(); }, 1); | ||||
|         } | ||||
|       }, 1); | ||||
|     } | ||||
|   } | ||||
| @ -532,6 +557,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { | ||||
|     this.fetchRbfSubscription.unsubscribe(); | ||||
|     this.fetchCachedTxSubscription.unsubscribe(); | ||||
|     this.txReplacedSubscription.unsubscribe(); | ||||
|     this.txRbfInfoSubscription.unsubscribe(); | ||||
|     this.blocksSubscription.unsubscribe(); | ||||
|     this.queryParamsSubscription.unsubscribe(); | ||||
|     this.flowPrefSubscription.unsubscribe(); | ||||
|  | ||||
| @ -26,6 +26,18 @@ export interface CpfpInfo { | ||||
|   bestDescendant?: BestDescendant | null; | ||||
| } | ||||
| 
 | ||||
| export interface RbfInfo { | ||||
|   tx: RbfTransaction; | ||||
|   time: number; | ||||
|   interval?: number; | ||||
| } | ||||
| 
 | ||||
| export interface RbfTree extends RbfInfo { | ||||
|   mined?: boolean; | ||||
|   fullRbf: boolean; | ||||
|   replaces: RbfTree[]; | ||||
| } | ||||
| 
 | ||||
| export interface DifficultyAdjustment { | ||||
|   progressPercent: number; | ||||
|   difficultyChange: number; | ||||
| @ -146,6 +158,11 @@ export interface TransactionStripped { | ||||
|   status?: 'found' | 'missing' | 'fresh' | 'added' | 'censored' | 'selected'; | ||||
| } | ||||
| 
 | ||||
| interface RbfTransaction extends TransactionStripped { | ||||
|   rbf?: boolean; | ||||
|   mined?: boolean, | ||||
| } | ||||
| 
 | ||||
| export interface RewardStats { | ||||
|   startBlock: number; | ||||
|   endBlock: number; | ||||
|  | ||||
| @ -1,6 +1,6 @@ | ||||
| import { ILoadingIndicators } from '../services/state.service'; | ||||
| import { Transaction } from './electrs.interface'; | ||||
| import { BlockExtended, DifficultyAdjustment } from './node-api.interface'; | ||||
| import { BlockExtended, DifficultyAdjustment, RbfTree } from './node-api.interface'; | ||||
| 
 | ||||
| export interface WebsocketResponse { | ||||
|   block?: BlockExtended; | ||||
| @ -16,6 +16,8 @@ export interface WebsocketResponse { | ||||
|   tx?: Transaction; | ||||
|   rbfTransaction?: ReplacedTransaction; | ||||
|   txReplaced?: ReplacedTransaction; | ||||
|   rbfInfo?: RbfTree; | ||||
|   rbfLatest?: RbfTree[]; | ||||
|   utxoSpent?: object; | ||||
|   transactions?: TransactionStripped[]; | ||||
|   loadingIndicators?: ILoadingIndicators; | ||||
| @ -26,6 +28,7 @@ export interface WebsocketResponse { | ||||
|   'track-address'?: string; | ||||
|   'track-asset'?: string; | ||||
|   'track-mempool-block'?: number; | ||||
|   'track-rbf'?: string; | ||||
|   'watch-mempool'?: boolean; | ||||
|   'track-bisq-market'?: string; | ||||
| } | ||||
|  | ||||
| @ -1,7 +1,7 @@ | ||||
| import { Injectable } from '@angular/core'; | ||||
| import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http'; | ||||
| import { CpfpInfo, OptimizedMempoolStats, AddressInformation, LiquidPegs, ITranslators, | ||||
|   PoolStat, BlockExtended, TransactionStripped, RewardStats, AuditScore, BlockSizesAndWeights } from '../interfaces/node-api.interface'; | ||||
|   PoolStat, BlockExtended, TransactionStripped, RewardStats, AuditScore, BlockSizesAndWeights, RbfTree } from '../interfaces/node-api.interface'; | ||||
| import { Observable } from 'rxjs'; | ||||
| import { StateService } from './state.service'; | ||||
| import { WebsocketResponse } from '../interfaces/websocket.interface'; | ||||
| @ -124,14 +124,18 @@ export class ApiService { | ||||
|     return this.httpClient.get<AddressInformation>(this.apiBaseUrl + this.apiBasePath + '/api/v1/validate-address/' + address); | ||||
|   } | ||||
| 
 | ||||
|   getRbfHistory$(txid: string): Observable<string[]> { | ||||
|     return this.httpClient.get<string[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/tx/' + txid + '/replaces'); | ||||
|   getRbfHistory$(txid: string): Observable<{ replacements: RbfTree, replaces: string[] }> { | ||||
|     return this.httpClient.get<{ replacements: RbfTree, replaces: string[] }>(this.apiBaseUrl + this.apiBasePath + '/api/v1/tx/' + txid + '/rbf'); | ||||
|   } | ||||
| 
 | ||||
|   getRbfCachedTx$(txid: string): Observable<Transaction> { | ||||
|     return this.httpClient.get<Transaction>(this.apiBaseUrl + this.apiBasePath + '/api/v1/tx/' + txid + '/cached'); | ||||
|   } | ||||
| 
 | ||||
|   getRbfList$(fullRbf: boolean, after?: string): Observable<RbfTree[]> { | ||||
|     return this.httpClient.get<RbfTree[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/' + (fullRbf ? 'fullrbf/' : '') + 'replacements/' + (after || '')); | ||||
|   } | ||||
| 
 | ||||
|   listLiquidPegsMonth$(): Observable<LiquidPegs[]> { | ||||
|     return this.httpClient.get<LiquidPegs[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/liquid/pegs/month'); | ||||
|   } | ||||
|  | ||||
| @ -2,7 +2,7 @@ import { Inject, Injectable, PLATFORM_ID, LOCALE_ID } from '@angular/core'; | ||||
| import { ReplaySubject, BehaviorSubject, Subject, fromEvent, Observable } from 'rxjs'; | ||||
| import { Transaction } from '../interfaces/electrs.interface'; | ||||
| import { IBackendInfo, MempoolBlock, MempoolBlockWithTransactions, MempoolBlockDelta, MempoolInfo, Recommendedfees, ReplacedTransaction, TransactionStripped } from '../interfaces/websocket.interface'; | ||||
| import { BlockExtended, DifficultyAdjustment, OptimizedMempoolStats } from '../interfaces/node-api.interface'; | ||||
| import { BlockExtended, DifficultyAdjustment, OptimizedMempoolStats, RbfTree } from '../interfaces/node-api.interface'; | ||||
| import { Router, NavigationStart } from '@angular/router'; | ||||
| import { isPlatformBrowser } from '@angular/common'; | ||||
| import { map, shareReplay } from 'rxjs/operators'; | ||||
| @ -43,6 +43,7 @@ export interface Env { | ||||
|   MAINNET_BLOCK_AUDIT_START_HEIGHT: number; | ||||
|   TESTNET_BLOCK_AUDIT_START_HEIGHT: number; | ||||
|   SIGNET_BLOCK_AUDIT_START_HEIGHT: number; | ||||
|   FULL_RBF_ENABLED: boolean; | ||||
|   HISTORICAL_PRICE: boolean; | ||||
| } | ||||
| 
 | ||||
| @ -73,6 +74,7 @@ const defaultEnv: Env = { | ||||
|   'MAINNET_BLOCK_AUDIT_START_HEIGHT': 0, | ||||
|   'TESTNET_BLOCK_AUDIT_START_HEIGHT': 0, | ||||
|   'SIGNET_BLOCK_AUDIT_START_HEIGHT': 0, | ||||
|   'FULL_RBF_ENABLED': false, | ||||
|   'HISTORICAL_PRICE': true, | ||||
| }; | ||||
| 
 | ||||
| @ -98,6 +100,8 @@ export class StateService { | ||||
|   mempoolBlockTransactions$ = new Subject<TransactionStripped[]>(); | ||||
|   mempoolBlockDelta$ = new Subject<MempoolBlockDelta>(); | ||||
|   txReplaced$ = new Subject<ReplacedTransaction>(); | ||||
|   txRbfInfo$ = new Subject<RbfTree>(); | ||||
|   rbfLatest$ = new Subject<RbfTree[]>(); | ||||
|   utxoSpent$ = new Subject<object>(); | ||||
|   difficultyAdjustment$ = new ReplaySubject<DifficultyAdjustment>(1); | ||||
|   mempoolTransactions$ = new Subject<Transaction>(); | ||||
|  | ||||
| @ -28,6 +28,7 @@ export class WebsocketService { | ||||
|   private isTrackingTx = false; | ||||
|   private trackingTxId: string; | ||||
|   private isTrackingMempoolBlock = false; | ||||
|   private isTrackingRbf = false; | ||||
|   private trackingMempoolBlock: number; | ||||
|   private latestGitCommit = ''; | ||||
|   private onlineCheckTimeout: number; | ||||
| @ -173,6 +174,16 @@ export class WebsocketService { | ||||
|     this.isTrackingMempoolBlock = false | ||||
|   } | ||||
| 
 | ||||
|   startTrackRbf(mode: 'all' | 'fullRbf') { | ||||
|     this.websocketSubject.next({ 'track-rbf': mode }); | ||||
|     this.isTrackingRbf = true; | ||||
|   } | ||||
| 
 | ||||
|   stopTrackRbf() { | ||||
|     this.websocketSubject.next({ 'track-rbf': 'stop' }); | ||||
|     this.isTrackingRbf = false; | ||||
|   } | ||||
| 
 | ||||
|   startTrackBisqMarket(market: string) { | ||||
|     this.websocketSubject.next({ 'track-bisq-market': market }); | ||||
|   } | ||||
| @ -257,6 +268,14 @@ export class WebsocketService { | ||||
|       this.stateService.txReplaced$.next(response.rbfTransaction); | ||||
|     } | ||||
| 
 | ||||
|     if (response.rbfInfo) { | ||||
|       this.stateService.txRbfInfo$.next(response.rbfInfo); | ||||
|     } | ||||
| 
 | ||||
|     if (response.rbfLatest) { | ||||
|       this.stateService.rbfLatest$.next(response.rbfLatest); | ||||
|     } | ||||
| 
 | ||||
|     if (response.txReplaced) { | ||||
|       this.stateService.txReplaced$.next(response.txReplaced); | ||||
|     } | ||||
|  | ||||
| @ -61,6 +61,8 @@ import { DifficultyComponent } from '../components/difficulty/difficulty.compone | ||||
| import { DifficultyTooltipComponent } from '../components/difficulty/difficulty-tooltip.component'; | ||||
| import { DifficultyMiningComponent } from '../components/difficulty-mining/difficulty-mining.component'; | ||||
| import { TermsOfServiceComponent } from '../components/terms-of-service/terms-of-service.component'; | ||||
| import { RbfTimelineComponent } from '../components/rbf-timeline/rbf-timeline.component'; | ||||
| import { RbfTimelineTooltipComponent } from '../components/rbf-timeline/rbf-timeline-tooltip.component'; | ||||
| import { TxBowtieGraphComponent } from '../components/tx-bowtie-graph/tx-bowtie-graph.component'; | ||||
| import { TxBowtieGraphTooltipComponent } from '../components/tx-bowtie-graph-tooltip/tx-bowtie-graph-tooltip.component'; | ||||
| import { PrivacyPolicyComponent } from '../components/privacy-policy/privacy-policy.component'; | ||||
| @ -72,6 +74,7 @@ import { AssetCirculationComponent } from '../components/asset-circulation/asset | ||||
| import { AmountShortenerPipe } from '../shared/pipes/amount-shortener.pipe'; | ||||
| import { DifficultyAdjustmentsTable } from '../components/difficulty-adjustments-table/difficulty-adjustments-table.components'; | ||||
| import { BlocksList } from '../components/blocks-list/blocks-list.component'; | ||||
| import { RbfList } from '../components/rbf-list/rbf-list.component'; | ||||
| import { RewardStatsComponent } from '../components/reward-stats/reward-stats.component'; | ||||
| import { DataCyDirective } from '../data-cy.directive'; | ||||
| import { LoadingIndicatorComponent } from '../components/loading-indicator/loading-indicator.component'; | ||||
| @ -138,6 +141,8 @@ import { TestnetAlertComponent } from './components/testnet-alert/testnet-alert. | ||||
|     DifficultyComponent, | ||||
|     DifficultyMiningComponent, | ||||
|     DifficultyTooltipComponent, | ||||
|     RbfTimelineComponent, | ||||
|     RbfTimelineTooltipComponent, | ||||
|     TxBowtieGraphComponent, | ||||
|     TxBowtieGraphTooltipComponent, | ||||
|     TermsOfServiceComponent, | ||||
| @ -151,6 +156,7 @@ import { TestnetAlertComponent } from './components/testnet-alert/testnet-alert. | ||||
|     AmountShortenerPipe, | ||||
|     DifficultyAdjustmentsTable, | ||||
|     BlocksList, | ||||
|     RbfList, | ||||
|     DataCyDirective, | ||||
|     RewardStatsComponent, | ||||
|     LoadingIndicatorComponent, | ||||
| @ -242,6 +248,8 @@ import { TestnetAlertComponent } from './components/testnet-alert/testnet-alert. | ||||
|     DifficultyComponent, | ||||
|     DifficultyMiningComponent, | ||||
|     DifficultyTooltipComponent, | ||||
|     RbfTimelineComponent, | ||||
|     RbfTimelineTooltipComponent, | ||||
|     TxBowtieGraphComponent, | ||||
|     TxBowtieGraphTooltipComponent, | ||||
|     TermsOfServiceComponent, | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user