use super::pairs::pairs;
use super::rows::{DocumentRow, EntryRow, ExportRow, HoldKind, PairRow, PostingRow, RateRow};
use crate::log::{Hash, TreeHead, leaf_hash, root_of};
use crate::model::{
Amount, Document, EntryParts, EntryRecord, HoldAction, MemoCommitment, PostAmount,
PostingRecord, Rate, Record,
};
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum VerifyError {
#[error("the rows of position {position} are malformed: {reason}")]
Malformed {
position: u64,
reason: &'static str,
},
#[error("the entry at position {position} does not rebuild to its leaf hash")]
EntryMismatch {
position: u64,
},
#[error("the memo of the entry at position {position} does not match its commitment")]
MemoMismatch {
position: u64,
},
#[error("the pairs of the entry at position {position} are not its postings' pairs")]
PairMismatch {
position: u64,
},
#[error("the leaf rows do not rebuild the head of size {size}")]
RootMismatch {
size: u64,
},
}
pub(crate) fn to_minor<const P: u8>(units: i128, exponent: u8) -> Option<i128> {
let factor = 10i128.checked_pow(u32::from(P.checked_sub(exponent)?))?;
(units.checked_rem(factor)? == 0).then(|| units.checked_div(factor))?
}
pub(crate) fn from_minor<const P: u8>(minor: i128, exponent: u8) -> Option<Amount<P>> {
let factor = 10i128.checked_pow(u32::from(P.checked_sub(exponent)?))?;
Amount::from_minor(minor.checked_mul(factor)?).ok()
}
pub(crate) fn post_exponent<const P: u8>(postings: &[PostingRow]) -> u8 {
postings.first().map_or(P, |row| row.exponent)
}
fn record<const P: u8>(
entry: &EntryRow,
postings: &[PostingRow],
documents: &[DocumentRow],
rates: &[RateRow],
) -> Result<EntryRecord<P>, VerifyError> {
let malformed = |reason| VerifyError::Malformed {
position: entry.position,
reason,
};
let counted = postings.len() == usize::from(entry.posting_count)
&& documents.len() == usize::from(entry.document_count)
&& rates.len() == usize::from(entry.rate_count);
if !counted {
return Err(malformed("the child rows do not match the entry's counts"));
}
let in_order = (0u16..).zip(postings).all(|(i, row)| row.index == i)
&& (0u16..).zip(documents).all(|(i, row)| row.ordinal == i)
&& (0u16..).zip(rates).all(|(i, row)| row.ordinal == i);
if !in_order {
return Err(malformed("the child rows are not numbered in order"));
}
let post_exponent = post_exponent::<P>(postings);
let postings = postings
.iter()
.map(|row| {
Ok(PostingRecord {
handle: row.account,
side: row.side,
amount: from_minor(row.minor_units, row.exponent)
.ok_or_else(|| malformed("a posting amount does not fit the scale"))?,
currency: row.currency,
layer: row.layer,
dimensions: row.dimensions.clone(),
})
})
.collect::<Result<Vec<_>, VerifyError>>()?;
let documents = documents
.iter()
.map(|row| {
let document = Document::new(row.doc_type.clone(), row.reference.clone())
.map_err(|_| malformed("a document reference is invalid"))?;
let document = match row.date {
Some(date) => document.dated(date),
None => document,
};
Ok(match row.sha256 {
Some(digest) => document.with_sha256(digest),
None => document,
})
})
.collect::<Result<Vec<_>, VerifyError>>()?;
let rates = rates
.iter()
.map(|row| {
Rate::new(row.base, row.quote, row.mantissa, row.exponent)
.map_err(|_| malformed("a rate is invalid"))
})
.collect::<Result<Vec<_>, VerifyError>>()?;
let hold = match (entry.hold_action, entry.hold_target) {
(None, None) => None,
(Some(HoldKind::Open), None) => Some(HoldAction::Open {
expires: entry.hold_expires,
}),
(Some(HoldKind::Post), Some(hold)) => Some(HoldAction::Post {
hold,
amount: match entry.post_amount_minor {
None => PostAmount::Full,
Some(minor) => PostAmount::Partial(
from_minor(minor, post_exponent)
.ok_or_else(|| malformed("a post amount does not fit the scale"))?,
),
},
}),
(Some(HoldKind::Void), Some(hold)) => Some(HoldAction::Void { hold }),
(Some(HoldKind::Expire), Some(hold)) => Some(HoldAction::Expire { hold }),
_ => return Err(malformed("the hold columns contradict each other")),
};
EntryRecord::new(EntryParts {
key: entry.key.clone(),
booking_date: entry.booking_date,
value_date: entry.value_date,
captured_at: entry.captured_at,
kind: entry.kind.clone(),
memo: entry.memo_commitment,
documents,
postings,
reverses: entry.reverses,
hold,
rates,
})
.map_err(|_| malformed("the entry is not a valid record"))
}
pub fn rebuild_entry<const P: u8>(
entry: &EntryRow,
postings: &[PostingRow],
documents: &[DocumentRow],
rates: &[RateRow],
) -> Result<Vec<u8>, VerifyError> {
Ok(Record::Entry(record::<P>(entry, postings, documents, rates)?).to_leaf_bytes())
}
struct Group {
entry: EntryRow,
postings: Vec<PostingRow>,
pairs: Vec<PairRow>,
documents: Vec<DocumentRow>,
rates: Vec<RateRow>,
}
impl Group {
fn check<const P: u8>(&self, leaves: &[Hash]) -> Result<(), VerifyError> {
let position = self.entry.position;
let record = record::<P>(&self.entry, &self.postings, &self.documents, &self.rates)?;
let rebuilt = leaf_hash(&Record::Entry(record.clone()).to_leaf_bytes());
let leaf = usize::try_from(position).ok().and_then(|at| leaves.get(at));
if rebuilt != self.entry.leaf_hash || leaf != Some(&rebuilt) {
return Err(VerifyError::EntryMismatch { position });
}
if let (Some(commitment), Some(text), Some(salt)) = (
self.entry.memo_commitment,
&self.entry.memo_text,
&self.entry.memo_salt,
) && MemoCommitment::compute(salt, text) != commitment
{
return Err(VerifyError::MemoMismatch { position });
}
let expected: Vec<_> = pairs(&record.parts().postings)
.iter()
.map(|pair| (pair.ordinal, pair.debit, pair.credit, pair.amount))
.collect();
let found: Vec<_> = self
.pairs
.iter()
.map(|row| {
(
row.ordinal,
row.debit_index,
row.credit_index,
from_minor::<P>(row.minor_units, row.exponent),
)
})
.map(|(ordinal, debit, credit, amount)| amount.map(|a| (ordinal, debit, credit, a)))
.collect::<Option<_>>()
.unwrap_or_default();
if found != expected {
return Err(VerifyError::PairMismatch { position });
}
Ok(())
}
}
pub fn verify(
rows: impl IntoIterator<Item = ExportRow>,
head: &TreeHead,
) -> Result<(), VerifyError> {
verify_with_precision::<2>(rows, head)
}
pub fn verify_with_precision<const P: u8>(
rows: impl IntoIterator<Item = ExportRow>,
head: &TreeHead,
) -> Result<(), VerifyError> {
let mut leaves: Vec<Hash> = Vec::new();
let mut group: Option<Group> = None;
let misplaced = |position, reason| VerifyError::Malformed { position, reason };
for row in rows {
match row {
ExportRow::Posting(row) => match &mut group {
Some(group) if group.entry.entry == row.entry => group.postings.push(row),
_ => return Err(misplaced(row.position, "a posting row outside its entry")),
},
ExportRow::Pair(row) => match &mut group {
Some(group) if group.entry.entry == row.entry => group.pairs.push(row),
_ => return Err(misplaced(row.position, "a pair row outside its entry")),
},
ExportRow::Document(row) => match &mut group {
Some(group) if group.entry.entry == row.entry => group.documents.push(row),
_ => return Err(misplaced(row.position, "a document row outside its entry")),
},
ExportRow::Rate(row) => match &mut group {
Some(group) if group.entry.entry == row.entry => group.rates.push(row),
_ => return Err(misplaced(row.position, "a rate row outside its entry")),
},
other => {
if let Some(done) = group.take() {
done.check::<P>(&leaves)?;
}
match other {
ExportRow::Leaf(leaf) => {
if usize::try_from(leaf.position).ok() != Some(leaves.len()) {
return Err(misplaced(leaf.position, "a leaf row out of order"));
}
leaves.push(leaf.leaf_hash);
}
ExportRow::Entry(entry) => {
group = Some(Group {
entry,
postings: Vec::new(),
pairs: Vec::new(),
documents: Vec::new(),
rates: Vec::new(),
});
}
_ => {}
}
}
}
}
if let Some(done) = group {
done.check::<P>(&leaves)?;
}
if u64::try_from(leaves.len()).ok() != Some(head.size) || root_of(&leaves) != head.root {
return Err(VerifyError::RootMismatch { size: head.size });
}
Ok(())
}