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` | Chaintracks header service for proof validation |
12//! | `ARC_URL` | Override the broadcaster URL (classic ARC, or the Arcade endpoint in Arcade mode) |
13//! | `ARC_MODE=arcade` or `ARCADE=1` | Arcade V2 mode: EF-only submit, SSE status stream, push proofs |
14//! | `CALLBACK_TOKEN` | Override the per-wallet callback token (otherwise auto-generated and persisted next to the db) |
15//! | `PUBLIC_CALLBACK_URL` | Public HTTPS URL Arcade should POST status webhooks to (`X-CallbackUrl`) |
16//! | `TAAL_API_KEY` | TAAL ARC key sent as `Authorization: Bearer <key>` |
17//! | `MAIN_TAAL_API_KEY` | TAAL ARC key sent as raw `Authorization: <key>` (TAAL accepts no Bearer prefix) |
18
19use anyhow::{Context, Result};
20use bsv_wallet_toolbox::{
21    services::{ArcadeConfig, ARCADE_V2_MAINNET},
22    ArcConfig, Chain, ServicesOptions,
23};
24use std::io::Write;
25use std::path::PathBuf;
26
27/// Resolved Arcade V2 runtime settings (present only in Arcade mode).
28#[derive(Debug, Clone)]
29pub struct ArcadeRuntime {
30    /// Arcade base URL (from `ARC_URL`, default [`ARCADE_V2_MAINNET`]).
31    pub url: String,
32    /// Per-wallet callback token (env override or persisted next to the db).
33    pub callback_token: String,
34    /// Public HTTPS webhook URL passed as `X-CallbackUrl` on submits.
35    pub public_callback_url: Option<String>,
36}
37
38/// Whether Arcade V2 mode is selected via env (`ARC_MODE=arcade` or `ARCADE=1`).
39pub fn arcade_mode_enabled() -> bool {
40    if let Ok(mode) = std::env::var("ARC_MODE") {
41        if mode.eq_ignore_ascii_case("arcade") {
42            return true;
43        }
44    }
45    if let Ok(v) = std::env::var("ARCADE") {
46        let v = v.trim();
47        return v == "1" || v.eq_ignore_ascii_case("true") || v.eq_ignore_ascii_case("yes");
48    }
49    false
50}
51
52/// Resolve the Arcade runtime settings, or `None` when not in Arcade mode.
53///
54/// `db_path` locates the persisted per-wallet callback token
55/// (`<db>.callback-token`), giving each wallet db/port its own independent
56/// SSE stream and webhook identity.
57pub fn arcade_runtime(db_path: &str) -> Result<Option<ArcadeRuntime>> {
58    if !arcade_mode_enabled() {
59        return Ok(None);
60    }
61    let url = std::env::var("ARC_URL").unwrap_or_else(|_| ARCADE_V2_MAINNET.to_string());
62    let callback_token = resolve_callback_token(db_path)?;
63    let public_callback_url = std::env::var("PUBLIC_CALLBACK_URL")
64        .ok()
65        .filter(|s| !s.is_empty());
66    Ok(Some(ArcadeRuntime {
67        url,
68        callback_token,
69        public_callback_url,
70    }))
71}
72
73/// Resolve the per-wallet callback token.
74///
75/// Priority: `CALLBACK_TOKEN` env → persisted `<db>.callback-token` file →
76/// auto-generate a random 32-hex token and persist it (0600 on unix).
77/// The token is NEVER logged.
78pub fn resolve_callback_token(db_path: &str) -> Result<String> {
79    if let Ok(tok) = std::env::var("CALLBACK_TOKEN") {
80        let tok = tok.trim().to_string();
81        if !tok.is_empty() {
82            return Ok(tok);
83        }
84    }
85
86    let token_path = callback_token_path(db_path);
87    if token_path.exists() {
88        let tok = std::fs::read_to_string(&token_path)
89            .with_context(|| format!("reading {}", token_path.display()))?
90            .trim()
91            .to_string();
92        if !tok.is_empty() {
93            return Ok(tok);
94        }
95    }
96
97    // Generate: 16 random bytes → 32 hex chars. PrivateKey::random() is the
98    // CSPRNG already in the dependency tree; we take half its bytes.
99    let tok: String = bsv_sdk::primitives::PrivateKey::random()
100        .to_hex()
101        .chars()
102        .take(32)
103        .collect();
104
105    let mut opts = std::fs::OpenOptions::new();
106    opts.write(true).create_new(true);
107    #[cfg(unix)]
108    {
109        use std::os::unix::fs::OpenOptionsExt;
110        opts.mode(0o600);
111    }
112    let mut f = opts
113        .open(&token_path)
114        .with_context(|| format!("creating {}", token_path.display()))?;
115    f.write_all(tok.as_bytes())?;
116    tracing::info!(path = %token_path.display(), "generated per-wallet callback token");
117    Ok(tok)
118}
119
120/// Where the per-wallet callback token lives: `<db>.callback-token`
121/// (covered by the repo's `*.db*` gitignore pattern).
122pub fn callback_token_path(db_path: &str) -> PathBuf {
123    PathBuf::from(format!("{}.callback-token", db_path))
124}
125
126/// Build `ServicesOptions` from the environment (the ONE shared helper).
127///
128/// `db_path` is used only in Arcade mode, to resolve the persisted callback
129/// token.
130pub fn services_options_from_env(chain: Chain, db_path: &str) -> Result<ServicesOptions> {
131    let mut opts = match chain {
132        Chain::Main => ServicesOptions::mainnet(),
133        Chain::Test => ServicesOptions::testnet(),
134    };
135
136    if let Ok(url) = std::env::var("CHAINTRACKS_URL") {
137        if !url.is_empty() {
138            opts = opts.with_chaintracks_url(url);
139        }
140    }
141
142    // TAAL ARC auth (applies to the classic ARC provider — in Arcade mode
143    // that provider is the failover behind Arcade).
144    // - TAAL_API_KEY       → `Authorization: Bearer <key>`
145    // - MAIN_TAAL_API_KEY  → raw `Authorization: <key>` (TAAL accepts the key
146    //   WITHOUT the Bearer prefix; kept for backward compatibility with
147    //   existing daemon deployments).
148    let mut arc_config: Option<ArcConfig> = None;
149    if let Ok(key) = std::env::var("TAAL_API_KEY") {
150        if !key.is_empty() {
151            arc_config = Some(ArcConfig::with_api_key(key));
152        }
153    }
154    if let Ok(key) = std::env::var("MAIN_TAAL_API_KEY") {
155        if !key.is_empty() {
156            let mut headers = std::collections::HashMap::new();
157            headers.insert("Authorization".to_string(), key);
158            let mut cfg = arc_config.unwrap_or_default();
159            cfg.headers = Some(headers);
160            arc_config = Some(cfg);
161        }
162    }
163
164    if let Some(runtime) = arcade_runtime(db_path)? {
165        // Arcade V2 mode: explicit flag on ServicesOptions (never inferred
166        // from the URL). Classic ARC config still applies to the TAAL
167        // failover provider.
168        let arcade_config = ArcadeConfig {
169            callback_token: Some(runtime.callback_token.clone()),
170            callback_url: runtime.public_callback_url.clone(),
171            ..Default::default()
172        };
173        opts.arc_config = arc_config;
174        opts = opts.with_arcade(runtime.url, Some(arcade_config));
175    } else {
176        // Classic ARC: honor ARC_URL override, else keep the chain default.
177        let arc_url = std::env::var("ARC_URL")
178            .ok()
179            .filter(|s| !s.is_empty())
180            .unwrap_or_else(|| opts.arc_url.clone());
181        opts = opts.with_arc(arc_url, arc_config);
182    }
183
184    Ok(opts)
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    #[test]
192    fn callback_token_path_is_next_to_db() {
193        let p = callback_token_path("/tmp/wallet.db");
194        assert_eq!(p, PathBuf::from("/tmp/wallet.db.callback-token"));
195    }
196
197    #[test]
198    fn generated_token_is_32_hex_and_persisted() {
199        let dir = tempfile::tempdir().unwrap();
200        let db = dir.path().join("w.db");
201        let db = db.to_str().unwrap();
202
203        // No env override in effect for this name; read/created from file.
204        std::env::remove_var("CALLBACK_TOKEN");
205        let tok = resolve_callback_token(db).unwrap();
206        assert_eq!(tok.len(), 32);
207        assert!(tok.chars().all(|c| c.is_ascii_hexdigit()));
208
209        // Stable on re-read.
210        let tok2 = resolve_callback_token(db).unwrap();
211        assert_eq!(tok, tok2);
212
213        // Persisted next to the db.
214        assert!(callback_token_path(db).exists());
215    }
216}