use anyhow::{Context, Result};
use bsv_wallet_toolbox::{
services::{ArcadeConfig, ARCADE_V2_MAINNET},
ArcConfig, Chain, ServicesOptions,
};
use std::io::Write;
use std::path::PathBuf;
pub const CHAINTRACKS_URL_REQUIRED: &str = "CHAINTRACKS_URL is not set: set it to the header \
service that checks merkle roots (your chaintracks-cloudflare or rust-chaintracks \
deployment), or to `off` to run with none (every merkle proof is then refused). There is \
no default header service.";
pub fn chaintracks_url_for(configured: Option<&str>) -> Result<Option<String>> {
match configured.map(str::trim) {
Some(v) if v.eq_ignore_ascii_case("off") => Ok(None),
Some(v) if !v.is_empty() => Ok(Some(v.to_string())),
_ => anyhow::bail!(CHAINTRACKS_URL_REQUIRED),
}
}
pub const BREAK_GLASS_EXPLORER_HEADERS_ENV: &str = "BREAK_GLASS_EXPLORER_HEADERS";
pub fn break_glass_explorer_headers(configured: Option<&str>) -> bool {
matches!(
configured.map(|v| v.trim().to_ascii_lowercase()).as_deref(),
Some("1" | "true" | "yes")
)
}
pub const BREAK_GLASS_WARNING: &str = "BREAK_GLASS_EXPLORER_HEADERS is on: when the header \
service gives no answer, merkle roots and block headers are asked of WhatsOnChain (then \
Bitails); every such call is logged. Turn it off once the header service is back.";
pub const CHAINTRACKS_OFF_WARNING: &str = "CHAINTRACKS_URL=off: no chain tracker, so every merkle \
proof will be refused and nothing marked proven until CHAINTRACKS_URL is set";
pub fn require_chain_tracker_to_prove(command: &str, has_tracker: bool) -> Result<()> {
if !has_tracker {
anyhow::bail!(
"{command} refused: no chain tracker is configured (CHAINTRACKS_URL=off); \
every merkle proof would be refused. Set CHAINTRACKS_URL to your header service."
);
}
Ok(())
}
#[derive(Debug, Clone)]
pub struct ArcadeRuntime {
pub url: String,
pub callback_token: String,
pub public_callback_url: Option<String>,
}
pub fn arcade_mode_enabled() -> bool {
if let Ok(mode) = std::env::var("ARC_MODE") {
if mode.eq_ignore_ascii_case("arcade") {
return true;
}
}
if let Ok(v) = std::env::var("ARCADE") {
let v = v.trim();
return v == "1" || v.eq_ignore_ascii_case("true") || v.eq_ignore_ascii_case("yes");
}
false
}
pub fn arcade_runtime(db_path: &str) -> Result<Option<ArcadeRuntime>> {
if !arcade_mode_enabled() {
return Ok(None);
}
let url = std::env::var("ARC_URL").unwrap_or_else(|_| ARCADE_V2_MAINNET.to_string());
let callback_token = resolve_callback_token(db_path)?;
let public_callback_url = std::env::var("PUBLIC_CALLBACK_URL")
.ok()
.filter(|s| !s.is_empty());
Ok(Some(ArcadeRuntime {
url,
callback_token,
public_callback_url,
}))
}
pub fn resolve_callback_token(db_path: &str) -> Result<String> {
if let Ok(tok) = std::env::var("CALLBACK_TOKEN") {
let tok = tok.trim().to_string();
if !tok.is_empty() {
return Ok(tok);
}
}
let token_path = callback_token_path(db_path);
if token_path.exists() {
let tok = std::fs::read_to_string(&token_path)
.with_context(|| format!("reading {}", token_path.display()))?
.trim()
.to_string();
if !tok.is_empty() {
return Ok(tok);
}
}
let tok: String = bsv_sdk::primitives::PrivateKey::random()
.to_hex()
.chars()
.take(32)
.collect();
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut f = opts
.open(&token_path)
.with_context(|| format!("creating {}", token_path.display()))?;
f.write_all(tok.as_bytes())?;
tracing::info!(path = %token_path.display(), "generated per-wallet callback token");
Ok(tok)
}
pub fn callback_token_path(db_path: &str) -> PathBuf {
PathBuf::from(format!("{}.callback-token", db_path))
}
pub fn services_options_from_env(chain: Chain, db_path: &str) -> Result<ServicesOptions> {
let mut opts = match chain {
Chain::Main => ServicesOptions::mainnet(),
Chain::Test => ServicesOptions::testnet(),
};
let configured = std::env::var("CHAINTRACKS_URL").ok();
match chaintracks_url_for(configured.as_deref())? {
Some(url) => opts = opts.with_chaintracks_url(url),
None => tracing::warn!("{}", CHAINTRACKS_OFF_WARNING),
}
let break_glass = std::env::var(BREAK_GLASS_EXPLORER_HEADERS_ENV).ok();
if break_glass_explorer_headers(break_glass.as_deref()) {
tracing::warn!(
marker = "break_glass_explorer_header",
"{}",
BREAK_GLASS_WARNING
);
opts = opts.with_break_glass_explorer_headers(true);
}
let mut arc_config: Option<ArcConfig> = None;
if let Ok(key) = std::env::var("TAAL_API_KEY") {
if !key.is_empty() {
arc_config = Some(ArcConfig::with_api_key(key));
}
}
if let Ok(key) = std::env::var("MAIN_TAAL_API_KEY") {
if !key.is_empty() {
let mut headers = std::collections::HashMap::new();
headers.insert("Authorization".to_string(), key);
let mut cfg = arc_config.unwrap_or_default();
cfg.headers = Some(headers);
arc_config = Some(cfg);
}
}
if let Some(runtime) = arcade_runtime(db_path)? {
let arcade_config = ArcadeConfig {
callback_token: Some(runtime.callback_token.clone()),
callback_url: runtime.public_callback_url.clone(),
..Default::default()
};
opts.arc_config = arc_config;
opts = opts.with_arcade(runtime.url, Some(arcade_config));
} else {
let arc_url = std::env::var("ARC_URL")
.ok()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| opts.arc_url.clone());
opts = opts.with_arc(arc_url, arc_config);
}
Ok(opts)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn chaintracks_has_no_default() {
for unset in [None, Some(""), Some(" ")] {
let err = chaintracks_url_for(unset).unwrap_err();
assert!(
err.to_string().contains("CHAINTRACKS_URL is not set"),
"{err}"
);
}
assert!(!CHAINTRACKS_URL_REQUIRED.contains("babbage"));
}
#[test]
fn chaintracks_honours_an_explicit_url() {
assert_eq!(
chaintracks_url_for(Some("https://ct.example/v1"))
.unwrap()
.as_deref(),
Some("https://ct.example/v1")
);
}
#[test]
fn chaintracks_off_disables_validation_on_purpose() {
assert_eq!(chaintracks_url_for(Some("off")).unwrap(), None);
assert_eq!(chaintracks_url_for(Some("OFF")).unwrap(), None);
}
#[test]
fn break_glass_is_off_unless_asked_for() {
for off in [
None,
Some(""),
Some("0"),
Some("false"),
Some("no"),
Some("on"),
] {
assert!(!break_glass_explorer_headers(off), "{off:?}");
}
for on in [Some("1"), Some("true"), Some("YES"), Some(" yes ")] {
assert!(break_glass_explorer_headers(on), "{on:?}");
}
}
#[test]
fn the_off_warning_says_proofs_are_refused() {
assert!(CHAINTRACKS_OFF_WARNING.contains("every merkle proof will be refused"));
assert!(!CHAINTRACKS_OFF_WARNING.contains("stored"));
}
#[test]
fn a_proving_command_needs_a_chain_tracker() {
let err = require_chain_tracker_to_prove("tick", false).unwrap_err();
assert!(err.to_string().starts_with("tick refused"), "{err}");
assert!(err.to_string().contains("CHAINTRACKS_URL=off"), "{err}");
assert!(require_chain_tracker_to_prove("tick", true).is_ok());
}
#[test]
fn callback_token_path_is_next_to_db() {
let p = callback_token_path("/tmp/wallet.db");
assert_eq!(p, PathBuf::from("/tmp/wallet.db.callback-token"));
}
#[test]
fn generated_token_is_32_hex_and_persisted() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("w.db");
let db = db.to_str().unwrap();
std::env::remove_var("CALLBACK_TOKEN");
let tok = resolve_callback_token(db).unwrap();
assert_eq!(tok.len(), 32);
assert!(tok.chars().all(|c| c.is_ascii_hexdigit()));
let tok2 = resolve_callback_token(db).unwrap();
assert_eq!(tok, tok2);
assert!(callback_token_path(db).exists());
}
}