From 33614b12facc9b5c98351c8705ea591e8d105cc8 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Sat, 4 Oct 2025 04:09:24 +0000 Subject: [PATCH 1/6] fix address poisoning group construction --- .../transactions-list.component.ts | 34 +++++++++++++++++-- 1 file changed, 31 insertions(+), 3 deletions(-) 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..0b43d791c 100644 --- a/frontend/src/app/components/transactions-list/transactions-list.component.ts +++ b/frontend/src/app/components/transactions-list/transactions-list.component.ts @@ -404,8 +404,38 @@ export class TransactionsListComponent implements OnInit, OnChanges, OnDestroy { ]) { 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++; + // 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 +443,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 }); } } From e93a8274125ed087482902f8cbc555f9c0f3f5e7 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Sat, 4 Oct 2025 04:09:52 +0000 Subject: [PATCH 2/6] adjust address poisoning threshold for birthday paradox --- .../transactions-list.component.ts | 25 ++++++++++++++++++- frontend/src/app/shared/address-utils.ts | 2 +- 2 files changed, 25 insertions(+), 2 deletions(-) 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 0b43d791c..4316d6c09 100644 --- a/frontend/src/app/components/transactions-list/transactions-list.component.ts +++ b/frontend/src/app/components/transactions-list/transactions-list.component.ts @@ -392,6 +392,29 @@ export class TransactionsListComponent implements OnInit, OnChanges, OnDestroy { 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)); + + // 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,7 +426,7 @@ 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) { + 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); diff --git a/frontend/src/app/shared/address-utils.ts b/frontend/src/app/shared/address-utils.ts index 065837840..16fc452b7 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; From 7758414505f012559afeb5d432b343a408bac6da Mon Sep 17 00:00:00 2001 From: Mononaut Date: Sat, 4 Oct 2025 04:10:39 +0000 Subject: [PATCH 3/6] discount address similarity score for imperfect matches --- frontend/src/app/shared/address-utils.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/shared/address-utils.ts b/frontend/src/app/shared/address-utils.ts index 16fc452b7..de26fbd11 100644 --- a/frontend/src/app/shared/address-utils.ts +++ b/frontend/src/app/shared/address-utils.ts @@ -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; } From b1d59ce4d73d9ab396d86641027161968aa324a2 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Sat, 4 Oct 2025 04:11:06 +0000 Subject: [PATCH 4/6] fix address similarity prefix length --- frontend/src/app/shared/address-utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/shared/address-utils.ts b/frontend/src/app/shared/address-utils.ts index de26fbd11..f9fd24a0f 100644 --- a/frontend/src/app/shared/address-utils.ts +++ b/frontend/src/app/shared/address-utils.ts @@ -347,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; From 6c7673c97ffc0ba5b7872f9d91217e0d9fcb0d28 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Sat, 4 Oct 2025 14:43:47 +0000 Subject: [PATCH 5/6] fix address overflow on long prefix match --- .../components/address-text/address-text.component.html | 6 +++--- .../components/address-text/address-text.component.ts | 2 ++ 2 files changed, 5 insertions(+), 3 deletions(-) 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)', From c871e1b1aff24f591c7d552042b78850597c3cc7 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Sat, 4 Oct 2025 14:47:03 +0000 Subject: [PATCH 6/6] exclude obvious fake scripthashes from address similarity scoring --- .../transactions-list/transactions-list.component.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) 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 4316d6c09..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,8 @@ 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 }>(); @@ -474,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(); }