Skip to main content

ch_rds/dev/
btcpay.rs

1//! Local BTCPay Server bootstrap helpers for development runners.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::env;
5use std::fmt;
6use std::fs;
7use std::path::{Path, PathBuf};
8use std::process::Command;
9use std::time::{Duration, Instant};
10
11use anyhow::{Context, Result, anyhow, bail, ensure};
12use base64::Engine;
13use reqwest::StatusCode;
14use reqwest::blocking::{Client, RequestBuilder};
15use ripemd::{Digest as RipemdDigest, Ripemd160};
16use secp256k1::{PublicKey, Secp256k1, SecretKey};
17use serde::Deserialize;
18use serde_json::{Value, json};
19use sha2::{Digest as ShaDigest, Sha256};
20use uuid::Uuid;
21
22const EXTENDED_PRIVATE_TO_PUBLIC_VERSIONS: &[([u8; 4], [u8; 4])] = &[
23    ([0x04, 0x88, 0xad, 0xe4], [0x04, 0x88, 0xb2, 0x1e]),
24    ([0x04, 0x35, 0x83, 0x94], [0x04, 0x35, 0x87, 0xcf]),
25];
26
27/// Project-owned metadata used by the local BTCPay development adapter.
28#[derive(Clone, Eq, PartialEq)]
29pub struct BtcpayDevProfile {
30    display_name: String,
31    env_prefix: String,
32    store_name: String,
33    api_key_label: String,
34    rate_source: String,
35    webhook_path: String,
36    default_admin_email: String,
37    public_url: String,
38    local_docker: LocalDockerConfig,
39    default_admin_password: Option<String>,
40    default_payment_signing_key: Option<String>,
41}
42
43impl BtcpayDevProfile {
44    /// Creates a local BTCPay profile with conservative development defaults.
45    pub fn new(
46        display_name: impl Into<String>,
47        env_prefix: impl Into<String>,
48        default_admin_email: impl Into<String>,
49        public_url: impl Into<String>,
50    ) -> Self {
51        let display_name = display_name.into();
52        let env_prefix = env_prefix.into();
53        Self {
54            store_name: display_name.clone(),
55            api_key_label: format!("{display_name} local dev"),
56            rate_source: "coingecko".to_owned(),
57            webhook_path: "/api/payments/webhooks/btcpay".to_owned(),
58            default_admin_email: default_admin_email.into(),
59            public_url: public_url.into(),
60            local_docker: LocalDockerConfig::new(env_prefix.to_ascii_lowercase()),
61            display_name,
62            env_prefix,
63            default_admin_password: None,
64            default_payment_signing_key: None,
65        }
66    }
67
68    /// Overrides the local reverse-proxy ports.
69    pub fn with_local_ports(mut self, http: u16, https: u16) -> Self {
70        self.local_docker.reverseproxy_http_port = http;
71        self.local_docker.reverseproxy_https_port = https;
72        self
73    }
74
75    /// Overrides the generated BTCPay Compose project name.
76    pub fn with_compose_project_name(mut self, name: impl Into<String>) -> Self {
77        self.local_docker.compose_project_name = name.into();
78        self
79    }
80
81    /// Overrides the BTCPay webhook path used by the application.
82    pub fn with_webhook_path(mut self, path: impl Into<String>) -> Self {
83        self.webhook_path = path.into();
84        self
85    }
86
87    pub(super) fn validate(&self) -> Result<()> {
88        for (field, value) in [
89            ("display_name", self.display_name.as_str()),
90            ("store_name", self.store_name.as_str()),
91            ("api_key_label", self.api_key_label.as_str()),
92            ("rate_source", self.rate_source.as_str()),
93            ("default_admin_email", self.default_admin_email.as_str()),
94            ("public_url", self.public_url.as_str()),
95        ] {
96            ensure!(
97                !value.is_empty() && value.trim() == value && !value.chars().any(char::is_control),
98                "payment profile {field} must be a non-empty single-line value"
99            );
100        }
101        ensure!(
102            self.env_prefix
103                .bytes()
104                .next()
105                .is_some_and(|byte| byte.is_ascii_uppercase())
106                && self
107                    .env_prefix
108                    .bytes()
109                    .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_'),
110            "payment profile env_prefix must use uppercase ASCII environment-key characters"
111        );
112        ensure!(
113            self.webhook_path.starts_with('/')
114                && !self.webhook_path.chars().any(char::is_whitespace),
115            "payment profile webhook_path must be an absolute path without whitespace"
116        );
117        let public_url = url::Url::parse(&self.public_url)
118            .context("payment profile public_url must be an absolute URL")?;
119        ensure!(
120            matches!(public_url.scheme(), "http" | "https")
121                && public_url.host_str().is_some()
122                && public_url.username().is_empty()
123                && public_url.password().is_none(),
124            "payment profile public_url must be an HTTP(S) URL without credentials"
125        );
126        self.local_docker.validate()?;
127        Ok(())
128    }
129
130    pub(super) fn display_name(&self) -> &str {
131        &self.display_name
132    }
133
134    pub(super) fn skip_btcpay_bootstrap_env_key(&self) -> String {
135        self.env_key("SKIP_BTCPAY_BOOTSTRAP")
136    }
137
138    fn env_key(&self, suffix: &str) -> String {
139        format!("{}_{}", self.env_prefix, suffix)
140    }
141}
142
143#[derive(Clone, Debug)]
144pub(super) struct LocalBtcpayBootstrapOptions {
145    profile: BtcpayDevProfile,
146    workspace_root: PathBuf,
147    project_root: PathBuf,
148    env_file: PathBuf,
149}
150
151impl LocalBtcpayBootstrapOptions {
152    pub(super) fn new(
153        profile: BtcpayDevProfile,
154        workspace_root: impl Into<PathBuf>,
155        project_root: impl Into<PathBuf>,
156    ) -> Self {
157        let workspace_root = workspace_root.into();
158        let project_root = project_root.into();
159        Self {
160            profile,
161            workspace_root,
162            env_file: project_root.join(".env"),
163            project_root,
164        }
165    }
166}
167
168impl fmt::Debug for BtcpayDevProfile {
169    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
170        formatter
171            .debug_struct("BtcpayDevProfile")
172            .field("display_name", &self.display_name)
173            .field("env_prefix", &self.env_prefix)
174            .field("store_name", &self.store_name)
175            .field("api_key_label", &self.api_key_label)
176            .field("rate_source", &self.rate_source)
177            .field("webhook_path", &self.webhook_path)
178            .field("default_admin_email", &self.default_admin_email)
179            .field("public_url", &self.public_url)
180            .field("local_docker", &self.local_docker)
181            .field(
182                "default_admin_password",
183                &self.default_admin_password.as_ref().map(|_| "[REDACTED]"),
184            )
185            .field(
186                "default_payment_signing_key",
187                &self
188                    .default_payment_signing_key
189                    .as_ref()
190                    .map(|_| "[REDACTED]"),
191            )
192            .finish()
193    }
194}
195
196#[derive(Clone, Debug)]
197pub(super) struct LocalBtcpayBootstrapSummary {
198    /// Human-facing app name.
199    pub app_display_name: String,
200    /// Local BTCPay base URL.
201    pub url: String,
202    /// Store id written to the app `.env`.
203    pub store_id: String,
204    /// Masked API key for logs.
205    pub api_key_masked: String,
206    /// Masked webhook secret for logs.
207    pub webhook_secret_masked: String,
208    /// App `.env` path updated by the bootstrap.
209    pub env_file: PathBuf,
210}
211
212pub(super) fn bootstrap_local_btcpay(
213    options: &LocalBtcpayBootstrapOptions,
214) -> Result<LocalBtcpayBootstrapSummary> {
215    let profile = &options.profile;
216    let btcpay_root = options.workspace_root.join("third-party").join("btcpay");
217    ensure!(
218        btcpay_root.is_dir(),
219        "BTCPay checkout not found at {}",
220        btcpay_root.display()
221    );
222    ensure!(
223        options.project_root.join("Cargo.toml").is_file(),
224        "app checkout not found at {}",
225        options.project_root.display()
226    );
227    let mut app_env = EnvFile::read(&options.env_file)?;
228    let public_url = app_env
229        .effective_value("PUBLIC_URL")
230        .filter(|value| !value.trim().is_empty())
231        .unwrap_or_else(|| profile.public_url.clone());
232
233    let docker_env = docker_env_values(&profile.local_docker, &app_env);
234    write_btcpay_env(&btcpay_root.join(".env"), &docker_env)?;
235    run_build_script(&btcpay_root, &docker_env)?;
236    patch_local_btcpay_compose(&btcpay_root, &docker_env)?;
237    docker_compose_up(&btcpay_root, &docker_env)?;
238
239    let url = format!(
240        "http://{}:{}",
241        profile.local_docker.host, profile.local_docker.reverseproxy_http_port
242    );
243    let admin_email = app_env
244        .effective_value(profile.env_key("LOCAL_BTCPAY_ADMIN_EMAIL").as_str())
245        .filter(|value| !value.trim().is_empty())
246        .unwrap_or_else(|| profile.default_admin_email.to_string());
247    let admin_password_key = profile.env_key("LOCAL_BTCPAY_ADMIN_PASSWORD");
248    let admin_password = app_env
249        .effective_value(admin_password_key.as_str())
250        .filter(|value| !value.trim().is_empty())
251        .or_else(|| {
252            profile
253                .default_admin_password
254                .as_deref()
255                .map(str::trim)
256                .filter(|value| !value.is_empty())
257                .map(ToOwned::to_owned)
258        })
259        .unwrap_or_else(generate_local_secret);
260    app_env.set(admin_password_key.as_str(), admin_password.as_str());
261    app_env.write(&options.env_file)?;
262
263    let client = BtcpayClient::new(profile.clone(), url.clone(), admin_email, admin_password)?;
264    client.wait_until_ready()?;
265    client.ensure_admin_user()?;
266    let store_id = client.ensure_store(&public_url)?;
267    client.ensure_onchain_wallet(&store_id, &btcpay_root, &docker_env)?;
268    client.ensure_rate_settings(&store_id)?;
269    let store_id_key = profile.env_key("BTCPAY_STORE_ID");
270    let api_key_key = profile.env_key("BTCPAY_API_KEY");
271    let webhook_secret_key = profile.env_key("BTCPAY_WEBHOOK_SECRET");
272    let current_api_key = app_env.effective_value(api_key_key.as_str());
273    let api_key = client.ensure_api_key(&store_id, current_api_key.as_deref())?;
274    let current_webhook_secret = app_env.effective_value(webhook_secret_key.as_str());
275    let webhook_secret = client.ensure_webhook(
276        &store_id,
277        &public_url,
278        &api_key,
279        current_webhook_secret.as_deref(),
280    )?;
281
282    app_env.set(store_id_key.as_str(), store_id.as_str());
283    app_env.set(api_key_key.as_str(), api_key.as_str());
284    app_env.set(webhook_secret_key.as_str(), webhook_secret.as_str());
285    let signing_key = app_env
286        .effective_value("PAYMENT_SIGNING_KEY")
287        .filter(|value| !value.trim().is_empty())
288        .or_else(|| {
289            profile
290                .default_payment_signing_key
291                .as_deref()
292                .map(str::trim)
293                .filter(|value| !value.is_empty())
294                .map(ToOwned::to_owned)
295        })
296        .unwrap_or_else(generate_local_secret);
297    app_env.set("PAYMENT_SIGNING_KEY", signing_key.as_str());
298    app_env.write(&options.env_file)?;
299
300    Ok(LocalBtcpayBootstrapSummary {
301        app_display_name: profile.display_name().to_string(),
302        url,
303        store_id,
304        api_key_masked: mask_secret(&api_key),
305        webhook_secret_masked: mask_secret(&webhook_secret),
306        env_file: options.env_file.clone(),
307    })
308}
309
310fn generate_local_secret() -> String {
311    format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple())
312}
313
314#[derive(Clone, Debug, Eq, PartialEq)]
315struct LocalDockerConfig {
316    host: String,
317    reverseproxy_http_port: u16,
318    reverseproxy_https_port: u16,
319    nbitcoin_network: String,
320    btcpaygen_crypto1: String,
321    btcpaygen_reverseproxy: String,
322    btcpaygen_subname: String,
323    compose_project_name: String,
324}
325
326impl LocalDockerConfig {
327    fn new(compose_project_name: String) -> Self {
328        Self {
329            host: "127.0.0.1".to_owned(),
330            reverseproxy_http_port: 23_005,
331            reverseproxy_https_port: 23_006,
332            nbitcoin_network: "regtest".to_owned(),
333            btcpaygen_crypto1: "btc".to_owned(),
334            btcpaygen_reverseproxy: "nginx".to_owned(),
335            btcpaygen_subname: "btcpay".to_owned(),
336            compose_project_name,
337        }
338    }
339
340    fn validate(&self) -> Result<()> {
341        for (field, value) in [
342            ("host", self.host.as_str()),
343            ("nbitcoin_network", self.nbitcoin_network.as_str()),
344            ("btcpaygen_crypto1", self.btcpaygen_crypto1.as_str()),
345            (
346                "btcpaygen_reverseproxy",
347                self.btcpaygen_reverseproxy.as_str(),
348            ),
349            ("btcpaygen_subname", self.btcpaygen_subname.as_str()),
350            ("compose_project_name", self.compose_project_name.as_str()),
351        ] {
352            ensure!(
353                !value.is_empty() && value.trim() == value && !value.chars().any(char::is_control),
354                "payment profile local_docker.{field} must be a non-empty single-line value"
355            );
356        }
357        ensure!(
358            self.reverseproxy_http_port > 0,
359            "payment profile local_docker.reverseproxy_http_port must be greater than zero"
360        );
361        ensure!(
362            self.reverseproxy_https_port > 0,
363            "payment profile local_docker.reverseproxy_https_port must be greater than zero"
364        );
365        Ok(())
366    }
367}
368
369#[derive(Clone, Debug, Default)]
370struct EnvFile {
371    lines: Vec<String>,
372    values: BTreeMap<String, String>,
373}
374
375impl EnvFile {
376    fn read(path: &Path) -> Result<Self> {
377        if !path.exists() {
378            return Ok(Self {
379                lines: vec![
380                    "# Local payment secrets and machine-specific BTCPay values.".to_string(),
381                    "# Local dev bootstrap is managed by ch-rds.".to_string(),
382                    String::new(),
383                ],
384                values: BTreeMap::new(),
385            });
386        }
387
388        let text = fs::read_to_string(path)
389            .with_context(|| format!("failed to read {}", path.display()))?;
390        let mut values = BTreeMap::new();
391        let lines = text
392            .lines()
393            .map(|line| {
394                if let Some((key, value)) = parse_env_line(line) {
395                    values.insert(key.to_string(), value.to_string());
396                }
397                line.to_string()
398            })
399            .collect();
400        Ok(Self { lines, values })
401    }
402
403    fn get(&self, key: &str) -> Option<&str> {
404        self.values.get(key).map(String::as_str)
405    }
406
407    fn effective_value(&self, key: &str) -> Option<String> {
408        env::var(key)
409            .ok()
410            .filter(|value| !value.trim().is_empty())
411            .or_else(|| self.get(key).map(str::to_string))
412    }
413
414    fn set(&mut self, key: &str, value: &str) {
415        self.values.insert(key.to_string(), value.to_string());
416    }
417
418    fn write(&self, path: &Path) -> Result<()> {
419        let mut seen = BTreeSet::new();
420        let mut output = Vec::new();
421        for line in &self.lines {
422            if let Some((key, _)) = parse_env_line(line)
423                && let Some(value) = self.values.get(key)
424            {
425                output.push(format!("{key}={value}"));
426                seen.insert(key.to_string());
427                continue;
428            }
429            output.push(line.clone());
430        }
431        for (key, value) in &self.values {
432            if seen.contains(key) {
433                continue;
434            }
435            if output.last().is_some_and(|line| !line.is_empty()) {
436                output.push(String::new());
437            }
438            output.push(format!("{key}={value}"));
439        }
440        if let Some(parent) = path.parent() {
441            fs::create_dir_all(parent)
442                .with_context(|| format!("failed to create {}", parent.display()))?;
443        }
444        fs::write(path, format!("{}\n", output.join("\n")))
445            .with_context(|| format!("failed to write {}", path.display()))
446    }
447}
448
449fn parse_env_line(line: &str) -> Option<(&str, &str)> {
450    let trimmed = line.trim_start();
451    if trimmed.is_empty() || trimmed.starts_with('#') {
452        return None;
453    }
454    let (key, value) = line.split_once('=')?;
455    let key = key.trim();
456    if key.is_empty() {
457        return None;
458    }
459    Some((key, value.trim()))
460}
461
462fn docker_env_values(config: &LocalDockerConfig, app_env: &EnvFile) -> BTreeMap<String, String> {
463    let mut values = BTreeMap::new();
464    values.insert("BTCPAY_HOST".to_string(), config.host.clone());
465    values.insert(
466        "REVERSEPROXY_HTTP_PORT".to_string(),
467        config.reverseproxy_http_port.to_string(),
468    );
469    values.insert(
470        "REVERSEPROXY_HTTPS_PORT".to_string(),
471        config.reverseproxy_https_port.to_string(),
472    );
473    values.insert(
474        "NBITCOIN_NETWORK".to_string(),
475        config.nbitcoin_network.clone(),
476    );
477    values.insert(
478        "BTCPAYGEN_CRYPTO1".to_string(),
479        config.btcpaygen_crypto1.clone(),
480    );
481    values.insert(
482        "BTCPAYGEN_REVERSEPROXY".to_string(),
483        config.btcpaygen_reverseproxy.clone(),
484    );
485    values.insert(
486        "BTCPAYGEN_SUBNAME".to_string(),
487        config.btcpaygen_subname.clone(),
488    );
489    values.insert(
490        "COMPOSE_PROJECT_NAME".to_string(),
491        config.compose_project_name.clone(),
492    );
493    values.insert(
494        "BTCPAY_ADDITIONAL_HOSTS".to_string(),
495        local_btcpay_additional_hosts(
496            config.host.as_str(),
497            app_env
498                .effective_value("BTCPAY_ADDITIONAL_HOSTS")
499                .as_deref()
500                .unwrap_or_default(),
501        ),
502    );
503    for key in [
504        "BTCPAY_SSHAUTHORIZEDKEYS",
505        "BTCPAY_SSHTRUSTEDFINGERPRINTS",
506        "BTCPAY_SSHKEYFILE",
507    ] {
508        values.insert(
509            key.to_string(),
510            app_env.effective_value(key).unwrap_or_default(),
511        );
512    }
513    values
514}
515
516fn write_btcpay_env(path: &Path, values: &BTreeMap<String, String>) -> Result<()> {
517    let mut lines = Vec::new();
518    for key in [
519        "BTCPAY_HOST",
520        "REVERSEPROXY_HTTP_PORT",
521        "REVERSEPROXY_HTTPS_PORT",
522        "NBITCOIN_NETWORK",
523        "BTCPAYGEN_CRYPTO1",
524        "BTCPAYGEN_REVERSEPROXY",
525        "BTCPAYGEN_SUBNAME",
526        "COMPOSE_PROJECT_NAME",
527        "BTCPAY_ADDITIONAL_HOSTS",
528        "BTCPAY_SSHAUTHORIZEDKEYS",
529        "BTCPAY_SSHTRUSTEDFINGERPRINTS",
530        "BTCPAY_SSHKEYFILE",
531    ] {
532        lines.push(format!(
533            "{key}={}",
534            values.get(key).map(String::as_str).unwrap_or_default()
535        ));
536    }
537    fs::write(path, format!("{}\n", lines.join("\n")))
538        .with_context(|| format!("failed to write {}", path.display()))
539}
540
541fn run_build_script(btcpay_root: &Path, envs: &BTreeMap<String, String>) -> Result<()> {
542    let (program, args): (&str, Vec<String>) = if cfg!(windows) {
543        (
544            "powershell",
545            vec![
546                "-NoProfile".to_string(),
547                "-ExecutionPolicy".to_string(),
548                "Bypass".to_string(),
549                "-File".to_string(),
550                btcpay_root.join("build.ps1").display().to_string(),
551            ],
552        )
553    } else {
554        (
555            "bash",
556            vec![btcpay_root.join("build.sh").display().to_string()],
557        )
558    };
559    run_command("BTCPay build step", program, &args, btcpay_root, envs)
560}
561
562fn patch_local_btcpay_compose(btcpay_root: &Path, envs: &BTreeMap<String, String>) -> Result<()> {
563    let subname = required_env_value(envs, "BTCPAYGEN_SUBNAME")?;
564    let compose_file = btcpay_root
565        .join("Generated")
566        .join(format!("docker-compose.{subname}.yml"));
567    let compose = fs::read_to_string(&compose_file)
568        .with_context(|| format!("failed to read {}", compose_file.display()))?;
569    let patched = compose.replace("\n        onion=tor:9050\n", "\n        onion=0\n");
570
571    if patched != compose {
572        fs::write(&compose_file, patched)
573            .with_context(|| format!("failed to write {}", compose_file.display()))?;
574    }
575
576    Ok(())
577}
578
579fn docker_compose_up(btcpay_root: &Path, envs: &BTreeMap<String, String>) -> Result<()> {
580    let subname = required_env_value(envs, "BTCPAYGEN_SUBNAME")?;
581    let project_name = required_env_value(envs, "COMPOSE_PROJECT_NAME")?;
582    let compose_file = btcpay_root
583        .join("Generated")
584        .join(format!("docker-compose.{subname}.yml"));
585    ensure!(
586        compose_file.is_file(),
587        "generated BTCPay compose file is missing at {}",
588        compose_file.display()
589    );
590
591    let args = vec![
592        "compose".to_string(),
593        "--project-name".to_string(),
594        project_name.to_string(),
595        "--env-file".to_string(),
596        ".env".to_string(),
597        "-f".to_string(),
598        compose_file.display().to_string(),
599        "up".to_string(),
600        "-d".to_string(),
601    ];
602    run_command(
603        "BTCPay docker compose up",
604        "docker",
605        &args,
606        btcpay_root,
607        envs,
608    )
609}
610
611fn run_command(
612    label: &str,
613    program: &str,
614    args: &[String],
615    cwd: &Path,
616    envs: &BTreeMap<String, String>,
617) -> Result<()> {
618    let mut command = Command::new(program);
619    command.current_dir(cwd).args(args).envs(envs);
620    command.env("COMPOSE_CONVERT_WINDOWS_PATHS", "1");
621    let status = command
622        .status()
623        .with_context(|| format!("failed to start {label}"))?;
624    ensure!(status.success(), "{label} exited with status {status}");
625    Ok(())
626}
627
628struct BtcpayClient {
629    profile: BtcpayDevProfile,
630    base_url: String,
631    admin_email: String,
632    admin_password: String,
633    client: Client,
634}
635
636impl BtcpayClient {
637    fn new(
638        profile: BtcpayDevProfile,
639        base_url: String,
640        admin_email: String,
641        admin_password: String,
642    ) -> Result<Self> {
643        let client = Client::builder()
644            .timeout(Duration::from_secs(30))
645            .build()
646            .context("failed to create BTCPay HTTP client")?;
647        Ok(Self {
648            profile,
649            base_url: base_url.trim_end_matches('/').to_string(),
650            admin_email,
651            admin_password,
652            client,
653        })
654    }
655
656    fn wait_until_ready(&self) -> Result<()> {
657        let deadline = Instant::now() + Duration::from_secs(180);
658        let mut last_error = None;
659        while Instant::now() < deadline {
660            match self.client.get(format!("{}/", self.base_url)).send() {
661                Ok(response)
662                    if response.status().is_success() || response.status().is_redirection() =>
663                {
664                    return Ok(());
665                }
666                Ok(response) => {
667                    last_error = Some(anyhow!("BTCPay returned {}", response.status()));
668                }
669                Err(error) => {
670                    last_error = Some(error.into());
671                }
672            }
673            std::thread::sleep(Duration::from_secs(2));
674        }
675        Err(last_error.unwrap_or_else(|| anyhow!("BTCPay did not become ready in time")))
676    }
677
678    fn ensure_admin_user(&self) -> Result<()> {
679        if self.has_valid_admin_credentials()? {
680            return Ok(());
681        }
682
683        let html = self
684            .client
685            .get(format!("{}/register", self.base_url))
686            .send()
687            .context("failed to load BTCPay register page")?
688            .text()
689            .context("failed to read BTCPay register page")?;
690        let csrf = parse_csrf_token(&html).ok_or_else(|| {
691            anyhow!(
692                "BTCPay admin account exists with different credentials. Reset local BTCPay volumes or set {}/{}.",
693                self.profile.env_key("LOCAL_BTCPAY_ADMIN_EMAIL"),
694                self.profile.env_key("LOCAL_BTCPAY_ADMIN_PASSWORD")
695            )
696        })?;
697        let response = self
698            .client
699            .post(format!("{}/register?logon=true", self.base_url))
700            .form(&[
701                ("Email", self.admin_email.as_str()),
702                ("Password", self.admin_password.as_str()),
703                ("ConfirmPassword", self.admin_password.as_str()),
704                ("__RequestVerificationToken", csrf.as_str()),
705            ])
706            .send()
707            .context("failed to submit BTCPay admin register form")?;
708        ensure!(
709            response.status().is_success() || response.status().is_redirection(),
710            "BTCPay admin register returned {}",
711            response.status()
712        );
713        ensure!(
714            self.has_valid_admin_credentials()?,
715            "BTCPay local admin bootstrap did not produce valid API credentials"
716        );
717        Ok(())
718    }
719
720    fn has_valid_admin_credentials(&self) -> Result<bool> {
721        let response = self
722            .admin_auth(
723                self.client
724                    .get(format!("{}/api/v1/users/me", self.base_url)),
725            )
726            .send()
727            .context("failed to check BTCPay admin credentials")?;
728        match response.status() {
729            StatusCode::OK => Ok(true),
730            StatusCode::UNAUTHORIZED => Ok(false),
731            status => bail!("BTCPay admin credential check returned {status}"),
732        }
733    }
734
735    fn ensure_store(&self, public_url: &str) -> Result<String> {
736        let stores: Vec<Value> = self.admin_get_json("/api/v1/stores")?;
737        for store in stores {
738            if store.get("name").and_then(Value::as_str) == Some(self.profile.store_name.as_str())
739                && let Some(id) = store.get("id").and_then(Value::as_str)
740            {
741                return Ok(id.to_string());
742            }
743        }
744
745        let created: Value = self.admin_post_json(
746            "/api/v1/stores",
747            &json!({
748                "name": self.profile.store_name.as_str(),
749                "website": public_url,
750                "defaultLang": "en",
751                "speedPolicy": "MediumSpeed",
752                "redirectAutomatically": false,
753                "anyoneCanCreateInvoice": false,
754            }),
755        )?;
756        created
757            .get("id")
758            .and_then(Value::as_str)
759            .map(str::to_string)
760            .ok_or_else(|| anyhow!("BTCPay store creation returned an unexpected response"))
761    }
762
763    fn ensure_onchain_wallet(
764        &self,
765        store_id: &str,
766        btcpay_root: &Path,
767        docker_env: &BTreeMap<String, String>,
768    ) -> Result<()> {
769        match self.admin_get_json::<Value>(&format!(
770            "/api/v1/stores/{store_id}/payment-methods/OnChain/BTC"
771        )) {
772            Ok(payload)
773                if payload.get("enabled").and_then(Value::as_bool) == Some(true)
774                    && payload
775                        .get("derivationScheme")
776                        .and_then(Value::as_str)
777                        .is_some() =>
778            {
779                return Ok(());
780            }
781            Ok(_) => {}
782            Err(error) if error.to_string().contains("404") => {}
783            Err(error) => return Err(error),
784        }
785
786        let (tpub, fingerprint) = derive_local_bitcoin_tpub(btcpay_root, docker_env)?;
787        let payload: Value = self.admin_put_json(
788            &format!("/api/v1/stores/{store_id}/payment-methods/OnChain/BTC"),
789            &json!({
790                "enabled": true,
791                "cryptoCode": "BTC",
792                "derivationScheme": tpub,
793                "label": self.profile.api_key_label.as_str(),
794                "accountKeyPath": fingerprint,
795            }),
796        )?;
797        ensure!(
798            payload
799                .get("derivationScheme")
800                .and_then(Value::as_str)
801                .is_some(),
802            "BTCPay on-chain wallet setup returned an unexpected response"
803        );
804        Ok(())
805    }
806
807    fn ensure_rate_settings(&self, store_id: &str) -> Result<()> {
808        let payload: Value = self.admin_put_json(
809            &format!("/api/v1/stores/{store_id}/rates/configuration/primary"),
810            &json!({
811                "spread": "0",
812                "preferredSource": self.profile.rate_source.as_str(),
813                "isCustomScript": false,
814                "effectiveScript": "",
815            }),
816        )?;
817        ensure!(
818            payload.get("isCustomScript").and_then(Value::as_bool) == Some(false),
819            "BTCPay rate configuration returned an unexpected response"
820        );
821        Ok(())
822    }
823
824    fn ensure_api_key(&self, store_id: &str, current_key: Option<&str>) -> Result<String> {
825        let required_permissions = [
826            format!("btcpay.store.cancreateinvoice:{store_id}"),
827            format!("btcpay.store.canviewinvoices:{store_id}"),
828            format!("btcpay.store.webhooks.canmodifywebhooks:{store_id}"),
829            format!("btcpay.store.canviewstoresettings:{store_id}"),
830        ];
831        if let Some(key) = current_key
832            && !is_placeholder_value(key)
833            && self.api_key_has_permissions(key, &required_permissions)?
834        {
835            return Ok(key.to_string());
836        }
837
838        let created: Value = self.admin_post_json(
839            "/api/v1/api-keys",
840            &json!({
841                "label": self.profile.api_key_label.as_str(),
842                "permissions": required_permissions,
843            }),
844        )?;
845        created
846            .get("apiKey")
847            .and_then(Value::as_str)
848            .map(str::to_string)
849            .ok_or_else(|| anyhow!("BTCPay API key creation returned an unexpected response"))
850    }
851
852    fn ensure_webhook(
853        &self,
854        store_id: &str,
855        public_url: &str,
856        api_key: &str,
857        current_secret: Option<&str>,
858    ) -> Result<String> {
859        let webhook_url = btcpay_webhook_url(public_url, self.profile.webhook_path.as_str())?;
860        if let Some(secret) = current_secret
861            && !is_placeholder_value(secret)
862            && self.webhook_exists(store_id, api_key, webhook_url.as_str())?
863        {
864            return Ok(secret.to_string());
865        }
866
867        let secret = format!("{}_{}", Uuid::new_v4(), Uuid::new_v4());
868        let created: Value = self.api_key_post_json(
869            api_key,
870            &format!("/api/v1/stores/{store_id}/webhooks"),
871            &json!({
872                "enabled": true,
873                "automaticRedelivery": true,
874                "url": webhook_url,
875                "secret": secret,
876                "authorizedEvents": {
877                    "everything": false,
878                    "specificEvents": [
879                        "InvoiceCreated",
880                        "InvoiceReceivedPayment",
881                        "InvoiceProcessing",
882                        "InvoiceSettled",
883                        "InvoiceExpired",
884                        "InvoiceInvalid"
885                    ]
886                }
887            }),
888        )?;
889        Ok(created
890            .get("secret")
891            .and_then(Value::as_str)
892            .unwrap_or(secret.as_str())
893            .to_string())
894    }
895
896    fn api_key_has_permissions(&self, key: &str, required_permissions: &[String]) -> Result<bool> {
897        let response = self
898            .api_key_auth(
899                key,
900                self.client
901                    .get(format!("{}/api/v1/api-keys/current", self.base_url)),
902            )
903            .send()
904            .context("failed to check BTCPay API key")?;
905        if response.status() == StatusCode::UNAUTHORIZED {
906            return Ok(false);
907        }
908        let payload: Value = response_json(response, "BTCPay API key check")?;
909        let Some(permissions) = payload.get("permissions").and_then(Value::as_array) else {
910            return Ok(false);
911        };
912        let permissions = permissions
913            .iter()
914            .filter_map(Value::as_str)
915            .collect::<BTreeSet<_>>();
916        Ok(required_permissions
917            .iter()
918            .all(|permission| permissions.contains(permission.as_str())))
919    }
920
921    fn webhook_exists(&self, store_id: &str, api_key: &str, webhook_url: &str) -> Result<bool> {
922        let webhooks: Vec<Value> =
923            self.api_key_get_json(api_key, &format!("/api/v1/stores/{store_id}/webhooks"))?;
924        Ok(webhooks
925            .iter()
926            .any(|webhook| webhook.get("url").and_then(Value::as_str) == Some(webhook_url)))
927    }
928
929    fn admin_get_json<T: for<'de> Deserialize<'de>>(&self, path: &str) -> Result<T> {
930        let response = self
931            .admin_auth(self.client.get(format!("{}{}", self.base_url, path)))
932            .send()
933            .with_context(|| format!("failed to request BTCPay {path}"))?;
934        response_json(response, path)
935    }
936
937    fn admin_post_json<T: for<'de> Deserialize<'de>>(
938        &self,
939        path: &str,
940        payload: &Value,
941    ) -> Result<T> {
942        let response = self
943            .admin_auth(self.client.post(format!("{}{}", self.base_url, path)))
944            .json(payload)
945            .send()
946            .with_context(|| format!("failed to request BTCPay {path}"))?;
947        response_json(response, path)
948    }
949
950    fn admin_put_json<T: for<'de> Deserialize<'de>>(
951        &self,
952        path: &str,
953        payload: &Value,
954    ) -> Result<T> {
955        let response = self
956            .admin_auth(self.client.put(format!("{}{}", self.base_url, path)))
957            .json(payload)
958            .send()
959            .with_context(|| format!("failed to request BTCPay {path}"))?;
960        response_json(response, path)
961    }
962
963    fn api_key_get_json<T: for<'de> Deserialize<'de>>(
964        &self,
965        api_key: &str,
966        path: &str,
967    ) -> Result<T> {
968        let response = self
969            .api_key_auth(
970                api_key,
971                self.client.get(format!("{}{}", self.base_url, path)),
972            )
973            .send()
974            .with_context(|| format!("failed to request BTCPay {path}"))?;
975        response_json(response, path)
976    }
977
978    fn api_key_post_json<T: for<'de> Deserialize<'de>>(
979        &self,
980        api_key: &str,
981        path: &str,
982        payload: &Value,
983    ) -> Result<T> {
984        let response = self
985            .api_key_auth(
986                api_key,
987                self.client.post(format!("{}{}", self.base_url, path)),
988            )
989            .json(payload)
990            .send()
991            .with_context(|| format!("failed to request BTCPay {path}"))?;
992        response_json(response, path)
993    }
994
995    fn admin_auth(&self, request: RequestBuilder) -> RequestBuilder {
996        let token = base64::engine::general_purpose::STANDARD
997            .encode(format!("{}:{}", self.admin_email, self.admin_password));
998        request.header(reqwest::header::AUTHORIZATION, format!("Basic {token}"))
999    }
1000
1001    fn api_key_auth(&self, api_key: &str, request: RequestBuilder) -> RequestBuilder {
1002        request.header(reqwest::header::AUTHORIZATION, format!("token {api_key}"))
1003    }
1004}
1005
1006fn response_json<T: for<'de> Deserialize<'de>>(
1007    response: reqwest::blocking::Response,
1008    label: &str,
1009) -> Result<T> {
1010    let status = response.status();
1011    let text = response
1012        .text()
1013        .with_context(|| format!("failed to read {label} response"))?;
1014    ensure!(
1015        status.is_success(),
1016        "{label} returned {status}: {}",
1017        text.chars().take(500).collect::<String>()
1018    );
1019    serde_json::from_str(&text)
1020        .with_context(|| format!("{label} returned an unexpected JSON response"))
1021}
1022
1023fn derive_local_bitcoin_tpub(
1024    btcpay_root: &Path,
1025    docker_env: &BTreeMap<String, String>,
1026) -> Result<(String, String)> {
1027    let command = [
1028        "rm -f /tmp/btcpay-walletdump.txt",
1029        "bitcoin-cli -datadir=/data -rpcwallet= dumpwallet /tmp/btcpay-walletdump.txt >/dev/null",
1030        "awk '/extended private masterkey/ {print $5}' /tmp/btcpay-walletdump.txt",
1031    ]
1032    .join(" && ");
1033    let compose_file = btcpay_root
1034        .join("Generated")
1035        .join("docker-compose.generated.yml");
1036    let output = Command::new("docker")
1037        .current_dir(btcpay_root)
1038        .envs(docker_env)
1039        .args(["compose", "--env-file", ".env", "-f"])
1040        .arg(compose_file)
1041        .args(["exec", "-T", "bitcoind", "sh", "-lc", command.as_str()])
1042        .output()
1043        .context("failed to read local BTCPay bitcoin wallet")?;
1044    ensure!(
1045        output.status.success(),
1046        "failed to read local BTCPay bitcoin wallet: {}",
1047        String::from_utf8_lossy(&output.stderr).trim()
1048    );
1049    let tprv = String::from_utf8_lossy(&output.stdout)
1050        .lines()
1051        .last()
1052        .map(str::trim)
1053        .filter(|line| !line.is_empty())
1054        .ok_or_else(|| {
1055            anyhow!("local BTCPay bitcoin wallet did not expose an extended private key")
1056        })?
1057        .to_string();
1058    extended_private_key_to_public(&tprv)
1059}
1060
1061fn extended_private_key_to_public(value: &str) -> Result<(String, String)> {
1062    let payload = bs58::decode(value)
1063        .with_check(None)
1064        .into_vec()
1065        .context("invalid local BTCPay extended private key")?;
1066    ensure!(
1067        payload.len() == 78 && payload.get(45) == Some(&0),
1068        "invalid local BTCPay extended private key payload"
1069    );
1070    let version: [u8; 4] = payload[0..4].try_into().expect("slice length is checked");
1071    let public_version = EXTENDED_PRIVATE_TO_PUBLIC_VERSIONS
1072        .iter()
1073        .find_map(|(private, public)| (*private == version).then_some(*public))
1074        .ok_or_else(|| anyhow!("unsupported local BTCPay extended private key version"))?;
1075
1076    let secret_bytes = payload[46..78].try_into().expect("slice length is checked");
1077    let secret = SecretKey::from_byte_array(secret_bytes)
1078        .context("invalid local BTCPay private key scalar")?;
1079    let secp = Secp256k1::new();
1080    let public_key = PublicKey::from_secret_key(&secp, &secret).serialize();
1081    let fingerprint = hash160(&public_key)[..4]
1082        .iter()
1083        .map(|byte| format!("{byte:02x}"))
1084        .collect::<String>();
1085
1086    let mut public_payload = Vec::with_capacity(78);
1087    public_payload.extend_from_slice(&public_version);
1088    public_payload.extend_from_slice(&payload[4..45]);
1089    public_payload.extend_from_slice(&public_key);
1090    let tpub = bs58::encode(public_payload).with_check().into_string();
1091    Ok((tpub, fingerprint))
1092}
1093
1094fn hash160(value: &[u8]) -> [u8; 20] {
1095    let sha = <Sha256 as ShaDigest>::digest(value);
1096    let ripe = <Ripemd160 as RipemdDigest>::digest(sha);
1097    ripe.into()
1098}
1099
1100fn btcpay_webhook_url(public_url: &str, webhook_path: &str) -> Result<String> {
1101    let parsed = url::Url::parse(public_url)
1102        .with_context(|| format!("invalid public payment URL `{public_url}`"))?;
1103    let host = match parsed.host_str() {
1104        Some("127.0.0.1" | "localhost") => "host.docker.internal",
1105        Some(host) => host,
1106        None => "host.docker.internal",
1107    };
1108    let mut netloc = host.to_string();
1109    if let Some(port) = parsed.port() {
1110        netloc = format!("{netloc}:{port}");
1111    }
1112    let scheme = if parsed.scheme().is_empty() {
1113        "http"
1114    } else {
1115        parsed.scheme()
1116    };
1117    Ok(format!("{scheme}://{netloc}{webhook_path}"))
1118}
1119
1120fn parse_csrf_token(html: &str) -> Option<String> {
1121    for tag in html.split('>') {
1122        if !tag.contains("__RequestVerificationToken") {
1123            continue;
1124        }
1125        if let Some(value) = html_attr_value(tag, "value") {
1126            return Some(value);
1127        }
1128    }
1129    None
1130}
1131
1132fn html_attr_value(tag: &str, name: &str) -> Option<String> {
1133    let needle = format!("{name}=\"");
1134    let start = tag.find(needle.as_str())? + needle.len();
1135    let rest = &tag[start..];
1136    let end = rest.find('"')?;
1137    Some(rest[..end].to_string())
1138}
1139
1140fn required_env_value<'a>(values: &'a BTreeMap<String, String>, key: &str) -> Result<&'a str> {
1141    values
1142        .get(key)
1143        .map(String::as_str)
1144        .filter(|value| !value.trim().is_empty())
1145        .ok_or_else(|| anyhow!("missing required BTCPay env value {key}"))
1146}
1147
1148fn local_btcpay_additional_hosts(primary_host: &str, configured_hosts: &str) -> String {
1149    let mut hosts = configured_hosts
1150        .split(',')
1151        .map(str::trim)
1152        .filter(|host| !host.is_empty())
1153        .map(str::to_string)
1154        .collect::<Vec<_>>();
1155    if primary_host == "127.0.0.1" && !hosts.iter().any(|host| host == "localhost") {
1156        hosts.push("localhost".to_string());
1157    }
1158    if primary_host == "localhost" && !hosts.iter().any(|host| host == "127.0.0.1") {
1159        hosts.push("127.0.0.1".to_string());
1160    }
1161    hosts.join(",")
1162}
1163
1164fn is_placeholder_value(value: &str) -> bool {
1165    matches!(
1166        value.trim(),
1167        "" | "__change_me__" | "BTCPAY_STORE_ID_FROM_UI"
1168    )
1169}
1170
1171fn mask_secret(value: &str) -> String {
1172    if value.len() <= 8 {
1173        return "********".to_string();
1174    }
1175    format!("{}...{}", &value[..4], &value[value.len() - 4..])
1176}