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 + 'backend-info', this.getBackendInfo)
|
||||||
.get(config.MEMPOOL.API_URL_PREFIX + 'init-data', this.getInitData)
|
.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 + '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 + '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)
|
.post(config.MEMPOOL.API_URL_PREFIX + 'tx/push', this.$postTransactionForm)
|
||||||
.get(config.MEMPOOL.API_URL_PREFIX + 'donations', async (req, res) => {
|
.get(config.MEMPOOL.API_URL_PREFIX + 'donations', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@ -642,8 +644,30 @@ class BitcoinRoutes {
|
|||||||
|
|
||||||
private async getRbfHistory(req: Request, res: Response) {
|
private async getRbfHistory(req: Request, res: Response) {
|
||||||
try {
|
try {
|
||||||
const result = rbfCache.getReplaces(req.params.txId);
|
const replacements = rbfCache.getRbfTree(req.params.txId) || null;
|
||||||
res.json(result || []);
|
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) {
|
} catch (e) {
|
||||||
res.status(500).send(e instanceof Error ? e.message : e);
|
res.status(500).send(e instanceof Error ? e.message : e);
|
||||||
}
|
}
|
||||||
|
@ -57,11 +57,11 @@ export class Common {
|
|||||||
return arr;
|
return arr;
|
||||||
}
|
}
|
||||||
|
|
||||||
static findRbfTransactions(added: TransactionExtended[], deleted: TransactionExtended[]): { [txid: string]: TransactionExtended } {
|
static findRbfTransactions(added: TransactionExtended[], deleted: TransactionExtended[]): { [txid: string]: TransactionExtended[] } {
|
||||||
const matches: { [txid: string]: TransactionExtended } = {};
|
const matches: { [txid: string]: TransactionExtended[] } = {};
|
||||||
deleted
|
added
|
||||||
.forEach((deletedTx) => {
|
.forEach((addedTx) => {
|
||||||
const foundMatches = added.find((addedTx) => {
|
const foundMatches = deleted.filter((deletedTx) => {
|
||||||
// The new tx must, absolutely speaking, pay at least as much fee as the replaced tx.
|
// The new tx must, absolutely speaking, pay at least as much fee as the replaced tx.
|
||||||
return addedTx.fee > deletedTx.fee
|
return addedTx.fee > deletedTx.fee
|
||||||
// The new transaction must pay more fee per kB than the replaced tx.
|
// The new transaction must pay more fee per kB than the replaced tx.
|
||||||
@ -70,8 +70,8 @@ export class Common {
|
|||||||
&& deletedTx.vin.some((deletedVin) =>
|
&& deletedTx.vin.some((deletedVin) =>
|
||||||
addedTx.vin.some((vin) => vin.txid === deletedVin.txid && vin.vout === deletedVin.vout));
|
addedTx.vin.some((vin) => vin.txid === deletedVin.txid && vin.vout === deletedVin.vout));
|
||||||
});
|
});
|
||||||
if (foundMatches) {
|
if (foundMatches?.length) {
|
||||||
matches[deletedTx.txid] = foundMatches;
|
matches[addedTx.txid] = foundMatches;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return matches;
|
return matches;
|
||||||
|
@ -7,14 +7,18 @@ import logger from '../logger';
|
|||||||
import config from '../config';
|
import config from '../config';
|
||||||
import { TransactionExtended } from '../mempool.interfaces';
|
import { TransactionExtended } from '../mempool.interfaces';
|
||||||
import { Common } from './common';
|
import { Common } from './common';
|
||||||
|
import rbfCache from './rbf-cache';
|
||||||
|
|
||||||
class DiskCache {
|
class DiskCache {
|
||||||
private cacheSchemaVersion = 3;
|
private cacheSchemaVersion = 3;
|
||||||
|
private rbfCacheSchemaVersion = 1;
|
||||||
|
|
||||||
private static TMP_FILE_NAME = config.MEMPOOL.CACHE_DIR + '/tmp-cache.json';
|
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 TMP_FILE_NAMES = config.MEMPOOL.CACHE_DIR + '/tmp-cache{number}.json';
|
||||||
private static FILE_NAME = config.MEMPOOL.CACHE_DIR + '/cache.json';
|
private static FILE_NAME = config.MEMPOOL.CACHE_DIR + '/cache.json';
|
||||||
private static FILE_NAMES = config.MEMPOOL.CACHE_DIR + '/cache{number}.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 static CHUNK_FILES = 25;
|
||||||
private isWritingCache = false;
|
private isWritingCache = false;
|
||||||
|
|
||||||
@ -100,6 +104,32 @@ class DiskCache {
|
|||||||
logger.warn('Error writing to cache file: ' + (e instanceof Error ? e.message : e));
|
logger.warn('Error writing to cache file: ' + (e instanceof Error ? e.message : e));
|
||||||
this.isWritingCache = false;
|
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 {
|
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> {
|
async $loadMempoolCache(): Promise<void> {
|
||||||
if (!fs.existsSync(DiskCache.FILE_NAME)) {
|
if (!fs.existsSync(DiskCache.FILE_NAME)) {
|
||||||
return;
|
return;
|
||||||
@ -174,6 +216,29 @@ class DiskCache {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn('Failed to parse mempoool and blocks cache. Skipping. Reason: ' + (e instanceof Error ? e.message : 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) {
|
for (const rbfTransaction in rbfTransactions) {
|
||||||
if (this.mempoolCache[rbfTransaction]) {
|
if (this.mempoolCache[rbfTransaction] && rbfTransactions[rbfTransaction]?.length) {
|
||||||
// Store replaced transactions
|
// 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
|
// 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 {
|
class RbfCache {
|
||||||
private replacedBy: { [txid: string]: string; } = {};
|
private replacedBy: Map<string, string> = new Map();
|
||||||
private replaces: { [txid: string]: string[] } = {};
|
private replaces: Map<string, string[]> = new Map();
|
||||||
private txs: { [txid: string]: TransactionExtended } = {};
|
private rbfTrees: Map<string, RbfTree> = new Map(); // sequences of consecutive replacements
|
||||||
private expiring: { [txid: string]: Date } = {};
|
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() {
|
constructor() {
|
||||||
setInterval(this.cleanup.bind(this), 1000 * 60 * 60);
|
setInterval(this.cleanup.bind(this), 1000 * 60 * 60);
|
||||||
}
|
}
|
||||||
|
|
||||||
public add(replacedTx: TransactionExtended, newTxId: string): void {
|
public add(replaced: TransactionExtended[], newTxExtended: TransactionExtended): void {
|
||||||
this.replacedBy[replacedTx.txid] = newTxId;
|
if (!newTxExtended || !replaced?.length) {
|
||||||
this.txs[replacedTx.txid] = replacedTx;
|
return;
|
||||||
if (!this.replaces[newTxId]) {
|
|
||||||
this.replaces[newTxId] = [];
|
|
||||||
}
|
}
|
||||||
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 {
|
public getReplacedBy(txId: string): string | undefined {
|
||||||
return this.replacedBy[txId];
|
return this.replacedBy.get(txId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public getReplaces(txId: string): string[] | undefined {
|
public getReplaces(txId: string): string[] | undefined {
|
||||||
return this.replaces[txId];
|
return this.replaces.get(txId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public getTx(txId: string): TransactionExtended | undefined {
|
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
|
// flag a transaction as removed from the mempool
|
||||||
public evict(txid): void {
|
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 {
|
private cleanup(): void {
|
||||||
const currentDate = new Date();
|
const currentDate = new Date();
|
||||||
for (const txid in this.expiring) {
|
for (const txid in this.expiring) {
|
||||||
if (this.expiring[txid] < currentDate) {
|
if ((this.expiring.get(txid) || 0) < currentDate) {
|
||||||
delete this.expiring[txid];
|
this.expiring.delete(txid);
|
||||||
this.remove(txid);
|
this.remove(txid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -48,18 +175,147 @@ class RbfCache {
|
|||||||
|
|
||||||
// remove a transaction & all previous versions from the cache
|
// remove a transaction & all previous versions from the cache
|
||||||
private remove(txid): void {
|
private remove(txid): void {
|
||||||
// don't remove a transaction while a newer version remains in the mempool
|
// don't remove a transaction if a newer version remains in the mempool
|
||||||
if (this.replaces[txid] && !this.replacedBy[txid]) {
|
if (!this.replacedBy.has(txid)) {
|
||||||
const replaces = this.replaces[txid];
|
const replaces = this.replaces.get(txid);
|
||||||
delete this.replaces[txid];
|
this.replaces.delete(txid);
|
||||||
for (const tx of replaces) {
|
this.treeMap.delete(txid);
|
||||||
|
this.txs.delete(txid);
|
||||||
|
this.expiring.delete(txid);
|
||||||
|
for (const tx of (replaces || [])) {
|
||||||
// recursively remove prior versions from the cache
|
// recursively remove prior versions from the cache
|
||||||
delete this.replacedBy[tx];
|
this.replacedBy.delete(tx);
|
||||||
delete this.txs[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);
|
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();
|
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') {
|
if (parsedMessage.action === 'init') {
|
||||||
const _blocks = blocks.getBlocks().slice(-config.MEMPOOL.INITIAL_BLOCKS_AMOUNT);
|
const _blocks = blocks.getBlocks().slice(-config.MEMPOOL.INITIAL_BLOCKS_AMOUNT);
|
||||||
if (!_blocks) {
|
if (!_blocks) {
|
||||||
@ -278,6 +286,13 @@ class WebsocketHandler {
|
|||||||
const rbfTransactions = Common.findRbfTransactions(newTransactions, deletedTransactions);
|
const rbfTransactions = Common.findRbfTransactions(newTransactions, deletedTransactions);
|
||||||
const da = difficultyAdjustment.getDifficultyAdjustment();
|
const da = difficultyAdjustment.getDifficultyAdjustment();
|
||||||
memPool.handleRbfTransactions(rbfTransactions);
|
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();
|
const recommendedFees = feeApi.getRecommendedFee();
|
||||||
|
|
||||||
this.wss.clients.forEach(async (client) => {
|
this.wss.clients.forEach(async (client) => {
|
||||||
@ -400,16 +415,17 @@ class WebsocketHandler {
|
|||||||
response['utxoSpent'] = outspends;
|
response['utxoSpent'] = outspends;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rbfTransactions[client['track-tx']]) {
|
const rbfReplacedBy = rbfCache.getReplacedBy(client['track-tx']);
|
||||||
for (const rbfTransaction in rbfTransactions) {
|
if (rbfReplacedBy) {
|
||||||
if (client['track-tx'] === rbfTransaction) {
|
response['rbfTransaction'] = {
|
||||||
response['rbfTransaction'] = {
|
txid: rbfReplacedBy,
|
||||||
txid: rbfTransactions[rbfTransaction].txid,
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rbfChange = rbfChanges.map[client['track-tx']];
|
||||||
|
if (rbfChange) {
|
||||||
|
response['rbfInfo'] = rbfChanges.trees[rbfChange];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (client['track-mempool-block'] >= 0) {
|
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) {
|
if (Object.keys(response).length) {
|
||||||
client.send(JSON.stringify(response));
|
client.send(JSON.stringify(response));
|
||||||
}
|
}
|
||||||
@ -500,7 +522,7 @@ class WebsocketHandler {
|
|||||||
// Update mempool to remove transactions included in the new block
|
// Update mempool to remove transactions included in the new block
|
||||||
for (const txId of txIds) {
|
for (const txId of txIds) {
|
||||||
delete _memPool[txId];
|
delete _memPool[txId];
|
||||||
rbfCache.evict(txId);
|
rbfCache.mined(txId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.MEMPOOL.ADVANCED_GBT_MEMPOOL) {
|
if (config.MEMPOOL.ADVANCED_GBT_MEMPOOL) {
|
||||||
|
@ -39,6 +39,7 @@ __AUDIT__=${AUDIT:=false}
|
|||||||
__MAINNET_BLOCK_AUDIT_START_HEIGHT__=${MAINNET_BLOCK_AUDIT_START_HEIGHT:=0}
|
__MAINNET_BLOCK_AUDIT_START_HEIGHT__=${MAINNET_BLOCK_AUDIT_START_HEIGHT:=0}
|
||||||
__TESTNET_BLOCK_AUDIT_START_HEIGHT__=${TESTNET_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}
|
__SIGNET_BLOCK_AUDIT_START_HEIGHT__=${SIGNET_BLOCK_AUDIT_START_HEIGHT:=0}
|
||||||
|
__FULL_RBF_ENABLED__=${FULL_RBF_ENABLED:=false}
|
||||||
__HISTORICAL_PRICE__=${HISTORICAL_PRICE:=true}
|
__HISTORICAL_PRICE__=${HISTORICAL_PRICE:=true}
|
||||||
|
|
||||||
# Export as environment variables to be used by envsubst
|
# Export as environment variables to be used by envsubst
|
||||||
@ -65,6 +66,7 @@ export __AUDIT__
|
|||||||
export __MAINNET_BLOCK_AUDIT_START_HEIGHT__
|
export __MAINNET_BLOCK_AUDIT_START_HEIGHT__
|
||||||
export __TESTNET_BLOCK_AUDIT_START_HEIGHT__
|
export __TESTNET_BLOCK_AUDIT_START_HEIGHT__
|
||||||
export __SIGNET_BLOCK_AUDIT_START_HEIGHT__
|
export __SIGNET_BLOCK_AUDIT_START_HEIGHT__
|
||||||
|
export __FULL_RBF_ENABLED__
|
||||||
export __HISTORICAL_PRICE__
|
export __HISTORICAL_PRICE__
|
||||||
|
|
||||||
folder=$(find /var/www/mempool -name "config.js" | xargs dirname)
|
folder=$(find /var/www/mempool -name "config.js" | xargs dirname)
|
||||||
|
@ -22,5 +22,6 @@
|
|||||||
"TESTNET_BLOCK_AUDIT_START_HEIGHT": 0,
|
"TESTNET_BLOCK_AUDIT_START_HEIGHT": 0,
|
||||||
"SIGNET_BLOCK_AUDIT_START_HEIGHT": 0,
|
"SIGNET_BLOCK_AUDIT_START_HEIGHT": 0,
|
||||||
"LIGHTNING": false,
|
"LIGHTNING": false,
|
||||||
|
"FULL_RBF_ENABLED": false,
|
||||||
"HISTORICAL_PRICE": true
|
"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 { BisqMasterPageComponent } from './components/bisq-master-page/bisq-master-page.component';
|
||||||
import { PushTransactionComponent } from './components/push-transaction/push-transaction.component';
|
import { PushTransactionComponent } from './components/push-transaction/push-transaction.component';
|
||||||
import { BlocksList } from './components/blocks-list/blocks-list.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 { LiquidMasterPageComponent } from './components/liquid-master-page/liquid-master-page.component';
|
||||||
import { AssetGroupComponent } from './components/assets/asset-group/asset-group.component';
|
import { AssetGroupComponent } from './components/assets/asset-group/asset-group.component';
|
||||||
import { AssetsFeaturedComponent } from './components/assets/assets-featured/assets-featured.component';
|
import { AssetsFeaturedComponent } from './components/assets/assets-featured/assets-featured.component';
|
||||||
@ -56,6 +57,10 @@ let routes: Routes = [
|
|||||||
path: 'blocks',
|
path: 'blocks',
|
||||||
component: BlocksList,
|
component: BlocksList,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'rbf',
|
||||||
|
component: RbfList,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'terms-of-service',
|
path: 'terms-of-service',
|
||||||
component: TermsOfServiceComponent
|
component: TermsOfServiceComponent
|
||||||
@ -162,6 +167,10 @@ let routes: Routes = [
|
|||||||
path: 'blocks',
|
path: 'blocks',
|
||||||
component: BlocksList,
|
component: BlocksList,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'rbf',
|
||||||
|
component: RbfList,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'terms-of-service',
|
path: 'terms-of-service',
|
||||||
component: TermsOfServiceComponent
|
component: TermsOfServiceComponent
|
||||||
@ -264,6 +273,10 @@ let routes: Routes = [
|
|||||||
path: 'blocks',
|
path: 'blocks',
|
||||||
component: BlocksList,
|
component: BlocksList,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'rbf',
|
||||||
|
component: RbfList,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'terms-of-service',
|
path: 'terms-of-service',
|
||||||
component: TermsOfServiceComponent
|
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="container-xl">
|
||||||
|
|
||||||
<div class="title-block">
|
<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>
|
<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>
|
<app-truncate [text]="rbfTransaction.txid" [lastChars]="12" [link]="['/tx/' | relativeUrl, rbfTransaction.txid]"></app-truncate>
|
||||||
</div>
|
</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">
|
<ng-container *ngIf="!rbfTransaction || rbfTransaction?.size || tx">
|
||||||
<h1 i18n="shared.transaction">Transaction</h1>
|
<h1 i18n="shared.transaction">Transaction</h1>
|
||||||
|
|
||||||
@ -45,7 +38,7 @@
|
|||||||
|
|
||||||
<ng-template [ngIf]="!isLoadingTx && !error">
|
<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="box">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@ -104,7 +97,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
<tr *ngIf="!replaced">
|
<tr *ngIf="!replaced && !isCached">
|
||||||
<td class="td-width" i18n="transaction.eta|Transaction ETA">ETA</td>
|
<td class="td-width" i18n="transaction.eta|Transaction ETA">ETA</td>
|
||||||
<td>
|
<td>
|
||||||
<ng-template [ngIf]="txInBlockIndex === undefined" [ngIfElse]="estimationTmpl">
|
<ng-template [ngIf]="txInBlockIndex === undefined" [ngIfElse]="estimationTmpl">
|
||||||
@ -197,6 +190,15 @@
|
|||||||
|
|
||||||
<br>
|
<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">
|
<ng-container *ngIf="flowEnabled; else flowPlaceholder">
|
||||||
<div class="title float-left">
|
<div class="title float-left">
|
||||||
<h2 id="flow" i18n="transaction.flow|Transaction flow">Flow</h2>
|
<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 i18n="transaction.fee-rate|Transaction fee rate">Fee rate</td>
|
||||||
<td>
|
<td>
|
||||||
{{ tx.feePerVsize | feeRounding }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span>
|
{{ 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>
|
<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>
|
</ng-template>
|
||||||
@ -488,7 +490,7 @@
|
|||||||
<td>
|
<td>
|
||||||
<div class="effective-fee-container">
|
<div class="effective-fee-container">
|
||||||
{{ tx.effectiveFeePerVsize | feeRounding }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span>
|
{{ 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>
|
<app-tx-fee-rating class="ml-2 mr-2" *ngIf="tx.fee || tx.effectiveFeePerVsize" [tx]="tx"></app-tx-fee-rating>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</div>
|
</div>
|
||||||
|
@ -19,7 +19,7 @@ import { WebsocketService } from '../../services/websocket.service';
|
|||||||
import { AudioService } from '../../services/audio.service';
|
import { AudioService } from '../../services/audio.service';
|
||||||
import { ApiService } from '../../services/api.service';
|
import { ApiService } from '../../services/api.service';
|
||||||
import { SeoService } from '../../services/seo.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 { LiquidUnblinding } from './liquid-ublinding';
|
||||||
import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe';
|
import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe';
|
||||||
import { Price, PriceService } from '../../services/price.service';
|
import { Price, PriceService } from '../../services/price.service';
|
||||||
@ -46,6 +46,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
fetchRbfSubscription: Subscription;
|
fetchRbfSubscription: Subscription;
|
||||||
fetchCachedTxSubscription: Subscription;
|
fetchCachedTxSubscription: Subscription;
|
||||||
txReplacedSubscription: Subscription;
|
txReplacedSubscription: Subscription;
|
||||||
|
txRbfInfoSubscription: Subscription;
|
||||||
blocksSubscription: Subscription;
|
blocksSubscription: Subscription;
|
||||||
queryParamsSubscription: Subscription;
|
queryParamsSubscription: Subscription;
|
||||||
urlFragmentSubscription: Subscription;
|
urlFragmentSubscription: Subscription;
|
||||||
@ -53,6 +54,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
rbfTransaction: undefined | Transaction;
|
rbfTransaction: undefined | Transaction;
|
||||||
replaced: boolean = false;
|
replaced: boolean = false;
|
||||||
rbfReplaces: string[];
|
rbfReplaces: string[];
|
||||||
|
rbfInfo: RbfTree;
|
||||||
cpfpInfo: CpfpInfo | null;
|
cpfpInfo: CpfpInfo | null;
|
||||||
showCpfpDetails = false;
|
showCpfpDetails = false;
|
||||||
fetchCpfp$ = new Subject<string>();
|
fetchCpfp$ = new Subject<string>();
|
||||||
@ -168,7 +170,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
|
|
||||||
this.tx.effectiveFeePerVsize = totalFees / (totalWeight / 4);
|
this.tx.effectiveFeePerVsize = totalFees / (totalWeight / 4);
|
||||||
|
|
||||||
if (!this.tx.status.confirmed) {
|
if (!this.tx?.status?.confirmed) {
|
||||||
this.stateService.markBlock$.next({
|
this.stateService.markBlock$.next({
|
||||||
txFeePerVSize: this.tx.effectiveFeePerVsize,
|
txFeePerVSize: this.tx.effectiveFeePerVsize,
|
||||||
});
|
});
|
||||||
@ -183,10 +185,11 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
.getRbfHistory$(txId)
|
.getRbfHistory$(txId)
|
||||||
),
|
),
|
||||||
catchError(() => {
|
catchError(() => {
|
||||||
return of([]);
|
return of(null);
|
||||||
})
|
})
|
||||||
).subscribe((replaces) => {
|
).subscribe((rbfResponse) => {
|
||||||
this.rbfReplaces = replaces;
|
this.rbfInfo = rbfResponse?.replacements;
|
||||||
|
this.rbfReplaces = rbfResponse?.replaces || null;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.fetchCachedTxSubscription = this.fetchCachedTx$
|
this.fetchCachedTxSubscription = this.fetchCachedTx$
|
||||||
@ -203,21 +206,27 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.tx = tx;
|
if (!this.tx) {
|
||||||
this.setFeatures();
|
this.tx = tx;
|
||||||
this.isCached = true;
|
this.setFeatures();
|
||||||
if (tx.fee === undefined) {
|
this.isCached = true;
|
||||||
this.tx.fee = 0;
|
if (tx.fee === undefined) {
|
||||||
}
|
this.tx.fee = 0;
|
||||||
this.tx.feePerVsize = tx.fee / (tx.weight / 4);
|
}
|
||||||
this.isLoadingTx = false;
|
this.tx.feePerVsize = tx.fee / (tx.weight / 4);
|
||||||
this.error = undefined;
|
this.isLoadingTx = false;
|
||||||
this.waitingForTransaction = false;
|
this.error = undefined;
|
||||||
this.graphExpanded = false;
|
this.waitingForTransaction = false;
|
||||||
this.setupGraph();
|
this.graphExpanded = false;
|
||||||
|
this.transactionTime = 0;
|
||||||
|
this.setupGraph();
|
||||||
|
|
||||||
if (!this.tx?.status?.confirmed) {
|
|
||||||
this.fetchRbfHistory$.next(this.tx.txid);
|
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),
|
of(true),
|
||||||
this.stateService.connectionState$.pipe(
|
this.stateService.connectionState$.pipe(
|
||||||
filter(
|
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) => {
|
.subscribe((tx: Transaction) => {
|
||||||
if (!tx) {
|
if (!tx) {
|
||||||
|
this.fetchCachedTx$.next(this.txId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -313,13 +323,17 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
this.graphExpanded = false;
|
this.graphExpanded = false;
|
||||||
this.setupGraph();
|
this.setupGraph();
|
||||||
|
|
||||||
if (!tx.status.confirmed && tx.firstSeen) {
|
if (!tx.status?.confirmed) {
|
||||||
this.transactionTime = tx.firstSeen;
|
if (tx.firstSeen) {
|
||||||
|
this.transactionTime = tx.firstSeen;
|
||||||
|
} else {
|
||||||
|
this.transactionTime = 0;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
this.getTransactionTime();
|
this.getTransactionTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.tx.status.confirmed) {
|
if (this.tx?.status?.confirmed) {
|
||||||
this.stateService.markBlock$.next({
|
this.stateService.markBlock$.next({
|
||||||
blockHeight: tx.status.block_height,
|
blockHeight: tx.status.block_height,
|
||||||
});
|
});
|
||||||
@ -336,10 +350,10 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
} else {
|
} else {
|
||||||
this.fetchCpfp$.next(this.tx.txid);
|
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) => {
|
tap((price) => {
|
||||||
this.blockConversion = 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) => {
|
this.queryParamsSubscription = this.route.queryParams.subscribe((params) => {
|
||||||
if (params.showFlow === 'false') {
|
if (params.showFlow === 'false') {
|
||||||
this.overrideFlowPreference = false;
|
this.overrideFlowPreference = false;
|
||||||
@ -460,6 +480,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
this.replaced = false;
|
this.replaced = false;
|
||||||
this.transactionTime = -1;
|
this.transactionTime = -1;
|
||||||
this.cpfpInfo = null;
|
this.cpfpInfo = null;
|
||||||
|
this.rbfInfo = null;
|
||||||
this.rbfReplaces = [];
|
this.rbfReplaces = [];
|
||||||
this.showCpfpDetails = false;
|
this.showCpfpDetails = false;
|
||||||
document.body.scrollTo(0, 0);
|
document.body.scrollTo(0, 0);
|
||||||
@ -519,9 +540,13 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
@HostListener('window:resize', ['$event'])
|
@HostListener('window:resize', ['$event'])
|
||||||
setGraphSize(): void {
|
setGraphSize(): void {
|
||||||
this.isMobile = window.innerWidth < 850;
|
this.isMobile = window.innerWidth < 850;
|
||||||
if (this.graphContainer) {
|
if (this.graphContainer?.nativeElement) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.graphWidth = this.graphContainer.nativeElement.clientWidth;
|
if (this.graphContainer?.nativeElement) {
|
||||||
|
this.graphWidth = this.graphContainer.nativeElement.clientWidth;
|
||||||
|
} else {
|
||||||
|
setTimeout(() => { this.setGraphSize(); }, 1);
|
||||||
|
}
|
||||||
}, 1);
|
}, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -532,6 +557,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
this.fetchRbfSubscription.unsubscribe();
|
this.fetchRbfSubscription.unsubscribe();
|
||||||
this.fetchCachedTxSubscription.unsubscribe();
|
this.fetchCachedTxSubscription.unsubscribe();
|
||||||
this.txReplacedSubscription.unsubscribe();
|
this.txReplacedSubscription.unsubscribe();
|
||||||
|
this.txRbfInfoSubscription.unsubscribe();
|
||||||
this.blocksSubscription.unsubscribe();
|
this.blocksSubscription.unsubscribe();
|
||||||
this.queryParamsSubscription.unsubscribe();
|
this.queryParamsSubscription.unsubscribe();
|
||||||
this.flowPrefSubscription.unsubscribe();
|
this.flowPrefSubscription.unsubscribe();
|
||||||
|
@ -26,6 +26,18 @@ export interface CpfpInfo {
|
|||||||
bestDescendant?: BestDescendant | null;
|
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 {
|
export interface DifficultyAdjustment {
|
||||||
progressPercent: number;
|
progressPercent: number;
|
||||||
difficultyChange: number;
|
difficultyChange: number;
|
||||||
@ -146,6 +158,11 @@ export interface TransactionStripped {
|
|||||||
status?: 'found' | 'missing' | 'fresh' | 'added' | 'censored' | 'selected';
|
status?: 'found' | 'missing' | 'fresh' | 'added' | 'censored' | 'selected';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RbfTransaction extends TransactionStripped {
|
||||||
|
rbf?: boolean;
|
||||||
|
mined?: boolean,
|
||||||
|
}
|
||||||
|
|
||||||
export interface RewardStats {
|
export interface RewardStats {
|
||||||
startBlock: number;
|
startBlock: number;
|
||||||
endBlock: number;
|
endBlock: number;
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { ILoadingIndicators } from '../services/state.service';
|
import { ILoadingIndicators } from '../services/state.service';
|
||||||
import { Transaction } from './electrs.interface';
|
import { Transaction } from './electrs.interface';
|
||||||
import { BlockExtended, DifficultyAdjustment } from './node-api.interface';
|
import { BlockExtended, DifficultyAdjustment, RbfTree } from './node-api.interface';
|
||||||
|
|
||||||
export interface WebsocketResponse {
|
export interface WebsocketResponse {
|
||||||
block?: BlockExtended;
|
block?: BlockExtended;
|
||||||
@ -16,6 +16,8 @@ export interface WebsocketResponse {
|
|||||||
tx?: Transaction;
|
tx?: Transaction;
|
||||||
rbfTransaction?: ReplacedTransaction;
|
rbfTransaction?: ReplacedTransaction;
|
||||||
txReplaced?: ReplacedTransaction;
|
txReplaced?: ReplacedTransaction;
|
||||||
|
rbfInfo?: RbfTree;
|
||||||
|
rbfLatest?: RbfTree[];
|
||||||
utxoSpent?: object;
|
utxoSpent?: object;
|
||||||
transactions?: TransactionStripped[];
|
transactions?: TransactionStripped[];
|
||||||
loadingIndicators?: ILoadingIndicators;
|
loadingIndicators?: ILoadingIndicators;
|
||||||
@ -26,6 +28,7 @@ export interface WebsocketResponse {
|
|||||||
'track-address'?: string;
|
'track-address'?: string;
|
||||||
'track-asset'?: string;
|
'track-asset'?: string;
|
||||||
'track-mempool-block'?: number;
|
'track-mempool-block'?: number;
|
||||||
|
'track-rbf'?: string;
|
||||||
'watch-mempool'?: boolean;
|
'watch-mempool'?: boolean;
|
||||||
'track-bisq-market'?: string;
|
'track-bisq-market'?: string;
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http';
|
import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http';
|
||||||
import { CpfpInfo, OptimizedMempoolStats, AddressInformation, LiquidPegs, ITranslators,
|
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 { Observable } from 'rxjs';
|
||||||
import { StateService } from './state.service';
|
import { StateService } from './state.service';
|
||||||
import { WebsocketResponse } from '../interfaces/websocket.interface';
|
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);
|
return this.httpClient.get<AddressInformation>(this.apiBaseUrl + this.apiBasePath + '/api/v1/validate-address/' + address);
|
||||||
}
|
}
|
||||||
|
|
||||||
getRbfHistory$(txid: string): Observable<string[]> {
|
getRbfHistory$(txid: string): Observable<{ replacements: RbfTree, replaces: string[] }> {
|
||||||
return this.httpClient.get<string[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/tx/' + txid + '/replaces');
|
return this.httpClient.get<{ replacements: RbfTree, replaces: string[] }>(this.apiBaseUrl + this.apiBasePath + '/api/v1/tx/' + txid + '/rbf');
|
||||||
}
|
}
|
||||||
|
|
||||||
getRbfCachedTx$(txid: string): Observable<Transaction> {
|
getRbfCachedTx$(txid: string): Observable<Transaction> {
|
||||||
return this.httpClient.get<Transaction>(this.apiBaseUrl + this.apiBasePath + '/api/v1/tx/' + txid + '/cached');
|
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[]> {
|
listLiquidPegsMonth$(): Observable<LiquidPegs[]> {
|
||||||
return this.httpClient.get<LiquidPegs[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/liquid/pegs/month');
|
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 { ReplaySubject, BehaviorSubject, Subject, fromEvent, Observable } from 'rxjs';
|
||||||
import { Transaction } from '../interfaces/electrs.interface';
|
import { Transaction } from '../interfaces/electrs.interface';
|
||||||
import { IBackendInfo, MempoolBlock, MempoolBlockWithTransactions, MempoolBlockDelta, MempoolInfo, Recommendedfees, ReplacedTransaction, TransactionStripped } from '../interfaces/websocket.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 { Router, NavigationStart } from '@angular/router';
|
||||||
import { isPlatformBrowser } from '@angular/common';
|
import { isPlatformBrowser } from '@angular/common';
|
||||||
import { map, shareReplay } from 'rxjs/operators';
|
import { map, shareReplay } from 'rxjs/operators';
|
||||||
@ -43,6 +43,7 @@ export interface Env {
|
|||||||
MAINNET_BLOCK_AUDIT_START_HEIGHT: number;
|
MAINNET_BLOCK_AUDIT_START_HEIGHT: number;
|
||||||
TESTNET_BLOCK_AUDIT_START_HEIGHT: number;
|
TESTNET_BLOCK_AUDIT_START_HEIGHT: number;
|
||||||
SIGNET_BLOCK_AUDIT_START_HEIGHT: number;
|
SIGNET_BLOCK_AUDIT_START_HEIGHT: number;
|
||||||
|
FULL_RBF_ENABLED: boolean;
|
||||||
HISTORICAL_PRICE: boolean;
|
HISTORICAL_PRICE: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -73,6 +74,7 @@ const defaultEnv: Env = {
|
|||||||
'MAINNET_BLOCK_AUDIT_START_HEIGHT': 0,
|
'MAINNET_BLOCK_AUDIT_START_HEIGHT': 0,
|
||||||
'TESTNET_BLOCK_AUDIT_START_HEIGHT': 0,
|
'TESTNET_BLOCK_AUDIT_START_HEIGHT': 0,
|
||||||
'SIGNET_BLOCK_AUDIT_START_HEIGHT': 0,
|
'SIGNET_BLOCK_AUDIT_START_HEIGHT': 0,
|
||||||
|
'FULL_RBF_ENABLED': false,
|
||||||
'HISTORICAL_PRICE': true,
|
'HISTORICAL_PRICE': true,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -98,6 +100,8 @@ export class StateService {
|
|||||||
mempoolBlockTransactions$ = new Subject<TransactionStripped[]>();
|
mempoolBlockTransactions$ = new Subject<TransactionStripped[]>();
|
||||||
mempoolBlockDelta$ = new Subject<MempoolBlockDelta>();
|
mempoolBlockDelta$ = new Subject<MempoolBlockDelta>();
|
||||||
txReplaced$ = new Subject<ReplacedTransaction>();
|
txReplaced$ = new Subject<ReplacedTransaction>();
|
||||||
|
txRbfInfo$ = new Subject<RbfTree>();
|
||||||
|
rbfLatest$ = new Subject<RbfTree[]>();
|
||||||
utxoSpent$ = new Subject<object>();
|
utxoSpent$ = new Subject<object>();
|
||||||
difficultyAdjustment$ = new ReplaySubject<DifficultyAdjustment>(1);
|
difficultyAdjustment$ = new ReplaySubject<DifficultyAdjustment>(1);
|
||||||
mempoolTransactions$ = new Subject<Transaction>();
|
mempoolTransactions$ = new Subject<Transaction>();
|
||||||
|
@ -28,6 +28,7 @@ export class WebsocketService {
|
|||||||
private isTrackingTx = false;
|
private isTrackingTx = false;
|
||||||
private trackingTxId: string;
|
private trackingTxId: string;
|
||||||
private isTrackingMempoolBlock = false;
|
private isTrackingMempoolBlock = false;
|
||||||
|
private isTrackingRbf = false;
|
||||||
private trackingMempoolBlock: number;
|
private trackingMempoolBlock: number;
|
||||||
private latestGitCommit = '';
|
private latestGitCommit = '';
|
||||||
private onlineCheckTimeout: number;
|
private onlineCheckTimeout: number;
|
||||||
@ -173,6 +174,16 @@ export class WebsocketService {
|
|||||||
this.isTrackingMempoolBlock = false
|
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) {
|
startTrackBisqMarket(market: string) {
|
||||||
this.websocketSubject.next({ 'track-bisq-market': market });
|
this.websocketSubject.next({ 'track-bisq-market': market });
|
||||||
}
|
}
|
||||||
@ -257,6 +268,14 @@ export class WebsocketService {
|
|||||||
this.stateService.txReplaced$.next(response.rbfTransaction);
|
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) {
|
if (response.txReplaced) {
|
||||||
this.stateService.txReplaced$.next(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 { DifficultyTooltipComponent } from '../components/difficulty/difficulty-tooltip.component';
|
||||||
import { DifficultyMiningComponent } from '../components/difficulty-mining/difficulty-mining.component';
|
import { DifficultyMiningComponent } from '../components/difficulty-mining/difficulty-mining.component';
|
||||||
import { TermsOfServiceComponent } from '../components/terms-of-service/terms-of-service.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 { TxBowtieGraphComponent } from '../components/tx-bowtie-graph/tx-bowtie-graph.component';
|
||||||
import { TxBowtieGraphTooltipComponent } from '../components/tx-bowtie-graph-tooltip/tx-bowtie-graph-tooltip.component';
|
import { TxBowtieGraphTooltipComponent } from '../components/tx-bowtie-graph-tooltip/tx-bowtie-graph-tooltip.component';
|
||||||
import { PrivacyPolicyComponent } from '../components/privacy-policy/privacy-policy.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 { AmountShortenerPipe } from '../shared/pipes/amount-shortener.pipe';
|
||||||
import { DifficultyAdjustmentsTable } from '../components/difficulty-adjustments-table/difficulty-adjustments-table.components';
|
import { DifficultyAdjustmentsTable } from '../components/difficulty-adjustments-table/difficulty-adjustments-table.components';
|
||||||
import { BlocksList } from '../components/blocks-list/blocks-list.component';
|
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 { RewardStatsComponent } from '../components/reward-stats/reward-stats.component';
|
||||||
import { DataCyDirective } from '../data-cy.directive';
|
import { DataCyDirective } from '../data-cy.directive';
|
||||||
import { LoadingIndicatorComponent } from '../components/loading-indicator/loading-indicator.component';
|
import { LoadingIndicatorComponent } from '../components/loading-indicator/loading-indicator.component';
|
||||||
@ -138,6 +141,8 @@ import { TestnetAlertComponent } from './components/testnet-alert/testnet-alert.
|
|||||||
DifficultyComponent,
|
DifficultyComponent,
|
||||||
DifficultyMiningComponent,
|
DifficultyMiningComponent,
|
||||||
DifficultyTooltipComponent,
|
DifficultyTooltipComponent,
|
||||||
|
RbfTimelineComponent,
|
||||||
|
RbfTimelineTooltipComponent,
|
||||||
TxBowtieGraphComponent,
|
TxBowtieGraphComponent,
|
||||||
TxBowtieGraphTooltipComponent,
|
TxBowtieGraphTooltipComponent,
|
||||||
TermsOfServiceComponent,
|
TermsOfServiceComponent,
|
||||||
@ -151,6 +156,7 @@ import { TestnetAlertComponent } from './components/testnet-alert/testnet-alert.
|
|||||||
AmountShortenerPipe,
|
AmountShortenerPipe,
|
||||||
DifficultyAdjustmentsTable,
|
DifficultyAdjustmentsTable,
|
||||||
BlocksList,
|
BlocksList,
|
||||||
|
RbfList,
|
||||||
DataCyDirective,
|
DataCyDirective,
|
||||||
RewardStatsComponent,
|
RewardStatsComponent,
|
||||||
LoadingIndicatorComponent,
|
LoadingIndicatorComponent,
|
||||||
@ -242,6 +248,8 @@ import { TestnetAlertComponent } from './components/testnet-alert/testnet-alert.
|
|||||||
DifficultyComponent,
|
DifficultyComponent,
|
||||||
DifficultyMiningComponent,
|
DifficultyMiningComponent,
|
||||||
DifficultyTooltipComponent,
|
DifficultyTooltipComponent,
|
||||||
|
RbfTimelineComponent,
|
||||||
|
RbfTimelineTooltipComponent,
|
||||||
TxBowtieGraphComponent,
|
TxBowtieGraphComponent,
|
||||||
TxBowtieGraphTooltipComponent,
|
TxBowtieGraphTooltipComponent,
|
||||||
TermsOfServiceComponent,
|
TermsOfServiceComponent,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user