use std::collections::HashMap;
use anyhow::Result;
use bsv_sdk::wallet::{CreateActionArgs, CreateActionResult, WalletInterface};
use bsv_tracker::Height;
use bsv_wallet_toolbox::{StorageSqlx, Wallet, WalletServices};
use serde::Serialize;
use sqlx::{Row, SqlitePool};
use crate::tracker_host::{read_creators, CreatorReading, TickOptions};
pub const DEFAULT_SPEND_OWN_CHANGE: bool = true;
pub const STALE_HOLD_SECS: i64 = 600;
const BATCH: usize = 32;
const FEE_MARGIN: u64 = 10_000;
pub const RECEIVED_PENDING: &str = "received-pending";
pub const ALLOW_PENDING_RECEIVED: &str = "received";
pub const ALLOW_PENDING_HINT: &str = "offered under allowPending: received";
pub const NAMED_INPUT_REFUSED: &str = "an input the spend guard does not offer";
#[derive(Debug, Clone)]
pub struct GateOptions {
pub spend_depth: Height,
pub own_change: bool,
pub allow_received: bool,
}
impl Default for GateOptions {
fn default() -> Self {
Self {
spend_depth: crate::tracker_host::DEFAULT_SPEND_DEPTH,
own_change: DEFAULT_SPEND_OWN_CHANGE,
allow_received: false,
}
}
}
pub fn allow_pending(word: Option<&str>) -> bool {
word == Some(ALLOW_PENDING_RECEIVED)
}
impl GateOptions {
pub fn from_env() -> Self {
let own_change = std::env::var("SPEND_OWN_CHANGE")
.map(|v| {
!matches!(
v.trim().to_ascii_lowercase().as_str(),
"0" | "false" | "no" | "off"
)
})
.unwrap_or(DEFAULT_SPEND_OWN_CHANGE);
Self {
spend_depth: TickOptions::from_env().spend_depth,
own_change,
allow_received: false,
}
}
pub fn with_allow_received(mut self, allow: bool) -> Self {
self.allow_received = allow;
self
}
fn tick(&self) -> TickOptions {
TickOptions {
spend_depth: self.spend_depth,
..TickOptions::from_env()
}
.floored()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Origin {
Made,
Received,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct HeldCoin {
pub outpoint: String,
pub satoshis: u64,
pub word: String,
pub origin: Origin,
pub age_minutes: i64,
pub reason: String,
#[serde(skip)]
output_id: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Refusal {
pub toolbox: String,
pub offered_satoshis: u64,
pub held: Vec<HeldCoin>,
}
impl std::fmt::Display for Refusal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.toolbox == NAMED_INPUT_REFUSED {
write!(
f,
"Access denied: {} ({} coin(s) held back)",
self.toolbox,
self.held.len()
)?;
} else {
write!(
f,
"Insufficient funds: no offerable coin covers the action ({}; {} sats offered, {} coin(s) held back)",
self.toolbox,
self.offered_satoshis,
self.held.len()
)?;
}
for c in &self.held {
write!(
f,
"\n {} {} sats: {}, {}, {} min old: {}",
c.outpoint,
c.satoshis,
c.word,
match c.origin {
Origin::Made => "made",
Origin::Received => "received",
},
c.age_minutes,
c.reason
)?;
}
Ok(())
}
}
impl std::error::Error for Refusal {}
impl Refusal {
pub fn is_named_input(&self) -> bool {
self.toolbox == NAMED_INPUT_REFUSED
}
}
#[derive(Debug)]
pub enum GateError {
Refused(Refusal),
Wallet(bsv_sdk::Error),
Gate(anyhow::Error),
}
impl std::fmt::Display for GateError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Refused(r) => r.fmt(f),
Self::Wallet(e) => e.fmt(f),
Self::Gate(e) => write!(f, "Storage error: spend gate: {e}"),
}
}
}
impl std::error::Error for GateError {}
#[derive(Debug, Clone)]
struct Candidate {
output_id: i64,
txid: String,
vout: i64,
satoshis: u64,
origin: Origin,
age_minutes: i64,
proof: Option<Vec<u8>>,
frozen: bool,
}
#[derive(Debug, Default, Clone)]
pub struct Assessment {
pub offered: Vec<(i64, u64)>,
pub held: Vec<HeldCoin>,
pub unread: Vec<i64>,
}
impl Assessment {
pub fn offered_satoshis(&self) -> u64 {
self.offered.iter().map(|(_, s)| s).sum()
}
}
async fn ensure_schema(pool: &SqlitePool) -> Result<()> {
sqlx::query(
"CREATE TABLE IF NOT EXISTS spend_gate_holds (\
output_id INTEGER PRIMARY KEY, \
holder TEXT NOT NULL, \
held_at INTEGER NOT NULL)",
)
.execute(pool)
.await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS spend_gate_actions (\
txid TEXT PRIMARY KEY, \
rule TEXT NOT NULL, \
recorded_at INTEGER NOT NULL, \
reference TEXT)",
)
.execute(pool)
.await?;
let has_reference: Option<String> = sqlx::query_scalar(
"SELECT name FROM pragma_table_info('spend_gate_actions') WHERE name = 'reference'",
)
.fetch_optional(pool)
.await?;
if has_reference.is_none() {
sqlx::query("ALTER TABLE spend_gate_actions ADD COLUMN reference TEXT")
.execute(pool)
.await?;
}
sqlx::query(
"CREATE UNIQUE INDEX IF NOT EXISTS spend_gate_actions_reference \
ON spend_gate_actions (reference)",
)
.execute(pool)
.await?;
Ok(())
}
pub async fn record_rule(pool: &SqlitePool, txid: &str, rule: &str) -> Result<()> {
ensure_schema(pool).await?;
sqlx::query(
"INSERT INTO spend_gate_actions (txid, rule, recorded_at) VALUES (?, ?, ?) \
ON CONFLICT(txid) DO NOTHING",
)
.bind(txid.to_ascii_lowercase())
.bind(rule)
.bind(chrono::Utc::now().timestamp())
.execute(pool)
.await?;
Ok(())
}
pub async fn record_rule_by_reference(
pool: &SqlitePool,
reference: &str,
rule: &str,
) -> Result<()> {
ensure_schema(pool).await?;
sqlx::query(
"INSERT INTO spend_gate_actions (txid, rule, recorded_at, reference) VALUES (NULL, ?, ?, ?) \
ON CONFLICT(reference) DO NOTHING",
)
.bind(rule)
.bind(chrono::Utc::now().timestamp())
.bind(reference)
.execute(pool)
.await?;
Ok(())
}
pub async fn fill_txid(pool: &SqlitePool, reference: &str, txid: &str) -> Result<()> {
ensure_schema(pool).await?;
sqlx::query("UPDATE spend_gate_actions SET txid = ? WHERE reference = ? AND txid IS NULL")
.bind(txid.to_ascii_lowercase())
.bind(reference)
.execute(pool)
.await?;
Ok(())
}
pub fn reference_string(reference: &[u8]) -> String {
String::from_utf8(reference.to_vec()).unwrap_or_else(|_| hex::encode(reference))
}
pub async fn rules_of(pool: &SqlitePool) -> Result<HashMap<String, String>> {
ensure_schema(pool).await?;
let rows = sqlx::query("SELECT txid, rule FROM spend_gate_actions WHERE txid IS NOT NULL")
.fetch_all(pool)
.await?;
Ok(rows
.iter()
.map(|r| (r.get::<String, _>("txid"), r.get::<String, _>("rule")))
.collect())
}
#[derive(Debug, Default, Clone)]
pub struct ActionRules {
pub by_txid: HashMap<String, String>,
pub by_template: HashMap<String, String>,
}
pub fn template_key(status: &str, description: &str, satoshis: i64) -> String {
format!("{status}\u{1f}{description}\u{1f}{satoshis}")
}
pub async fn action_rules(pool: &SqlitePool) -> Result<ActionRules> {
let by_txid = rules_of(pool).await?;
let rows = sqlx::query(
"SELECT t.status, t.description, t.satoshis, g.rule FROM transactions t \
LEFT JOIN spend_gate_actions g ON g.reference = t.reference AND g.txid IS NULL \
WHERE t.txid IS NULL",
)
.fetch_all(pool)
.await?;
let mut shapes: HashMap<String, Option<String>> = HashMap::new();
let mut ambiguous = std::collections::HashSet::new();
for r in &rows {
let key = template_key(
&r.get::<String, _>("status"),
&r.get::<String, _>("description"),
r.get::<i64, _>("satoshis"),
);
let rule: Option<String> = r.get("rule");
match shapes.get(&key) {
Some(seen) if *seen != rule => {
ambiguous.insert(key);
}
Some(_) => {}
None => {
shapes.insert(key, rule);
}
}
}
let by_template = shapes
.into_iter()
.filter(|(k, _)| !ambiguous.contains(k))
.filter_map(|(k, rule)| rule.map(|r| (k, r)))
.collect();
Ok(ActionRules {
by_txid,
by_template,
})
}
fn blocks(n: u64) -> String {
if n == 1 {
"1 block".to_string()
} else {
format!("{n} blocks")
}
}
pub fn hold_reason(reading: &CreatorReading, origin: Origin, opts: &GateOptions) -> Option<String> {
if let Some(fault) = &reading.fault {
return Some(format!("headers unavailable: {fault}"));
}
if reading.offered {
return None;
}
let depth = format!(
"depth {}: {}",
opts.spend_depth
.max(crate::tracker_host::DEFAULT_SPEND_DEPTH),
blocks(reading.depth)
);
match reading.word {
"mined" if reading.suspect => Some("suspect: a fork at or below its height".to_string()),
"mined" => Some(depth),
"stale" | "rejected" | "conflicted" | "abandoned" => {
Some(format!("{}: never offered", reading.word))
}
_ => match origin {
Origin::Received if opts.allow_received => None,
Origin::Received => Some(format!("{depth}; received, no proof; {ALLOW_PENDING_HINT}")),
Origin::Made if opts.own_change => None,
Origin::Made => Some(format!("{depth}; own change, SPEND_OWN_CHANGE=0")),
},
}
}
async fn candidates(pool: &SqlitePool) -> Result<Vec<Candidate>> {
let frozen = crate::tracker_host::frozen_outpoints(pool).await?;
let rows = sqlx::query(
"SELECT o.output_id, t.txid, o.vout, o.satoshis, t.is_outgoing, \
CAST((julianday('now') - julianday(o.created_at)) * 1440 AS INTEGER) AS age, \
p.merkle_path \
FROM outputs o \
JOIN transactions t ON o.transaction_id = t.transaction_id \
JOIN output_baskets b ON o.basket_id = b.basket_id AND b.name = 'default' \
LEFT JOIN proven_txs p ON p.txid = t.txid \
WHERE o.change = 1 AND o.spent_by IS NULL AND t.txid IS NOT NULL \
AND t.status IN ('completed', 'unproven') \
ORDER BY CASE WHEN t.status = 'completed' THEN 0 ELSE 1 END, o.satoshis DESC",
)
.fetch_all(pool)
.await?;
Ok(rows
.iter()
.map(|r| Candidate {
output_id: r.get("output_id"),
txid: r.get::<String, _>("txid").to_ascii_lowercase(),
vout: r.get("vout"),
satoshis: r.get::<i64, _>("satoshis").max(0) as u64,
origin: if r.get::<i64, _>("is_outgoing") != 0 {
Origin::Made
} else {
Origin::Received
},
age_minutes: r.get::<Option<i64>, _>("age").unwrap_or(0).max(0),
proof: r.get("merkle_path"),
frozen: frozen.contains(&(
r.get::<String, _>("txid").to_ascii_lowercase(),
r.get::<i64, _>("vout"),
)),
})
.collect())
}
const FROZEN: &str = "frozen: never offered";
async fn locked(pool: &SqlitePool) -> Result<Vec<HeldCoin>> {
let rows = sqlx::query(
"SELECT o.output_id, t.txid, o.vout, o.satoshis, t.is_outgoing, \
CAST((julianday('now') - julianday(o.created_at)) * 1440 AS INTEGER) AS age, \
COALESCE(s.txid, s.reference) AS holder \
FROM outputs o \
JOIN transactions t ON o.transaction_id = t.transaction_id \
JOIN transactions s ON o.spent_by = s.transaction_id \
JOIN output_baskets b ON o.basket_id = b.basket_id AND b.name = 'default' \
WHERE o.change = 1 AND s.status NOT IN ('completed', 'failed') \
AND t.status IN ('completed', 'unproven')",
)
.fetch_all(pool)
.await?;
Ok(rows
.iter()
.map(|r| HeldCoin {
outpoint: format!("{}.{}", r.get::<String, _>("txid"), r.get::<i64, _>("vout")),
satoshis: r.get::<i64, _>("satoshis").max(0) as u64,
word: "locked".to_string(),
origin: if r.get::<i64, _>("is_outgoing") != 0 {
Origin::Made
} else {
Origin::Received
},
age_minutes: r.get::<Option<i64>, _>("age").unwrap_or(0).max(0),
reason: format!(
"locked by {}",
r.get::<Option<String>, _>("holder").unwrap_or_default()
),
output_id: r.get("output_id"),
})
.collect())
}
pub async fn assess<V: WalletServices + ?Sized>(
storage: &StorageSqlx,
services: &V,
opts: &GateOptions,
need: Option<u64>,
) -> Result<Assessment> {
let all = candidates(storage.pool()).await?;
let tick = opts.tick();
let mut by_creator: Vec<(String, Option<Vec<u8>>)> = Vec::new();
for c in &all {
if !by_creator.iter().any(|(t, _)| t == &c.txid) {
by_creator.push((c.txid.clone(), c.proof.clone()));
}
}
let mut readings: HashMap<String, CreatorReading> = HashMap::new();
let mut out = Assessment::default();
let mut next = 0;
for c in &all {
let covered = need.is_some_and(|need| out.offered_satoshis() >= need);
if c.frozen {
out.held.push(HeldCoin {
outpoint: format!("{}.{}", c.txid, c.vout),
satoshis: c.satoshis,
word: "frozen".to_string(),
origin: c.origin,
age_minutes: c.age_minutes,
reason: FROZEN.to_string(),
output_id: c.output_id,
});
continue;
}
if !readings.contains_key(&c.txid) {
if covered {
out.unread.push(c.output_id);
continue;
}
let end = if need.is_some() {
(next + BATCH).min(by_creator.len())
} else {
by_creator.len()
};
let batch: Vec<_> = by_creator[next..end]
.iter()
.filter(|(t, _)| !readings.contains_key(t))
.cloned()
.collect();
next = end;
readings.extend(read_creators(storage, services, &tick, &batch).await?);
}
let Some(reading) = readings.get(&c.txid) else {
out.unread.push(c.output_id);
continue;
};
match hold_reason(reading, c.origin, opts) {
None if !covered => out.offered.push((c.output_id, c.satoshis)),
None => out.unread.push(c.output_id),
Some(reason) => out.held.push(HeldCoin {
outpoint: format!("{}.{}", c.txid, c.vout),
satoshis: c.satoshis,
word: reading.word.to_string(),
origin: c.origin,
age_minutes: c.age_minutes,
reason,
output_id: c.output_id,
}),
}
}
Ok(out)
}
pub async fn assess_named<V: WalletServices + ?Sized>(
storage: &StorageSqlx,
services: &V,
opts: &GateOptions,
named: &[(String, u32)],
reserved_to: Option<i64>,
) -> Result<Vec<HeldCoin>> {
let pool = storage.pool();
let frozen = crate::tracker_host::frozen_outpoints(pool).await?;
let mut coins = Vec::new();
for (txid, vout) in named {
let txid = txid.to_ascii_lowercase();
let row = sqlx::query(
"SELECT o.output_id, o.satoshis, o.spent_by, t.is_outgoing, \
CAST((julianday('now') - julianday(o.created_at)) * 1440 AS INTEGER) AS age, \
COALESCE(s.txid, s.reference) AS holder, p.merkle_path \
FROM outputs o \
JOIN transactions t ON o.transaction_id = t.transaction_id \
LEFT JOIN transactions s ON o.spent_by = s.transaction_id \
LEFT JOIN proven_txs p ON p.txid = t.txid \
WHERE lower(t.txid) = ? AND o.vout = ?",
)
.bind(&txid)
.bind(i64::from(*vout))
.fetch_optional(pool)
.await?;
let Some(r) = row else { continue };
coins.push((txid, *vout, r));
}
let is_frozen = |txid: &str, vout: u32| frozen.contains(&(txid.to_string(), i64::from(vout)));
let is_locked = |r: &sqlx::sqlite::SqliteRow| {
let spent_by: Option<i64> = r.get("spent_by");
spent_by.is_some() && spent_by != reserved_to
};
let creators: Vec<(String, Option<Vec<u8>>)> = coins
.iter()
.filter(|(t, v, r)| !is_frozen(t, *v) && !is_locked(r))
.map(|(t, _, r)| (t.clone(), r.get("merkle_path")))
.collect();
let readings = if creators.is_empty() {
HashMap::new()
} else {
read_creators(storage, services, &opts.tick(), &creators).await?
};
let mut held = Vec::new();
for (txid, vout, r) in coins {
let origin = if r.get::<i64, _>("is_outgoing") != 0 {
Origin::Made
} else {
Origin::Received
};
let reading = readings.get(&txid);
let (word, reason) = if is_frozen(&txid, vout) {
("frozen".to_string(), Some(FROZEN.to_string()))
} else if is_locked(&r) {
(
"locked".to_string(),
Some(format!(
"locked by {}",
r.get::<Option<String>, _>("holder").unwrap_or_default()
)),
)
} else {
match reading {
Some(reading) => (reading.word.to_string(), hold_reason(reading, origin, opts)),
None => ("unknown".to_string(), Some("not read".to_string())),
}
};
if let Some(reason) = reason {
held.push(HeldCoin {
outpoint: format!("{txid}.{vout}"),
satoshis: r.get::<i64, _>("satoshis").max(0) as u64,
word,
origin,
age_minutes: r.get::<Option<i64>, _>("age").unwrap_or(0).max(0),
reason,
output_id: r.get("output_id"),
});
}
}
Ok(held)
}
fn holder() -> String {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
format!(
"{}:{}",
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
)
}
async fn hold(pool: &SqlitePool, holder: &str, ids: &[i64]) -> Result<()> {
ensure_schema(pool).await?;
let now = chrono::Utc::now().timestamp();
let mut tx = pool.begin().await?;
for id in ids {
let held = sqlx::query(
"UPDATE outputs SET change = 0 WHERE output_id = ? AND change = 1 \
AND output_id NOT IN (SELECT output_id FROM spend_gate_holds)",
)
.bind(id)
.execute(&mut *tx)
.await?
.rows_affected();
if held == 1 {
sqlx::query(
"INSERT INTO spend_gate_holds (output_id, holder, held_at) VALUES (?, ?, ?)",
)
.bind(id)
.bind(holder)
.bind(now)
.execute(&mut *tx)
.await?;
}
}
tx.commit().await?;
Ok(())
}
pub async fn restore(pool: &SqlitePool, holder: Option<&str>) -> Result<u64> {
ensure_schema(pool).await?;
let stale = chrono::Utc::now().timestamp() - STALE_HOLD_SECS;
let mut tx = pool.begin().await?;
let which = "SELECT output_id FROM spend_gate_holds WHERE holder = ? OR held_at < ?";
let restored = sqlx::query(&format!(
"UPDATE outputs SET change = 1 WHERE change = 0 AND output_id IN ({which})"
))
.bind(holder.unwrap_or(""))
.bind(stale)
.execute(&mut *tx)
.await?
.rows_affected();
sqlx::query("DELETE FROM spend_gate_holds WHERE holder = ? OR held_at < ?")
.bind(holder.unwrap_or(""))
.bind(stale)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(restored)
}
fn is_insufficient(e: &bsv_sdk::Error) -> bool {
e.to_string()
.to_ascii_lowercase()
.contains("insufficient funds")
}
async fn run_held<V: WalletServices + Send + Sync + 'static>(
wallet: &Wallet<StorageSqlx, V>,
args: CreateActionArgs,
originator: &str,
assessment: &Assessment,
) -> std::result::Result<bsv_sdk::Result<CreateActionResult>, GateError> {
let pool = wallet.storage().pool();
let holder = holder();
let ids: Vec<i64> = assessment
.held
.iter()
.map(|c| c.output_id)
.chain(assessment.unread.iter().copied())
.collect();
hold(pool, &holder, &ids).await.map_err(GateError::Gate)?;
let outcome = wallet.create_action(args, originator).await;
restore(pool, Some(&holder))
.await
.map_err(GateError::Gate)?;
Ok(outcome)
}
pub async fn create_action<V: WalletServices + Send + Sync + 'static>(
wallet: &Wallet<StorageSqlx, V>,
args: CreateActionArgs,
originator: &str,
opts: &GateOptions,
) -> std::result::Result<CreateActionResult, GateError> {
let storage = wallet.storage();
restore(storage.pool(), None)
.await
.map_err(GateError::Gate)?;
let named: Vec<(String, u32)> = args
.inputs
.iter()
.flatten()
.map(|i| {
(
bsv_sdk::primitives::to_hex(&i.outpoint.txid),
i.outpoint.vout,
)
})
.collect();
if !named.is_empty() {
let held = assess_named(storage, wallet.services(), opts, &named, None)
.await
.map_err(GateError::Gate)?;
if !held.is_empty() {
return Err(GateError::Refused(Refusal {
toolbox: NAMED_INPUT_REFUSED.to_string(),
offered_satoshis: 0,
held,
}));
}
}
let result = run_gated(wallet, args, originator, opts).await?;
if opts.allow_received {
if let Some(txid) = result.txid.as_ref() {
record_rule(
storage.pool(),
&bsv_sdk::primitives::to_hex(txid),
RECEIVED_PENDING,
)
.await
.map_err(GateError::Gate)?;
} else if let Some(st) = result.signable_transaction.as_ref() {
record_rule_by_reference(
storage.pool(),
&reference_string(&st.reference),
RECEIVED_PENDING,
)
.await
.map_err(GateError::Gate)?;
}
}
Ok(result)
}
async fn run_gated<V: WalletServices + Send + Sync + 'static>(
wallet: &Wallet<StorageSqlx, V>,
args: CreateActionArgs,
originator: &str,
opts: &GateOptions,
) -> std::result::Result<CreateActionResult, GateError> {
let storage = wallet.storage();
let need = args
.outputs
.as_ref()
.map(|o| o.iter().map(|x| x.satoshis).sum::<u64>())
.unwrap_or(0)
.saturating_add(FEE_MARGIN);
let first = assess(storage, wallet.services(), opts, Some(need))
.await
.map_err(GateError::Gate)?;
let lazy = !first.unread.is_empty();
match run_held(wallet, args.clone(), originator, &first).await? {
Ok(result) => return Ok(result),
Err(e) if !is_insufficient(&e) => return Err(GateError::Wallet(e)),
Err(e) if !lazy => return Err(refusal(storage, &first, &e).await),
Err(_) => {}
}
let full = assess(storage, wallet.services(), opts, None)
.await
.map_err(GateError::Gate)?;
match run_held(wallet, args, originator, &full).await? {
Ok(result) => Ok(result),
Err(e) if is_insufficient(&e) => Err(refusal(storage, &full, &e).await),
Err(e) => Err(GateError::Wallet(e)),
}
}
async fn refusal(storage: &StorageSqlx, assessment: &Assessment, e: &bsv_sdk::Error) -> GateError {
let mut held = assessment.held.clone();
match locked(storage.pool()).await {
Ok(mut l) => held.append(&mut l),
Err(fault) => return GateError::Gate(fault),
}
let toolbox = e.to_string();
GateError::Refused(Refusal {
toolbox: toolbox
.strip_prefix("wallet error: ")
.unwrap_or(&toolbox)
.to_string(),
offered_satoshis: assessment.offered_satoshis(),
held,
})
}
#[cfg(test)]
mod tests;