mirror of
https://github.com/mempool/mempool.git
synced 2025-03-03 17:47:01 +01:00
support trees of RBF replacements
This commit is contained in:
parent
c064ef6ace
commit
086b41d958
18 changed files with 413 additions and 219 deletions
|
@ -644,7 +644,7 @@ class BitcoinRoutes {
|
||||||
|
|
||||||
private async getRbfHistory(req: Request, res: Response) {
|
private async getRbfHistory(req: Request, res: Response) {
|
||||||
try {
|
try {
|
||||||
const replacements = rbfCache.getRbfChain(req.params.txId) || [];
|
const replacements = rbfCache.getRbfTree(req.params.txId) || null;
|
||||||
const replaces = rbfCache.getReplaces(req.params.txId) || null;
|
const replaces = rbfCache.getReplaces(req.params.txId) || null;
|
||||||
res.json({
|
res.json({
|
||||||
replacements,
|
replacements,
|
||||||
|
@ -657,7 +657,7 @@ class BitcoinRoutes {
|
||||||
|
|
||||||
private async getRbfReplacements(req: Request, res: Response) {
|
private async getRbfReplacements(req: Request, res: Response) {
|
||||||
try {
|
try {
|
||||||
const result = rbfCache.getRbfChains(false);
|
const result = rbfCache.getRbfTrees(false);
|
||||||
res.json(result);
|
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);
|
||||||
|
@ -666,7 +666,7 @@ class BitcoinRoutes {
|
||||||
|
|
||||||
private async getFullRbfReplacements(req: Request, res: Response) {
|
private async getFullRbfReplacements(req: Request, res: Response) {
|
||||||
try {
|
try {
|
||||||
const result = rbfCache.getRbfChains(true);
|
const result = rbfCache.getRbfTrees(true);
|
||||||
res.json(result);
|
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;
|
||||||
|
|
|
@ -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]);
|
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,22 +1,27 @@
|
||||||
|
import { runInNewContext } from "vm";
|
||||||
import { TransactionExtended, TransactionStripped } from "../mempool.interfaces";
|
import { TransactionExtended, TransactionStripped } from "../mempool.interfaces";
|
||||||
import { Common } from "./common";
|
import { Common } from "./common";
|
||||||
|
|
||||||
interface RbfTransaction extends TransactionStripped {
|
interface RbfTransaction extends TransactionStripped {
|
||||||
rbf?: boolean;
|
rbf?: boolean;
|
||||||
|
mined?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type RbfChain = {
|
interface RbfTree {
|
||||||
tx: RbfTransaction,
|
tx: RbfTransaction;
|
||||||
time: number,
|
time: number;
|
||||||
mined?: boolean,
|
interval?: number;
|
||||||
}[];
|
mined?: boolean;
|
||||||
|
fullRbf: boolean;
|
||||||
|
replaces: RbfTree[];
|
||||||
|
}
|
||||||
|
|
||||||
class RbfCache {
|
class RbfCache {
|
||||||
private replacedBy: Map<string, string> = new Map();
|
private replacedBy: Map<string, string> = new Map();
|
||||||
private replaces: Map<string, string[]> = new Map();
|
private replaces: Map<string, string[]> = new Map();
|
||||||
private rbfChains: Map<string, RbfChain> = new Map(); // sequences of consecutive replacements
|
private rbfTrees: Map<string, RbfTree> = new Map(); // sequences of consecutive replacements
|
||||||
private dirtyChains: Set<string> = new Set();
|
private dirtyTrees: Set<string> = new Set();
|
||||||
private chainMap: Map<string, string> = new Map(); // map of txids to sequence ids
|
private treeMap: Map<string, string> = new Map(); // map of txids to sequence ids
|
||||||
private txs: Map<string, TransactionExtended> = new Map();
|
private txs: Map<string, TransactionExtended> = new Map();
|
||||||
private expiring: Map<string, Date> = new Map();
|
private expiring: Map<string, Date> = new Map();
|
||||||
|
|
||||||
|
@ -24,37 +29,58 @@ class RbfCache {
|
||||||
setInterval(this.cleanup.bind(this), 1000 * 60 * 60);
|
setInterval(this.cleanup.bind(this), 1000 * 60 * 60);
|
||||||
}
|
}
|
||||||
|
|
||||||
public add(replacedTxExtended: TransactionExtended, newTxExtended: TransactionExtended): void {
|
public add(replaced: TransactionExtended[], newTxExtended: TransactionExtended): void {
|
||||||
const replacedTx = Common.stripTransaction(replacedTxExtended) as RbfTransaction;
|
if (!newTxExtended || !replaced?.length) {
|
||||||
replacedTx.rbf = replacedTxExtended.vin.some((v) => v.sequence < 0xfffffffe);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const newTx = Common.stripTransaction(newTxExtended) as RbfTransaction;
|
const newTx = Common.stripTransaction(newTxExtended) as RbfTransaction;
|
||||||
|
const newTime = newTxExtended.firstSeen || Date.now();
|
||||||
newTx.rbf = newTxExtended.vin.some((v) => v.sequence < 0xfffffffe);
|
newTx.rbf = newTxExtended.vin.some((v) => v.sequence < 0xfffffffe);
|
||||||
|
|
||||||
this.replacedBy.set(replacedTx.txid, newTx.txid);
|
|
||||||
this.txs.set(replacedTx.txid, replacedTxExtended);
|
|
||||||
this.txs.set(newTx.txid, newTxExtended);
|
this.txs.set(newTx.txid, newTxExtended);
|
||||||
if (!this.replaces.has(newTx.txid)) {
|
|
||||||
this.replaces.set(newTx.txid, []);
|
|
||||||
}
|
|
||||||
this.replaces.get(newTx.txid)?.push(replacedTx.txid);
|
|
||||||
|
|
||||||
// maintain rbf chains
|
// maintain rbf trees
|
||||||
if (this.chainMap.has(replacedTx.txid)) {
|
let fullRbf = false;
|
||||||
// add to an existing chain
|
const replacedTrees: RbfTree[] = [];
|
||||||
const chainRoot = this.chainMap.get(replacedTx.txid) || '';
|
for (const replacedTxExtended of replaced) {
|
||||||
this.rbfChains.get(chainRoot)?.push({ tx: newTx, time: newTxExtended.firstSeen || Date.now() });
|
const replacedTx = Common.stripTransaction(replacedTxExtended) as RbfTransaction;
|
||||||
this.chainMap.set(newTx.txid, chainRoot);
|
replacedTx.rbf = replacedTxExtended.vin.some((v) => v.sequence < 0xfffffffe);
|
||||||
this.dirtyChains.add(chainRoot);
|
this.replacedBy.set(replacedTx.txid, newTx.txid);
|
||||||
} else {
|
if (this.treeMap.has(replacedTx.txid)) {
|
||||||
// start a new chain
|
const treeId = this.treeMap.get(replacedTx.txid);
|
||||||
this.rbfChains.set(replacedTx.txid, [
|
if (treeId) {
|
||||||
{ tx: replacedTx, time: replacedTxExtended.firstSeen || Date.now() },
|
const tree = this.rbfTrees.get(treeId);
|
||||||
{ tx: newTx, time: newTxExtended.firstSeen || Date.now() },
|
this.rbfTrees.delete(treeId);
|
||||||
]);
|
if (tree) {
|
||||||
this.chainMap.set(replacedTx.txid, replacedTx.txid);
|
tree.interval = newTime - tree?.time;
|
||||||
this.chainMap.set(newTx.txid, replacedTx.txid);
|
replacedTrees.push(tree);
|
||||||
this.dirtyChains.add(replacedTx.txid);
|
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 {
|
||||||
|
@ -69,66 +95,64 @@ class RbfCache {
|
||||||
return this.txs.get(txId);
|
return this.txs.get(txId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public getRbfChain(txId: string): RbfChain {
|
public getRbfTree(txId: string): RbfTree | void {
|
||||||
return this.rbfChains.get(this.chainMap.get(txId) || '') || [];
|
return this.rbfTrees.get(this.treeMap.get(txId) || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
// get a paginated list of RbfChains
|
// get a paginated list of RbfTrees
|
||||||
// ordered by most recent replacement time
|
// ordered by most recent replacement time
|
||||||
public getRbfChains(onlyFullRbf: boolean, after?: string): RbfChain[] {
|
public getRbfTrees(onlyFullRbf: boolean, after?: string): RbfTree[] {
|
||||||
const limit = 25;
|
const limit = 25;
|
||||||
const chains: RbfChain[] = [];
|
const trees: RbfTree[] = [];
|
||||||
const used = new Set<string>();
|
const used = new Set<string>();
|
||||||
const replacements: string[][] = Array.from(this.replacedBy).reverse();
|
const replacements: string[][] = Array.from(this.replacedBy).reverse();
|
||||||
const afterChain = after ? this.chainMap.get(after) : null;
|
const afterTree = after ? this.treeMap.get(after) : null;
|
||||||
let ready = !afterChain;
|
let ready = !afterTree;
|
||||||
for (let i = 0; i < replacements.length && chains.length <= limit - 1; i++) {
|
for (let i = 0; i < replacements.length && trees.length <= limit - 1; i++) {
|
||||||
const txid = replacements[i][1];
|
const txid = replacements[i][1];
|
||||||
const chainRoot = this.chainMap.get(txid) || '';
|
const treeId = this.treeMap.get(txid) || '';
|
||||||
if (chainRoot === afterChain) {
|
if (treeId === afterTree) {
|
||||||
ready = true;
|
ready = true;
|
||||||
} else if (ready) {
|
} else if (ready) {
|
||||||
if (!used.has(chainRoot)) {
|
if (!used.has(treeId)) {
|
||||||
const chain = this.rbfChains.get(chainRoot);
|
const tree = this.rbfTrees.get(treeId);
|
||||||
used.add(chainRoot);
|
used.add(treeId);
|
||||||
if (chain && (!onlyFullRbf || chain.slice(0, -1).some(entry => !entry.tx.rbf))) {
|
if (tree && (!onlyFullRbf || tree.fullRbf)) {
|
||||||
chains.push(chain);
|
trees.push(tree);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return chains;
|
return trees;
|
||||||
}
|
}
|
||||||
|
|
||||||
// get map of rbf chains that have been updated since the last call
|
// get map of rbf trees that have been updated since the last call
|
||||||
public getRbfChanges(): { chains: {[root: string]: RbfChain }, map: { [txid: string]: string }} {
|
public getRbfChanges(): { trees: {[id: string]: RbfTree }, map: { [txid: string]: string }} {
|
||||||
const changes: { chains: {[root: string]: RbfChain }, map: { [txid: string]: string }} = {
|
const changes: { trees: {[id: string]: RbfTree }, map: { [txid: string]: string }} = {
|
||||||
chains: {},
|
trees: {},
|
||||||
map: {},
|
map: {},
|
||||||
};
|
};
|
||||||
this.dirtyChains.forEach(root => {
|
this.dirtyTrees.forEach(id => {
|
||||||
const chain = this.rbfChains.get(root);
|
const tree = this.rbfTrees.get(id);
|
||||||
if (chain) {
|
if (tree) {
|
||||||
changes.chains[root] = chain;
|
changes.trees[id] = tree;
|
||||||
chain.forEach(entry => {
|
this.getTransactionsInTree(tree).forEach(tx => {
|
||||||
changes.map[entry.tx.txid] = root;
|
changes.map[tx.txid] = id;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.dirtyChains = new Set();
|
this.dirtyTrees = new Set();
|
||||||
return changes;
|
return changes;
|
||||||
}
|
}
|
||||||
|
|
||||||
public mined(txid): void {
|
public mined(txid): void {
|
||||||
const chainRoot = this.chainMap.get(txid)
|
const treeId = this.treeMap.get(txid);
|
||||||
if (chainRoot && this.rbfChains.has(chainRoot)) {
|
if (treeId && this.rbfTrees.has(treeId)) {
|
||||||
const chain = this.rbfChains.get(chainRoot);
|
const tree = this.rbfTrees.get(treeId);
|
||||||
if (chain) {
|
if (tree) {
|
||||||
const chainEntry = chain.find(entry => entry.tx.txid === txid);
|
this.setTreeMined(tree, txid);
|
||||||
if (chainEntry) {
|
tree.mined = true;
|
||||||
chainEntry.mined = true;
|
this.dirtyTrees.add(treeId);
|
||||||
}
|
|
||||||
this.dirtyChains.add(chainRoot);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.evict(txid);
|
this.evict(txid);
|
||||||
|
@ -155,20 +179,45 @@ class RbfCache {
|
||||||
if (!this.replacedBy.has(txid)) {
|
if (!this.replacedBy.has(txid)) {
|
||||||
const replaces = this.replaces.get(txid);
|
const replaces = this.replaces.get(txid);
|
||||||
this.replaces.delete(txid);
|
this.replaces.delete(txid);
|
||||||
this.chainMap.delete(txid);
|
this.treeMap.delete(txid);
|
||||||
this.txs.delete(txid);
|
this.txs.delete(txid);
|
||||||
this.expiring.delete(txid);
|
this.expiring.delete(txid);
|
||||||
for (const tx of (replaces || [])) {
|
for (const tx of (replaces || [])) {
|
||||||
// recursively remove prior versions from the cache
|
// recursively remove prior versions from the cache
|
||||||
this.replacedBy.delete(tx);
|
this.replacedBy.delete(tx);
|
||||||
// if this is the root of a chain, remove that too
|
// if this is the id of a tree, remove that too
|
||||||
if (this.chainMap.get(tx) === tx) {
|
if (this.treeMap.get(tx) === tx) {
|
||||||
this.rbfChains.delete(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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new RbfCache();
|
export default new RbfCache();
|
||||||
|
|
|
@ -289,9 +289,9 @@ class WebsocketHandler {
|
||||||
const rbfChanges = rbfCache.getRbfChanges();
|
const rbfChanges = rbfCache.getRbfChanges();
|
||||||
let rbfReplacements;
|
let rbfReplacements;
|
||||||
let fullRbfReplacements;
|
let fullRbfReplacements;
|
||||||
if (Object.keys(rbfChanges.chains).length) {
|
if (Object.keys(rbfChanges.trees).length) {
|
||||||
rbfReplacements = rbfCache.getRbfChains(false);
|
rbfReplacements = rbfCache.getRbfTrees(false);
|
||||||
fullRbfReplacements = rbfCache.getRbfChains(true);
|
fullRbfReplacements = rbfCache.getRbfTrees(true);
|
||||||
}
|
}
|
||||||
const recommendedFees = feeApi.getRecommendedFee();
|
const recommendedFees = feeApi.getRecommendedFee();
|
||||||
|
|
||||||
|
@ -415,20 +415,16 @@ 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']];
|
const rbfChange = rbfChanges.map[client['track-tx']];
|
||||||
if (rbfChange) {
|
if (rbfChange) {
|
||||||
response['rbfInfo'] = rbfChanges.chains[rbfChange];
|
response['rbfInfo'] = rbfChanges.trees[rbfChange];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -17,37 +17,22 @@
|
||||||
|
|
||||||
<div class="clearfix"></div>
|
<div class="clearfix"></div>
|
||||||
|
|
||||||
<div class="rbf-chains" style="min-height: 295px">
|
<div class="rbf-trees" style="min-height: 295px">
|
||||||
<ng-container *ngIf="rbfChains$ | async as chains">
|
<ng-container *ngIf="rbfTrees$ | async as trees">
|
||||||
<div *ngFor="let chain of chains" class="chain">
|
<div *ngFor="let tree of trees" class="tree">
|
||||||
<p class="info">
|
<p class="info">
|
||||||
<app-time kind="since" [time]="chain[chain.length - 1].time"></app-time>
|
|
||||||
<span class="type">
|
<span class="type">
|
||||||
<span *ngIf="isMined(chain)" class="badge badge-success" i18n="transaction.rbf.mined">Mined</span>
|
<span *ngIf="isMined(tree)" class="badge badge-success" i18n="transaction.rbf.mined">Mined</span>
|
||||||
<span *ngIf="isFullRbf(chain)" class="badge badge-info" i18n="transaction.full-rbf">Full RBF</span>
|
<span *ngIf="isFullRbf(tree)" class="badge badge-info" i18n="transaction.full-rbf">Full RBF</span>
|
||||||
</span>
|
</span>
|
||||||
|
<app-time kind="since" [time]="tree.time"></app-time>
|
||||||
</p>
|
</p>
|
||||||
<div class="txids">
|
<div class="timeline-wrapper" [class.mined]="isMined(tree)">
|
||||||
<span class="txid">
|
<app-rbf-timeline [replacements]="tree"></app-rbf-timeline>
|
||||||
<a class="rbf-link" [routerLink]="['/tx/' | relativeUrl, chain[0].tx.txid]">
|
|
||||||
<span class="d-inline">{{ chain[0].tx.txid | shortenString : 24 }}</span>
|
|
||||||
</a>
|
|
||||||
</span>
|
|
||||||
<span class="arrow">
|
|
||||||
<fa-icon [icon]="['fas', 'arrow-right']" [fixedWidth]="true"></fa-icon>
|
|
||||||
</span>
|
|
||||||
<span class="txid right">
|
|
||||||
<a class="rbf-link" [routerLink]="['/tx/' | relativeUrl, chain[chain.length - 1].tx.txid]">
|
|
||||||
<span class="d-inline">{{ chain[chain.length - 1].tx.txid | shortenString : 24 }}</span>
|
|
||||||
</a>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="timeline-wrapper" [class.mined]="isMined(chain)">
|
|
||||||
<app-rbf-timeline [replacements]="chain"></app-rbf-timeline>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="no-replacements" *ngIf="!chains?.length">
|
<div class="no-replacements" *ngIf="!trees?.length">
|
||||||
<p i18n="rbf.no-replacements-yet">there are no replacements in the mempool yet!</p>
|
<p i18n="rbf.no-replacements-yet">there are no replacements in the mempool yet!</p>
|
||||||
</div>
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
|
@ -4,13 +4,14 @@
|
||||||
margin-top: 13px;
|
margin-top: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rbf-chains {
|
.rbf-trees {
|
||||||
.info {
|
.info {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
|
||||||
.type {
|
.type {
|
||||||
.badge {
|
.badge {
|
||||||
|
@ -19,27 +20,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.chain {
|
.tree {
|
||||||
margin-bottom: 1em;
|
margin-bottom: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.txids {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: baseline;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
|
|
||||||
.txid {
|
|
||||||
flex-basis: 0;
|
|
||||||
flex-grow: 1;
|
|
||||||
|
|
||||||
&.right {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-wrapper.mined {
|
.timeline-wrapper.mined {
|
||||||
border: solid 4px #1a9436;
|
border: solid 4px #1a9436;
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { BehaviorSubject, EMPTY, merge, Observable, Subscription } from 'rxjs';
|
import { BehaviorSubject, EMPTY, merge, Observable, Subscription } from 'rxjs';
|
||||||
import { catchError, switchMap, tap } from 'rxjs/operators';
|
import { catchError, switchMap, tap } from 'rxjs/operators';
|
||||||
import { WebsocketService } from 'src/app/services/websocket.service';
|
import { WebsocketService } from 'src/app/services/websocket.service';
|
||||||
import { RbfInfo } from '../../interfaces/node-api.interface';
|
import { RbfTree } from '../../interfaces/node-api.interface';
|
||||||
import { ApiService } from '../../services/api.service';
|
import { ApiService } from '../../services/api.service';
|
||||||
import { StateService } from '../../services/state.service';
|
import { StateService } from '../../services/state.service';
|
||||||
|
|
||||||
|
@ -14,14 +14,12 @@ import { StateService } from '../../services/state.service';
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class RbfList implements OnInit, OnDestroy {
|
export class RbfList implements OnInit, OnDestroy {
|
||||||
rbfChains$: Observable<RbfInfo[][]>;
|
rbfTrees$: Observable<RbfTree[]>;
|
||||||
fromChainSubject = new BehaviorSubject(null);
|
nextRbfSubject = new BehaviorSubject(null);
|
||||||
urlFragmentSubscription: Subscription;
|
urlFragmentSubscription: Subscription;
|
||||||
fullRbfEnabled: boolean;
|
fullRbfEnabled: boolean;
|
||||||
fullRbf: boolean;
|
fullRbf: boolean;
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
firstChainId: string;
|
|
||||||
lastChainId: string;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
|
@ -37,13 +35,13 @@ export class RbfList implements OnInit, OnDestroy {
|
||||||
this.urlFragmentSubscription = this.route.fragment.subscribe((fragment) => {
|
this.urlFragmentSubscription = this.route.fragment.subscribe((fragment) => {
|
||||||
this.fullRbf = (fragment === 'fullrbf');
|
this.fullRbf = (fragment === 'fullrbf');
|
||||||
this.websocketService.startTrackRbf(this.fullRbf ? 'fullRbf' : 'all');
|
this.websocketService.startTrackRbf(this.fullRbf ? 'fullRbf' : 'all');
|
||||||
this.fromChainSubject.next(this.firstChainId);
|
this.nextRbfSubject.next(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.rbfChains$ = merge(
|
this.rbfTrees$ = merge(
|
||||||
this.fromChainSubject.pipe(
|
this.nextRbfSubject.pipe(
|
||||||
switchMap((fromChainId) => {
|
switchMap(() => {
|
||||||
return this.apiService.getRbfList$(this.fullRbf, fromChainId || undefined)
|
return this.apiService.getRbfList$(this.fullRbf);
|
||||||
}),
|
}),
|
||||||
catchError((e) => {
|
catchError((e) => {
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
|
@ -52,11 +50,8 @@ export class RbfList implements OnInit, OnDestroy {
|
||||||
this.stateService.rbfLatest$
|
this.stateService.rbfLatest$
|
||||||
)
|
)
|
||||||
.pipe(
|
.pipe(
|
||||||
tap((result: RbfInfo[][]) => {
|
tap(() => {
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
if (result && result.length && result[0].length) {
|
|
||||||
this.lastChainId = result[result.length - 1][0].tx.txid;
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -68,16 +63,16 @@ export class RbfList implements OnInit, OnDestroy {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
isFullRbf(chain: RbfInfo[]): boolean {
|
isFullRbf(tree: RbfTree): boolean {
|
||||||
return chain.slice(0, -1).some(entry => !entry.tx.rbf);
|
return tree.fullRbf;
|
||||||
}
|
}
|
||||||
|
|
||||||
isMined(chain: RbfInfo[]): boolean {
|
isMined(tree: RbfTree): boolean {
|
||||||
return chain.some(entry => entry.mined);
|
return tree.mined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// pageChange(page: number) {
|
// pageChange(page: number) {
|
||||||
// this.fromChainSubject.next(this.lastChainId);
|
// this.fromTreeSubject.next(this.lastTreeId);
|
||||||
// }
|
// }
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
|
|
|
@ -1,31 +1,54 @@
|
||||||
<div class="rbf-timeline box" [class.mined]="mined">
|
<div class="rbf-timeline box" [class.mined]="replacements.mined">
|
||||||
<div class="timeline">
|
<div class="timeline-wrapper">
|
||||||
<div class="intervals">
|
<div class="timeline" *ngFor="let timeline of rows">
|
||||||
<ng-container *ngFor="let replacement of replacements; let i = index;">
|
<div class="intervals">
|
||||||
<div class="interval" *ngIf="i > 0">
|
<ng-container *ngFor="let cell of timeline; let i = index;">
|
||||||
<div class="interval-time">
|
<div class="node-spacer"></div>
|
||||||
<app-time [time]="replacement.time - replacements[i-1].time" [relative]="false"></app-time>
|
<ng-container *ngIf="i < timeline.length - 1">
|
||||||
</div>
|
<div class="interval" *ngIf="cell.replacement?.interval != null; else intervalSpacer">
|
||||||
</div>
|
<div class="interval-time">
|
||||||
<div class="node-spacer"></div>
|
<app-time [time]="cell.replacement.interval" [relative]="false"></app-time>
|
||||||
</ng-container>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="nodes">
|
</ng-container>
|
||||||
<ng-container *ngFor="let replacement of replacements; let i = index;">
|
</ng-container>
|
||||||
<div class="interval-spacer" *ngIf="i > 0">
|
</div>
|
||||||
<div class="track"></div>
|
<div class="nodes">
|
||||||
</div>
|
<ng-container *ngFor="let cell of timeline; let i = index;">
|
||||||
<div class="node" [class.selected]="txid === replacement.tx.txid" [class.mined]="replacement.mined">
|
<ng-container *ngIf="cell.replacement; else nonNode">
|
||||||
<div class="track"></div>
|
<div class="node" [class.selected]="txid === cell.replacement.tx.txid" [class.mined]="cell.replacement.tx.mined" [class.first-node]="cell.first">
|
||||||
<a class="shape-border" [class.rbf]="replacement.tx.rbf" [routerLink]="['/tx/' | relativeUrl, replacement.tx.txid]" [title]="replacement.tx.txid">
|
<div class="track"></div>
|
||||||
<div class="shape"></div>
|
<a class="shape-border" [class.rbf]="cell.replacement.tx.rbf" [routerLink]="['/tx/' | relativeUrl, cell.replacement.tx.txid]" [title]="cell.replacement.tx.txid">
|
||||||
</a>
|
<div class="shape"></div>
|
||||||
<span class="fee-rate">{{ replacement.tx.fee / (replacement.tx.vsize) | feeRounding }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span></span>
|
</a>
|
||||||
</div>
|
<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>
|
||||||
</ng-container>
|
</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>
|
||||||
</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
|
<!-- <app-rbf-timeline-tooltip
|
||||||
*ngIf=[tooltip]
|
*ngIf=[tooltip]
|
||||||
[line]="hoverLine"
|
[line]="hoverLine"
|
||||||
|
|
|
@ -23,7 +23,7 @@
|
||||||
background: linear-gradient(to left, #24273e, #24273e, transparent);
|
background: linear-gradient(to left, #24273e, #24273e, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline {
|
.timeline-wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: calc(100% - 2em);
|
width: calc(100% - 2em);
|
||||||
margin: auto;
|
margin: auto;
|
||||||
|
@ -44,20 +44,27 @@
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
.node, .node-spacer {
|
.node, .node-spacer, .connector {
|
||||||
width: 4em;
|
width: 6em;
|
||||||
min-width: 4em;
|
min-width: 6em;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.interval, .interval-spacer {
|
.interval, .interval-spacer {
|
||||||
width: 8em;
|
width: 8em;
|
||||||
min-width: 4em;
|
min-width: 5em;
|
||||||
max-width: 8em;
|
max-width: 8em;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.interval-time {
|
.interval-time {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
line-height: 16px;
|
||||||
|
padding: 0 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -73,7 +80,7 @@
|
||||||
background: #105fb0;
|
background: #105fb0;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
}
|
}
|
||||||
&:first-child {
|
&.first-node {
|
||||||
.track {
|
.track {
|
||||||
left: 50%;
|
left: 50%;
|
||||||
}
|
}
|
||||||
|
@ -139,5 +146,24 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,18 +1,26 @@
|
||||||
import { Component, Input, OnInit, OnChanges, Inject, LOCALE_ID } from '@angular/core';
|
import { Component, Input, OnInit, OnChanges, Inject, LOCALE_ID } from '@angular/core';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { RbfInfo } from '../../interfaces/node-api.interface';
|
import { RbfInfo, RbfTree } from '../../interfaces/node-api.interface';
|
||||||
import { StateService } from '../../services/state.service';
|
import { StateService } from '../../services/state.service';
|
||||||
import { ApiService } from '../../services/api.service';
|
import { ApiService } from '../../services/api.service';
|
||||||
|
|
||||||
|
type Connector = 'pipe' | 'corner';
|
||||||
|
|
||||||
|
interface TimelineCell {
|
||||||
|
replacement?: RbfInfo,
|
||||||
|
connector?: Connector,
|
||||||
|
first?: boolean,
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-rbf-timeline',
|
selector: 'app-rbf-timeline',
|
||||||
templateUrl: './rbf-timeline.component.html',
|
templateUrl: './rbf-timeline.component.html',
|
||||||
styleUrls: ['./rbf-timeline.component.scss'],
|
styleUrls: ['./rbf-timeline.component.scss'],
|
||||||
})
|
})
|
||||||
export class RbfTimelineComponent implements OnInit, OnChanges {
|
export class RbfTimelineComponent implements OnInit, OnChanges {
|
||||||
@Input() replacements: RbfInfo[];
|
@Input() replacements: RbfTree;
|
||||||
@Input() txid: string;
|
@Input() txid: string;
|
||||||
mined: boolean;
|
rows: TimelineCell[][] = [];
|
||||||
|
|
||||||
dir: 'rtl' | 'ltr' = 'ltr';
|
dir: 'rtl' | 'ltr' = 'ltr';
|
||||||
|
|
||||||
|
@ -28,10 +36,130 @@ export class RbfTimelineComponent implements OnInit, OnChanges {
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.mined = this.replacements.some(entry => entry.mined);
|
this.rows = this.buildTimelines(this.replacements);
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnChanges(): void {
|
ngOnChanges(): void {
|
||||||
this.mined = this.replacements.some(entry => entry.mined);
|
this.rows = this.buildTimelines(this.replacements);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -190,7 +190,7 @@
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
<ng-container *ngIf="rbfInfo?.length">
|
<ng-container *ngIf="rbfInfo">
|
||||||
<div class="title float-left">
|
<div class="title float-left">
|
||||||
<h2 id="rbf" i18n="transaction.replacements|Replacements">Replacements</h2>
|
<h2 id="rbf" i18n="transaction.replacements|Replacements">Replacements</h2>
|
||||||
</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, RbfInfo } 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';
|
||||||
|
@ -54,7 +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: RbfInfo[];
|
rbfInfo: RbfTree;
|
||||||
cpfpInfo: CpfpInfo | null;
|
cpfpInfo: CpfpInfo | null;
|
||||||
showCpfpDetails = false;
|
showCpfpDetails = false;
|
||||||
fetchCpfp$ = new Subject<string>();
|
fetchCpfp$ = new Subject<string>();
|
||||||
|
@ -188,7 +188,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||||
return of(null);
|
return of(null);
|
||||||
})
|
})
|
||||||
).subscribe((rbfResponse) => {
|
).subscribe((rbfResponse) => {
|
||||||
this.rbfInfo = rbfResponse?.replacements || [];
|
this.rbfInfo = rbfResponse?.replacements;
|
||||||
this.rbfReplaces = rbfResponse?.replaces || null;
|
this.rbfReplaces = rbfResponse?.replaces || null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -476,7 +476,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 = [];
|
this.rbfInfo = null;
|
||||||
this.rbfReplaces = [];
|
this.rbfReplaces = [];
|
||||||
this.showCpfpDetails = false;
|
this.showCpfpDetails = false;
|
||||||
document.body.scrollTo(0, 0);
|
document.body.scrollTo(0, 0);
|
||||||
|
|
|
@ -27,9 +27,15 @@ export interface CpfpInfo {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RbfInfo {
|
export interface RbfInfo {
|
||||||
tx: RbfTransaction,
|
tx: RbfTransaction;
|
||||||
time: number,
|
time: number;
|
||||||
mined?: boolean,
|
interval?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RbfTree extends RbfInfo {
|
||||||
|
mined?: boolean;
|
||||||
|
fullRbf: boolean;
|
||||||
|
replaces: RbfTree[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DifficultyAdjustment {
|
export interface DifficultyAdjustment {
|
||||||
|
@ -154,6 +160,7 @@ export interface TransactionStripped {
|
||||||
|
|
||||||
interface RbfTransaction extends TransactionStripped {
|
interface RbfTransaction extends TransactionStripped {
|
||||||
rbf?: boolean;
|
rbf?: boolean;
|
||||||
|
mined?: boolean,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RewardStats {
|
export interface RewardStats {
|
||||||
|
|
|
@ -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, RbfInfo } from './node-api.interface';
|
import { BlockExtended, DifficultyAdjustment, RbfTree } from './node-api.interface';
|
||||||
|
|
||||||
export interface WebsocketResponse {
|
export interface WebsocketResponse {
|
||||||
block?: BlockExtended;
|
block?: BlockExtended;
|
||||||
|
@ -16,8 +16,8 @@ export interface WebsocketResponse {
|
||||||
tx?: Transaction;
|
tx?: Transaction;
|
||||||
rbfTransaction?: ReplacedTransaction;
|
rbfTransaction?: ReplacedTransaction;
|
||||||
txReplaced?: ReplacedTransaction;
|
txReplaced?: ReplacedTransaction;
|
||||||
rbfInfo?: RbfInfo[];
|
rbfInfo?: RbfTree;
|
||||||
rbfLatest?: RbfInfo[][];
|
rbfLatest?: RbfTree[];
|
||||||
utxoSpent?: object;
|
utxoSpent?: object;
|
||||||
transactions?: TransactionStripped[];
|
transactions?: TransactionStripped[];
|
||||||
loadingIndicators?: ILoadingIndicators;
|
loadingIndicators?: ILoadingIndicators;
|
||||||
|
|
|
@ -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, RbfInfo } 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,16 +124,16 @@ 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<{ replacements: RbfInfo[], replaces: string[] }> {
|
getRbfHistory$(txid: string): Observable<{ replacements: RbfTree, replaces: string[] }> {
|
||||||
return this.httpClient.get<{ replacements: RbfInfo[], replaces: string[] }>(this.apiBaseUrl + this.apiBasePath + '/api/v1/tx/' + txid + '/rbf');
|
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<RbfInfo[][]> {
|
getRbfList$(fullRbf: boolean, after?: string): Observable<RbfTree[]> {
|
||||||
return this.httpClient.get<RbfInfo[][]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/' + (fullRbf ? 'fullrbf/' : '') + 'replacements/' + (after || ''));
|
return this.httpClient.get<RbfTree[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/' + (fullRbf ? 'fullrbf/' : '') + 'replacements/' + (after || ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
listLiquidPegsMonth$(): Observable<LiquidPegs[]> {
|
listLiquidPegsMonth$(): Observable<LiquidPegs[]> {
|
||||||
|
|
|
@ -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, RbfInfo } 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';
|
||||||
|
@ -98,8 +98,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<RbfInfo[]>();
|
txRbfInfo$ = new Subject<RbfTree>();
|
||||||
rbfLatest$ = new Subject<RbfInfo[][]>();
|
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>();
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { NgbCollapseModule, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstra
|
||||||
import { FontAwesomeModule, FaIconLibrary } from '@fortawesome/angular-fontawesome';
|
import { FontAwesomeModule, FaIconLibrary } from '@fortawesome/angular-fontawesome';
|
||||||
import { faFilter, faAngleDown, faAngleUp, faAngleRight, faAngleLeft, faBolt, faChartArea, faCogs, faCubes, faHammer, faDatabase, faExchangeAlt, faInfoCircle,
|
import { faFilter, faAngleDown, faAngleUp, faAngleRight, faAngleLeft, faBolt, faChartArea, faCogs, faCubes, faHammer, faDatabase, faExchangeAlt, faInfoCircle,
|
||||||
faLink, faList, faSearch, faCaretUp, faCaretDown, faTachometerAlt, faThList, faTint, faTv, faAngleDoubleDown, faSortUp, faAngleDoubleUp, faChevronDown,
|
faLink, faList, faSearch, faCaretUp, faCaretDown, faTachometerAlt, faThList, faTint, faTv, faAngleDoubleDown, faSortUp, faAngleDoubleUp, faChevronDown,
|
||||||
faFileAlt, faRedoAlt, faArrowAltCircleRight, faExternalLinkAlt, faBook, faListUl, faDownload, faQrcode, faArrowRightArrowLeft, faArrowRight, faArrowsRotate, faCircleLeft } from '@fortawesome/free-solid-svg-icons';
|
faFileAlt, faRedoAlt, faArrowAltCircleRight, faExternalLinkAlt, faBook, faListUl, faDownload, faQrcode, faArrowRightArrowLeft, faArrowsRotate, faCircleLeft } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { InfiniteScrollModule } from 'ngx-infinite-scroll';
|
import { InfiniteScrollModule } from 'ngx-infinite-scroll';
|
||||||
import { MasterPageComponent } from '../components/master-page/master-page.component';
|
import { MasterPageComponent } from '../components/master-page/master-page.component';
|
||||||
import { PreviewTitleComponent } from '../components/master-page-preview/preview-title.component';
|
import { PreviewTitleComponent } from '../components/master-page-preview/preview-title.component';
|
||||||
|
@ -315,7 +315,6 @@ export class SharedModule {
|
||||||
library.addIcons(faDownload);
|
library.addIcons(faDownload);
|
||||||
library.addIcons(faQrcode);
|
library.addIcons(faQrcode);
|
||||||
library.addIcons(faArrowRightArrowLeft);
|
library.addIcons(faArrowRightArrowLeft);
|
||||||
library.addIcons(faArrowRight);
|
|
||||||
library.addIcons(faExchangeAlt);
|
library.addIcons(faExchangeAlt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue