Skip to main content

bsv_wallet_cli/
services_env.rs

1//! Single source of truth for building `ServicesOptions` from the environment.
2//!
3//! Previously `context.rs`, `commands/daemon.rs`, `commands/tick.rs`, and
4//! `commands/services.rs` each rolled their own (subtly divergent) services
5//! setup. This module unifies them and adds the Arcade V2 surface.
6//!
7//! # Environment variables
8//!
9//! | Var | Effect |
10//! |-----|--------|
11//! | `CHAINTRACKS_URL` | Required: the header service that checks every merkle root (your `chaintracks-cloudflare` or rust-chaintracks deployment). Unset or empty is refused: there is no third-party default. `off` = no header service, so every proof is refused and nothing is marked proven |
12//! | `BREAK_GLASS_EXPLORER_HEADERS` | Break-glass, off by default: `1`, `true` or `yes` lets the proof path ask WhatsOnChain (then Bitails) for headers when the header service gives no answer; every such call is logged at warn level (marker `break_glass_explorer_header`) |
13//! | `ARC_URL` | Override the broadcaster URL (classic ARC, or the Arcade endpoint in Arcade mode) |
14//! | `ARC_MODE=arcade` or `ARCADE=1` | Arcade V2 mode: EF-only submit, SSE status stream, push proofs |
15//! | `CALLBACK_TOKEN` | Override the per-wallet callback token (otherwise auto-generated and persisted next to the db) |
16//! | `PUBLIC_CALLBACK_URL` | Public HTTPS URL Arcade should POST status webhooks to (`X-CallbackUrl`) |
17//! | `TAAL_API_KEY` | TAAL ARC key sent as `Authorization: Bearer <key>` |
18//! | `MAIN_TAAL_API_KEY` | TAAL ARC key sent as raw `Authorization: <key>` (TAAL accepts no Bearer prefix) |
19
20use 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
28/// What an unset or empty `CHAINTRACKS_URL` is told. No explorer in the
29/// proof path (P0-1c, bsv-stack-lean #48): the header source is the
30/// operator's own service, and ours (`chaintracks-cloudflare`) documents no
31/// public URL to default to.
32pub 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
37/// Resolve the header service from the `CHAINTRACKS_URL` value.
38///
39/// A URL is the header service. `off` (any case) returns `None`: the
40/// wallet has no chain tracker, and the toolbox refuses every proof that
41/// reaches it (webhook, SSE, monitor, relay) rather than take it on the
42/// broadcaster's word, so nothing is marked proven; only ever right for an
43/// offline or air-gapped run. Unset or empty is an error
44/// ([`CHAINTRACKS_URL_REQUIRED`]): there is no default.
45pub 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
53/// The environment variable that turns the break-glass explorer header
54/// fallback on (the toolbox's `ServicesOptions::break_glass_explorer_headers`).
55pub const BREAK_GLASS_EXPLORER_HEADERS_ENV: &str = "BREAK_GLASS_EXPLORER_HEADERS";
56
57/// Is the break-glass explorer header fallback asked for? `1`, `true` or
58/// `yes` (any case); anything else, or unset, is off.
59pub 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
66/// What a wallet with the break-glass setting on is told at startup.
67pub 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
71/// What a wallet with `CHAINTRACKS_URL=off` is told at startup.
72pub 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
75/// Refuse a command whose job is to prove when no chain tracker is
76/// configured: it could only refuse every proof it met, so it exits
77/// non-zero instead of reporting a run that proved nothing.
78pub 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/// Resolved Arcade V2 runtime settings (present only in Arcade mode).
89#[derive(Debug, Clone)]
90pub struct ArcadeRuntime {
91    /// Arcade base URL (from `ARC_URL`, default [`ARCADE_V2_MAINNET`]).
92    pub url: String,
93    /// Per-wallet callback token (env override or persisted next to the db).
94    pub callback_token: String,
95    /// Public HTTPS webhook URL passed as `X-CallbackUrl` on submits.
96    pub public_callback_url: Option<String>,
97}
98
99/// Whether Arcade V2 mode is selected via env (`ARC_MODE=arcade` or `ARCADE=1`).
100pub 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
113/// Resolve the Arcade runtime settings, or `None` when not in Arcade mode.
114///
115/// `db_path` locates the persisted per-wallet callback token
116/// (`<db>.callback-token`), giving each wallet db/port its own independent
117/// SSE stream and webhook identity.
118pub 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
134/// Resolve the per-wallet callback token.
135///
136/// Priority: `CALLBACK_TOKEN` env → persisted `<db>.callback-token` file →
137/// auto-generate a random 32-hex token and persist it (0600 on unix).
138/// The token is NEVER logged.
139pub 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    // Generate: 16 random bytes → 32 hex chars. PrivateKey::random() is the
159    // CSPRNG already in the dependency tree; we take half its bytes.
160    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
181/// Where the per-wallet callback token lives: `<db>.callback-token`
182/// (covered by the repo's `*.db*` gitignore pattern).
183pub fn callback_token_path(db_path: &str) -> PathBuf {
184    PathBuf::from(format!("{}.callback-token", db_path))
185}
186
187/// Build `ServicesOptions` from the environment (the ONE shared helper).
188///
189/// `db_path` is used only in Arcade mode, to resolve the persisted callback
190/// token.
191pub 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    // TAAL ARC auth (applies to the classic ARC provider — in Arcade mode
213    // that provider is the failover behind Arcade).
214    // - TAAL_API_KEY       → `Authorization: Bearer <key>`
215    // - MAIN_TAAL_API_KEY  → raw `Authorization: <key>` (TAAL accepts the key
216    //   WITHOUT the Bearer prefix; kept for backward compatibility with
217    //   existing daemon deployments).
218    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        // Arcade V2 mode: explicit flag on ServicesOptions (never inferred
236        // from the URL). Classic ARC config still applies to the TAAL
237        // failover provider.
238        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        // Classic ARC: honor ARC_URL override, else keep the chain default.
247        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        // No env override in effect for this name; read/created from file.
333        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        // Stable on re-read.
339        let tok2 = resolve_callback_token(db).unwrap();
340        assert_eq!(tok, tok2);
341
342        // Persisted next to the db.
343        assert!(callback_token_path(db).exists());
344    }
345}