Skip to main content

doubleentry/log/
balance_tree.rs

1//! Seal balance trees: one RFC 6962 tree per (currency, layer) whose leaf `i`
2//! is account handle `i`'s gross balance.
3//!
4//! Every account has a leaf — the canonical zero leaf when it has no postings
5//! — so a leaf's index binds it to its handle, two rows for one account are
6//! impossible, and "no balance" is provable. Trees are built from the
7//! non-zero leaves only, using precomputed zero-subtree hashes, in
8//! `O((k + 1) · log n)` hashes for `k` non-zero leaves.
9
10use std::collections::BTreeMap;
11use std::sync::OnceLock;
12
13use super::{Hash, InclusionProof, ProofError, empty_root, leaf_hash, node_hash};
14use crate::model::{
15    AccountHandle, AmountError, BALANCE_LEAF_KIND, Balance, Currency, Layer, Seal, TreeHeadEntry,
16};
17
18/// The leaf bytes of a balance: `0x81 ‖ i128 debit ‖ i128 credit ‖ u64 postings`.
19///
20/// ```
21/// use doubleentry::{Balance, log::balance_leaf_bytes};
22///
23/// let zero = balance_leaf_bytes(&Balance::<2>::ZERO);
24/// assert_eq!(zero[0], 0x81);
25/// assert!(zero[1..].iter().all(|b| *b == 0));
26/// ```
27#[must_use]
28pub fn balance_leaf_bytes<const P: u8>(balance: &Balance<P>) -> [u8; 41] {
29    let bytes = core::iter::once(BALANCE_LEAF_KIND)
30        .chain(balance.debit().to_minor().to_le_bytes())
31        .chain(balance.credit().to_minor().to_le_bytes())
32        .chain(balance.postings().to_le_bytes());
33    let mut leaf = [0u8; 41];
34    for (slot, byte) in leaf.iter_mut().zip(bytes) {
35        *slot = byte;
36    }
37    leaf
38}
39
40/// The leaf hash of an account without postings.
41#[must_use]
42pub fn zero_leaf_hash() -> Hash {
43    zero_subtree(0)
44}
45
46/// `Z₀ … Z₆₂`: the roots of all-zero subtrees of `2^k` leaves.
47fn zeros() -> &'static [Hash; 63] {
48    static ZEROS: OnceLock<[Hash; 63]> = OnceLock::new();
49    ZEROS.get_or_init(|| {
50        let mut next = leaf_hash(&balance_leaf_bytes(&Balance::<0>::ZERO));
51        core::array::from_fn(|_| {
52            let current = next;
53            next = node_hash(&current, &current);
54            current
55        })
56    })
57}
58
59/// The root of an all-zero subtree of `2^level` leaves.
60fn zero_subtree(level: u32) -> Hash {
61    match usize::try_from(level)
62        .ok()
63        .and_then(|level| zeros().get(level))
64    {
65        Some(hash) => *hash,
66        // Beyond the table (never for `u32` account counts): fold directly.
67        None => (0..level).fold(zero_subtree(0), |hash, _| node_hash(&hash, &hash)),
68    }
69}
70
71/// The largest power of two strictly below `n`, for `n ≥ 2`.
72fn split(n: u32) -> u32 {
73    n.saturating_sub(1)
74        .checked_ilog2()
75        .map_or(0, |exponent| 1u32.rotate_left(exponent))
76}
77
78/// The hashes of the aligned complete subtrees that contain a non-zero leaf,
79/// by `(level, index)`: everything else is a zero subtree.
80type Nodes = BTreeMap<(u32, u32), Hash>;
81
82/// One (currency, layer) of a seal: `accounts` leaves, the non-zero ones kept.
83///
84/// ```
85/// use doubleentry::{AccountHandle, Balance, Layer, Side, amount, currency::EUR, log::BalanceTree};
86///
87/// let mut tree: BalanceTree = BalanceTree::new(EUR, Layer::Settled, 3);
88/// let cash = Balance::ZERO.post(Side::Debit, amount!("1190.00"))?;
89/// let sales = Balance::ZERO.post(Side::Credit, amount!("1190.00"))?;
90/// tree.set(AccountHandle::new(0).unwrap(), cash)?;
91/// tree.set(AccountHandle::new(2).unwrap(), sales)?;
92/// assert_eq!(
93///     tree.root().to_string(),
94///     "61a2dffcc9633717adfac15db9f40c2f9390bfab45181a7e2dad078cf25451e8",
95/// );
96/// # Ok::<(), Box<dyn std::error::Error>>(())
97/// ```
98#[derive(Clone)]
99pub struct BalanceTree<const P: u8 = 2> {
100    currency: Currency,
101    layer: Layer,
102    accounts: u32,
103    leaves: BTreeMap<u32, Balance<P>>,
104    /// Built once on the first root or proof after a change, in
105    /// `O(k · log accounts)` hashes; every proof then costs `O(log accounts)`.
106    nodes: OnceLock<Nodes>,
107}
108
109impl<const P: u8> PartialEq for BalanceTree<P> {
110    fn eq(&self, other: &Self) -> bool {
111        (self.currency, self.layer, self.accounts, &self.leaves)
112            == (other.currency, other.layer, other.accounts, &other.leaves)
113    }
114}
115
116impl<const P: u8> Eq for BalanceTree<P> {}
117
118impl<const P: u8> core::fmt::Debug for BalanceTree<P> {
119    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
120        f.debug_struct("BalanceTree")
121            .field("currency", &self.currency)
122            .field("layer", &self.layer)
123            .field("accounts", &self.accounts)
124            .field("leaves", &self.leaves)
125            .finish()
126    }
127}
128
129impl<const P: u8> BalanceTree<P> {
130    /// An all-zero tree of `accounts` leaves.
131    #[must_use]
132    pub const fn new(currency: Currency, layer: Layer, accounts: u32) -> Self {
133        Self {
134            currency,
135            layer,
136            accounts,
137            leaves: BTreeMap::new(),
138            nodes: OnceLock::new(),
139        }
140    }
141
142    /// Sets a handle's balance; a zero balance removes it.
143    ///
144    /// # Errors
145    ///
146    /// [`ProofError::IndexOutOfRange`] for a handle not below `accounts`.
147    pub fn set(&mut self, handle: AccountHandle, balance: Balance<P>) -> Result<(), ProofError> {
148        let index = handle.get();
149        if index >= self.accounts {
150            return Err(ProofError::IndexOutOfRange {
151                index: u64::from(index),
152                size: u64::from(self.accounts),
153            });
154        }
155        if balance.is_zero() {
156            self.leaves.remove(&index);
157        } else {
158            self.leaves.insert(index, balance);
159        }
160        self.nodes.take();
161        Ok(())
162    }
163
164    /// The currency.
165    #[must_use]
166    pub const fn currency(&self) -> Currency {
167        self.currency
168    }
169
170    /// The layer.
171    #[must_use]
172    pub const fn layer(&self) -> Layer {
173        self.layer
174    }
175
176    /// The number of leaves.
177    #[must_use]
178    pub const fn accounts(&self) -> u32 {
179        self.accounts
180    }
181
182    /// The RFC 6962 root over all `accounts` leaves.
183    #[must_use]
184    pub fn root(&self) -> Hash {
185        if self.accounts == 0 {
186            return empty_root();
187        }
188        let nodes = self.nodes();
189        range_root(0, self.accounts, &mut |level, index| {
190            lookup(nodes, level, index)
191        })
192    }
193
194    /// The component-wise sum of every leaf.
195    ///
196    /// # Errors
197    ///
198    /// [`AmountError::Overflow`] when a total leaves its range.
199    pub fn totals(&self) -> Result<Balance<P>, AmountError> {
200        self.leaves
201            .values()
202            .try_fold(Balance::ZERO, |sum, balance| sum.checked_add(balance))
203    }
204
205    /// The seal entry for this tree: currency, layer, root and totals.
206    ///
207    /// # Errors
208    ///
209    /// As [`totals`](Self::totals).
210    pub fn head(&self) -> Result<TreeHeadEntry<P>, AmountError> {
211        Ok(TreeHeadEntry {
212            currency: self.currency,
213            layer: self.layer,
214            root: self.root(),
215            totals: self.totals()?,
216        })
217    }
218
219    /// The proof of one handle's balance (the zero leaf for an account
220    /// without postings).
221    ///
222    /// # Errors
223    ///
224    /// [`ProofError::IndexOutOfRange`] for a handle not below `accounts`.
225    pub fn prove(&self, handle: AccountHandle) -> Result<BalanceProof<P>, ProofError> {
226        let index = handle.get();
227        if index >= self.accounts {
228            return Err(ProofError::IndexOutOfRange {
229                index: u64::from(index),
230                size: u64::from(self.accounts),
231            });
232        }
233        let mut path = Vec::new();
234        let nodes = self.nodes();
235        walk_path(0, self.accounts, index, &mut path, &mut |level, at| {
236            lookup(nodes, level, at)
237        });
238        Ok(BalanceProof {
239            currency: self.currency,
240            layer: self.layer,
241            handle,
242            balance: self.leaves.get(&index).copied().unwrap_or(Balance::ZERO),
243            path: InclusionProof {
244                index: u64::from(index),
245                size: u64::from(self.accounts),
246                path,
247            },
248        })
249    }
250
251    /// The aligned non-zero subtrees, built bottom-up from the non-zero
252    /// leaves: one hash per node that has a non-zero leaf below it and lies
253    /// inside the tree.
254    fn nodes(&self) -> &Nodes {
255        self.nodes.get_or_init(|| {
256            let mut nodes: Nodes = self
257                .leaves
258                .iter()
259                .map(|(index, balance)| ((0, *index), leaf_hash(&balance_leaf_bytes(balance))))
260                .collect();
261            let mut level_nodes: Vec<u32> = self.leaves.keys().copied().collect();
262            for level in 0..u32::BITS {
263                let width = 2u64.pow(level.saturating_add(1));
264                let mut parents: Vec<u32> = level_nodes.iter().map(|index| index / 2).collect();
265                parents.dedup();
266                // Only nodes whose whole range lies inside the tree are used.
267                parents.retain(|parent| {
268                    u64::from(*parent).saturating_add(1).saturating_mul(width)
269                        <= u64::from(self.accounts)
270                });
271                for parent in &parents {
272                    let child = |index: u32| {
273                        nodes
274                            .get(&(level, index))
275                            .copied()
276                            .unwrap_or_else(|| zero_subtree(level))
277                    };
278                    // A retained parent lies inside the tree, so its children
279                    // are below `accounts` and the products cannot saturate.
280                    let left = parent.saturating_mul(2);
281                    let hash = node_hash(&child(left), &child(left.saturating_add(1)));
282                    nodes.insert((level.saturating_add(1), *parent), hash);
283                }
284                if parents.is_empty() {
285                    break;
286                }
287                level_nodes = parents;
288            }
289            nodes
290        })
291    }
292
293    /// Every non-zero leaf, by handle.
294    pub(crate) fn leaves(&self) -> impl Iterator<Item = (u32, Balance<P>)> + '_ {
295        self.leaves
296            .iter()
297            .map(|(index, balance)| (*index, *balance))
298    }
299
300    /// One handle's leaf (zero without postings).
301    pub(crate) fn leaf(&self, handle: AccountHandle) -> Balance<P> {
302        self.leaves
303            .get(&handle.get())
304            .copied()
305            .unwrap_or(Balance::ZERO)
306    }
307
308    /// The stored nodes of the tree: the hash of every aligned subtree that
309    /// lies inside it and holds a non-zero leaf, by `(level, index)`
310    /// (level 0: the non-zero leaves). Every other aligned subtree is a zero
311    /// subtree, so these are all a proof needs ([`proof_nodes`]).
312    pub(crate) fn stored_nodes(&self) -> impl Iterator<Item = (u32, u32, Hash)> + '_ {
313        self.nodes()
314            .iter()
315            .map(|((level, index), hash)| (*level, *index, *hash))
316    }
317
318    /// The tree of `accounts` leaves known only by the stored nodes a proof
319    /// of `handle` reads ([`proof_nodes`]) and that handle's balance: what
320    /// [`prove`](Self::prove) needs to prove it in `O(log accounts)`.
321    pub(crate) fn from_nodes(
322        currency: Currency,
323        layer: Layer,
324        accounts: u32,
325        leaf: (AccountHandle, Balance<P>),
326        nodes: impl IntoIterator<Item = ((u32, u32), Hash)>,
327    ) -> Self {
328        let mut leaves = BTreeMap::new();
329        if !leaf.1.is_zero() {
330            leaves.insert(leaf.0.get(), leaf.1);
331        }
332        Self {
333            currency,
334            layer,
335            accounts,
336            leaves,
337            nodes: OnceLock::from(nodes.into_iter().collect::<Nodes>()),
338        }
339    }
340}
341
342/// A stored node, or the zero subtree of its level when none is stored.
343fn lookup(nodes: &Nodes, level: u32, index: u32) -> Hash {
344    nodes
345        .get(&(level, index))
346        .copied()
347        .unwrap_or_else(|| zero_subtree(level))
348}
349
350/// The root of leaves `[lo, lo + n)`, `n ≥ 1`, from the aligned subtrees
351/// `node(level, index)` answers. Power-of-two ranges are aligned in RFC
352/// 6962's decomposition, so they are looked up; only the ragged right edge
353/// is hashed here.
354fn range_root(lo: u32, n: u32, node: &mut dyn FnMut(u32, u32) -> Hash) -> Hash {
355    if n.is_power_of_two() {
356        let level = n.trailing_zeros();
357        return node(level, lo.checked_shr(level).unwrap_or(0));
358    }
359    let k = split(n);
360    let left = range_root(lo, k, node);
361    let right = range_root(lo.saturating_add(k), n.saturating_sub(k), node);
362    node_hash(&left, &right)
363}
364
365/// The RFC 6962 inclusion path of `index` within `[lo, lo + n)`.
366fn walk_path(
367    lo: u32,
368    n: u32,
369    index: u32,
370    out: &mut Vec<Hash>,
371    node: &mut dyn FnMut(u32, u32) -> Hash,
372) {
373    if n <= 1 {
374        return;
375    }
376    let k = split(n);
377    let mid = lo.saturating_add(k);
378    if index < mid {
379        walk_path(lo, k, index, out, node);
380        out.push(range_root(mid, n.saturating_sub(k), node));
381    } else {
382        walk_path(mid, n.saturating_sub(k), index, out, node);
383        out.push(range_root(lo, k, node));
384    }
385}
386
387/// The aligned subtrees, `(level, index)`, a proof of `handle` in a tree of
388/// `accounts` leaves reads: `O(log² accounts)` at most.
389pub(crate) fn proof_nodes(accounts: u32, handle: AccountHandle) -> Vec<(u32, u32)> {
390    let mut ids = Vec::new();
391    let mut path = Vec::new();
392    walk_path(0, accounts, handle.get(), &mut path, &mut |level, index| {
393        ids.push((level, index));
394        zero_subtree(level)
395    });
396    ids.sort_unstable();
397    ids.dedup();
398    ids
399}
400
401/// Proof of one handle's balance in one (currency, layer) of a seal.
402#[derive(Debug, Clone, PartialEq, Eq)]
403#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
404pub struct BalanceProof<const P: u8 = 2> {
405    /// The currency.
406    pub currency: Currency,
407    /// The layer.
408    pub layer: Layer,
409    /// The account's handle.
410    pub handle: AccountHandle,
411    /// The account's gross balance.
412    pub balance: Balance<P>,
413    /// The leaf's path in the balance tree.
414    pub path: InclusionProof,
415}
416
417impl<const P: u8> BalanceProof<P> {
418    /// Verifies the balance against the seal's tree for its (currency, layer):
419    /// the leaf index is the handle, the tree size is the seal's account
420    /// count, and the path reaches the committed root.
421    ///
422    /// # Errors
423    ///
424    /// [`ProofError::NoSuchTree`], [`ProofError::HandleMismatch`],
425    /// [`ProofError::AccountCountMismatch`], or the errors of
426    /// [`InclusionProof::root_from`] and [`ProofError::RootMismatch`].
427    pub fn verify(&self, seal: &Seal<P>) -> Result<(), ProofError> {
428        let tree = seal
429            .tree(self.currency, self.layer)
430            .ok_or(ProofError::NoSuchTree {
431                currency: self.currency,
432                layer: self.layer,
433            })?;
434        if self.path.index != u64::from(self.handle.get()) {
435            return Err(ProofError::HandleMismatch {
436                index: self.path.index,
437                handle: self.handle.get(),
438            });
439        }
440        let accounts = seal.parts().accounts;
441        if self.path.size != u64::from(accounts) {
442            return Err(ProofError::AccountCountMismatch {
443                tree_size: self.path.size,
444                accounts,
445            });
446        }
447        let computed = self
448            .path
449            .root_from(&leaf_hash(&balance_leaf_bytes(&self.balance)))?;
450        if computed == tree.root {
451            Ok(())
452        } else {
453            Err(ProofError::RootMismatch {
454                expected: tree.root,
455                computed,
456            })
457        }
458    }
459}
460
461/// Builds one tree per (currency, layer) that has a non-zero balance, in
462/// ascending (currency, layer) order — the trees a seal commits to.
463///
464/// ```
465/// use doubleentry::{AccountHandle, Balance, Layer, Side, amount, currency::EUR, log::build_seal_trees};
466///
467/// let cash: Balance = Balance::ZERO.post(Side::Debit, amount!("10.00"))?;
468/// let trees = build_seal_trees(2, [(AccountHandle::new(0).unwrap(), EUR, Layer::Settled, cash)])?;
469/// assert_eq!(trees.len(), 1);
470/// assert_eq!(trees[0].totals()?, cash);
471/// # Ok::<(), Box<dyn std::error::Error>>(())
472/// ```
473///
474/// # Errors
475///
476/// [`ProofError::IndexOutOfRange`] for a handle not below `accounts`;
477/// [`ProofError::DuplicateBalance`] for a (handle, currency, layer) given twice.
478pub fn build_seal_trees<const P: u8>(
479    accounts: u32,
480    balances: impl IntoIterator<Item = (AccountHandle, Currency, Layer, Balance<P>)>,
481) -> Result<Vec<BalanceTree<P>>, ProofError> {
482    let mut trees: BTreeMap<(Currency, Layer), BalanceTree<P>> = BTreeMap::new();
483    for (handle, currency, layer, balance) in balances {
484        if balance.is_zero() {
485            continue;
486        }
487        let tree = trees
488            .entry((currency, layer))
489            .or_insert_with(|| BalanceTree::new(currency, layer, accounts));
490        if tree.leaves.contains_key(&handle.get()) {
491            return Err(ProofError::DuplicateBalance {
492                handle: handle.get(),
493            });
494        }
495        tree.set(handle, balance)?;
496    }
497    Ok(trees.into_values().collect())
498}
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503    use crate::log::hash::counter;
504    use crate::log::root_of;
505    use crate::model::{Amount, EUR, SealParts, Side};
506
507    fn balance(minor: i128) -> Balance {
508        Balance::ZERO
509            .post(Side::Debit, Amount::from_minor(minor).unwrap())
510            .unwrap()
511    }
512
513    #[test]
514    fn a_million_accounts_with_a_thousand_balances_take_under_50000_hashes() {
515        let accounts = 1_000_000u32;
516        let mut tree = BalanceTree::new(EUR, Layer::Settled, accounts);
517        let handles: Vec<u32> = (0..1000u32).map(|i| i * 997 + 3).collect();
518        for (i, handle) in handles.iter().enumerate() {
519            tree.set(AccountHandle::new(*handle).unwrap(), balance(i as i128 + 1))
520                .unwrap();
521        }
522        zeros();
523        let (root, hashes) = counter::count(|| tree.root());
524        assert!(hashes < 50_000, "{hashes} hashes");
525
526        let head = tree.head().unwrap();
527        let seal = Seal::new(SealParts {
528            period: crate::model::Label::new("p").unwrap(),
529            start: time::macros::date!(2026 - 01 - 01),
530            end: time::macros::date!(2026 - 01 - 31),
531            through: 1,
532            accounts,
533            trees: vec![head],
534        })
535        .unwrap();
536        assert_eq!(seal.tree(EUR, Layer::Settled).unwrap().root, root);
537        let zero_handles = (0..1000u32)
538            .map(|i| i * 991 + 1)
539            .filter(|h| !handles.contains(h));
540        for handle in handles.iter().copied().chain(zero_handles) {
541            let proof = tree.prove(AccountHandle::new(handle).unwrap()).unwrap();
542            proof.verify(&seal).unwrap();
543        }
544    }
545
546    #[test]
547    fn sparse_roots_equal_the_explicit_tree() {
548        for accounts in 1..=40u32 {
549            let mut tree = BalanceTree::<2>::new(EUR, Layer::Settled, accounts);
550            let mut explicit = Vec::new();
551            for handle in 0..accounts {
552                let b = if handle % 3 == 1 {
553                    balance(i128::from(handle))
554                } else {
555                    Balance::ZERO
556                };
557                tree.set(AccountHandle::new(handle).unwrap(), b).unwrap();
558                explicit.push(leaf_hash(&balance_leaf_bytes(&b)));
559            }
560            assert_eq!(tree.root(), root_of(&explicit), "accounts = {accounts}");
561        }
562    }
563}