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
262/// What a gated `createAction` failed on.
263#[derive(Debug)]
264pub enum GateError {
265    /// No offerable coin covers it.
266    Refused(Refusal),
267    /// The toolbox's own error, as it gave it.
268    Wallet(bsv_sdk::Error),
269    /// The store or the header service failed under the gate.
270    Gate(anyhow::Error),
271}
272
273impl std::fmt::Display for GateError {
274    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
275        match self {
276            Self::Refused(r) => r.fmt(f),
277            Self::Wallet(e) => e.fmt(f),
278            Self::Gate(e) => write!(f, "Storage error: spend gate: {e}"),
279        }
280    }
281}
282
283impl std::error::Error for GateError {}
284
285/// A candidate coin, as the toolbox would see it.
286#[derive(Debug, Clone)]
287struct Candidate {
288    output_id: i64,
289    txid: String,
290    vout: i64,
291    satoshis: u64,
292    origin: Origin,
293    age_minutes: i64,
294    proof: Option<Vec<u8>>,
295    frozen: bool,
296}
297
298/// The gate's verdict on the coins it read.
299#[derive(Debug, Default, Clone)]
300pub struct Assessment {
301    /// Coins offered: (output id, satoshis).
302    pub offered: Vec<(i64, u64)>,
303    /// Coins held back, with why.
304    pub held: Vec<HeldCoin>,
305    /// Coins not read (enough was offered before them); held too.
306    pub unread: Vec<i64>,
307}
308
309impl Assessment {
310    pub fn offered_satoshis(&self) -> u64 {
311        self.offered.iter().map(|(_, s)| s).sum()
312    }
313}
314
315async fn ensure_schema(pool: &SqlitePool) -> Result<()> {
316    sqlx::query(
317        "CREATE TABLE IF NOT EXISTS spend_gate_holds (\
318            output_id INTEGER PRIMARY KEY, \
319            holder TEXT NOT NULL, \
320            held_at INTEGER NOT NULL)",
321    )
322    .execute(pool)
323    .await?;
324    // The rule an action was created under (R1 (d)): the toolbox's
325    // `transactions` row is the reference's shape and not ours to widen.
326    // An action is named by its txid, or, a deferred-signing template with
327    // no txid yet, by the toolbox's `reference` until `signAction` gives it one.
328    sqlx::query(
329        "CREATE TABLE IF NOT EXISTS spend_gate_actions (\
330            txid TEXT PRIMARY KEY, \
331            rule TEXT NOT NULL, \
332            recorded_at INTEGER NOT NULL, \
333            reference TEXT)",
334    )
335    .execute(pool)
336    .await?;
337    let has_reference: Option<String> = sqlx::query_scalar(
338        "SELECT name FROM pragma_table_info('spend_gate_actions') WHERE name = 'reference'",
339    )
340    .fetch_optional(pool)
341    .await?;
342    if has_reference.is_none() {
343        sqlx::query("ALTER TABLE spend_gate_actions ADD COLUMN reference TEXT")
344            .execute(pool)
345            .await?;
346    }
347    sqlx::query(
348        "CREATE UNIQUE INDEX IF NOT EXISTS spend_gate_actions_reference \
349         ON spend_gate_actions (reference)",
350    )
351    .execute(pool)
352    .await?;
353    Ok(())
354}
355
356/// Record that the action `txid` was created under `rule`.
357pub async fn record_rule(pool: &SqlitePool, txid: &str, rule: &str) -> Result<()> {
358    ensure_schema(pool).await?;
359    sqlx::query(
360        "INSERT INTO spend_gate_actions (txid, rule, recorded_at) VALUES (?, ?, ?) \
361         ON CONFLICT(txid) DO NOTHING",
362    )
363    .bind(txid.to_ascii_lowercase())
364    .bind(rule)
365    .bind(chrono::Utc::now().timestamp())
366    .execute(pool)
367    .await?;
368    Ok(())
369}
370
371/// Record that the deferred-signing template `reference` (no txid yet) was
372/// created under `rule`; [`fill_txid`] names it by its txid once signed.
373pub async fn record_rule_by_reference(
374    pool: &SqlitePool,
375    reference: &str,
376    rule: &str,
377) -> Result<()> {
378    ensure_schema(pool).await?;
379    sqlx::query(
380        "INSERT INTO spend_gate_actions (txid, rule, recorded_at, reference) VALUES (NULL, ?, ?, ?) \
381         ON CONFLICT(reference) DO NOTHING",
382    )
383    .bind(rule)
384    .bind(chrono::Utc::now().timestamp())
385    .bind(reference)
386    .execute(pool)
387    .await?;
388    Ok(())
389}
390
391/// The template `reference` was signed as `txid`: its row, where one was
392/// recorded, gains the txid and keeps the reference.
393pub async fn fill_txid(pool: &SqlitePool, reference: &str, txid: &str) -> Result<()> {
394    ensure_schema(pool).await?;
395    sqlx::query("UPDATE spend_gate_actions SET txid = ? WHERE reference = ? AND txid IS NULL")
396        .bind(txid.to_ascii_lowercase())
397        .bind(reference)
398        .execute(pool)
399        .await?;
400    Ok(())
401}
402
403/// The toolbox's `reference` string of a signable transaction, as
404/// `signAction` / `abortAction` take it back.
405pub fn reference_string(reference: &[u8]) -> String {
406    String::from_utf8(reference.to_vec()).unwrap_or_else(|_| hex::encode(reference))
407}
408
409/// The rule each action was created under, where one was recorded, by
410/// txid; a template not yet signed has none (see [`ActionRules`]).
411pub async fn rules_of(pool: &SqlitePool) -> Result<HashMap<String, String>> {
412    ensure_schema(pool).await?;
413    let rows = sqlx::query("SELECT txid, rule FROM spend_gate_actions WHERE txid IS NOT NULL")
414        .fetch_all(pool)
415        .await?;
416    Ok(rows
417        .iter()
418        .map(|r| (r.get::<String, _>("txid"), r.get::<String, _>("rule")))
419        .collect())
420}
421
422/// The rules a listing shows as `underRule`: by txid, and for a template
423/// not yet signed (listed with an all-zero txid) by its shape in the
424/// listing, read off the toolbox row its reference names.
425#[derive(Debug, Default, Clone)]
426pub struct ActionRules {
427    pub by_txid: HashMap<String, String>,
428    /// [`template_key`] → rule, only where every unsigned toolbox row of
429    /// that shape carries the same rule: a shape shared with a template
430    /// under no rule, or another rule, names none.
431    pub by_template: HashMap<String, String>,
432}
433
434/// A template's shape as a listing shows it: status, description, satoshis.
435pub fn template_key(status: &str, description: &str, satoshis: i64) -> String {
436    format!("{status}\u{1f}{description}\u{1f}{satoshis}")
437}
438
439/// Every recorded rule, as a listing shows it.
440pub async fn action_rules(pool: &SqlitePool) -> Result<ActionRules> {
441    let by_txid = rules_of(pool).await?;
442    let rows = sqlx::query(
443        "SELECT t.status, t.description, t.satoshis, g.rule FROM transactions t \
444         LEFT JOIN spend_gate_actions g ON g.reference = t.reference AND g.txid IS NULL \
445         WHERE t.txid IS NULL",
446    )
447    .fetch_all(pool)
448    .await?;
449    let mut shapes: HashMap<String, Option<String>> = HashMap::new();
450    let mut ambiguous = std::collections::HashSet::new();
451    for r in &rows {
452        let key = template_key(
453            &r.get::<String, _>("status"),
454            &r.get::<String, _>("description"),
455            r.get::<i64, _>("satoshis"),
456        );
457        let rule: Option<String> = r.get("rule");
458        match shapes.get(&key) {
459            Some(seen) if *seen != rule => {
460                ambiguous.insert(key);
461            }
462            Some(_) => {}
463            None => {
464                shapes.insert(key, rule);
465            }
466        }
467    }
468    let by_template = shapes
469        .into_iter()
470        .filter(|(k, _)| !ambiguous.contains(k))
471        .filter_map(|(k, rule)| rule.map(|r| (k, r)))
472        .collect();
473    Ok(ActionRules {
474        by_txid,
475        by_template,
476    })
477}
478
479fn blocks(n: u64) -> String {
480    if n == 1 {
481        "1 block".to_string()
482    } else {
483        format!("{n} blocks")
484    }
485}
486
487/// Why `reading` holds a coin of `origin`, or `None` when it is offered.
488pub fn hold_reason(reading: &CreatorReading, origin: Origin, opts: &GateOptions) -> Option<String> {
489    if let Some(fault) = &reading.fault {
490        return Some(format!("headers unavailable: {fault}"));
491    }
492    if reading.offered {
493        return None;
494    }
495    let depth = format!(
496        "depth {}: {}",
497        opts.spend_depth
498            .max(crate::tracker_host::DEFAULT_SPEND_DEPTH),
499        blocks(reading.depth)
500    );
501    match reading.word {
502        "mined" if reading.suspect => Some("suspect: a fork at or below its height".to_string()),
503        "mined" => Some(depth),
504        "stale" | "rejected" | "conflicted" | "abandoned" => {
505            Some(format!("{}: never offered", reading.word))
506        }
507        // `pending`: the word claims nothing about the chain; only R1's
508        // rule for our own change (b), or the action's word for a received
509        // coin (d), offers it.
510        _ => match origin {
511            Origin::Received if opts.allow_received => None,
512            Origin::Received => Some(format!("{depth}; received, no proof; {ALLOW_PENDING_HINT}")),
513            Origin::Made if opts.own_change => None,
514            Origin::Made => Some(format!("{depth}; own change, SPEND_OWN_CHANGE=0")),
515        },
516    }
517}
518
519/// Every coin the toolbox could allocate: a change output of a default
520/// basket, unspent, its transaction `completed` or `unproven`. In the
521/// toolbox's preference: completed first, then the largest.
522async fn candidates(pool: &SqlitePool) -> Result<Vec<Candidate>> {
523    let frozen = crate::tracker_host::frozen_outpoints(pool).await?;
524    let rows = sqlx::query(
525        "SELECT o.output_id, t.txid, o.vout, o.satoshis, t.is_outgoing, \
526                CAST((julianday('now') - julianday(o.created_at)) * 1440 AS INTEGER) AS age, \
527                p.merkle_path \
528         FROM outputs o \
529         JOIN transactions t ON o.transaction_id = t.transaction_id \
530         JOIN output_baskets b ON o.basket_id = b.basket_id AND b.name = 'default' \
531         LEFT JOIN proven_txs p ON p.txid = t.txid \
532         WHERE o.change = 1 AND o.spent_by IS NULL AND t.txid IS NOT NULL \
533           AND t.status IN ('completed', 'unproven') \
534         ORDER BY CASE WHEN t.status = 'completed' THEN 0 ELSE 1 END, o.satoshis DESC",
535    )
536    .fetch_all(pool)
537    .await?;
538    Ok(rows
539        .iter()
540        .map(|r| Candidate {
541            output_id: r.get("output_id"),
542            txid: r.get::<String, _>("txid").to_ascii_lowercase(),
543            vout: r.get("vout"),
544            satoshis: r.get::<i64, _>("satoshis").max(0) as u64,
545            origin: if r.get::<i64, _>("is_outgoing") != 0 {
546                Origin::Made
547            } else {
548                Origin::Received
549            },
550            age_minutes: r.get::<Option<i64>, _>("age").unwrap_or(0).max(0),
551            proof: r.get("merkle_path"),
552            frozen: frozen.contains(&(
553                r.get::<String, _>("txid").to_ascii_lowercase(),
554                r.get::<i64, _>("vout"),
555            )),
556        })
557        .collect())
558}
559
560/// The frozen record's hold (R6: never cleared here).
561const FROZEN: &str = "frozen: never offered";
562
563/// The coins an action of ours holds (`spent_by` a live action): listed in
564/// a refusal, never offered.
565async fn locked(pool: &SqlitePool) -> Result<Vec<HeldCoin>> {
566    let rows = sqlx::query(
567        "SELECT o.output_id, t.txid, o.vout, o.satoshis, t.is_outgoing, \
568                CAST((julianday('now') - julianday(o.created_at)) * 1440 AS INTEGER) AS age, \
569                COALESCE(s.txid, s.reference) AS holder \
570         FROM outputs o \
571         JOIN transactions t ON o.transaction_id = t.transaction_id \
572         JOIN transactions s ON o.spent_by = s.transaction_id \
573         JOIN output_baskets b ON o.basket_id = b.basket_id AND b.name = 'default' \
574         WHERE o.change = 1 AND s.status NOT IN ('completed', 'failed') \
575           AND t.status IN ('completed', 'unproven')",
576    )
577    .fetch_all(pool)
578    .await?;
579    Ok(rows
580        .iter()
581        .map(|r| HeldCoin {
582            outpoint: format!("{}.{}", r.get::<String, _>("txid"), r.get::<i64, _>("vout")),
583            satoshis: r.get::<i64, _>("satoshis").max(0) as u64,
584            word: "locked".to_string(),
585            origin: if r.get::<i64, _>("is_outgoing") != 0 {
586                Origin::Made
587            } else {
588                Origin::Received
589            },
590            age_minutes: r.get::<Option<i64>, _>("age").unwrap_or(0).max(0),
591            reason: format!(
592                "locked by {}",
593                r.get::<Option<String>, _>("holder").unwrap_or_default()
594            ),
595            output_id: r.get("output_id"),
596        })
597        .collect())
598}
599
600/// Read the coins and judge each. With `need`, stop reading once the coins
601/// offered reach it (the rest are `unread`); with `None`, read every coin.
602pub async fn assess<V: WalletServices + ?Sized>(
603    storage: &StorageSqlx,
604    services: &V,
605    opts: &GateOptions,
606    need: Option<u64>,
607) -> Result<Assessment> {
608    let all = candidates(storage.pool()).await?;
609    let tick = opts.tick();
610    let mut by_creator: Vec<(String, Option<Vec<u8>>)> = Vec::new();
611    for c in &all {
612        if !by_creator.iter().any(|(t, _)| t == &c.txid) {
613            by_creator.push((c.txid.clone(), c.proof.clone()));
614        }
615    }
616    let mut readings: HashMap<String, CreatorReading> = HashMap::new();
617    let mut out = Assessment::default();
618    let mut next = 0;
619    for c in &all {
620        let covered = need.is_some_and(|need| out.offered_satoshis() >= need);
621        if c.frozen {
622            out.held.push(HeldCoin {
623                outpoint: format!("{}.{}", c.txid, c.vout),
624                satoshis: c.satoshis,
625                word: "frozen".to_string(),
626                origin: c.origin,
627                age_minutes: c.age_minutes,
628                reason: FROZEN.to_string(),
629                output_id: c.output_id,
630            });
631            continue;
632        }
633        if !readings.contains_key(&c.txid) {
634            if covered {
635                out.unread.push(c.output_id);
636                continue;
637            }
638            let end = if need.is_some() {
639                (next + BATCH).min(by_creator.len())
640            } else {
641                by_creator.len()
642            };
643            let batch: Vec<_> = by_creator[next..end]
644                .iter()
645                .filter(|(t, _)| !readings.contains_key(t))
646                .cloned()
647                .collect();
648            next = end;
649            readings.extend(read_creators(storage, services, &tick, &batch).await?);
650        }
651        let Some(reading) = readings.get(&c.txid) else {
652            out.unread.push(c.output_id);
653            continue;
654        };
655        match hold_reason(reading, c.origin, opts) {
656            None if !covered => out.offered.push((c.output_id, c.satoshis)),
657            None => out.unread.push(c.output_id),
658            Some(reason) => out.held.push(HeldCoin {
659                outpoint: format!("{}.{}", c.txid, c.vout),
660                satoshis: c.satoshis,
661                word: reading.word.to_string(),
662                origin: c.origin,
663                age_minutes: c.age_minutes,
664                reason,
665                output_id: c.output_id,
666            }),
667        }
668    }
669    Ok(out)
670}
671
672/// The coins a caller named (`createAction`'s `inputs`, `respend`'s exact
673/// inputs), judged coin by coin as the toolbox's own allocation is: frozen,
674/// locked (`spent_by`, unless it is `reserved_to`, the phantom a respend
675/// replaces), or a creator the gate would not offer. Returns every named
676/// coin it holds. An outpoint that is not one of this wallet's outputs is
677/// not a coin of ours and is left to the toolbox (a covenant or a foreign
678/// input carried in `inputBEEF`).
679pub async fn assess_named<V: WalletServices + ?Sized>(
680    storage: &StorageSqlx,
681    services: &V,
682    opts: &GateOptions,
683    named: &[(String, u32)],
684    reserved_to: Option<i64>,
685) -> Result<Vec<HeldCoin>> {
686    let pool = storage.pool();
687    let frozen = crate::tracker_host::frozen_outpoints(pool).await?;
688    let mut coins = Vec::new();
689    for (txid, vout) in named {
690        let txid = txid.to_ascii_lowercase();
691        let row = sqlx::query(
692            "SELECT o.output_id, o.satoshis, o.spent_by, t.is_outgoing, \
693                    CAST((julianday('now') - julianday(o.created_at)) * 1440 AS INTEGER) AS age, \
694                    COALESCE(s.txid, s.reference) AS holder, p.merkle_path \
695             FROM outputs o \
696             JOIN transactions t ON o.transaction_id = t.transaction_id \
697             LEFT JOIN transactions s ON o.spent_by = s.transaction_id \
698             LEFT JOIN proven_txs p ON p.txid = t.txid \
699             WHERE lower(t.txid) = ? AND o.vout = ?",
700        )
701        .bind(&txid)
702        .bind(i64::from(*vout))
703        .fetch_optional(pool)
704        .await?;
705        let Some(r) = row else { continue };
706        coins.push((txid, *vout, r));
707    }
708    let is_frozen = |txid: &str, vout: u32| frozen.contains(&(txid.to_string(), i64::from(vout)));
709    let is_locked = |r: &sqlx::sqlite::SqliteRow| {
710        let spent_by: Option<i64> = r.get("spent_by");
711        spent_by.is_some() && spent_by != reserved_to
712    };
713    // Only a coin neither frozen nor locked needs its creator read.
714    let creators: Vec<(String, Option<Vec<u8>>)> = coins
715        .iter()
716        .filter(|(t, v, r)| !is_frozen(t, *v) && !is_locked(r))
717        .map(|(t, _, r)| (t.clone(), r.get("merkle_path")))
718        .collect();
719    let readings = if creators.is_empty() {
720        HashMap::new()
721    } else {
722        read_creators(storage, services, &opts.tick(), &creators).await?
723    };
724    let mut held = Vec::new();
725    for (txid, vout, r) in coins {
726        let origin = if r.get::<i64, _>("is_outgoing") != 0 {
727            Origin::Made
728        } else {
729            Origin::Received
730        };
731        let reading = readings.get(&txid);
732        let (word, reason) = if is_frozen(&txid, vout) {
733            ("frozen".to_string(), Some(FROZEN.to_string()))
734        } else if is_locked(&r) {
735            (
736                "locked".to_string(),
737                Some(format!(
738                    "locked by {}",
739                    r.get::<Option<String>, _>("holder").unwrap_or_default()
740                )),
741            )
742        } else {
743            match reading {
744                Some(reading) => (reading.word.to_string(), hold_reason(reading, origin, opts)),
745                None => ("unknown".to_string(), Some("not read".to_string())),
746            }
747        };
748        if let Some(reason) = reason {
749            held.push(HeldCoin {
750                outpoint: format!("{txid}.{vout}"),
751                satoshis: r.get::<i64, _>("satoshis").max(0) as u64,
752                word,
753                origin,
754                age_minutes: r.get::<Option<i64>, _>("age").unwrap_or(0).max(0),
755                reason,
756                output_id: r.get("output_id"),
757            });
758        }
759    }
760    Ok(held)
761}
762
763/// A unique name for this call's holds.
764fn holder() -> String {
765    use std::sync::atomic::{AtomicU64, Ordering};
766    static N: AtomicU64 = AtomicU64::new(0);
767    format!(
768        "{}:{}",
769        std::process::id(),
770        N.fetch_add(1, Ordering::Relaxed)
771    )
772}
773
774/// Mark `ids` not change for the call: the toolbox does not allocate them.
775async fn hold(pool: &SqlitePool, holder: &str, ids: &[i64]) -> Result<()> {
776    ensure_schema(pool).await?;
777    let now = chrono::Utc::now().timestamp();
778    let mut tx = pool.begin().await?;
779    for id in ids {
780        // Only a coin still change, and not held by another call, is held:
781        // restoring sets back exactly what was set here.
782        let held = sqlx::query(
783            "UPDATE outputs SET change = 0 WHERE output_id = ? AND change = 1 \
784             AND output_id NOT IN (SELECT output_id FROM spend_gate_holds)",
785        )
786        .bind(id)
787        .execute(&mut *tx)
788        .await?
789        .rows_affected();
790        if held == 1 {
791            sqlx::query(
792                "INSERT INTO spend_gate_holds (output_id, holder, held_at) VALUES (?, ?, ?)",
793            )
794            .bind(id)
795            .bind(holder)
796            .bind(now)
797            .execute(&mut *tx)
798            .await?;
799        }
800    }
801    tx.commit().await?;
802    Ok(())
803}
804
805/// Restore what `holder` held, and any hold a call that did not finish left
806/// behind ([`STALE_HOLD_SECS`]).
807pub async fn restore(pool: &SqlitePool, holder: Option<&str>) -> Result<u64> {
808    ensure_schema(pool).await?;
809    let stale = chrono::Utc::now().timestamp() - STALE_HOLD_SECS;
810    let mut tx = pool.begin().await?;
811    let which = "SELECT output_id FROM spend_gate_holds WHERE holder = ? OR held_at < ?";
812    let restored = sqlx::query(&format!(
813        "UPDATE outputs SET change = 1 WHERE change = 0 AND output_id IN ({which})"
814    ))
815    .bind(holder.unwrap_or(""))
816    .bind(stale)
817    .execute(&mut *tx)
818    .await?
819    .rows_affected();
820    sqlx::query("DELETE FROM spend_gate_holds WHERE holder = ? OR held_at < ?")
821        .bind(holder.unwrap_or(""))
822        .bind(stale)
823        .execute(&mut *tx)
824        .await?;
825    tx.commit().await?;
826    Ok(restored)
827}
828
829fn is_insufficient(e: &bsv_sdk::Error) -> bool {
830    e.to_string()
831        .to_ascii_lowercase()
832        .contains("insufficient funds")
833}
834
835/// One toolbox call with every coin the assessment did not offer held.
836async fn run_held<V: WalletServices + Send + Sync + 'static>(
837    wallet: &Wallet<StorageSqlx, V>,
838    args: CreateActionArgs,
839    originator: &str,
840    assessment: &Assessment,
841) -> std::result::Result<bsv_sdk::Result<CreateActionResult>, GateError> {
842    let pool = wallet.storage().pool();
843    let holder = holder();
844    let ids: Vec<i64> = assessment
845        .held
846        .iter()
847        .map(|c| c.output_id)
848        .chain(assessment.unread.iter().copied())
849        .collect();
850    hold(pool, &holder, &ids).await.map_err(GateError::Gate)?;
851    let outcome = wallet.create_action(args, originator).await;
852    restore(pool, Some(&holder))
853        .await
854        .map_err(GateError::Gate)?;
855    Ok(outcome)
856}
857
858/// `createAction` through the gate: the toolbox allocates only coins the
859/// gate offers. The caller holds the spending lock.
860pub async fn create_action<V: WalletServices + Send + Sync + 'static>(
861    wallet: &Wallet<StorageSqlx, V>,
862    args: CreateActionArgs,
863    originator: &str,
864    opts: &GateOptions,
865) -> std::result::Result<CreateActionResult, GateError> {
866    let storage = wallet.storage();
867    restore(storage.pool(), None)
868        .await
869        .map_err(GateError::Gate)?;
870    let named: Vec<(String, u32)> = args
871        .inputs
872        .iter()
873        .flatten()
874        .map(|i| {
875            (
876                bsv_sdk::primitives::to_hex(&i.outpoint.txid),
877                i.outpoint.vout,
878            )
879        })
880        .collect();
881    if !named.is_empty() {
882        let held = assess_named(storage, wallet.services(), opts, &named, None)
883            .await
884            .map_err(GateError::Gate)?;
885        if !held.is_empty() {
886            return Err(GateError::Refused(Refusal {
887                toolbox: NAMED_INPUT_REFUSED.to_string(),
888                offered_satoshis: 0,
889                held,
890            }));
891        }
892    }
893    let result = run_gated(wallet, args, originator, opts).await?;
894    // The action records the word it was created under (R1 (d)): by its
895    // txid, or a template for deferred signing by its reference.
896    if opts.allow_received {
897        if let Some(txid) = result.txid.as_ref() {
898            record_rule(
899                storage.pool(),
900                &bsv_sdk::primitives::to_hex(txid),
901                RECEIVED_PENDING,
902            )
903            .await
904            .map_err(GateError::Gate)?;
905        } else if let Some(st) = result.signable_transaction.as_ref() {
906            record_rule_by_reference(
907                storage.pool(),
908                &reference_string(&st.reference),
909                RECEIVED_PENDING,
910            )
911            .await
912            .map_err(GateError::Gate)?;
913        }
914    }
915    Ok(result)
916}
917
918async fn run_gated<V: WalletServices + Send + Sync + 'static>(
919    wallet: &Wallet<StorageSqlx, V>,
920    args: CreateActionArgs,
921    originator: &str,
922    opts: &GateOptions,
923) -> std::result::Result<CreateActionResult, GateError> {
924    let storage = wallet.storage();
925    let need = args
926        .outputs
927        .as_ref()
928        .map(|o| o.iter().map(|x| x.satoshis).sum::<u64>())
929        .unwrap_or(0)
930        .saturating_add(FEE_MARGIN);
931    let first = assess(storage, wallet.services(), opts, Some(need))
932        .await
933        .map_err(GateError::Gate)?;
934    let lazy = !first.unread.is_empty();
935    match run_held(wallet, args.clone(), originator, &first).await? {
936        Ok(result) => return Ok(result),
937        Err(e) if !is_insufficient(&e) => return Err(GateError::Wallet(e)),
938        Err(e) if !lazy => return Err(refusal(storage, &first, &e).await),
939        Err(_) => {}
940    }
941    // The lazy read left coins unread: read every coin and try once more.
942    let full = assess(storage, wallet.services(), opts, None)
943        .await
944        .map_err(GateError::Gate)?;
945    match run_held(wallet, args, originator, &full).await? {
946        Ok(result) => Ok(result),
947        Err(e) if is_insufficient(&e) => Err(refusal(storage, &full, &e).await),
948        Err(e) => Err(GateError::Wallet(e)),
949    }
950}
951
952async fn refusal(storage: &StorageSqlx, assessment: &Assessment, e: &bsv_sdk::Error) -> GateError {
953    let mut held = assessment.held.clone();
954    match locked(storage.pool()).await {
955        Ok(mut l) => held.append(&mut l),
956        Err(fault) => return GateError::Gate(fault),
957    }
958    let toolbox = e.to_string();
959    GateError::Refused(Refusal {
960        toolbox: toolbox
961            .strip_prefix("wallet error: ")
962            .unwrap_or(&toolbox)
963            .to_string(),
964        offered_satoshis: assessment.offered_satoshis(),
965        held,
966    })
967}
968
969#[cfg(test)]
970mod tests;