Skip to main content

bsv_wallet_cli/
spend_guard.rs

1//! The host's spend guard against an immediate-refusal hint
2//! (bsv-stack-lean #66; its tracker charter section 4, `Reask.spend`:
3//! confirm the word before the coin is used).
4//!
5//! From the toolbox 0.7.4 a transaction whose immediate post drew no
6//! accepting word keeps its word: an internalized one stays `unproven`, and
7//! its outputs stay `spendable`, so the toolbox's coin selection (change
8//! outputs of `completed` and `unproven` transactions) would spend a coin
9//! whose transaction no broadcaster took and no status source holds. A
10//! spend of it is posted behind a parent the network may never accept. A
11//! `createAction` refused on its post stays `sending`, which the selection
12//! never reads; the guard covers it all the same.
13//!
14//! The guard holds such a coin before every spend this CLI makes (the
15//! served `/createAction`, `send`, `drain`, `split`, `gift-send`) and on
16//! each served pass: it marks the coin not spendable and records the hold
17//! in its own table, `spend_guard_holds`. A hold is released, and the coin
18//! spendable again, once a status source holds its transaction (the
19//! toolbox promotes the request to `unmined`, or a proof completes it). A
20//! hold whose transaction is retired (`failed`) is dropped without
21//! restoring: the retire's word stands. The guard writes no transaction or
22//! request word; it changes only what it held, and only back.
23//!
24//! The hint is the toolbox's `immediateBroadcastHint` note on the
25//! request's history, with the request still `unsent` (or `sending`,
26//! `unprocessed`): no status source has held the transaction since.
27
28use anyhow::Result;
29use sqlx::{Row, SqlitePool};
30
31use crate::server::post_word::{last_note, IMMEDIATE_HINT};
32
33/// What one run of the guard did.
34#[derive(Debug, Default, Clone, PartialEq, Eq)]
35pub struct GuardReport {
36    /// Transactions whose coins were held this run (txids).
37    pub held: Vec<String>,
38    /// Transactions whose coins were released this run: a status source
39    /// holds them now (txids).
40    pub released: Vec<String>,
41    /// Holds dropped without restoring: the transaction was retired.
42    pub dropped: Vec<String>,
43}
44
45impl GuardReport {
46    /// Nothing moved.
47    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
64/// Release what a status source now holds, then hold every spendable coin
65/// of a transaction whose last word is an immediate-refusal hint.
66pub async fn run(pool: &SqlitePool) -> Result<GuardReport> {
67    ensure_schema(pool).await?;
68    let mut report = GuardReport::default();
69
70    // 1. Release.
71    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    // 2. Hold.
108    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}