use crate::beef_limits;
use std::collections::{HashMap, HashSet};
use std::rc::Rc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use async_trait::async_trait;
use bsv_rs::transaction::{Beef, ChainTracker, ChainTrackerError, MerklePath, Transaction};
use tracing::{debug, error, warn};
use crate::gasp::{GASPError, GASPStorage};
use crate::storage::Storage;
use crate::topic_manager::TopicManager;
use crate::types::{
GASPInputRequest, GASPNode, GASPNodeResponse, GASPOutput, SubmitMode, TopicAdmittanceContext,
};
const LANDED_READS_PER_NODE: usize = 16;
#[derive(Debug, Clone)]
struct PendingNode {
node: GASPNode,
#[allow(dead_code)]
spent_by: Option<String>,
children: Vec<String>, }
#[derive(Debug, Clone)]
pub struct FinalizedGraph {
pub topic: String,
pub beefs: Vec<Vec<u8>>,
}
pub type FinalizedGraphSink = Rc<Mutex<Vec<FinalizedGraph>>>;
pub fn new_finalized_graph_sink() -> FinalizedGraphSink {
Rc::new(Mutex::new(Vec::new()))
}
pub struct OverlayGASPStorage<'a> {
storage: &'a dyn Storage,
topic: String,
topic_manager: Option<&'a dyn TopicManager>,
pending_graphs: Mutex<HashMap<String, PendingNode>>,
finalized_sink: FinalizedGraphSink,
strict_beef: bool,
chain_tracker: Option<&'a dyn ChainTracker>,
verify_scripts: bool,
walk_budget: crate::engine::DoorBudget,
peer: String,
configured_peer: bool,
anchored: Mutex<Option<AnchoredGraph>>,
}
struct AnchoredGraph {
graph_id: String,
strict_beef: bool,
order: Vec<String>,
assembly: GraphAssembly,
}
struct FaultNotingTracker<'a> {
inner: &'a dyn ChainTracker,
faulted: AtomicBool,
}
#[async_trait]
impl ChainTracker for FaultNotingTracker<'_> {
async fn is_valid_root_for_height(
&self,
root: &str,
height: u32,
) -> Result<bool, ChainTrackerError> {
let answer = self.inner.is_valid_root_for_height(root, height).await;
if answer.is_err() {
self.faulted.store(true, Ordering::Relaxed);
}
answer
}
async fn current_height(&self) -> Result<u32, ChainTrackerError> {
self.inner.current_height().await
}
}
struct AnchorInput {
root_txid: String,
root_beef: Vec<u8>,
absent_sources: Vec<(String, u32)>,
order: Vec<String>,
assembly: GraphAssembly,
}
struct ParsedTx {
tx: Transaction,
txid: String,
}
struct AssemblyNode {
parsed: Rc<ParsedTx>,
proof: Option<MerklePath>,
sources: Vec<Option<String>>,
}
#[derive(Default)]
struct GraphAssembly {
txs: HashMap<[u8; 32], Rc<ParsedTx>>,
parsed: HashMap<String, Rc<ParsedTx>>,
nodes: HashMap<String, Rc<AssemblyNode>>,
checked: HashSet<(String, bool)>,
}
impl GraphAssembly {
fn parsed_tx(
&mut self,
key: &str,
refs: &HashMap<String, PendingNode>,
) -> Result<Rc<ParsedTx>, GASPError> {
if let Some(parsed) = self.parsed.get(key) {
return Ok(parsed.clone());
}
let pending = refs
.get(key)
.ok_or_else(|| GASPError::Other(format!("Node {key} not found in graph refs")))?;
let unparsed =
|e: bsv_rs::Error| GASPError::Other(format!("Failed to parse raw_tx for {key}: {e}"));
let raw = bsv_rs::primitives::from_hex(&pending.node.raw_tx).map_err(unparsed)?;
let parsed = match self.txs.entry(bsv_rs::primitives::hash::sha256(&raw)) {
std::collections::hash_map::Entry::Occupied(known) => known.get().clone(),
std::collections::hash_map::Entry::Vacant(new) => {
let tx = Transaction::from_binary(&raw).map_err(unparsed)?;
let txid = tx.id();
new.insert(Rc::new(ParsedTx { tx, txid })).clone()
}
};
self.parsed.insert(key.to_string(), parsed.clone());
Ok(parsed)
}
fn node(
&mut self,
key: &str,
refs: &HashMap<String, PendingNode>,
) -> Result<Rc<AssemblyNode>, GASPError> {
if let Some(node) = self.nodes.get(key) {
return Ok(node.clone());
}
let parsed = self.parsed_tx(key, refs)?;
let proof =
match refs
.get(key)
.and_then(|pending| pending.node.proof.as_ref())
{
Some(proof_hex) => Some(beef_limits::proof_from_hex(proof_hex).map_err(|e| {
GASPError::Other(format!("Failed to parse proof for {key}: {e}"))
})?),
None => None,
};
let sources = if proof.is_some() {
Vec::new()
} else {
parsed
.tx
.inputs
.iter()
.map(|input| {
let source_txid = input.get_source_txid().unwrap_or_default();
if source_txid.is_empty() {
return None;
}
let child_key = format!("{}.{}", source_txid, input.source_output_index);
refs.contains_key(&child_key).then_some(child_key)
})
.collect()
};
let node = Rc::new(AssemblyNode {
parsed,
proof,
sources,
});
self.nodes.insert(key.to_string(), node.clone());
Ok(node)
}
fn check(
&mut self,
key: &str,
refs: &HashMap<String, PendingNode>,
strict: bool,
) -> Result<(), GASPError> {
let mut stack: Vec<(String, Rc<AssemblyNode>, usize)> = Vec::new();
let mut inside: HashSet<String> = HashSet::new();
let mut entering: Option<String> = Some(key.to_string());
loop {
if let Some(key) = entering.take() {
if !self.checked.contains(&(key.clone(), strict)) && inside.insert(key.clone()) {
let node = self.node(&key, refs)?;
stack.push((key, node, 0));
}
}
let Some((_, node, next)) = stack.last_mut() else {
return Ok(());
};
if let Some(source) = node.sources.get(*next) {
*next += 1;
entering.clone_from(source);
continue;
}
let (key, node, _) = stack.pop().expect("the stack is not empty");
if strict {
if let Some(missing) = node.sources.iter().rposition(Option::is_none) {
let source_txid = node.parsed.tx.inputs[missing]
.source_txid
.as_deref()
.unwrap_or("unknown");
let e = bsv_rs::Error::TransactionError(format!(
"Missing source transaction for input {missing} (txid: {source_txid}). \
Set allow_partial=true to skip missing source transactions."
));
return Err(GASPError::Other(format!(
"Failed to serialize BEEF for {key}: {e}"
)));
}
}
inside.remove(&key);
self.checked.insert((key, strict));
}
}
fn txid_of(&self, key: &str) -> Option<&str> {
self.nodes.get(key).map(|node| node.parsed.txid.as_str())
}
fn beef_of(&self, key: &str) -> Vec<u8> {
struct Frame<'n> {
node: &'n AssemblyNode,
inputs_left: usize,
}
let mut seen: HashSet<&str> = HashSet::new();
let mut inside: HashSet<&str> = HashSet::new();
let mut ancestors: Vec<&AssemblyNode> = Vec::new();
let mut bumps: Vec<MerklePath> = Vec::new();
let mut bump_index_by_root: HashMap<String, usize> = HashMap::new();
let mut stack: Vec<Frame> = Vec::new();
let mut entering: Option<&AssemblyNode> = self.nodes.get(key).map(Rc::as_ref);
loop {
if let Some(node) = entering.take() {
let txid = node.parsed.txid.as_str();
if seen.contains(txid) || inside.contains(txid) {
} else if let Some(merkle_path) = &node.proof {
let root = merkle_path.compute_root(Some(txid)).unwrap_or_default();
let slot = format!("{}:{}", merkle_path.block_height, root);
match bump_index_by_root.get(&slot) {
Some(&existing) if bumps[existing].combine(merkle_path).is_ok() => {}
_ => {
bump_index_by_root.insert(slot, bumps.len());
bumps.push(merkle_path.clone());
}
}
seen.insert(txid);
ancestors.push(node);
} else {
inside.insert(txid);
stack.push(Frame {
node,
inputs_left: node.sources.len(),
});
}
}
let Some(top) = stack.last_mut() else {
break;
};
if top.inputs_left == 0 {
let node = stack.pop().expect("the stack is not empty").node;
inside.remove(node.parsed.txid.as_str());
seen.insert(node.parsed.txid.as_str());
ancestors.push(node);
continue;
}
top.inputs_left -= 1;
if let Some(source) = &top.node.sources[top.inputs_left] {
entering = self.nodes.get(source).map(Rc::as_ref);
}
}
let mut beef = Beef::new();
for bump in bumps {
beef.merge_bump(bump);
}
for node in ancestors {
beef.merge_transaction(node.parsed.tx.clone());
}
let mut bytes = beef.to_binary();
bytes.shrink_to_fit();
bytes
}
}
impl<'a> OverlayGASPStorage<'a> {
pub fn new(
storage: &'a dyn Storage,
topic: impl Into<String>,
sink: FinalizedGraphSink,
) -> Self {
Self {
storage,
topic: topic.into(),
topic_manager: None,
pending_graphs: Mutex::new(HashMap::new()),
finalized_sink: sink,
strict_beef: false,
chain_tracker: None,
verify_scripts: true,
walk_budget: crate::engine::DoorBudget::DEFAULT,
peer: String::new(),
configured_peer: false,
anchored: Mutex::new(None),
}
}
fn keep_anchored(&self, graph_id: &str, order: Vec<String>, assembly: GraphAssembly) {
*self
.anchored
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(AnchoredGraph {
graph_id: graph_id.to_string(),
strict_beef: self.strict_beef,
order,
assembly,
});
}
fn take_anchored(&self) -> Option<AnchoredGraph> {
self.anchored
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take()
}
#[must_use]
pub fn with_peer(mut self, peer: impl Into<String>) -> Self {
self.peer = peer.into();
self
}
#[must_use]
pub fn with_configured_peer(mut self, configured: bool) -> Self {
self.configured_peer = configured;
self
}
#[must_use]
pub fn with_chain_tracker(mut self, tracker: &'a dyn ChainTracker) -> Self {
self.chain_tracker = Some(tracker);
self
}
#[must_use]
pub fn with_script_verification(mut self, enabled: bool) -> Self {
self.verify_scripts = enabled;
self
}
#[must_use]
pub fn with_walk_budget(mut self, budget: crate::engine::DoorBudget) -> Self {
self.walk_budget = budget;
self
}
#[must_use]
pub fn with_topic_manager(mut self, manager: &'a dyn TopicManager) -> Self {
self.topic_manager = Some(manager);
self
}
#[must_use]
pub fn with_strict_beef(mut self, strict: bool) -> Self {
self.strict_beef = strict;
self
}
pub fn take_finalized_graphs(&self) -> Vec<FinalizedGraph> {
self.finalized_sink
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.drain(..)
.collect()
}
#[cfg(test)]
pub fn pending_graph_count(&self) -> usize {
let refs = self.pending_graphs.lock().unwrap();
let graph_ids: std::collections::HashSet<&str> =
refs.values().map(|pn| pn.node.graph_id.as_str()).collect();
graph_ids.len()
}
#[cfg(test)]
pub fn finalized_graph_count(&self) -> usize {
self.finalized_sink.lock().unwrap().len()
}
fn ordered_keys(graph_id: &str, refs: &HashMap<String, PendingNode>) -> Vec<String> {
let mut order: Vec<String> = Vec::new();
let mut visited: HashSet<&str> = HashSet::new();
let mut stack: Vec<(&str, &[String], usize)> = Vec::new();
let mut entering: Option<&str> = Some(graph_id);
loop {
if let Some(key) = entering.take() {
if visited.insert(key) {
if let Some(pending) = refs.get(key) {
stack.push((key, &pending.children, 0));
}
}
}
let Some((key, children, next)) = stack.last_mut() else {
return order;
};
if let Some(child) = children.get(*next) {
*next += 1;
entering = Some(child.as_str());
} else {
order.push((*key).to_string());
stack.pop();
}
}
}
fn compute_ordered_beefs(
graph_id: &str,
refs: &HashMap<String, PendingNode>,
strict_beef: bool,
anchored: Option<AnchoredGraph>,
) -> Result<Vec<Vec<u8>>, GASPError> {
let (order, assembly) = match anchored {
Some(kept) if kept.graph_id == graph_id && kept.strict_beef == strict_beef => {
(kept.order, kept.assembly)
}
_ => {
let mut assembly = GraphAssembly::default();
let order = Self::ordered_keys(graph_id, refs);
for key in &order {
assembly.check(key, refs, strict_beef)?;
}
(order, assembly)
}
};
Ok(order.iter().map(|key| assembly.beef_of(key)).collect())
}
fn read_anchor_input(
graph_id: &str,
refs: &HashMap<String, PendingNode>,
strict_beef: bool,
) -> Result<AnchorInput, GASPError> {
let unassembled = |e: GASPError| {
GASPError::AnchorUnavailable(format!("graph {graph_id} cannot be assembled: {e}"))
};
if !refs.contains_key(graph_id) {
return Err(GASPError::ValidationFailed(format!(
"Graph node with ID {graph_id} not found"
)));
}
let mut assembly = GraphAssembly::default();
let mut absent_sources: Vec<(String, u32)> = Vec::new();
let mut visited: HashSet<&str> = HashSet::new();
let mut stack: Vec<(&str, bool)> = vec![(graph_id, true)];
while let Some((key, in_root_beef)) = stack.pop() {
if !visited.insert(key) {
continue;
}
let Some(pending) = refs.get(key) else {
continue;
};
let parsed = assembly.parsed_tx(key, refs).map_err(unassembled)?;
let spends: HashSet<String> = parsed
.tx
.inputs
.iter()
.filter_map(|input| {
let source_txid = input.get_source_txid().ok()?;
Some(format!("{}.{}", source_txid, input.source_output_index))
})
.collect();
for child in &pending.children {
if !spends.contains(child) {
return Err(GASPError::ValidationFailed(format!(
"node {child} is not an input of its declared parent {key}"
)));
}
}
let unproven = pending.node.proof.is_none();
if in_root_beef && unproven {
for outpoint in &spends {
if !refs.contains_key(outpoint) {
if let Some(source) = crate::gasp::parse_outpoint(outpoint) {
absent_sources.push(source);
}
}
}
}
for child in &pending.children {
stack.push((child.as_str(), in_root_beef && unproven));
}
}
assembly.check(graph_id, refs, false).map_err(unassembled)?;
let root_beef = assembly.beef_of(graph_id);
let root_txid = assembly.txid_of(graph_id).unwrap_or_default().to_string();
let order = Self::ordered_keys(graph_id, refs);
for key in &order {
assembly
.check(key, refs, strict_beef)
.map_err(unassembled)?;
}
Ok(AnchorInput {
root_txid,
root_beef,
absent_sources,
order,
assembly,
})
}
}
#[async_trait(?Send)]
impl GASPStorage for OverlayGASPStorage<'_> {
async fn find_known_utxos(
&self,
since: u64,
limit: Option<u64>,
) -> Result<Vec<GASPOutput>, GASPError> {
let outputs = self
.storage
.find_utxos_for_topic(
&self.topic,
Some(since as f64),
limit,
false, )
.await
.map_err(|e| GASPError::StorageError(e.to_string()))?;
Ok(outputs
.iter()
.map(|o| GASPOutput {
txid: o.txid.clone(),
output_index: o.output_index,
score: o.score.unwrap_or(0.0),
})
.collect())
}
async fn hydrate_gasp_node(
&self,
graph_id: &str,
txid: &str,
output_index: u32,
_metadata: bool,
) -> Result<GASPNode, GASPError> {
let output = self
.storage
.find_output(txid, output_index, Some(&self.topic), None, true)
.await
.map_err(|e| GASPError::StorageError(e.to_string()))?;
if let Some(output) = output {
if let Some(ref beef) = output.beef {
match beef_limits::transaction_from_beef(
beef,
None,
&beef_limits::STORED_BEEF_LIMITS,
) {
Ok(tx) => {
let mut node = GASPNode {
graph_id: graph_id.to_string(),
raw_tx: tx.to_hex(),
output_index,
proof: None,
tx_metadata: None,
output_metadata: None,
inputs: None,
};
if let Some(ref merkle_path) = tx.merkle_path {
node.proof = Some(merkle_path.to_hex());
}
return Ok(node);
}
Err(e) => {
warn!("Failed to parse BEEF for {txid}: {e}");
}
}
}
}
let output = self
.storage
.find_output(txid, output_index, None, None, true)
.await
.map_err(|e| GASPError::StorageError(e.to_string()))?
.ok_or_else(|| {
GASPError::NodeNotFound(format!("Output {txid}.{output_index} not found"))
})?;
let beef = output.beef.as_ref().ok_or_else(|| {
GASPError::NodeNotFound(format!("No BEEF data for {txid}.{output_index}"))
})?;
let tx = beef_limits::transaction_from_beef(beef, None, &beef_limits::STORED_BEEF_LIMITS)
.map_err(|e| GASPError::Other(format!("BEEF parse error: {e}")))?;
let mut node = GASPNode {
graph_id: graph_id.to_string(),
raw_tx: tx.to_hex(),
output_index,
proof: None,
tx_metadata: None,
output_metadata: None,
inputs: None,
};
if let Some(ref merkle_path) = tx.merkle_path {
node.proof = Some(merkle_path.to_hex());
}
Ok(node)
}
async fn find_needed_inputs(
&self,
node: &GASPNode,
) -> Result<Option<GASPNodeResponse>, GASPError> {
let mut requested_inputs: HashMap<String, GASPInputRequest> = HashMap::new();
let mut admitted = false;
if let Some(proof_hex) = &node.proof {
let Some(manager) = self.topic_manager else {
return Ok(None);
};
let mut tx = Transaction::from_hex(&node.raw_tx)
.map_err(|e| GASPError::Other(format!("Failed to parse raw_tx: {e}")))?;
tx.merkle_path = Some(
beef_limits::proof_from_hex(proof_hex)
.map_err(|e| GASPError::Other(format!("Failed to parse proof: {e}")))?,
);
let admittance = manager
.identify_admissible_outputs(
&tx,
&[],
node.tx_metadata.as_deref().map(str::as_bytes),
SubmitMode::HistoricalTx,
&TopicAdmittanceContext::DRY_RUN,
)
.await
.map_err(|e| GASPError::Other(e.to_string()))?;
admitted = admittance.outputs_to_admit.contains(&node.output_index);
if admitted
&& !tx
.inputs
.iter()
.any(|input| input.get_source_txid().is_ok_and(|s| !s.is_empty()))
{
return Ok(None);
}
let needed_inputs = async {
let beef = tx
.to_beef(false)
.map_err(|e| format!("Failed to serialize proven BEEF: {e}"))?;
manager
.identify_needed_inputs(&beef, None)
.await
.map_err(|e| e.to_string())
}
.await;
match needed_inputs {
Ok(inputs) => {
for input in inputs {
requested_inputs
.insert(input.to_graph_id(), GASPInputRequest { metadata: false });
}
}
Err(e) => {
error!(
"An error occurred when identifying needed inputs for transaction: {}.{}: {e}",
tx.id(), node.output_index
);
return Ok(None);
}
}
} else {
let tx = match Transaction::from_hex(&node.raw_tx) {
Ok(tx) => tx,
Err(e) => {
warn!(
"Cannot parse raw_tx for find_needed_inputs (graph_id={}): {e}",
node.graph_id
);
return Ok(None);
}
};
for input in &tx.inputs {
let source_txid = input.get_source_txid().unwrap_or_default();
if source_txid.is_empty() {
continue;
}
let outpoint = format!("{}.{}", source_txid, input.source_output_index);
requested_inputs.insert(outpoint, GASPInputRequest { metadata: false });
}
}
if requested_inputs.is_empty() {
return Ok(None);
}
let mut to_remove = Vec::new();
for outpoint in requested_inputs.keys() {
if let Some((txid, oi)) = crate::gasp::parse_outpoint(outpoint) {
match self
.storage
.find_output(&txid, oi, Some(&self.topic), None, false)
.await
{
Ok(Some(_)) => {
to_remove.push(outpoint.clone());
}
Ok(None) => {}
Err(e) => {
debug!("Storage lookup failed for {outpoint}: {e}");
}
}
}
}
for key in &to_remove {
requested_inputs.remove(key);
}
if admitted {
let mut landed: HashSet<String> = HashSet::new();
let mut unlanded: HashSet<String> = HashSet::new();
let mut reads = 0usize;
let mut outpoints: Vec<&String> = requested_inputs.keys().collect();
outpoints.sort();
for outpoint in outpoints {
let Some((txid, _)) = crate::gasp::parse_outpoint(outpoint) else {
continue;
};
if landed.contains(&txid) || unlanded.contains(&txid) {
continue;
}
if reads + 2 > LANDED_READS_PER_NODE {
break;
}
reads += 1;
let record = crate::types::AppliedTransaction {
txid: txid.clone(),
topic: self.topic.clone(),
};
let mut holds = matches!(
self.storage.does_applied_transaction_exist(&record).await,
Ok(true)
);
if !holds {
reads += 1;
holds = matches!(
self.storage.find_outputs_for_transaction(&txid, false).await,
Ok(outputs) if outputs.iter().any(|o| o.topic == self.topic)
);
}
if holds {
landed.insert(txid);
} else {
unlanded.insert(txid);
}
}
requested_inputs.retain(|outpoint, _| {
crate::gasp::parse_outpoint(outpoint)
.is_none_or(|(txid, _)| !landed.contains(&txid))
});
}
if requested_inputs.is_empty() {
return Ok(None);
}
Ok(Some(GASPNodeResponse { requested_inputs }))
}
async fn append_to_graph(
&self,
node: &GASPNode,
spent_by: Option<&str>,
) -> Result<(), GASPError> {
debug!(
"Appending node to graph {}: tx={}..., oi={}, spent_by={:?}",
node.graph_id,
&node.raw_tx[..node.raw_tx.len().min(16)],
node.output_index,
spent_by,
);
let node_key = if spent_by.is_none() {
node.graph_id.clone()
} else {
match Transaction::from_hex(&node.raw_tx) {
Ok(tx) => format!("{}.{}", tx.id(), node.output_index),
Err(_) => {
format!(
"{}.{}",
&node.raw_tx[..node.raw_tx.len().min(64)],
node.output_index
)
}
}
};
self.take_anchored();
let mut refs = self
.pending_graphs
.lock()
.map_err(|e| GASPError::Other(format!("Lock poisoned: {e}")))?;
refs.insert(
node_key.clone(),
PendingNode {
node: node.clone(),
spent_by: spent_by.map(std::string::ToString::to_string),
children: Vec::new(),
},
);
if let Some(parent_key) = spent_by {
if let Some(parent) = refs.get_mut(parent_key) {
parent.children.push(node_key);
}
}
Ok(())
}
async fn validate_graph_anchor(&self, graph_id: &str) -> Result<(), GASPError> {
let anchor = {
let refs = self
.pending_graphs
.lock()
.map_err(|e| GASPError::Other(format!("Lock poisoned: {e}")))?;
Self::read_anchor_input(graph_id, &refs, self.strict_beef)?
};
let unavailable =
|what: String| GASPError::AnchorUnavailable(format!("graph {graph_id}: {what}"));
let refused = |why: String| GASPError::ValidationFailed(format!("graph {graph_id}: {why}"));
let mut root_beef = anchor.root_beef;
if !anchor.absent_sources.is_empty() {
let mut merged = beef_limits::parse_beef(&root_beef, &beef_limits::PEER_BEEF_LIMITS)
.map_err(|e| refused(format!("root BEEF does not parse: {e}")))?;
for (txid, output_index) in &anchor.absent_sources {
let held = self
.storage
.find_output(txid, *output_index, Some(&self.topic), None, true)
.await
.map_err(|e| unavailable(format!("reading {txid}.{output_index}: {e}")))?;
if let Some(stored) = held.and_then(|output| output.beef) {
if let Ok(stored) =
beef_limits::parse_beef(&stored, &beef_limits::STORED_BEEF_LIMITS)
{
merged.merge_beef(&stored);
}
}
}
root_beef = merged.to_binary();
}
let tracker = self.chain_tracker.map(|inner| FaultNotingTracker {
inner,
faulted: AtomicBool::new(false),
});
let verdict = crate::engine::verify_spv_like_the_reference(
tracker.as_ref().map(|t| t as &dyn ChainTracker),
self.verify_scripts,
&root_beef,
&anchor.root_txid,
self.walk_budget,
)
.await;
drop(root_beef);
let verdict = match verdict {
Ok(Some(stop)) => {
warn!(
"GASP anchor: the script walk of {} could not run ({}: at {}; {}): held, the graph is not finalized until its root is served proven (bsv-low #592, E592 fold)",
stop.subject_txid,
stop.limb.as_str(),
stop.at_txid,
stop.what
);
return Err(GASPError::AnchorWalkHeld(format!(
"graph {graph_id}: the script walk of {} could not run within the budget ({}: {})",
stop.subject_txid,
stop.limb.as_str(),
stop.what
)));
}
Ok(None) => Ok(()),
Err(e) => Err(e),
};
if let Err(e) = verdict {
if tracker
.as_ref()
.is_some_and(|t| t.faulted.load(Ordering::Relaxed))
{
return Err(unavailable(format!("the chain tracker faulted: {e}")));
}
return Err(refused(format!(
"The graph is not well-anchored according to the rules of Bitcoin: {e}"
)));
}
let Some(manager) = self.topic_manager else {
self.keep_anchored(graph_id, anchor.order, anchor.assembly);
return Ok(());
};
let mut coins: HashSet<String> = HashSet::new();
let mut spent: HashSet<String> = HashSet::new();
let mut replayed: HashSet<String> = HashSet::new();
for key in &anchor.order {
let beef = &anchor.assembly.beef_of(key);
let subject = beef_limits::parse_beef(beef, &beef_limits::PEER_BEEF_LIMITS)
.map(|mut b| crate::subject::subject_txid_of(&mut b))
.map_err(|e| refused(format!("a graph BEEF does not parse: {e}")))?;
let tx = beef_limits::transaction_from_beef(
beef,
subject.as_deref(),
&beef_limits::PEER_BEEF_LIMITS,
)
.map_err(|e| refused(format!("a graph BEEF does not parse: {e}")))?;
let txid = tx.id();
if !replayed.insert(txid.clone()) {
continue;
}
let mut previous_coins: Vec<u8> = Vec::new();
for (input_index, input) in tx.inputs.iter().enumerate() {
let source_txid = input.get_source_txid().unwrap_or_default();
if source_txid.is_empty() {
continue;
}
let outpoint = format!("{}.{}", source_txid, input.source_output_index);
if !spent.insert(outpoint.clone()) {
return Err(refused(format!(
"the graph contains a conflicting spend of {outpoint}"
)));
}
let is_coin = coins.contains(&outpoint)
|| self
.storage
.find_output(
&source_txid,
input.source_output_index,
Some(&self.topic),
None,
false,
)
.await
.map_err(|e| unavailable(format!("reading {outpoint}: {e}")))?
.is_some();
if is_coin {
previous_coins.extend_from_slice(&(input_index as u32).to_le_bytes());
}
}
let admittance = manager
.identify_admissible_outputs(
&tx,
&previous_coins,
None,
SubmitMode::HistoricalTx,
&TopicAdmittanceContext::DRY_RUN,
)
.await
.map_err(|e| unavailable(format!("the topic manager failed on {txid}: {e}")))?;
for output_index in admittance.outputs_to_admit {
coins.insert(format!("{txid}.{output_index}"));
}
}
if !coins.contains(graph_id) {
return Err(refused(
"This graph did not result in topical admittance of the root node. Rejecting."
.to_string(),
));
}
self.keep_anchored(graph_id, anchor.order, anchor.assembly);
Ok(())
}
async fn finalize_graph(&self, graph_id: &str) -> Result<(), GASPError> {
let refs = self
.pending_graphs
.lock()
.map_err(|e| GASPError::Other(format!("Lock poisoned: {e}")))?;
if !refs.contains_key(graph_id) {
return Err(GASPError::Other(format!("No pending graph for {graph_id}")));
}
let node_count = refs
.values()
.filter(|pn| pn.node.graph_id == graph_id)
.count();
debug!("Finalizing graph {graph_id} ({node_count} nodes). Computing ordered BEEFs.");
let beefs =
Self::compute_ordered_beefs(graph_id, &refs, self.strict_beef, self.take_anchored())?;
drop(refs);
self.finalized_sink
.lock()
.map_err(|e| GASPError::Other(format!("Lock poisoned: {e}")))?
.push(FinalizedGraph {
topic: self.topic.clone(),
beefs,
});
let mut refs = self
.pending_graphs
.lock()
.map_err(|e| GASPError::Other(format!("Lock poisoned: {e}")))?;
let keys_to_remove: Vec<String> = refs
.iter()
.filter(|(_, pn)| pn.node.graph_id == graph_id)
.map(|(k, _)| k.clone())
.collect();
for key in keys_to_remove {
refs.remove(&key);
}
Ok(())
}
async fn discard_graph(&self, graph_id: &str) -> Result<(), GASPError> {
debug!("Discarding graph: {graph_id}");
self.take_anchored();
let mut refs = self
.pending_graphs
.lock()
.map_err(|e| GASPError::Other(format!("Lock poisoned: {e}")))?;
let keys_to_remove: Vec<String> = refs
.iter()
.filter(|(_, pn)| pn.node.graph_id == graph_id)
.map(|(k, _)| k.clone())
.collect();
for key in keys_to_remove {
refs.remove(&key);
}
Ok(())
}
async fn load_deferred_graphs(&self) -> Result<Vec<crate::gasp::DeferredGraphKey>, GASPError> {
self.storage
.find_deferred_graphs(&self.peer, &self.topic)
.await
.map_err(|e| GASPError::StorageError(e.to_string()))
}
async fn get_deferred_graph(
&self,
outpoint: &str,
) -> Result<Option<crate::gasp::DeferredGraph>, GASPError> {
self.storage
.get_deferred_graph(&self.peer, &self.topic, outpoint)
.await
.map_err(|e| GASPError::StorageError(e.to_string()))
}
async fn save_deferred_graph(
&self,
record: &crate::gasp::DeferredGraph,
) -> Result<crate::gasp::DeferredGraphSave, GASPError> {
let mut record = record.clone();
record.peer.clone_from(&self.peer);
record.topic.clone_from(&self.topic);
record.configured = self.configured_peer;
self.storage
.put_deferred_graph(&record)
.await
.map_err(|e| GASPError::StorageError(e.to_string()))
}
async fn delete_deferred_graph(&self, outpoint: &str) -> Result<(), GASPError> {
self.storage
.delete_deferred_graph(&self.peer, &self.topic, outpoint)
.await
.map_err(|e| GASPError::StorageError(e.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gasp::GASPStorage;
use crate::storage::memory::MemoryStorage;
use crate::storage::Storage;
use crate::types::Output;
fn make_sink() -> FinalizedGraphSink {
new_finalized_graph_sink()
}
fn make_output(txid: &str, index: u32, topic: &str, score: f64) -> Output {
Output {
txid: txid.to_string(),
output_index: index,
output_script: vec![0x76, 0xa9],
satoshis: 1000,
topic: topic.to_string(),
spent: false,
outputs_consumed: vec![],
consumed_by: vec![],
beef: Some(vec![0xBE, 0xEF]),
block_height: None,
score: Some(score),
}
}
fn make_valid_tx_hex(source_txid: &str, source_oi: u32) -> String {
let mut tx = Transaction::new();
let input = bsv_rs::transaction::TransactionInput::new(source_txid.to_string(), source_oi);
tx.inputs.push(input);
tx.outputs.push(bsv_rs::transaction::TransactionOutput::new(
100,
bsv_rs::script::LockingScript::from_hex(
"76a914000000000000000000000000000000000000000088ac",
)
.unwrap(),
));
tx.to_hex()
}
fn make_no_input_tx_hex() -> String {
let mut tx = Transaction::new();
tx.outputs.push(bsv_rs::transaction::TransactionOutput::new(
5000,
bsv_rs::script::LockingScript::from_hex(
"76a914000000000000000000000000000000000000000088ac",
)
.unwrap(),
));
tx.to_hex()
}
fn make_coinbase_tx_hex() -> String {
let mut tx = Transaction::new();
let mut input = bsv_rs::transaction::TransactionInput::new("00".repeat(32), u32::MAX);
input.unlocking_script =
Some(bsv_rs::script::UnlockingScript::from_binary(&[0x51]).unwrap());
tx.inputs.push(input);
tx.outputs.push(bsv_rs::transaction::TransactionOutput::new(
5000,
bsv_rs::script::LockingScript::from_hex(
"76a914000000000000000000000000000000000000000088ac",
)
.unwrap(),
));
tx.to_hex()
}
#[tokio::test]
async fn find_known_utxos_returns_correct_outputs() {
let store = MemoryStorage::new();
store
.insert_output(&make_output("tx1", 0, "tm_test", 100.0))
.await
.unwrap();
store
.insert_output(&make_output("tx2", 0, "tm_test", 200.0))
.await
.unwrap();
store
.insert_output(&make_output("tx3", 0, "tm_other", 300.0))
.await
.unwrap();
let gasp_storage = OverlayGASPStorage::new(&store, "tm_test", make_sink());
let utxos = gasp_storage.find_known_utxos(0, None).await.unwrap();
assert_eq!(utxos.len(), 2, "Should return only tm_test UTXOs");
assert_eq!(utxos[0].txid, "tx1");
assert_eq!(utxos[1].txid, "tx2");
}
#[tokio::test]
async fn find_known_utxos_respects_since() {
let store = MemoryStorage::new();
store
.insert_output(&make_output("tx1", 0, "tm_test", 100.0))
.await
.unwrap();
store
.insert_output(&make_output("tx2", 0, "tm_test", 200.0))
.await
.unwrap();
store
.insert_output(&make_output("tx3", 0, "tm_test", 300.0))
.await
.unwrap();
let gasp_storage = OverlayGASPStorage::new(&store, "tm_test", make_sink());
let utxos = gasp_storage.find_known_utxos(200, None).await.unwrap();
assert_eq!(utxos.len(), 2, "Should return UTXOs with score >= 200");
assert_eq!(utxos[0].txid, "tx2");
assert_eq!(utxos[1].txid, "tx3");
}
#[tokio::test]
async fn find_known_utxos_respects_limit() {
let store = MemoryStorage::new();
store
.insert_output(&make_output("tx1", 0, "tm_test", 100.0))
.await
.unwrap();
store
.insert_output(&make_output("tx2", 0, "tm_test", 200.0))
.await
.unwrap();
store
.insert_output(&make_output("tx3", 0, "tm_test", 300.0))
.await
.unwrap();
let gasp_storage = OverlayGASPStorage::new(&store, "tm_test", make_sink());
let utxos = gasp_storage.find_known_utxos(0, Some(2)).await.unwrap();
assert_eq!(utxos.len(), 2, "Should limit to 2 results");
}
#[tokio::test]
async fn find_known_utxos_excludes_spent() {
let store = MemoryStorage::new();
store
.insert_output(&make_output("tx1", 0, "tm_test", 100.0))
.await
.unwrap();
store
.insert_output(&make_output("tx2", 0, "tm_test", 200.0))
.await
.unwrap();
store.mark_utxo_as_spent("tx1", 0, "tm_test").await.unwrap();
let gasp_storage = OverlayGASPStorage::new(&store, "tm_test", make_sink());
let utxos = gasp_storage.find_known_utxos(0, None).await.unwrap();
assert_eq!(utxos.len(), 1, "Should exclude spent UTXOs");
assert_eq!(utxos[0].txid, "tx2");
}
#[tokio::test]
async fn find_known_utxos_empty_topic() {
let store = MemoryStorage::new();
let gasp_storage = OverlayGASPStorage::new(&store, "tm_empty", make_sink());
let utxos = gasp_storage.find_known_utxos(0, None).await.unwrap();
assert!(utxos.is_empty());
}
#[tokio::test]
async fn find_needed_inputs_returns_none_for_unparsable_tx() {
let store = MemoryStorage::new();
let gasp_storage = OverlayGASPStorage::new(&store, "tm_test", make_sink());
let node = GASPNode {
graph_id: "abc.0".to_string(),
raw_tx: "deadbeef".to_string(), output_index: 0,
proof: None,
tx_metadata: None,
output_metadata: None,
inputs: None,
};
let result = gasp_storage.find_needed_inputs(&node).await.unwrap();
assert!(result.is_none(), "Unparsable tx: graceful fallback to None");
}
#[tokio::test]
async fn find_needed_inputs_returns_none_for_proved_tx() {
let store = MemoryStorage::new();
let gasp_storage = OverlayGASPStorage::new(&store, "tm_test", make_sink());
let raw_tx = make_valid_tx_hex(
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
0,
);
let node = GASPNode {
graph_id: "abc.0".to_string(),
raw_tx,
output_index: 0,
proof: Some("some_proof_hex".to_string()),
tx_metadata: None,
output_metadata: None,
inputs: None,
};
let result = gasp_storage.find_needed_inputs(&node).await.unwrap();
assert!(result.is_none(), "Proved tx should not need inputs");
}
#[tokio::test]
async fn find_needed_inputs_requests_inputs_for_unproved_tx() {
let store = MemoryStorage::new();
let gasp_storage = OverlayGASPStorage::new(&store, "tm_test", make_sink());
let source_txid = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
let raw_tx = make_valid_tx_hex(source_txid, 0);
let node = GASPNode {
graph_id: "abc.0".to_string(),
raw_tx,
output_index: 0,
proof: None,
tx_metadata: None,
output_metadata: None,
inputs: None,
};
let result = gasp_storage.find_needed_inputs(&node).await.unwrap();
assert!(result.is_some(), "Unproved tx should request inputs");
let response = result.unwrap();
assert_eq!(response.requested_inputs.len(), 1);
let key = format!("{source_txid}.0");
assert!(
response.requested_inputs.contains_key(&key),
"Should request the source input"
);
}
#[tokio::test]
async fn find_needed_inputs_strips_known_inputs() {
let store = MemoryStorage::new();
let source_txid = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";
store
.insert_output(&make_output(source_txid, 0, "tm_test", 50.0))
.await
.unwrap();
let gasp_storage = OverlayGASPStorage::new(&store, "tm_test", make_sink());
let raw_tx = make_valid_tx_hex(source_txid, 0);
let node = GASPNode {
graph_id: "abc.0".to_string(),
raw_tx,
output_index: 0,
proof: None,
tx_metadata: None,
output_metadata: None,
inputs: None,
};
let result = gasp_storage.find_needed_inputs(&node).await.unwrap();
assert!(
result.is_none(),
"Should strip already-known inputs, resulting in None"
);
}
#[tokio::test]
async fn find_needed_inputs_returns_none_for_coinbase() {
let store = MemoryStorage::new();
let gasp_storage = OverlayGASPStorage::new(&store, "tm_test", make_sink());
let raw_tx = make_no_input_tx_hex();
let node = GASPNode {
graph_id: "abc.0".to_string(),
raw_tx,
output_index: 0,
proof: None,
tx_metadata: None,
output_metadata: None,
inputs: None,
};
let result = gasp_storage.find_needed_inputs(&node).await.unwrap();
assert!(
result.is_none(),
"Coinbase-like tx (no inputs) should need nothing"
);
}
#[tokio::test]
async fn append_and_finalize_graph() {
let store = MemoryStorage::new();
let sink = make_sink();
let gasp_storage = OverlayGASPStorage::new(&store, "tm_test", sink.clone());
let raw_tx = make_coinbase_tx_hex();
let node = GASPNode {
graph_id: "abc.0".to_string(),
raw_tx,
output_index: 0,
proof: None,
tx_metadata: None,
output_metadata: None,
inputs: None,
};
gasp_storage.append_to_graph(&node, None).await.unwrap();
assert_eq!(gasp_storage.pending_graph_count(), 1);
gasp_storage.finalize_graph("abc.0").await.unwrap();
assert_eq!(gasp_storage.pending_graph_count(), 0);
let finalized = sink.lock().unwrap();
assert_eq!(finalized.len(), 1, "Should have one finalized graph");
assert_eq!(finalized[0].topic, "tm_test");
assert!(!finalized[0].beefs.is_empty(), "Should have BEEF data");
}
#[tokio::test]
async fn finalize_graph_produces_submittable_beef() {
let store = MemoryStorage::new();
let sink = make_sink();
let gasp_storage = OverlayGASPStorage::new(&store, "tm_test", sink.clone());
let raw_tx = make_coinbase_tx_hex();
let node = GASPNode {
graph_id: "root.0".to_string(),
raw_tx,
output_index: 0,
proof: None,
tx_metadata: None,
output_metadata: None,
inputs: None,
};
gasp_storage.append_to_graph(&node, None).await.unwrap();
gasp_storage.finalize_graph("root.0").await.unwrap();
let finalized = sink.lock().unwrap();
assert_eq!(finalized.len(), 1);
for beef_bytes in &finalized[0].beefs {
let tx = Transaction::from_beef(beef_bytes, None);
assert!(
tx.is_ok(),
"Finalized BEEF should be parseable: {:?}",
tx.err()
);
}
}
#[tokio::test]
async fn append_multiple_nodes_same_graph() {
let store = MemoryStorage::new();
let gasp_storage = OverlayGASPStorage::new(&store, "tm_test", make_sink());
let raw_tx1 = make_coinbase_tx_hex();
let node1 = GASPNode {
graph_id: "abc.0".to_string(),
raw_tx: raw_tx1,
output_index: 0,
proof: None,
tx_metadata: None,
output_metadata: None,
inputs: None,
};
let raw_tx2 = make_coinbase_tx_hex();
let node2 = GASPNode {
graph_id: "abc.0".to_string(),
raw_tx: raw_tx2,
output_index: 0,
proof: None,
tx_metadata: None,
output_metadata: None,
inputs: None,
};
gasp_storage.append_to_graph(&node1, None).await.unwrap();
gasp_storage
.append_to_graph(&node2, Some("abc.0"))
.await
.unwrap();
assert_eq!(gasp_storage.pending_graph_count(), 1);
}
#[tokio::test]
async fn discard_graph_removes_pending() {
let store = MemoryStorage::new();
let gasp_storage = OverlayGASPStorage::new(&store, "tm_test", make_sink());
let raw_tx = make_coinbase_tx_hex();
let node = GASPNode {
graph_id: "abc.0".to_string(),
raw_tx,
output_index: 0,
proof: None,
tx_metadata: None,
output_metadata: None,
inputs: None,
};
gasp_storage.append_to_graph(&node, None).await.unwrap();
assert_eq!(gasp_storage.pending_graph_count(), 1);
gasp_storage.discard_graph("abc.0").await.unwrap();
assert_eq!(gasp_storage.pending_graph_count(), 0);
}
#[tokio::test]
async fn validate_graph_anchor_refuses_an_unknown_graph() {
let store = MemoryStorage::new();
let gasp_storage = OverlayGASPStorage::new(&store, "tm_test", make_sink());
let error = gasp_storage
.validate_graph_anchor("nonexistent.0")
.await
.unwrap_err();
assert!(matches!(error, GASPError::ValidationFailed(_)), "{error}");
}
#[tokio::test]
async fn finalize_empty_graph_errors() {
let store = MemoryStorage::new();
let gasp_storage = OverlayGASPStorage::new(&store, "tm_test", make_sink());
let result = gasp_storage.finalize_graph("nonexistent.0").await;
assert!(result.is_err(), "Should error on unknown graph");
}
#[tokio::test]
async fn gasp_storage_is_object_safe() {
let store = MemoryStorage::new();
let gasp_storage = OverlayGASPStorage::new(&store, "tm_test", make_sink());
let boxed: Box<dyn GASPStorage> = Box::new(gasp_storage);
let utxos = boxed.find_known_utxos(0, None).await.unwrap();
assert!(utxos.is_empty());
}
#[tokio::test]
async fn overlay_gasp_storage_with_gasp_sync() {
use crate::gasp::{GASPRemote, GASPSync};
use crate::types::{GASPInitialReply, GASPInitialRequest, GASPInitialResponse};
let store = MemoryStorage::new();
store
.insert_output(&make_output("local_tx", 0, "tm_test", 50.0))
.await
.unwrap();
let sink = make_sink();
let gasp_storage = OverlayGASPStorage::new(&store, "tm_test", sink.clone());
struct TestRemote;
#[async_trait(?Send)]
impl GASPRemote for TestRemote {
async fn get_initial_response(
&self,
request: &GASPInitialRequest,
) -> Result<GASPInitialResponse, GASPError> {
Ok(GASPInitialResponse {
utxo_list: vec![
GASPOutput {
txid: "remote_tx1".to_string(),
output_index: 0,
score: 100.0,
},
GASPOutput {
txid: "remote_tx2".to_string(),
output_index: 0,
score: 200.0,
},
],
since: request.since,
})
}
async fn get_initial_reply(
&self,
_: &GASPInitialResponse,
) -> Result<GASPInitialReply, GASPError> {
Ok(GASPInitialReply {
utxo_list: Vec::new(),
})
}
async fn request_node(
&self,
graph_id: &str,
txid: &str,
output_index: u32,
_: bool,
) -> Result<GASPNode, GASPError> {
Ok(GASPNode {
graph_id: graph_id.to_string(),
raw_tx: format!("rawtx_{txid}"),
output_index,
proof: None,
tx_metadata: None,
output_metadata: None,
inputs: None,
})
}
async fn submit_node(
&self,
_: &GASPNode,
) -> Result<Option<GASPNodeResponse>, GASPError> {
Ok(None)
}
}
let mut sync = GASPSync::new(
Box::new(gasp_storage),
Box::new(TestRemote),
0,
"[TEST]",
true, );
sync.sync(None).await.unwrap();
assert_eq!(sync.last_interaction, 0);
}
#[tokio::test]
async fn gasp_sync_with_valid_tx_produces_finalized_beefs() {
use crate::gasp::{GASPRemote, GASPSync};
use crate::types::{GASPInitialReply, GASPInitialRequest, GASPInitialResponse};
let store = MemoryStorage::new();
let sink = make_sink();
let gasp_storage = OverlayGASPStorage::new(&store, "tm_test", sink.clone());
let valid_tx_hex = make_coinbase_tx_hex();
struct ValidTxRemote {
tx_hex: String,
}
#[async_trait(?Send)]
impl GASPRemote for ValidTxRemote {
async fn get_initial_response(
&self,
request: &GASPInitialRequest,
) -> Result<GASPInitialResponse, GASPError> {
Ok(GASPInitialResponse {
utxo_list: vec![GASPOutput {
txid: "valid_tx".to_string(),
output_index: 0,
score: 100.0,
}],
since: request.since,
})
}
async fn get_initial_reply(
&self,
_: &GASPInitialResponse,
) -> Result<GASPInitialReply, GASPError> {
Ok(GASPInitialReply {
utxo_list: Vec::new(),
})
}
async fn request_node(
&self,
graph_id: &str,
_txid: &str,
output_index: u32,
_: bool,
) -> Result<GASPNode, GASPError> {
let txid = Transaction::from_hex(&self.tx_hex).unwrap().id();
let proof = MerklePath::new(
100,
vec![vec![bsv_rs::transaction::MerklePathLeaf::new_txid(0, txid)]],
)
.unwrap();
Ok(GASPNode {
graph_id: graph_id.to_string(),
raw_tx: self.tx_hex.clone(),
output_index,
proof: Some(proof.to_hex()),
tx_metadata: None,
output_metadata: None,
inputs: None,
})
}
async fn submit_node(
&self,
_: &GASPNode,
) -> Result<Option<GASPNodeResponse>, GASPError> {
Ok(None)
}
}
let remote = ValidTxRemote {
tx_hex: valid_tx_hex,
};
let mut sync = GASPSync::new(Box::new(gasp_storage), Box::new(remote), 0, "[TEST]", true);
sync.sync(None).await.unwrap();
let finalized = sink.lock().unwrap();
assert_eq!(finalized.len(), 1, "Should have one finalized graph");
assert!(!finalized[0].beefs.is_empty(), "Should have BEEF bytes");
for beef_bytes in &finalized[0].beefs {
let parsed = Transaction::from_beef(beef_bytes, None);
assert!(parsed.is_ok(), "BEEF should be parseable");
}
}
#[tokio::test]
async fn take_finalized_graphs_drains_list() {
let store = MemoryStorage::new();
let sink = make_sink();
let gasp_storage = OverlayGASPStorage::new(&store, "tm_test", sink.clone());
let raw_tx = make_coinbase_tx_hex();
let node = GASPNode {
graph_id: "abc.0".to_string(),
raw_tx,
output_index: 0,
proof: None,
tx_metadata: None,
output_metadata: None,
inputs: None,
};
gasp_storage.append_to_graph(&node, None).await.unwrap();
gasp_storage.finalize_graph("abc.0").await.unwrap();
let taken = gasp_storage.take_finalized_graphs();
assert_eq!(taken.len(), 1);
let taken2 = gasp_storage.take_finalized_graphs();
assert!(taken2.is_empty());
}
fn legacy_get_beef_for_node(
node_key: &str,
refs: &HashMap<String, PendingNode>,
strict_beef: bool,
) -> Result<(Transaction, Vec<u8>), GASPError> {
let pending = refs
.get(node_key)
.ok_or_else(|| GASPError::Other(format!("Node {node_key} not found in graph refs")))?;
let mut tx = Transaction::from_hex(&pending.node.raw_tx)
.map_err(|e| GASPError::Other(format!("Failed to parse raw_tx for {node_key}: {e}")))?;
if let Some(ref proof_hex) = pending.node.proof {
tx.merkle_path = Some(beef_limits::proof_from_hex(proof_hex).map_err(|e| {
GASPError::Other(format!("Failed to parse proof for {node_key}: {e}"))
})?);
} else {
let child_info: Vec<(usize, String)> = tx
.inputs
.iter()
.enumerate()
.filter_map(|(idx, input)| {
let source_txid = input.get_source_txid().unwrap_or_default();
if source_txid.is_empty() {
return None;
}
let child_key = format!("{}.{}", source_txid, input.source_output_index);
if refs.contains_key(&child_key) {
Some((idx, child_key))
} else {
None
}
})
.collect();
for (input_idx, child_key) in child_info {
let (child_tx, _) = legacy_get_beef_for_node(&child_key, refs, strict_beef)?;
tx.inputs[input_idx].source_transaction = Some(Box::new(child_tx));
}
}
let allow_partial = !strict_beef;
let beef = tx.to_beef(allow_partial).map_err(|e| {
GASPError::Other(format!("Failed to serialize BEEF for {node_key}: {e}"))
})?;
Ok((tx, beef))
}
fn legacy_compute_ordered_beefs(
graph_id: &str,
refs: &HashMap<String, PendingNode>,
strict_beef: bool,
) -> Result<Vec<Vec<u8>>, GASPError> {
let mut beefs: Vec<Vec<u8>> = Vec::new();
let mut visited: std::collections::HashSet<String> = std::collections::HashSet::new();
fn hydrate(
node_key: &str,
refs: &HashMap<String, PendingNode>,
beefs: &mut Vec<Vec<u8>>,
visited: &mut std::collections::HashSet<String>,
strict_beef: bool,
) -> Result<(), GASPError> {
if visited.contains(node_key) {
return Ok(());
}
visited.insert(node_key.to_string());
let Some(pending) = refs.get(node_key) else {
return Ok(());
};
for child_key in &pending.children {
hydrate(child_key, refs, beefs, visited, strict_beef)?;
}
let (_, beef) = legacy_get_beef_for_node(node_key, refs, strict_beef)?;
beefs.push(beef);
Ok(())
}
hydrate(graph_id, refs, &mut beefs, &mut visited, strict_beef)?;
Ok(beefs)
}
struct Lcg(u64);
impl Lcg {
fn below(&mut self, n: u64) -> u64 {
self.0 = self
.0
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
(self.0 >> 33) % n
}
}
fn pending(raw_tx: String, output_index: u32, proof: Option<String>) -> PendingNode {
PendingNode {
node: GASPNode {
graph_id: "g".to_string(),
raw_tx,
output_index,
proof,
tx_metadata: None,
output_metadata: None,
inputs: None,
},
spent_by: None,
children: Vec::new(),
}
}
fn random_graph(rng: &mut Lcg) -> (HashMap<String, PendingNode>, String) {
use bsv_rs::transaction::{MerklePathLeaf, TransactionInput, TransactionOutput};
let count = 2 + rng.below(8) as usize;
let mut txs: Vec<Transaction> = Vec::new();
for i in 0..count {
let mut tx = Transaction::new();
let mut spent: HashSet<(usize, u32)> = HashSet::new();
if i > 0 {
for _ in 0..rng.below(5) {
let source = rng.below(i as u64) as usize;
let vout = rng.below(3) as u32;
if spent.insert((source, vout)) {
tx.inputs
.push(TransactionInput::new(txs[source].id(), vout));
}
}
}
for _ in 0..3 {
tx.outputs.push(TransactionOutput::new(
1000 + i as u64,
bsv_rs::script::LockingScript::from_hex("51").unwrap(),
));
}
txs.push(tx);
}
let mut proofs: Vec<Option<String>> = vec![None; count];
let mut i = 0;
while i < count {
match rng.below(5) {
0 => {
let path = vec![vec![MerklePathLeaf::new_txid(0, txs[i].id())]];
proofs[i] = Some(MerklePath::new(100 + i as u32, path).unwrap().to_hex());
}
1 if i + 1 < count => {
let (a, b) = (txs[i].id(), txs[i + 1].id());
let height = 100 + i as u32;
let of_a = vec![vec![
MerklePathLeaf::new_txid(0, a.clone()),
MerklePathLeaf::new(1, b.clone()),
]];
let of_b = vec![vec![
MerklePathLeaf::new(0, a),
MerklePathLeaf::new_txid(1, b),
]];
proofs[i] = Some(MerklePath::new(height, of_a).unwrap().to_hex());
proofs[i + 1] = Some(MerklePath::new(height, of_b).unwrap().to_hex());
i += 1;
}
_ => {}
}
i += 1;
}
let root_key = format!("{}.0", txs[count - 1].id());
let mut refs: HashMap<String, PendingNode> = HashMap::new();
refs.insert(
root_key.clone(),
pending(txs[count - 1].to_hex(), 0, proofs[count - 1].clone()),
);
for (i, tx) in txs.iter().enumerate().rev() {
let own_keys: Vec<String> = (0..3)
.map(|vout| format!("{}.{vout}", tx.id()))
.filter(|key| refs.contains_key(key))
.collect();
if own_keys.is_empty() || proofs[i].is_some() && rng.below(3) > 0 {
continue;
}
for input in &tx.inputs {
let source_txid = input.get_source_txid().unwrap();
let source = txs.iter().position(|t| t.id() == source_txid).unwrap();
let key = format!("{source_txid}.{}", input.source_output_index);
if rng.below(6) == 0 {
continue;
}
let mut node = pending(
txs[source].to_hex(),
input.source_output_index,
proofs[source].clone(),
);
match rng.below(40) {
0 => node.node.proof = Some("zz".to_string()),
1 => node.node.raw_tx = "00".to_string(),
_ => {}
}
refs.insert(key.clone(), node);
for own in &own_keys {
let children = &mut refs.get_mut(own).unwrap().children;
if !children.contains(&key) {
children.push(key.clone());
}
}
}
}
(refs, root_key)
}
#[test]
fn e586_the_assembly_gives_the_hydrated_trees_bytes_and_errors() {
let mut rng = Lcg(0x586);
let (mut beefs, mut refusals, mut proven, mut partial) = (0, 0, 0, 0);
for _ in 0..600 {
let (refs, root_key) = random_graph(&mut rng);
for strict in [false, true] {
let mut keys: Vec<&String> = refs.keys().collect();
keys.sort();
for key in keys {
let legacy = legacy_get_beef_for_node(key, &refs, strict)
.map(|(_, beef)| beef)
.map_err(|e| e.to_string());
let mut assembly = GraphAssembly::default();
let ours = assembly
.check(key, &refs, strict)
.map(|()| assembly.beef_of(key))
.map_err(|e| e.to_string());
assert_eq!(ours, legacy, "node {key}, strict {strict}");
match &ours {
Ok(beef) => {
beefs += 1;
let parsed = Beef::from_binary(beef).unwrap();
proven += usize::from(!parsed.bumps.is_empty());
partial += usize::from(
!strict && legacy_get_beef_for_node(key, &refs, true).is_err(),
);
}
Err(_) => refusals += 1,
}
}
let legacy = legacy_compute_ordered_beefs(&root_key, &refs, strict)
.map_err(|e| e.to_string());
let ours =
OverlayGASPStorage::compute_ordered_beefs(&root_key, &refs, strict, None)
.map_err(|e| e.to_string());
assert_eq!(ours, legacy, "graph {root_key}, strict {strict}");
}
}
println!(
"#586: {beefs} BEEFs equal ({proven} with a proof, {partial} partial), {refusals} refusals equal"
);
assert!(beefs > 2000 && proven > 300 && partial > 100 && refusals > 300);
}
#[test]
fn e586_the_refusals_are_the_recursions_words() {
let parent = "ab".repeat(32);
let raw = make_valid_tx_hex(&parent, 3);
let key = format!("{}.0", Transaction::from_hex(&raw).unwrap().id());
let refs = HashMap::from([(key.clone(), pending(raw, 0, None))]);
let mut assembly = GraphAssembly::default();
assert_eq!(
assembly
.check("nope.0", &refs, false)
.unwrap_err()
.to_string(),
legacy_get_beef_for_node("nope.0", &refs, false)
.unwrap_err()
.to_string()
);
let strict = assembly.check(&key, &refs, true).unwrap_err().to_string();
assert_eq!(
strict,
legacy_get_beef_for_node(&key, &refs, true)
.unwrap_err()
.to_string()
);
assert!(strict.contains(&format!(
"Missing source transaction for input 0 (txid: {parent})"
)));
assembly.check(&key, &refs, false).unwrap();
assert_eq!(
assembly.beef_of(&key),
legacy_get_beef_for_node(&key, &refs, false).unwrap().1
);
}
#[test]
fn e586_a_deep_chain_and_a_cycle_do_not_use_the_stack() {
use bsv_rs::transaction::{TransactionInput, TransactionOutput};
let mut refs: HashMap<String, PendingNode> = HashMap::new();
let mut previous: Option<String> = None;
let mut tip = String::new();
for i in 0..20_000u64 {
let mut tx = Transaction::new();
if let Some(txid) = &previous {
tx.inputs.push(TransactionInput::new(txid.clone(), 0));
}
tx.outputs.push(TransactionOutput::new(
i,
bsv_rs::script::LockingScript::from_hex("51").unwrap(),
));
let txid = tx.id();
tip = format!("{txid}.0");
let mut node = pending(tx.to_hex(), 0, None);
if let Some(below) = &previous {
node.children.push(format!("{below}.0"));
}
refs.insert(tip.clone(), node);
previous = Some(txid);
}
assert_eq!(OverlayGASPStorage::ordered_keys(&tip, &refs).len(), 20_000);
let mut assembly = GraphAssembly::default();
assembly.check(&tip, &refs, true).unwrap();
let beef = Beef::from_binary(&assembly.beef_of(&tip)).unwrap();
assert_eq!(beef.txs.len(), 20_000);
let decoy = "cd".repeat(32);
let y_raw = make_valid_tx_hex(&decoy, 0);
let y = Transaction::from_hex(&y_raw).unwrap().id();
let root_raw = make_valid_tx_hex(&y, 0);
let root_key = format!("{decoy}.0");
let mut root = pending(root_raw, 0, None);
root.children.push(format!("{y}.0"));
let refs = HashMap::from([
(root_key.clone(), root),
(format!("{y}.0"), pending(y_raw, 0, None)),
]);
let mut assembly = GraphAssembly::default();
assembly.check(&root_key, &refs, true).unwrap();
let beef = Beef::from_binary(&assembly.beef_of(&root_key)).unwrap();
assert_eq!(beef.txs.len(), 2);
let beefs =
OverlayGASPStorage::compute_ordered_beefs(&root_key, &refs, true, None).unwrap();
assert_eq!(beefs.len(), 2);
}
}