diff --git a/frontend/src/app/components/transactions-list/transactions-list.component.html b/frontend/src/app/components/transactions-list/transactions-list.component.html index 6f1d76538..7721298fb 100644 --- a/frontend/src/app/components/transactions-list/transactions-list.component.html +++ b/frontend/src/app/components/transactions-list/transactions-list.component.html @@ -16,6 +16,11 @@
{{ errorUnblinded }}
+ @if (similarityMatches.get(tx.txid)?.size) { + + }
@@ -68,9 +73,11 @@ - - - + {{ vin.prevout.scriptpubkey_type?.toUpperCase() }} @@ -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))) }">
- - - + P2PK diff --git a/frontend/src/app/components/transactions-list/transactions-list.component.ts b/frontend/src/app/components/transactions-list/transactions-list.component.ts index 20f11abd8..6f01a6e59 100644 --- a/frontend/src/app/components/transactions-list/transactions-list.component.ts +++ b/frontend/src/app/components/transactions-list/transactions-list.component.ts @@ -14,6 +14,7 @@ import { StorageService } from '@app/services/storage.service'; import { OrdApiService } from '@app/services/ord-api.service'; import { Inscription } from '@app/shared/ord/inscription.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({ selector: 'app-transactions-list', @@ -55,6 +56,7 @@ export class TransactionsListComponent implements OnInit, OnChanges { showFullScript: { [vinIndex: number]: boolean } = {}; showFullWitness: { [vinIndex: number]: { [witnessIndex: number]: boolean } } = {}; showOrdData: { [key: string]: { show: boolean; inscriptions?: Inscription[]; runestone?: Runestone, runeInfo?: { [id: string]: { etching: Etching; txid: string; } }; } } = {}; + similarityMatches: Map> = new Map(); constructor( public stateService: StateService, @@ -144,6 +146,8 @@ export class TransactionsListComponent implements OnInit, OnChanges { this.currency = currency; this.refreshPrice(); }); + + this.updateAddressSimilarities(); } refreshPrice(): void { @@ -183,6 +187,8 @@ export class TransactionsListComponent implements OnInit, OnChanges { } } if (changes.transactions || changes.addresses) { + this.similarityMatches.clear(); + this.updateAddressSimilarities(); if (!this.transactions || !this.transactions.length) { 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 = 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 { this.loadMore.emit(); } diff --git a/frontend/src/app/shared/address-utils.ts b/frontend/src/app/shared/address-utils.ts index 0a7f2df02..0ca4ec2e0 100644 --- a/frontend/src/app/shared/address-utils.ts +++ b/frontend/src/app/shared/address-utils.ts @@ -78,6 +78,7 @@ const p2trRegex = RegExp('^p' + BECH32_CHARS_LW + '{58}$'); const pubkeyRegex = RegExp('^' + `(04${HEX_CHARS}{128})|(0[23]${HEX_CHARS}{64})$`); export function detectAddressType(address: string, network: string): AddressType { + network = network || 'mainnet'; // normal address types const firstChar = address.substring(0, 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 { this.scripts.set(script.key, script); 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, + ); +} + diff --git a/frontend/src/app/shared/components/address-text/address-text.component.html b/frontend/src/app/shared/components/address-text/address-text.component.html new file mode 100644 index 000000000..ddcd8d751 --- /dev/null +++ b/frontend/src/app/shared/components/address-text/address-text.component.html @@ -0,0 +1,17 @@ + +@if (similarity) { + +} @else { + + + +} \ No newline at end of file diff --git a/frontend/src/app/shared/components/address-text/address-text.component.scss b/frontend/src/app/shared/components/address-text/address-text.component.scss new file mode 100644 index 000000000..3b5bf50b7 --- /dev/null +++ b/frontend/src/app/shared/components/address-text/address-text.component.scss @@ -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); +} \ No newline at end of file diff --git a/frontend/src/app/shared/components/address-text/address-text.component.ts b/frontend/src/app/shared/components/address-text/address-text.component.ts new file mode 100644 index 000000000..f618428aa --- /dev/null +++ b/frontend/src/app/shared/components/address-text/address-text.component.ts @@ -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', + ]; +} diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index d937e6bbb..335b428ed 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -7,7 +7,7 @@ import { faFilter, faAngleDown, faAngleUp, faAngleRight, faAngleLeft, faBolt, fa faFileAlt, faRedoAlt, faArrowAltCircleRight, faExternalLinkAlt, faBook, faListUl, faDownload, faQrcode, faArrowRightArrowLeft, faArrowsRotate, faCircleLeft, faFastForward, faWallet, faUserClock, faWrench, faUserFriends, faQuestionCircle, faHistory, faSignOutAlt, faKey, faSuitcase, faIdCardAlt, faNetworkWired, faUserCheck, 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 { MenuComponent } from '@components/menu/menu.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 { FeeRateComponent } from '@app/shared/components/fee-rate/fee-rate.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 { SearchResultsComponent } from '@components/search-form/search-results/search-results.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, FeeRateComponent, AddressTypeComponent, + AddressTextComponent, TruncateComponent, SearchResultsComponent, TimestampComponent, @@ -360,6 +362,7 @@ import { GithubLogin } from '../components/github-login.component/github-login.c BtcComponent, FeeRateComponent, AddressTypeComponent, + AddressTextComponent, TruncateComponent, SearchResultsComponent, TimestampComponent, @@ -465,5 +468,6 @@ export class SharedModule { library.addIcons(faShareNodes); library.addIcons(faCreditCard); library.addIcons(faMicroscope); + library.addIcons(faExclamationTriangle); } }