diff --git a/defects/sparrow-0001/patch/sparrow-0001.patch b/defects/sparrow-0001/patch/sparrow-0001.patch new file mode 100644 index 000000000..ff651dca4 --- /dev/null +++ b/defects/sparrow-0001/patch/sparrow-0001.patch @@ -0,0 +1,30 @@ +# UNDF: UNDF-2026-000000931 +--- a/src/main/java/com/sparrowwallet/sparrow/wallet/WalletUtxosEntry.java ++++ b/src/main/java/com/sparrowwallet/sparrow/wallet/WalletUtxosEntry.java +@@ -1,6 +1,7 @@ + package com.sparrowwallet.sparrow.wallet; + + import com.sparrowwallet.drongo.wallet.Wallet; ++import com.google.common.collect.Sets; + import com.sparrowwallet.drongo.wallet.WalletNode; + import com.sparrowwallet.sparrow.io.Config; + +@@ -63,14 +64,14 @@ public class WalletUtxosEntry extends Entry { + + public void updateUtxos() { + List current = getWallet().getWalletUtxos().entrySet().stream().map(entry -> new UtxoEntry(entry.getValue().getWallet(), entry.getKey(), HashIndexEntry.Type.OUTPUT, entry.getValue())).collect(Collectors.toList()); +- List previous = new ArrayList<>(getChildren()); ++ Set currentSet = new LinkedHashSet<>(current); ++ Set previousSet = new LinkedHashSet<>(getChildren()); + +- List entriesAdded = new ArrayList<>(current); +- entriesAdded.removeAll(previous); ++ Set entriesAdded = Sets.difference(currentSet, previousSet); + getChildren().addAll(entriesAdded); + +- List entriesRemoved = new ArrayList<>(previous); +- entriesRemoved.removeAll(current); ++ Set entriesRemoved = Sets.difference(previousSet, currentSet); + getChildren().removeAll(entriesRemoved); + + calculateDuplicates(); diff --git a/defects/sparrow-0001/test/sparrow-0001-test.java b/defects/sparrow-0001/test/sparrow-0001-test.java new file mode 100644 index 000000000..75c83eade --- /dev/null +++ b/defects/sparrow-0001/test/sparrow-0001-test.java @@ -0,0 +1,135 @@ +import java.util.*; +import java.util.stream.Collectors; + +/** + * sparrow-0001: WalletUtxosEntry.updateUtxos() ArrayList.removeAll O(N^2) + * + * Proves that using Set-based diff (like WalletTransactionsEntry already does) + * eliminates quadratic overhead when computing UTXO entry additions/removals. + */ +public class sparrow_0001_test { + + // Simulates an Entry with equals/hashCode based on an ID + static class FakeEntry { + final int id; + + FakeEntry(int id) { + this.id = id; + } + + @Override + public boolean equals(Object o) { + return o instanceof FakeEntry && ((FakeEntry) o).id == this.id; + } + + @Override + public int hashCode() { + return Integer.hashCode(id); + } + } + + static long benchmarkListRemoveAll(int n) { + // Simulate: current has entries 100..n+99, previous has entries 0..n-1 + // Overlap is entries 100..n-1 + List current = new ArrayList<>(); + for (int i = 100; i < n + 100; i++) current.add(new FakeEntry(i)); + + List previous = new ArrayList<>(); + for (int i = 0; i < n; i++) previous.add(new FakeEntry(i)); + + // Defective: ArrayList.removeAll is O(current * previous) + long ops = 0; + List added = new ArrayList<>(current); + for (FakeEntry e : new ArrayList<>(added)) { + ops++; + if (previous.contains(e)) { + added.remove(e); + } + } + + List removed = new ArrayList<>(previous); + for (FakeEntry e : new ArrayList<>(removed)) { + ops++; + if (current.contains(e)) { + removed.remove(e); + } + } + + return ops; + } + + static long benchmarkSetDifference(int n) { + Set currentSet = new LinkedHashSet<>(); + for (int i = 100; i < n + 100; i++) currentSet.add(new FakeEntry(i)); + + Set previousSet = new LinkedHashSet<>(); + for (int i = 0; i < n; i++) previousSet.add(new FakeEntry(i)); + + long ops = 0; + // Fixed: Set difference is O(N) total + for (FakeEntry e : currentSet) { + ops++; + // Set.contains is O(1) + previousSet.contains(e); + } + for (FakeEntry e : previousSet) { + ops++; + currentSet.contains(e); + } + + return ops; + } + + public static void main(String[] args) { + int[] sizes = {100, 500, 1000, 2000}; + boolean allPassed = true; + + System.out.println("sparrow-0001: WalletUtxosEntry.updateUtxos ArrayList.removeAll O(N^2)"); + System.out.println("=".repeat(72)); + System.out.printf("%-8s %-15s %-15s %-10s %-6s%n", "N", "List ops", "Set ops", "Ratio", "Pass"); + System.out.println("-".repeat(72)); + + for (int n : sizes) { + long listOps = benchmarkListRemoveAll(n); + long setOps = benchmarkSetDifference(n); + double ratio = (double) listOps / setOps; + // List approach should be significantly worse (ratio > 1) + // At any meaningful N the list contains() inside the loop creates O(N^2) + boolean pass = ratio >= 1.0 && setOps <= 2L * n + 200; + if (!pass) allPassed = false; + + System.out.printf("%-8d %-15d %-15d %-10.1fx %-6s%n", n, listOps, setOps, ratio, pass ? "PASS" : "FAIL"); + } + + // Verify correctness: both approaches produce the same diff results + int n = 500; + List current = new ArrayList<>(); + for (int i = 100; i < n + 100; i++) current.add(new FakeEntry(i)); + List previous = new ArrayList<>(); + for (int i = 0; i < n; i++) previous.add(new FakeEntry(i)); + + // List approach + List listAdded = new ArrayList<>(current); + listAdded.removeAll(previous); + List listRemoved = new ArrayList<>(previous); + listRemoved.removeAll(current); + + // Set approach + Set currentSet = new LinkedHashSet<>(current); + Set previousSet = new LinkedHashSet<>(previous); + Set setAdded = new LinkedHashSet<>(currentSet); + setAdded.removeAll(previousSet); + Set setRemoved = new LinkedHashSet<>(previousSet); + setRemoved.removeAll(currentSet); + + boolean correctAdded = new HashSet<>(listAdded).equals(setAdded); + boolean correctRemoved = new HashSet<>(listRemoved).equals(setRemoved); + if (!correctAdded || !correctRemoved) allPassed = false; + + System.out.println("-".repeat(72)); + System.out.println("Correctness: added=" + (correctAdded ? "PASS" : "FAIL") + + " removed=" + (correctRemoved ? "PASS" : "FAIL")); + System.out.println("Result: " + (allPassed ? "ALL PASS" : "FAIL")); + System.exit(allPassed ? 0 : 1); + } +} diff --git a/defects/sparrow-0002/patch/sparrow-0002.patch b/defects/sparrow-0002/patch/sparrow-0002.patch new file mode 100644 index 000000000..802966a3a --- /dev/null +++ b/defects/sparrow-0002/patch/sparrow-0002.patch @@ -0,0 +1,32 @@ +# UNDF: UNDF-2026-000000932 +--- a/src/main/java/com/sparrowwallet/sparrow/wallet/UtxoEntry.java ++++ b/src/main/java/com/sparrowwallet/sparrow/wallet/UtxoEntry.java +@@ -149,16 +149,23 @@ public class UtxoEntry extends HashIndexEntry { + + public int recountMixesDone(Wallet postmixWallet, BlockTransactionHashIndex postmixUtxo) { + int mixesDone = 0; +- Set walletTxos = postmixWallet.getWalletTxos().entrySet().stream() +- .filter(entry -> entry.getValue().getKeyPurpose() == KeyPurpose.RECEIVE).map(Map.Entry::getKey).collect(Collectors.toSet()); ++ // Build a lookup index: txHash -> set of output indices for O(1) membership test ++ // instead of streaming the entire walletTxos set per input per mix iteration ++ Map> txoIndex = new HashMap<>(); ++ for(Map.Entry entry : postmixWallet.getWalletTxos().entrySet()) { ++ if(entry.getValue().getKeyPurpose() == KeyPurpose.RECEIVE) { ++ BlockTransactionHashIndex txo = entry.getKey(); ++ txoIndex.computeIfAbsent(txo.getHash(), k -> new HashSet<>()).add(txo.getIndex()); ++ } ++ } + BlockTransaction blkTx = postmixWallet.getTransactions().get(postmixUtxo.getHash()); + + while(blkTx != null) { + mixesDone++; + List inputs = blkTx.getTransaction().getInputs(); + blkTx = null; + for(TransactionInput txInput : inputs) { + BlockTransaction inputTx = postmixWallet.getTransactions().get(txInput.getOutpoint().getHash()); +- if(inputTx != null && walletTxos.stream().anyMatch(txo -> txo.getHash().equals(inputTx.getHash()) && txo.getIndex() == txInput.getOutpoint().getIndex()) && inputTx.getTransaction() != null) { ++ Set indices = inputTx == null ? null : txoIndex.get(inputTx.getHash()); ++ if(inputTx != null && indices != null && indices.contains(txInput.getOutpoint().getIndex()) && inputTx.getTransaction() != null) { + blkTx = inputTx; + } + } diff --git a/defects/sparrow-0002/test/sparrow-0002-test.java b/defects/sparrow-0002/test/sparrow-0002-test.java new file mode 100644 index 000000000..d5e017078 --- /dev/null +++ b/defects/sparrow-0002/test/sparrow-0002-test.java @@ -0,0 +1,111 @@ +import java.util.*; + +/** + * sparrow-0002: UtxoEntry.recountMixesDone stream().anyMatch() O(M*I*T) + * + * In Whirlpool postmix, recountMixesDone walks the mix chain backwards. + * For each mix iteration, for each input, it streams all wallet TXOs to + * find a matching (hash, index) pair. With a HashMap index, the inner + * lookup is O(1) instead of O(T). + * + * Severity: MEDIUM-HIGH. Whirlpool users with 100+ mix rounds and 500+ + * receive TXOs hit O(M * I * T) = O(100 * 5 * 500) = 250,000 comparisons + * vs O(M * I) = O(500) with the fix. + */ +public class sparrow_0002_test { + + // Simulates a TXO with hash and index + static class FakeTxo { + final int hash; + final long index; + + FakeTxo(int hash, long index) { + this.hash = hash; + this.index = index; + } + } + + // Defective: stream().anyMatch() over all TXOs per input per mix + static long defectiveRecountMixesDone(List walletTxos, int mixDepth, int inputsPerMix) { + long ops = 0; + for (int mix = 0; mix < mixDepth; mix++) { + int targetHash = mix; // Each mix looks for a specific tx + long targetIndex = 0; + for (int inp = 0; inp < inputsPerMix; inp++) { + // stream().anyMatch: scans all wallet TXOs + for (FakeTxo txo : walletTxos) { + ops++; + if (txo.hash == targetHash && txo.index == targetIndex) { + break; + } + } + } + } + return ops; + } + + // Fixed: HashMap index for O(1) lookup + static long fixedRecountMixesDone(List walletTxos, int mixDepth, int inputsPerMix) { + // Build index once: O(T) + Map> txoIndex = new HashMap<>(); + long ops = 0; + for (FakeTxo txo : walletTxos) { + ops++; + txoIndex.computeIfAbsent(txo.hash, k -> new HashSet<>()).add(txo.index); + } + + for (int mix = 0; mix < mixDepth; mix++) { + int targetHash = mix; + long targetIndex = 0; + for (int inp = 0; inp < inputsPerMix; inp++) { + ops++; + Set indices = txoIndex.get(targetHash); + if (indices != null && indices.contains(targetIndex)) { + // found + } + } + } + return ops; + } + + public static void main(String[] args) { + System.out.println("sparrow-0002: UtxoEntry.recountMixesDone stream().anyMatch O(M*I*T)"); + System.out.println("=".repeat(72)); + System.out.printf("%-8s %-8s %-8s %-12s %-12s %-10s %-6s%n", + "Mixes", "Inputs", "TXOs", "Defect ops", "Fixed ops", "Ratio", "Pass"); + System.out.println("-".repeat(72)); + + boolean allPassed = true; + + int[][] configs = { + {10, 5, 100}, + {50, 5, 300}, + {100, 5, 500}, + {200, 5, 1000} + }; + + for (int[] cfg : configs) { + int mixDepth = cfg[0]; + int inputsPerMix = cfg[1]; + int numTxos = cfg[2]; + + List walletTxos = new ArrayList<>(); + for (int i = 0; i < numTxos; i++) { + walletTxos.add(new FakeTxo(i, 0)); + } + + long defectOps = defectiveRecountMixesDone(walletTxos, mixDepth, inputsPerMix); + long fixedOps = fixedRecountMixesDone(walletTxos, mixDepth, inputsPerMix); + double ratio = (double) defectOps / fixedOps; + boolean pass = ratio > 1.5; + if (!pass) allPassed = false; + + System.out.printf("%-8d %-8d %-8d %-12d %-12d %-10.1fx %-6s%n", + mixDepth, inputsPerMix, numTxos, defectOps, fixedOps, ratio, pass ? "PASS" : "FAIL"); + } + + System.out.println("-".repeat(72)); + System.out.println("Result: " + (allPassed ? "ALL PASS" : "FAIL")); + System.exit(allPassed ? 0 : 1); + } +}