bsv_wallet_cli/
services_env.rs1use anyhow::{Context, Result};
21use bsv_wallet_toolbox::{
22 services::{ArcadeConfig, ARCADE_V2_MAINNET},
23 ArcConfig, Chain, ServicesOptions,
24};
25use std::io::Write;
26use std::path::PathBuf;
27
28pub const CHAINTRACKS_URL_REQUIRED: &str = "CHAINTRACKS_URL is not set: set it to the header \
33 service that checks merkle roots (your chaintracks-cloudflare or rust-chaintracks \
34 deployment), or to `off` to run with none (every merkle proof is then refused). There is \
35 no default header service.";
36
37pub fn chaintracks_url_for(configured: Option<&str>) -> Result<Option<String>> {
46 match configured.map(str::trim) {
47 Some(v) if v.eq_ignore_ascii_case("off") => Ok(None),
48 Some(v) if !v.is_empty() => Ok(Some(v.to_string())),
49 _ => anyhow::bail!(CHAINTRACKS_URL_REQUIRED),
50 }
51}
52
53pub const BREAK_GLASS_EXPLORER_HEADERS_ENV: &str = "BREAK_GLASS_EXPLORER_HEADERS";
56
57pub fn break_glass_explorer_headers(configured: Option<&str>) -> bool {
60 matches!(
61 configured.map(|v| v.trim().to_ascii_lowercase()).as_deref(),
62 Some("1" | "true" | "yes")
63 )
64}
65
66pub const BREAK_GLASS_WARNING: &str = "BREAK_GLASS_EXPLORER_HEADERS is on: when the header \
68 service gives no answer, merkle roots and block headers are asked of WhatsOnChain (then \
69 Bitails); every such call is logged. Turn it off once the header service is back.";
70
71pub const CHAINTRACKS_OFF_WARNING: &str = "CHAINTRACKS_URL=off: no chain tracker, so every merkle \
73 proof will be refused and nothing marked proven until CHAINTRACKS_URL is set";
74
75pub fn require_chain_tracker_to_prove(command: &str, has_tracker: bool) -> Result<()> {
79 if !has_tracker {
80 anyhow::bail!(
81 "{command} refused: no chain tracker is configured (CHAINTRACKS_URL=off); \
82 every merkle proof would be refused. Set CHAINTRACKS_URL to your header service."
83 );
84 }
85 Ok(())
86}
87
88#[derive(Debug, Clone)]
90pub struct ArcadeRuntime {
91 pub url: String,
93 pub callback_token: String,
95 pub public_callback_url: Option<String>,
97}
98
99pub fn arcade_mode_enabled() -> bool {
101 if let Ok(mode) = std::env::var("ARC_MODE") {
102 if mode.eq_ignore_ascii_case("arcade") {
103 return true;
104 }
105 }
106 if let Ok(v) = std::env::var("ARCADE") {
107 let v = v.trim();
108 return v == "1" || v.eq_ignore_ascii_case("true") || v.eq_ignore_ascii_case("yes");
109 }
110 false
111}
112
113pub fn arcade_runtime(db_path: &str) -> Result<Option<ArcadeRuntime>> {
119 if !arcade_mode_enabled() {
120 return Ok(None);
121 }
122 let url = std::env::var("ARC_URL").unwrap_or_else(|_| ARCADE_V2_MAINNET.to_string());
123 let callback_token = resolve_callback_token(db_path)?;
124 let public_callback_url = std::env::var("PUBLIC_CALLBACK_URL")
125 .ok()
126 .filter(|s| !s.is_empty());
127 Ok(Some(ArcadeRuntime {
128 url,
129 callback_token,
130 public_callback_url,
131 }))
132}
133
134pub fn resolve_callback_token(db_path: &str) -> Result<String> {
140 if let Ok(tok) = std::env::var("CALLBACK_TOKEN") {
141 let tok = tok.trim().to_string();
142 if !tok.is_empty() {
143 return Ok(tok);
144 }
145 }
146
147 let token_path = callback_token_path(db_path);
148 if token_path.exists() {
149 let tok = std::fs::read_to_string(&token_path)
150 .with_context(|| format!("reading {}", token_path.display()))?
151 .trim()
152 .to_string();
153 if !tok.is_empty() {
154 return Ok(tok);
155 }
156 }
157
158 let tok: String = bsv_sdk::primitives::PrivateKey::random()
161 .to_hex()
162 .chars()
163 .take(32)
164 .collect();
165
166 let mut opts = std::fs::OpenOptions::new();
167 opts.write(true).create_new(true);
168 #[cfg(unix)]
169 {
170 use std::os::unix::fs::OpenOptionsExt;
171 opts.mode(0o600);
172 }
173 let mut f = opts
174 .open(&token_path)
175 .with_context(|| format!("creating {}", token_path.display()))?;
176 f.write_all(tok.as_bytes())?;
177 tracing::info!(path = %token_path.display(), "generated per-wallet callback token");
178 Ok(tok)
179}
180
181pub fn callback_token_path(db_path: &str) -> PathBuf {
184 PathBuf::from(format!("{}.callback-token", db_path))
185}
186
187pub fn services_options_from_env(chain: Chain, db_path: &str) -> Result<ServicesOptions> {
192 let mut opts = match chain {
193 Chain::Main => ServicesOptions::mainnet(),
194 Chain::Test => ServicesOptions::testnet(),
195 };
196
197 let configured = std::env::var("CHAINTRACKS_URL").ok();
198 match chaintracks_url_for(configured.as_deref())? {
199 Some(url) => opts = opts.with_chaintracks_url(url),
200 None => tracing::warn!("{}", CHAINTRACKS_OFF_WARNING),
201 }
202 let break_glass = std::env::var(BREAK_GLASS_EXPLORER_HEADERS_ENV).ok();
203 if break_glass_explorer_headers(break_glass.as_deref()) {
204 tracing::warn!(
205 marker = "break_glass_explorer_header",
206 "{}",
207 BREAK_GLASS_WARNING
208 );
209 opts = opts.with_break_glass_explorer_headers(true);
210 }
211
212 let mut arc_config: Option<ArcConfig> = None;
219 if let Ok(key) = std::env::var("TAAL_API_KEY") {
220 if !key.is_empty() {
221 arc_config = Some(ArcConfig::with_api_key(key));
222 }
223 }
224 if let Ok(key) = std::env::var("MAIN_TAAL_API_KEY") {
225 if !key.is_empty() {
226 let mut headers = std::collections::HashMap::new();
227 headers.insert("Authorization".to_string(), key);
228 let mut cfg = arc_config.unwrap_or_default();
229 cfg.headers = Some(headers);
230 arc_config = Some(cfg);
231 }
232 }
233
234 if let Some(runtime) = arcade_runtime(db_path)? {
235 let arcade_config = ArcadeConfig {
239 callback_token: Some(runtime.callback_token.clone()),
240 callback_url: runtime.public_callback_url.clone(),
241 ..Default::default()
242 };
243 opts.arc_config = arc_config;
244 opts = opts.with_arcade(runtime.url, Some(arcade_config));
245 } else {
246 let arc_url = std::env::var("ARC_URL")
248 .ok()
249 .filter(|s| !s.is_empty())
250 .unwrap_or_else(|| opts.arc_url.clone());
251 opts = opts.with_arc(arc_url, arc_config);
252 }
253
254 Ok(opts)
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260
261 #[test]
262 fn chaintracks_has_no_default() {
263 for unset in [None, Some(""), Some(" ")] {
264 let err = chaintracks_url_for(unset).unwrap_err();
265 assert!(
266 err.to_string().contains("CHAINTRACKS_URL is not set"),
267 "{err}"
268 );
269 }
270 assert!(!CHAINTRACKS_URL_REQUIRED.contains("babbage"));
271 }
272
273 #[test]
274 fn chaintracks_honours_an_explicit_url() {
275 assert_eq!(
276 chaintracks_url_for(Some("https://ct.example/v1"))
277 .unwrap()
278 .as_deref(),
279 Some("https://ct.example/v1")
280 );
281 }
282
283 #[test]
284 fn chaintracks_off_disables_validation_on_purpose() {
285 assert_eq!(chaintracks_url_for(Some("off")).unwrap(), None);
286 assert_eq!(chaintracks_url_for(Some("OFF")).unwrap(), None);
287 }
288
289 #[test]
290 fn break_glass_is_off_unless_asked_for() {
291 for off in [
292 None,
293 Some(""),
294 Some("0"),
295 Some("false"),
296 Some("no"),
297 Some("on"),
298 ] {
299 assert!(!break_glass_explorer_headers(off), "{off:?}");
300 }
301 for on in [Some("1"), Some("true"), Some("YES"), Some(" yes ")] {
302 assert!(break_glass_explorer_headers(on), "{on:?}");
303 }
304 }
305
306 #[test]
307 fn the_off_warning_says_proofs_are_refused() {
308 assert!(CHAINTRACKS_OFF_WARNING.contains("every merkle proof will be refused"));
309 assert!(!CHAINTRACKS_OFF_WARNING.contains("stored"));
310 }
311
312 #[test]
313 fn a_proving_command_needs_a_chain_tracker() {
314 let err = require_chain_tracker_to_prove("tick", false).unwrap_err();
315 assert!(err.to_string().starts_with("tick refused"), "{err}");
316 assert!(err.to_string().contains("CHAINTRACKS_URL=off"), "{err}");
317 assert!(require_chain_tracker_to_prove("tick", true).is_ok());
318 }
319
320 #[test]
321 fn callback_token_path_is_next_to_db() {
322 let p = callback_token_path("/tmp/wallet.db");
323 assert_eq!(p, PathBuf::from("/tmp/wallet.db.callback-token"));
324 }
325
326 #[test]
327 fn generated_token_is_32_hex_and_persisted() {
328 let dir = tempfile::tempdir().unwrap();
329 let db = dir.path().join("w.db");
330 let db = db.to_str().unwrap();
331
332 std::env::remove_var("CALLBACK_TOKEN");
334 let tok = resolve_callback_token(db).unwrap();
335 assert_eq!(tok.len(), 32);
336 assert!(tok.chars().all(|c| c.is_ascii_hexdigit()));
337
338 let tok2 = resolve_callback_token(db).unwrap();
340 assert_eq!(tok, tok2);
341
342 assert!(callback_token_path(db).exists());
344 }
345}