mirror of
https://github.com/mempool/mempool.git
synced 2025-03-03 09:39:17 +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) {
|
||||
try {
|
||||
const replacements = rbfCache.getRbfChain(req.params.txId) || [];
|
||||
const replacements = rbfCache.getRbfTree(req.params.txId) || null;
|
||||
const replaces = rbfCache.getReplaces(req.params.txId) || null;
|
||||
res.json({
|
||||
replacements,
|
||||
|
@ -657,7 +657,7 @@ class BitcoinRoutes {
|
|||
|
||||
private async getRbfReplacements(req: Request, res: Response) {
|
||||
try {
|
||||
const result = rbfCache.getRbfChains(false);
|
||||
const result = rbfCache.getRbfTrees(false);
|
||||
res.json(result);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
|
@ -666,7 +666,7 @@ class BitcoinRoutes {
|
|||
|
||||
private async getFullRbfReplacements(req: Request, res: Response) {
|
||||
try {
|
||||
const result = rbfCache.getRbfChains(true);
|
||||
const result = rbfCache.getRbfTrees(true);
|
||||
res.json(result);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
|
|
|
@ -57,11 +57,11 @@ export class Common {
|
|||
return arr;
|
||||
}
|
||||
|
||||
static findRbfTransactions(added: TransactionExtended[], deleted: TransactionExtended[]): { [txid: string]: TransactionExtended } {
|
||||
const matches: { [txid: string]: TransactionExtended } = {};
|
||||
deleted
|
||||
.forEach((deletedTx) => {
|
||||
const foundMatches = added.find((addedTx) => {
|
||||
static findRbfTransactions(added: TransactionExtended[], deleted: TransactionExtended[]): { [txid: string]: TransactionExtended[] } {
|
||||
const matches: { [txid: string]: TransactionExtended[] } = {};
|
||||
added
|
||||
.forEach((addedTx) => {
|
||||
const foundMatches = deleted.filter((deletedTx) => {
|
||||
// The new tx must, absolutely speaking, pay at least as much fee as the replaced tx.
|
||||
return addedTx.fee > deletedTx.fee
|
||||
// The new transaction must pay more fee per kB than the replaced tx.
|
||||
|
@ -70,8 +70,8 @@ export class Common {
|
|||
&& deletedTx.vin.some((deletedVin) =>
|
||||
addedTx.vin.some((vin) => vin.txid === deletedVin.txid && vin.vout === deletedVin.vout));
|
||||
});
|
||||
if (foundMatches) {
|
||||
matches[deletedTx.txid] = foundMatches;
|
||||
if (foundMatches?.length) {
|
||||
matches[addedTx.txid] = foundMatches;
|
||||
}
|
||||
});
|
||||
return matches;
|
||||
|
|
|
@ -265,13 +265,15 @@ class Mempool {
|
|||
}
|
||||
}
|
||||
|
||||
public handleRbfTransactions(rbfTransactions: { [txid: string]: TransactionExtended; }) {
|
||||
public handleRbfTransactions(rbfTransactions: { [txid: string]: TransactionExtended[]; }): void {
|
||||
for (const rbfTransaction in rbfTransactions) {
|
||||
if (this.mempoolCache[rbfTransaction]) {
|
||||
if (this.mempoolCache[rbfTransaction] && rbfTransactions[rbfTransaction]?.length) {
|
||||
// Store replaced transactions
|
||||
rbfCache.add(this.mempoolCache[rbfTransaction], rbfTransactions[rbfTransaction]);
|
||||
rbfCache.add(rbfTransactions[rbfTransaction], this.mempoolCache[rbfTransaction]);
|
||||
// Erase the replaced transactions from the local mempool
|
||||
delete this.mempoolCache[rbfTransaction];
|
||||
for (const replaced of rbfTransactions[rbfTransaction]) {
|
||||
delete this.mempoolCache[replaced.txid];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,22 +1,27 @@
|
|||
import { runInNewContext } from "vm";
|
||||
import { TransactionExtended, TransactionStripped } from "../mempool.interfaces";
|
||||
import { Common } from "./common";
|
||||
|
||||
interface RbfTransaction extends TransactionStripped {
|
||||
rbf?: boolean;
|
||||
mined?: boolean;
|
||||
}
|
||||
|
||||
type RbfChain = {
|
||||
tx: RbfTransaction,
|
||||
time: number,
|
||||
mined?: boolean,
|
||||
}[];
|
||||
interface RbfTree {
|
||||
tx: RbfTransaction;
|
||||
time: number;
|
||||
interval?: number;
|
||||
mined?: boolean;
|
||||
fullRbf: boolean;
|
||||
replaces: RbfTree[];
|
||||
}
|
||||
|
||||
class RbfCache {
|
||||
private replacedBy: Map<string, string> = new Map();
|
||||
private replaces: Map<string, string[]> = new Map();
|
||||
private rbfChains: Map<string, RbfChain> = new Map(); // sequences of consecutive replacements
|
||||
private dirtyChains: Set<string> = new Set();
|
||||
private chainMap: Map<string, string> = new Map(); // map of txids to sequence ids
|
||||
private rbfTrees: Map<string, RbfTree> = new Map(); // sequences of consecutive replacements
|
||||
private dirtyTrees: Set<string> = new Set();
|
||||
private treeMap: Map<string, string> = new Map(); // map of txids to sequence ids
|
||||
private txs: Map<string, TransactionExtended> = new Map();
|
||||
private expiring: Map<string, Date> = new Map();
|
||||
|
||||
|
@ -24,37 +29,58 @@ class RbfCache {
|
|||
setInterval(this.cleanup.bind(this), 1000 * 60 * 60);
|
||||
}
|
||||
|
||||
public add(replacedTxExtended: TransactionExtended, newTxExtended: TransactionExtended): void {
|
||||
const replacedTx = Common.stripTransaction(replacedTxExtended) as RbfTransaction;
|
||||
replacedTx.rbf = replacedTxExtended.vin.some((v) => v.sequence < 0xfffffffe);
|
||||
public add(replaced: TransactionExtended[], newTxExtended: TransactionExtended): void {
|
||||
if (!newTxExtended || !replaced?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newTx = Common.stripTransaction(newTxExtended) as RbfTransaction;
|
||||
const newTime = newTxExtended.firstSeen || Date.now();
|
||||
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);
|
||||
if (!this.replaces.has(newTx.txid)) {
|
||||
this.replaces.set(newTx.txid, []);
|
||||
}
|
||||
this.replaces.get(newTx.txid)?.push(replacedTx.txid);
|
||||
|
||||
// maintain rbf chains
|
||||
if (this.chainMap.has(replacedTx.txid)) {
|
||||
// add to an existing chain
|
||||
const chainRoot = this.chainMap.get(replacedTx.txid) || '';
|
||||
this.rbfChains.get(chainRoot)?.push({ tx: newTx, time: newTxExtended.firstSeen || Date.now() });
|
||||
this.chainMap.set(newTx.txid, chainRoot);
|
||||
this.dirtyChains.add(chainRoot);
|
||||
} else {
|
||||
// start a new chain
|
||||
this.rbfChains.set(replacedTx.txid, [
|
||||
{ tx: replacedTx, time: replacedTxExtended.firstSeen || Date.now() },
|
||||
{ tx: newTx, time: newTxExtended.firstSeen || Date.now() },
|
||||
]);
|
||||
this.chainMap.set(replacedTx.txid, replacedTx.txid);
|
||||
this.chainMap.set(newTx.txid, replacedTx.txid);
|
||||
this.dirtyChains.add(replacedTx.txid);
|
||||
// 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 {
|
||||
|
@ -69,66 +95,64 @@ class RbfCache {
|
|||
return this.txs.get(txId);
|
||||
}
|
||||
|
||||
public getRbfChain(txId: string): RbfChain {
|
||||
return this.rbfChains.get(this.chainMap.get(txId) || '') || [];
|
||||
public getRbfTree(txId: string): RbfTree | void {
|
||||
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
|
||||
public getRbfChains(onlyFullRbf: boolean, after?: string): RbfChain[] {
|
||||
public getRbfTrees(onlyFullRbf: boolean, after?: string): RbfTree[] {
|
||||
const limit = 25;
|
||||
const chains: RbfChain[] = [];
|
||||
const trees: RbfTree[] = [];
|
||||
const used = new Set<string>();
|
||||
const replacements: string[][] = Array.from(this.replacedBy).reverse();
|
||||
const afterChain = after ? this.chainMap.get(after) : null;
|
||||
let ready = !afterChain;
|
||||
for (let i = 0; i < replacements.length && chains.length <= limit - 1; i++) {
|
||||
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 chainRoot = this.chainMap.get(txid) || '';
|
||||
if (chainRoot === afterChain) {
|
||||
const treeId = this.treeMap.get(txid) || '';
|
||||
if (treeId === afterTree) {
|
||||
ready = true;
|
||||
} else if (ready) {
|
||||
if (!used.has(chainRoot)) {
|
||||
const chain = this.rbfChains.get(chainRoot);
|
||||
used.add(chainRoot);
|
||||
if (chain && (!onlyFullRbf || chain.slice(0, -1).some(entry => !entry.tx.rbf))) {
|
||||
chains.push(chain);
|
||||
if (!used.has(treeId)) {
|
||||
const tree = this.rbfTrees.get(treeId);
|
||||
used.add(treeId);
|
||||
if (tree && (!onlyFullRbf || tree.fullRbf)) {
|
||||
trees.push(tree);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return chains;
|
||||
return trees;
|
||||
}
|
||||
|
||||
// get map of rbf chains that have been updated since the last call
|
||||
public getRbfChanges(): { chains: {[root: string]: RbfChain }, map: { [txid: string]: string }} {
|
||||
const changes: { chains: {[root: string]: RbfChain }, map: { [txid: string]: string }} = {
|
||||
chains: {},
|
||||
// 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.dirtyChains.forEach(root => {
|
||||
const chain = this.rbfChains.get(root);
|
||||
if (chain) {
|
||||
changes.chains[root] = chain;
|
||||
chain.forEach(entry => {
|
||||
changes.map[entry.tx.txid] = root;
|
||||
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.dirtyChains = new Set();
|
||||
this.dirtyTrees = new Set();
|
||||
return changes;
|
||||
}
|
||||
|
||||
public mined(txid): void {
|
||||
const chainRoot = this.chainMap.get(txid)
|
||||
if (chainRoot && this.rbfChains.has(chainRoot)) {
|
||||
const chain = this.rbfChains.get(chainRoot);
|
||||
if (chain) {
|
||||
const chainEntry = chain.find(entry => entry.tx.txid === txid);
|
||||
if (chainEntry) {
|
||||
chainEntry.mined = true;
|
||||
}
|
||||
this.dirtyChains.add(chainRoot);
|
||||
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);
|
||||
|
@ -155,20 +179,45 @@ class RbfCache {
|
|||
if (!this.replacedBy.has(txid)) {
|
||||
const replaces = this.replaces.get(txid);
|
||||
this.replaces.delete(txid);
|
||||
this.chainMap.delete(txid);
|
||||
this.treeMap.delete(txid);
|
||||
this.txs.delete(txid);
|
||||
this.expiring.delete(txid);
|
||||
for (const tx of (replaces || [])) {
|
||||
// recursively remove prior versions from the cache
|
||||
this.replacedBy.delete(tx);
|
||||
// if this is the root of a chain, remove that too
|
||||
if (this.chainMap.get(tx) === tx) {
|
||||
this.rbfChains.delete(tx);
|
||||
// if this is the id of a tree, remove that too
|
||||
if (this.treeMap.get(tx) === tx) {
|
||||
this.rbfTrees.delete(tx);
|
||||
}
|
||||
this.remove(tx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private updateTreeMap(newId: string, tree: RbfTree): void {
|
||||
this.treeMap.set(tree.tx.txid, newId);
|
||||
tree.replaces.forEach(subtree => {
|
||||
this.updateTreeMap(newId, subtree);
|
||||
});
|
||||
}
|
||||
|
||||
private getTransactionsInTree(tree: RbfTree, txs: RbfTransaction[] = []): RbfTransaction[] {
|
||||
txs.push(tree.tx);
|
||||
tree.replaces.forEach(subtree => {
|
||||
this.getTransactionsInTree(subtree, txs);
|
||||
});
|
||||
return txs;
|
||||
}
|
||||
|
||||
private setTreeMined(tree: RbfTree, txid: string): void {
|
||||
if (tree.tx.txid === txid) {
|
||||
tree.tx.mined = true;
|
||||
} else {
|
||||
tree.replaces.forEach(subtree => {
|
||||
this.setTreeMined(subtree, txid);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new RbfCache();
|
||||
|
|
|
@ -289,9 +289,9 @@ class WebsocketHandler {
|
|||
const rbfChanges = rbfCache.getRbfChanges();
|
||||
let rbfReplacements;
|
||||
let fullRbfReplacements;
|
||||
if (Object.keys(rbfChanges.chains).length) {
|
||||
rbfReplacements = rbfCache.getRbfChains(false);
|
||||
fullRbfReplacements = rbfCache.getRbfChains(true);
|
||||
if (Object.keys(rbfChanges.trees).length) {
|
||||
rbfReplacements = rbfCache.getRbfTrees(false);
|
||||
fullRbfReplacements = rbfCache.getRbfTrees(true);
|
||||
}
|
||||
const recommendedFees = feeApi.getRecommendedFee();
|
||||
|
||||
|
@ -415,20 +415,16 @@ class WebsocketHandler {
|
|||
response['utxoSpent'] = outspends;
|
||||
}
|
||||
|
||||
if (rbfTransactions[client['track-tx']]) {
|
||||
for (const rbfTransaction in rbfTransactions) {
|
||||
if (client['track-tx'] === rbfTransaction) {
|
||||
response['rbfTransaction'] = {
|
||||
txid: rbfTransactions[rbfTransaction].txid,
|
||||
};
|
||||
break;
|
||||
}
|
||||
const rbfReplacedBy = rbfCache.getReplacedBy(client['track-tx']);
|
||||
if (rbfReplacedBy) {
|
||||
response['rbfTransaction'] = {
|
||||
txid: rbfReplacedBy,
|
||||
}
|
||||
}
|
||||
|
||||
const rbfChange = rbfChanges.map[client['track-tx']];
|
||||
if (rbfChange) {
|
||||
response['rbfInfo'] = rbfChanges.chains[rbfChange];
|
||||
response['rbfInfo'] = rbfChanges.trees[rbfChange];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -17,37 +17,22 @@
|
|||
|
||||
<div class="clearfix"></div>
|
||||
|
||||
<div class="rbf-chains" style="min-height: 295px">
|
||||
<ng-container *ngIf="rbfChains$ | async as chains">
|
||||
<div *ngFor="let chain of chains" class="chain">
|
||||
<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">
|
||||
<app-time kind="since" [time]="chain[chain.length - 1].time"></app-time>
|
||||
<span class="type">
|
||||
<span *ngIf="isMined(chain)" 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="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="txids">
|
||||
<span class="txid">
|
||||
<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 class="timeline-wrapper" [class.mined]="isMined(tree)">
|
||||
<app-rbf-timeline [replacements]="tree"></app-rbf-timeline>
|
||||
</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>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
|
|
@ -4,13 +4,14 @@
|
|||
margin-top: 13px;
|
||||
}
|
||||
|
||||
.rbf-chains {
|
||||
.rbf-trees {
|
||||
.info {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin: 0;
|
||||
margin-bottom: 0.5em;
|
||||
|
||||
.type {
|
||||
.badge {
|
||||
|
@ -19,27 +20,10 @@
|
|||
}
|
||||
}
|
||||
|
||||
.chain {
|
||||
.tree {
|
||||
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 {
|
||||
border: solid 4px #1a9436;
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@ import { ActivatedRoute, Router } from '@angular/router';
|
|||
import { BehaviorSubject, EMPTY, merge, Observable, Subscription } from 'rxjs';
|
||||
import { catchError, switchMap, tap } from 'rxjs/operators';
|
||||
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 { StateService } from '../../services/state.service';
|
||||
|
||||
|
@ -14,14 +14,12 @@ import { StateService } from '../../services/state.service';
|
|||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class RbfList implements OnInit, OnDestroy {
|
||||
rbfChains$: Observable<RbfInfo[][]>;
|
||||
fromChainSubject = new BehaviorSubject(null);
|
||||
rbfTrees$: Observable<RbfTree[]>;
|
||||
nextRbfSubject = new BehaviorSubject(null);
|
||||
urlFragmentSubscription: Subscription;
|
||||
fullRbfEnabled: boolean;
|
||||
fullRbf: boolean;
|
||||
isLoading = true;
|
||||
firstChainId: string;
|
||||
lastChainId: string;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
|
@ -37,13 +35,13 @@ export class RbfList implements OnInit, OnDestroy {
|
|||
this.urlFragmentSubscription = this.route.fragment.subscribe((fragment) => {
|
||||
this.fullRbf = (fragment === 'fullrbf');
|
||||
this.websocketService.startTrackRbf(this.fullRbf ? 'fullRbf' : 'all');
|
||||
this.fromChainSubject.next(this.firstChainId);
|
||||
this.nextRbfSubject.next(null);
|
||||
});
|
||||
|
||||
this.rbfChains$ = merge(
|
||||
this.fromChainSubject.pipe(
|
||||
switchMap((fromChainId) => {
|
||||
return this.apiService.getRbfList$(this.fullRbf, fromChainId || undefined)
|
||||
this.rbfTrees$ = merge(
|
||||
this.nextRbfSubject.pipe(
|
||||
switchMap(() => {
|
||||
return this.apiService.getRbfList$(this.fullRbf);
|
||||
}),
|
||||
catchError((e) => {
|
||||
return EMPTY;
|
||||
|
@ -52,11 +50,8 @@ export class RbfList implements OnInit, OnDestroy {
|
|||
this.stateService.rbfLatest$
|
||||
)
|
||||
.pipe(
|
||||
tap((result: RbfInfo[][]) => {
|
||||
tap(() => {
|
||||
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 {
|
||||
return chain.slice(0, -1).some(entry => !entry.tx.rbf);
|
||||
isFullRbf(tree: RbfTree): boolean {
|
||||
return tree.fullRbf;
|
||||
}
|
||||
|
||||
isMined(chain: RbfInfo[]): boolean {
|
||||
return chain.some(entry => entry.mined);
|
||||
isMined(tree: RbfTree): boolean {
|
||||
return tree.mined;
|
||||
}
|
||||
|
||||
// pageChange(page: number) {
|
||||
// this.fromChainSubject.next(this.lastChainId);
|
||||
// this.fromTreeSubject.next(this.lastTreeId);
|
||||
// }
|
||||
|
||||
ngOnDestroy(): void {
|
||||
|
|
|
@ -1,31 +1,54 @@
|
|||
<div class="rbf-timeline box" [class.mined]="mined">
|
||||
<div class="timeline">
|
||||
<div class="intervals">
|
||||
<ng-container *ngFor="let replacement of replacements; let i = index;">
|
||||
<div class="interval" *ngIf="i > 0">
|
||||
<div class="interval-time">
|
||||
<app-time [time]="replacement.time - replacements[i-1].time" [relative]="false"></app-time>
|
||||
</div>
|
||||
</div>
|
||||
<div class="node-spacer"></div>
|
||||
</ng-container>
|
||||
</div>
|
||||
<div class="nodes">
|
||||
<ng-container *ngFor="let replacement of replacements; let i = index;">
|
||||
<div class="interval-spacer" *ngIf="i > 0">
|
||||
<div class="track"></div>
|
||||
</div>
|
||||
<div class="node" [class.selected]="txid === replacement.tx.txid" [class.mined]="replacement.mined">
|
||||
<div class="track"></div>
|
||||
<a class="shape-border" [class.rbf]="replacement.tx.rbf" [routerLink]="['/tx/' | relativeUrl, replacement.tx.txid]" [title]="replacement.tx.txid">
|
||||
<div class="shape"></div>
|
||||
</a>
|
||||
<span class="fee-rate">{{ replacement.tx.fee / (replacement.tx.vsize) | feeRounding }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span></span>
|
||||
</div>
|
||||
</ng-container>
|
||||
<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" [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]" [title]="cell.replacement.tx.txid">
|
||||
<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
|
||||
*ngIf=[tooltip]
|
||||
[line]="hoverLine"
|
||||
|
|
|
@ -23,7 +23,7 @@
|
|||
background: linear-gradient(to left, #24273e, #24273e, transparent);
|
||||
}
|
||||
|
||||
.timeline {
|
||||
.timeline-wrapper {
|
||||
position: relative;
|
||||
width: calc(100% - 2em);
|
||||
margin: auto;
|
||||
|
@ -44,20 +44,27 @@
|
|||
align-items: flex-start;
|
||||
text-align: center;
|
||||
|
||||
.node, .node-spacer {
|
||||
width: 4em;
|
||||
min-width: 4em;
|
||||
.node, .node-spacer, .connector {
|
||||
width: 6em;
|
||||
min-width: 6em;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.interval, .interval-spacer {
|
||||
width: 8em;
|
||||
min-width: 4em;
|
||||
min-width: 5em;
|
||||
max-width: 8em;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.interval-time {
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -73,7 +80,7 @@
|
|||
background: #105fb0;
|
||||
border-radius: 5px;
|
||||
}
|
||||
&:first-child {
|
||||
&.first-node {
|
||||
.track {
|
||||
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 { 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 { 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: RbfInfo[];
|
||||
@Input() replacements: RbfTree;
|
||||
@Input() txid: string;
|
||||
mined: boolean;
|
||||
rows: TimelineCell[][] = [];
|
||||
|
||||
dir: 'rtl' | 'ltr' = 'ltr';
|
||||
|
||||
|
@ -28,10 +36,130 @@ export class RbfTimelineComponent implements OnInit, OnChanges {
|
|||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.mined = this.replacements.some(entry => entry.mined);
|
||||
this.rows = this.buildTimelines(this.replacements);
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<ng-container *ngIf="rbfInfo?.length">
|
||||
<ng-container *ngIf="rbfInfo">
|
||||
<div class="title float-left">
|
||||
<h2 id="rbf" i18n="transaction.replacements|Replacements">Replacements</h2>
|
||||
</div>
|
||||
|
|
|
@ -19,7 +19,7 @@ import { WebsocketService } from '../../services/websocket.service';
|
|||
import { AudioService } from '../../services/audio.service';
|
||||
import { ApiService } from '../../services/api.service';
|
||||
import { SeoService } from '../../services/seo.service';
|
||||
import { BlockExtended, CpfpInfo, RbfInfo } from '../../interfaces/node-api.interface';
|
||||
import { BlockExtended, CpfpInfo, RbfTree } from '../../interfaces/node-api.interface';
|
||||
import { LiquidUnblinding } from './liquid-ublinding';
|
||||
import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe';
|
||||
import { Price, PriceService } from '../../services/price.service';
|
||||
|
@ -54,7 +54,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
rbfTransaction: undefined | Transaction;
|
||||
replaced: boolean = false;
|
||||
rbfReplaces: string[];
|
||||
rbfInfo: RbfInfo[];
|
||||
rbfInfo: RbfTree;
|
||||
cpfpInfo: CpfpInfo | null;
|
||||
showCpfpDetails = false;
|
||||
fetchCpfp$ = new Subject<string>();
|
||||
|
@ -188,7 +188,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
return of(null);
|
||||
})
|
||||
).subscribe((rbfResponse) => {
|
||||
this.rbfInfo = rbfResponse?.replacements || [];
|
||||
this.rbfInfo = rbfResponse?.replacements;
|
||||
this.rbfReplaces = rbfResponse?.replaces || null;
|
||||
});
|
||||
|
||||
|
@ -476,7 +476,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
this.replaced = false;
|
||||
this.transactionTime = -1;
|
||||
this.cpfpInfo = null;
|
||||
this.rbfInfo = [];
|
||||
this.rbfInfo = null;
|
||||
this.rbfReplaces = [];
|
||||
this.showCpfpDetails = false;
|
||||
document.body.scrollTo(0, 0);
|
||||
|
|
|
@ -27,9 +27,15 @@ export interface CpfpInfo {
|
|||
}
|
||||
|
||||
export interface RbfInfo {
|
||||
tx: RbfTransaction,
|
||||
time: number,
|
||||
mined?: boolean,
|
||||
tx: RbfTransaction;
|
||||
time: number;
|
||||
interval?: number;
|
||||
}
|
||||
|
||||
export interface RbfTree extends RbfInfo {
|
||||
mined?: boolean;
|
||||
fullRbf: boolean;
|
||||
replaces: RbfTree[];
|
||||
}
|
||||
|
||||
export interface DifficultyAdjustment {
|
||||
|
@ -154,6 +160,7 @@ export interface TransactionStripped {
|
|||
|
||||
interface RbfTransaction extends TransactionStripped {
|
||||
rbf?: boolean;
|
||||
mined?: boolean,
|
||||
}
|
||||
|
||||
export interface RewardStats {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { ILoadingIndicators } from '../services/state.service';
|
||||
import { Transaction } from './electrs.interface';
|
||||
import { BlockExtended, DifficultyAdjustment, RbfInfo } from './node-api.interface';
|
||||
import { BlockExtended, DifficultyAdjustment, RbfTree } from './node-api.interface';
|
||||
|
||||
export interface WebsocketResponse {
|
||||
block?: BlockExtended;
|
||||
|
@ -16,8 +16,8 @@ export interface WebsocketResponse {
|
|||
tx?: Transaction;
|
||||
rbfTransaction?: ReplacedTransaction;
|
||||
txReplaced?: ReplacedTransaction;
|
||||
rbfInfo?: RbfInfo[];
|
||||
rbfLatest?: RbfInfo[][];
|
||||
rbfInfo?: RbfTree;
|
||||
rbfLatest?: RbfTree[];
|
||||
utxoSpent?: object;
|
||||
transactions?: TransactionStripped[];
|
||||
loadingIndicators?: ILoadingIndicators;
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http';
|
||||
import { CpfpInfo, OptimizedMempoolStats, AddressInformation, LiquidPegs, ITranslators,
|
||||
PoolStat, BlockExtended, TransactionStripped, RewardStats, AuditScore, BlockSizesAndWeights, RbfInfo } from '../interfaces/node-api.interface';
|
||||
PoolStat, BlockExtended, TransactionStripped, RewardStats, AuditScore, BlockSizesAndWeights, RbfTree } from '../interfaces/node-api.interface';
|
||||
import { Observable } from 'rxjs';
|
||||
import { StateService } from './state.service';
|
||||
import { WebsocketResponse } from '../interfaces/websocket.interface';
|
||||
|
@ -124,16 +124,16 @@ export class ApiService {
|
|||
return this.httpClient.get<AddressInformation>(this.apiBaseUrl + this.apiBasePath + '/api/v1/validate-address/' + address);
|
||||
}
|
||||
|
||||
getRbfHistory$(txid: string): Observable<{ replacements: RbfInfo[], replaces: string[] }> {
|
||||
return this.httpClient.get<{ replacements: RbfInfo[], replaces: string[] }>(this.apiBaseUrl + this.apiBasePath + '/api/v1/tx/' + txid + '/rbf');
|
||||
getRbfHistory$(txid: string): Observable<{ replacements: RbfTree, replaces: string[] }> {
|
||||
return this.httpClient.get<{ replacements: RbfTree, replaces: string[] }>(this.apiBaseUrl + this.apiBasePath + '/api/v1/tx/' + txid + '/rbf');
|
||||
}
|
||||
|
||||
getRbfCachedTx$(txid: string): Observable<Transaction> {
|
||||
return this.httpClient.get<Transaction>(this.apiBaseUrl + this.apiBasePath + '/api/v1/tx/' + txid + '/cached');
|
||||
}
|
||||
|
||||
getRbfList$(fullRbf: boolean, after?: string): Observable<RbfInfo[][]> {
|
||||
return this.httpClient.get<RbfInfo[][]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/' + (fullRbf ? 'fullrbf/' : '') + 'replacements/' + (after || ''));
|
||||
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[]> {
|
||||
|
|
|
@ -2,7 +2,7 @@ import { Inject, Injectable, PLATFORM_ID, LOCALE_ID } from '@angular/core';
|
|||
import { ReplaySubject, BehaviorSubject, Subject, fromEvent, Observable } from 'rxjs';
|
||||
import { Transaction } from '../interfaces/electrs.interface';
|
||||
import { IBackendInfo, MempoolBlock, MempoolBlockWithTransactions, MempoolBlockDelta, MempoolInfo, Recommendedfees, ReplacedTransaction, TransactionStripped } from '../interfaces/websocket.interface';
|
||||
import { BlockExtended, DifficultyAdjustment, OptimizedMempoolStats, RbfInfo } from '../interfaces/node-api.interface';
|
||||
import { BlockExtended, DifficultyAdjustment, OptimizedMempoolStats, RbfTree } from '../interfaces/node-api.interface';
|
||||
import { Router, NavigationStart } from '@angular/router';
|
||||
import { isPlatformBrowser } from '@angular/common';
|
||||
import { map, shareReplay } from 'rxjs/operators';
|
||||
|
@ -98,8 +98,8 @@ export class StateService {
|
|||
mempoolBlockTransactions$ = new Subject<TransactionStripped[]>();
|
||||
mempoolBlockDelta$ = new Subject<MempoolBlockDelta>();
|
||||
txReplaced$ = new Subject<ReplacedTransaction>();
|
||||
txRbfInfo$ = new Subject<RbfInfo[]>();
|
||||
rbfLatest$ = new Subject<RbfInfo[][]>();
|
||||
txRbfInfo$ = new Subject<RbfTree>();
|
||||
rbfLatest$ = new Subject<RbfTree[]>();
|
||||
utxoSpent$ = new Subject<object>();
|
||||
difficultyAdjustment$ = new ReplaySubject<DifficultyAdjustment>(1);
|
||||
mempoolTransactions$ = new Subject<Transaction>();
|
||||
|
|
|
@ -4,7 +4,7 @@ import { NgbCollapseModule, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstra
|
|||
import { FontAwesomeModule, FaIconLibrary } from '@fortawesome/angular-fontawesome';
|
||||
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,
|
||||
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 { MasterPageComponent } from '../components/master-page/master-page.component';
|
||||
import { PreviewTitleComponent } from '../components/master-page-preview/preview-title.component';
|
||||
|
@ -315,7 +315,6 @@ export class SharedModule {
|
|||
library.addIcons(faDownload);
|
||||
library.addIcons(faQrcode);
|
||||
library.addIcons(faArrowRightArrowLeft);
|
||||
library.addIcons(faArrowRight);
|
||||
library.addIcons(faExchangeAlt);
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue