use super::*;
use rusqlite::params;
use std::num::NonZeroU32;
use zakura_one_time_ivk::swap::{OperationStatus, RESTORE_WATCH_SECS, ReceiptExpectation};
const NOW: i64 = 1_000;
fn advance(st: &mut State, count: usize) -> ChainPoint {
st.generate_and_scan_empty_blocks_with_one_time_ivks(count);
tip(st)
}
fn prepare(
st: &mut State,
through: ChainPoint,
now: i64,
) -> Result<Vec<DiscoveryWork>, SweepDeferral> {
let account = st.test_account().unwrap().id();
let limit = NonZeroU32::new(64).unwrap();
let db = st.wallet_mut().db_mut();
let prepared = db.prepare_one_time_sweeps(account, through, now, limit);
prepared.unwrap()
}
fn setup() -> (State, AccountUuid, ChainPoint) {
let st = scanned_wallet();
let account = st.test_account().unwrap().id();
let point = tip(&st);
(st, account, point)
}
fn due(st: &mut State, through: ChainPoint, now: i64) -> Vec<KeyId> {
let work = prepare(st, through, now).unwrap();
work.into_iter().map(|w| w.key).collect()
}
fn begin(st: &mut State, key: KeyId, now: i64, seen: bool) {
let account = st.test_account().unwrap().id();
let db = st.wallet_mut().db_mut();
db.begin_one_time_sweep_attempt(account, key, now, seen)
.unwrap();
}
fn check(st: &mut State, publication: ChainPoint, checked: &[(KeyId, bool)]) {
let account = st.test_account().unwrap().id();
let db = st.wallet_mut().db_mut();
db.record_paid_filter_check(account, publication, checked)
.unwrap();
}
fn sweep(
db: &mut Db,
account: AccountUuid,
key: KeyId,
(through, publication): (ChainPoint, ChainPoint),
recent: bool,
witness: bool,
) {
let siblings = witness.then(|| first_leaf_path().auth_path().map(|h| h.to_bytes()));
let applied =
db.apply_one_time_sweep(account, key, through, publication, recent, |_, _| siblings);
assert_eq!(applied.unwrap(), Ok(PaymentApplication::Applied));
}
fn finish_empty(
db: &mut Db,
account: AccountUuid,
key: KeyId,
points: (ChainPoint, ChainPoint),
recent: bool,
) {
queue_lookup(db, account, key, points.1, &[]).unwrap();
sweep(db, account, key, points, recent, false);
}
fn restore(db: &mut Db, account: AccountUuid, key: KeyId, from: BlockHeight) -> OneTimeKey {
match key.purpose() {
Purpose::Receive => watch(db, account, key.index(), from),
_ => recover(db, account, key, from),
}
}
fn close_unrefunded(db: &mut Db, account: AccountUuid, key: KeyId, now: i64, tip: BlockHeight) {
let no_refund = OperationStatus::Terminal(ReceiptExpectation::None);
observe(db, account, key, "swap", no_refund, now).unwrap();
assert_eq!(close_at(db, account, now, tip), 1);
}
fn recheck(st: &State, height: BlockHeight) -> Vec<KeyId> {
let account = st.test_account().unwrap().id();
let db = st.wallet();
let keys = db.one_time_keys_to_recheck(account, height).unwrap();
keys.iter().map(|w| w.key).collect()
}
fn history_pending(db: &Db, account: AccountUuid, height: BlockHeight) -> bool {
db.one_time_history_pending(account, height).unwrap()
}
#[test]
fn restored_keys_are_swept_instead_of_scanned_or_issued() {
let (mut st, account, through) = setup();
let db = st.wallet_mut().db_mut();
let incoming = watch(db, account, 0, through.height);
let refund = recover(db, account, KeyId::new(Purpose::Refund, 4), through.height);
let gap = prepare_from(db, account, NOW, through.height + 1, None);
assert_eq!(gap.unwrap().err(), Some(ReservationPolicy::Gap));
assert!(scanning_keys(&st).is_empty());
assert!(st.wallet().suggest_scan_ranges().unwrap().is_empty());
let db = st.wallet_mut().db_mut();
let issued = reserve_key(db, account, Purpose::Refund, through.height).key_id();
recover(db, account, issued, through.height);
st.scan_cached_blocks_with_one_time_ivks(through.height, 1);
let work = prepare(&mut st, through, NOW).unwrap();
let swept: Vec<_> = work.iter().map(|w| (w.key, w.receiver)).collect();
let expected = [incoming, refund].map(|k| (k.key_id(), k.receiver().to_raw_address_bytes()));
assert_eq!(swept, expected);
}
#[test]
fn bounded_batches_do_not_derive_ten_thousand_restored_keys() {
let (mut st, account, through) = setup();
let db = st.wallet_mut().db_mut();
let (owner, _) = account_key(&db.conn, &db.params, account).unwrap();
let keys = "INSERT INTO ironwood_one_time_keys
(account_id, purpose, key_index, receiver, scan_from, advances_allocation)
VALUES (?1, 0, ?2, zeroblob(43), ?3, 1)";
let sweeps = "INSERT INTO ironwood_one_time_sweeps (one_time_key_id)
SELECT id FROM ironwood_one_time_keys";
tx(db, |c, _| {
let mut insert = c.prepare(keys)?;
for i in 0u64..10_000 {
insert.execute(params![owner.0, i.to_be_bytes(), u32::from(through.height)])?;
}
c.execute(sweeps, [])?;
Ok(())
})
.unwrap();
let batch = prepare(&mut st, through, NOW).unwrap();
assert_eq!(batch.len(), 64);
assert!(batch.iter().all(|w| w.receiver == [0; 43]));
let first = batch[0].key;
begin(&mut st, first, NOW, false);
let batch = due(&mut st, through, NOW);
assert_eq!(batch[0].index(), 1);
assert!(!batch.contains(&first));
}
#[test]
fn sweeps_are_due_from_their_scan_start_at_canonical_points() {
let (mut st, account, base) = setup();
let key = watch(st.wallet_mut().db_mut(), account, 0, base.height + 1).key_id();
assert!(due(&mut st, base, NOW).is_empty());
let removed = advance(&mut st, 2);
st.truncate_to_height(base.height);
let next = advance(&mut st, 1);
assert_eq!(due(&mut st, next, NOW), [key]);
let replaced = advance(&mut st, 1);
let stale = prepare(&mut st, removed, NOW);
assert!(matches!(stale, Err(SweepDeferral::UnknownAnchor)));
assert_eq!(due(&mut st, replaced, NOW), [key]);
}
#[test]
fn sweeps_wait_for_the_fully_scanned_tip() {
let (mut st, account, through) = setup();
let key = watch(st.wallet_mut().db_mut(), account, 0, through.height).key_id();
let (next, _) = st.generate_empty_block();
st.wallet_mut().update_chain_tip(next).unwrap();
assert!(due(&mut st, through, NOW).is_empty());
st.scan_cached_blocks_with_one_time_ivks(next, 1);
let through = tip(&st);
reserve_key(
st.wallet_mut().db_mut(),
account,
Purpose::Refund,
through.height,
);
assert!(due(&mut st, through, NOW).is_empty());
st.scan_cached_blocks_with_one_time_ivks(through.height, 1);
assert_eq!(due(&mut st, through, NOW), [key]);
}
#[test]
fn attempts_back_off_from_one_minute_to_twelve_hours() {
let (mut st, account, through) = setup();
let key = watch(st.wallet_mut().db_mut(), account, 0, through.height).key_id();
let mut now = NOW;
let delays = (0..10).map(|i| 60 << i).chain([43_200, 43_200]);
for delay in delays {
begin(&mut st, key, now, false);
assert!(due(&mut st, through, now + delay - 1).is_empty(), "{delay}");
assert_eq!(due(&mut st, through, now + delay), [key], "{delay}");
now += delay;
}
}
#[test]
fn lookups_queue_atomically_at_canonical_anchors_and_survive_restart() {
let (mut st, account, before) = setup();
let key = KeyId::new(Purpose::Receive, 8);
watch(st.wallet_mut().db_mut(), account, 8, before.height);
let candidate = pay_candidate(&mut st, key);
let through = tip(&st);
let forked = ChainPoint {
hash: BlockHash([9; 32]),
..through
};
let unscanned = ChainPoint {
height: through.height + 1,
..through
};
let mut moved = candidate.clone();
moved.position += 1; let conflicting = [candidate.clone(), moved];
let one = std::slice::from_ref(&candidate);
let db = st.wallet_mut().db_mut();
let rejected = [
(forked, &[][..]),
(unscanned, &[]),
(through, &conflicting),
(before, one),
];
for (i, (anchor, payments)) in rejected.into_iter().enumerate() {
let queued = queue_lookup(db, account, key, anchor, payments);
assert!(queued.is_err(), "{i}");
}
assert!(pending(db, account, key).is_empty());
assert_eq!(lookup_coverage(db, account, key), None);
queue_lookup(db, account, key, through, one).unwrap();
reopen(&mut st);
let db = st.wallet_mut().db_mut();
assert!(pending(db, account, key) == [candidate]);
assert_eq!(lookup_coverage(db, account, key), Some(through));
queue_lookup(db, account, key, before, &[]).unwrap();
assert_eq!(lookup_coverage(db, account, key), Some(before));
}
#[test]
fn finished_sweeps_rescan_for_payments_after_their_lookup() {
for (purpose, index) in [(Purpose::Refund, 3), (Purpose::Receive, 0)] {
let key = KeyId::new(purpose, index);
let (mut st, account, anchor) = setup();
let db = st.wallet_mut().db_mut();
let restored = restore(db, account, key, anchor.height);
let paid = pay(&mut st, restored.full_viewing_key(), 50_000);
assert!(unspent_keys(&st, paid).is_empty(), "{key:?}");
finish_sweep(st.wallet_mut().db_mut(), account, key, anchor);
assert_eq!(scanning_keys(&st), [key], "{key:?}");
let rescan = ScanRange::from_parts(paid..paid + 1, ScanPriority::Historic);
let ranges = st.wallet().suggest_scan_ranges().unwrap();
assert_eq!(ranges, [rescan], "{key:?}");
st.scan_cached_blocks_with_one_time_ivks(paid, 1);
assert_eq!(unspent_keys(&st, paid), [Some(key)], "{key:?}");
}
}
#[test]
fn finished_incoming_sweeps_watch_paid_and_unpaid_keys() {
let (mut st, account, from) = setup();
let paid = KeyId::new(Purpose::Receive, 8);
let db = st.wallet_mut().db_mut();
let unpaid = watch(db, account, 0, from.height).key_id();
watch(db, account, 8, from.height);
let candidate = pay_candidate(&mut st, paid);
let through = tip(&st);
let db = st.wallet_mut().db_mut();
queue_lookup(db, account, paid, through, std::slice::from_ref(&candidate)).unwrap();
let applied = apply(db, account, paid, &candidate, through, &first_leaf_path());
assert_eq!(applied, PaymentApplication::Applied);
sweep(db, account, paid, (through, through), WATCHED, false);
finish_sweep(db, account, unpaid, through);
assert_eq!(scanning_keys(&st), [unpaid, paid]);
assert!(st.wallet().suggest_scan_ranges().unwrap().is_empty());
}
#[test]
fn an_unwatched_sweep_closes_only_a_key_that_was_not_scanning() {
let (mut st, account, _) = setup();
let through = advance(&mut st, 2);
let db = st.wallet_mut().db_mut();
let idle = watch(db, account, 0, through.height).key_id();
let watched = watch(db, account, 1, through.height).key_id();
finish_empty(db, account, idle, (through, through), false);
finish_sweep(db, account, watched, through);
assert_eq!(scanning_keys(&st), [watched]);
st.generate_and_scan_empty_blocks_with_one_time_ivks(1);
st.truncate_to_height_retaining_cache(through.height - 1);
st.scan_cached_blocks_with_one_time_ivks(through.height, 2);
let tip = tip(&st);
let db = st.wallet_mut().db_mut();
finish_empty(db, account, watched, (tip, tip), false);
assert_eq!(scanning_keys(&st), [watched]);
}
#[test]
fn a_newer_paid_filter_rechecks_closed_unpaid_keys() {
let (mut st, account, anchor) = setup();
let db = st.wallet_mut().db_mut();
let [missed, hit] = [0, 1].map(|index| watch(db, account, index, anchor.height).key_id());
for key in [missed, hit] {
finish_empty(db, account, key, (anchor, anchor), false);
}
let scanned = reserve_key(db, account, Purpose::Refund, anchor.height + 1).key_id();
close_unrefunded(db, account, scanned, 0, anchor.height);
assert_eq!(recheck(&st, anchor.height), [scanned]);
let publication = advance(&mut st, 1);
assert_eq!(recheck(&st, publication.height), [missed, hit, scanned]);
let checked = [(missed, false), (hit, true), (scanned, false)];
check(&mut st, publication, &checked);
assert!(recheck(&st, publication.height).is_empty());
assert_eq!(due(&mut st, publication, NOW), [hit]);
let newer = advance(&mut st, 1);
assert_eq!(recheck(&st, newer.height), [missed, scanned]);
check(&mut st, newer, &[(missed, false), (scanned, true)]);
assert_eq!(due(&mut st, newer, NOW), [hit, scanned]);
let sql = "UPDATE ironwood_one_time_keys SET paid_before_birthday = 1";
exec(st.wallet().conn(), sql, []);
assert!(recheck(&st, newer.height + 1).is_empty());
}
#[test]
fn a_lagging_recent_sweep_watches_its_keys() {
let (mut st, account, anchor) = setup();
let db = st.wallet_mut().db_mut();
let keys = [
watch(db, account, 0, anchor.height),
recover(db, account, KeyId::new(Purpose::Refund, 0), anchor.height),
watch(db, account, 1, anchor.height),
];
let ids = keys.each_ref().map(|k| k.key_id());
for key in &keys {
pay(&mut st, key.full_viewing_key(), 50_000);
}
let through = advance(&mut st, 1);
assert!(unspent_keys(&st, through.height).is_empty());
for key in ids {
begin(&mut st, key, NOW, key == ids[2]);
let db = st.wallet_mut().db_mut();
finish_empty(db, account, key, (through, anchor), WATCHED);
}
let scanning: HashSet<_> = scanning_keys(&st).into_iter().collect();
assert_eq!(scanning, HashSet::from(ids));
let late = clock_now() + 2 * RESTORE_WATCH_SECS;
let db = st.wallet_mut().db_mut();
assert_eq!(close_at(db, account, late, through.height), 0);
let replay = anchor.height + 1;
let rescan = ScanRange::from_parts(replay..through.height + 1, ScanPriority::Historic);
assert_eq!(st.wallet().suggest_scan_ranges().unwrap(), [rescan]);
st.scan_cached_blocks_with_one_time_ivks(replay, keys.len() + 1);
let received = |st: &State, key| {
let keys = unspent_keys(st, tip(st).height);
keys.iter().filter(|k| **k == Some(key)).count()
};
assert!(ids.iter().all(|&key| received(&st, key) == 1));
for key in &keys {
pay(&mut st, key.full_viewing_key(), 50_000);
}
assert!(ids.iter().all(|&key| received(&st, key) == 2));
}
#[test]
fn restored_keys_are_watched_then_closed_and_reissued_without_rescan() {
for (purpose, index, swept_after) in [
(Purpose::Receive, 0, None),
(Purpose::Refund, 0, None),
(Purpose::Refund, 0, Some(24 * 60 * 60)),
] {
let key = KeyId::new(purpose, index);
let (mut st, account, anchor) = setup();
if let Some(after) = swept_after {
let sql = "UPDATE blocks SET time = ?2 WHERE height = ?1";
let (height, time) = (u32::from(anchor.height), clock_now() + after);
exec(st.wallet().conn(), sql, params![height, time]);
}
let db = st.wallet_mut().db_mut();
restore(db, account, key, anchor.height);
finish_sweep(db, account, key, anchor);
db.update_chain_tip(anchor.height).unwrap();
let tip = advance(&mut st, 1).height;
assert_eq!(scanning_keys(&st), [key], "{key:?}");
let sql = "SELECT MAX(k.registered_at, b.time) FROM ironwood_one_time_keys k
JOIN ironwood_one_time_sweeps s ON s.one_time_key_id = k.id
JOIN blocks b ON b.height = s.done_height WHERE k.purpose = ?1";
let purpose = purpose_code(key.purpose()).unwrap();
let closes: i64 = value(st.wallet().conn(), sql, [purpose]);
let closes = closes + RESTORE_WATCH_SECS;
let db = st.wallet_mut().db_mut();
assert_eq!(close_at(db, account, closes - 1, tip), 0, "{key:?}");
assert_eq!(close_at(db, account, closes, tip), 1, "{key:?}");
assert!(scanning_keys(&st).is_empty(), "{key:?}");
if key.purpose() != Purpose::Receive {
continue;
}
let through = advance(&mut st, 2);
let db = st.wallet_mut().db_mut();
let reservation = prepare_from(db, account, NOW, through.height + 1, None);
assert_eq!(reservation.unwrap().unwrap().key_id(), key);
assert_eq!(scanning_keys(&st), [key]);
assert!(st.wallet().suggest_scan_ranges().unwrap().is_empty());
assert!(due(&mut st, through, NOW).is_empty());
}
}
#[test]
fn history_is_pending_until_sweeps_finish_and_candidates_apply() {
let (mut st, account, before) = setup();
let before = before.height;
let key = KeyId::new(Purpose::Receive, 8);
let db = st.wallet_mut().db_mut();
assert!(!history_pending(db, account, before));
watch(db, account, 8, before + 1);
assert!(!history_pending(db, account, before));
let candidate = pay_candidate(&mut st, key);
let through = tip(&st);
let db = st.wallet_mut().db_mut();
assert!(history_pending(db, account, through.height));
begin(&mut st, key, NOW, false);
let db = st.wallet_mut().db_mut();
assert!(history_pending(db, account, through.height));
finish_sweep(db, account, key, through);
assert!(!history_pending(db, account, through.height));
queue_payment(db, account, key, &candidate).unwrap();
assert!(history_pending(db, account, before));
let applied = apply(db, account, key, &candidate, through, &first_leaf_path());
assert_eq!(applied, PaymentApplication::Applied);
assert!(!history_pending(db, account, through.height));
}
#[test]
fn candidates_queued_after_finish_are_offered_again() {
let (mut st, account, from) = setup();
let key = KeyId::new(Purpose::Receive, 8);
watch(st.wallet_mut().db_mut(), account, 8, from.height);
let candidate = pay_candidate(&mut st, key);
let through = tip(&st);
let db = st.wallet_mut().db_mut();
finish_sweep(db, account, key, through);
let late = PendingPayment {
tx_index: candidate.tx_index + 1,
..candidate.clone()
};
queue_lookup(db, account, key, through, &[late]).unwrap();
assert_eq!(due(&mut st, through, NOW), [key]);
let corrected = directory_answer(&candidate);
begin(&mut st, key, NOW, false);
let db = st.wallet_mut().db_mut();
let queued = db.queue_directory_lookup(account, key, through, &[corrected]);
assert_eq!(queued.unwrap(), Ok(()));
reopen(&mut st);
let db = st.wallet_mut().db_mut();
assert!(pending(db, account, key) == [candidate.clone()]);
assert_eq!(lookup_coverage(db, account, key), Some(through));
assert!(history_pending(db, account, through.height));
assert!(due(&mut st, through, NOW).is_empty());
assert_eq!(due(&mut st, through, NOW + 60), [key]);
let db = st.wallet_mut().db_mut();
sweep(db, account, key, (through, through), WATCHED, true);
assert!(!history_pending(db, account, through.height));
assert_eq!(unspent_keys(&st, through.height), [Some(key)]);
}
#[test]
fn rewind_reruns_only_sweeps_above_the_retained_chain() {
let (mut st, account, kept) = setup();
let db = st.wallet_mut().db_mut();
let [early, late] = [0, 1].map(|i| watch(db, account, i, kept.height).key_id());
finish_sweep(db, account, early, kept);
let removed = advance(&mut st, 1);
assert_eq!(due(&mut st, removed, NOW), [late]);
begin(&mut st, late, NOW, false);
finish_sweep(st.wallet_mut().db_mut(), account, late, removed);
st.truncate_to_height(kept.height);
let db = st.wallet_mut().db_mut();
assert_eq!(lookup_coverage(db, account, early), Some(kept));
assert_eq!(lookup_coverage(db, account, late), None);
assert!(history_pending(db, account, kept.height));
let rerun = db.apply_one_time_sweep(account, late, kept, kept, WATCHED, |_, _| None);
assert!(matches!(rerun, Ok(Err(SweepDeferral::UnknownAnchor))));
assert_eq!(due(&mut st, kept, NOW), [late]);
}
#[test]
fn only_a_payout_extends_the_lookahead() {
let (mut st, account, through) = setup();
let window = RECEIVE_GAP_LIMIT;
let db = st.wallet_mut().db_mut();
lookahead(db, account);
for i in 0..window {
finish_sweep(db, account, KeyId::new(Purpose::Receive, i), through);
}
let issue = |db: &mut Db| {
let key = prepare_from(db, account, NOW, through.height + 1, None);
key.unwrap().unwrap().key_id()
};
let issued = issue(db);
assert_eq!(issued.index(), 0);
lookahead(db, account);
assert!(due(&mut st, through, NOW).is_empty());
assert_eq!(issue(st.wallet_mut().db_mut()), issued);
let edge = KeyId::new(Purpose::Receive, window - 1);
let fvk = derive(&st, edge);
let paid = pay(&mut st, &fvk, 50_000);
assert_eq!(unspent_keys(&st, paid), [Some(edge)]);
let through = tip(&st);
let db = st.wallet_mut().db_mut();
lookahead(db, account);
let due = due(&mut st, through, NOW);
let indices: Vec<_> = due.iter().map(|k| k.index()).collect();
assert_eq!(indices, Vec::from_iter(window..2 * window));
}
#[test]
fn retained_spend_history_covers_early_spends_in_large_batches() {
for (retained, spent) in [(false, true), (true, false), (true, true)] {
let (mut st, account, from) = setup();
let key = KeyId::new(Purpose::Receive, 8);
let db = st.wallet_mut().db_mut();
if retained {
retain(db, account);
}
let watched = watch(db, account, 8, from.height);
let candidate = pay_candidate(&mut st, key);
if spent {
let fvk = watched.full_viewing_key().clone();
let note = candidate.encrypted_note.decrypt(&parent(&st), key).unwrap();
let nf = note.note().nullifier(&fvk);
let value = Zatoshis::const_from_u64(100_000);
let to = unified(foreign_receiver());
st.generate_next_block_spending(&IronwoodFvk(fvk), (nf, value), to, value);
} else {
st.generate_empty_block();
}
for _ in 0..200 {
st.generate_empty_block();
}
st.scan_cached_blocks_with_one_time_ivks(candidate.height + 1, 201);
let through = tip(&st);
let status = spend_status(st.wallet_mut().db_mut(), account, key, &candidate, through);
let label = format!("retained {retained}, spent {spent}");
match (retained, spent) {
(true, true) => assert!(matches!(status, Ok(SpendStatus::Spent(_))), "{label}"),
(true, false) => assert_eq!(status, Ok(SpendStatus::Unspent), "{label}"),
(false, _) => assert_eq!(status, Ok(SpendStatus::Unknown), "{label}"),
}
}
}
#[test]
fn recheck_sweeps_closed_keys_and_finds_a_later_refund() {
let (mut st, account, at) = setup();
let db = st.wallet_mut().db_mut();
let key = reserve_key(db, account, Purpose::Refund, at.height + 1).key_id();
close_unrefunded(db, account, key, NOW, at.height);
let candidate = pay_candidate(&mut st, key);
let through = tip(&st);
assert!(unspent_keys(&st, through.height).is_empty());
let db = st.wallet_mut().db_mut();
assert_eq!(db.recheck_one_time_key_history(account).unwrap(), 1);
assert_eq!(due(&mut st, through, NOW), [key]);
let db = st.wallet_mut().db_mut();
queue_lookup(db, account, key, through, std::slice::from_ref(&candidate)).unwrap();
let applied = apply(db, account, key, &candidate, through, &first_leaf_path());
assert_eq!(applied, PaymentApplication::Applied);
sweep(db, account, key, (through, through), WATCHED, false);
assert_eq!(unspent_keys(&st, through.height), [Some(key)]);
assert_eq!(scanning_keys(&st), [key]);
let db = st.wallet_mut().db_mut();
assert_eq!(db.recheck_one_time_key_history(account).unwrap(), 0);
}
#[test]
fn an_expired_receipt_leaves_a_closed_key_to_the_paid_filter() {
let (mut st, account, height) = setup();
let height = height.height;
let db = st.wallet_mut().db_mut();
let key = reserve_key(db, account, Purpose::Refund, height + 1);
pay(&mut st, key.full_viewing_key(), 50_000);
let key = key.key_id();
st.truncate_to_height(height);
let sql = "UPDATE transactions SET expiry_height = ?1 WHERE mined_height IS NULL";
exec(st.wallet().conn(), sql, [u32::from(height) + 1]);
let expired = advance(&mut st, 1);
close_unrefunded(st.wallet_mut().db_mut(), account, key, NOW, expired.height);
let candidate = pay_candidate(&mut st, key);
let publication = tip(&st);
assert_eq!(recheck(&st, publication.height), [key]);
check(&mut st, publication, &[(key, true)]);
assert_eq!(due(&mut st, publication, NOW), [key]);
let db = st.wallet_mut().db_mut();
let one = std::slice::from_ref(&candidate);
queue_lookup(db, account, key, publication, one).unwrap();
sweep(db, account, key, (publication, publication), false, true);
assert_eq!(unspent_keys(&st, publication.height), [Some(key)]);
assert!(recheck(&st, publication.height + 1).is_empty());
}