mirror of
https://github.com/mempool/mempool.git
synced 2025-09-27 06:27:21 +02:00
Merge pull request #5769 from mempool/mononaut/address-antidote
detect and warn about address poisoning attacks
This commit is contained in:
@@ -16,6 +16,11 @@
|
|||||||
<div class="header-bg box" [attr.data-cy]="'tx-' + i">
|
<div class="header-bg box" [attr.data-cy]="'tx-' + i">
|
||||||
|
|
||||||
<div *ngIf="errorUnblinded" class="error-unblinded">{{ errorUnblinded }}</div>
|
<div *ngIf="errorUnblinded" class="error-unblinded">{{ errorUnblinded }}</div>
|
||||||
|
@if (similarityMatches.get(tx.txid)?.size) {
|
||||||
|
<div class="alert alert-mempool" role="alert">
|
||||||
|
<span i18n="transaction.poison.warning">Warning! This transaction involves deceptively similar addresses. It may be an address poisoning attack.</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<table class="table table-fixed table-borderless smaller-text table-sm table-tx-vin">
|
<table class="table table-fixed table-borderless smaller-text table-sm table-tx-vin">
|
||||||
@@ -68,9 +73,11 @@
|
|||||||
</ng-template>
|
</ng-template>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
<ng-template #defaultAddress>
|
<ng-template #defaultAddress>
|
||||||
<a class="address" *ngIf="vin.prevout.scriptpubkey_address; else vinScriptPubkeyType" [routerLink]="['/address/' | relativeUrl, vin.prevout.scriptpubkey_address]" title="{{ vin.prevout.scriptpubkey_address }}">
|
<app-address-text
|
||||||
<app-truncate [text]="vin.prevout.scriptpubkey_address" [lastChars]="8"></app-truncate>
|
*ngIf="vin.prevout.scriptpubkey_address; else vinScriptPubkeyType"
|
||||||
</a>
|
[address]="vin.prevout.scriptpubkey_address"
|
||||||
|
[similarity]="similarityMatches.get(tx.txid)?.get(vin.prevout.scriptpubkey_address)"
|
||||||
|
></app-address-text>
|
||||||
<ng-template #vinScriptPubkeyType>
|
<ng-template #vinScriptPubkeyType>
|
||||||
{{ vin.prevout.scriptpubkey_type?.toUpperCase() }}
|
{{ vin.prevout.scriptpubkey_type?.toUpperCase() }}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
@@ -217,9 +224,11 @@
|
|||||||
'highlight': this.addresses.length && ((vout.scriptpubkey_type !== 'p2pk' && addresses.includes(vout.scriptpubkey_address)) || this.addresses.includes(vout.scriptpubkey.slice(2, -2)))
|
'highlight': this.addresses.length && ((vout.scriptpubkey_type !== 'p2pk' && addresses.includes(vout.scriptpubkey_address)) || this.addresses.includes(vout.scriptpubkey.slice(2, -2)))
|
||||||
}">
|
}">
|
||||||
<td class="address-cell">
|
<td class="address-cell">
|
||||||
<a class="address" *ngIf="vout.scriptpubkey_address; else pubkey_type" [routerLink]="['/address/' | relativeUrl, vout.scriptpubkey_address]" title="{{ vout.scriptpubkey_address }}">
|
<app-address-text
|
||||||
<app-truncate [text]="vout.scriptpubkey_address" [lastChars]="8"></app-truncate>
|
*ngIf="vout.scriptpubkey_address; else pubkey_type"
|
||||||
</a>
|
[address]="vout.scriptpubkey_address"
|
||||||
|
[similarity]="similarityMatches.get(tx.txid)?.get(vout.scriptpubkey_address)"
|
||||||
|
></app-address-text>
|
||||||
<ng-template #pubkey_type>
|
<ng-template #pubkey_type>
|
||||||
<ng-container *ngIf="vout.scriptpubkey_type === 'p2pk'; else scriptpubkey_type">
|
<ng-container *ngIf="vout.scriptpubkey_type === 'p2pk'; else scriptpubkey_type">
|
||||||
P2PK <a class="address p2pk-address" [routerLink]="['/address/' | relativeUrl, vout.scriptpubkey.slice(2, -2)]" title="{{ vout.scriptpubkey.slice(2, -2) }}">
|
P2PK <a class="address p2pk-address" [routerLink]="['/address/' | relativeUrl, vout.scriptpubkey.slice(2, -2)]" title="{{ vout.scriptpubkey.slice(2, -2) }}">
|
||||||
|
@@ -14,6 +14,7 @@ import { StorageService } from '@app/services/storage.service';
|
|||||||
import { OrdApiService } from '@app/services/ord-api.service';
|
import { OrdApiService } from '@app/services/ord-api.service';
|
||||||
import { Inscription } from '@app/shared/ord/inscription.utils';
|
import { Inscription } from '@app/shared/ord/inscription.utils';
|
||||||
import { Etching, Runestone } from '@app/shared/ord/rune.utils';
|
import { Etching, Runestone } from '@app/shared/ord/rune.utils';
|
||||||
|
import { ADDRESS_SIMILARITY_THRESHOLD, AddressMatch, AddressSimilarity, AddressType, AddressTypeInfo, checkedCompareAddressStrings, detectAddressType } from '@app/shared/address-utils';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-transactions-list',
|
selector: 'app-transactions-list',
|
||||||
@@ -55,6 +56,7 @@ export class TransactionsListComponent implements OnInit, OnChanges {
|
|||||||
showFullScript: { [vinIndex: number]: boolean } = {};
|
showFullScript: { [vinIndex: number]: boolean } = {};
|
||||||
showFullWitness: { [vinIndex: number]: { [witnessIndex: number]: boolean } } = {};
|
showFullWitness: { [vinIndex: number]: { [witnessIndex: number]: boolean } } = {};
|
||||||
showOrdData: { [key: string]: { show: boolean; inscriptions?: Inscription[]; runestone?: Runestone, runeInfo?: { [id: string]: { etching: Etching; txid: string; } }; } } = {};
|
showOrdData: { [key: string]: { show: boolean; inscriptions?: Inscription[]; runestone?: Runestone, runeInfo?: { [id: string]: { etching: Etching; txid: string; } }; } } = {};
|
||||||
|
similarityMatches: Map<string, Map<string, { score: number, match: AddressMatch, group: number }>> = new Map();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public stateService: StateService,
|
public stateService: StateService,
|
||||||
@@ -144,6 +146,8 @@ export class TransactionsListComponent implements OnInit, OnChanges {
|
|||||||
this.currency = currency;
|
this.currency = currency;
|
||||||
this.refreshPrice();
|
this.refreshPrice();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.updateAddressSimilarities();
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshPrice(): void {
|
refreshPrice(): void {
|
||||||
@@ -183,6 +187,8 @@ export class TransactionsListComponent implements OnInit, OnChanges {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (changes.transactions || changes.addresses) {
|
if (changes.transactions || changes.addresses) {
|
||||||
|
this.similarityMatches.clear();
|
||||||
|
this.updateAddressSimilarities();
|
||||||
if (!this.transactions || !this.transactions.length) {
|
if (!this.transactions || !this.transactions.length) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -296,6 +302,56 @@ export class TransactionsListComponent implements OnInit, OnChanges {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateAddressSimilarities(): void {
|
||||||
|
if (!this.transactions || !this.transactions.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const tx of this.transactions) {
|
||||||
|
if (this.similarityMatches.get(tx.txid)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const similarityGroups: Map<string, number> = new Map();
|
||||||
|
let lastGroup = 0;
|
||||||
|
|
||||||
|
// Check for address poisoning similarity matches
|
||||||
|
this.similarityMatches.set(tx.txid, new Map());
|
||||||
|
const comparableVouts = [
|
||||||
|
...tx.vout.slice(0, 20),
|
||||||
|
...this.addresses.map(addr => ({ scriptpubkey_address: addr, scriptpubkey_type: detectAddressType(addr, this.stateService.network) }))
|
||||||
|
].filter(v => ['p2pkh', 'p2sh', 'v0_p2wpkh', 'v0_p2wsh', 'v1_p2tr'].includes(v.scriptpubkey_type));
|
||||||
|
const comparableVins = tx.vin.slice(0, 20).map(v => v.prevout).filter(v => ['p2pkh', 'p2sh', 'v0_p2wpkh', 'v0_p2wsh', 'v1_p2tr'].includes(v?.scriptpubkey_type));
|
||||||
|
for (const vout of comparableVouts) {
|
||||||
|
const address = vout.scriptpubkey_address;
|
||||||
|
const addressType = vout.scriptpubkey_type;
|
||||||
|
if (this.similarityMatches.get(tx.txid)?.has(address)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const compareAddr of [
|
||||||
|
...comparableVouts.filter(v => v.scriptpubkey_type === addressType && v.scriptpubkey_address !== address),
|
||||||
|
...comparableVins.filter(v => v.scriptpubkey_type === addressType && v.scriptpubkey_address !== address)
|
||||||
|
]) {
|
||||||
|
const similarity = checkedCompareAddressStrings(address, compareAddr.scriptpubkey_address, addressType as AddressType, this.stateService.network);
|
||||||
|
if (similarity?.status === 'comparable' && similarity.score > ADDRESS_SIMILARITY_THRESHOLD) {
|
||||||
|
let group = similarityGroups.get(address) || lastGroup++;
|
||||||
|
similarityGroups.set(address, group);
|
||||||
|
const bestVout = this.similarityMatches.get(tx.txid)?.get(address);
|
||||||
|
if (!bestVout || bestVout.score < similarity.score) {
|
||||||
|
this.similarityMatches.get(tx.txid)?.set(address, { score: similarity.score, match: similarity.left, group });
|
||||||
|
}
|
||||||
|
// opportunistically update the entry for the compared address
|
||||||
|
const bestCompare = this.similarityMatches.get(tx.txid)?.get(compareAddr.scriptpubkey_address);
|
||||||
|
if (!bestCompare || bestCompare.score < similarity.score) {
|
||||||
|
group = similarityGroups.get(compareAddr.scriptpubkey_address) || lastGroup++;
|
||||||
|
similarityGroups.set(compareAddr.scriptpubkey_address, group);
|
||||||
|
this.similarityMatches.get(tx.txid)?.set(compareAddr.scriptpubkey_address, { score: similarity.score, match: similarity.right, group });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onScroll(): void {
|
onScroll(): void {
|
||||||
this.loadMore.emit();
|
this.loadMore.emit();
|
||||||
}
|
}
|
||||||
|
@@ -78,6 +78,7 @@ const p2trRegex = RegExp('^p' + BECH32_CHARS_LW + '{58}$');
|
|||||||
const pubkeyRegex = RegExp('^' + `(04${HEX_CHARS}{128})|(0[23]${HEX_CHARS}{64})$`);
|
const pubkeyRegex = RegExp('^' + `(04${HEX_CHARS}{128})|(0[23]${HEX_CHARS}{64})$`);
|
||||||
|
|
||||||
export function detectAddressType(address: string, network: string): AddressType {
|
export function detectAddressType(address: string, network: string): AddressType {
|
||||||
|
network = network || 'mainnet';
|
||||||
// normal address types
|
// normal address types
|
||||||
const firstChar = address.substring(0, 1);
|
const firstChar = address.substring(0, 1);
|
||||||
if (ADDRESS_PREFIXES[network].base58.pubkey.includes(firstChar) && base58Regex.test(address.slice(1))) {
|
if (ADDRESS_PREFIXES[network].base58.pubkey.includes(firstChar) && base58Regex.test(address.slice(1))) {
|
||||||
@@ -211,6 +212,18 @@ export class AddressTypeInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public compareTo(other: AddressTypeInfo): AddressSimilarityResult {
|
||||||
|
return compareAddresses(this.address, other.address, this.network);
|
||||||
|
}
|
||||||
|
|
||||||
|
public compareToString(other: string): AddressSimilarityResult {
|
||||||
|
if (other === this.address) {
|
||||||
|
return { status: 'identical' };
|
||||||
|
}
|
||||||
|
const otherInfo = new AddressTypeInfo(this.network, other);
|
||||||
|
return this.compareTo(otherInfo);
|
||||||
|
}
|
||||||
|
|
||||||
private processScript(script: ScriptInfo): void {
|
private processScript(script: ScriptInfo): void {
|
||||||
this.scripts.set(script.key, script);
|
this.scripts.set(script.key, script);
|
||||||
if (script.template?.type === 'multisig') {
|
if (script.template?.type === 'multisig') {
|
||||||
@@ -218,3 +231,135 @@ export class AddressTypeInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AddressMatch {
|
||||||
|
prefix: string;
|
||||||
|
postfix: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddressSimilarity {
|
||||||
|
status: 'comparable';
|
||||||
|
score: number;
|
||||||
|
left: AddressMatch;
|
||||||
|
right: AddressMatch;
|
||||||
|
}
|
||||||
|
export type AddressSimilarityResult =
|
||||||
|
| { status: 'identical' }
|
||||||
|
| { status: 'incomparable' }
|
||||||
|
| AddressSimilarity;
|
||||||
|
|
||||||
|
export const ADDRESS_SIMILARITY_THRESHOLD = 10_000_000; // 1 false positive per ~10 million comparisons
|
||||||
|
|
||||||
|
function fuzzyPrefixMatch(a: string, b: string, rtl: boolean = false): { score: number, matchA: string, matchB: string } {
|
||||||
|
let score = 0;
|
||||||
|
let gap = false;
|
||||||
|
let done = false;
|
||||||
|
|
||||||
|
let ai = 0;
|
||||||
|
let bi = 0;
|
||||||
|
let prefixA = '';
|
||||||
|
let prefixB = '';
|
||||||
|
if (rtl) {
|
||||||
|
a = a.split('').reverse().join('');
|
||||||
|
b = b.split('').reverse().join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
while (ai < a.length && bi < b.length && !done) {
|
||||||
|
if (a[ai] === b[bi]) {
|
||||||
|
// matching characters
|
||||||
|
prefixA += a[ai];
|
||||||
|
prefixB += b[bi];
|
||||||
|
score++;
|
||||||
|
ai++;
|
||||||
|
bi++;
|
||||||
|
} else if (!gap) {
|
||||||
|
// try looking ahead in both strings to find the best match
|
||||||
|
const nextMatchA = (ai + 1 < a.length && a[ai + 1] === b[bi]);
|
||||||
|
const nextMatchB = (bi + 1 < b.length && a[ai] === b[bi + 1]);
|
||||||
|
const nextMatchBoth = (ai + 1 < a.length && bi + 1 < b.length && a[ai + 1] === b[bi + 1]);
|
||||||
|
if (nextMatchBoth) {
|
||||||
|
// single differing character
|
||||||
|
prefixA += a[ai];
|
||||||
|
prefixB += b[bi];
|
||||||
|
ai++;
|
||||||
|
bi++;
|
||||||
|
} else if (nextMatchA) {
|
||||||
|
// character missing in b
|
||||||
|
prefixA += a[ai];
|
||||||
|
ai++;
|
||||||
|
} else if (nextMatchB) {
|
||||||
|
// character missing in a
|
||||||
|
prefixB += b[bi];
|
||||||
|
bi++;
|
||||||
|
} else {
|
||||||
|
ai++;
|
||||||
|
bi++;
|
||||||
|
}
|
||||||
|
gap = true;
|
||||||
|
} else {
|
||||||
|
done = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rtl) {
|
||||||
|
prefixA = prefixA.split('').reverse().join('');
|
||||||
|
prefixB = prefixB.split('').reverse().join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { score, matchA: prefixA, matchB: prefixB };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compareAddressInfo(a: AddressTypeInfo, b: AddressTypeInfo): AddressSimilarityResult {
|
||||||
|
if (a.address === b.address) {
|
||||||
|
return { status: 'identical' };
|
||||||
|
}
|
||||||
|
if (a.type !== b.type) {
|
||||||
|
return { status: 'incomparable' };
|
||||||
|
}
|
||||||
|
if (!['p2pkh', 'p2sh', 'p2sh-p2wpkh', 'p2sh-p2wsh', 'v0_p2wpkh', 'v0_p2wsh', 'v1_p2tr'].includes(a.type)) {
|
||||||
|
return { status: 'incomparable' };
|
||||||
|
}
|
||||||
|
const isBase58 = a.type === 'p2pkh' || a.type === 'p2sh';
|
||||||
|
|
||||||
|
const left = fuzzyPrefixMatch(a.address, b.address);
|
||||||
|
const right = fuzzyPrefixMatch(a.address, b.address, true);
|
||||||
|
// depending on address type, some number of matching prefix characters are guaranteed
|
||||||
|
const prefixScore = isBase58 ? 1 : ADDRESS_PREFIXES[a.network || 'mainnet'].bech32.length;
|
||||||
|
|
||||||
|
// add the two scores together
|
||||||
|
const totalScore = left.score + right.score - prefixScore;
|
||||||
|
|
||||||
|
// adjust for the size of the alphabet (58 vs 32)
|
||||||
|
const normalizedScore = Math.pow(isBase58 ? 58 : 32, totalScore);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'comparable',
|
||||||
|
score: normalizedScore,
|
||||||
|
left: {
|
||||||
|
prefix: left.matchA,
|
||||||
|
postfix: right.matchA,
|
||||||
|
},
|
||||||
|
right: {
|
||||||
|
prefix: left.matchB,
|
||||||
|
postfix: right.matchB,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compareAddresses(a: string, b: string, network: string): AddressSimilarityResult {
|
||||||
|
if (a === b) {
|
||||||
|
return { status: 'identical' };
|
||||||
|
}
|
||||||
|
const aInfo = new AddressTypeInfo(network, a);
|
||||||
|
return aInfo.compareToString(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
// avoids the overhead of creating AddressTypeInfo objects for each address,
|
||||||
|
// but a and b *MUST* be valid normalized addresses, of the same valid type
|
||||||
|
export function checkedCompareAddressStrings(a: string, b: string, type: AddressType, network: string): AddressSimilarityResult {
|
||||||
|
return compareAddressInfo(
|
||||||
|
{ address: a, type: type, network: network } as AddressTypeInfo,
|
||||||
|
{ address: b, type: type, network: network } as AddressTypeInfo,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@@ -0,0 +1,17 @@
|
|||||||
|
|
||||||
|
@if (similarity) {
|
||||||
|
<div class="address-text">
|
||||||
|
<a class="address" style="display: contents;" [routerLink]="['/address/' | relativeUrl, address]" title="{{ address }}">
|
||||||
|
<span class="prefix">{{ similarity.match.prefix }}</span>
|
||||||
|
<span class="infix" [ngStyle]="{'text-decoration-color': groupColors[similarity.group % (groupColors.length)]}">{{ address.slice(similarity.match.prefix.length || 0, -similarity.match.postfix.length || undefined) }}</span>
|
||||||
|
<span class="postfix"> {{ similarity.match.postfix }}</span>
|
||||||
|
</a>
|
||||||
|
<span class="poison-alert" *ngIf="similarity" i18n-ngbTooltip="address-poisoning.warning-tooltip" ngbTooltip="This address is deceptively similar to another output. It may be part of an address poisoning attack.">
|
||||||
|
<fa-icon [icon]="['fas', 'exclamation-triangle']" [fixedWidth]="true"></fa-icon>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<a class="address" [routerLink]="['/address/' | relativeUrl, address]" title="{{ address }}">
|
||||||
|
<app-truncate [text]="address" [lastChars]="8"></app-truncate>
|
||||||
|
</a>
|
||||||
|
}
|
@@ -0,0 +1,32 @@
|
|||||||
|
.address-text {
|
||||||
|
text-overflow: unset;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: start;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
font-family: monospace;
|
||||||
|
|
||||||
|
.infix {
|
||||||
|
flex-grow: 0;
|
||||||
|
flex-shrink: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
text-decoration: underline 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prefix, .postfix {
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-grow: 0;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
text-decoration: underline var(--red) 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.poison-alert {
|
||||||
|
margin-left: .5em;
|
||||||
|
color: var(--yellow);
|
||||||
|
}
|
@@ -0,0 +1,20 @@
|
|||||||
|
import { Component, Input } from '@angular/core';
|
||||||
|
import { AddressMatch, AddressTypeInfo } from '@app/shared/address-utils';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-address-text',
|
||||||
|
templateUrl: './address-text.component.html',
|
||||||
|
styleUrls: ['./address-text.component.scss']
|
||||||
|
})
|
||||||
|
export class AddressTextComponent {
|
||||||
|
@Input() address: string;
|
||||||
|
@Input() info: AddressTypeInfo | null;
|
||||||
|
@Input() similarity: { score: number, match: AddressMatch, group: number } | null;
|
||||||
|
|
||||||
|
groupColors: string[] = [
|
||||||
|
'var(--primary)',
|
||||||
|
'var(--success)',
|
||||||
|
'var(--info)',
|
||||||
|
'white',
|
||||||
|
];
|
||||||
|
}
|
@@ -7,7 +7,7 @@ import { faFilter, faAngleDown, faAngleUp, faAngleRight, faAngleLeft, faBolt, fa
|
|||||||
faFileAlt, faRedoAlt, faArrowAltCircleRight, faExternalLinkAlt, faBook, faListUl, faDownload, faQrcode, faArrowRightArrowLeft, faArrowsRotate, faCircleLeft,
|
faFileAlt, faRedoAlt, faArrowAltCircleRight, faExternalLinkAlt, faBook, faListUl, faDownload, faQrcode, faArrowRightArrowLeft, faArrowsRotate, faCircleLeft,
|
||||||
faFastForward, faWallet, faUserClock, faWrench, faUserFriends, faQuestionCircle, faHistory, faSignOutAlt, faKey, faSuitcase, faIdCardAlt, faNetworkWired, faUserCheck,
|
faFastForward, faWallet, faUserClock, faWrench, faUserFriends, faQuestionCircle, faHistory, faSignOutAlt, faKey, faSuitcase, faIdCardAlt, faNetworkWired, faUserCheck,
|
||||||
faCircleCheck, faUserCircle, faCheck, faRocket, faScaleBalanced, faHourglassStart, faHourglassHalf, faHourglassEnd, faWandMagicSparkles, faFaucetDrip, faTimeline,
|
faCircleCheck, faUserCircle, faCheck, faRocket, faScaleBalanced, faHourglassStart, faHourglassHalf, faHourglassEnd, faWandMagicSparkles, faFaucetDrip, faTimeline,
|
||||||
faCircleXmark, faCalendarCheck, faMoneyBillTrendUp, faRobot, faShareNodes, faCreditCard, faMicroscope } from '@fortawesome/free-solid-svg-icons';
|
faCircleXmark, faCalendarCheck, faMoneyBillTrendUp, faRobot, faShareNodes, faCreditCard, faMicroscope, faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { InfiniteScrollModule } from 'ngx-infinite-scroll';
|
import { InfiniteScrollModule } from 'ngx-infinite-scroll';
|
||||||
import { MenuComponent } from '@components/menu/menu.component';
|
import { MenuComponent } from '@components/menu/menu.component';
|
||||||
import { PreviewTitleComponent } from '@components/master-page-preview/preview-title.component';
|
import { PreviewTitleComponent } from '@components/master-page-preview/preview-title.component';
|
||||||
@@ -94,6 +94,7 @@ import { SatsComponent } from '@app/shared/components/sats/sats.component';
|
|||||||
import { BtcComponent } from '@app/shared/components/btc/btc.component';
|
import { BtcComponent } from '@app/shared/components/btc/btc.component';
|
||||||
import { FeeRateComponent } from '@app/shared/components/fee-rate/fee-rate.component';
|
import { FeeRateComponent } from '@app/shared/components/fee-rate/fee-rate.component';
|
||||||
import { AddressTypeComponent } from '@app/shared/components/address-type/address-type.component';
|
import { AddressTypeComponent } from '@app/shared/components/address-type/address-type.component';
|
||||||
|
import { AddressTextComponent } from '@app/shared/components/address-text/address-text.component';
|
||||||
import { TruncateComponent } from '@app/shared/components/truncate/truncate.component';
|
import { TruncateComponent } from '@app/shared/components/truncate/truncate.component';
|
||||||
import { SearchResultsComponent } from '@components/search-form/search-results/search-results.component';
|
import { SearchResultsComponent } from '@components/search-form/search-results/search-results.component';
|
||||||
import { TimestampComponent } from '@app/shared/components/timestamp/timestamp.component';
|
import { TimestampComponent } from '@app/shared/components/timestamp/timestamp.component';
|
||||||
@@ -214,6 +215,7 @@ import { GithubLogin } from '../components/github-login.component/github-login.c
|
|||||||
BtcComponent,
|
BtcComponent,
|
||||||
FeeRateComponent,
|
FeeRateComponent,
|
||||||
AddressTypeComponent,
|
AddressTypeComponent,
|
||||||
|
AddressTextComponent,
|
||||||
TruncateComponent,
|
TruncateComponent,
|
||||||
SearchResultsComponent,
|
SearchResultsComponent,
|
||||||
TimestampComponent,
|
TimestampComponent,
|
||||||
@@ -360,6 +362,7 @@ import { GithubLogin } from '../components/github-login.component/github-login.c
|
|||||||
BtcComponent,
|
BtcComponent,
|
||||||
FeeRateComponent,
|
FeeRateComponent,
|
||||||
AddressTypeComponent,
|
AddressTypeComponent,
|
||||||
|
AddressTextComponent,
|
||||||
TruncateComponent,
|
TruncateComponent,
|
||||||
SearchResultsComponent,
|
SearchResultsComponent,
|
||||||
TimestampComponent,
|
TimestampComponent,
|
||||||
@@ -465,5 +468,6 @@ export class SharedModule {
|
|||||||
library.addIcons(faShareNodes);
|
library.addIcons(faShareNodes);
|
||||||
library.addIcons(faCreditCard);
|
library.addIcons(faCreditCard);
|
||||||
library.addIcons(faMicroscope);
|
library.addIcons(faMicroscope);
|
||||||
|
library.addIcons(faExclamationTriangle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user