From d825143b3562c4ab493f56ae67a4eadcfde7a469 Mon Sep 17 00:00:00 2001 From: junderw Date: Sun, 4 Sep 2022 21:31:02 +0900 Subject: [PATCH] Search for full address in separate network if matches --- .../search-form/search-form.component.ts | 26 +- frontend/src/app/shared/common.utils.ts | 1 - .../pipes/relative-url/relative-url.pipe.ts | 4 +- frontend/src/app/shared/regex.utils.ts | 224 ++++++++++++++++++ 4 files changed, 235 insertions(+), 20 deletions(-) create mode 100644 frontend/src/app/shared/regex.utils.ts diff --git a/frontend/src/app/components/search-form/search-form.component.ts b/frontend/src/app/components/search-form/search-form.component.ts index 8031195f0..a9e31221a 100644 --- a/frontend/src/app/components/search-form/search-form.component.ts +++ b/frontend/src/app/components/search-form/search-form.component.ts @@ -9,7 +9,7 @@ import { ElectrsApiService } from '../../services/electrs-api.service'; import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; import { ApiService } from '../../services/api.service'; import { SearchResultsComponent } from './search-results/search-results.component'; -import { ADDRESS_REGEXES, getRegex } from '../../shared/common.utils'; +import { findOtherNetworks, getRegex } from '../../shared/regex.utils'; @Component({ selector: 'app-search-form', @@ -208,22 +208,13 @@ export class SearchFormComponent implements OnInit { const searchText = result || this.searchForm.value.searchText.trim(); if (searchText) { this.isSearching = true; + + const otherNetworks = findOtherNetworks(searchText, this.network as any); if (!this.regexTransaction.test(searchText) && this.regexAddress.test(searchText)) { this.navigate('/address/', searchText); - } else if ( - // If the search text matches any other network besides this one - ADDRESS_REGEXES - .filter(([, network]) => network !== this.network) - .some(([regex]) => regex.test(searchText)) - ) { - // Gather all network matches as string[] - const networks = ADDRESS_REGEXES.filter(([regex, network]) => - network !== this.network && - regex.test(searchText) - ).map(([, network]) => network); - // ############################################### - // TODO: Create the search items for the drop down - // ############################################### + } else if (otherNetworks.length > 0) { + // Change the network to the first match + this.navigate('/address/', searchText, undefined, otherNetworks[0]); } else if (this.regexBlockhash.test(searchText) || this.regexBlockheight.test(searchText)) { this.navigate('/block/', searchText); } else if (this.regexTransaction.test(searchText)) { @@ -252,8 +243,9 @@ 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.router.navigate([this.relativeUrlPipe.transform(url, swapNetwork), searchText], extras); this.searchTriggered.emit(); this.searchForm.setValue({ searchText: '', diff --git a/frontend/src/app/shared/common.utils.ts b/frontend/src/app/shared/common.utils.ts index 8b914beda..e50ba13b7 100644 --- a/frontend/src/app/shared/common.utils.ts +++ b/frontend/src/app/shared/common.utils.ts @@ -119,7 +119,6 @@ export function convertRegion(input, to: 'name' | 'abbreviated'): string { } } - export function haversineDistance(lat1: number, lon1: number, lat2: number, lon2: number): number { const rlat1 = lat1 * Math.PI / 180; const rlon1 = lon1 * Math.PI / 180; diff --git a/frontend/src/app/shared/pipes/relative-url/relative-url.pipe.ts b/frontend/src/app/shared/pipes/relative-url/relative-url.pipe.ts index d7fe612fe..83f5f20df 100644 --- a/frontend/src/app/shared/pipes/relative-url/relative-url.pipe.ts +++ b/frontend/src/app/shared/pipes/relative-url/relative-url.pipe.ts @@ -10,8 +10,8 @@ export class RelativeUrlPipe implements PipeTransform { private stateService: StateService, ) { } - transform(value: string): string { - let network = this.stateService.network; + transform(value: string, swapNetwork?: string): string { + let network = swapNetwork || this.stateService.network; if (this.stateService.env.BASE_MODULE === 'liquid' && network === 'liquidtestnet') { network = 'testnet'; } else if (this.stateService.env.BASE_MODULE !== 'mempool') { diff --git a/frontend/src/app/shared/regex.utils.ts b/frontend/src/app/shared/regex.utils.ts new file mode 100644 index 000000000..bac256c8d --- /dev/null +++ b/frontend/src/app/shared/regex.utils.ts @@ -0,0 +1,224 @@ +// 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 13 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,12} // Any succession of numbers up to 13 digits starting with 1-9 +// ) // End the non-capturing group. +const ZERO_INDEX_NUMBER_CHARS = `(?:0|[1-9][0-9]{0,12})`; + +// 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 + + `{6,100}` // As per bech32, 6 char checksum is minimum + + `|` + + `BC1` // All upper case version + + BECH32_CHARS_UP + + `{6,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 bc1 + + BECH32_CHARS_LW + + `{6,100}` // As per bech32, 6 char checksum is minimum + + `|` + + `TB1` // All upper case version + + BECH32_CHARS_UP + + `{6,100}` + + `)`, + }, + signet: { + base58: `[mn2]` + + BASE58_CHARS + + `{33,34}`, + bech32: `(?:` + + `tb1` // Starts with tb1 + + BECH32_CHARS_LW + + `{6,100}` + + `|` + + `TB1` // All upper case version + + BECH32_CHARS_UP + + `{6,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 ex or lq + + `ex` + + `|` + + `lq` + + `)` + + BECH32_CHARS_LW // blech32 and bech32 are the same alphabet and protocol, different checksums. + + `{6,100}` + + `|` + + `(?:` // Same as above but all upper case + + `EX` + + `|` + + `LQ` + + `)` + + BECH32_CHARS_UP + + `{6,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 + + `tex` // TODO: Why does mempool use this and not ert|el like in the elements source? + + `|` + + `tlq` // TODO: does this exist? + + `)` + + BECH32_CHARS_LW // blech32 and bech32 are the same alphabet and protocol, different checksums. + + `{6,100}` + + `|` + + `(?:` // Same as above but all upper case + + `TEX` + + `|` + + `TLQ` + + `)` + + BECH32_CHARS_UP + + `{6,100}` + + `)`, + }, + bisq: { + base58: `B1` // bisq base58 addrs start with B1 + + BASE58_CHARS + + `{33}`, // always length 35 + bech32: `(?:` + + `bbc1` // Starts with bbc1 + + BECH32_CHARS_LW + + `{6,100}` + + `|` + + `BBC1` // All upper case version + + BECH32_CHARS_UP + + `{6,100}` + + `)`, + }, +} +type RegexTypeNoAddr = `blockhash` | `transaction` | `blockheight`; +export type RegexType = `address` | RegexTypeNoAddr; + +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): Network[] { + return ADDRESS_REGEXES.filter(([regex, network]) => + network !== skipNetwork && + regex.test(address) + ).map(([, network]) => network); +} + +export function getRegex(type: RegexTypeNoAddr): RegExp; +export function getRegex(type: 'address', 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. Assumes at least 32 bits of difficulty. + // [Testing Order]: Must always be tested before `transaction` + case `blockhash`: + regex += `0{8}`; // Starts with exactly 8 zeroes in a row + regex += `${HEX_CHARS}{56}`; // Continues with exactly 56 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; + break; + case `testnet`: + regex += ADDRESS_CHARS.testnet.base58; + regex += `|`; // OR + regex += ADDRESS_CHARS.testnet.bech32; + break; + case `signet`: + regex += ADDRESS_CHARS.signet.base58; + regex += `|`; // OR + regex += ADDRESS_CHARS.signet.bech32; + 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; + default: + throw new Error(`Invalid RegexType ${type} (Unreachable error in TypeScript)`); + } + regex += `$`; // $ = End of string + return new RegExp(regex); +}