use orchard::keys::Scope;
use rusqlite::{Connection, OptionalExtension, params};
use zakura_one_time_ivk::swap::{Observation, OperationStatus, REFUND_MEMO_MAGIC, RefundMemo};
use zcash_client_backend::proposal::Proposal;
use zcash_keys::{address::Address, encoding::AddressCodec as _};
use zcash_primitives::transaction::Transaction;
use zcash_protocol::{
PoolType,
consensus::{BlockHeight, Parameters},
memo::MemoBytes,
};
use super::{
ProviderSeen, ReservationPolicy, issuance_start, lifecycle::record,
reservations::record_provider_seen,
};
use crate::wallet::one_time_ivk::{
Discovery, KeyId, OneTimeKey, Purpose, account_key, activate, decode_index, index_after,
invalid, register, restore::recovery, restore_start,
};
use crate::{AccountUuid, error::SqliteClientError};
pub(in crate::wallet::one_time_ivk) fn reserve<P: Parameters>(
conn: &rusqlite::Transaction<'_>,
params: &P,
account: AccountUuid,
tip: BlockHeight,
seen: Option<&ProviderSeen<'_>>,
now: i64,
) -> Result<Result<OneTimeKey, ReservationPolicy>, SqliteClientError> {
let scan_from = match issuance_start(conn, tip)? {
Ok(from) => from,
Err(policy) => return Ok(Err(policy)),
};
recovery::recover_refund_memos(conn, params, account, now)?;
if recovery::refund_memos_pending(conn, account)? {
return Ok(Err(ReservationPolicy::Coverage));
}
let index = match next_refund_index(conn, params, account, seen, now)? {
Ok(index) => index,
Err(policy) => return Ok(Err(policy)),
};
let key = KeyId::new(Purpose::Refund, index);
let (_, key) = register(
conn,
params,
account,
key,
scan_from,
true,
Discovery::Funding,
now,
)?;
Ok(Ok(key))
}
const REFUND_SEEN_PROBES: u32 = 100;
pub(in crate::wallet::one_time_ivk) fn next_refund_index<P: Parameters>(
conn: &rusqlite::Transaction<'_>,
params: &P,
account: AccountUuid,
seen: Option<&ProviderSeen<'_>>,
now: i64,
) -> Result<Result<u64, ReservationPolicy>, SqliteClientError> {
let (account_ref, parent) = account_key(conn, params, account)?;
let last: Option<Vec<u8>> = conn.query_row(
"SELECT MAX(key_index) FROM ironwood_one_time_keys
WHERE account_id = ?1 AND purpose = 0 AND advances_allocation = 1",
[account_ref.0],
|row| row.get(0),
)?;
let mut index = match last {
Some(bytes) => index_after(decode_index(bytes)?, 1)?,
None => 0,
};
let mut probes = 0;
loop {
let registered = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM ironwood_one_time_keys
WHERE account_id = ?1 AND purpose = 0 AND key_index = ?2)",
params![account_ref.0, index.to_be_bytes()],
|row| row.get::<_, bool>(0),
)?;
if !registered {
let Some(seen) = seen else {
return Ok(Ok(index));
};
if probes == REFUND_SEEN_PROBES {
return Ok(Err(ReservationPolicy::Gap));
}
probes += 1;
let key = KeyId::new(Purpose::Refund, index);
let receiver = key.derive(&parent)?.address_at(0u32, Scope::External);
if !seen.holds(&receiver)? {
return Ok(Ok(index));
}
let from = restore_start(conn, params, account_ref)?;
let (id, _) = register(
conn,
params,
account,
key,
from,
false,
Discovery::Sweep,
now,
)?;
record_provider_seen(conn, id)?;
}
index = index_after(index, 1)?;
}
}
pub(in crate::wallet::one_time_ivk) fn funding_memo<P: Parameters>(
conn: &Connection,
params: &P,
account: AccountUuid,
index: u64,
deposit: &str,
) -> Result<MemoBytes, SqliteClientError> {
check_deposit(params, deposit)?;
let key = refund_key(conn, params, account, index)?;
if refund_operation(conn, key, deposit)?.is_none() {
return Err(invalid("refund operation was not recorded for this key"));
}
Ok(MemoBytes::from_bytes(&RefundMemo::new(index).encode()).expect("a full-length memo"))
}
pub(in crate::wallet::one_time_ivk) fn record_operation<P: Parameters>(
conn: &rusqlite::Transaction<'_>,
params: &P,
account: AccountUuid,
index: u64,
deposit: &str,
deadline: i64,
now: i64,
) -> Result<(), SqliteClientError> {
check_deposit(params, deposit)?;
if now < 0 || deadline <= now {
return Err(invalid("invalid refund operation deadline"));
}
let key = refund_key(conn, params, account, index)?;
let (elsewhere, recorded, rebound): (bool, bool, bool) = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM ironwood_one_time_operations
WHERE reference = ?2 AND one_time_key_id <> ?1),
EXISTS(SELECT 1 FROM ironwood_one_time_operations
WHERE reference = ?2 AND one_time_key_id = ?1),
EXISTS(SELECT 1 FROM ironwood_one_time_operations
WHERE reference <> ?2 AND one_time_key_id = ?1)",
params![key, deposit],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)?;
if elsewhere {
return Err(invalid("deposit address is recorded for another key"));
}
if rebound {
return Err(invalid("refund key is bound to another deposit"));
}
if !recorded {
conn.execute(
"INSERT INTO ironwood_one_time_operations
(one_time_key_id, reference, deadline, begun_at, expectation)
VALUES (?1, ?2, ?3, ?4, 1)",
params![key, deposit, deadline, now],
)?;
}
Ok(())
}
fn refund_operation(
conn: &Connection,
key: i64,
deposit: &str,
) -> Result<Option<i64>, SqliteClientError> {
Ok(conn
.query_row(
"SELECT id FROM ironwood_one_time_operations
WHERE one_time_key_id = ?1 AND reference = ?2",
params![key, deposit],
|r| r.get(0),
)
.optional()?)
}
pub fn verify_refund_funding_proposal<FeeRuleT, NoteRef>(
proposal: &Proposal<FeeRuleT, NoteRef>,
memo: &MemoBytes,
deposit: &str,
) -> Result<(), SqliteClientError> {
if proposal.steps().len() != 1 {
return Err(invalid("refund funding must be a single transaction"));
}
let step = proposal.steps().first();
let payments = step.transaction_request().payments();
if payments.len() != 1
|| payments
.values()
.any(|p| p.recipient_address().to_string() != deposit || p.memo().is_some())
{
return Err(invalid("refund funding must pay only the deposit address"));
}
let change = step.balance().proposed_change();
if change
.iter()
.any(|c| c.is_ephemeral() || c.output_pool() == PoolType::TRANSPARENT)
|| !change
.iter()
.any(|c| c.output_pool() == PoolType::IRONWOOD && c.memo() == Some(memo))
{
return Err(invalid(
"refund funding requires the recovery memo on internal Ironwood change",
));
}
Ok(())
}
fn check_deposit<P: Parameters>(params: &P, deposit: &str) -> Result<(), SqliteClientError> {
match Address::decode(params, deposit) {
Some(address) if address.encode(params) != deposit => {
Err(invalid("invalid deposit address for this network"))
}
Some(Address::Transparent(_)) => Ok(()),
Some(_) => Err(invalid(
"refund funding requires a transparent deposit address",
)),
None => Err(invalid("invalid deposit address for this network")),
}
}
fn refund_key<P: Parameters>(
conn: &Connection,
params: &P,
account: AccountUuid,
index: u64,
) -> Result<i64, SqliteClientError> {
let (account_ref, _) = account_key(conn, params, account)?;
conn.query_row(
"SELECT id FROM ironwood_one_time_keys
WHERE account_id = ?1 AND purpose = 0 AND key_index = ?2
AND advances_allocation = 1 AND closed_at IS NULL",
params![account_ref.0, index.to_be_bytes()],
|r| r.get(0),
)
.optional()?
.ok_or_else(|| invalid("refund key is not reserved and open in this account"))
}
pub(crate) fn start_funded_refund_keys<P: Parameters>(
conn: &rusqlite::Transaction<'_>,
params: &P,
tx: &Transaction,
) -> Result<(), SqliteClientError> {
let deposit = match tx.transparent_bundle().map(|bundle| &bundle.vout[..]) {
Some([output]) => output.recipient_address().map(|a| a.encode(params)),
_ => None,
};
let Some(deposit) = deposit else {
return Ok(());
};
let memos = conn
.prepare_cached(
"SELECT n.account_id, n.memo FROM ironwood_received_notes n
JOIN transactions t ON t.id_tx = n.transaction_id
WHERE t.txid = ?1 AND n.recipient_key_scope = 1
AND n.one_time_key_id IS NULL AND substr(n.memo, 1, 5) = ?2",
)?
.query_map(params![tx.txid().as_ref(), REFUND_MEMO_MAGIC], |row| {
Ok((row.get::<_, i64>(0)?, row.get::<_, Vec<u8>>(1)?))
})?
.collect::<Result<Vec<_>, _>>()?;
let above_scanned: Option<u32> =
conn.query_row("SELECT MAX(height) + 1 FROM blocks", [], |row| row.get(0))?;
for (account, memo) in memos {
let Some(memo) = MemoBytes::from_bytes(&memo)
.ok()
.and_then(|memo| RefundMemo::decode(memo.as_array()))
else {
continue;
};
let Some((id, scan_from)) = conn
.query_row(
"SELECT id, scan_from FROM ironwood_one_time_keys
WHERE account_id = ?1 AND purpose = 0 AND key_index = ?2
AND advances_allocation = 1",
params![account, memo.index().to_be_bytes()],
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, u32>(1)?)),
)
.optional()?
else {
continue;
};
let Some(operation) = refund_operation(conn, id, &deposit)? else {
return Err(invalid(
"refund memo funds a deposit not recorded for its key",
));
};
activate(conn, id, above_scanned.unwrap_or(scan_from).into())?;
let sent = Observation {
status: OperationStatus::Active,
funded: false,
};
record(conn, operation, sent, 0)?;
}
Ok(())
}