use orchard::keys::Scope;
use rusqlite::{Connection, OptionalExtension, named_params, params};
use zakura_one_time_ivk::swap::{
Observation,
OperationStatus::Terminal,
ReceiptExpectation::{Positive, Unknown},
};
use zcash_client_backend::data_api::wallet::ConfirmationsPolicy;
use zcash_protocol::consensus::{BlockHeight, Parameters};
use super::{RESERVED, ReservationPolicy, lifecycle::record};
use crate::wallet::one_time_ivk::{
Discovery, KeyId, OneTimeKey, Purpose, account_key, activate, corrupt, decode_index,
index_after, invalid, key_by_id, register, restore_start,
};
use crate::{AccountUuid, error::SqliteClientError, wallet};
pub const RECEIVE_GAP_LIMIT: u64 = 30;
pub const RECEIVE_RECLAIM_SECONDS: i64 = 2 * 60 * 60;
const STATUS_FRESH_SECONDS: i64 = 120;
pub const SEEN_SLACK_SECONDS: i64 = 60 * 60;
pub struct ProviderSeen<'a> {
pub since: i64,
pub until: i64,
pub contains: &'a dyn Fn(&[[u8; 43]]) -> Vec<bool>,
}
impl ProviderSeen<'_> {
pub(in crate::wallet::one_time_ivk) fn holds(
&self,
receiver: &orchard::Address,
) -> Result<bool, SqliteClientError> {
match (self.contains)(&[receiver.to_raw_address_bytes()])[..] {
[hit] => Ok(hit),
_ => Err(wrong_answer_count()),
}
}
}
fn wrong_answer_count() -> SqliteClientError {
invalid("seen set answered for the wrong number of receivers")
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ReceiveDeposit {
pub address: String,
pub memo: Option<String>,
pub deadline: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum OperationOutcome {
Accepted(ReceiveDeposit),
Rejected,
}
fn coverage(seen: Option<&ProviderSeen<'_>>) -> (i64, i64) {
seen.map_or((i64::MAX, i64::MIN), |s| {
(
s.since.saturating_add(SEEN_SLACK_SECONDS),
s.until.saturating_sub(SEEN_SLACK_SECONDS),
)
})
}
const SEEN: &str = "(k.provider_seen = 1
OR EXISTS(SELECT 1 FROM ironwood_one_time_operations o
WHERE o.one_time_key_id = k.id AND o.request IS NOT NULL
AND (o.reference IS NOT NULL OR o.rejected = 1
OR o.begun_at < :since OR o.begun_at > :covered)))";
pub(in crate::wallet::one_time_ivk) const STARTED: &str =
"EXISTS(SELECT 1 FROM ironwood_one_time_operations o
WHERE o.one_time_key_id = k.id AND o.started = 1)";
fn scanned_empty(conn: &Connection, key: i64) -> Result<bool, SqliteClientError> {
let empty: bool = conn.query_row(
"SELECT active_from IS NOT NULL AND closed_at IS NULL AND used = 0
AND NOT EXISTS(SELECT 1 FROM ironwood_one_time_payment_recovery
WHERE one_time_key_id = ?1)
FROM ironwood_one_time_keys WHERE id = ?1",
[key],
|r| r.get(0),
)?;
let tip = wallet::chain_tip_height(conn)?;
Ok(empty && tip.is_some() && wallet::fully_scanned_height(conn)? == tip)
}
fn recovery_end(conn: &Connection, account: i64) -> Result<u64, SqliteClientError> {
let confirmed_below = wallet::chain_tip_height(conn)?.map(|tip| {
u32::from(tip)
.saturating_add(1)
.saturating_sub(ConfirmationsPolicy::default().untrusted().get())
});
let paid: Option<Vec<u8>> = conn.query_row(
"SELECT MAX(k.key_index) FROM ironwood_one_time_keys k
WHERE k.account_id = :account AND k.purpose = 1 AND (k.paid_before_birthday = 1
OR k.provider_seen = 1
OR EXISTS(SELECT 1 FROM ironwood_one_time_operations o
WHERE o.one_time_key_id = k.id AND o.reference IS NOT NULL)
OR EXISTS(SELECT 1 FROM ironwood_received_notes n
JOIN transactions t ON t.id_tx = n.transaction_id
JOIN blocks b ON b.height = t.mined_height
WHERE n.one_time_key_id = k.id AND t.mined_height <= :confirmed_below))",
named_params![":account": account, ":confirmed_below": confirmed_below],
|r| r.get(0),
)?;
let next = paid
.map(|bytes| index_after(decode_index(bytes)?, 1))
.transpose()?
.unwrap_or(0);
index_after(next, RECEIVE_GAP_LIMIT)
}
fn release(conn: &Connection, key: i64, now: i64) -> Result<(), SqliteClientError> {
conn.execute(
"UPDATE ironwood_one_time_keys SET released_at = ?2 WHERE id = ?1",
params![key, now],
)?;
conn.execute(
"UPDATE ironwood_one_time_operations
SET expectation = 1, expected_value = NULL, observed_at = MAX(observed_at, ?2)
WHERE one_time_key_id = ?1 AND request IS NOT NULL AND expectation IN (0, 4)",
params![key, now],
)?;
Ok(())
}
fn abandon(conn: &Connection, key: i64, now: i64) -> Result<(), SqliteClientError> {
release(conn, key, now)?;
conn.execute(
"UPDATE ironwood_one_time_keys SET closed_at = ?2 WHERE id = ?1 AND closed_at IS NULL",
params![key, now],
)?;
Ok(())
}
fn deadline_clock(conn: &Connection, now: i64) -> Result<i64, SqliteClientError> {
Ok(conn.query_row(
"SELECT MIN(?1, IFNULL((SELECT time FROM blocks ORDER BY height DESC LIMIT 1), ?1))",
[now],
|r| r.get(0),
)?)
}
fn reusable(
conn: &Connection,
key: i64,
now: i64,
deadline_now: i64,
) -> Result<bool, SqliteClientError> {
Ok(conn.query_row(
&format!(
"SELECT {RESERVED} AND k.reserved_at <= :now - :reclaim AND k.used = 0
AND NOT EXISTS(SELECT 1 FROM ironwood_one_time_operations o
WHERE o.one_time_key_id = k.id AND o.request IS NOT NULL AND (
o.deadline > :deadline_now - :reclaim
OR ({STARTED} AND o.reference IS NOT NULL AND NOT (
o.observed_at BETWEEN :now - :fresh AND :now
AND (o.expectation = 1
OR (o.expectation IN (3, 4) AND o.funded = 0))))))
FROM ironwood_one_time_keys k WHERE k.id = :key"
),
named_params![
":key": key,
":now": now,
":deadline_now": deadline_now,
":reclaim": RECEIVE_RECLAIM_SECONDS,
":fresh": STATUS_FRESH_SECONDS,
],
|r| r.get(0),
)?)
}
pub(in crate::wallet::one_time_ivk) fn reap<P: Parameters>(
conn: &rusqlite::Transaction<'_>,
params: &P,
account: AccountUuid,
now: i64,
) -> Result<Vec<u64>, SqliteClientError> {
let (owner, _) = account_key(conn, params, account)?;
let deadline_now = deadline_clock(conn, now)?;
let paid = conn
.prepare(&format!(
"SELECT k.id FROM ironwood_one_time_keys k
WHERE k.account_id = :account AND k.used = 1 AND {RESERVED}
AND NOT EXISTS(SELECT 1 FROM ironwood_one_time_operations o
WHERE o.one_time_key_id = k.id AND o.request IS NOT NULL AND (
(o.reference IS NULL AND o.deadline > :deadline_now - :reclaim)
OR (o.reference IS NOT NULL AND (o.observed_at > :now OR NOT (
o.expectation IN (1, 2, 3)
OR (o.expectation = 4 AND o.funded = 0
AND o.deadline <= :deadline_now - :reclaim
AND o.observed_at >= :now - :fresh))))))"
))?
.query_map(
named_params![
":account": owner.0,
":now": now,
":deadline_now": deadline_now,
":reclaim": RECEIVE_RECLAIM_SECONDS,
":fresh": STATUS_FRESH_SECONDS,
],
|r| r.get::<_, i64>(0),
)?
.collect::<Result<Vec<_>, _>>()?;
for key in paid {
release(conn, key, now)?;
}
let open = conn
.prepare(&format!(
"SELECT k.id, k.key_index FROM ironwood_one_time_keys k
WHERE k.account_id = ?1 AND {RESERVED} ORDER BY k.key_index"
))?
.query_map([owner.0], |r| Ok((r.get::<_, i64>(0)?, r.get(1)?)))?
.collect::<Result<Vec<_>, _>>()?;
let mut reclaimed = Vec::new();
for (key, index) in open {
if reusable(conn, key, now, deadline_now)? && scanned_empty(conn, key)? {
abandon(conn, key, now)?;
reclaimed.push(decode_index(index)?);
}
}
Ok(reclaimed)
}
pub(in crate::wallet::one_time_ivk) fn begin(
conn: &rusqlite::Transaction<'_>,
account: AccountUuid,
index: u64,
request: &str,
deadline: i64,
now: i64,
) -> Result<Result<(), ReservationPolicy>, SqliteClientError> {
if request.is_empty() {
return Err(invalid("empty operation request"));
}
if deadline <= now {
return Err(invalid("operation deadline has passed"));
}
let (key, owner, open): (i64, i64, bool) = conn
.query_row(
&format!(
"SELECT k.id, k.account_id, {RESERVED} AND k.used = 0 AND NOT {STARTED}
FROM ironwood_one_time_keys k JOIN accounts a ON a.id = k.account_id
WHERE a.uuid = ?1 AND k.purpose = 1 AND k.key_index = ?2"
),
params![account.0, index.to_be_bytes()],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.optional()?
.ok_or_else(|| invalid("unknown receive reservation"))?;
if !open {
return Ok(Err(ReservationPolicy::Stale));
}
if index >= recovery_end(conn, owner)? {
return Ok(Err(ReservationPolicy::Gap));
}
if !scanned_empty(conn, key)? {
return Ok(Err(ReservationPolicy::Coverage));
}
conn.execute(
"INSERT INTO ironwood_one_time_operations
(one_time_key_id, request, deadline, begun_at, observed_at)
VALUES (?1, ?2, ?3, ?4, ?4)",
params![key, request, deadline, now],
)?;
Ok(Ok(()))
}
fn operation(
conn: &Connection,
account: AccountUuid,
request: &str,
) -> Result<i64, SqliteClientError> {
conn.query_row(
"SELECT o.id FROM ironwood_one_time_operations o
JOIN ironwood_one_time_keys k ON k.id = o.one_time_key_id
JOIN accounts a ON a.id = k.account_id
WHERE a.uuid = ?1 AND o.request = ?2",
params![account.0, request],
|r| r.get(0),
)
.optional()?
.ok_or_else(|| invalid("unknown operation request"))
}
pub(in crate::wallet::one_time_ivk) fn finish(
conn: &rusqlite::Transaction<'_>,
account: AccountUuid,
request: &str,
outcome: &OperationOutcome,
) -> Result<(), SqliteClientError> {
let id = operation(conn, account, request)?;
match outcome {
OperationOutcome::Accepted(deposit) => {
if deposit.address.is_empty() {
return Err(invalid("empty deposit address"));
}
let expired: bool = conn.query_row(
"SELECT ?2 <= MIN(begun_at,
IFNULL((SELECT time FROM blocks ORDER BY height DESC LIMIT 1), begun_at))
FROM ironwood_one_time_operations WHERE id = ?1",
params![id, deposit.deadline],
|r| r.get(0),
)?;
if expired {
return Err(invalid("accepted deadline has passed"));
}
let bound: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM ironwood_one_time_operations o
JOIN ironwood_one_time_keys k ON k.id = o.one_time_key_id
JOIN accounts a ON a.id = k.account_id
WHERE a.uuid = ?1 AND o.id != ?2 AND o.reference = ?3 AND o.memo IS ?4)",
params![account.0, id, deposit.address, deposit.memo],
|r| r.get(0),
)?;
if bound {
return Err(invalid("deposit instructions belong to another operation"));
}
let changed = conn.execute(
"UPDATE ironwood_one_time_operations
SET reference = ?2, memo = ?3, deadline = MIN(deadline, ?4)
WHERE id = ?1 AND rejected = 0
AND (reference IS NULL OR (reference = ?2 AND memo IS ?3))",
params![id, deposit.address, deposit.memo, deposit.deadline],
)?;
if changed != 1 {
return Err(invalid("operation outcome changed"));
}
}
OperationOutcome::Rejected => {
let changed = conn.execute(
"UPDATE ironwood_one_time_operations SET rejected = 1
WHERE id = ?1 AND reference IS NULL",
[id],
)?;
if changed != 1 {
return Err(invalid("operation outcome changed"));
}
}
}
Ok(())
}
pub(in crate::wallet::one_time_ivk) fn start(
conn: &rusqlite::Transaction<'_>,
account: AccountUuid,
request: &str,
now: i64,
) -> Result<Result<ReceiveDeposit, ReservationPolicy>, SqliteClientError> {
let id = operation(conn, account, request)?;
let deposit = conn
.query_row(
&format!(
"SELECT o.reference, o.memo, o.deadline FROM ironwood_one_time_operations o
JOIN ironwood_one_time_keys k ON k.id = o.one_time_key_id
WHERE o.id = ?1 AND o.reference IS NOT NULL AND {RESERVED}"
),
[id],
|r| {
Ok(ReceiveDeposit {
address: r.get(0)?,
memo: r.get(1)?,
deadline: r.get(2)?,
})
},
)
.optional()?;
let Some(deposit) = deposit else {
return Ok(Err(ReservationPolicy::Stale));
};
let other_started: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM ironwood_one_time_operations o
JOIN ironwood_one_time_operations other
ON other.one_time_key_id = o.one_time_key_id
WHERE o.id = ?1 AND other.id != ?1 AND other.started = 1)",
[id],
|r| r.get(0),
)?;
if other_started || deposit.deadline <= deadline_clock(conn, now)? {
return Ok(Err(ReservationPolicy::Stale));
}
conn.execute(
"UPDATE ironwood_one_time_operations SET started = 1 WHERE id = ?1",
[id],
)?;
Ok(Ok(deposit))
}
pub(in crate::wallet::one_time_ivk) fn record_status<P: Parameters>(
conn: &rusqlite::Transaction<'_>,
params: &P,
account: AccountUuid,
(deposit, memo): (&str, Option<&str>),
observation: Observation,
now: i64,
) -> Result<bool, SqliteClientError> {
if deposit.is_empty() {
return Err(invalid("empty deposit address"));
}
let operations = conn
.prepare(&format!(
"SELECT o.id, k.id,
k.purpose = 0 AND k.active_from IS NULL AND k.closed_at IS NULL, k.scan_from
FROM ironwood_one_time_operations o
JOIN ironwood_one_time_keys k ON k.id = o.one_time_key_id
JOIN accounts a ON a.id = k.account_id
WHERE a.uuid = ?1 AND o.reference = ?2 AND o.memo IS ?3
AND (k.purpose = 0 OR {RESERVED} OR (k.used = 1 AND (o.funded = 1 OR ?4)))"
))?
.query_map(params![account.0, deposit, memo, observation.funded], |r| {
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))
})?
.collect::<Result<Vec<(i64, i64, bool, u32)>, _>>()?;
let refund_owed = matches!(observation.status, Terminal(Positive(_) | Unknown));
for &(id, key, unstarted_refund, scan_from) in &operations {
if record(conn, id, observation, now)? && unstarted_refund && refund_owed {
activate(conn, key, BlockHeight::from(scan_from))?;
}
}
reap(conn, params, account, now)?;
Ok(!operations.is_empty())
}
pub(in crate::wallet::one_time_ivk) fn prepare<P: Parameters>(
conn: &rusqlite::Transaction<'_>,
params: &P,
account: AccountUuid,
now: i64,
scan_from: BlockHeight,
seen: Option<&ProviderSeen<'_>>,
) -> Result<Result<OneTimeKey, ReservationPolicy>, SqliteClientError> {
let (a, parent) = account_key(conn, params, account)?;
let sweeping: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM ironwood_one_time_sweeps s
JOIN ironwood_one_time_keys k ON k.id = s.one_time_key_id
WHERE k.account_id = ?1 AND k.purpose = 1 AND s.done_height IS NULL)",
[a.0],
|r| r.get(0),
)?;
if sweeping {
return Ok(Err(ReservationPolicy::Gap));
}
let coverage = coverage(seen);
let end = recovery_end(conn, a.0)?;
let draft: Option<(i64, Vec<u8>, bool)> = conn
.query_row(
&format!(
"SELECT k.id, k.key_index, {SEEN} FROM ironwood_one_time_keys k
WHERE k.account_id = :account AND {RESERVED} AND k.used = 0 AND NOT {STARTED}
ORDER BY k.reserved_at, k.id LIMIT 1"
),
named_params![":account": a.0, ":since": coverage.0, ":covered": coverage.1],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.optional()?;
match draft {
Some((key, index, false)) => {
if decode_index(index)? >= end {
return Ok(Err(ReservationPolicy::Gap));
}
return key_by_id(conn, params, account, key).map(Ok);
}
Some((key, _, true)) => abandon(conn, key, now)?,
None => {}
}
let mut abandoned = None;
let mut fresh = None;
for index in 0..end {
let row: Option<(bool, bool)> = conn
.query_row(
&format!(
"SELECT k.used = 1 OR {SEEN} OR {RESERVED}
OR EXISTS(SELECT 1 FROM ironwood_one_time_payment_recovery p
WHERE p.one_time_key_id = k.id),
EXISTS(SELECT 1 FROM ironwood_one_time_operations o
WHERE o.one_time_key_id = k.id AND o.request IS NOT NULL)
FROM ironwood_one_time_keys k
WHERE k.account_id = :account AND k.purpose = 1 AND k.key_index = :index"
),
named_params![
":account": a.0,
":index": index.to_be_bytes(),
":since": coverage.0,
":covered": coverage.1,
],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()?;
let Some((blocked, quoted)) = row else {
let key_id = KeyId::new(Purpose::Receive, index);
let receiver = key_id.derive(&parent)?.address_at(0u32, Scope::External);
if seen.map(|s| s.holds(&receiver)).transpose()? == Some(true) {
let from = restore_start(conn, params, a)?;
let (id, _) = register(
conn,
params,
account,
key_id,
from,
false,
Discovery::Sweep,
now,
)?;
record_provider_seen(conn, id)?;
continue;
}
fresh = Some(index);
break;
};
if blocked {
continue;
}
if !quoted {
fresh = Some(index);
break;
}
abandoned.get_or_insert(index);
}
let Some(index) = fresh.or(abandoned) else {
return Ok(Err(ReservationPolicy::Limit));
};
let (key, registered) = register(
conn,
params,
account,
KeyId::new(Purpose::Receive, index),
scan_from,
true,
Discovery::Scan,
now,
)?;
conn.execute(
"UPDATE ironwood_one_time_keys SET reserved_at = ?2, released_at = NULL WHERE id = ?1",
params![key, now],
)?;
Ok(Ok(registered))
}
pub(in crate::wallet::one_time_ivk) fn record_seen(
conn: &Connection,
account: i64,
seen: &ProviderSeen<'_>,
) -> Result<(), SqliteClientError> {
let mut stmt = conn.prepare(
"SELECT k.id, k.receiver FROM ironwood_one_time_keys k
WHERE k.account_id = ?1 AND k.purpose = 1 AND k.provider_seen = 0",
)?;
let keys = stmt
.query_map([account], |r| {
Ok((r.get::<_, i64>(0)?, r.get::<_, Vec<u8>>(1)?))
})?
.collect::<Result<Vec<_>, _>>()?;
let receivers = keys
.iter()
.map(|(_, receiver)| {
<[u8; 43]>::try_from(receiver.as_slice()).map_err(|_| corrupt("invalid receiver"))
})
.collect::<Result<Vec<_>, _>>()?;
let hits = (seen.contains)(&receivers);
if hits.len() != keys.len() {
return Err(wrong_answer_count());
}
for ((id, _), hit) in keys.iter().zip(hits) {
if hit {
record_provider_seen(conn, *id)?;
}
}
Ok(())
}
pub(in crate::wallet::one_time_ivk) fn record_provider_seen(
conn: &Connection,
id: i64,
) -> Result<(), SqliteClientError> {
conn.execute(
"UPDATE ironwood_one_time_keys
SET provider_seen = 1, advances_allocation = 1 WHERE id = ?1",
[id],
)?;
Ok(())
}