1use 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
91pub const DEFAULT_SPEND_OWN_CHANGE: bool = true;
94pub const STALE_HOLD_SECS: i64 = 600;
97const BATCH: usize = 32;
99const FEE_MARGIN: u64 = 10_000;
101pub const RECEIVED_PENDING: &str = "received-pending";
104pub const ALLOW_PENDING_RECEIVED: &str = "received";
106pub const ALLOW_PENDING_HINT: &str = "offered under allowPending: received";
108pub const NAMED_INPUT_REFUSED: &str = "an input the spend guard does not offer";
110
111#[derive(Debug, Clone)]
113pub struct GateOptions {
114 pub spend_depth: Height,
116 pub own_change: bool,
119 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
134pub fn allow_pending(word: Option<&str>) -> bool {
137 word == Some(ALLOW_PENDING_RECEIVED)
138}
139
140impl GateOptions {
141 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
176#[serde(rename_all = "lowercase")]
177pub enum Origin {
178 Made,
180 Received,
182}
183
184#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
186pub struct HeldCoin {
187 pub outpoint: String,
189 pub satoshis: u64,
190 pub word: String,
193 pub origin: Origin,
194 pub age_minutes: i64,
196 pub reason: String,
200 #[serde(skip)]
201 output_id: i64,
202}
203
204#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
207pub struct Refusal {
208 pub toolbox: String,
210 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 pub fn is_named_input(&self) -> bool {
258 self.toolbox == NAMED_INPUT_REFUSED
259 }
260
261 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 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 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
302pub 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#[derive(Debug)]
321pub enum GateError {
322 Refused(Refusal),
324 Wallet(bsv_sdk::Error),
326 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#[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#[derive(Debug, Default, Clone)]
357pub struct Assessment {
358 pub offered: Vec<(i64, u64)>,
360 pub held: Vec<HeldCoin>,
362 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 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
413pub 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
428pub 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
448pub 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
460pub fn reference_string(reference: &[u8]) -> String {
463 String::from_utf8(reference.to_vec()).unwrap_or_else(|_| hex::encode(reference))
464}
465
466pub 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#[derive(Debug, Default, Clone)]
483pub struct ActionRules {
484 pub by_txid: HashMap<String, String>,
485 pub by_template: HashMap<String, String>,
489}
490
491pub fn template_key(status: &str, description: &str, satoshis: i64) -> String {
493 format!("{status}\u{1f}{description}\u{1f}{satoshis}")
494}
495
496pub 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
544pub 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 _ => 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
578async 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
619const FROZEN: &str = "frozen: never offered";
621
622async 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
659pub 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
731pub 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 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
822fn 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
833async 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 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
864pub 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
894async 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
917pub 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 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 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;