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 ensure_schema(pool).await?;
68 let mut report = GuardReport::default();
69
70 let held: Vec<(String, Option<String>, Option<String>)> = sqlx::query_as(
72 "SELECT DISTINCT h.txid, t.status, r.status FROM spend_guard_holds h \
73 LEFT JOIN transactions t ON t.txid = h.txid \
74 LEFT JOIN proven_tx_reqs r ON r.txid = h.txid",
75 )
76 .fetch_all(pool)
77 .await?;
78 for (txid, tx_status, req_status) in held {
79 let tx_status = tx_status.unwrap_or_default();
80 let req_status = req_status.unwrap_or_default();
81 if tx_status == "failed" || matches!(req_status.as_str(), "invalid" | "doubleSpend") {
82 sqlx::query("DELETE FROM spend_guard_holds WHERE txid = ?")
83 .bind(&txid)
84 .execute(pool)
85 .await?;
86 report.dropped.push(txid);
87 } else if tx_status == "completed" || matches!(req_status.as_str(), "unmined" | "completed")
88 {
89 let mut tx = pool.begin().await?;
90 sqlx::query(
91 "UPDATE outputs SET spendable = 1 WHERE spendable = 0 AND spent_by IS NULL \
92 AND output_id IN (SELECT output_id FROM spend_guard_holds WHERE txid = ?)",
93 )
94 .bind(&txid)
95 .execute(&mut *tx)
96 .await?;
97 sqlx::query("DELETE FROM spend_guard_holds WHERE txid = ?")
98 .bind(&txid)
99 .execute(&mut *tx)
100 .await?;
101 tx.commit().await?;
102 tracing::info!(txid = %txid, "spend guard: a status source holds it; its coins are selectable again");
103 report.released.push(txid);
104 }
105 }
106
107 let rows = sqlx::query(
109 "SELECT o.output_id, t.txid, r.history FROM outputs o \
110 JOIN transactions t ON o.transaction_id = t.transaction_id \
111 JOIN proven_tx_reqs r ON r.txid = t.txid \
112 WHERE o.spendable = 1 AND o.spent_by IS NULL \
113 AND t.status IN ('unproven', 'sending') \
114 AND r.status IN ('unsent', 'sending', 'unprocessed') \
115 AND r.history LIKE ?",
116 )
117 .bind(format!("%{IMMEDIATE_HINT}%"))
118 .fetch_all(pool)
119 .await?;
120 for row in rows {
121 let output_id: i64 = row.get("output_id");
122 let txid: String = row.get("txid");
123 let history: String = row.get("history");
124 if last_note(&history, IMMEDIATE_HINT).is_none() {
125 continue;
126 }
127 let mut tx = pool.begin().await?;
128 sqlx::query("INSERT OR IGNORE INTO spend_guard_holds (output_id, txid) VALUES (?, ?)")
129 .bind(output_id)
130 .bind(&txid)
131 .execute(&mut *tx)
132 .await?;
133 sqlx::query(
134 "UPDATE outputs SET spendable = 0 WHERE output_id = ? AND spendable = 1 AND spent_by IS NULL",
135 )
136 .bind(output_id)
137 .execute(&mut *tx)
138 .await?;
139 tx.commit().await?;
140 if !report.held.contains(&txid) {
141 tracing::warn!(
142 txid = %txid,
143 "spend guard: its immediate post drew no accepting word and no status source holds it; its coins are not selected until one does"
144 );
145 report.held.push(txid);
146 }
147 }
148 Ok(report)
149}