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
262#[derive(Debug)]
264pub enum GateError {
265 Refused(Refusal),
267 Wallet(bsv_sdk::Error),
269 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#[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#[derive(Debug, Default, Clone)]
300pub struct Assessment {
301 pub offered: Vec<(i64, u64)>,
303 pub held: Vec<HeldCoin>,
305 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 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
356pub 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
371pub 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
391pub 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
403pub fn reference_string(reference: &[u8]) -> String {
406 String::from_utf8(reference.to_vec()).unwrap_or_else(|_| hex::encode(reference))
407}
408
409pub 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#[derive(Debug, Default, Clone)]
426pub struct ActionRules {
427 pub by_txid: HashMap<String, String>,
428 pub by_template: HashMap<String, String>,
432}
433
434pub fn template_key(status: &str, description: &str, satoshis: i64) -> String {
436 format!("{status}\u{1f}{description}\u{1f}{satoshis}")
437}
438
439pub 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
487pub 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 _ => 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
519async 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
560const FROZEN: &str = "frozen: never offered";
562
563async 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
600pub 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
672pub 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 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
763fn 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
774async 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 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
805pub 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
835async 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
858pub 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 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 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;