Skip to content

feat: port 5 vulnerability detection heuristics from am-i.exposed#19

Open
satsfy wants to merge 3 commits intostealth-bitcoin:mainfrom
satsfy:add-am-i-exposed-vulns
Open

feat: port 5 vulnerability detection heuristics from am-i.exposed#19
satsfy wants to merge 3 commits intostealth-bitcoin:mainfrom
satsfy:add-am-i-exposed-vulns

Conversation

@satsfy
Copy link
Copy Markdown
Collaborator

@satsfy satsfy commented Mar 26, 2026

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:

  • Dust attack — detects wallets receiving dust intended to poison future spends or reveal ownership
  • Peel chain — detects repeated spend patterns where value is peeled off across a chain of transactions
  • Deterministic links — detects transaction structures that create strong, guessable links between inputs and outputs
  • Unnecessary input — detects transactions that include more inputs than needed, increasing linkability
  • Toxic change — detects change outputs that are especially privacy-damaging to spend later

Reviewer Notes

Run integration tests with cargo test.

@satsfy satsfy changed the title feat: port 5 vulnerability detection heurustics from am-i.exposed feat: port 5 vulnerability detection heuristics from am-i.exposed Mar 26, 2026
@satsfy satsfy force-pushed the add-am-i-exposed-vulns branch from 68987d2 to 8b3d50c Compare April 6, 2026 00:30
@satsfy satsfy marked this pull request as ready for review April 6, 2026 00:31
Comment thread engine/src/detect.rs
Comment on lines +1520 to +1532
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
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
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()
}

Comment thread engine/src/detect.rs
Comment on lines +1014 to +1100

// ── 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(),
),
});
}
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread engine/src/detect.rs
Comment on lines +1337 to +1354
} 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,
});
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread README.md

## Vulnerabilities detected

Stealth currently runs **12 detectors** in `stealth-engine`.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

17 detectors, not 12

Comment thread engine/src/detect.rs
Comment on lines +1451 to +1452
const TOXIC_UPPER: u64 = 10_000;
const DUST_LOWER: u64 = 546;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These thresholds should live in DetectorThresholds in model/src/config.rs so we could keep all tuning values in one configurable place

Comment thread engine/src/detect.rs
Comment on lines +1158 to +1162
let child_txid = self.find_spending_tx(&trace_txid, trace_vout);
let child_txid = match child_txid {
Some(t) => t,
None => break,
};
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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;
};

Comment thread engine/src/detect.rs
Comment on lines +1478 to +1482
let child_txid = self.find_spending_tx(&txid, out.index);
let child_txid = match child_txid {
Some(t) => t,
None => continue,
};
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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;
};

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants