#[cfg(feature = "http")]
use crate::overlay::types::OutputListItem;
use crate::overlay::types::{LookupAnswer, LookupQuestion, Steak, TaggedBEEF};
use crate::{Error, Result};
use async_trait::async_trait;
#[async_trait(?Send)]
pub trait OverlayLookupFacilitator: Send + Sync {
async fn lookup(
&self,
url: &str,
question: &LookupQuestion,
timeout_ms: Option<u64>,
) -> Result<LookupAnswer>;
}
#[async_trait(?Send)]
pub trait OverlayBroadcastFacilitator: Send + Sync {
async fn send(&self, url: &str, tagged_beef: &TaggedBEEF) -> Result<Steak>;
}
#[derive(Clone)]
pub struct HttpsOverlayLookupFacilitator {
#[cfg(feature = "http")]
client: reqwest::Client,
#[allow(dead_code)]
allow_http: bool,
}
impl HttpsOverlayLookupFacilitator {
pub fn new(allow_http: bool) -> Self {
Self {
#[cfg(feature = "http")]
client: reqwest::Client::new(),
allow_http,
}
}
}
impl Default for HttpsOverlayLookupFacilitator {
fn default() -> Self {
Self::new(false)
}
}
#[async_trait(?Send)]
impl OverlayLookupFacilitator for HttpsOverlayLookupFacilitator {
async fn lookup(
&self,
url: &str,
question: &LookupQuestion,
timeout_ms: Option<u64>,
) -> Result<LookupAnswer> {
#[cfg(not(feature = "http"))]
{
let _ = (url, question, timeout_ms);
return Err(Error::OverlayError(
"HTTP feature not enabled. Enable the 'http' feature to use HTTP facilitators."
.into(),
));
}
#[cfg(feature = "http")]
{
if !self.allow_http && url.starts_with("http://") {
return Err(Error::OverlayError(
"HTTPS facilitator can only use URLs that start with \"https:\"".into(),
));
}
let lookup_url = format!("{}/lookup", url.trim_end_matches('/'));
let timeout = std::time::Duration::from_millis(timeout_ms.unwrap_or(5000));
let request = self
.client
.post(&lookup_url)
.header("Content-Type", "application/json")
.header("X-Aggregation", "yes")
.json(&serde_json::json!({
"service": question.service,
"query": question.query,
}))
.timeout(timeout);
let response = request
.send()
.await
.map_err(|e| Error::OverlayError(format!("Request failed: {}", e)))?;
if !response.status().is_success() {
return Err(Error::OverlayError(format!(
"Lookup failed with HTTP status: {}",
response.status()
)));
}
let content_type = response
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if content_type.contains("octet-stream") {
let bytes = response
.bytes()
.await
.map_err(|e| Error::OverlayError(format!("Failed to read response: {}", e)))?;
parse_binary_lookup_response(&bytes)
} else {
let json: serde_json::Value = response
.json()
.await
.map_err(|e| Error::OverlayError(format!("Failed to parse JSON: {}", e)))?;
parse_json_lookup_answer(json)
}
}
}
}
#[derive(Clone)]
pub struct HttpsOverlayBroadcastFacilitator {
#[cfg(feature = "http")]
client: reqwest::Client,
#[allow(dead_code)]
allow_http: bool,
}
impl HttpsOverlayBroadcastFacilitator {
pub fn new(allow_http: bool) -> Self {
Self {
#[cfg(feature = "http")]
client: reqwest::Client::new(),
allow_http,
}
}
}
impl Default for HttpsOverlayBroadcastFacilitator {
fn default() -> Self {
Self::new(false)
}
}
#[async_trait(?Send)]
impl OverlayBroadcastFacilitator for HttpsOverlayBroadcastFacilitator {
async fn send(&self, url: &str, tagged_beef: &TaggedBEEF) -> Result<Steak> {
#[cfg(not(feature = "http"))]
{
let _ = (url, tagged_beef);
return Err(Error::OverlayError(
"HTTP feature not enabled. Enable the 'http' feature to use HTTP facilitators."
.into(),
));
}
#[cfg(feature = "http")]
{
if !self.allow_http && url.starts_with("http://") {
return Err(Error::OverlayError(
"HTTPS facilitator can only use URLs that start with \"https:\"".into(),
));
}
let submit_url = format!("{}/submit", url.trim_end_matches('/'));
let (body, has_off_chain) = if let Some(ref off_chain) = tagged_beef.off_chain_values {
let mut buf = Vec::new();
buf.extend_from_slice(&varint_encode(tagged_beef.beef.len() as u64));
buf.extend_from_slice(&tagged_beef.beef);
buf.extend_from_slice(off_chain);
(buf, true)
} else {
(tagged_beef.beef.clone(), false)
};
let topics_header = serde_json::to_string(&tagged_beef.topics)
.map_err(|e| Error::OverlayError(format!("Failed to serialize topics: {}", e)))?;
let mut request = self
.client
.post(&submit_url)
.header("Content-Type", "application/octet-stream")
.header("X-Topics", topics_header);
if has_off_chain {
request = request.header("x-includes-off-chain-values", "true");
}
let response = request
.body(body)
.send()
.await
.map_err(|e| Error::OverlayError(format!("Request failed: {}", e)))?;
if !response.status().is_success() {
return Err(Error::OverlayError(format!(
"Broadcast failed with HTTP status: {}",
response.status()
)));
}
let steak: Steak = response
.json()
.await
.map_err(|e| Error::OverlayError(format!("Failed to parse STEAK: {}", e)))?;
Ok(steak)
}
}
}
#[cfg(feature = "http")]
fn parse_json_lookup_answer(json: serde_json::Value) -> Result<LookupAnswer> {
let answer_type = json
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("output-list");
match answer_type {
"output-list" => {
let outputs = json
.get("outputs")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|item| {
let beef = item.get("beef").and_then(|v| {
if let Some(arr) = v.as_array() {
Some(
arr.iter()
.filter_map(|n| n.as_u64().map(|n| n as u8))
.collect(),
)
} else if let Some(s) = v.as_str() {
hex::decode(s).ok().or_else(|| {
base64::Engine::decode(
&base64::engine::general_purpose::STANDARD,
s,
)
.ok()
})
} else {
None
}
})?;
let output_index =
item.get("outputIndex").and_then(|v| v.as_u64())? as u32;
let context = item.get("context").and_then(|v| {
v.as_array().map(|arr| {
arr.iter()
.filter_map(|n| n.as_u64().map(|n| n as u8))
.collect()
})
});
Some(OutputListItem {
beef,
output_index,
context,
})
})
.collect()
})
.unwrap_or_default();
Ok(LookupAnswer::OutputList { outputs })
}
"freeform" => {
let result = json
.get("result")
.cloned()
.unwrap_or(serde_json::Value::Null);
Ok(LookupAnswer::Freeform { result })
}
"formula" => {
let formulas = json
.get("formulas")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
Ok(LookupAnswer::Formula { formulas })
}
_ => Err(Error::OverlayError(format!(
"Unknown answer type: {}",
answer_type
))),
}
}
#[cfg(feature = "http")]
fn parse_binary_lookup_response(data: &[u8]) -> Result<LookupAnswer> {
use crate::primitives::{to_hex, Reader};
let mut reader = Reader::new(data);
let n_outpoints = reader
.read_var_int()
.map_err(|e| Error::OverlayError(format!("Failed to read outpoint count: {}", e)))?
as usize;
let mut outpoints = Vec::with_capacity(n_outpoints);
for _ in 0..n_outpoints {
let txid_bytes = reader
.read_bytes(32)
.map_err(|e| Error::OverlayError(format!("Failed to read txid: {}", e)))?;
let txid = to_hex(txid_bytes);
let output_index = reader
.read_var_int()
.map_err(|e| Error::OverlayError(format!("Failed to read output index: {}", e)))?
as u32;
let context_len = reader
.read_var_int()
.map_err(|e| Error::OverlayError(format!("Failed to read context length: {}", e)))?
as usize;
let context = if context_len > 0 {
Some(
reader
.read_bytes(context_len)
.map_err(|e| Error::OverlayError(format!("Failed to read context: {}", e)))?
.to_vec(),
)
} else {
None
};
outpoints.push((txid, output_index, context));
}
let beef_data = reader.read_remaining().to_vec();
let outputs = outpoints
.into_iter()
.map(|(_txid, output_index, context)| {
OutputListItem {
beef: beef_data.clone(),
output_index,
context,
}
})
.collect();
Ok(LookupAnswer::OutputList { outputs })
}
#[cfg(feature = "http")]
fn varint_encode(n: u64) -> Vec<u8> {
if n < 0xfd {
vec![n as u8]
} else if n <= 0xffff {
let mut buf = vec![0xfd];
buf.extend_from_slice(&(n as u16).to_le_bytes());
buf
} else if n <= 0xffffffff {
let mut buf = vec![0xfe];
buf.extend_from_slice(&(n as u32).to_le_bytes());
buf
} else {
let mut buf = vec![0xff];
buf.extend_from_slice(&n.to_le_bytes());
buf
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "http")]
#[test]
fn test_parse_json_output_list() {
let json = serde_json::json!({
"type": "output-list",
"outputs": [
{
"beef": [1, 2, 3, 4],
"outputIndex": 0,
}
]
});
let answer = parse_json_lookup_answer(json).unwrap();
match answer {
LookupAnswer::OutputList { outputs } => {
assert_eq!(outputs.len(), 1);
assert_eq!(outputs[0].beef, vec![1, 2, 3, 4]);
assert_eq!(outputs[0].output_index, 0);
}
_ => panic!("Expected OutputList"),
}
}
#[cfg(feature = "http")]
#[test]
fn test_parse_json_freeform() {
let json = serde_json::json!({
"type": "freeform",
"result": {"key": "value"}
});
let answer = parse_json_lookup_answer(json).unwrap();
match answer {
LookupAnswer::Freeform { result } => {
assert_eq!(result["key"], "value");
}
_ => panic!("Expected Freeform"),
}
}
#[cfg(feature = "http")]
#[test]
fn test_parse_json_default_type() {
let json = serde_json::json!({
"outputs": []
});
let answer = parse_json_lookup_answer(json).unwrap();
assert!(matches!(answer, LookupAnswer::OutputList { .. }));
}
#[cfg(feature = "http")]
#[test]
fn test_varint_encode() {
assert_eq!(varint_encode(0), vec![0]);
assert_eq!(varint_encode(252), vec![252]);
assert_eq!(varint_encode(253), vec![0xfd, 253, 0]);
assert_eq!(varint_encode(0x1234), vec![0xfd, 0x34, 0x12]);
assert_eq!(
varint_encode(0x12345678),
vec![0xfe, 0x78, 0x56, 0x34, 0x12]
);
}
#[test]
fn test_https_lookup_facilitator_rejects_http() {
let facilitator = HttpsOverlayLookupFacilitator::new(false);
assert!(!facilitator.allow_http);
}
#[test]
fn test_https_lookup_facilitator_allows_http_when_configured() {
let facilitator = HttpsOverlayLookupFacilitator::new(true);
assert!(facilitator.allow_http);
}
}