use crate::account::AccountBindingProof;
use crate::balance::{Balance, BalanceKey, TrialBalance};
use crate::canonical::{Canonical, CanonicalWriter};
use crate::hash::{Hash, tag, tagged};
use crate::merkle::{InclusionProof, MerkleLog, ProofError, TreeHead};
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 seal_hash: Hash,
}
impl Seal {
#[must_use]
pub fn build<const P: u8>(
ledger: LedgerId,
period: PeriodId,
coverage: PeriodCoverage,
tree_head: TreeHead,
trial_balance: &TrialBalance<P>,
accounts: TreeHead,
prev_seal: Option<Hash>,
) -> Self {
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,
seal_hash: Hash::from_bytes([0u8; 32]),
};
seal.seal_hash = seal.compute_hash();
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>,
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,
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());
});
}
}
#[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.iter().position(|(k, _)| k == key)?;
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, 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,
},
}
#[derive(Debug, Clone)]
pub struct SealChain {
ledger: LedgerId,
seals: Vec<Seal>,
}
impl SealChain {
#[must_use]
pub fn new(ledger: LedgerId) -> Self {
Self {
ledger,
seals: Vec::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,
preceding: &[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 preceding.iter().any(|s| s.period == seal.period) {
return Err(SealChainError::DuplicatePeriod { period: period() });
}
match (preceding.last(), seal.prev_seal) {
(None, None) => Ok(()),
(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() });
}
Ok(())
}
_ => Err(SealChainError::MisplacedGenesis { period: period() }),
}
}
pub fn push(&mut self, seal: Seal) -> Result<(), SealChainError> {
Self::check_link(&self.ledger, &self.seals, &seal)?;
self.seals.push(seal);
Ok(())
}
pub fn verify(&self) -> Result<(), SealChainError> {
for (position, seal) in self.seals.iter().enumerate() {
let preceding = self.seals.get(..position).unwrap_or_default();
Self::check_link(&self.ledger, preceding, seal)?;
}
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.iter().find(|s| s.period == *period)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::account::{AccountId, AccountRegistry};
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 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),
head(10, 1),
&tb(&[(0, 100, 0)]),
accounts_head(),
None,
);
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),
head(10, 1),
&tb(&[(0, 100, 0)]),
accounts_head(),
None,
);
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),
head(2, 1),
&tb(&[(0, 100, 0)]),
accounts_head(),
None,
);
let b = Seal::build(
lid(),
pid("p"),
PeriodCoverage::spanning(0, 1, 0),
head(2, 1),
&tb(&[(0, 101, 0)]),
accounts_head(),
None,
);
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,
head(0, 1),
&tb(&[(0, 0, 0)]),
accounts_head(),
None,
);
let busy = Seal::build(
lid(),
pid("p"),
PeriodCoverage::EMPTY,
head(0, 1),
&tb(&[(0, 500, 500)]),
accounts_head(),
None,
);
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),
head(5, 1),
&tb(&[(0, 100, 0)]),
accounts_head(),
None,
);
chain.push(first.clone()).expect("genesis");
let second = Seal::build(
lid(),
pid("2026-02"),
PeriodCoverage::spanning(5, 9, 5),
head(10, 2),
&tb(&[(0, 200, 0)]),
accounts_head(),
Some(first.seal_hash),
);
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,
head(1, 1),
&tb(&[]),
accounts_head(),
None,
);
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),
head(5, 1),
&tb(&[(0, 100, 0)]),
accounts_head(),
None,
);
let restated = Seal::build(
lid(),
pid("2026-01"),
PeriodCoverage::spanning(0, 4, 5),
head(5, 1),
&tb(&[(0, 999, 0)]),
accounts_head(),
Some(first.seal_hash),
);
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,
head(1, 1),
&tb(&[]),
accounts_head(),
None,
);
let second = Seal::build(
lid(),
pid("2026-02"),
PeriodCoverage::EMPTY,
head(2, 2),
&tb(&[]),
accounts_head(),
Some(first.seal_hash),
);
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),
head(1, 1),
&tb(&[]),
accounts_head(),
None,
);
chain.push(first).expect("genesis");
let orphan = Seal::build(
lid(),
pid("b"),
PeriodCoverage::spanning(1, 1, 1),
head(2, 2),
&tb(&[]),
accounts_head(),
Some(Hash::from_bytes([7u8; 32])),
);
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,
head(1, 1),
&tb(&[]),
accounts_head(),
None,
))
.expect("genesis");
let second_genesis = Seal::build(
lid(),
pid("b"),
PeriodCoverage::EMPTY,
head(2, 2),
&tb(&[]),
accounts_head(),
None,
);
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 = Seal::build(
lid(),
pid("a"),
PeriodCoverage::EMPTY,
head(1, 1),
&tb(&[]),
accounts_head(),
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),
head(10, 1),
&tb(&[]),
accounts_head(),
None,
);
chain.push(first.clone()).expect("genesis");
let shrunk = Seal::build(
lid(),
pid("b"),
PeriodCoverage::spanning(0, 4, 0),
head(5, 2),
&tb(&[]),
accounts_head(),
Some(first.seal_hash),
);
assert!(matches!(
chain.push(shrunk),
Err(SealChainError::NonMonotonic { .. })
));
}
#[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),
head(5, 1),
&tb(&[(0, 100, 0)]),
accounts_head(),
None,
);
chain.push(first.clone()).expect("genesis");
chain
.push(Seal::build(
lid(),
pid("b"),
PeriodCoverage::spanning(5, 9, 5),
head(10, 2),
&tb(&[(0, 200, 0)]),
accounts_head(),
Some(first.seal_hash),
))
.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),
head(4, 1),
&trial,
accounts_head(),
None,
);
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,
head(2, 1),
&trial,
accounts_head(),
None,
);
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,
head(2, 1),
&march,
accounts_head(),
None,
);
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),
head(2, 1),
&trial,
registry().commitment(),
None,
);
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);
}
#[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),
head(2, 1),
&trial,
accounts.commitment(),
None,
);
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.account().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,
head(2, 1),
&trial,
accounts_head(),
None,
);
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.record.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,
head(1, 1),
&tb(&[(0, 100, 0)]),
accounts_head(),
None,
);
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,
head(0, 1),
&tb(&[]),
accounts_head(),
None,
);
assert_eq!(seal.entry_count, 0);
assert!(seal.is_self_consistent());
}
}