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 9b9604044..32e00b177 100644 --- a/frontend/src/app/components/transactions-list/transactions-list.component.ts +++ b/frontend/src/app/components/transactions-list/transactions-list.component.ts @@ -390,8 +390,31 @@ export class TransactionsListComponent implements OnInit, OnChanges, OnDestroy { // Check for address poisoning similarity matches this.similarityMatches.set(tx.txid, new Map()); - const comparableVouts = tx.vout.slice(0, 20).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)); + const comparableVouts = tx.vout.slice(0, 20).filter(v => ['p2pkh', 'p2sh', 'v0_p2wpkh', 'v0_p2wsh', 'v1_p2tr'].includes(v.scriptpubkey_type) && !this.isFakeScripthash(v)); + 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) && !this.isFakeScripthash(v)); + + // Count unique addresses per type & position + const typeCount = new Map, vinAddrs: Set }>(); + for (const vout of comparableVouts) { + const count = typeCount.get(vout.scriptpubkey_type) || { voutAddrs: new Set(), vinAddrs: new Set() }; + count.voutAddrs.add(vout.scriptpubkey_address); + typeCount.set(vout.scriptpubkey_type, count); + } + for (const vin of comparableVins) { + const count = typeCount.get(vin.scriptpubkey_type!) || { voutAddrs: new Set(), vinAddrs: new Set() }; + count.vinAddrs.add(vin.scriptpubkey_address); + typeCount.set(vin.scriptpubkey_type!, count); + } + // We compare each vout to every distinct vin and every other vout address of the same type + let totalUniquePairs = 0; + for (const { voutAddrs, vinAddrs } of typeCount.values()) { + const V = voutAddrs.size; + const I = vinAddrs.size; + totalUniquePairs += (V * (V - 1)) / 2 + V * I; + } + // Adjust threshold to correct for the birthday paradox + const adjustedThreshold = totalUniquePairs > 0 ? ADDRESS_SIMILARITY_THRESHOLD * totalUniquePairs : ADDRESS_SIMILARITY_THRESHOLD; + for (const vout of comparableVouts) { const address = vout.scriptpubkey_address; const addressType = vout.scriptpubkey_type; @@ -403,9 +426,39 @@ export class TransactionsListComponent implements OnInit, OnChanges, OnDestroy { ...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++; + if (similarity?.status === 'comparable' && similarity.score > adjustedThreshold) { + // Get or create group numbers for both addresses + let group1 = similarityGroups.get(address); + let group2 = similarityGroups.get(compareAddr.scriptpubkey_address); + + let group: number; + if (group1 !== undefined && group2 !== undefined) { + // Both have groups - merge by using the lower group number + group = Math.min(group1, group2); + // Update all addresses with the higher group number to use the lower one + if (group1 !== group2) { + const higherGroup = Math.max(group1, group2); + for (const [addr, g] of similarityGroups.entries()) { + if (g === higherGroup) { + similarityGroups.set(addr, group); + } + } + } + } else if (group1 !== undefined) { + // Only first address has a group + group = group1; + } else if (group2 !== undefined) { + // Only second address has a group + group = group2; + } else { + // Neither has a group - create a new one + group = lastGroup++; + } + + // Assign the group to both addresses similarityGroups.set(address, group); + similarityGroups.set(compareAddr.scriptpubkey_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 }); @@ -413,8 +466,6 @@ export class TransactionsListComponent implements OnInit, OnChanges, OnDestroy { // 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 }); } } @@ -423,6 +474,12 @@ export class TransactionsListComponent implements OnInit, OnChanges, OnDestroy { } } + // assume any address with 12 or more contiguous repeated substrings is fake + fakeScriptHashRegex = new RegExp(/(.+?)\1{11,}/); + isFakeScripthash(vout: Vout): boolean { + return this.fakeScriptHashRegex.test(vout.scriptpubkey_address); + } + onScroll(): void { this.loadMore.emit(); } diff --git a/frontend/src/app/shared/address-utils.ts b/frontend/src/app/shared/address-utils.ts index 065837840..f9fd24a0f 100644 --- a/frontend/src/app/shared/address-utils.ts +++ b/frontend/src/app/shared/address-utils.ts @@ -264,7 +264,7 @@ export type AddressSimilarityResult = | { status: 'incomparable' } | AddressSimilarity; -export const ADDRESS_SIMILARITY_THRESHOLD = 10_000_000; // 1 false positive per ~10 million comparisons +export const ADDRESS_SIMILARITY_THRESHOLD = 1_000_000; // 1 false positive per ~1 million comparisons function fuzzyPrefixMatch(a: string, b: string, rtl: boolean = false): { score: number, matchA: string, matchB: string } { let score = 0; @@ -280,12 +280,18 @@ function fuzzyPrefixMatch(a: string, b: string, rtl: boolean = false): { score: b = b.split('').reverse().join(''); } + let discounted = false; while (ai < a.length && bi < b.length && !done) { if (a[ai] === b[bi]) { // matching characters prefixA += a[ai]; prefixB += b[bi]; - score++; + if (discounted) { + score += 0.5; + } else { + score ++; + } + discounted = false; ai++; bi++; } else if (!gap) { @@ -312,6 +318,7 @@ function fuzzyPrefixMatch(a: string, b: string, rtl: boolean = false): { score: bi++; } gap = true; + discounted = true; } else { done = true; } @@ -340,7 +347,7 @@ export function compareAddressInfo(a: AddressTypeInfo, b: AddressTypeInfo): Addr 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; + const prefixScore = isBase58 ? 1 : (ADDRESS_PREFIXES[a.network || 'mainnet'].bech32.length + 1); // add the two scores together const totalScore = left.score + right.score - prefixScore; 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 index ddcd8d751..ae8643349 100644 --- a/frontend/src/app/shared/components/address-text/address-text.component.html +++ b/frontend/src/app/shared/components/address-text/address-text.component.html @@ -2,9 +2,9 @@ @if (similarity) {
- {{ similarity.match.prefix }} - {{ address.slice(similarity.match.prefix.length || 0, -similarity.match.postfix.length || undefined) }} - {{ similarity.match.postfix }} + {{ similarity.match.prefix.slice(0, 16) }} + {{ address.slice(min(similarity.match.prefix.length, 16) || 0, -min(similarity.match.postfix.length, 16) || undefined) }} + {{ similarity.match.postfix.slice(-16) }} 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 index f618428aa..75a4851da 100644 --- a/frontend/src/app/shared/components/address-text/address-text.component.ts +++ b/frontend/src/app/shared/components/address-text/address-text.component.ts @@ -11,6 +11,8 @@ export class AddressTextComponent { @Input() info: AddressTypeInfo | null; @Input() similarity: { score: number, match: AddressMatch, group: number } | null; + min = Math.min; + groupColors: string[] = [ 'var(--primary)', 'var(--success)',