1use 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
67pub const DEFAULT_SPEND_OWN_CHANGE: bool = true;
70pub const STALE_HOLD_SECS: i64 = 600;
73const BATCH: usize = 32;
75const FEE_MARGIN: u64 = 10_000;
77
78#[derive(Debug, Clone)]
80pub struct GateOptions {
81 pub spend_depth: Height,
83 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
126#[serde(rename_all = "lowercase")]
127pub enum Origin {
128 Made,
130 Received,
132}
133
134#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
136pub struct HeldCoin {
137 pub outpoint: String,
139 pub satoshis: u64,
140 pub word: String,
143 pub origin: Origin,
144 pub age_minutes: i64,
146 pub reason: String,
149 #[serde(skip)]
150 output_id: i64,
151}
152
153#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
156pub struct Refusal {
157 pub toolbox: String,
159 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#[derive(Debug)]
196pub enum GateError {
197 Refused(Refusal),
199 Wallet(bsv_sdk::Error),
201 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#[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#[derive(Debug, Default, Clone)]
231pub struct Assessment {
232 pub offered: Vec<(i64, u64)>,
234 pub held: Vec<HeldCoin>,
236 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
266pub 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 _ => 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
296async 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
332async 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
369pub 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
429fn 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
440async 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 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
471pub 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
501async 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
524pub 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 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;