use std::collections::HashSet;
use bitcoin::consensus;
use bitcoin::hashes::{sha256, Hash};
use bitcoin::{Script, ScriptBuf, Transaction, Txid};
use crate::error::StreamError;
use crate::event::{Outpoint, PrefixMatch};
pub fn scripthash_of(script_pubkey: &[u8]) -> [u8; 32] {
sha256::Hash::hash(script_pubkey).to_byte_array()
}
pub fn prefix_of(script_pubkey: &[u8], bits: u32) -> (Vec<u8>, u32) {
let bits = bits.clamp(1, crate::client::MAX_PREFIX_BITS);
let sh = scripthash_of(script_pubkey);
(mask_prefix(&sh, bits), bits)
}
fn mask_prefix(scripthash: &[u8], bits: u32) -> Vec<u8> {
let n = (bits as usize).div_ceil(8);
let mut out = scripthash[..n].to_vec();
let rem = bits % 8;
if rem != 0 {
out[n - 1] &= 0xffu8 << (8 - rem);
}
out
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FundingHit {
pub vout: u32,
pub scripthash: [u8; 32],
pub value: u64,
pub script_pubkey: ScriptBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SpendingHit {
pub vin: Option<u32>,
pub outpoint: Outpoint,
pub scripthash: [u8; 32],
pub script_pubkey: ScriptBuf,
pub amount: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PrefixHits {
pub txid: Txid,
pub confirmed: bool,
pub height: u32,
pub funding: Vec<FundingHit>,
pub spending: Vec<SpendingHit>,
pub unresolved: Vec<Outpoint>,
}
impl PrefixHits {
pub fn is_match(&self) -> bool {
!self.funding.is_empty() || !self.spending.is_empty()
}
pub fn has_unresolved(&self) -> bool {
!self.unresolved.is_empty()
}
}
#[derive(Debug, Clone, Default)]
pub struct PrefixWatcher {
watched: HashSet<[u8; 32]>,
}
impl PrefixWatcher {
pub fn new() -> Self {
Self::default()
}
pub fn with_scripts<'a>(scripts: impl IntoIterator<Item = &'a Script>) -> Self {
let mut w = Self::new();
for s in scripts {
w.watch_script(s);
}
w
}
pub fn watch_script(&mut self, script_pubkey: &Script) -> [u8; 32] {
self.watch_script_bytes(script_pubkey.as_bytes())
}
pub fn watch_script_bytes(&mut self, script_pubkey: &[u8]) -> [u8; 32] {
let sh = scripthash_of(script_pubkey);
self.watched.insert(sh);
sh
}
pub fn unwatch_script(&mut self, script_pubkey: &Script) -> bool {
self.watched.remove(&scripthash_of(script_pubkey.as_bytes()))
}
pub fn is_watched(&self, scripthash: &[u8; 32]) -> bool {
self.watched.contains(scripthash)
}
pub fn len(&self) -> usize {
self.watched.len()
}
pub fn is_empty(&self) -> bool {
self.watched.is_empty()
}
pub fn prefixes(&self, bits: u32) -> Vec<(Vec<u8>, u32)> {
let bits = bits.clamp(1, crate::client::MAX_PREFIX_BITS);
let buckets: HashSet<Vec<u8>> =
self.watched.iter().map(|sh| mask_prefix(sh, bits)).collect();
let mut out: Vec<(Vec<u8>, u32)> = buckets.into_iter().map(|p| (p, bits)).collect();
out.sort();
out
}
pub fn filter(&self, m: &PrefixMatch) -> Result<PrefixHits, StreamError> {
let tx: Transaction = consensus::deserialize(&m.raw_tx)
.map_err(|e| StreamError::Decode(format!("prefix raw_tx decode: {e}")))?;
let txid = tx.compute_txid();
let mut funding = Vec::new();
for (vout, out) in tx.output.iter().enumerate() {
let sh = scripthash_of(out.script_pubkey.as_bytes());
if self.watched.contains(&sh) {
funding.push(FundingHit {
vout: vout as u32,
scripthash: sh,
value: out.value.to_sat(),
script_pubkey: out.script_pubkey.clone(),
});
}
}
let mut spending = Vec::new();
let mut unresolved = Vec::new();
for sp in &m.matched_prevouts {
if sp.script_pubkey.is_empty() {
unresolved.push(sp.outpoint.clone());
continue;
}
let sh = scripthash_of(&sp.script_pubkey);
if self.watched.contains(&sh) {
spending.push(SpendingHit {
vin: find_vin(&tx, &sp.outpoint),
outpoint: sp.outpoint.clone(),
scripthash: sh,
script_pubkey: ScriptBuf::from_bytes(sp.script_pubkey.clone()),
amount: sp.amount,
});
}
}
Ok(PrefixHits { txid, confirmed: m.confirmed, height: m.height, funding, spending, unresolved })
}
}
fn find_vin(tx: &Transaction, outpoint: &Outpoint) -> Option<u32> {
tx.input.iter().position(|i| {
i.previous_output.vout == outpoint.vout
&& i.previous_output.txid.to_byte_array().as_slice() == outpoint.txid.as_slice()
}).map(|p| p as u32)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::{ScriptPrefix, SpentPrevout};
use bitcoin::{Amount, OutPoint, Sequence, TxIn, TxOut, Witness};
fn spk(tag: u8) -> ScriptBuf {
ScriptBuf::from_bytes(vec![0x6a, 0x01, tag])
}
fn dummy_outpoint(byte: u8, vout: u32) -> (OutPoint, Outpoint) {
let txid = Txid::from_byte_array([byte; 32]);
(
OutPoint { txid, vout },
Outpoint { txid: txid.to_byte_array().to_vec(), vout },
)
}
fn tx_with(outputs: Vec<TxOut>, inputs: Vec<TxIn>) -> Transaction {
Transaction {
version: bitcoin::transaction::Version::TWO,
lock_time: bitcoin::absolute::LockTime::ZERO,
input: inputs,
output: outputs,
}
}
fn prefix_match(tx: &Transaction, prevouts: Vec<SpentPrevout>) -> PrefixMatch {
PrefixMatch {
prefix: ScriptPrefix { prefix: vec![0u8, 0u8], bits: 16 },
raw_tx: consensus::serialize(tx),
confirmed: true,
height: 800_000,
matched_prevouts: prevouts,
}
}
#[test]
fn scripthash_matches_single_sha256_of_spk() {
let s = spk(1);
let expect = sha256::Hash::hash(s.as_bytes()).to_byte_array();
assert_eq!(scripthash_of(s.as_bytes()), expect);
}
#[test]
fn prefix_of_masks_bits_below_the_declared_width() {
let s = spk(7);
let sh = scripthash_of(s.as_bytes());
let (p, bits) = prefix_of(s.as_bytes(), 12);
assert_eq!(bits, 12);
assert_eq!(p.len(), 2, "ceil(12/8) bytes on the wire");
assert_eq!(p[0], sh[0], "the first whole byte is inside the width");
assert_eq!(p[1], sh[1] & 0xf0, "the 4 bits past the width are zeroed");
let key = |b0: u8, b1: u8| u32::from_be_bytes([b0, b1, 0, 0]) & (u32::MAX << 20);
assert_eq!(key(p[0], p[1]), key(sh[0], sh[1]), "masking must not move the bucket");
}
#[test]
fn prefixes_collapse_when_scripts_share_a_sub_byte_bucket() {
fn spk_n(i: u32) -> ScriptBuf {
let mut v = vec![0x6a, 0x04];
v.extend_from_slice(&i.to_be_bytes());
ScriptBuf::from_bytes(v)
}
let mut pair = None;
let mut seen: std::collections::HashMap<[u8; 2], ScriptBuf> = Default::default();
for i in 0..20_000u32 {
let s = spk_n(i);
let sh = scripthash_of(s.as_bytes());
let bucket = [sh[0], sh[1] & 0xf0];
if let Some(prev) = seen.get(&bucket) {
if scripthash_of(prev.as_bytes())[1] != sh[1] {
pair = Some((prev.clone(), s));
break;
}
} else {
seen.insert(bucket, s);
}
}
let (a, b) = pair.expect("a 12-bit bucket collision within the search space");
let mut w = PrefixWatcher::new();
w.watch_script(&a);
w.watch_script(&b);
assert_eq!(w.len(), 2, "two distinct scripts are watched");
let buckets = w.prefixes(12);
assert_eq!(
buckets.len(),
1,
"scripts sharing a 12-bit bucket must collapse to one registration, got {buckets:?}"
);
assert_eq!(buckets[0].1, 12);
assert_eq!(buckets[0].0[1] & 0x0f, 0, "the registered bucket is masked");
}
#[test]
fn prefix_of_takes_top_bytes_of_scripthash() {
let s = spk(7);
let sh = scripthash_of(s.as_bytes());
assert_eq!(prefix_of(s.as_bytes(), 16), (sh[..2].to_vec(), 16));
assert_eq!(prefix_of(s.as_bytes(), 8), (sh[..1].to_vec(), 8));
assert_eq!(prefix_of(s.as_bytes(), 32), (sh[..4].to_vec(), 32));
assert_eq!(prefix_of(s.as_bytes(), 256), (sh[..4].to_vec(), 32));
}
#[test]
fn funding_match_only_for_watched_output() {
let watched = spk(1);
let decoy = spk(2);
let mut w = PrefixWatcher::new();
w.watch_script(&watched);
let tx = tx_with(
vec![
TxOut { value: Amount::from_sat(500), script_pubkey: decoy },
TxOut { value: Amount::from_sat(1234), script_pubkey: watched.clone() },
],
vec![],
);
let hits = w.filter(&prefix_match(&tx, vec![])).unwrap();
assert!(hits.is_match());
assert_eq!(hits.funding.len(), 1);
assert_eq!(hits.funding[0].vout, 1);
assert_eq!(hits.funding[0].value, 1234);
assert_eq!(hits.funding[0].script_pubkey, watched);
assert!(hits.spending.is_empty());
assert!(!hits.has_unresolved());
}
#[test]
fn decoy_only_tx_is_no_match() {
let mut w = PrefixWatcher::new();
w.watch_script(&spk(1));
let tx = tx_with(
vec![TxOut { value: Amount::from_sat(9), script_pubkey: spk(99) }],
vec![],
);
let hits = w.filter(&prefix_match(&tx, vec![])).unwrap();
assert!(!hits.is_match());
assert!(!hits.has_unresolved());
}
#[test]
fn spend_match_locates_vin_and_amount() {
let watched = spk(5);
let mut w = PrefixWatcher::new();
w.watch_script(&watched);
let (op_btc, op_wire) = dummy_outpoint(0xaa, 3);
let tx = tx_with(
vec![TxOut { value: Amount::from_sat(10), script_pubkey: spk(42) }],
vec![
TxIn {
previous_output: dummy_outpoint(0xbb, 0).0,
script_sig: ScriptBuf::new(),
sequence: Sequence::MAX,
witness: Witness::new(),
},
TxIn {
previous_output: op_btc,
script_sig: ScriptBuf::new(),
sequence: Sequence::MAX,
witness: Witness::new(),
},
],
);
let prevout = SpentPrevout {
outpoint: op_wire.clone(),
script_pubkey: watched.as_bytes().to_vec(),
amount: Some(7777),
};
let hits = w.filter(&prefix_match(&tx, vec![prevout])).unwrap();
assert_eq!(hits.spending.len(), 1);
assert_eq!(hits.spending[0].vin, Some(1)); assert_eq!(hits.spending[0].amount, Some(7777));
assert_eq!(hits.spending[0].outpoint, op_wire);
}
#[test]
fn unretained_prevout_surfaces_as_unresolved() {
let mut w = PrefixWatcher::new();
w.watch_script(&spk(5));
let (_op_btc, op_wire) = dummy_outpoint(0xcc, 1);
let tx = tx_with(
vec![TxOut { value: Amount::from_sat(10), script_pubkey: spk(42) }],
vec![],
);
let prevout = SpentPrevout { outpoint: op_wire.clone(), script_pubkey: vec![], amount: None };
let hits = w.filter(&prefix_match(&tx, vec![prevout])).unwrap();
assert!(!hits.is_match()); assert!(hits.has_unresolved());
assert_eq!(hits.unresolved, vec![op_wire]);
}
#[test]
fn prefixes_dedup_shared_bucket() {
let mut w = PrefixWatcher::new();
w.watch_script(&spk(1));
w.watch_script(&spk(2));
assert_eq!(w.prefixes(32).len(), 2);
assert_eq!(w.prefixes(256).len(), 2); assert!(w.prefixes(1).len() <= 2);
for (p, bits) in w.prefixes(16) {
assert_eq!(bits, 16);
assert_eq!(p.len(), 2);
}
}
#[test]
fn garbage_raw_tx_is_decode_error() {
let w = PrefixWatcher::new();
let m = PrefixMatch {
prefix: ScriptPrefix { prefix: vec![0, 0], bits: 16 },
raw_tx: vec![0xff, 0x00, 0x01],
confirmed: false,
height: 0,
matched_prevouts: vec![],
};
assert!(matches!(w.filter(&m), Err(StreamError::Decode(_))));
}
}