bsv_wallet_cli/
spend_guard.rs1use anyhow::Result;
29use sqlx::{Row, SqlitePool};
30
31use crate::server::post_word::{last_note, IMMEDIATE_HINT};
32
33#[derive(Debug, Default, Clone, PartialEq, Eq)]
35pub struct GuardReport {
36 pub held: Vec<String>,
38 pub released: Vec<String>,
41 pub dropped: Vec<String>,
43}
44
45impl GuardReport {
46 pub fn is_quiet(&self) -> bool {
48 self.held.is_empty() && self.released.is_empty() && self.dropped.is_empty()
49 }
50}
51
52async fn ensure_schema(pool: &SqlitePool) -> Result<()> {
53 sqlx::query(
54 "CREATE TABLE IF NOT EXISTS spend_guard_holds (\
55 output_id INTEGER PRIMARY KEY, \
56 txid TEXT NOT NULL, \
57 held_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP)",
58 )
59 .execute(pool)
60 .await?;
61 Ok(())
62}
63
64pub async fn run(pool: &SqlitePool) -> Result<GuardReport> {
67 crate::respend::reserve_inputs(pool).await?;
68 ensure_schema(pool).await?;
69 let mut report = GuardReport::default();
70
71 let held: Vec<(String, Option<String>, Option<String>)> = sqlx::query_as(
73 "SELECT DISTINCT h.txid, t.status, r.status FROM spend_guard_holds h \
74 LEFT JOIN transactions t ON t.txid = h.txid \
75 LEFT JOIN proven_tx_reqs r ON r.txid = h.txid",
76 )
77 .fetch_all(pool)
78 .await?;
79 for (txid, tx_status, req_status) in held {
80 let tx_status = tx_status.unwrap_or_default();
81 let req_status = req_status.unwrap_or_default();
82 if tx_status == "failed" || matches!(req_status.as_str(), "invalid" | "doubleSpend") {
83 sqlx::query("DELETE FROM spend_guard_holds WHERE txid = ?")
84 .bind(&txid)
85 .execute(pool)
86 .await?;
87 report.dropped.push(txid);
88 } else if tx_status == "completed" || matches!(req_status.as_str(), "unmined" | "completed")
89 {
90 let mut tx = pool.begin().await?;
91 sqlx::query(
92 "UPDATE outputs SET spendable = 1 WHERE spendable = 0 AND spent_by IS NULL \
93 AND output_id IN (SELECT output_id FROM spend_guard_holds WHERE txid = ?)",
94 )
95 .bind(&txid)
96 .execute(&mut *tx)
97 .await?;
98 sqlx::query("DELETE FROM spend_guard_holds WHERE txid = ?")
99 .bind(&txid)
100 .execute(&mut *tx)
101 .await?;
102 tx.commit().await?;
103 tracing::info!(txid = %txid, "spend guard: a status source holds it; its coins are selectable again");
104 report.released.push(txid);
105 }
106 }
107
108 let rows = sqlx::query(
110 "SELECT o.output_id, t.txid, r.history FROM outputs o \
111 JOIN transactions t ON o.transaction_id = t.transaction_id \
112 JOIN proven_tx_reqs r ON r.txid = t.txid \
113 WHERE o.spendable = 1 AND o.spent_by IS NULL \
114 AND t.status IN ('unproven', 'sending') \
115 AND r.status IN ('unsent', 'sending', 'unprocessed') \
116 AND r.history LIKE ?",
117 )
118 .bind(format!("%{IMMEDIATE_HINT}%"))
119 .fetch_all(pool)
120 .await?;
121 for row in rows {
122 let output_id: i64 = row.get("output_id");
123 let txid: String = row.get("txid");
124 let history: String = row.get("history");
125 if last_note(&history, IMMEDIATE_HINT).is_none() {
126 continue;
127 }
128 let mut tx = pool.begin().await?;
129 sqlx::query("INSERT OR IGNORE INTO spend_guard_holds (output_id, txid) VALUES (?, ?)")
130 .bind(output_id)
131 .bind(&txid)
132 .execute(&mut *tx)
133 .await?;
134 sqlx::query(
135 "UPDATE outputs SET spendable = 0 WHERE output_id = ? AND spendable = 1 AND spent_by IS NULL",
136 )
137 .bind(output_id)
138 .execute(&mut *tx)
139 .await?;
140 tx.commit().await?;
141 if !report.held.contains(&txid) {
142 tracing::warn!(
143 txid = %txid,
144 "spend guard: its immediate post drew no accepting word and no status source holds it; its coins are not selected until one does"
145 );
146 report.held.push(txid);
147 }
148 }
149 Ok(report)
150}