feat: port 5 vulnerability detection heuristics from am-i.exposed#19
feat: port 5 vulnerability detection heuristics from am-i.exposed#19satsfy wants to merge 3 commits intostealth-bitcoin:mainfrom
Conversation
68987d2 to
8b3d50c
Compare
| fn find_spending_tx(&self, source_txid: &Txid, source_vout: u32) -> Option<Txid> { | ||
| for txid in &self.our_txids { | ||
| if let Some(tx) = self.fetch_tx(txid) { | ||
| let spends_outpoint = tx.vin.iter().any(|vin| { | ||
| vin.previous_txid == *source_txid && vin.previous_vout == source_vout | ||
| }); | ||
| if spends_outpoint { | ||
| return Some(*txid); | ||
| } | ||
| } | ||
| } | ||
| None | ||
| } |
There was a problem hiding this comment.
find_spending_tx iterates all transactions and all their inputs on every call. Peel chain detection calls it up to 6 times per chain, and toxic change calls it once per small output. For a wallet with hundreds of transactions this adds up.
Build a reverse spending index once in from_wallet_history():
// HashMap<(Txid, u32), Txid> — maps (parent_txid, vout) → spending txid
spending_index: HashMap<(Txid, u32), Txid>,
The data is already available — from_wallet_history() already iterates every transaction to build input_cache and output_cache. Adding entries to this map in the same loop costs nothing extra, and makes every find_spending_tx call O(1) instead of O(n×m).
It could become something like this:
| fn find_spending_tx(&self, source_txid: &Txid, source_vout: u32) -> Option<Txid> { | |
| for txid in &self.our_txids { | |
| if let Some(tx) = self.fetch_tx(txid) { | |
| let spends_outpoint = tx.vin.iter().any(|vin| { | |
| vin.previous_txid == *source_txid && vin.previous_vout == source_vout | |
| }); | |
| if spends_outpoint { | |
| return Some(*txid); | |
| } | |
| } | |
| } | |
| None | |
| } | |
| fn find_spending_tx(&self, source_txid: &Txid, source_vout: u32) -> Option<Txid> { | |
| self.spending_index.get(&(*source_txid, source_vout)).copied() | |
| } |
|
|
||
| // ── 13. Dust Attack Detection ────────────────────────────────────────── | ||
| // | ||
| // Port of: am-i-exposed/src/lib/analysis/chain/backward.ts | ||
| // | ||
| // Detects when our wallet received a tiny UTXO from a probable dust | ||
| // attack transaction. A dust attack parent typically has ≥10 outputs, | ||
| // ≥5 of which are ≤ 546 sats, distributed to many distinct addresses. | ||
|
|
||
| fn detect_dust_attack(&self, findings: &mut Vec<Finding>) { | ||
| const MIN_OUTPUTS: usize = 10; | ||
| const DUST_THRESHOLD: u64 = 546; | ||
| const MIN_DUST_OUTPUTS: usize = 5; | ||
|
|
||
| // Check receiving transactions only (we didn't create them). | ||
| let txids: Vec<Txid> = self.our_txids.iter().copied().collect(); | ||
| for txid in txids { | ||
| let input_addrs = self.get_input_addresses(&txid); | ||
| let has_our_inputs = input_addrs.iter().any(|ia| self.is_ours(&ia.address)); | ||
| if has_our_inputs { | ||
| continue; // Skip our own sends | ||
| } | ||
|
|
||
| let outputs = self.get_output_addresses(&txid); | ||
| if outputs.len() < MIN_OUTPUTS { | ||
| continue; | ||
| } | ||
|
|
||
| let dust_outputs: Vec<_> = outputs | ||
| .iter() | ||
| .filter(|o| o.value.to_sat() <= DUST_THRESHOLD) | ||
| .collect(); | ||
| if dust_outputs.len() < MIN_DUST_OUTPUTS { | ||
| continue; | ||
| } | ||
|
|
||
| let unique_addrs: HashSet<String> = outputs | ||
| .iter() | ||
| .map(|o| o.address.assume_checked_ref().to_string()) | ||
| .collect(); | ||
| let diversity = unique_addrs.len() as f64 / outputs.len() as f64; | ||
| if diversity < 0.8 { | ||
| continue; | ||
| } | ||
|
|
||
| // Our wallet received from this dust attack tx | ||
| let our_outs: Vec<_> = outputs | ||
| .iter() | ||
| .filter(|o| self.is_ours(&o.address)) | ||
| .collect(); | ||
| if our_outs.is_empty() { | ||
| continue; | ||
| } | ||
|
|
||
| findings.push(Finding { | ||
| vulnerability_type: VulnerabilityType::DustAttack, | ||
| severity: Severity::Critical, | ||
| description: format!( | ||
| "TX {} is a likely dust attack: {} outputs, {} of which are ≤{} sats, \ | ||
| targeting {} unique addresses", | ||
| txid, | ||
| outputs.len(), | ||
| dust_outputs.len(), | ||
| DUST_THRESHOLD, | ||
| unique_addrs.len() | ||
| ), | ||
| details: Some(json!({ | ||
| "txid": txid.to_string(), | ||
| "total_outputs": outputs.len(), | ||
| "dust_outputs": dust_outputs.len(), | ||
| "unique_addresses": unique_addrs.len(), | ||
| "diversity_ratio": (diversity * 100.0).round() as u32, | ||
| "our_received": our_outs.iter().map(|o| { | ||
| json!({ | ||
| "address": o.address.assume_checked_ref().to_string(), | ||
| "sats": o.value.to_sat() | ||
| }) | ||
| }).collect::<Vec<_>>(), | ||
| })), | ||
| correction: Some( | ||
| "Do NOT spend this dust UTXO — spending it reveals your other UTXOs \ | ||
| via common-input-ownership. Freeze it in your wallet immediately." | ||
| .into(), | ||
| ), | ||
| }); | ||
| } | ||
| } |
There was a problem hiding this comment.
Detector 13 (Dust Attack) flags a subset of what 3 (Dust) already catches — every dust attack UTXO is also a dust UTXO. Having both fire for the same output is confusing for the user ("is this dust or a dust attack?") and means we maintain two separate detection paths for overlapping cases.
Could you make Dust Attack run inside the Dust detector instead? When the Dust detector finds a dust UTXO, check if the parent transaction matches the dust attack pattern (≥10 outputs, ≥5 dust, high address diversity). If it does, report it as DUST_ATTACK instead of DUST. One finding per output, and the user gets the most specific diagnosis.
| } else if ambiguity >= 0.6 { | ||
| warnings.push(Finding { | ||
| vulnerability_type: VulnerabilityType::DeterministicLink, | ||
| severity: Severity::Low, | ||
| description: format!( | ||
| "TX {} has good ambiguity ({:.0}%, {} valid interpretations)", | ||
| txid, | ||
| ambiguity * 100.0, | ||
| total_valid | ||
| ), | ||
| details: Some(json!({ | ||
| "txid": txid.to_string(), | ||
| "total_valid_interpretations": total_valid, | ||
| "ambiguity_pct": (ambiguity * 100.0).round() as u32, | ||
| })), | ||
| correction: None, | ||
| }); | ||
| } |
There was a problem hiding this comment.
The "good ambiguity" warning fires when a transaction has ≥60% ambiguity — that's a positive signal, not a problem. Warnings should flag risks. Seeing "8 warnings" and discovering some are compliments is confusing.
Flip the logic: warn when ambiguity is low (e.g. <40%) but not fully deterministic. That's the risky middle ground — the link isn't proven, but an observer can make a strong guess. High ambiguity transactions don't need any output.
|
|
||
| ## Vulnerabilities detected | ||
|
|
||
| Stealth currently runs **12 detectors** in `stealth-engine`. |
| const TOXIC_UPPER: u64 = 10_000; | ||
| const DUST_LOWER: u64 = 546; |
There was a problem hiding this comment.
These thresholds should live in DetectorThresholds in model/src/config.rs so we could keep all tuning values in one configurable place
| let child_txid = self.find_spending_tx(&trace_txid, trace_vout); | ||
| let child_txid = match child_txid { | ||
| Some(t) => t, | ||
| None => break, | ||
| }; |
There was a problem hiding this comment.
| let child_txid = self.find_spending_tx(&trace_txid, trace_vout); | |
| let child_txid = match child_txid { | |
| Some(t) => t, | |
| None => break, | |
| }; | |
| let Some(child_txid) = self.find_spending_tx(&trace_txid, trace_vout) else { | |
| break; | |
| }; |
| let child_txid = self.find_spending_tx(&txid, out.index); | ||
| let child_txid = match child_txid { | ||
| Some(t) => t, | ||
| None => continue, | ||
| }; |
There was a problem hiding this comment.
| let child_txid = self.find_spending_tx(&txid, out.index); | |
| let child_txid = match child_txid { | |
| Some(t) => t, | |
| None => continue, | |
| }; | |
| let Some(child_txid) = self.find_spending_tx(&txid, out.index) else { | |
| continue; | |
| }; |
Depends on #16 (it will be in draft mode until its merged)
Ports 5 privacy heuristics inspired by am-i-exposed.
New privacy heuristics in this PR:
Reviewer Notes
Run integration tests with
cargo test.