Merge pull request #4543 from natsee/cross-network-address-search

Search bar feature: network based regex and address matches
This commit is contained in:
softsimon 2024-01-14 18:38:50 +07:00 committed by GitHub
commit 73d4ea75de
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 426 additions and 37 deletions

View file

@ -2,13 +2,14 @@ import { Component, OnInit, ChangeDetectionStrategy, EventEmitter, Output, ViewC
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { EventType, NavigationStart, Router } from '@angular/router'; import { EventType, NavigationStart, Router } from '@angular/router';
import { AssetsService } from '../../services/assets.service'; import { AssetsService } from '../../services/assets.service';
import { StateService } from '../../services/state.service'; import { Env, StateService } from '../../services/state.service';
import { Observable, of, Subject, zip, BehaviorSubject, combineLatest } from 'rxjs'; import { Observable, of, Subject, zip, BehaviorSubject, combineLatest } from 'rxjs';
import { debounceTime, distinctUntilChanged, switchMap, catchError, map, startWith, tap } from 'rxjs/operators'; import { debounceTime, distinctUntilChanged, switchMap, catchError, map, startWith, tap } from 'rxjs/operators';
import { ElectrsApiService } from '../../services/electrs-api.service'; import { ElectrsApiService } from '../../services/electrs-api.service';
import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe';
import { ApiService } from '../../services/api.service'; import { ApiService } from '../../services/api.service';
import { SearchResultsComponent } from './search-results/search-results.component'; import { SearchResultsComponent } from './search-results/search-results.component';
import { Network, findOtherNetworks, getRegex, getTargetUrl, needBaseModuleChange } from '../../shared/regex.utils';
@Component({ @Component({
selector: 'app-search-form', selector: 'app-search-form',
@ -18,7 +19,7 @@ import { SearchResultsComponent } from './search-results/search-results.componen
}) })
export class SearchFormComponent implements OnInit { export class SearchFormComponent implements OnInit {
@Input() hamburgerOpen = false; @Input() hamburgerOpen = false;
env: Env;
network = ''; network = '';
assets: object = {}; assets: object = {};
isSearching = false; isSearching = false;
@ -36,12 +37,13 @@ export class SearchFormComponent implements OnInit {
} }
} }
regexAddress = /^([a-km-zA-HJ-NP-Z1-9]{26,35}|[a-km-zA-HJ-NP-Z1-9]{80}|[A-z]{2,5}1[a-zA-HJ-NP-Z0-9]{39,59}|04[a-fA-F0-9]{128}|(02|03)[a-fA-F0-9]{64})$/; regexAddress = getRegex('address', 'mainnet'); // Default to mainnet
regexBlockhash = /^[0]{8}[a-fA-F0-9]{56}$/; regexBlockhash = getRegex('blockhash', 'mainnet');
regexTransaction = /^([a-fA-F0-9]{64})(:\d+)?$/; regexTransaction = getRegex('transaction');
regexBlockheight = /^[0-9]{1,9}$/; regexBlockheight = getRegex('blockheight');
regexDate = /^(?:\d{4}[-/]\d{1,2}[-/]\d{1,2}(?: \d{1,2}:\d{2})?)$/; regexDate = getRegex('date');
regexUnixTimestamp = /^\d{10}$/; regexUnixTimestamp = getRegex('timestamp');
focus$ = new Subject<string>(); focus$ = new Subject<string>();
click$ = new Subject<string>(); click$ = new Subject<string>();
@ -66,7 +68,13 @@ export class SearchFormComponent implements OnInit {
} }
ngOnInit(): void { ngOnInit(): void {
this.stateService.networkChanged$.subscribe((network) => this.network = network); this.env = this.stateService.env;
this.stateService.networkChanged$.subscribe((network) => {
this.network = network;
// TODO: Eventually change network type here from string to enum of consts
this.regexAddress = getRegex('address', network as any || 'mainnet');
this.regexBlockhash = getRegex('blockhash', network as any || 'mainnet');
});
this.router.events.subscribe((e: NavigationStart) => { // Reset search focus when changing page this.router.events.subscribe((e: NavigationStart) => { // Reset search focus when changing page
if (this.searchInput && e.type === EventType.NavigationStart) { if (this.searchInput && e.type === EventType.NavigationStart) {
@ -96,9 +104,6 @@ export class SearchFormComponent implements OnInit {
const searchText$ = this.searchForm.get('searchText').valueChanges const searchText$ = this.searchForm.get('searchText').valueChanges
.pipe( .pipe(
map((text) => { map((text) => {
if (this.network === 'bisq' && text.match(/^(b)[^c]/i)) {
return text.substr(1);
}
return text.trim(); return text.trim();
}), }),
tap((text) => { tap((text) => {
@ -132,9 +137,6 @@ export class SearchFormComponent implements OnInit {
); );
}), }),
map((result: any[]) => { map((result: any[]) => {
if (this.network === 'bisq') {
result[0] = result[0].map((address: string) => 'B' + address);
}
return result; return result;
}), }),
tap(() => { tap(() => {
@ -164,6 +166,7 @@ export class SearchFormComponent implements OnInit {
blockHeight: false, blockHeight: false,
txId: false, txId: false,
address: false, address: false,
otherNetworks: [],
addresses: [], addresses: [],
nodes: [], nodes: [],
channels: [], channels: [],
@ -174,15 +177,21 @@ export class SearchFormComponent implements OnInit {
const addressPrefixSearchResults = result[0]; const addressPrefixSearchResults = result[0];
const lightningResults = result[1]; const lightningResults = result[1];
// Do not show date and timestamp results for liquid and bisq
const isNetworkBitcoin = this.network === '' || this.network === 'testnet' || this.network === 'signet';
const matchesBlockHeight = this.regexBlockheight.test(searchText) && parseInt(searchText) <= this.stateService.latestBlockHeight; const matchesBlockHeight = this.regexBlockheight.test(searchText) && parseInt(searchText) <= this.stateService.latestBlockHeight;
const matchesDateTime = this.regexDate.test(searchText) && new Date(searchText).toString() !== 'Invalid Date'; const matchesDateTime = this.regexDate.test(searchText) && new Date(searchText).toString() !== 'Invalid Date' && new Date(searchText).getTime() <= Date.now() && isNetworkBitcoin;
const matchesUnixTimestamp = this.regexUnixTimestamp.test(searchText); const matchesUnixTimestamp = this.regexUnixTimestamp.test(searchText) && parseInt(searchText) <= Math.floor(Date.now() / 1000) && isNetworkBitcoin;
const matchesTxId = this.regexTransaction.test(searchText) && !this.regexBlockhash.test(searchText); const matchesTxId = this.regexTransaction.test(searchText) && !this.regexBlockhash.test(searchText);
const matchesBlockHash = this.regexBlockhash.test(searchText); const matchesBlockHash = this.regexBlockhash.test(searchText);
const matchesAddress = !matchesTxId && this.regexAddress.test(searchText); let matchesAddress = !matchesTxId && this.regexAddress.test(searchText);
const otherNetworks = findOtherNetworks(searchText, this.network as any || 'mainnet', this.env);
if (matchesAddress && this.network === 'bisq') { // Add B prefix to addresses in Bisq network
searchText = 'B' + searchText; if (!matchesAddress && this.network === 'bisq' && getRegex('address', 'mainnet').test(searchText)) {
searchText = 'B' + searchText;
matchesAddress = !matchesTxId && this.regexAddress.test(searchText);
} }
if (matchesDateTime && searchText.indexOf('/') !== -1) { if (matchesDateTime && searchText.indexOf('/') !== -1) {
@ -198,7 +207,8 @@ export class SearchFormComponent implements OnInit {
txId: matchesTxId, txId: matchesTxId,
blockHash: matchesBlockHash, blockHash: matchesBlockHash,
address: matchesAddress, address: matchesAddress,
addresses: addressPrefixSearchResults, addresses: matchesAddress && addressPrefixSearchResults.length === 1 && searchText === addressPrefixSearchResults[0] ? [] : addressPrefixSearchResults, // If there is only one address and it matches the search text, don't show it in the dropdown
otherNetworks: otherNetworks,
nodes: lightningResults.nodes, nodes: lightningResults.nodes,
channels: lightningResults.channels, channels: lightningResults.channels,
}; };
@ -223,6 +233,15 @@ export class SearchFormComponent implements OnInit {
this.navigate('/lightning/node/', result.public_key); this.navigate('/lightning/node/', result.public_key);
} else if (result.short_id) { } else if (result.short_id) {
this.navigate('/lightning/channel/', result.id); this.navigate('/lightning/channel/', result.id);
} else if (result.network) {
if (result.isNetworkAvailable) {
this.navigate('/address/', result.address, undefined, result.network);
} else {
this.searchForm.setValue({
searchText: '',
});
this.isSearching = false;
}
} }
} }
@ -230,6 +249,7 @@ export class SearchFormComponent implements OnInit {
const searchText = result || this.searchForm.value.searchText.trim(); const searchText = result || this.searchForm.value.searchText.trim();
if (searchText) { if (searchText) {
this.isSearching = true; this.isSearching = true;
if (!this.regexTransaction.test(searchText) && this.regexAddress.test(searchText)) { if (!this.regexTransaction.test(searchText) && this.regexAddress.test(searchText)) {
this.navigate('/address/', searchText); this.navigate('/address/', searchText);
} else if (this.regexBlockhash.test(searchText)) { } else if (this.regexBlockhash.test(searchText)) {
@ -258,6 +278,11 @@ export class SearchFormComponent implements OnInit {
} else if (this.regexDate.test(searchText) || this.regexUnixTimestamp.test(searchText)) { } else if (this.regexDate.test(searchText) || this.regexUnixTimestamp.test(searchText)) {
let timestamp: number; let timestamp: number;
this.regexDate.test(searchText) ? timestamp = Math.floor(new Date(searchText).getTime() / 1000) : timestamp = searchText; this.regexDate.test(searchText) ? timestamp = Math.floor(new Date(searchText).getTime() / 1000) : timestamp = searchText;
// Check if timestamp is too far in the future or before the genesis block
if (timestamp > Math.floor(Date.now() / 1000)) {
this.isSearching = false;
return;
}
this.apiService.getBlockDataFromTimestamp$(timestamp).subscribe( this.apiService.getBlockDataFromTimestamp$(timestamp).subscribe(
(data) => { this.navigate('/block/', data.hash); }, (data) => { this.navigate('/block/', data.hash); },
(error) => { console.log(error); this.isSearching = false; } (error) => { console.log(error); this.isSearching = false; }
@ -269,12 +294,17 @@ export class SearchFormComponent implements OnInit {
} }
} }
navigate(url: string, searchText: string, extras?: any): void {
this.router.navigate([this.relativeUrlPipe.transform(url), searchText], extras); navigate(url: string, searchText: string, extras?: any, swapNetwork?: string) {
this.searchTriggered.emit(); if (needBaseModuleChange(this.env.BASE_MODULE as 'liquid' | 'bisq' | 'mempool', swapNetwork as Network)) {
this.searchForm.setValue({ window.location.href = getTargetUrl(swapNetwork as Network, searchText, this.env);
searchText: '', } else {
}); this.router.navigate([this.relativeUrlPipe.transform(url, swapNetwork), searchText], extras);
this.isSearching = false; this.searchTriggered.emit();
this.searchForm.setValue({
searchText: '',
});
this.isSearching = false;
}
} }
} }

View file

@ -1,4 +1,4 @@
<div class="dropdown-menu show" *ngIf="results" [hidden]="!results.hashQuickMatch && !results.addresses.length && !results.nodes.length && !results.channels.length"> <div class="dropdown-menu show" *ngIf="results" [hidden]="!results.hashQuickMatch && !results.otherNetworks.length && !results.addresses.length && !results.nodes.length && !results.channels.length">
<ng-template [ngIf]="results.blockHeight"> <ng-template [ngIf]="results.blockHeight">
<div class="card-title" i18n="search.bitcoin-block-height">Bitcoin Block Height</div> <div class="card-title" i18n="search.bitcoin-block-height">Bitcoin Block Height</div>
<button (click)="clickItem(0)" [class.active]="0 === activeIdx" type="button" role="option" class="dropdown-item"> <button (click)="clickItem(0)" [class.active]="0 === activeIdx" type="button" role="option" class="dropdown-item">
@ -35,10 +35,18 @@
<ng-container *ngTemplateOutlet="goTo; context: { $implicit: results.searchText | shortenString : 13 }"></ng-container> <ng-container *ngTemplateOutlet="goTo; context: { $implicit: results.searchText | shortenString : 13 }"></ng-container>
</button> </button>
</ng-template> </ng-template>
<ng-template [ngIf]="results.addresses.length && !(results.address && results.addresses.length === 1 && results.searchText === results.addresses[0])"> <ng-template [ngIf]="results.otherNetworks.length">
<div class="card-title danger" i18n="search.other-networks">Other Network Address</div>
<ng-template ngFor [ngForOf]="results.otherNetworks" let-otherNetwork let-i="index">
<button (click)="clickItem(results.hashQuickMatch + i)" [class.active]="(results.hashQuickMatch + i) === activeIdx" [class.inactive]="!otherNetwork.isNetworkAvailable" type="button" role="option" class="dropdown-item">
<ng-container *ngTemplateOutlet="goTo; context: { $implicit: otherNetwork.address| shortenString : isMobile ? 20 : 25 }"></ng-container>&nbsp;<b>({{ otherNetwork.network.charAt(0).toUpperCase() + otherNetwork.network.slice(1) }})</b>
</button>
</ng-template>
</ng-template>
<ng-template [ngIf]="results.addresses.length">
<div class="card-title" i18n="search.bitcoin-addresses">Bitcoin Addresses</div> <div class="card-title" i18n="search.bitcoin-addresses">Bitcoin Addresses</div>
<ng-template ngFor [ngForOf]="results.addresses" let-address let-i="index"> <ng-template ngFor [ngForOf]="results.addresses" let-address let-i="index">
<button (click)="clickItem(results.hashQuickMatch + i)" [class.active]="(results.hashQuickMatch + i) === activeIdx" type="button" role="option" class="dropdown-item"> <button (click)="clickItem(results.hashQuickMatch + results.otherNetworks.length + i)" [class.active]="(results.hashQuickMatch + results.otherNetworks.length + i) === activeIdx" type="button" role="option" class="dropdown-item">
<ngb-highlight [result]="address | shortenString : isMobile ? 25 : 36" [term]="results.searchText"></ngb-highlight> <ngb-highlight [result]="address | shortenString : isMobile ? 25 : 36" [term]="results.searchText"></ngb-highlight>
</button> </button>
</ng-template> </ng-template>
@ -46,7 +54,7 @@
<ng-template [ngIf]="results.nodes.length"> <ng-template [ngIf]="results.nodes.length">
<div class="card-title" i18n="search.lightning-nodes">Lightning Nodes</div> <div class="card-title" i18n="search.lightning-nodes">Lightning Nodes</div>
<ng-template ngFor [ngForOf]="results.nodes" let-node let-i="index"> <ng-template ngFor [ngForOf]="results.nodes" let-node let-i="index">
<button (click)="clickItem(results.hashQuickMatch + results.addresses.length + i)" [class.inactive]="node.status === 0" [class.active]="results.hashQuickMatch + results.addresses.length + i === activeIdx" [routerLink]="['/lightning/node' | relativeUrl, node.public_key]" type="button" role="option" class="dropdown-item"> <button (click)="clickItem(results.hashQuickMatch + results.otherNetworks.length + results.addresses.length + i)" [class.inactive]="node.status === 0" [class.active]="results.hashQuickMatch + results.otherNetworks.length + results.addresses.length + i === activeIdx" [routerLink]="['/lightning/node' | relativeUrl, node.public_key]" type="button" role="option" class="dropdown-item">
<ngb-highlight [result]="node.alias" [term]="results.searchText"></ngb-highlight> &nbsp;<span class="symbol">{{ node.public_key | shortenString : 10 }}</span> <ngb-highlight [result]="node.alias" [term]="results.searchText"></ngb-highlight> &nbsp;<span class="symbol">{{ node.public_key | shortenString : 10 }}</span>
</button> </button>
</ng-template> </ng-template>
@ -54,7 +62,7 @@
<ng-template [ngIf]="results.channels.length"> <ng-template [ngIf]="results.channels.length">
<div class="card-title" i18n="search.lightning-channels">Lightning Channels</div> <div class="card-title" i18n="search.lightning-channels">Lightning Channels</div>
<ng-template ngFor [ngForOf]="results.channels" let-channel let-i="index"> <ng-template ngFor [ngForOf]="results.channels" let-channel let-i="index">
<button (click)="clickItem(results.hashQuickMatch + results.addresses.length + results.nodes.length + i)" [class.inactive]="channel.status === 2" [class.active]="results.hashQuickMatch + results.addresses.length + results.nodes.length + i === activeIdx" type="button" role="option" class="dropdown-item"> <button (click)="clickItem(results.hashQuickMatch + results.otherNetworks.length + results.addresses.length + results.nodes.length + i)" [class.inactive]="channel.status === 2" [class.active]="results.hashQuickMatch + results.otherNetworks.length + results.addresses.length + results.nodes.length + i === activeIdx" type="button" role="option" class="dropdown-item">
<ngb-highlight [result]="channel.short_id" [term]="results.searchText"></ngb-highlight> &nbsp;<span class="symbol">{{ channel.id }}</span> <ngb-highlight [result]="channel.short_id" [term]="results.searchText"></ngb-highlight> &nbsp;<span class="symbol">{{ channel.id }}</span>
</button> </button>
</ng-template> </ng-template>

View file

@ -7,6 +7,10 @@
margin-left: 10px; margin-left: 10px;
} }
.danger {
color: #dc3545;
}
.dropdown-menu { .dropdown-menu {
position: absolute; position: absolute;
top: 42px; top: 42px;

View file

@ -22,7 +22,7 @@ export class SearchResultsComponent implements OnChanges {
ngOnChanges() { ngOnChanges() {
this.activeIdx = 0; this.activeIdx = 0;
if (this.results) { if (this.results) {
this.resultsFlattened = [...(this.results.hashQuickMatch ? [this.results.searchText] : []), ...this.results.addresses, ...this.results.nodes, ...this.results.channels]; this.resultsFlattened = [...(this.results.hashQuickMatch ? [this.results.searchText] : []), ...this.results.otherNetworks, ...this.results.addresses, ...this.results.nodes, ...this.results.channels];
} }
} }
@ -45,6 +45,9 @@ export class SearchResultsComponent implements OnChanges {
break; break;
case 'Enter': case 'Enter':
event.preventDefault(); event.preventDefault();
if (this.resultsFlattened[this.activeIdx]?.isNetworkAvailable === false) {
return;
}
if (this.resultsFlattened[this.activeIdx]) { if (this.resultsFlattened[this.activeIdx]) {
this.selectedResult.emit(this.resultsFlattened[this.activeIdx]); this.selectedResult.emit(this.resultsFlattened[this.activeIdx]);
} else { } else {

View file

@ -10,8 +10,9 @@ export class RelativeUrlPipe implements PipeTransform {
private stateService: StateService, private stateService: StateService,
) { } ) { }
transform(value: string): string { transform(value: string, swapNetwork?: string): string {
let network = this.stateService.network; let network = swapNetwork || this.stateService.network;
if (network === 'mainnet') network = '';
if (this.stateService.env.BASE_MODULE === 'liquid' && network === 'liquidtestnet') { if (this.stateService.env.BASE_MODULE === 'liquid' && network === 'liquidtestnet') {
network = 'testnet'; network = 'testnet';
} else if (this.stateService.env.BASE_MODULE !== 'mempool') { } else if (this.stateService.env.BASE_MODULE !== 'mempool') {

View file

@ -0,0 +1,343 @@
import { Env } from '../services/state.service';
// all base58 characters
const BASE58_CHARS = `[a-km-zA-HJ-NP-Z1-9]`;
// all bech32 characters (after the separator)
const BECH32_CHARS_LW = `[ac-hj-np-z02-9]`;
const BECH32_CHARS_UP = `[AC-HJ-NP-Z02-9]`;
// Hex characters
const HEX_CHARS = `[a-fA-F0-9]`;
// A regex to say "A single 0 OR any number with no leading zeroes"
// Capped at 9 digits so as to not be confused with lightning channel IDs (which are around 17 digits)
// (?: // Start a non-capturing group
// 0 // A single 0
// | // OR
// [1-9][0-9]{0,8} // Any succession of numbers up to 9 digits starting with 1-9
// ) // End the non-capturing group.
const ZERO_INDEX_NUMBER_CHARS = `(?:0|[1-9][0-9]{0,8})`;
// Simple digits only regex
const NUMBER_CHARS = `[0-9]`;
// Formatting of the address regex is for readability,
// We should ignore formatting it with automated formatting tools like prettier.
//
// prettier-ignore
const ADDRESS_CHARS: {
[k in Network]: {
base58: string;
bech32: string;
};
} = {
mainnet: {
base58: `[13]` // Starts with a single 1 or 3
+ BASE58_CHARS
+ `{26,33}`, // Repeat the previous char 26-33 times.
// Version byte 0x00 (P2PKH) can be as short as 27 characters, up to 34 length
// P2SH must be 34 length
bech32: `(?:`
+ `bc1` // Starts with bc1
+ BECH32_CHARS_LW
+ `{20,100}` // As per bech32, 6 char checksum is minimum
+ `|`
+ `BC1` // All upper case version
+ BECH32_CHARS_UP
+ `{20,100}`
+ `)`,
},
testnet: {
base58: `[mn2]` // Starts with a single m, n, or 2 (P2PKH is m or n, 2 is P2SH)
+ BASE58_CHARS
+ `{33,34}`, // m|n is 34 length, 2 is 35 length (We match the first letter separately)
bech32: `(?:`
+ `tb1` // Starts with tb1
+ BECH32_CHARS_LW
+ `{20,100}` // As per bech32, 6 char checksum is minimum
+ `|`
+ `TB1` // All upper case version
+ BECH32_CHARS_UP
+ `{20,100}`
+ `)`,
},
signet: {
base58: `[mn2]`
+ BASE58_CHARS
+ `{33,34}`,
bech32: `(?:`
+ `tb1` // Starts with tb1
+ BECH32_CHARS_LW
+ `{20,100}`
+ `|`
+ `TB1` // All upper case version
+ BECH32_CHARS_UP
+ `{20,100}`
+ `)`,
},
liquid: {
base58: `[GHPQ]` // G|H is P2PKH, P|Q is P2SH
+ BASE58_CHARS
+ `{33}`, // All min-max lengths are 34
bech32: `(?:`
+ `(?:` // bech32 liquid starts with ex1 or lq1
+ `ex1`
+ `|`
+ `lq1`
+ `)`
+ BECH32_CHARS_LW // blech32 and bech32 are the same alphabet and protocol, different checksums.
+ `{20,100}`
+ `|`
+ `(?:` // Same as above but all upper case
+ `EX1`
+ `|`
+ `LQ1`
+ `)`
+ BECH32_CHARS_UP
+ `{20,100}`
+ `)`,
},
liquidtestnet: {
base58: `[89]` // ???(TODO: find version) is P2PKH, 8|9 is P2SH
+ BASE58_CHARS
+ `{33}`, // P2PKH is ???(TODO: find size), P2SH is 34
bech32: `(?:`
+ `(?:` // bech32 liquid testnet starts with tex or tlq
+ `tex1` // TODO: Why does mempool use this and not ert|el like in the elements source?
+ `|`
+ `tlq1` // TODO: does this exist?
+ `)`
+ BECH32_CHARS_LW // blech32 and bech32 are the same alphabet and protocol, different checksums.
+ `{20,100}`
+ `|`
+ `(?:` // Same as above but all upper case
+ `TEX1`
+ `|`
+ `TLQ1`
+ `)`
+ BECH32_CHARS_UP
+ `{20,100}`
+ `)`,
},
bisq: {
base58: `(?:[bB][13]` // b or B at the start, followed by a single 1 or 3
+ BASE58_CHARS
+ `{26,33})`,
bech32: `(?:`
+ `[bB]bc1` // b or B at the start, followed by bc1
+ BECH32_CHARS_LW
+ `{20,100}`
+ `|`
+ `[bB]BC1` // b or B at the start, followed by BC1
+ BECH32_CHARS_UP
+ `{20,100}`
+ `)`,
},
}
type RegexTypeNoAddrNoBlockHash = | `transaction` | `blockheight` | `date` | `timestamp`;
export type RegexType = `address` | `blockhash` | RegexTypeNoAddrNoBlockHash;
export const NETWORKS = [`testnet`, `signet`, `liquid`, `liquidtestnet`, `bisq`, `mainnet`] as const;
export type Network = typeof NETWORKS[number]; // Turn const array into union type
export const ADDRESS_REGEXES: [RegExp, Network][] = NETWORKS
.map(network => [getRegex('address', network), network])
export function findOtherNetworks(address: string, skipNetwork: Network, env: Env): { network: Network, address: string, isNetworkAvailable: boolean }[] {
return ADDRESS_REGEXES
.filter(([regex, network]) => network !== skipNetwork && regex.test(address))
.map(([, network]) => ({ network, address, isNetworkAvailable: isNetworkAvailable(network, env) }));
}
function isNetworkAvailable(network: Network, env: Env): boolean {
switch (network) {
case 'testnet':
return env.TESTNET_ENABLED === true;
case 'signet':
return env.SIGNET_ENABLED === true;
case 'liquid':
return env.LIQUID_ENABLED === true;
case 'liquidtestnet':
return env.LIQUID_TESTNET_ENABLED === true;
case 'bisq':
return env.BISQ_ENABLED === true;
case 'mainnet':
return true; // There is no "MAINNET_ENABLED" flag
default:
return false;
}
}
export function needBaseModuleChange(fromBaseModule: 'mempool' | 'liquid' | 'bisq', toNetwork: Network): boolean {
if (!toNetwork) return false; // No target network means no change needed
if (fromBaseModule === 'mempool') {
return toNetwork !== 'mainnet' && toNetwork !== 'testnet' && toNetwork !== 'signet';
}
if (fromBaseModule === 'liquid') {
return toNetwork !== 'liquid' && toNetwork !== 'liquidtestnet';
}
if (fromBaseModule === 'bisq') {
return toNetwork !== 'bisq';
}
}
export function getTargetUrl(toNetwork: Network, address: string, env: Env): string {
let targetUrl = '';
if (toNetwork === 'liquid' || toNetwork === 'liquidtestnet') {
targetUrl = env.LIQUID_WEBSITE_URL;
targetUrl += (toNetwork === 'liquidtestnet' ? '/testnet' : '');
targetUrl += '/address/';
targetUrl += address;
}
if (toNetwork === 'bisq') {
targetUrl = env.BISQ_WEBSITE_URL;
targetUrl += '/address/';
targetUrl += address;
}
if (toNetwork === 'mainnet' || toNetwork === 'testnet' || toNetwork === 'signet') {
targetUrl = env.MEMPOOL_WEBSITE_URL;
targetUrl += (toNetwork === 'mainnet' ? '' : `/${toNetwork}`);
targetUrl += '/address/';
targetUrl += address;
}
return targetUrl;
}
export function getRegex(type: RegexTypeNoAddrNoBlockHash): RegExp;
export function getRegex(type: 'address', network: Network): RegExp;
export function getRegex(type: 'blockhash', network: Network): RegExp;
export function getRegex(type: RegexType, network?: Network): RegExp {
let regex = `^`; // ^ = Start of string
switch (type) {
// Match a block height number
// [Testing Order]: any order is fine
case `blockheight`:
regex += ZERO_INDEX_NUMBER_CHARS; // block height is a 0 indexed number
break;
// Match a 32 byte block hash in hex.
// [Testing Order]: Must always be tested before `transaction`
case `blockhash`:
if (!network) {
throw new Error(`Must pass network when type is blockhash`);
}
let leadingZeroes: number;
switch (network) {
case `mainnet`:
leadingZeroes = 8; // Assumes at least 32 bits of difficulty
break;
case `testnet`:
leadingZeroes = 8; // Assumes at least 32 bits of difficulty
break;
case `signet`:
leadingZeroes = 5;
break;
case `liquid`:
leadingZeroes = 8; // We are not interested in Liquid block hashes
break;
case `liquidtestnet`:
leadingZeroes = 8; // We are not interested in Liquid block hashes
break;
case `bisq`:
leadingZeroes = 8; // Assumes at least 32 bits of difficulty
break;
default:
throw new Error(`Invalid Network ${network} (Unreachable error in TypeScript)`);
}
regex += `0{${leadingZeroes}}`;
regex += `${HEX_CHARS}{${64 - leadingZeroes}}`; // Exactly 64 hex letters/numbers
break;
// Match a 32 byte tx hash in hex. Contains optional output index specifier.
// [Testing Order]: Must always be tested after `blockhash`
case `transaction`:
regex += `${HEX_CHARS}{64}`; // Exactly 64 hex letters/numbers
regex += `(?:`; // Start a non-capturing group
regex += `:`; // 1 instances of the symbol ":"
regex += ZERO_INDEX_NUMBER_CHARS; // A zero indexed number
regex += `)?`; // End the non-capturing group. This group appears 0 or 1 times
break;
// Match any one of the many address types
// [Testing Order]: While possible that a bech32 address happens to be 64 hex
// characters in the future (current lengths are not 64), it is highly unlikely
// Order therefore, does not matter.
case `address`:
if (!network) {
throw new Error(`Must pass network when type is address`);
}
regex += `(?:`; // Start a non-capturing group (each network has multiple options)
switch (network) {
case `mainnet`:
regex += ADDRESS_CHARS.mainnet.base58;
regex += `|`; // OR
regex += ADDRESS_CHARS.mainnet.bech32;
regex += `|`; // OR
regex += `04${HEX_CHARS}{128}`; // Uncompressed pubkey
regex += `|`; // OR
regex += `(?:02|03)${HEX_CHARS}{64}`; // Compressed pubkey
break;
case `testnet`:
regex += ADDRESS_CHARS.testnet.base58;
regex += `|`; // OR
regex += ADDRESS_CHARS.testnet.bech32;
regex += `|`; // OR
regex += `04${HEX_CHARS}{128}`; // Uncompressed pubkey
regex += `|`; // OR
regex += `(?:02|03)${HEX_CHARS}{64}`; // Compressed pubkey
break;
case `signet`:
regex += ADDRESS_CHARS.signet.base58;
regex += `|`; // OR
regex += ADDRESS_CHARS.signet.bech32;
regex += `|`; // OR
regex += `04${HEX_CHARS}{128}`; // Uncompressed pubkey
regex += `|`; // OR
regex += `(?:02|03)${HEX_CHARS}{64}`; // Compressed pubkey
break;
case `liquid`:
regex += ADDRESS_CHARS.liquid.base58;
regex += `|`; // OR
regex += ADDRESS_CHARS.liquid.bech32;
break;
case `liquidtestnet`:
regex += ADDRESS_CHARS.liquidtestnet.base58;
regex += `|`; // OR
regex += ADDRESS_CHARS.liquidtestnet.bech32;
break;
case `bisq`:
regex += ADDRESS_CHARS.bisq.base58;
regex += `|`; // OR
regex += ADDRESS_CHARS.bisq.bech32;
break;
default:
throw new Error(`Invalid Network ${network} (Unreachable error in TypeScript)`);
}
regex += `)`; // End the non-capturing group
break;
// Match a date in the format YYYY-MM-DD (optional: HH:MM)
// [Testing Order]: any order is fine
case `date`:
regex += `(?:`; // Start a non-capturing group
regex += `${NUMBER_CHARS}{4}`; // Exactly 4 digits
regex += `[-/]`; // 1 instance of the symbol "-" or "/"
regex += `${NUMBER_CHARS}{1,2}`; // Exactly 4 digits
regex += `[-/]`; // 1 instance of the symbol "-" or "/"
regex += `${NUMBER_CHARS}{1,2}`; // Exactly 4 digits
regex += `(?:`; // Start a non-capturing group
regex += ` `; // 1 instance of the symbol " "
regex += `${NUMBER_CHARS}{1,2}`; // Exactly 4 digits
regex += `:`; // 1 instance of the symbol ":"
regex += `${NUMBER_CHARS}{1,2}`; // Exactly 4 digits
regex += `)?`; // End the non-capturing group. This group appears 0 or 1 times
regex += `)`; // End the non-capturing group
break;
// Match a unix timestamp
// [Testing Order]: any order is fine
case `timestamp`:
regex += `${NUMBER_CHARS}{10}`; // Exactly 10 digits
break;
default:
throw new Error(`Invalid RegexType ${type} (Unreachable error in TypeScript)`);
}
regex += `$`; // $ = End of string
return new RegExp(regex);
}