1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
use crate::{
ChainEvent, CheckError, Hash, Headers, Height, Input, Params, Reask, State, TxId, Word,
};
use std::collections::{BTreeMap, BTreeSet};
/// A named result of an event on one affected transaction.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ChainUpdate {
/// The transaction selected by an index, never by a chain scan.
pub txid: TxId,
/// The transition's result. The updated state's re-ask survives an error.
pub result: Result<(), CheckError>,
/// The next host action after the event.
pub reask: Option<Reask>,
}
/// A host's tracked transactions, with indexes for targeted chain events.
/// Hints, ages and spend attempts name one transaction. A tip uses only the
/// suspect set; reorgs, forks and splits use only the relevant mined height
/// range.
#[derive(Clone, Debug)]
pub struct Tracker {
params: Params,
states: BTreeMap<TxId, State>,
mined: BTreeMap<Height, BTreeSet<TxId>>,
headers: BTreeMap<Hash, BTreeSet<TxId>>,
suspect: BTreeSet<TxId>,
}
#[derive(Debug, PartialEq, Eq)]
struct Index {
height: Option<Height>,
header: Option<Hash>,
suspect: bool,
}
impl Index {
fn of(state: &State) -> Self {
Self {
height: state.word().height(),
header: match state.word() {
Word::Mined(m) => Some(m.checked().header().hash.clone()),
_ => None,
},
suspect: state.suspect(),
}
}
}
impl Tracker {
/// An empty host registry with explicit age parameters.
#[must_use]
pub fn new(params: Params) -> Self {
Self {
params,
states: BTreeMap::new(),
mined: BTreeMap::new(),
headers: BTreeMap::new(),
suspect: BTreeSet::new(),
}
}
/// Begin tracking one transaction; repeating this does not erase its state.
pub fn track(&mut self, txid: impl Into<TxId>) -> &State {
let txid = txid.into().to_ascii_lowercase();
self.states
.entry(txid.clone())
.or_insert_with(|| State::new(txid))
}
/// Hold a state restored by [`State::from_compact`] and index it as a
/// transition would; a restored `mined` word joins the suspect set, so the
/// next tip rechecks it. The state it replaces, if the txid was tracked.
pub fn restore(&mut self, state: State) -> Option<State> {
let txid = state.txid().to_owned();
let tracked = self.states.contains_key(&txid);
let mut previous = None;
// The transition only swaps the state; it cannot fail.
let _ = self.apply_transition(&txid, |_, slot| {
previous = Some(std::mem::replace(slot, state));
Ok(())
});
previous.filter(|_| tracked)
}
/// An immutable state, or none when the host does not track this txid.
#[must_use]
pub fn get(&self, txid: &str) -> Option<&State> {
self.states.get(&txid.to_ascii_lowercase())
}
/// Number of transactions this host tracks.
#[must_use]
pub fn len(&self) -> usize {
self.states.len()
}
/// Whether the registry is empty.
#[must_use]
pub fn is_empty(&self) -> bool {
self.states.is_empty()
}
/// Apply an input to one transaction and maintain the event indexes,
/// including after a failed proof or header lookup.
///
/// # Errors
///
/// The [`CheckError`] of [`State::step`] for that transaction.
pub fn apply<H: Headers + ?Sized>(
&mut self,
txid: &str,
headers: &H,
input: Input,
) -> Result<(), CheckError> {
self.apply_transition(txid, |params, state| state.step(params, headers, input))
}
fn apply_transition(
&mut self,
txid: &str,
transition: impl FnOnce(&Params, &mut State) -> Result<(), CheckError>,
) -> Result<(), CheckError> {
let txid = txid.to_ascii_lowercase();
let state = self
.states
.entry(txid.clone())
.or_insert_with(|| State::new(&txid));
let old = Index::of(state);
let result = transition(&self.params, state);
let new = Index::of(state);
if old != new {
if let Some(h) = old.height {
remove_index(&mut self.mined, &h, &txid);
}
if let Some(hash) = old.header {
remove_index(&mut self.headers, &hash, &txid);
}
self.suspect.remove(&txid);
if let Some(h) = new.height {
self.mined.entry(h).or_default().insert(txid.clone());
}
if let Some(hash) = new.header {
self.headers.entry(hash).or_default().insert(txid.clone());
}
if new.suspect {
self.suspect.insert(txid);
}
}
result
}
/// Deliver an evidence event only to the set it names. The output contains
/// every selected txid and each fault for the host to report.
///
/// The selection is the bound of the recheck, never the stale mark: a
/// reorg names the mined words at or above its fork height and an
/// invalidation the words checked against its block, and each is rechecked
/// against `headers` by its stored root. Only a word whose root moved
/// comes back with [`Reask::Reorg`]; a host fetches proofs for those
/// alone, a few a pass, the pacing being the host's.
// By reference would break the public API; a later minor (bsv-stack-lean #134).
#[allow(clippy::needless_pass_by_value)]
pub fn on_chain<H: Headers + ?Sized>(
&mut self,
headers: &H,
event: ChainEvent,
) -> Vec<ChainUpdate> {
let targets: Vec<TxId> = match &event {
ChainEvent::Fork { height, .. } => self.at_or_above(*height),
ChainEvent::Reorg { fork_height, .. }
| ChainEvent::Split { fork_height, .. }
| ChainEvent::SplitResolved { fork_height, .. } => self.at_or_above(*fork_height),
ChainEvent::Invalidated { block_hash } => self
.headers
.get(block_hash)
.map(|ids| ids.iter().cloned().collect())
.unwrap_or_default(),
ChainEvent::Tip { .. } => self.suspect.iter().cloned().collect(),
ChainEvent::Frozen { .. } | ChainEvent::TipAge { .. } => vec![],
};
targets
.into_iter()
.map(|txid| {
let result =
self.apply_transition(&txid, |_, state| state.on_chain(headers, &event));
let reask = self.states[&txid].reask().cloned();
ChainUpdate {
txid,
result,
reask,
}
})
.collect()
}
fn at_or_above(&self, height: Height) -> Vec<TxId> {
self.mined
.range(height..)
.flat_map(|(_, ids)| ids.iter().cloned())
.collect()
}
}
fn remove_index<K: Ord>(index: &mut BTreeMap<K, BTreeSet<TxId>>, key: &K, txid: &str) {
if let Some(ids) = index.get_mut(key) {
ids.remove(txid);
if ids.is_empty() {
index.remove(key);
}
}
}