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 a host setting (`ruleFor` reads no `received` arm
30//!    host-wide); or
31//! 3. its creator's word is `pending`, the coin was received, and this one
32//!    action names the rule at the door: `allowPending: "received"` on the
33//!    served `/createAction`, `--allow-pending received` on `send`, `drain`,
34//!    `split` and `gift-send` ([`GateOptions::allow_received`]). **R1 (d)**
35//!    (the owner's ruling of 2026-10-18 00:1xZ; `lean/Wallet.lean`
36//!    `receivedPending`, `ruleFor`): the rule under the word is the
37//!    ruling's own, any creator word that claims nothing about the chain,
38//!    never a host setting, and there is no environment variable for it.
39//!    The body of every such coin was checked at internalize: the toolbox
40//!    refuses an incomplete ancestry and checks every merkle root the BEEF
41//!    carries against the header service in Strict mode
42//!    (bsv-wallet-toolbox-rs 0.8.0 `storage/sqlx/internalize_action.rs`
43//!    96-119), and `fund` refuses an invalid BEEF; so the one trusted word
44//!    is the sender's broadcast. The action records the word
45//!    (`spend_gate_actions`, [`RECEIVED_PENDING`]) by its txid, or a
46//!    deferred-signing template by its reference until `signAction` names
47//!    its txid; shown by `actions` and `/listActions` as `underRule`.
48//!
49//! A coin whose creator is `stale`, `rejected`, `conflicted` or `abandoned`
50//! is never offered; a coin an action of ours holds is the toolbox's lock
51//! (`spent_by`) and is never a candidate. A frozen coin (the feed's
52//! `frozen` event, recorded in `frozen_outpoints` by
53//! [`crate::tracker_host::deliver_chain_event`]) is never offered, rule or
54//! no rule (R6: never cleared here).
55//!
56//! Coins a caller names (`createAction`'s `inputs`, `respend`'s exact
57//! inputs) pass the same per-coin judgment ([`assess_named`]); a named coin
58//! the gate would hold refuses the action with the same `held[]` listing
59//! (the charter's `onCreate`: every input offerable).
60//!
61//! # The shape: held for the call, restored after
62//!
63//! The toolbox's allocation cannot be filtered before it runs: it is one SQL
64//! query inside `create_action`. So the gate marks every coin it does not
65//! offer as not change (`outputs.change = 0`) for the duration of the call,
66//! records each in `spend_gate_holds`, and restores exactly those after.
67//! `change`, not `spendable`: with `acceptDelayedBroadcast` the toolbox
68//! allocates without reading `spendable` (`require_spendable = !is_delayed`),
69//! but it always reads `change = 1`. A hold a crashed call left behind is
70//! restored by the next call (any hold older than [`STALE_HOLD_SECS`]).
71//!
72//! The coins are read in the toolbox's own preference (completed first, then
73//! the largest), a batch of creators at a time, until the coins offered
74//! cover the outputs with a margin; the coins not read are held too. Only
75//! when the toolbox then finds no funds is every coin read, and the call run
76//! once more; if that fails for funds, the refusal lists every coin held
77//! back ([`Refusal`]). Nothing is fetched but the header service's tip and
78//! headers: no chain scan, no explorer.
79
80use std::collections::HashMap;
81
82use anyhow::Result;
83use bsv_sdk::wallet::{CreateActionArgs, CreateActionResult, WalletInterface};
84use bsv_tracker::Height;
85use bsv_wallet_toolbox::{StorageSqlx, Wallet, WalletServices};
86use serde::Serialize;
87use sqlx::{Row, SqlitePool};
88
89use crate::tracker_host::{read_creators, CreatorReading, TickOptions};
90
91/// `SPEND_OWN_CHANGE` when unset: this host chains payments, so its own
92/// change is offered before it is mined (R1 (b)).
93pub const DEFAULT_SPEND_OWN_CHANGE: bool = true;
94/// A hold older than this was left by a call that did not finish; the next
95/// call restores it.
96pub const STALE_HOLD_SECS: i64 = 600;
97/// Creators read per batch while the offered coins do not yet cover.
98const BATCH: usize = 32;
99/// Satoshis read beyond the outputs before the lazy read stops (fees).
100const FEE_MARGIN: u64 = 10_000;
101/// R1 (d)'s rule, by the ruling's name (`lean/Wallet.lean`
102/// `receivedPending`), as recorded on an action that named it.
103pub const RECEIVED_PENDING: &str = "received-pending";
104/// The door's one value for `allowPending`.
105pub const ALLOW_PENDING_RECEIVED: &str = "received";
106/// Appended to a held received `pending` coin's reason: what would offer it.
107pub const ALLOW_PENDING_HINT: &str = "offered under allowPending: received";
108/// The refusal's toolbox line when a coin a caller named is held.
109pub const NAMED_INPUT_REFUSED: &str = "an input the spend guard does not offer";
110
111/// The gate's two settings.
112#[derive(Debug, Clone)]
113pub struct GateOptions {
114    /// `SPEND_DEPTH`, floor 1 (R3 (b)).
115    pub spend_depth: Height,
116    /// `SPEND_OWN_CHANGE` (R1 (b)): offer our own `made` change whose
117    /// creator's word is `pending`.
118    pub own_change: bool,
119    /// R1 (d): this one action named `allowPending: received` at the door.
120    /// Never read from the environment; off unless the caller says so.
121    pub allow_received: bool,
122}
123
124impl Default for GateOptions {
125    fn default() -> Self {
126        Self {
127            spend_depth: crate::tracker_host::DEFAULT_SPEND_DEPTH,
128            own_change: DEFAULT_SPEND_OWN_CHANGE,
129            allow_received: false,
130        }
131    }
132}
133
134/// The door's word: `allowPending` is on only for the one value
135/// `"received"`; absent or anything else is off.
136pub fn allow_pending(word: Option<&str>) -> bool {
137    word == Some(ALLOW_PENDING_RECEIVED)
138}
139
140impl GateOptions {
141    /// `SPEND_DEPTH` and `SPEND_OWN_CHANGE` (`0`, `false`, `no`, `off` turn
142    /// it off; anything else, or unset, leaves it on).
143    pub fn from_env() -> Self {
144        let own_change = std::env::var("SPEND_OWN_CHANGE")
145            .map(|v| {
146                !matches!(
147                    v.trim().to_ascii_lowercase().as_str(),
148                    "0" | "false" | "no" | "off"
149                )
150            })
151            .unwrap_or(DEFAULT_SPEND_OWN_CHANGE);
152        Self {
153            spend_depth: TickOptions::from_env().spend_depth,
154            own_change,
155            allow_received: false,
156        }
157    }
158
159    /// These settings, with the action's word for R1 (d).
160    pub fn with_allow_received(mut self, allow: bool) -> Self {
161        self.allow_received = allow;
162        self
163    }
164
165    fn tick(&self) -> TickOptions {
166        TickOptions {
167            spend_depth: self.spend_depth,
168            ..TickOptions::from_env()
169        }
170        .floored()
171    }
172}
173
174/// Who made a coin.
175#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
176#[serde(rename_all = "lowercase")]
177pub enum Origin {
178    /// Change of an action this wallet created and signed.
179    Made,
180    /// A payment `internalizeAction` took in.
181    Received,
182}
183
184/// One coin the gate did not offer, and why.
185#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
186pub struct HeldCoin {
187    /// `txid.vout`.
188    pub outpoint: String,
189    pub satoshis: u64,
190    /// The creator's tracker word (`mined`, `unknown`, `built`, ...), or
191    /// `locked` for a coin an action of ours holds.
192    pub word: String,
193    pub origin: Origin,
194    /// Minutes since the wallet learned of the coin.
195    pub age_minutes: i64,
196    /// The setting or rule that holds it: `depth 1: 0 blocks`, `received,
197    /// no proof; offered under allowPending: received`, `own change,
198    /// SPEND_OWN_CHANGE=0`, `locked by <txid>`, `frozen: never offered`.
199    pub reason: String,
200    #[serde(skip)]
201    output_id: i64,
202}
203
204/// The refusal: no offered coin covers the action. Every coin held back is
205/// listed.
206#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
207pub struct Refusal {
208    /// The toolbox's own words for the shortfall.
209    pub toolbox: String,
210    /// Satoshis the gate offered.
211    pub offered_satoshis: u64,
212    pub held: Vec<HeldCoin>,
213}
214
215impl std::fmt::Display for Refusal {
216    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
217        if self.toolbox == NAMED_INPUT_REFUSED {
218            write!(
219                f,
220                "Access denied: {} ({} coin(s) held back)",
221                self.toolbox,
222                self.held.len()
223            )?;
224        } else {
225            write!(
226                f,
227                "Insufficient funds: no offerable coin covers the action ({}; {} sats offered, {} coin(s) held back)",
228                self.toolbox,
229                self.offered_satoshis,
230                self.held.len()
231            )?;
232        }
233        for c in &self.held {
234            write!(
235                f,
236                "\n  {} {} sats: {}, {}, {} min old: {}",
237                c.outpoint,
238                c.satoshis,
239                c.word,
240                match c.origin {
241                    Origin::Made => "made",
242                    Origin::Received => "received",
243                },
244                c.age_minutes,
245                c.reason
246            )?;
247        }
248        Ok(())
249    }
250}
251
252impl std::error::Error for Refusal {}
253
254impl Refusal {
255    /// A coin the caller named is held: the action is refused before the
256    /// toolbox runs.
257    pub fn is_named_input(&self) -> bool {
258        self.toolbox == NAMED_INPUT_REFUSED
259    }
260
261    /// The refusal's code: `INPUT_NOT_OFFERED` for a named coin held,
262    /// else `INSUFFICIENT_FUNDS`.
263    pub fn code(&self) -> &'static str {
264        if self.is_named_input() {
265            "INPUT_NOT_OFFERED"
266        } else {
267            "INSUFFICIENT_FUNDS"
268        }
269    }
270
271    /// The held coins, as the route and `--json` give them.
272    pub fn held_json(&self) -> serde_json::Value {
273        serde_json::Value::Array(
274            self.held
275                .iter()
276                .map(|c| {
277                    serde_json::json!({
278                        "outpoint": c.outpoint,
279                        "satoshis": c.satoshis,
280                        "word": c.word,
281                        "origin": c.origin,
282                        "ageMinutes": c.age_minutes,
283                        "reason": c.reason,
284                    })
285                })
286                .collect(),
287        )
288    }
289
290    /// The refusal as one JSON object, the route's body shape:
291    /// `{code, message, offeredSatoshis, held}`.
292    pub fn to_json(&self) -> serde_json::Value {
293        serde_json::json!({
294            "code": self.code(),
295            "message": self.to_string(),
296            "offeredSatoshis": self.offered_satoshis,
297            "held": self.held_json(),
298        })
299    }
300}
301
302/// A command's gated `createAction`: under `--json` a refusal is printed as
303/// [`Refusal::to_json`] on stdout and the process exits 1, as the text
304/// refusal does; anything else is returned as it was.
305pub fn refused_as_json<T>(
306    result: std::result::Result<T, GateError>,
307    json: bool,
308) -> anyhow::Result<T> {
309    match result {
310        Ok(value) => Ok(value),
311        Err(GateError::Refused(refusal)) if json => {
312            println!("{}", refusal.to_json());
313            std::process::exit(1);
314        }
315        Err(e) => Err(e.into()),
316    }
317}
318
319/// What a gated `createAction` failed on.
320#[derive(Debug)]
321pub enum GateError {
322    /// No offerable coin covers it.
323    Refused(Refusal),
324    /// The toolbox's own error, as it gave it.
325    Wallet(bsv_sdk::Error),
326    /// The store or the header service failed under the gate.
327    Gate(anyhow::Error),
328}
329
330impl std::fmt::Display for GateError {
331    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
332        match self {
333            Self::Refused(r) => r.fmt(f),
334            Self::Wallet(e) => e.fmt(f),
335            Self::Gate(e) => write!(f, "Storage error: spend gate: {e}"),
336        }
337    }
338}
339
340impl std::error::Error for GateError {}
341
342/// A candidate coin, as the toolbox would see it.
343#[derive(Debug, Clone)]
344struct Candidate {
345    output_id: i64,
346    txid: String,
347    vout: i64,
348    satoshis: u64,
349    origin: Origin,
350    age_minutes: i64,
351    proof: Option<Vec<u8>>,
352    frozen: bool,
353}
354
355/// The gate's verdict on the coins it read.
356#[derive(Debug, Default, Clone)]
357pub struct Assessment {
358    /// Coins offered: (output id, satoshis).
359    pub offered: Vec<(i64, u64)>,
360    /// Coins held back, with why.
361    pub held: Vec<HeldCoin>,
362    /// Coins not read (enough was offered before them); held too.
363    pub unread: Vec<i64>,
364}
365
366impl Assessment {
367    pub fn offered_satoshis(&self) -> u64 {
368        self.offered.iter().map(|(_, s)| s).sum()
369    }
370}
371
372async fn ensure_schema(pool: &SqlitePool) -> Result<()> {
373    sqlx::query(
374        "CREATE TABLE IF NOT EXISTS spend_gate_holds (\
375            output_id INTEGER PRIMARY KEY, \
376            holder TEXT NOT NULL, \
377            held_at INTEGER NOT NULL)",
378    )
379    .execute(pool)
380    .await?;
381    // The rule an action was created under (R1 (d)): the toolbox's
382    // `transactions` row is the reference's shape and not ours to widen.
383    // An action is named by its txid, or, a deferred-signing template with
384    // no txid yet, by the toolbox's `reference` until `signAction` gives it one.
385    sqlx::query(
386        "CREATE TABLE IF NOT EXISTS spend_gate_actions (\
387            txid TEXT PRIMARY KEY, \
388            rule TEXT NOT NULL, \
389            recorded_at INTEGER NOT NULL, \
390            reference TEXT)",
391    )
392    .execute(pool)
393    .await?;
394    let has_reference: Option<String> = sqlx::query_scalar(
395        "SELECT name FROM pragma_table_info('spend_gate_actions') WHERE name = 'reference'",
396    )
397    .fetch_optional(pool)
398    .await?;
399    if has_reference.is_none() {
400        sqlx::query("ALTER TABLE spend_gate_actions ADD COLUMN reference TEXT")
401            .execute(pool)
402            .await?;
403    }
404    sqlx::query(
405        "CREATE UNIQUE INDEX IF NOT EXISTS spend_gate_actions_reference \
406         ON spend_gate_actions (reference)",
407    )
408    .execute(pool)
409    .await?;
410    Ok(())
411}
412
413/// Record that the action `txid` was created under `rule`.
414pub async fn record_rule(pool: &SqlitePool, txid: &str, rule: &str) -> Result<()> {
415    ensure_schema(pool).await?;
416    sqlx::query(
417        "INSERT INTO spend_gate_actions (txid, rule, recorded_at) VALUES (?, ?, ?) \
418         ON CONFLICT(txid) DO NOTHING",
419    )
420    .bind(txid.to_ascii_lowercase())
421    .bind(rule)
422    .bind(chrono::Utc::now().timestamp())
423    .execute(pool)
424    .await?;
425    Ok(())
426}
427
428/// Record that the deferred-signing template `reference` (no txid yet) was
429/// created under `rule`; [`fill_txid`] names it by its txid once signed.
430pub async fn record_rule_by_reference(
431    pool: &SqlitePool,
432    reference: &str,
433    rule: &str,
434) -> Result<()> {
435    ensure_schema(pool).await?;
436    sqlx::query(
437        "INSERT INTO spend_gate_actions (txid, rule, recorded_at, reference) VALUES (NULL, ?, ?, ?) \
438         ON CONFLICT(reference) DO NOTHING",
439    )
440    .bind(rule)
441    .bind(chrono::Utc::now().timestamp())
442    .bind(reference)
443    .execute(pool)
444    .await?;
445    Ok(())
446}
447
448/// The template `reference` was signed as `txid`: its row, where one was
449/// recorded, gains the txid and keeps the reference.
450pub async fn fill_txid(pool: &SqlitePool, reference: &str, txid: &str) -> Result<()> {
451    ensure_schema(pool).await?;
452    sqlx::query("UPDATE spend_gate_actions SET txid = ? WHERE reference = ? AND txid IS NULL")
453        .bind(txid.to_ascii_lowercase())
454        .bind(reference)
455        .execute(pool)
456        .await?;
457    Ok(())
458}
459
460/// The toolbox's `reference` string of a signable transaction, as
461/// `signAction` / `abortAction` take it back.
462pub fn reference_string(reference: &[u8]) -> String {
463    String::from_utf8(reference.to_vec()).unwrap_or_else(|_| hex::encode(reference))
464}
465
466/// The rule each action was created under, where one was recorded, by
467/// txid; a template not yet signed has none (see [`ActionRules`]).
468pub async fn rules_of(pool: &SqlitePool) -> Result<HashMap<String, String>> {
469    ensure_schema(pool).await?;
470    let rows = sqlx::query("SELECT txid, rule FROM spend_gate_actions WHERE txid IS NOT NULL")
471        .fetch_all(pool)
472        .await?;
473    Ok(rows
474        .iter()
475        .map(|r| (r.get::<String, _>("txid"), r.get::<String, _>("rule")))
476        .collect())
477}
478
479/// The rules a listing shows as `underRule`: by txid, and for a template
480/// not yet signed (listed with an all-zero txid) by its shape in the
481/// listing, read off the toolbox row its reference names.
482#[derive(Debug, Default, Clone)]
483pub struct ActionRules {
484    pub by_txid: HashMap<String, String>,
485    /// [`template_key`] → rule, only where every unsigned toolbox row of
486    /// that shape carries the same rule: a shape shared with a template
487    /// under no rule, or another rule, names none.
488    pub by_template: HashMap<String, String>,
489}
490
491/// A template's shape as a listing shows it: status, description, satoshis.
492pub fn template_key(status: &str, description: &str, satoshis: i64) -> String {
493    format!("{status}\u{1f}{description}\u{1f}{satoshis}")
494}
495
496/// Every recorded rule, as a listing shows it.
497pub async fn action_rules(pool: &SqlitePool) -> Result<ActionRules> {
498    let by_txid = rules_of(pool).await?;
499    let rows = sqlx::query(
500        "SELECT t.status, t.description, t.satoshis, g.rule FROM transactions t \
501         LEFT JOIN spend_gate_actions g ON g.reference = t.reference AND g.txid IS NULL \
502         WHERE t.txid IS NULL",
503    )
504    .fetch_all(pool)
505    .await?;
506    let mut shapes: HashMap<String, Option<String>> = HashMap::new();
507    let mut ambiguous = std::collections::HashSet::new();
508    for r in &rows {
509        let key = template_key(
510            &r.get::<String, _>("status"),
511            &r.get::<String, _>("description"),
512            r.get::<i64, _>("satoshis"),
513        );
514        let rule: Option<String> = r.get("rule");
515        match shapes.get(&key) {
516            Some(seen) if *seen != rule => {
517                ambiguous.insert(key);
518            }
519            Some(_) => {}
520            None => {
521                shapes.insert(key, rule);
522            }
523        }
524    }
525    let by_template = shapes
526        .into_iter()
527        .filter(|(k, _)| !ambiguous.contains(k))
528        .filter_map(|(k, rule)| rule.map(|r| (k, r)))
529        .collect();
530    Ok(ActionRules {
531        by_txid,
532        by_template,
533    })
534}
535
536fn blocks(n: u64) -> String {
537    if n == 1 {
538        "1 block".to_string()
539    } else {
540        format!("{n} blocks")
541    }
542}
543
544/// Why `reading` holds a coin of `origin`, or `None` when it is offered.
545pub fn hold_reason(reading: &CreatorReading, origin: Origin, opts: &GateOptions) -> Option<String> {
546    if let Some(fault) = &reading.fault {
547        return Some(format!("headers unavailable: {fault}"));
548    }
549    if reading.offered {
550        return None;
551    }
552    let depth = format!(
553        "depth {}: {}",
554        opts.spend_depth
555            .max(crate::tracker_host::DEFAULT_SPEND_DEPTH),
556        blocks(reading.depth)
557    );
558    match reading.word {
559        "mined" if reading.suspect => {
560            Some("suspect: a fork or an unresolved split at or below its height".to_string())
561        }
562        "mined" => Some(depth),
563        "stale" | "rejected" | "conflicted" | "abandoned" => {
564            Some(format!("{}: never offered", reading.word))
565        }
566        // `pending`: the word claims nothing about the chain; only R1's
567        // rule for our own change (b), or the action's word for a received
568        // coin (d), offers it.
569        _ => match origin {
570            Origin::Received if opts.allow_received => None,
571            Origin::Received => Some(format!("{depth}; received, no proof; {ALLOW_PENDING_HINT}")),
572            Origin::Made if opts.own_change => None,
573            Origin::Made => Some(format!("{depth}; own change, SPEND_OWN_CHANGE=0")),
574        },
575    }
576}
577
578/// Every coin the toolbox could allocate: a change output of a default
579/// basket, unspent, its transaction `completed` or `unproven`. In the
580/// toolbox's preference: completed first, then the largest.
581async fn candidates(pool: &SqlitePool) -> Result<Vec<Candidate>> {
582    let frozen = crate::tracker_host::frozen_outpoints(pool).await?;
583    let rows = sqlx::query(
584        "SELECT o.output_id, t.txid, o.vout, o.satoshis, t.is_outgoing, \
585                CAST((julianday('now') - julianday(o.created_at)) * 1440 AS INTEGER) AS age, \
586                p.merkle_path \
587         FROM outputs o \
588         JOIN transactions t ON o.transaction_id = t.transaction_id \
589         JOIN output_baskets b ON o.basket_id = b.basket_id AND b.name = 'default' \
590         LEFT JOIN proven_txs p ON p.txid = t.txid \
591         WHERE o.change = 1 AND o.spent_by IS NULL AND t.txid IS NOT NULL \
592           AND t.status IN ('completed', 'unproven') \
593         ORDER BY CASE WHEN t.status = 'completed' THEN 0 ELSE 1 END, o.satoshis DESC",
594    )
595    .fetch_all(pool)
596    .await?;
597    Ok(rows
598        .iter()
599        .map(|r| Candidate {
600            output_id: r.get("output_id"),
601            txid: r.get::<String, _>("txid").to_ascii_lowercase(),
602            vout: r.get("vout"),
603            satoshis: r.get::<i64, _>("satoshis").max(0) as u64,
604            origin: if r.get::<i64, _>("is_outgoing") != 0 {
605                Origin::Made
606            } else {
607                Origin::Received
608            },
609            age_minutes: r.get::<Option<i64>, _>("age").unwrap_or(0).max(0),
610            proof: r.get("merkle_path"),
611            frozen: frozen.contains(&(
612                r.get::<String, _>("txid").to_ascii_lowercase(),
613                r.get::<i64, _>("vout"),
614            )),
615        })
616        .collect())
617}
618
619/// The frozen record's hold (R6: never cleared here).
620const FROZEN: &str = "frozen: never offered";
621
622/// The coins an action of ours holds (`spent_by` a live action): listed in
623/// a refusal, never offered.
624async fn locked(pool: &SqlitePool) -> Result<Vec<HeldCoin>> {
625    let rows = sqlx::query(
626        "SELECT o.output_id, t.txid, o.vout, o.satoshis, t.is_outgoing, \
627                CAST((julianday('now') - julianday(o.created_at)) * 1440 AS INTEGER) AS age, \
628                COALESCE(s.txid, s.reference) AS holder \
629         FROM outputs o \
630         JOIN transactions t ON o.transaction_id = t.transaction_id \
631         JOIN transactions s ON o.spent_by = s.transaction_id \
632         JOIN output_baskets b ON o.basket_id = b.basket_id AND b.name = 'default' \
633         WHERE o.change = 1 AND s.status NOT IN ('completed', 'failed') \
634           AND t.status IN ('completed', 'unproven')",
635    )
636    .fetch_all(pool)
637    .await?;
638    Ok(rows
639        .iter()
640        .map(|r| HeldCoin {
641            outpoint: format!("{}.{}", r.get::<String, _>("txid"), r.get::<i64, _>("vout")),
642            satoshis: r.get::<i64, _>("satoshis").max(0) as u64,
643            word: "locked".to_string(),
644            origin: if r.get::<i64, _>("is_outgoing") != 0 {
645                Origin::Made
646            } else {
647                Origin::Received
648            },
649            age_minutes: r.get::<Option<i64>, _>("age").unwrap_or(0).max(0),
650            reason: format!(
651                "locked by {}",
652                r.get::<Option<String>, _>("holder").unwrap_or_default()
653            ),
654            output_id: r.get("output_id"),
655        })
656        .collect())
657}
658
659/// Read the coins and judge each. With `need`, stop reading once the coins
660/// offered reach it (the rest are `unread`); with `None`, read every coin.
661pub async fn assess<V: WalletServices + ?Sized>(
662    storage: &StorageSqlx,
663    services: &V,
664    opts: &GateOptions,
665    need: Option<u64>,
666) -> Result<Assessment> {
667    let all = candidates(storage.pool()).await?;
668    let tick = opts.tick();
669    let mut by_creator: Vec<(String, Option<Vec<u8>>)> = Vec::new();
670    for c in &all {
671        if !by_creator.iter().any(|(t, _)| t == &c.txid) {
672            by_creator.push((c.txid.clone(), c.proof.clone()));
673        }
674    }
675    let mut readings: HashMap<String, CreatorReading> = HashMap::new();
676    let mut out = Assessment::default();
677    let mut next = 0;
678    for c in &all {
679        let covered = need.is_some_and(|need| out.offered_satoshis() >= need);
680        if c.frozen {
681            out.held.push(HeldCoin {
682                outpoint: format!("{}.{}", c.txid, c.vout),
683                satoshis: c.satoshis,
684                word: "frozen".to_string(),
685                origin: c.origin,
686                age_minutes: c.age_minutes,
687                reason: FROZEN.to_string(),
688                output_id: c.output_id,
689            });
690            continue;
691        }
692        if !readings.contains_key(&c.txid) {
693            if covered {
694                out.unread.push(c.output_id);
695                continue;
696            }
697            let end = if need.is_some() {
698                (next + BATCH).min(by_creator.len())
699            } else {
700                by_creator.len()
701            };
702            let batch: Vec<_> = by_creator[next..end]
703                .iter()
704                .filter(|(t, _)| !readings.contains_key(t))
705                .cloned()
706                .collect();
707            next = end;
708            readings.extend(read_creators(storage, services, &tick, &batch).await?);
709        }
710        let Some(reading) = readings.get(&c.txid) else {
711            out.unread.push(c.output_id);
712            continue;
713        };
714        match hold_reason(reading, c.origin, opts) {
715            None if !covered => out.offered.push((c.output_id, c.satoshis)),
716            None => out.unread.push(c.output_id),
717            Some(reason) => out.held.push(HeldCoin {
718                outpoint: format!("{}.{}", c.txid, c.vout),
719                satoshis: c.satoshis,
720                word: reading.word.to_string(),
721                origin: c.origin,
722                age_minutes: c.age_minutes,
723                reason,
724                output_id: c.output_id,
725            }),
726        }
727    }
728    Ok(out)
729}
730
731/// The coins a caller named (`createAction`'s `inputs`, `respend`'s exact
732/// inputs), judged coin by coin as the toolbox's own allocation is: frozen,
733/// locked (`spent_by`, unless it is `reserved_to`, the phantom a respend
734/// replaces), or a creator the gate would not offer. Returns every named
735/// coin it holds. An outpoint that is not one of this wallet's outputs is
736/// not a coin of ours and is left to the toolbox (a covenant or a foreign
737/// input carried in `inputBEEF`).
738pub async fn assess_named<V: WalletServices + ?Sized>(
739    storage: &StorageSqlx,
740    services: &V,
741    opts: &GateOptions,
742    named: &[(String, u32)],
743    reserved_to: Option<i64>,
744) -> Result<Vec<HeldCoin>> {
745    let pool = storage.pool();
746    let frozen = crate::tracker_host::frozen_outpoints(pool).await?;
747    let mut coins = Vec::new();
748    for (txid, vout) in named {
749        let txid = txid.to_ascii_lowercase();
750        let row = sqlx::query(
751            "SELECT o.output_id, o.satoshis, o.spent_by, t.is_outgoing, \
752                    CAST((julianday('now') - julianday(o.created_at)) * 1440 AS INTEGER) AS age, \
753                    COALESCE(s.txid, s.reference) AS holder, p.merkle_path \
754             FROM outputs o \
755             JOIN transactions t ON o.transaction_id = t.transaction_id \
756             LEFT JOIN transactions s ON o.spent_by = s.transaction_id \
757             LEFT JOIN proven_txs p ON p.txid = t.txid \
758             WHERE lower(t.txid) = ? AND o.vout = ?",
759        )
760        .bind(&txid)
761        .bind(i64::from(*vout))
762        .fetch_optional(pool)
763        .await?;
764        let Some(r) = row else { continue };
765        coins.push((txid, *vout, r));
766    }
767    let is_frozen = |txid: &str, vout: u32| frozen.contains(&(txid.to_string(), i64::from(vout)));
768    let is_locked = |r: &sqlx::sqlite::SqliteRow| {
769        let spent_by: Option<i64> = r.get("spent_by");
770        spent_by.is_some() && spent_by != reserved_to
771    };
772    // Only a coin neither frozen nor locked needs its creator read.
773    let creators: Vec<(String, Option<Vec<u8>>)> = coins
774        .iter()
775        .filter(|(t, v, r)| !is_frozen(t, *v) && !is_locked(r))
776        .map(|(t, _, r)| (t.clone(), r.get("merkle_path")))
777        .collect();
778    let readings = if creators.is_empty() {
779        HashMap::new()
780    } else {
781        read_creators(storage, services, &opts.tick(), &creators).await?
782    };
783    let mut held = Vec::new();
784    for (txid, vout, r) in coins {
785        let origin = if r.get::<i64, _>("is_outgoing") != 0 {
786            Origin::Made
787        } else {
788            Origin::Received
789        };
790        let reading = readings.get(&txid);
791        let (word, reason) = if is_frozen(&txid, vout) {
792            ("frozen".to_string(), Some(FROZEN.to_string()))
793        } else if is_locked(&r) {
794            (
795                "locked".to_string(),
796                Some(format!(
797                    "locked by {}",
798                    r.get::<Option<String>, _>("holder").unwrap_or_default()
799                )),
800            )
801        } else {
802            match reading {
803                Some(reading) => (reading.word.to_string(), hold_reason(reading, origin, opts)),
804                None => ("unknown".to_string(), Some("not read".to_string())),
805            }
806        };
807        if let Some(reason) = reason {
808            held.push(HeldCoin {
809                outpoint: format!("{txid}.{vout}"),
810                satoshis: r.get::<i64, _>("satoshis").max(0) as u64,
811                word,
812                origin,
813                age_minutes: r.get::<Option<i64>, _>("age").unwrap_or(0).max(0),
814                reason,
815                output_id: r.get("output_id"),
816            });
817        }
818    }
819    Ok(held)
820}
821
822/// A unique name for this call's holds.
823fn holder() -> String {
824    use std::sync::atomic::{AtomicU64, Ordering};
825    static N: AtomicU64 = AtomicU64::new(0);
826    format!(
827        "{}:{}",
828        std::process::id(),
829        N.fetch_add(1, Ordering::Relaxed)
830    )
831}
832
833/// Mark `ids` not change for the call: the toolbox does not allocate them.
834async fn hold(pool: &SqlitePool, holder: &str, ids: &[i64]) -> Result<()> {
835    ensure_schema(pool).await?;
836    let now = chrono::Utc::now().timestamp();
837    let mut tx = pool.begin().await?;
838    for id in ids {
839        // Only a coin still change, and not held by another call, is held:
840        // restoring sets back exactly what was set here.
841        let held = sqlx::query(
842            "UPDATE outputs SET change = 0 WHERE output_id = ? AND change = 1 \
843             AND output_id NOT IN (SELECT output_id FROM spend_gate_holds)",
844        )
845        .bind(id)
846        .execute(&mut *tx)
847        .await?
848        .rows_affected();
849        if held == 1 {
850            sqlx::query(
851                "INSERT INTO spend_gate_holds (output_id, holder, held_at) VALUES (?, ?, ?)",
852            )
853            .bind(id)
854            .bind(holder)
855            .bind(now)
856            .execute(&mut *tx)
857            .await?;
858        }
859    }
860    tx.commit().await?;
861    Ok(())
862}
863
864/// Restore what `holder` held, and any hold a call that did not finish left
865/// behind ([`STALE_HOLD_SECS`]).
866pub async fn restore(pool: &SqlitePool, holder: Option<&str>) -> Result<u64> {
867    ensure_schema(pool).await?;
868    let stale = chrono::Utc::now().timestamp() - STALE_HOLD_SECS;
869    let mut tx = pool.begin().await?;
870    let which = "SELECT output_id FROM spend_gate_holds WHERE holder = ? OR held_at < ?";
871    let restored = sqlx::query(&format!(
872        "UPDATE outputs SET change = 1 WHERE change = 0 AND output_id IN ({which})"
873    ))
874    .bind(holder.unwrap_or(""))
875    .bind(stale)
876    .execute(&mut *tx)
877    .await?
878    .rows_affected();
879    sqlx::query("DELETE FROM spend_gate_holds WHERE holder = ? OR held_at < ?")
880        .bind(holder.unwrap_or(""))
881        .bind(stale)
882        .execute(&mut *tx)
883        .await?;
884    tx.commit().await?;
885    Ok(restored)
886}
887
888fn is_insufficient(e: &bsv_sdk::Error) -> bool {
889    e.to_string()
890        .to_ascii_lowercase()
891        .contains("insufficient funds")
892}
893
894/// One toolbox call with every coin the assessment did not offer held.
895async fn run_held<V: WalletServices + Send + Sync + 'static>(
896    wallet: &Wallet<StorageSqlx, V>,
897    args: CreateActionArgs,
898    originator: &str,
899    assessment: &Assessment,
900) -> std::result::Result<bsv_sdk::Result<CreateActionResult>, GateError> {
901    let pool = wallet.storage().pool();
902    let holder = holder();
903    let ids: Vec<i64> = assessment
904        .held
905        .iter()
906        .map(|c| c.output_id)
907        .chain(assessment.unread.iter().copied())
908        .collect();
909    hold(pool, &holder, &ids).await.map_err(GateError::Gate)?;
910    let outcome = wallet.create_action(args, originator).await;
911    restore(pool, Some(&holder))
912        .await
913        .map_err(GateError::Gate)?;
914    Ok(outcome)
915}
916
917/// `createAction` through the gate: the toolbox allocates only coins the
918/// gate offers. The caller holds the spending lock.
919pub async fn create_action<V: WalletServices + Send + Sync + 'static>(
920    wallet: &Wallet<StorageSqlx, V>,
921    args: CreateActionArgs,
922    originator: &str,
923    opts: &GateOptions,
924) -> std::result::Result<CreateActionResult, GateError> {
925    let storage = wallet.storage();
926    restore(storage.pool(), None)
927        .await
928        .map_err(GateError::Gate)?;
929    let named: Vec<(String, u32)> = args
930        .inputs
931        .iter()
932        .flatten()
933        .map(|i| {
934            (
935                bsv_sdk::primitives::to_hex(&i.outpoint.txid),
936                i.outpoint.vout,
937            )
938        })
939        .collect();
940    if !named.is_empty() {
941        let held = assess_named(storage, wallet.services(), opts, &named, None)
942            .await
943            .map_err(GateError::Gate)?;
944        if !held.is_empty() {
945            return Err(GateError::Refused(Refusal {
946                toolbox: NAMED_INPUT_REFUSED.to_string(),
947                offered_satoshis: 0,
948                held,
949            }));
950        }
951    }
952    let result = run_gated(wallet, args, originator, opts).await?;
953    // The action records the word it was created under (R1 (d)): by its
954    // txid, or a template for deferred signing by its reference.
955    if opts.allow_received {
956        if let Some(txid) = result.txid.as_ref() {
957            record_rule(
958                storage.pool(),
959                &bsv_sdk::primitives::to_hex(txid),
960                RECEIVED_PENDING,
961            )
962            .await
963            .map_err(GateError::Gate)?;
964        } else if let Some(st) = result.signable_transaction.as_ref() {
965            record_rule_by_reference(
966                storage.pool(),
967                &reference_string(&st.reference),
968                RECEIVED_PENDING,
969            )
970            .await
971            .map_err(GateError::Gate)?;
972        }
973    }
974    Ok(result)
975}
976
977async fn run_gated<V: WalletServices + Send + Sync + 'static>(
978    wallet: &Wallet<StorageSqlx, V>,
979    args: CreateActionArgs,
980    originator: &str,
981    opts: &GateOptions,
982) -> std::result::Result<CreateActionResult, GateError> {
983    let storage = wallet.storage();
984    let need = args
985        .outputs
986        .as_ref()
987        .map(|o| o.iter().map(|x| x.satoshis).sum::<u64>())
988        .unwrap_or(0)
989        .saturating_add(FEE_MARGIN);
990    let first = assess(storage, wallet.services(), opts, Some(need))
991        .await
992        .map_err(GateError::Gate)?;
993    let lazy = !first.unread.is_empty();
994    match run_held(wallet, args.clone(), originator, &first).await? {
995        Ok(result) => return Ok(result),
996        Err(e) if !is_insufficient(&e) => return Err(GateError::Wallet(e)),
997        Err(e) if !lazy => return Err(refusal(storage, &first, &e).await),
998        Err(_) => {}
999    }
1000    // The lazy read left coins unread: read every coin and try once more.
1001    let full = assess(storage, wallet.services(), opts, None)
1002        .await
1003        .map_err(GateError::Gate)?;
1004    match run_held(wallet, args, originator, &full).await? {
1005        Ok(result) => Ok(result),
1006        Err(e) if is_insufficient(&e) => Err(refusal(storage, &full, &e).await),
1007        Err(e) => Err(GateError::Wallet(e)),
1008    }
1009}
1010
1011async fn refusal(storage: &StorageSqlx, assessment: &Assessment, e: &bsv_sdk::Error) -> GateError {
1012    let mut held = assessment.held.clone();
1013    match locked(storage.pool()).await {
1014        Ok(mut l) => held.append(&mut l),
1015        Err(fault) => return GateError::Gate(fault),
1016    }
1017    let toolbox = e.to_string();
1018    GateError::Refused(Refusal {
1019        toolbox: toolbox
1020            .strip_prefix("wallet error: ")
1021            .unwrap_or(&toolbox)
1022            .to_string(),
1023        offered_satoshis: assessment.offered_satoshis(),
1024        held,
1025    })
1026}
1027
1028#[cfg(test)]
1029mod tests;