use std::collections::BTreeMap;
use std::fmt;
use std::ops::RangeInclusive;
use time::Date;
mod export;
pub use export::ExportPages;
use crate::core::{
self, AccountSpec, AuditReport, ChangeInput, Done, HoldState, MemoPlain, Op, Recorded,
Replayer, SealReceipt,
};
use crate::error::{ConfigError, Error, IntegrityError, IntegrityKind, QueryError};
use crate::export::{ExportScope, Exporter};
use crate::facade::{Account, Reads, SaltSource, seal_first, within};
use crate::journal::Journal;
use crate::log::{ConsistencyProof, InclusionProof, SealedBalanceProof, TreeHead};
use crate::model::{
AccountHandle, AccountKind, AccountRef, Amount, Balance, BalanceLimit, Currency, Entry,
EntryId, EntryRecord, Fingerprint, Genesis, Layer, LedgerId, LedgerPolicy, MemoSalt,
PostingRef, ValueError,
};
use crate::report::{
AccountName, AssertionOutcome, BalanceAssertion, BalanceQuery, OpenItem, Rollup, Statement,
TrialBalance,
};
use crate::store::{
Cell, Erasure, ProjectionPart, ProjectionRead, ProjectionRow, Store, StoreErrorKind, each_row,
head_at,
};
#[cfg(feature = "witness")]
use crate::transparency::{Checkpoint, KeyName, LogKey};
use crate::transparency::{ProofBundle, SignedCheckpoint, TileId};
use crate::{HoldCursor, Page, PageRead};
#[derive(Clone)]
pub struct Ledger<S, const P: u8 = 2> {
store: S,
ledger: LedgerId,
actor: String,
salt: SaltSource,
}
impl<S, const P: u8> fmt::Debug for Ledger<S, P> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Ledger")
.field("ledger", &self.ledger)
.field("actor", &self.actor)
.finish_non_exhaustive()
}
}
impl<S: Store<2>> Ledger<S, 2> {
pub async fn new(store: S, id: LedgerId) -> Result<Self, Error> {
Self::with_precision(store, id).await
}
}
impl<S: Store<P>, const P: u8> Ledger<S, P> {
const SCALE_OK: () = assert!(P <= Amount::<P>::MAX_PRECISION, "P must be <= 18");
pub async fn with_precision(store: S, id: LedgerId) -> Result<Self, Error> {
let () = Self::SCALE_OK;
let info = match store.open().await {
Ok(info) => info,
Err(error) if *error.kind() == StoreErrorKind::NotCreated => {
store.create(&Genesis { ledger: id.clone() }).await?
}
Err(error) => return Err(error.into()),
};
if info.ledger != id || info.precision != P {
return Err(ConfigError::ledgers((&id, P), (&info.ledger, info.precision)).into());
}
Ok(Self {
store,
ledger: id,
actor: String::from("system"),
salt: SaltSource::Os,
})
}
#[must_use]
pub fn with_salt_source(self, source: impl Fn() -> MemoSalt + Send + Sync + 'static) -> Self {
Self {
salt: SaltSource::custom(source),
..self
}
}
#[must_use]
pub fn as_actor(self, actor: impl AsRef<str>) -> Self {
Self {
actor: actor.as_ref().to_owned(),
..self
}
}
#[must_use]
pub fn actor(&self) -> &str {
&self.actor
}
#[must_use]
pub const fn id(&self) -> &LedgerId {
&self.ledger
}
fn reads(&self) -> Reads<'_, S, P> {
Reads::new(self.store.clone(), &self.ledger)
}
pub(crate) async fn run(&self, op: Op<P>) -> Result<Done<P>, Error> {
Ok(self.store.write(&op).await??)
}
pub async fn declare_currency(
&self,
code: Currency,
exponent: u8,
effective: Date,
) -> Result<(), Error> {
self.run(Op::Currency {
code,
exponent,
effective,
actor: self.actor.clone(),
})
.await
.map(drop)
}
pub async fn open(
&self,
path: impl AsRef<str>,
kind: AccountKind,
on: Date,
) -> Result<AccountHandle, Error> {
self.register(AccountSpec::new(path, kind, on)).await
}
pub async fn register(&self, spec: AccountSpec) -> Result<AccountHandle, Error> {
self.run(Op::Register {
spec,
actor: self.actor.clone(),
})
.await?
.into_account()
}
async fn change<A>(&self, account: A, change: ChangeInput, effective: Date) -> Result<(), Error>
where
A: TryInto<AccountRef>,
A::Error: Into<ValueError>,
{
let account = core::account_ref(account)?;
self.run(Op::Change {
account,
change,
effective,
actor: self.actor.clone(),
})
.await
.map(drop)
}
pub async fn set_limit<A>(
&self,
account: A,
limit: BalanceLimit,
effective: Date,
) -> Result<(), Error>
where
A: TryInto<AccountRef>,
A::Error: Into<ValueError>,
{
self.change(account, ChangeInput::Limit(limit), effective)
.await
}
pub async fn set_class<A>(
&self,
account: A,
class: Option<&str>,
effective: Date,
) -> Result<(), Error>
where
A: TryInto<AccountRef>,
A::Error: Into<ValueError>,
{
let class = ChangeInput::Class(class.map(str::to_owned));
self.change(account, class, effective).await
}
pub async fn close_account<A>(&self, account: A, last_day: Date) -> Result<(), Error>
where
A: TryInto<AccountRef>,
A::Error: Into<ValueError>,
{
self.change(account, ChangeInput::OpenTo(Some(last_day)), last_day)
.await
}
pub async fn reopen_account<A>(&self, account: A, effective: Date) -> Result<(), Error>
where
A: TryInto<AccountRef>,
A::Error: Into<ValueError>,
{
self.change(account, ChangeInput::OpenTo(None), effective)
.await
}
pub async fn set_policy(&self, policy: LedgerPolicy, effective: Date) -> Result<(), Error> {
self.run(Op::Policy {
policy,
effective,
actor: self.actor.clone(),
})
.await
.map(drop)
}
pub async fn define_period(
&self,
id: impl AsRef<str>,
start: Date,
end: Date,
) -> Result<(), Error> {
self.run(Op::Period {
id: id.as_ref().to_owned(),
start,
end,
})
.await
.map(drop)
}
pub async fn define_months(&self, year: i32, months: RangeInclusive<u8>) -> Result<(), Error> {
for (id, start, end) in core::months(year, months)? {
self.define_period(id, start, end).await?;
}
Ok(())
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(name = "seal_period", target = "doubleentry", level = "info", skip_all, fields(ledger = %self.ledger, period = %crate::trace::key(id.as_ref()), outcome = tracing::field::Empty, code = tracing::field::Empty, position = tracing::field::Empty))
)]
pub async fn seal_period(&self, id: impl AsRef<str>) -> Result<SealReceipt<P>, Error> {
let result = self
.run(Op::Seal {
period: id.as_ref().to_owned(),
})
.await
.and_then(Done::into_sealed);
#[cfg(feature = "tracing")]
crate::trace::sealed(result.as_ref());
result
}
pub async fn seal_through(&self, id: impl AsRef<str>) -> Result<Vec<SealReceipt<P>>, Error> {
let mut sealed = Vec::new();
loop {
match self.seal_period(id.as_ref()).await {
Ok(receipt) => {
sealed.push(receipt);
return Ok(sealed);
}
Err(error) => match seal_first(&error) {
Some(earliest) => sealed.push(self.seal_period(earliest).await?),
None => return Err(error),
},
}
}
}
pub async fn close_year<A>(
&self,
key: &str,
on: Date,
equity: A,
) -> Result<Vec<Entry<P>>, Error>
where
A: TryInto<AccountRef>,
A::Error: Into<ValueError>,
{
let op = Op::CloseYear {
key: key.to_owned(),
on,
equity: core::account_ref(equity)?,
};
self.run(op).await?.into_closing()
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(name = "record", target = "doubleentry", level = "info", skip_all, fields(ledger = %self.ledger, key = %crate::trace::draft_key(&entry), outcome = tracing::field::Empty, code = tracing::field::Empty, position = tracing::field::Empty))
)]
pub async fn record(&self, entry: Entry<P>) -> Result<Recorded, Error> {
let result = self.record_entry(entry).await;
#[cfg(feature = "tracing")]
crate::trace::recorded(result.as_ref());
result
}
async fn record_entry(&self, entry: Entry<P>) -> Result<Recorded, Error> {
let entry = self.salt.salted(entry)?;
self.run(Op::Record(Box::new(entry))).await?.into_recorded()
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(name = "record_batch", target = "doubleentry", level = "info", skip_all, fields(ledger = %self.ledger, outcome = tracing::field::Empty, code = tracing::field::Empty, index = tracing::field::Empty, position = tracing::field::Empty))
)]
pub async fn record_batch(
&self,
drafts: impl IntoIterator<Item = Entry<P>>,
) -> Result<Vec<Recorded>, Error> {
let result = match self.salt.salted_all(drafts) {
Ok(drafts) => self.run(Op::Batch(drafts)).await.and_then(Done::into_batch),
Err(error) => Err(error),
};
#[cfg(feature = "tracing")]
crate::trace::batch(result.as_ref());
result
}
pub async fn reversal(&self, id: EntryId, key: &str, on: Date) -> Result<Entry<P>, Error> {
self.reads().reversal(id, key, on).await
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(name = "record", target = "doubleentry", level = "info", skip_all, fields(ledger = %self.ledger, key = %crate::trace::key(key), outcome = tracing::field::Empty, code = tracing::field::Empty, position = tracing::field::Empty))
)]
pub async fn clear(
&self,
key: &str,
debit: PostingRef,
credit: PostingRef,
amount: Amount<P>,
on: Date,
) -> Result<u64, Error> {
let result = self
.clearing(Op::Clear {
key: key.to_owned(),
debit,
credit,
amount,
date: on,
})
.await;
#[cfg(feature = "tracing")]
crate::trace::cleared(result.as_ref());
result
}
async fn clearing(&self, op: Op<P>) -> Result<u64, Error> {
self.run(op).await?.into_cleared()
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(name = "record", target = "doubleentry", level = "info", skip_all, fields(ledger = %self.ledger, key = %crate::trace::key(key), outcome = tracing::field::Empty, code = tracing::field::Empty, position = tracing::field::Empty))
)]
pub async fn reset_clearing(&self, key: &str, clearing: u64, on: Date) -> Result<u64, Error> {
let result = self
.clearing(Op::ResetClearing {
key: key.to_owned(),
clearing,
date: on,
})
.await;
#[cfg(feature = "tracing")]
crate::trace::cleared(result.as_ref());
result
}
pub async fn erase_memo(&self, id: EntryId) -> Result<(), Error> {
match self.store.erase_memo(id).await? {
Erasure::UnknownEntry => Err(Error::Query(QueryError::UnknownEntry { id })),
Erasure::Erased | Erasure::AlreadyErased | Erasure::NoMemo => Ok(()),
}
}
pub async fn entry(&self, id: EntryId) -> Result<Option<EntryRecord<P>>, Error> {
self.reads().entry(id).await
}
pub async fn entry_by_key(
&self,
key: impl AsRef<str>,
) -> Result<Option<EntryRecord<P>>, Error> {
self.reads().entry_by_key(key.as_ref().as_bytes()).await
}
pub async fn account<A>(&self, account: A) -> Result<Option<Account>, Error>
where
A: TryInto<AccountRef>,
A::Error: Into<ValueError>,
{
match account.try_into() {
Ok(account) => self.reads().account(account).await,
Err(_) => Ok(None),
}
}
pub async fn memo(&self, id: EntryId) -> Result<Option<String>, Error> {
self.memo_text(id).await
}
pub async fn exponent(&self, currency: Currency) -> Result<Option<u8>, Error> {
let size = self.store.size().await?;
Ok(self.reads().exponents(size).await?.get(¤cy).copied())
}
async fn memo_text(&self, id: EntryId) -> Result<Option<String>, Error> {
self.reads().memo(id).await
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(name = "audit", target = "doubleentry", level = "info", skip_all, fields(ledger = %self.ledger, records = tracing::field::Empty, outcome = tracing::field::Empty, code = tracing::field::Empty))
)]
pub async fn audit(&self) -> Result<AuditReport, Error> {
let result = self.replay().await.map(|(report, _)| report);
#[cfg(feature = "tracing")]
crate::trace::audited(result.as_ref());
result
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(name = "audit", target = "doubleentry", level = "info", skip_all, fields(ledger = %self.ledger, records = tracing::field::Empty, outcome = tracing::field::Empty, code = tracing::field::Empty))
)]
pub async fn to_journal(&self) -> Result<Journal<P>, Error> {
let result = self.replay().await;
#[cfg(feature = "tracing")]
crate::trace::audited(result.as_ref().map(|(report, _)| report));
let (_, replay) = result?;
Ok(Journal::from_replay(replay, &self.actor, self.salt.clone()))
}
async fn replay(&self) -> Result<(AuditReport, Replayer<P>), Error> {
let mut replay = Replayer::<P>::new(&self.ledger);
let mut side: BTreeMap<u64, ProjectionRow> = BTreeMap::new();
let mut fed = 0u64;
loop {
let size = self.store.size().await?;
self.side_data(fed, size, &mut side).await?;
self.feed(&mut replay, &side, &mut fed, size).await?;
if fed != size {
return Err(IntegrityError::new(IntegrityKind::MissingRecord, Some(fed)).into());
}
if self.compare(&replay, &side, size).await? {
return Ok((replay.report(), replay));
}
}
}
async fn side_data(
&self,
from: u64,
size: u64,
side: &mut BTreeMap<u64, ProjectionRow>,
) -> Result<(), Error> {
let mut page = PageRead::first(PageRead::<()>::MAX_LIMIT);
if let Some(last) = from.checked_sub(1) {
page = page.after(key_after(last));
}
loop {
let read = self
.store
.projection(&ProjectionRead {
part: ProjectionPart::Memos,
page: page.clone(),
})
.await?;
for row in read.items {
let position = row_position(&row)?;
if position >= size {
return Ok(());
}
side.insert(position, row);
}
match read.next {
Some(next) => page = page.after(next),
None => return Ok(()),
}
}
}
async fn feed(
&self,
replay: &mut Replayer<P>,
side: &BTreeMap<u64, ProjectionRow>,
fed: &mut u64,
size: u64,
) -> Result<(), Error> {
while *fed < size {
let mut page = PageRead::first(PageRead::<()>::MAX_LIMIT);
if let Some(last) = fed.checked_sub(1) {
page = page.after(key_after(last));
}
let read = self
.store
.projection(&ProjectionRead {
part: ProjectionPart::Leaves,
page,
})
.await?;
if read.items.is_empty() {
return Ok(());
}
for row in read.items {
let position = row_position(&row)?;
if position >= size {
return Ok(());
}
let (kind, bytes) = match row.cells() {
[_, Cell::Int(kind), Cell::Bytes(bytes)] => (*kind, bytes),
_ => return Err(undecodable(position)),
};
if bytes.first().map(|first| i64::from(*first)) != Some(kind) {
return Err(undecodable(position));
}
let (memo, fingerprint) =
side.get(&position).map_or(Ok((None, None)), side_cells)?;
replay.feed(core::ReplayLeaf {
position,
bytes,
memo: memo.as_ref(),
fingerprint,
})?;
*fed = position.saturating_add(1);
}
}
Ok(())
}
async fn compare(
&self,
replay: &Replayer<P>,
side: &BTreeMap<u64, ProjectionRow>,
size: u64,
) -> Result<bool, Error> {
if head_at(&self.store, size).await? != replay.head() {
return Err(core::AuditFailure {
position: None,
kind: core::AuditFailureKind::HeadMismatch,
}
.into());
}
for part in ProjectionPart::ALL {
let mut check = replay.check_part(part);
match part {
ProjectionPart::Leaves => continue,
ProjectionPart::Memos => {
for row in side.values() {
check.feed(row.clone())?;
}
}
_ => {
let complete = each_row(&self.store, part, size, |row| {
check.feed(row).map_err(Error::from)
})
.await?;
if !complete {
return Ok(false);
}
}
}
check.finish()?;
}
Ok(true)
}
#[cfg(feature = "testing")]
pub(crate) const fn store(&self) -> &S {
&self.store
}
pub async fn head(&self) -> Result<TreeHead, Error> {
self.reads().head().await
}
pub async fn head_at(&self, size: u64) -> Result<TreeHead, Error> {
self.reads().head_at(size).await
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(name = "prove", target = "doubleentry", level = "debug", skip_all, fields(ledger = %self.ledger, kind = "entry", size = tracing::field::Empty))
)]
pub async fn prove_entry(&self, id: EntryId) -> Result<InclusionProof, Error> {
self.reads().inclusion(id, None).await
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(name = "prove", target = "doubleentry", level = "debug", skip_all, fields(ledger = %self.ledger, kind = "entry", size = tracing::field::Empty))
)]
pub async fn prove_entry_at(&self, id: EntryId, size: u64) -> Result<InclusionProof, Error> {
self.reads().inclusion(id, Some(size)).await
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(name = "prove", target = "doubleentry", level = "debug", skip_all, fields(ledger = %self.ledger, kind = "consistency", size = tracing::field::Empty))
)]
pub async fn prove_consistency(
&self,
old_size: u64,
new_size: u64,
) -> Result<ConsistencyProof, Error> {
self.reads().consistency(old_size, new_size).await
}
pub async fn hold(&self, id: EntryId) -> Result<Option<HoldState<P>>, Error> {
self.reads().hold(id).await
}
pub async fn holds_due(
&self,
as_of: Date,
page: PageRead<HoldCursor>,
) -> Result<Page<HoldState<P>, HoldCursor>, Error> {
self.reads().holds_due(as_of, page).await
}
pub async fn balance<A>(
&self,
account: A,
currency: Currency,
query: &BalanceQuery,
) -> Result<Balance<P>, Error>
where
A: TryInto<AccountRef> + fmt::Display,
A::Error: Into<ValueError>,
{
self.reads()
.balance(&AccountName::new(account), currency, query)
.await
}
pub async fn trial_balance(&self, query: &BalanceQuery) -> Result<TrialBalance<P>, Error> {
self.reads().trial_balance(query).await
}
pub async fn statement<A>(
&self,
account: A,
currency: Currency,
query: &BalanceQuery,
) -> Result<Statement<P>, Error>
where
A: TryInto<AccountRef> + fmt::Display,
A::Error: Into<ValueError>,
{
self.reads()
.statement(&AccountName::new(account), currency, query)
.await
}
pub async fn rollup(
&self,
currency: Currency,
query: &BalanceQuery,
) -> Result<Rollup<P>, Error> {
self.reads().rollup(currency, query).await
}
pub async fn open_items<A>(
&self,
account: A,
currency: Currency,
query: &BalanceQuery,
) -> Result<Vec<OpenItem<P>>, Error>
where
A: TryInto<AccountRef> + fmt::Display,
A::Error: Into<ValueError>,
{
self.reads()
.open_items(&AccountName::new(account), currency, query)
.await
}
pub async fn check_assertion(
&self,
assertion: &BalanceAssertion<P>,
) -> Result<AssertionOutcome<P>, Error> {
self.reads().check_assertion(assertion).await
}
pub async fn check_assertions(
&self,
assertions: &[BalanceAssertion<P>],
) -> Result<Vec<AssertionOutcome<P>>, Error> {
self.reads().check_assertions(assertions).await
}
pub async fn export(
&self,
scope: ExportScope,
head: Option<TreeHead>,
) -> Result<ExportPages<'_, S, P>, Error> {
let size = self.store.size().await?;
let bound = match head {
Some(head) => {
within(head.size, size)?;
self.reads().pinned(head).await?;
head.size
}
None => size,
};
let exporter = Exporter::new(scope)?;
if let Some(id) = exporter.period()
&& self.reads().period(id, bound).await?.is_none()
{
return Err(QueryError::UnknownPeriod {
id: id.as_str().to_owned(),
}
.into());
}
Ok(ExportPages::new(self, exporter, bound))
}
}
impl<S: Store<P>, const P: u8> Ledger<S, P> {
#[cfg(feature = "witness")]
#[cfg_attr(
feature = "tracing",
tracing::instrument(name = "checkpoint", target = "doubleentry", level = "info", skip_all, fields(ledger = %self.ledger, size = tracing::field::Empty))
)]
pub async fn checkpoint(&self, key: &LogKey) -> Result<SignedCheckpoint, Error> {
let head = self.head().await?;
#[cfg(feature = "tracing")]
crate::trace::proved(head.size);
Ok(key.sign(&Checkpoint::new(KeyName::from(&self.ledger), head))?)
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(name = "prove", target = "doubleentry", level = "debug", skip_all, fields(ledger = %self.ledger, kind = "balance", size = tracing::field::Empty))
)]
pub async fn prove_balance<A>(
&self,
account: A,
currency: Currency,
layer: Layer,
period: impl AsRef<str>,
) -> Result<SealedBalanceProof<P>, Error>
where
A: TryInto<AccountRef> + fmt::Display,
A::Error: Into<ValueError>,
{
self.reads()
.prove_balance(&AccountName::new(account), currency, layer, period.as_ref())
.await
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(name = "prove", target = "doubleentry", level = "debug", skip_all, fields(ledger = %self.ledger, kind = "record", size = tracing::field::Empty))
)]
pub async fn bundle_record(
&self,
position: u64,
checkpoint: &SignedCheckpoint,
) -> Result<ProofBundle<P>, Error> {
self.reads().bundle_record(position, checkpoint).await
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(name = "prove", target = "doubleentry", level = "debug", skip_all, fields(ledger = %self.ledger, kind = "entry", size = tracing::field::Empty))
)]
pub async fn bundle_entry(
&self,
id: EntryId,
checkpoint: &SignedCheckpoint,
) -> Result<ProofBundle<P>, Error> {
self.reads().bundle_entry(id, checkpoint).await
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(name = "prove", target = "doubleentry", level = "debug", skip_all, fields(ledger = %self.ledger, kind = "balance", size = tracing::field::Empty))
)]
pub async fn bundle_balance<A>(
&self,
account: A,
currency: Currency,
layer: Layer,
period: impl AsRef<str>,
checkpoint: &SignedCheckpoint,
) -> Result<ProofBundle<P>, Error>
where
A: TryInto<AccountRef> + fmt::Display,
A::Error: Into<ValueError>,
{
let account = AccountName::new(account);
self.reads()
.bundle_balance(&account, currency, layer, period.as_ref(), checkpoint)
.await
}
pub async fn tile(&self, id: TileId) -> Result<Vec<u8>, Error> {
self.reads().tile(id).await
}
pub async fn tile_entries(&self, id: TileId) -> Result<Vec<u8>, Error> {
self.reads().entry_bundle(id).await
}
}
fn key_after(position: u64) -> Vec<u8> {
ProjectionRow::new(vec![crate::store::cells::int(position)]).to_bytes()
}
fn undecodable(position: u64) -> Error {
IntegrityError::new(IntegrityKind::Undecodable, Some(position)).into()
}
fn row_position(row: &ProjectionRow) -> Result<u64, Error> {
match row.cells().first() {
Some(Cell::Int(position)) => u64::try_from(*position)
.map_err(|_| IntegrityError::new(IntegrityKind::ReplayMismatch, None).into()),
_ => Err(IntegrityError::new(IntegrityKind::ReplayMismatch, None).into()),
}
}
fn side_cells(row: &ProjectionRow) -> Result<(Option<MemoPlain>, Option<Fingerprint>), Error> {
let bad = || -> Error { IntegrityError::new(IntegrityKind::ReplayMismatch, None).into() };
let [_, fingerprint, _, salt, text] = row.cells() else {
return Err(bad());
};
let fingerprint = match fingerprint {
Cell::Null => None,
Cell::Bytes(bytes) => Some(Fingerprint::from_bytes(
bytes.as_slice().try_into().map_err(|_| bad())?,
)),
_ => return Err(bad()),
};
let memo = match (salt, text) {
(Cell::Bytes(salt), Cell::Text(text)) => Some(MemoPlain {
salt: MemoSalt(salt.as_slice().try_into().map_err(|_| bad())?),
text: text.clone(),
}),
(Cell::Null, Cell::Null) => None,
_ => return Err(bad()),
};
Ok((memo, fingerprint))
}