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    crate::respend::reserve_inputs(pool).await?;
68    ensure_schema(pool).await?;
69    let mut report = GuardReport::default();
70
71    // 1. Release.
72    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    // 2. Hold.
109    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}