use super::*;
#[cfg(feature = "transparent-inputs")]
fn check_well_formed(commit: &TransparentLedgerCommit<AccountUuid>) -> Result<(), InvalidCommit> {
let identifier_ok = |id: &[u8]| !id.is_empty() && id.len() <= MAX_RECOVERY_IDENTIFIER_LEN;
if !identifier_ok(&commit.revision.source) || !identifier_ok(&commit.revision.revision) {
return Err(InvalidCommit::Identifier);
}
if i64::try_from(commit.revision.lineage).is_err() {
return Err(InvalidCommit::Lineage);
}
let anchor = commit.anchor.height;
if anchor > commit.context.target.height {
return Err(InvalidCommit::AnchorAboveTarget);
}
let publication = &commit.revision.publication;
if anchor > publication.height
|| (anchor == publication.height && commit.anchor.hash != publication.hash)
{
return Err(InvalidCommit::AnchorOutsidePublication);
}
let check_range = |from: BlockHeight, through: BlockHeight| {
if from > through {
Err(InvalidCommit::EmptyRange)
} else if through > anchor {
Err(InvalidCommit::AboveAnchor)
} else {
Ok(())
}
};
for range in commit.coverage.iter().chain(&commit.unsupported) {
check_range(range.from, range.through)?;
}
let mut opened = BTreeSet::new();
for page in &commit.opened_pages {
check_range(page.from, page.through)?;
if !identifier_ok(&page.page) {
return Err(InvalidCommit::Identifier);
}
if page.addresses.is_empty() || !opened.insert(&page.page) {
return Err(InvalidCommit::Page);
}
}
if commit
.completed_pages
.iter()
.any(|page| !identifier_ok(page))
{
return Err(InvalidCommit::Identifier);
}
for receive in &commit.receives {
if receive
.metadata
.is_some_and(|m| !m.is_valid_for(receive.coinbase))
{
return Err(InvalidCommit::TransactionMetadata);
}
}
for spend in &commit.spends {
if spend.metadata.is_some_and(|m| {
!m.is_valid_for(false) || spend.input_index >= m.transparent_input_count
}) {
return Err(InvalidCommit::TransactionMetadata);
}
}
let mined = commit.receives.iter().map(|r| r.mined_height);
if mined
.chain(commit.spends.iter().map(|s| s.mined_height))
.any(|h| h > anchor)
{
return Err(InvalidCommit::AboveAnchor);
}
Ok(())
}
#[cfg(feature = "transparent-inputs")]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum CommitTrust {
Observed,
Qualified,
}
#[cfg(feature = "transparent-inputs")]
pub(crate) fn apply_commit<P: consensus::Parameters>(
conn: &rusqlite::Connection,
params: &P,
gap_limits: &GapLimits,
configured: Option<TransparentLedgerMode>,
commit: TransparentLedgerCommit<AccountUuid>,
trust: CommitTrust,
) -> Result<CommitOutcome, SqliteClientError> {
check_well_formed(&commit).map_err(|e| reject(CommitRejection::Invalid(e)))?;
atomically(conn, |conn| {
resolve_mode(conn, configured)?;
if !grants_private_authority(conn, configured)? {
return Err(SqliteClientError::TransparentRecoveryNotEnabled);
}
check_transparent_policy_generation(conn, commit.context.policy_generation)?;
let stale = |reason| reject(CommitRejection::Stale(reason));
let recovery_addresses =
AccountRecoveryAddresses::load(conn, params, gap_limits, commit.context.account)?
.ok_or_else(|| stale(StaleCommit::AccountUnknown))?;
let account_ref = recovery_addresses.account.internal_id();
if source_recovery_is_suspended(conn, &commit.revision.source)? {
return Err(reject(CommitRejection::Refused(
RefusedCommit::SourceRecoverySuspended,
)));
}
if account_recovery_is_suspended(conn, account_ref)? {
return Err(reject(CommitRejection::Refused(
RefusedCommit::AccountRecoverySuspended,
)));
}
if account_lifecycle(conn, account_ref)? != commit.context.lifecycle {
return Err(stale(StaleCommit::LifecycleChanged));
}
let target = commit.context.target;
if fully_scanned_height(conn)?.is_none_or(|scanned| target.height > scanned)
|| !is_local_block(conn, &target)?
{
return Err(stale(StaleCommit::TargetNotAccepted));
}
if !is_local_block(conn, &commit.anchor)? {
return Err(stale(StaleCommit::AnchorNotAccepted));
}
let named = commit
.receives
.iter()
.map(|r| r.address)
.chain(commit.spends.iter().map(|s| s.prevout_address))
.chain(commit.coverage.iter().map(|r| r.address))
.chain(commit.unsupported.iter().map(|r| r.address))
.chain(
commit
.opened_pages
.iter()
.flat_map(|p| p.addresses.iter().copied()),
);
for address in named {
if !recovery_addresses.addresses.contains_key(&address) {
return Err(stale(StaleCommit::AddressNotWatched(address)));
}
}
match atomically(conn, |conn| {
if trust == CommitTrust::Qualified {
qualify_in(conn, &commit.revision)?;
}
apply_facts(conn, params, gap_limits, &recovery_addresses, &commit)
}) {
Err(SqliteClientError::TransparentLedgerCommitRejected(
rejection @ CommitRejection::Integrity(_),
)) => {
suspend_recovery(conn, &commit.revision.source, account_ref)?;
Ok(Err(reject(rejection)))
}
result => result.map(Ok),
}
})?
}
#[cfg(feature = "transparent-inputs")]
fn apply_facts<P: consensus::Parameters>(
conn: &rusqlite::Connection,
params: &P,
gap_limits: &GapLimits,
recovery_addresses: &AccountRecoveryAddresses,
commit: &TransparentLedgerCommit<AccountUuid>,
) -> Result<CommitOutcome, SqliteClientError> {
let stale = |reason| reject(CommitRejection::Stale(reason));
let account_ref = recovery_addresses.account.internal_id();
let target = commit.context.target;
let active = commit.context.lifecycle == AccountLifecycle::Active;
let revision_id = super::revisions::register_revision(conn, &commit.revision)?;
if active && !is_qualified(conn, revision_id)? {
return Err(reject(CommitRejection::Refused(
RefusedCommit::UnqualifiedRevision,
)));
}
for page in &commit.completed_pages {
let removed = conn.execute(
"DELETE FROM tpir_pending_pages
WHERE account_id = :account_id AND revision_id = :revision_id AND page = :page",
named_params![
":account_id": account_ref.0,
":revision_id": revision_id,
":page": page,
],
)?;
if removed == 0 {
return Err(stale(StaleCommit::UnknownPage(page.clone())));
}
}
for page in &commit.opened_pages {
open_page(conn, account_ref, revision_id, &target, page)?;
}
for receive in &commit.receives {
apply_receive(conn, account_ref, revision_id, receive)?;
super::metadata::apply_metadata(
conn,
account_ref,
revision_id,
TxId::from_bytes(*receive.outpoint.hash()),
receive.mined_height,
receive.metadata,
)?;
}
for spend in &commit.spends {
apply_spend(conn, account_ref, revision_id, spend)?;
super::metadata::apply_metadata(
conn,
account_ref,
revision_id,
spend.spending_txid,
spend.mined_height,
spend.metadata,
)?;
}
for range in &commit.coverage {
record_range(
conn,
account_ref,
revision_id,
&commit.anchor,
range,
CoverageSupport::Supported,
)?;
}
for range in &commit.unsupported {
record_range(
conn,
account_ref,
revision_id,
&commit.anchor,
range,
CoverageSupport::Unsupported,
)?;
}
let window_grew = if active {
ownership::materialize_recovery_addresses(conn, params, recovery_addresses)?;
for receive in &commit.receives {
projection::project_receive(conn, params, gap_limits, commit.context.account, receive)?;
}
for spend in &commit.spends {
projection::project_spend(conn, params, gap_limits, spend)?;
}
let after =
AccountRecoveryAddresses::load(conn, params, gap_limits, commit.context.account)?
.ok_or(SqliteClientError::AccountUnknown)?;
(0..WINDOW_SCOPES.len())
.any(|slot| after.watched_end(slot) > recovery_addresses.watched_end(slot))
} else {
let mut window_grew = false;
for (slot, needed) in recovery_addresses
.window_needs(conn, gap_limits)?
.into_iter()
.enumerate()
{
if let Some(needed) = needed.filter(|_| recovery_addresses.derivable[slot]) {
conn.execute(
"INSERT INTO tpir_candidate_windows (account_id, key_scope, end_index)
VALUES (:account_id, :key_scope, :end_index)
ON CONFLICT (account_id, key_scope)
DO UPDATE SET end_index = MAX(end_index, excluded.end_index)",
named_params![
":account_id": account_ref.0,
":key_scope": KeyScope::try_from(WINDOW_SCOPES[slot])?.encode(),
":end_index": needed,
],
)?;
window_grew |= needed > recovery_addresses.watched_end(slot);
}
}
window_grew
};
super::super::require_reader_version(conn, super::super::RECOVERY_READER_VERSION)?;
Ok(CommitOutcome { window_grew })
}
#[cfg(feature = "transparent-inputs")]
fn is_qualified(conn: &rusqlite::Connection, revision_id: i64) -> Result<bool, SqliteClientError> {
Ok(conn.query_row(
"SELECT EXISTS (
SELECT 1 FROM tpir_qualified_revisions WHERE revision_id = :revision_id
)",
named_params![":revision_id": revision_id],
|row| row.get(0),
)?)
}
#[cfg(feature = "transparent-inputs")]
pub(super) fn account_lifecycle(
conn: &rusqlite::Connection,
account_ref: AccountRef,
) -> Result<AccountLifecycle, SqliteClientError> {
let active: bool = conn.query_row(
"SELECT EXISTS (SELECT 1 FROM tpir_active_accounts WHERE account_id = :account_id)",
named_params![":account_id": account_ref.0],
|row| row.get(0),
)?;
Ok(if active {
AccountLifecycle::Active
} else {
AccountLifecycle::Candidate
})
}
#[cfg(all(
feature = "transparent-inputs",
any(test, feature = "test-dependencies")
))]
pub(crate) fn qualify_revision(
conn: &rusqlite::Connection,
revision: &RecoveryRevision,
) -> Result<(), SqliteClientError> {
let identifier_ok = |id: &[u8]| !id.is_empty() && id.len() <= MAX_RECOVERY_IDENTIFIER_LEN;
if !identifier_ok(&revision.source) || !identifier_ok(&revision.revision) {
return Err(reject(CommitRejection::Invalid(InvalidCommit::Identifier)));
}
if i64::try_from(revision.lineage).is_err() {
return Err(reject(CommitRejection::Invalid(InvalidCommit::Lineage)));
}
atomically(conn, |conn| {
durable_policy(conn)?;
qualify_in(conn, revision)
})
}
#[cfg(feature = "transparent-inputs")]
fn qualify_in(
conn: &rusqlite::Connection,
revision: &RecoveryRevision,
) -> Result<(), SqliteClientError> {
let revision_id = super::revisions::register_revision(conn, revision)?;
let inserted = conn.execute(
"INSERT OR IGNORE INTO tpir_qualified_revisions (revision_id) VALUES (:revision_id)",
named_params![":revision_id": revision_id],
)?;
if inserted > 0 {
super::revisions::supersede_provisional(conn, revision)?;
}
super::super::require_reader_version(conn, super::super::REVISION_READER_VERSION)
}