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 (default: the public Babbage instance for the chain; `off` disables validation on purpose) |
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/// Header service used to validate merkle proofs when `CHAINTRACKS_URL` is
28/// unset: the public Babbage chaintracks for the chain, the same default the
29/// TS toolbox and MetaNet Desktop ship with. The toolbox keeps a
30/// WhatsOnChain header fallback behind it.
31pub const DEFAULT_MAINNET_CHAINTRACKS_URL: &str = "https://mainnet-chaintracks.babbage.systems";
32/// Testnet counterpart of [`DEFAULT_MAINNET_CHAINTRACKS_URL`].
33pub const DEFAULT_TESTNET_CHAINTRACKS_URL: &str = "https://testnet-chaintracks.babbage.systems";
34
35/// Resolve the header service from the `CHAINTRACKS_URL` value.
36///
37/// Unset or empty falls back to the chain's public default; `off` (any
38/// case) returns `None` and the wallet stores proofs unvalidated, which is
39/// only ever right for an offline or air-gapped run. Without a header
40/// service every proof that reaches the wallet (webhook, SSE, monitor,
41/// `tick`) would be taken on the broadcaster's word.
42pub fn chaintracks_url_for(chain: Chain, configured: Option<&str>) -> Option<String> {
43    match configured.map(str::trim) {
44        Some(v) if v.eq_ignore_ascii_case("off") => None,
45        Some(v) if !v.is_empty() => Some(v.to_string()),
46        _ => Some(
47            match chain {
48                Chain::Main => DEFAULT_MAINNET_CHAINTRACKS_URL,
49                Chain::Test => DEFAULT_TESTNET_CHAINTRACKS_URL,
50            }
51            .to_string(),
52        ),
53    }
54}
55
56/// Resolved Arcade V2 runtime settings (present only in Arcade mode).
57#[derive(Debug, Clone)]
58pub struct ArcadeRuntime {
59    /// Arcade base URL (from `ARC_URL`, default [`ARCADE_V2_MAINNET`]).
60    pub url: String,
61    /// Per-wallet callback token (env override or persisted next to the db).
62    pub callback_token: String,
63    /// Public HTTPS webhook URL passed as `X-CallbackUrl` on submits.
64    pub public_callback_url: Option<String>,
65}
66
67/// Whether Arcade V2 mode is selected via env (`ARC_MODE=arcade` or `ARCADE=1`).
68pub fn arcade_mode_enabled() -> bool {
69    if let Ok(mode) = std::env::var("ARC_MODE") {
70        if mode.eq_ignore_ascii_case("arcade") {
71            return true;
72        }
73    }
74    if let Ok(v) = std::env::var("ARCADE") {
75        let v = v.trim();
76        return v == "1" || v.eq_ignore_ascii_case("true") || v.eq_ignore_ascii_case("yes");
77    }
78    false
79}
80
81/// Resolve the Arcade runtime settings, or `None` when not in Arcade mode.
82///
83/// `db_path` locates the persisted per-wallet callback token
84/// (`<db>.callback-token`), giving each wallet db/port its own independent
85/// SSE stream and webhook identity.
86pub fn arcade_runtime(db_path: &str) -> Result<Option<ArcadeRuntime>> {
87    if !arcade_mode_enabled() {
88        return Ok(None);
89    }
90    let url = std::env::var("ARC_URL").unwrap_or_else(|_| ARCADE_V2_MAINNET.to_string());
91    let callback_token = resolve_callback_token(db_path)?;
92    let public_callback_url = std::env::var("PUBLIC_CALLBACK_URL")
93        .ok()
94        .filter(|s| !s.is_empty());
95    Ok(Some(ArcadeRuntime {
96        url,
97        callback_token,
98        public_callback_url,
99    }))
100}
101
102/// Resolve the per-wallet callback token.
103///
104/// Priority: `CALLBACK_TOKEN` env → persisted `<db>.callback-token` file →
105/// auto-generate a random 32-hex token and persist it (0600 on unix).
106/// The token is NEVER logged.
107pub fn resolve_callback_token(db_path: &str) -> Result<String> {
108    if let Ok(tok) = std::env::var("CALLBACK_TOKEN") {
109        let tok = tok.trim().to_string();
110        if !tok.is_empty() {
111            return Ok(tok);
112        }
113    }
114
115    let token_path = callback_token_path(db_path);
116    if token_path.exists() {
117        let tok = std::fs::read_to_string(&token_path)
118            .with_context(|| format!("reading {}", token_path.display()))?
119            .trim()
120            .to_string();
121        if !tok.is_empty() {
122            return Ok(tok);
123        }
124    }
125
126    // Generate: 16 random bytes → 32 hex chars. PrivateKey::random() is the
127    // CSPRNG already in the dependency tree; we take half its bytes.
128    let tok: String = bsv_sdk::primitives::PrivateKey::random()
129        .to_hex()
130        .chars()
131        .take(32)
132        .collect();
133
134    let mut opts = std::fs::OpenOptions::new();
135    opts.write(true).create_new(true);
136    #[cfg(unix)]
137    {
138        use std::os::unix::fs::OpenOptionsExt;
139        opts.mode(0o600);
140    }
141    let mut f = opts
142        .open(&token_path)
143        .with_context(|| format!("creating {}", token_path.display()))?;
144    f.write_all(tok.as_bytes())?;
145    tracing::info!(path = %token_path.display(), "generated per-wallet callback token");
146    Ok(tok)
147}
148
149/// Where the per-wallet callback token lives: `<db>.callback-token`
150/// (covered by the repo's `*.db*` gitignore pattern).
151pub fn callback_token_path(db_path: &str) -> PathBuf {
152    PathBuf::from(format!("{}.callback-token", db_path))
153}
154
155/// Build `ServicesOptions` from the environment (the ONE shared helper).
156///
157/// `db_path` is used only in Arcade mode, to resolve the persisted callback
158/// token.
159pub fn services_options_from_env(chain: Chain, db_path: &str) -> Result<ServicesOptions> {
160    let mut opts = match chain {
161        Chain::Main => ServicesOptions::mainnet(),
162        Chain::Test => ServicesOptions::testnet(),
163    };
164
165    let configured = std::env::var("CHAINTRACKS_URL").ok();
166    match chaintracks_url_for(chain, configured.as_deref()) {
167        Some(url) => opts = opts.with_chaintracks_url(url),
168        None => tracing::warn!(
169            "CHAINTRACKS_URL=off: merkle proofs will be stored without header validation"
170        ),
171    }
172
173    // TAAL ARC auth (applies to the classic ARC provider — in Arcade mode
174    // that provider is the failover behind Arcade).
175    // - TAAL_API_KEY       → `Authorization: Bearer <key>`
176    // - MAIN_TAAL_API_KEY  → raw `Authorization: <key>` (TAAL accepts the key
177    //   WITHOUT the Bearer prefix; kept for backward compatibility with
178    //   existing daemon deployments).
179    let mut arc_config: Option<ArcConfig> = None;
180    if let Ok(key) = std::env::var("TAAL_API_KEY") {
181        if !key.is_empty() {
182            arc_config = Some(ArcConfig::with_api_key(key));
183        }
184    }
185    if let Ok(key) = std::env::var("MAIN_TAAL_API_KEY") {
186        if !key.is_empty() {
187            let mut headers = std::collections::HashMap::new();
188            headers.insert("Authorization".to_string(), key);
189            let mut cfg = arc_config.unwrap_or_default();
190            cfg.headers = Some(headers);
191            arc_config = Some(cfg);
192        }
193    }
194
195    if let Some(runtime) = arcade_runtime(db_path)? {
196        // Arcade V2 mode: explicit flag on ServicesOptions (never inferred
197        // from the URL). Classic ARC config still applies to the TAAL
198        // failover provider.
199        let arcade_config = ArcadeConfig {
200            callback_token: Some(runtime.callback_token.clone()),
201            callback_url: runtime.public_callback_url.clone(),
202            ..Default::default()
203        };
204        opts.arc_config = arc_config;
205        opts = opts.with_arcade(runtime.url, Some(arcade_config));
206    } else {
207        // Classic ARC: honor ARC_URL override, else keep the chain default.
208        let arc_url = std::env::var("ARC_URL")
209            .ok()
210            .filter(|s| !s.is_empty())
211            .unwrap_or_else(|| opts.arc_url.clone());
212        opts = opts.with_arc(arc_url, arc_config);
213    }
214
215    Ok(opts)
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    #[test]
223    fn chaintracks_defaults_to_the_public_instance_for_the_chain() {
224        assert_eq!(
225            chaintracks_url_for(Chain::Main, None).as_deref(),
226            Some(DEFAULT_MAINNET_CHAINTRACKS_URL)
227        );
228        assert_eq!(
229            chaintracks_url_for(Chain::Test, Some("")).as_deref(),
230            Some(DEFAULT_TESTNET_CHAINTRACKS_URL)
231        );
232        assert_eq!(
233            chaintracks_url_for(Chain::Main, Some("   ")).as_deref(),
234            Some(DEFAULT_MAINNET_CHAINTRACKS_URL)
235        );
236    }
237
238    #[test]
239    fn chaintracks_honours_an_explicit_url() {
240        assert_eq!(
241            chaintracks_url_for(Chain::Main, Some("https://ct.example/v1")).as_deref(),
242            Some("https://ct.example/v1")
243        );
244    }
245
246    #[test]
247    fn chaintracks_off_disables_validation_on_purpose() {
248        assert_eq!(chaintracks_url_for(Chain::Main, Some("off")), None);
249        assert_eq!(chaintracks_url_for(Chain::Test, Some("OFF")), None);
250    }
251
252    #[test]
253    fn callback_token_path_is_next_to_db() {
254        let p = callback_token_path("/tmp/wallet.db");
255        assert_eq!(p, PathBuf::from("/tmp/wallet.db.callback-token"));
256    }
257
258    #[test]
259    fn generated_token_is_32_hex_and_persisted() {
260        let dir = tempfile::tempdir().unwrap();
261        let db = dir.path().join("w.db");
262        let db = db.to_str().unwrap();
263
264        // No env override in effect for this name; read/created from file.
265        std::env::remove_var("CALLBACK_TOKEN");
266        let tok = resolve_callback_token(db).unwrap();
267        assert_eq!(tok.len(), 32);
268        assert!(tok.chars().all(|c| c.is_ascii_hexdigit()));
269
270        // Stable on re-read.
271        let tok2 = resolve_callback_token(db).unwrap();
272        assert_eq!(tok, tok2);
273
274        // Persisted next to the db.
275        assert!(callback_token_path(db).exists());
276    }
277}