use std::collections::{BTreeMap, BTreeSet};
use crate::account::AccountBindingProof;
use crate::balance::{Balance, BalanceKey, TrialBalance};
use crate::canonical::{Canonical, CanonicalWriter};
use crate::hash::{Hash, tag, tagged};
use crate::merkle::{
ConsistencyProof, InclusionProof, MerkleLog, ProofError, TreeHead, empty_root,
};
use crate::period::{LedgerId, PeriodId};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct PeriodCoverage {
pub first_index: Option<u64>,
pub last_index: Option<u64>,
pub entry_count: u64,
}
impl PeriodCoverage {
pub const EMPTY: Self = Self {
first_index: None,
last_index: None,
entry_count: 0,
};
#[must_use]
pub fn spanning(first: u64, last: u64, entry_count: u64) -> Self {
Self {
first_index: Some(first),
last_index: Some(last),
entry_count,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct Seal {
pub ledger: LedgerId,
pub period: PeriodId,
pub first_index: Option<u64>,
pub last_index: Option<u64>,
pub entry_count: u64,
pub tree_head: TreeHead,
pub trial_balance: TreeHead,
pub accounts: TreeHead,
pub prev_seal: Option<Hash>,
pub prev_consistency: Option<ConsistencyProof>,
pub seal_hash: Hash,
}
impl Seal {
pub fn build<const P: u8>(
ledger: LedgerId,
period: PeriodId,
coverage: PeriodCoverage,
log: &MerkleLog,
trial_balance: &TrialBalance<P>,
accounts: TreeHead,
previous: Option<&Self>,
) -> Result<Self, SealChainError> {
let tree_head = log.head();
let prev_consistency = match previous {
Some(prev) if prev.tree_head.size > 0 => Some(
log.consistency_proof_between(prev.tree_head.size, tree_head.size)
.map_err(|_| SealChainError::NotAPrefix {
period: period.clone(),
})?,
),
_ => None,
};
Self::from_parts(
ledger,
period,
coverage,
tree_head,
prev_consistency,
trial_balance,
accounts,
previous,
)
}
#[allow(clippy::too_many_arguments)]
pub fn from_parts<const P: u8>(
ledger: LedgerId,
period: PeriodId,
coverage: PeriodCoverage,
tree_head: TreeHead,
prev_consistency: Option<ConsistencyProof>,
trial_balance: &TrialBalance<P>,
accounts: TreeHead,
previous: Option<&Self>,
) -> Result<Self, SealChainError> {
check_extends(&period, previous, &tree_head, prev_consistency.as_ref())?;
let trial_balance = trial_balance_head(trial_balance);
let mut seal = Self {
ledger,
period,
first_index: coverage.first_index,
last_index: coverage.last_index,
entry_count: coverage.entry_count,
tree_head,
trial_balance,
accounts,
prev_seal: previous.map(|p| p.seal_hash),
prev_consistency,
seal_hash: Hash::from_bytes([0u8; 32]),
};
seal.seal_hash = seal.compute_hash();
Ok(seal)
}
#[must_use]
pub fn compute_hash(&self) -> Hash {
let mut w = CanonicalWriter::new();
self.encode(&mut w);
tagged(tag::SEAL_V1, &w.finish())
}
#[must_use]
pub fn is_self_consistent(&self) -> bool {
self.compute_hash() == self.seal_hash
}
#[must_use]
pub fn index_span(&self) -> Option<(u64, u64)> {
match (self.first_index, self.last_index) {
(Some(first), Some(last)) if last >= first => Some((first, last)),
_ => None,
}
}
}
#[cfg(feature = "serde")]
#[derive(serde::Deserialize)]
struct SealOwned {
ledger: LedgerId,
period: PeriodId,
first_index: Option<u64>,
last_index: Option<u64>,
entry_count: u64,
tree_head: TreeHead,
trial_balance: TreeHead,
accounts: TreeHead,
prev_seal: Option<Hash>,
prev_consistency: Option<ConsistencyProof>,
seal_hash: Hash,
}
#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for Seal {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let raw = SealOwned::deserialize(d)?;
let seal = Self {
ledger: raw.ledger,
period: raw.period,
first_index: raw.first_index,
last_index: raw.last_index,
entry_count: raw.entry_count,
tree_head: raw.tree_head,
trial_balance: raw.trial_balance,
accounts: raw.accounts,
prev_seal: raw.prev_seal,
prev_consistency: raw.prev_consistency,
seal_hash: raw.seal_hash,
};
if seal.is_self_consistent() {
Ok(seal)
} else {
Err(serde::de::Error::custom(
"seal does not match its own contents",
))
}
}
}
impl Canonical for Seal {
fn encode(&self, w: &mut CanonicalWriter) {
self.ledger.encode(w);
self.period.encode(w);
w.option(self.first_index.as_ref(), |w, v| {
w.u64(*v);
});
w.option(self.last_index.as_ref(), |w, v| {
w.u64(*v);
});
w.u64(self.entry_count);
w.u64(self.tree_head.size);
w.fixed(self.tree_head.root.as_bytes());
w.u64(self.trial_balance.size);
w.fixed(self.trial_balance.root.as_bytes());
w.u64(self.accounts.size);
w.fixed(self.accounts.root.as_bytes());
w.option(self.prev_seal.as_ref(), |w, v| {
w.fixed(v.as_bytes());
});
w.option(self.prev_consistency.as_ref(), |w, v| v.encode(w));
}
}
#[must_use]
pub fn trial_balance_head<const P: u8>(tb: &TrialBalance<P>) -> TreeHead {
TrialBalanceCommitment::of(tb).head()
}
#[must_use]
pub fn balance_leaf<const P: u8>(key: &BalanceKey, balance: &Balance<P>) -> Hash {
let mut w = CanonicalWriter::new();
w.u32(key.account.index());
w.fixed(key.currency.as_bytes());
w.u8(key.layer.discriminant());
w.u8(P);
w.i64(balance.debits.to_minor());
w.i64(balance.credits.to_minor());
tagged(tag::TRIAL_BALANCE_V1, &w.finish())
}
#[derive(Debug, Clone)]
pub struct TrialBalanceCommitment<const P: u8> {
rows: Vec<(BalanceKey, Balance<P>)>,
log: MerkleLog,
}
impl<const P: u8> TrialBalanceCommitment<P> {
#[must_use]
pub fn of(tb: &TrialBalance<P>) -> Self {
let rows: Vec<(BalanceKey, Balance<P>)> = tb.iter().map(|(k, b)| (*k, *b)).collect();
let leaves = rows.iter().map(|(k, b)| balance_leaf(k, b)).collect();
Self {
rows,
log: MerkleLog::from_leaves(leaves),
}
}
#[must_use]
pub fn head(&self) -> TreeHead {
self.log.head()
}
#[must_use]
pub fn root(&self) -> Hash {
self.log.root()
}
#[must_use]
pub fn len(&self) -> usize {
self.rows.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.rows.is_empty()
}
#[must_use]
pub fn prove(&self, key: &BalanceKey) -> Option<BalanceProof<P>> {
let index = self.rows.binary_search_by(|(k, _)| k.cmp(key)).ok()?;
let (key, balance) = self.rows.get(index).copied()?;
let proof = self.log.inclusion_proof(index as u64).ok()?;
Some(BalanceProof {
key,
balance,
proof,
})
}
pub fn prove_all(&self) -> Result<Vec<BalanceProof<P>>, ProofError> {
self.rows
.iter()
.enumerate()
.map(|(index, (key, balance))| {
Ok(BalanceProof {
key: *key,
balance: *balance,
proof: self.log.inclusion_proof(index as u64)?,
})
})
.collect()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct BalanceProof<const P: u8> {
pub key: BalanceKey,
pub balance: Balance<P>,
pub proof: InclusionProof,
}
impl<const P: u8> BalanceProof<P> {
#[must_use]
pub fn verify(&self, trial_balance: &TreeHead) -> bool {
self.proof
.verify(&balance_leaf(&self.key, &self.balance), trial_balance)
}
#[must_use]
pub fn verify_against(&self, seal: &Seal) -> bool {
seal.is_self_consistent() && self.verify(&seal.trial_balance)
}
#[must_use]
pub fn verify_naming(&self, binding: &AccountBindingProof, seal: &Seal) -> bool {
self.verify_against(seal)
&& binding.id() == self.key.account
&& binding.verify(&seal.accounts)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum SealedBalanceOutcome<const P: u8> {
Proven(Box<SealedBalance<P>>),
NoRow,
NotYetRegistered,
}
impl<const P: u8> SealedBalanceOutcome<P> {
#[must_use]
pub fn proven(&self) -> Option<&SealedBalance<P>> {
match self {
Self::Proven(balance) => Some(balance),
_ => None,
}
}
#[must_use]
pub fn into_proven(self) -> Option<SealedBalance<P>> {
match self {
Self::Proven(balance) => Some(*balance),
_ => None,
}
}
#[must_use]
pub fn is_absent(&self) -> bool {
!matches!(self, Self::Proven(_))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct SealedBalance<const P: u8> {
pub seal: Seal,
pub balance: BalanceProof<P>,
pub binding: AccountBindingProof,
}
impl<const P: u8> SealedBalance<P> {
pub fn assemble(
seal: Seal,
closing: &TrialBalance<P>,
accounts: &crate::account::AccountRegistry,
key: BalanceKey,
) -> Result<SealedBalanceOutcome<P>, SealedBalanceError> {
let commitment = TrialBalanceCommitment::of(closing);
if commitment.head() != seal.trial_balance {
return Err(SealedBalanceError::Restated {
period: seal.period,
});
}
let Some(binding) = accounts.prove_binding_at(key.account, seal.accounts.size) else {
return Ok(SealedBalanceOutcome::NotYetRegistered);
};
if !binding.verify(&seal.accounts) {
return Err(SealedBalanceError::RegistryMismatch {
period: seal.period,
});
}
Ok(match commitment.prove(&key) {
Some(balance) => SealedBalanceOutcome::Proven(Box::new(Self {
seal,
balance,
binding,
})),
None => SealedBalanceOutcome::NoRow,
})
}
#[must_use]
pub fn verify(&self) -> bool {
self.balance.verify_naming(&self.binding, &self.seal)
}
#[must_use]
pub fn path(&self) -> &crate::account::AccountPath {
self.binding.path()
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum SealedBalanceError {
#[error("period {period} has not been sealed")]
NotSealed {
period: PeriodId,
},
#[error("period {period} is sealed but no longer defined")]
UndefinedPeriod {
period: PeriodId,
},
#[error("the rebuilt closing balance does not match the seal for period {period}")]
Restated {
period: PeriodId,
},
#[error("the account bindings do not match the seal for period {period}")]
RegistryMismatch {
period: PeriodId,
},
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum SealChainError {
#[error("seal for period {period} does not match its own contents")]
Tampered {
period: PeriodId,
},
#[error("seal for period {period} does not chain to its predecessor")]
BrokenChain {
period: PeriodId,
},
#[error("seal for period {period} belongs to ledger {found}, not {expected}")]
ForeignLedger {
period: PeriodId,
expected: LedgerId,
found: LedgerId,
},
#[error("period {period} is sealed more than once in this chain")]
DuplicatePeriod {
period: PeriodId,
},
#[error("seal for period {period} has an unexpected predecessor reference")]
MisplacedGenesis {
period: PeriodId,
},
#[error("seal for period {period} does not extend the previous tree")]
NonMonotonic {
period: PeriodId,
},
#[error(
"seal for period {period} carries no consistency proof from its \
predecessor's tree of {previous_size} entries"
)]
MissingConsistency {
period: PeriodId,
previous_size: u64,
},
#[error("seal for period {period} carries a consistency proof it cannot have one for")]
UnexpectedConsistency {
period: PeriodId,
},
#[error("seal for period {period} does not prove its log extends its predecessor's")]
NotAPrefix {
period: PeriodId,
},
#[error("seal for period {period} follows a zero-size tree that is not the empty tree")]
NotTheEmptyTree {
period: PeriodId,
},
#[error("seal for period {period} commits to root {expected}, but this log held {found}")]
HeadMismatch {
period: PeriodId,
expected: Hash,
found: Hash,
},
#[error(
"seal for period {period} commits to {claimed} entries, but the log holds \
only {holds}"
)]
BeyondTheLog {
period: PeriodId,
claimed: u64,
holds: u64,
},
#[error(
"seal for period {period} commits to {found} account bindings, fewer than \
the {expected} its predecessor did"
)]
ShrunkenRegistry {
period: PeriodId,
expected: u64,
found: u64,
},
#[error(
"seal for period {period} commits to {claimed} account bindings, but the \
registry holds only {holds}"
)]
BeyondTheRegistry {
period: PeriodId,
claimed: u64,
holds: u64,
},
#[error(
"seal for period {period} commits to account bindings {expected}, but the \
registry's first {size} handles hash to {found}"
)]
AccountsRebound {
period: PeriodId,
size: u64,
expected: Hash,
found: Hash,
},
}
fn check_extends(
period: &PeriodId,
previous: Option<&Seal>,
tree_head: &TreeHead,
proof: Option<&ConsistencyProof>,
) -> Result<(), SealChainError> {
let at = || period.clone();
match (previous, proof) {
(None, None) => Ok(()),
(None, Some(_)) => Err(SealChainError::UnexpectedConsistency { period: at() }),
(Some(prev), proof) => match (prev.tree_head.size, proof) {
(0, None) => {
if prev.tree_head.root == empty_root() {
Ok(())
} else {
Err(SealChainError::NotTheEmptyTree { period: at() })
}
}
(0, Some(_)) => Err(SealChainError::UnexpectedConsistency { period: at() }),
(previous_size, None) => Err(SealChainError::MissingConsistency {
period: at(),
previous_size,
}),
(_, Some(proof)) => {
if proof.verify(&prev.tree_head, tree_head) {
Ok(())
} else {
Err(SealChainError::NotAPrefix { period: at() })
}
}
},
}
}
#[derive(Debug, Clone)]
pub struct SealChain {
ledger: LedgerId,
seals: Vec<Seal>,
periods: BTreeMap<PeriodId, usize>,
}
impl SealChain {
#[must_use]
pub fn new(ledger: LedgerId) -> Self {
Self {
ledger,
seals: Vec::new(),
periods: BTreeMap::new(),
}
}
pub fn from_seals(
ledger: LedgerId,
seals: impl IntoIterator<Item = Seal>,
) -> Result<Self, SealChainError> {
let mut chain = Self::new(ledger);
for seal in seals {
chain.push(seal)?;
}
Ok(chain)
}
#[must_use]
pub fn ledger(&self) -> &LedgerId {
&self.ledger
}
#[must_use]
pub fn head(&self) -> Option<Hash> {
self.seals.last().map(|s| s.seal_hash)
}
#[must_use]
pub fn last(&self) -> Option<&Seal> {
self.seals.last()
}
fn check_link(
ledger: &LedgerId,
already_sealed: impl Fn(&PeriodId) -> bool,
previous: Option<&Seal>,
seal: &Seal,
) -> Result<(), SealChainError> {
let period = || seal.period.clone();
if !seal.is_self_consistent() {
return Err(SealChainError::Tampered { period: period() });
}
if seal.ledger != *ledger {
return Err(SealChainError::ForeignLedger {
period: period(),
expected: ledger.clone(),
found: seal.ledger.clone(),
});
}
if already_sealed(&seal.period) {
return Err(SealChainError::DuplicatePeriod { period: period() });
}
match (previous, seal.prev_seal) {
(None, None) => check_extends(
&seal.period,
None,
&seal.tree_head,
seal.prev_consistency.as_ref(),
),
(Some(prev), Some(reference)) => {
if prev.seal_hash != reference {
return Err(SealChainError::BrokenChain { period: period() });
}
if seal.tree_head.size < prev.tree_head.size {
return Err(SealChainError::NonMonotonic { period: period() });
}
if seal.accounts.size < prev.accounts.size {
return Err(SealChainError::ShrunkenRegistry {
period: period(),
expected: prev.accounts.size,
found: seal.accounts.size,
});
}
check_extends(
&seal.period,
previous,
&seal.tree_head,
seal.prev_consistency.as_ref(),
)
}
_ => Err(SealChainError::MisplacedGenesis { period: period() }),
}
}
pub fn push(&mut self, seal: Seal) -> Result<(), SealChainError> {
Self::check_link(
&self.ledger,
|period| self.periods.contains_key(period),
self.seals.last(),
&seal,
)?;
self.periods.insert(seal.period.clone(), self.seals.len());
self.seals.push(seal);
Ok(())
}
pub fn verify_against_accounts(
&self,
accounts: &crate::account::AccountRegistry,
) -> Result<(), SealChainError> {
self.verify()?;
for seal in &self.seals {
let Some(actual) = accounts.commitment_at(seal.accounts.size) else {
return Err(SealChainError::BeyondTheRegistry {
period: seal.period.clone(),
claimed: seal.accounts.size,
holds: accounts.len() as u64,
});
};
if actual != seal.accounts {
return Err(SealChainError::AccountsRebound {
period: seal.period.clone(),
size: seal.accounts.size,
expected: seal.accounts.root,
found: actual.root,
});
}
}
Ok(())
}
pub fn verify(&self) -> Result<(), SealChainError> {
let mut seen: BTreeSet<PeriodId> = BTreeSet::new();
let mut previous: Option<&Seal> = None;
for seal in &self.seals {
Self::check_link(&self.ledger, |period| seen.contains(period), previous, seal)?;
seen.insert(seal.period.clone());
previous = Some(seal);
}
Ok(())
}
pub fn verify_against_log(&self, log: &MerkleLog) -> Result<(), SealChainError> {
self.verify()?;
for seal in &self.seals {
let actual =
log.head_at(seal.tree_head.size)
.map_err(|_| SealChainError::BeyondTheLog {
period: seal.period.clone(),
claimed: seal.tree_head.size,
holds: log.len(),
})?;
if actual != seal.tree_head {
return Err(SealChainError::HeadMismatch {
period: seal.period.clone(),
expected: seal.tree_head.root,
found: actual.root,
});
}
}
Ok(())
}
#[must_use]
pub fn seals(&self) -> &[Seal] {
&self.seals
}
#[must_use]
pub fn len(&self) -> usize {
self.seals.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.seals.is_empty()
}
#[must_use]
pub fn get(&self, period: &PeriodId) -> Option<&Seal> {
self.seals.get(*self.periods.get(period)?)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::account::{AccountId, AccountRegistry};
use crate::merkle::MerkleLog;
use crate::money::{Amount, Currency};
use crate::posting::{Direction, Layer};
use time::macros::date;
type Eur = Amount<2>;
fn lid() -> LedgerId {
LedgerId::new("test-ledger").expect("valid")
}
fn pid(s: &str) -> PeriodId {
PeriodId::new(s).expect("valid")
}
fn head(size: u64, byte: u8) -> TreeHead {
TreeHead {
size,
root: Hash::from_bytes([byte; 32]),
}
}
fn log_of(n: u64) -> MerkleLog {
let mut log = MerkleLog::new();
for i in 0..n {
let mut bytes = [0u8; 32];
if let Some(slot) = bytes.get_mut(..8) {
slot.copy_from_slice(&i.to_le_bytes());
}
log.append(Hash::from_bytes(bytes));
}
log
}
fn forged(seal: &Seal, edit: impl FnOnce(&mut Seal)) -> Seal {
let mut forged = seal.clone();
edit(&mut forged);
forged.seal_hash = forged.compute_hash();
forged
}
fn accounts_head() -> TreeHead {
TreeHead {
size: 2,
root: Hash::from_bytes([0xa0; 32]),
}
}
fn registry() -> AccountRegistry {
let mut r = AccountRegistry::new();
for path in ["Assets:Cash", "Income:Sales"] {
r.register_path(path, date!(2026 - 01 - 01))
.expect("registers");
}
r
}
fn tb(entries: &[(u32, i64, i64)]) -> TrialBalance<2> {
let mut tb = TrialBalance::<2>::new();
for (account, debit, credit) in entries {
let key = BalanceKey {
account: AccountId::from_index(*account),
currency: Currency::EUR,
layer: Layer::Settled,
};
let mut balance = Balance::<2>::ZERO;
balance
.add(Direction::Debit, Eur::from_minor(*debit))
.expect("ok");
balance
.add(Direction::Credit, Eur::from_minor(*credit))
.expect("ok");
tb.set(key, balance);
}
tb
}
#[test]
fn a_seal_hashes_its_own_contents() {
let seal = Seal::build(
lid(),
pid("2026-03"),
PeriodCoverage::spanning(0, 9, 10),
&log_of(10),
&tb(&[(0, 100, 0)]),
accounts_head(),
None,
)
.expect("builds");
assert!(seal.is_self_consistent());
assert_eq!(seal.entry_count, 10);
}
#[test]
fn editing_any_field_invalidates_the_seal() {
let original = Seal::build(
lid(),
pid("2026-03"),
PeriodCoverage::spanning(0, 9, 10),
&log_of(10),
&tb(&[(0, 100, 0)]),
accounts_head(),
None,
)
.expect("builds");
let mut altered = original.clone();
altered.last_index = Some(8);
assert!(!altered.is_self_consistent());
let mut retargeted = original.clone();
retargeted.tree_head = head(11, 1);
assert!(!retargeted.is_self_consistent());
let mut restated = original;
restated.trial_balance.root = Hash::from_bytes([9u8; 32]);
assert!(!restated.is_self_consistent());
}
#[test]
fn the_trial_balance_head_reflects_the_balances() {
let a = Seal::build(
lid(),
pid("p"),
PeriodCoverage::spanning(0, 1, 0),
&log_of(2),
&tb(&[(0, 100, 0)]),
accounts_head(),
None,
)
.expect("builds");
let b = Seal::build(
lid(),
pid("p"),
PeriodCoverage::spanning(0, 1, 0),
&log_of(2),
&tb(&[(0, 101, 0)]),
accounts_head(),
None,
)
.expect("builds");
assert_ne!(a.trial_balance, b.trial_balance);
}
#[test]
fn gross_totals_are_covered_not_just_the_net() {
let quiet = Seal::build(
lid(),
pid("p"),
PeriodCoverage::EMPTY,
&log_of(0),
&tb(&[(0, 0, 0)]),
accounts_head(),
None,
)
.expect("builds");
let busy = Seal::build(
lid(),
pid("p"),
PeriodCoverage::EMPTY,
&log_of(0),
&tb(&[(0, 500, 500)]),
accounts_head(),
None,
)
.expect("builds");
assert_ne!(quiet.trial_balance, busy.trial_balance);
}
#[test]
fn seals_chain_and_verify() {
let mut chain = SealChain::new(lid());
let first = Seal::build(
lid(),
pid("2026-01"),
PeriodCoverage::spanning(0, 4, 0),
&log_of(5),
&tb(&[(0, 100, 0)]),
accounts_head(),
None,
)
.expect("builds");
chain.push(first.clone()).expect("genesis");
let second = Seal::build(
lid(),
pid("2026-02"),
PeriodCoverage::spanning(5, 9, 5),
&log_of(10),
&tb(&[(0, 200, 0)]),
accounts_head(),
Some(&first),
)
.expect("builds");
chain.push(second).expect("chains");
assert_eq!(chain.len(), 2);
assert!(chain.verify().is_ok());
assert_eq!(
chain.get(&pid("2026-01")).map(|s| s.period.clone()),
Some(pid("2026-01"))
);
}
#[test]
fn a_chain_refuses_a_foreign_seal_even_as_its_first() {
let mut chain = SealChain::new(lid());
let foreign = Seal::build(
LedgerId::new("someone-else").expect("valid"),
pid("2026-01"),
PeriodCoverage::EMPTY,
&log_of(1),
&tb(&[]),
accounts_head(),
None,
)
.expect("builds");
assert!(matches!(
chain.push(foreign),
Err(SealChainError::ForeignLedger { .. })
));
assert!(chain.is_empty(), "a refused seal appends nothing");
assert_eq!(chain.ledger(), &lid());
}
#[test]
fn one_period_may_not_be_sealed_twice_in_a_chain() {
let first = Seal::build(
lid(),
pid("2026-01"),
PeriodCoverage::spanning(0, 4, 5),
&log_of(5),
&tb(&[(0, 100, 0)]),
accounts_head(),
None,
)
.expect("builds");
let restated = Seal::build(
lid(),
pid("2026-01"),
PeriodCoverage::spanning(0, 4, 5),
&log_of(5),
&tb(&[(0, 999, 0)]),
accounts_head(),
Some(&first),
)
.expect("builds");
let mut chain = SealChain::new(lid());
chain.push(first).expect("genesis");
assert!(matches!(
chain.push(restated),
Err(SealChainError::DuplicatePeriod { .. })
));
}
#[test]
fn from_seals_rebuilds_and_re_checks_a_stored_chain() {
let first = Seal::build(
lid(),
pid("2026-01"),
PeriodCoverage::EMPTY,
&log_of(1),
&tb(&[]),
accounts_head(),
None,
)
.expect("builds");
let second = Seal::build(
lid(),
pid("2026-02"),
PeriodCoverage::EMPTY,
&log_of(2),
&tb(&[]),
accounts_head(),
Some(&first),
)
.expect("builds");
let chain = SealChain::from_seals(lid(), [first.clone(), second.clone()]).expect("chains");
assert_eq!(chain.len(), 2);
assert!(chain.verify().is_ok());
assert!(SealChain::from_seals(lid(), [second, first.clone()]).is_err());
assert!(
SealChain::from_seals(LedgerId::new("elsewhere").expect("valid"), [first]).is_err()
);
}
#[test]
fn a_seal_that_does_not_reference_the_head_is_refused() {
let mut chain = SealChain::new(lid());
let first = Seal::build(
lid(),
pid("a"),
PeriodCoverage::spanning(0, 0, 1),
&log_of(1),
&tb(&[]),
accounts_head(),
None,
)
.expect("builds");
chain.push(first.clone()).expect("genesis");
let orphan = forged(
&Seal::build(
lid(),
pid("b"),
PeriodCoverage::spanning(1, 1, 1),
&log_of(2),
&tb(&[]),
accounts_head(),
Some(&first),
)
.expect("builds"),
|s| s.prev_seal = Some(Hash::from_bytes([7u8; 32])),
);
assert!(orphan.is_self_consistent(), "re-hashed, as a forger would");
assert!(matches!(
chain.push(orphan),
Err(SealChainError::BrokenChain { .. })
));
}
#[test]
fn only_the_first_seal_may_omit_a_predecessor() {
let mut chain = SealChain::new(lid());
chain
.push(
Seal::build(
lid(),
pid("a"),
PeriodCoverage::EMPTY,
&log_of(1),
&tb(&[]),
accounts_head(),
None,
)
.expect("builds"),
)
.expect("genesis");
let second_genesis = Seal::build(
lid(),
pid("b"),
PeriodCoverage::EMPTY,
&log_of(2),
&tb(&[]),
accounts_head(),
None,
)
.expect("builds");
assert!(matches!(
chain.push(second_genesis),
Err(SealChainError::MisplacedGenesis { .. })
));
}
#[test]
fn a_first_seal_may_not_claim_a_predecessor() {
let mut chain = SealChain::new(lid());
let bogus = forged(
&Seal::build(
lid(),
pid("a"),
PeriodCoverage::EMPTY,
&log_of(1),
&tb(&[]),
accounts_head(),
None,
)
.expect("builds"),
|s| s.prev_seal = Some(Hash::from_bytes([3u8; 32])),
);
assert!(matches!(
chain.push(bogus),
Err(SealChainError::MisplacedGenesis { .. })
));
}
#[test]
fn the_tree_may_not_shrink_between_seals() {
let mut chain = SealChain::new(lid());
let first = Seal::build(
lid(),
pid("a"),
PeriodCoverage::spanning(0, 9, 10),
&log_of(10),
&tb(&[]),
accounts_head(),
None,
)
.expect("builds");
chain.push(first.clone()).expect("genesis");
let shrunk = forged(
&Seal::build(
lid(),
pid("b"),
PeriodCoverage::spanning(0, 4, 0),
&log_of(10),
&tb(&[]),
accounts_head(),
Some(&first),
)
.expect("builds"),
|s| s.tree_head = head(5, 2),
);
assert!(matches!(
chain.push(shrunk),
Err(SealChainError::NonMonotonic { .. })
));
}
#[test]
fn the_account_registry_may_not_shrink_between_seals() {
let mut chain = SealChain::new(lid());
let first = Seal::build(
lid(),
pid("2026-01"),
PeriodCoverage::spanning(0, 4, 5),
&log_of(5),
&tb(&[(0, 100, 0)]),
TreeHead {
size: 12,
root: Hash::from_bytes([0xa0; 32]),
},
None,
)
.expect("builds");
chain.push(first.clone()).expect("genesis");
let renumbered = Seal::build(
lid(),
pid("2026-02"),
PeriodCoverage::spanning(5, 9, 5),
&log_of(10),
&tb(&[(0, 200, 0)]),
TreeHead {
size: 9,
root: Hash::from_bytes([0xa1; 32]),
},
Some(&first),
)
.expect("builds");
assert!(matches!(
chain.push(renumbered),
Err(SealChainError::ShrunkenRegistry {
expected: 12,
found: 9,
..
})
));
for size in [12, 13] {
let grown = Seal::build(
lid(),
pid("2026-02"),
PeriodCoverage::spanning(5, 9, 5),
&log_of(10),
&tb(&[(0, 200, 0)]),
TreeHead {
size,
root: Hash::from_bytes([0xa2; 32]),
},
Some(&first),
)
.expect("builds");
let mut fresh = SealChain::new(lid());
fresh.push(first.clone()).expect("genesis");
fresh.push(grown).expect("a registry that grew");
assert!(fresh.verify().is_ok());
}
}
#[test]
fn tampering_with_a_sealed_period_is_detected_by_the_chain() {
let mut chain = SealChain::new(lid());
let first = Seal::build(
lid(),
pid("a"),
PeriodCoverage::spanning(0, 4, 0),
&log_of(5),
&tb(&[(0, 100, 0)]),
accounts_head(),
None,
)
.expect("builds");
chain.push(first.clone()).expect("genesis");
chain
.push(
Seal::build(
lid(),
pid("b"),
PeriodCoverage::spanning(5, 9, 5),
&log_of(10),
&tb(&[(0, 200, 0)]),
accounts_head(),
Some(&first),
)
.expect("builds"),
)
.expect("chains");
assert!(chain.verify().is_ok());
let mut tampered = chain;
if let Some(seal) = tampered.seals.first_mut() {
seal.trial_balance.root = Hash::from_bytes([0xffu8; 32]);
}
assert!(matches!(
tampered.verify(),
Err(SealChainError::Tampered { .. })
));
}
#[test]
fn a_balance_can_be_proven_against_a_seal_alone() {
let trial = tb(&[(0, 119_000, 0), (1, 0, 119_000), (2, 500, 500)]);
let seal = Seal::build(
lid(),
pid("2026-03"),
PeriodCoverage::spanning(0, 3, 4),
&log_of(4),
&trial,
accounts_head(),
None,
)
.expect("builds");
let commitment = TrialBalanceCommitment::of(&trial);
assert_eq!(commitment.head(), seal.trial_balance);
assert_eq!(commitment.len(), 3);
for proof in commitment.prove_all().expect("well-formed") {
assert!(proof.verify_against(&seal), "{:?} must prove", proof.key);
}
}
#[test]
fn a_restated_balance_does_not_prove() {
let trial = tb(&[(0, 119_000, 0), (1, 0, 119_000)]);
let seal = Seal::build(
lid(),
pid("p"),
PeriodCoverage::EMPTY,
&log_of(2),
&trial,
accounts_head(),
None,
)
.expect("builds");
let key = BalanceKey {
account: AccountId::from_index(0),
currency: Currency::EUR,
layer: Layer::Settled,
};
let mut proof = TrialBalanceCommitment::of(&trial)
.prove(&key)
.expect("row exists");
assert!(proof.verify_against(&seal));
proof.balance.debits = Eur::from_minor(119_001);
assert!(!proof.verify_against(&seal));
let mut retargeted = TrialBalanceCommitment::of(&trial)
.prove(&key)
.expect("row exists");
retargeted.key.account = AccountId::from_index(7);
assert!(!retargeted.verify_against(&seal));
}
#[test]
fn an_account_with_no_row_cannot_be_proven_to_be_zero() {
let trial = tb(&[(0, 100, 0)]);
let absent = BalanceKey {
account: AccountId::from_index(9),
currency: Currency::EUR,
layer: Layer::Settled,
};
assert!(TrialBalanceCommitment::of(&trial).prove(&absent).is_none());
}
#[test]
fn a_proof_does_not_carry_across_seals() {
let march = tb(&[(0, 100, 0), (1, 0, 100)]);
let april = tb(&[(0, 250, 0), (1, 0, 250)]);
let march_seal = Seal::build(
lid(),
pid("2026-03"),
PeriodCoverage::EMPTY,
&log_of(2),
&march,
accounts_head(),
None,
)
.expect("builds");
let key = BalanceKey {
account: AccountId::from_index(0),
currency: Currency::EUR,
layer: Layer::Settled,
};
let april_proof = TrialBalanceCommitment::of(&april)
.prove(&key)
.expect("row exists");
assert!(!april_proof.verify_against(&march_seal));
}
#[test]
fn a_seal_binds_the_handles_its_balances_are_keyed_on() {
let trial = tb(&[(0, 100, 0), (1, 0, 100)]);
let seal = Seal::build(
lid(),
pid("2026-03"),
PeriodCoverage::spanning(0, 1, 2),
&log_of(2),
&trial,
registry().commitment(),
None,
)
.expect("builds");
let mut renumbered = AccountRegistry::new();
for path in ["Income:Sales", "Assets:Cash"] {
renumbered
.register_path(path, date!(2026 - 01 - 01))
.expect("registers");
}
assert_eq!(trial_balance_head(&trial), seal.trial_balance);
assert_ne!(renumbered.commitment(), seal.accounts);
let mut chain = SealChain::new(lid());
chain.push(seal.clone()).expect("chains");
chain
.verify_against_accounts(®istry())
.expect("the registry the seal was taken against");
assert!(matches!(
chain.verify_against_accounts(&renumbered),
Err(SealChainError::AccountsRebound { size: 2, .. })
));
}
#[test]
fn a_seal_is_checked_against_the_registry_as_it_stood_then() {
let trial = tb(&[(0, 100, 0), (1, 0, 100)]);
let seal = Seal::build(
lid(),
pid("2026-03"),
PeriodCoverage::spanning(0, 1, 2),
&log_of(2),
&trial,
registry().commitment(),
None,
)
.expect("builds");
let mut chain = SealChain::new(lid());
chain.push(seal).expect("chains");
let mut grown = registry();
grown
.register_path("Expense:Rent", date!(2026 - 01 - 01))
.expect("registers");
assert_ne!(grown.commitment().size, 2, "the registry really did grow");
chain
.verify_against_accounts(&grown)
.expect("growth is not tampering");
let mut truncated = AccountRegistry::new();
truncated
.register_path("Assets:Cash", date!(2026 - 01 - 01))
.expect("registers");
assert!(matches!(
chain.verify_against_accounts(&truncated),
Err(SealChainError::BeyondTheRegistry {
claimed: 2,
holds: 1,
..
})
));
}
#[test]
fn a_balance_proof_can_name_the_account_it_is_about() {
let accounts = registry();
let cash = accounts
.id_of(&crate::account::AccountPath::parse("Assets:Cash").expect("valid"))
.expect("registered");
let trial = tb(&[(cash.index(), 119_000, 0), (1, 0, 119_000)]);
let seal = Seal::build(
lid(),
pid("2026-03"),
PeriodCoverage::spanning(0, 1, 2),
&log_of(2),
&trial,
accounts.commitment(),
None,
)
.expect("builds");
let key = BalanceKey {
account: cash,
currency: Currency::EUR,
layer: Layer::Settled,
};
let balance = TrialBalanceCommitment::of(&trial)
.prove(&key)
.expect("row exists");
let binding = accounts.prove_binding(cash).expect("registered");
assert!(balance.verify_naming(&binding, &seal));
assert_eq!(binding.path().to_string(), "Assets:Cash");
let other = accounts
.prove_binding(AccountId::from_index(1))
.expect("registered");
assert!(!balance.verify_naming(&other, &seal));
let foreign = Seal::build(
lid(),
pid("2026-04"),
PeriodCoverage::EMPTY,
&log_of(2),
&trial,
accounts_head(),
None,
)
.expect("builds");
assert!(!balance.verify_naming(&binding, &foreign));
}
#[test]
fn a_binding_proof_cannot_be_replayed_at_another_handle() {
let accounts = registry();
let root = accounts.commitment();
let mut proof = accounts
.prove_binding(AccountId::from_index(0))
.expect("registered");
assert!(proof.verify(&root));
proof.id = AccountId::from_index(1);
assert!(!proof.verify(&root));
}
#[cfg(feature = "serde")]
#[test]
fn a_deserialised_seal_is_checked_against_its_own_hash() {
let seal = Seal::build(
lid(),
pid("p"),
PeriodCoverage::EMPTY,
&log_of(1),
&tb(&[(0, 100, 0)]),
accounts_head(),
None,
)
.expect("builds");
let json = serde_json::to_string(&seal).expect("serialises");
assert_eq!(
serde_json::from_str::<Seal>(&json).expect("round-trips"),
seal
);
let forged = json.replace("\"entry_count\":0", "\"entry_count\":99");
assert_ne!(forged, json, "the test must actually alter the payload");
assert!(serde_json::from_str::<Seal>(&forged).is_err());
}
#[test]
fn an_empty_period_seals_with_no_entries() {
let seal = Seal::build(
lid(),
pid("quiet"),
PeriodCoverage::EMPTY,
&log_of(0),
&tb(&[]),
accounts_head(),
None,
)
.expect("builds");
assert_eq!(seal.entry_count, 0);
assert!(seal.is_self_consistent());
}
}