Skip to main content

bsv_wallet_cli/
spend_gate.rs

1//! The spend guard on the binary's real coin selection (bsv-wallet-cli #28,
2//! 0.10.0): which of the wallet's coins the toolbox's `createAction` may
3//! allocate.
4//!
5//! The toolbox chooses the funding coins itself, inside its own store
6//! transaction (`allocate_change_input`: a change output of the default
7//! basket, unspent, its transaction `completed` or `unproven`). Like the
8//! reference it allocates unproven change (`[SRC]` ts-stack `edf6e03`
9//! `wallet-toolbox/src/storage/StorageKnex.ts:2222-2231`: `completed`,
10//! `unproven`, and `sending` unless excluded), whoever made the coin, and
11//! reads no depth. The stack's wallet charter (bsv-stack-lean
12//! `docs/charters/wallet.md` section 2, rulings of 2026-10-12;
13//! `lean/Wallet.lean` `offerable`, `ruleFor`, `guardDepth`) departs from it,
14//! and this gate applies the departure. A coin is offered when, in this
15//! order:
16//!
17//! 1. its creator's tracker word is `mined(h)`, our header at `h` carries
18//!    the checked root now, the creator is not marked suspect, and the block
19//!    is `SPEND_DEPTH` deep (the tip is 1). **R3 (b)**: the depth is the
20//!    host's parameter, 1 the floor ([`crate::tracker_host::read_creators`],
21//!    the 0.9.0 `spend_guard` run over many creators at once); or
22//! 2. its creator's word claims nothing about the chain (`pending`:
23//!    `unknown`, `built`, `announced`, `seen`), the creator is an action of
24//!    ours (`made`: change of a transaction this wallet created and signed),
25//!    and the config names the rule, `SPEND_OWN_CHANGE` (default `1` for
26//!    this host; `0` turns it off). **R1 (b)**: the one rule a host may name
27//!    is for its own `made` change (`Rules .made`); a received coin
28//!    (`internalizeAction`, any counterparty's payment) with no proof is
29//!    never offered, by no setting (`ruleFor` reads no `received` arm).
30//!
31//! A coin whose creator is `stale`, `rejected`, `conflicted` or `abandoned`
32//! is never offered; a coin an action of ours holds is the toolbox's lock
33//! (`spent_by`) and is never a candidate. A frozen coin has no record in
34//! this store (the feed's `frozen` event moves no tracked row at 0.9.0), so
35//! there is nothing to read for it here.
36//!
37//! # The shape: held for the call, restored after
38//!
39//! The toolbox's allocation cannot be filtered before it runs: it is one SQL
40//! query inside `create_action`. So the gate marks every coin it does not
41//! offer as not change (`outputs.change = 0`) for the duration of the call,
42//! records each in `spend_gate_holds`, and restores exactly those after.
43//! `change`, not `spendable`: with `acceptDelayedBroadcast` the toolbox
44//! allocates without reading `spendable` (`require_spendable = !is_delayed`),
45//! but it always reads `change = 1`. A hold a crashed call left behind is
46//! restored by the next call (any hold older than [`STALE_HOLD_SECS`]).
47//!
48//! The coins are read in the toolbox's own preference (completed first, then
49//! the largest), a batch of creators at a time, until the coins offered
50//! cover the outputs with a margin; the coins not read are held too. Only
51//! when the toolbox then finds no funds is every coin read, and the call run
52//! once more; if that fails for funds, the refusal lists every coin held
53//! back ([`Refusal`]). Nothing is fetched but the header service's tip and
54//! headers: no chain scan, no explorer.
55
56use std::collections::HashMap;
57
58use anyhow::Result;
59use bsv_sdk::wallet::{CreateActionArgs, CreateActionResult, WalletInterface};
60use bsv_tracker::Height;
61use bsv_wallet_toolbox::{StorageSqlx, Wallet, WalletServices};
62use serde::Serialize;
63use sqlx::{Row, SqlitePool};
64
65use crate::tracker_host::{read_creators, CreatorReading, TickOptions};
66
67/// `SPEND_OWN_CHANGE` when unset: this host chains payments, so its own
68/// change is offered before it is mined (R1 (b)).
69pub const DEFAULT_SPEND_OWN_CHANGE: bool = true;
70/// A hold older than this was left by a call that did not finish; the next
71/// call restores it.
72pub const STALE_HOLD_SECS: i64 = 600;
73/// Creators read per batch while the offered coins do not yet cover.
74const BATCH: usize = 32;
75/// Satoshis read beyond the outputs before the lazy read stops (fees).
76const FEE_MARGIN: u64 = 10_000;
77
78/// The gate's two settings.
79#[derive(Debug, Clone)]
80pub struct GateOptions {
81    /// `SPEND_DEPTH`, floor 1 (R3 (b)).
82    pub spend_depth: Height,
83    /// `SPEND_OWN_CHANGE` (R1 (b)): offer our own `made` change whose
84    /// creator's word is `pending`.
85    pub own_change: bool,
86}
87
88impl Default for GateOptions {
89    fn default() -> Self {
90        Self {
91            spend_depth: crate::tracker_host::DEFAULT_SPEND_DEPTH,
92            own_change: DEFAULT_SPEND_OWN_CHANGE,
93        }
94    }
95}
96
97impl GateOptions {
98    /// `SPEND_DEPTH` and `SPEND_OWN_CHANGE` (`0`, `false`, `no`, `off` turn
99    /// it off; anything else, or unset, leaves it on).
100    pub fn from_env() -> Self {
101        let own_change = std::env::var("SPEND_OWN_CHANGE")
102            .map(|v| {
103                !matches!(
104                    v.trim().to_ascii_lowercase().as_str(),
105                    "0" | "false" | "no" | "off"
106                )
107            })
108            .unwrap_or(DEFAULT_SPEND_OWN_CHANGE);
109        Self {
110            spend_depth: TickOptions::from_env().spend_depth,
111            own_change,
112        }
113    }
114
115    fn tick(&self) -> TickOptions {
116        TickOptions {
117            spend_depth: self.spend_depth,
118            ..TickOptions::from_env()
119        }
120        .floored()
121    }
122}
123
124/// Who made a coin.
125#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
126#[serde(rename_all = "lowercase")]
127pub enum Origin {
128    /// Change of an action this wallet created and signed.
129    Made,
130    /// A payment `internalizeAction` took in.
131    Received,
132}
133
134/// One coin the gate did not offer, and why.
135#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
136pub struct HeldCoin {
137    /// `txid.vout`.
138    pub outpoint: String,
139    pub satoshis: u64,
140    /// The creator's tracker word (`mined`, `unknown`, `built`, ...), or
141    /// `locked` for a coin an action of ours holds.
142    pub word: String,
143    pub origin: Origin,
144    /// Minutes since the wallet learned of the coin.
145    pub age_minutes: i64,
146    /// The setting or rule that holds it: `depth 1: 0 blocks`, `received,
147    /// no proof`, `own change, SPEND_OWN_CHANGE=0`, `locked by <txid>`.
148    pub reason: String,
149    #[serde(skip)]
150    output_id: i64,
151}
152
153/// The refusal: no offered coin covers the action. Every coin held back is
154/// listed.
155#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
156pub struct Refusal {
157    /// The toolbox's own words for the shortfall.
158    pub toolbox: String,
159    /// Satoshis the gate offered.
160    pub offered_satoshis: u64,
161    pub held: Vec<HeldCoin>,
162}
163
164impl std::fmt::Display for Refusal {
165    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166        write!(
167            f,
168            "Insufficient funds: no offerable coin covers the action ({}; {} sats offered, {} coin(s) held back)",
169            self.toolbox,
170            self.offered_satoshis,
171            self.held.len()
172        )?;
173        for c in &self.held {
174            write!(
175                f,
176                "\n  {} {} sats: {}, {}, {} min old: {}",
177                c.outpoint,
178                c.satoshis,
179                c.word,
180                match c.origin {
181                    Origin::Made => "made",
182                    Origin::Received => "received",
183                },
184                c.age_minutes,
185                c.reason
186            )?;
187        }
188        Ok(())
189    }
190}
191
192impl std::error::Error for Refusal {}
193
194/// What a gated `createAction` failed on.
195#[derive(Debug)]
196pub enum GateError {
197    /// No offerable coin covers it.
198    Refused(Refusal),
199    /// The toolbox's own error, as it gave it.
200    Wallet(bsv_sdk::Error),
201    /// The store or the header service failed under the gate.
202    Gate(anyhow::Error),
203}
204
205impl std::fmt::Display for GateError {
206    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
207        match self {
208            Self::Refused(r) => r.fmt(f),
209            Self::Wallet(e) => e.fmt(f),
210            Self::Gate(e) => write!(f, "Storage error: spend gate: {e}"),
211        }
212    }
213}
214
215impl std::error::Error for GateError {}
216
217/// A candidate coin, as the toolbox would see it.
218#[derive(Debug, Clone)]
219struct Candidate {
220    output_id: i64,
221    txid: String,
222    vout: i64,
223    satoshis: u64,
224    origin: Origin,
225    age_minutes: i64,
226    proof: Option<Vec<u8>>,
227}
228
229/// The gate's verdict on the coins it read.
230#[derive(Debug, Default, Clone)]
231pub struct Assessment {
232    /// Coins offered: (output id, satoshis).
233    pub offered: Vec<(i64, u64)>,
234    /// Coins held back, with why.
235    pub held: Vec<HeldCoin>,
236    /// Coins not read (enough was offered before them); held too.
237    pub unread: Vec<i64>,
238}
239
240impl Assessment {
241    pub fn offered_satoshis(&self) -> u64 {
242        self.offered.iter().map(|(_, s)| s).sum()
243    }
244}
245
246async fn ensure_schema(pool: &SqlitePool) -> Result<()> {
247    sqlx::query(
248        "CREATE TABLE IF NOT EXISTS spend_gate_holds (\
249            output_id INTEGER PRIMARY KEY, \
250            holder TEXT NOT NULL, \
251            held_at INTEGER NOT NULL)",
252    )
253    .execute(pool)
254    .await?;
255    Ok(())
256}
257
258fn blocks(n: u64) -> String {
259    if n == 1 {
260        "1 block".to_string()
261    } else {
262        format!("{n} blocks")
263    }
264}
265
266/// Why `reading` holds a coin of `origin`, or `None` when it is offered.
267pub fn hold_reason(reading: &CreatorReading, origin: Origin, opts: &GateOptions) -> Option<String> {
268    if let Some(fault) = &reading.fault {
269        return Some(format!("headers unavailable: {fault}"));
270    }
271    if reading.offered {
272        return None;
273    }
274    let depth = format!(
275        "depth {}: {}",
276        opts.spend_depth
277            .max(crate::tracker_host::DEFAULT_SPEND_DEPTH),
278        blocks(reading.depth)
279    );
280    match reading.word {
281        "mined" if reading.suspect => Some("suspect: a fork at or below its height".to_string()),
282        "mined" => Some(depth),
283        "stale" | "rejected" | "conflicted" | "abandoned" => {
284            Some(format!("{}: never offered", reading.word))
285        }
286        // `pending`: the word claims nothing about the chain; only R1's
287        // rule for our own change offers it.
288        _ => match origin {
289            Origin::Received => Some(format!("{depth}; received, no proof")),
290            Origin::Made if opts.own_change => None,
291            Origin::Made => Some(format!("{depth}; own change, SPEND_OWN_CHANGE=0")),
292        },
293    }
294}
295
296/// Every coin the toolbox could allocate: a change output of a default
297/// basket, unspent, its transaction `completed` or `unproven`. In the
298/// toolbox's preference: completed first, then the largest.
299async fn candidates(pool: &SqlitePool) -> Result<Vec<Candidate>> {
300    let rows = sqlx::query(
301        "SELECT o.output_id, t.txid, o.vout, o.satoshis, t.is_outgoing, \
302                CAST((julianday('now') - julianday(o.created_at)) * 1440 AS INTEGER) AS age, \
303                p.merkle_path \
304         FROM outputs o \
305         JOIN transactions t ON o.transaction_id = t.transaction_id \
306         JOIN output_baskets b ON o.basket_id = b.basket_id AND b.name = 'default' \
307         LEFT JOIN proven_txs p ON p.txid = t.txid \
308         WHERE o.change = 1 AND o.spent_by IS NULL AND t.txid IS NOT NULL \
309           AND t.status IN ('completed', 'unproven') \
310         ORDER BY CASE WHEN t.status = 'completed' THEN 0 ELSE 1 END, o.satoshis DESC",
311    )
312    .fetch_all(pool)
313    .await?;
314    Ok(rows
315        .iter()
316        .map(|r| Candidate {
317            output_id: r.get("output_id"),
318            txid: r.get::<String, _>("txid").to_ascii_lowercase(),
319            vout: r.get("vout"),
320            satoshis: r.get::<i64, _>("satoshis").max(0) as u64,
321            origin: if r.get::<i64, _>("is_outgoing") != 0 {
322                Origin::Made
323            } else {
324                Origin::Received
325            },
326            age_minutes: r.get::<Option<i64>, _>("age").unwrap_or(0).max(0),
327            proof: r.get("merkle_path"),
328        })
329        .collect())
330}
331
332/// The coins an action of ours holds (`spent_by` a live action): listed in
333/// a refusal, never offered.
334async fn locked(pool: &SqlitePool) -> Result<Vec<HeldCoin>> {
335    let rows = sqlx::query(
336        "SELECT o.output_id, t.txid, o.vout, o.satoshis, t.is_outgoing, \
337                CAST((julianday('now') - julianday(o.created_at)) * 1440 AS INTEGER) AS age, \
338                COALESCE(s.txid, s.reference) AS holder \
339         FROM outputs o \
340         JOIN transactions t ON o.transaction_id = t.transaction_id \
341         JOIN transactions s ON o.spent_by = s.transaction_id \
342         JOIN output_baskets b ON o.basket_id = b.basket_id AND b.name = 'default' \
343         WHERE o.change = 1 AND s.status NOT IN ('completed', 'failed') \
344           AND t.status IN ('completed', 'unproven')",
345    )
346    .fetch_all(pool)
347    .await?;
348    Ok(rows
349        .iter()
350        .map(|r| HeldCoin {
351            outpoint: format!("{}.{}", r.get::<String, _>("txid"), r.get::<i64, _>("vout")),
352            satoshis: r.get::<i64, _>("satoshis").max(0) as u64,
353            word: "locked".to_string(),
354            origin: if r.get::<i64, _>("is_outgoing") != 0 {
355                Origin::Made
356            } else {
357                Origin::Received
358            },
359            age_minutes: r.get::<Option<i64>, _>("age").unwrap_or(0).max(0),
360            reason: format!(
361                "locked by {}",
362                r.get::<Option<String>, _>("holder").unwrap_or_default()
363            ),
364            output_id: r.get("output_id"),
365        })
366        .collect())
367}
368
369/// Read the coins and judge each. With `need`, stop reading once the coins
370/// offered reach it (the rest are `unread`); with `None`, read every coin.
371pub async fn assess<V: WalletServices + ?Sized>(
372    storage: &StorageSqlx,
373    services: &V,
374    opts: &GateOptions,
375    need: Option<u64>,
376) -> Result<Assessment> {
377    let all = candidates(storage.pool()).await?;
378    let tick = opts.tick();
379    let mut by_creator: Vec<(String, Option<Vec<u8>>)> = Vec::new();
380    for c in &all {
381        if !by_creator.iter().any(|(t, _)| t == &c.txid) {
382            by_creator.push((c.txid.clone(), c.proof.clone()));
383        }
384    }
385    let mut readings: HashMap<String, CreatorReading> = HashMap::new();
386    let mut out = Assessment::default();
387    let mut next = 0;
388    for c in &all {
389        let covered = need.is_some_and(|need| out.offered_satoshis() >= need);
390        if !readings.contains_key(&c.txid) {
391            if covered {
392                out.unread.push(c.output_id);
393                continue;
394            }
395            let end = if need.is_some() {
396                (next + BATCH).min(by_creator.len())
397            } else {
398                by_creator.len()
399            };
400            let batch: Vec<_> = by_creator[next..end]
401                .iter()
402                .filter(|(t, _)| !readings.contains_key(t))
403                .cloned()
404                .collect();
405            next = end;
406            readings.extend(read_creators(storage, services, &tick, &batch).await?);
407        }
408        let Some(reading) = readings.get(&c.txid) else {
409            out.unread.push(c.output_id);
410            continue;
411        };
412        match hold_reason(reading, c.origin, opts) {
413            None if !covered => out.offered.push((c.output_id, c.satoshis)),
414            None => out.unread.push(c.output_id),
415            Some(reason) => out.held.push(HeldCoin {
416                outpoint: format!("{}.{}", c.txid, c.vout),
417                satoshis: c.satoshis,
418                word: reading.word.to_string(),
419                origin: c.origin,
420                age_minutes: c.age_minutes,
421                reason,
422                output_id: c.output_id,
423            }),
424        }
425    }
426    Ok(out)
427}
428
429/// A unique name for this call's holds.
430fn holder() -> String {
431    use std::sync::atomic::{AtomicU64, Ordering};
432    static N: AtomicU64 = AtomicU64::new(0);
433    format!(
434        "{}:{}",
435        std::process::id(),
436        N.fetch_add(1, Ordering::Relaxed)
437    )
438}
439
440/// Mark `ids` not change for the call: the toolbox does not allocate them.
441async fn hold(pool: &SqlitePool, holder: &str, ids: &[i64]) -> Result<()> {
442    ensure_schema(pool).await?;
443    let now = chrono::Utc::now().timestamp();
444    let mut tx = pool.begin().await?;
445    for id in ids {
446        // Only a coin still change, and not held by another call, is held:
447        // restoring sets back exactly what was set here.
448        let held = sqlx::query(
449            "UPDATE outputs SET change = 0 WHERE output_id = ? AND change = 1 \
450             AND output_id NOT IN (SELECT output_id FROM spend_gate_holds)",
451        )
452        .bind(id)
453        .execute(&mut *tx)
454        .await?
455        .rows_affected();
456        if held == 1 {
457            sqlx::query(
458                "INSERT INTO spend_gate_holds (output_id, holder, held_at) VALUES (?, ?, ?)",
459            )
460            .bind(id)
461            .bind(holder)
462            .bind(now)
463            .execute(&mut *tx)
464            .await?;
465        }
466    }
467    tx.commit().await?;
468    Ok(())
469}
470
471/// Restore what `holder` held, and any hold a call that did not finish left
472/// behind ([`STALE_HOLD_SECS`]).
473pub async fn restore(pool: &SqlitePool, holder: Option<&str>) -> Result<u64> {
474    ensure_schema(pool).await?;
475    let stale = chrono::Utc::now().timestamp() - STALE_HOLD_SECS;
476    let mut tx = pool.begin().await?;
477    let which = "SELECT output_id FROM spend_gate_holds WHERE holder = ? OR held_at < ?";
478    let restored = sqlx::query(&format!(
479        "UPDATE outputs SET change = 1 WHERE change = 0 AND output_id IN ({which})"
480    ))
481    .bind(holder.unwrap_or(""))
482    .bind(stale)
483    .execute(&mut *tx)
484    .await?
485    .rows_affected();
486    sqlx::query("DELETE FROM spend_gate_holds WHERE holder = ? OR held_at < ?")
487        .bind(holder.unwrap_or(""))
488        .bind(stale)
489        .execute(&mut *tx)
490        .await?;
491    tx.commit().await?;
492    Ok(restored)
493}
494
495fn is_insufficient(e: &bsv_sdk::Error) -> bool {
496    e.to_string()
497        .to_ascii_lowercase()
498        .contains("insufficient funds")
499}
500
501/// One toolbox call with every coin the assessment did not offer held.
502async fn run_held<V: WalletServices + Send + Sync + 'static>(
503    wallet: &Wallet<StorageSqlx, V>,
504    args: CreateActionArgs,
505    originator: &str,
506    assessment: &Assessment,
507) -> std::result::Result<bsv_sdk::Result<CreateActionResult>, GateError> {
508    let pool = wallet.storage().pool();
509    let holder = holder();
510    let ids: Vec<i64> = assessment
511        .held
512        .iter()
513        .map(|c| c.output_id)
514        .chain(assessment.unread.iter().copied())
515        .collect();
516    hold(pool, &holder, &ids).await.map_err(GateError::Gate)?;
517    let outcome = wallet.create_action(args, originator).await;
518    restore(pool, Some(&holder))
519        .await
520        .map_err(GateError::Gate)?;
521    Ok(outcome)
522}
523
524/// `createAction` through the gate: the toolbox allocates only coins the
525/// gate offers. The caller holds the spending lock.
526pub async fn create_action<V: WalletServices + Send + Sync + 'static>(
527    wallet: &Wallet<StorageSqlx, V>,
528    args: CreateActionArgs,
529    originator: &str,
530    opts: &GateOptions,
531) -> std::result::Result<CreateActionResult, GateError> {
532    let storage = wallet.storage();
533    restore(storage.pool(), None)
534        .await
535        .map_err(GateError::Gate)?;
536    let need = args
537        .outputs
538        .as_ref()
539        .map(|o| o.iter().map(|x| x.satoshis).sum::<u64>())
540        .unwrap_or(0)
541        .saturating_add(FEE_MARGIN);
542    let first = assess(storage, wallet.services(), opts, Some(need))
543        .await
544        .map_err(GateError::Gate)?;
545    let lazy = !first.unread.is_empty();
546    match run_held(wallet, args.clone(), originator, &first).await? {
547        Ok(result) => return Ok(result),
548        Err(e) if !is_insufficient(&e) => return Err(GateError::Wallet(e)),
549        Err(e) if !lazy => return Err(refusal(storage, &first, &e).await),
550        Err(_) => {}
551    }
552    // The lazy read left coins unread: read every coin and try once more.
553    let full = assess(storage, wallet.services(), opts, None)
554        .await
555        .map_err(GateError::Gate)?;
556    match run_held(wallet, args, originator, &full).await? {
557        Ok(result) => Ok(result),
558        Err(e) if is_insufficient(&e) => Err(refusal(storage, &full, &e).await),
559        Err(e) => Err(GateError::Wallet(e)),
560    }
561}
562
563async fn refusal(storage: &StorageSqlx, assessment: &Assessment, e: &bsv_sdk::Error) -> GateError {
564    let mut held = assessment.held.clone();
565    match locked(storage.pool()).await {
566        Ok(mut l) => held.append(&mut l),
567        Err(fault) => return GateError::Gate(fault),
568    }
569    let toolbox = e.to_string();
570    GateError::Refused(Refusal {
571        toolbox: toolbox
572            .strip_prefix("wallet error: ")
573            .unwrap_or(&toolbox)
574            .to_string(),
575        offered_satoshis: assessment.offered_satoshis(),
576        held,
577    })
578}
579
580#[cfg(test)]
581mod tests;