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::fmt;
5use std::fs;
6use std::path::{Path, PathBuf};
7use std::process::Command;
8use std::time::{Duration, Instant};
9
10use anyhow::{Context, Result, anyhow, bail, ensure};
11use base64::Engine;
12use reqwest::StatusCode;
13use reqwest::blocking::{Client, RequestBuilder};
14use serde::Deserialize;
15use serde_json::{Value, json};
16use uuid::Uuid;
17
18const BTCPAY_LATEST_STABLE_RELEASE_URL: &str =
19    "https://api.github.com/repos/btcpayserver/btcpayserver/releases/latest";
20const BTCPAY_STABLE_FALLBACK_IMAGE: &str = "btcpayserver/btcpayserver:2.4.3";
21
22#[derive(Deserialize)]
23struct GithubRelease {
24    tag_name: String,
25    draft: bool,
26    prerelease: bool,
27}
28
29/// Project-owned metadata used by the local BTCPay development adapter.
30#[derive(Clone, Eq, PartialEq)]
31pub struct BtcpayDevProfile {
32    display_name: String,
33    store_name: String,
34    api_key_label: String,
35    rate_source: String,
36    webhook_path: String,
37    default_admin_email: String,
38    public_url: String,
39    local_docker: LocalDockerConfig,
40    default_admin_password: Option<String>,
41    default_payment_signing_key: Option<String>,
42}
43
44impl BtcpayDevProfile {
45    /// Creates a local BTCPay profile with conservative development defaults.
46    pub fn new(
47        display_name: 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        Self {
53            store_name: display_name.clone(),
54            api_key_label: format!("{display_name} local dev"),
55            rate_source: "kraken".to_owned(),
56            webhook_path: "/api/payments/webhooks/btcpay".to_owned(),
57            default_admin_email: default_admin_email.into(),
58            public_url: public_url.into(),
59            local_docker: LocalDockerConfig::new(display_name.to_ascii_lowercase()),
60            display_name,
61            default_admin_password: None,
62            default_payment_signing_key: None,
63        }
64    }
65
66    /// Overrides the local reverse-proxy ports.
67    pub fn with_local_ports(mut self, http: u16, https: u16) -> Self {
68        self.local_docker.reverseproxy_http_port = http;
69        self.local_docker.reverseproxy_https_port = https;
70        self
71    }
72
73    /// Overrides the generated BTCPay Compose project name.
74    pub fn with_compose_project_name(mut self, name: impl Into<String>) -> Self {
75        self.local_docker.compose_project_name = name.into();
76        self
77    }
78
79    /// Overrides the BTCPay webhook path used by the application.
80    pub fn with_webhook_path(mut self, path: impl Into<String>) -> Self {
81        self.webhook_path = path.into();
82        self
83    }
84
85    pub(super) fn validate(&self) -> Result<()> {
86        for (field, value) in [
87            ("display_name", self.display_name.as_str()),
88            ("store_name", self.store_name.as_str()),
89            ("api_key_label", self.api_key_label.as_str()),
90            ("rate_source", self.rate_source.as_str()),
91            ("default_admin_email", self.default_admin_email.as_str()),
92            ("public_url", self.public_url.as_str()),
93        ] {
94            ensure!(
95                !value.is_empty() && value.trim() == value && !value.chars().any(char::is_control),
96                "payment profile {field} must be a non-empty single-line value"
97            );
98        }
99        ensure!(
100            self.webhook_path.starts_with('/')
101                && !self.webhook_path.chars().any(char::is_whitespace),
102            "payment profile webhook_path must be an absolute path without whitespace"
103        );
104        let public_url = url::Url::parse(&self.public_url)
105            .context("payment profile public_url must be an absolute URL")?;
106        ensure!(
107            matches!(public_url.scheme(), "http" | "https")
108                && public_url.host_str().is_some()
109                && public_url.username().is_empty()
110                && public_url.password().is_none(),
111            "payment profile public_url must be an HTTP(S) URL without credentials"
112        );
113        self.local_docker.validate()?;
114        Ok(())
115    }
116
117    pub(super) fn display_name(&self) -> &str {
118        &self.display_name
119    }
120}
121
122#[derive(Clone, Debug)]
123pub(super) struct LocalBtcpayBootstrapOptions {
124    profile: BtcpayDevProfile,
125    project_root: PathBuf,
126    env_file: PathBuf,
127}
128
129impl LocalBtcpayBootstrapOptions {
130    pub(super) fn new(profile: BtcpayDevProfile, project_root: impl Into<PathBuf>) -> Self {
131        let project_root = project_root.into();
132        Self {
133            profile,
134            env_file: project_root.join(".env"),
135            project_root,
136        }
137    }
138}
139
140fn btcpay_checkout_root(project_root: &Path) -> Result<PathBuf> {
141    let checkout_root = project_root
142        .parent()
143        .ok_or_else(|| anyhow!("app checkout has no parent directory"))?;
144    Ok(checkout_root.join("third-party").join("btcpay"))
145}
146
147impl fmt::Debug for BtcpayDevProfile {
148    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
149        formatter
150            .debug_struct("BtcpayDevProfile")
151            .field("display_name", &self.display_name)
152            .field("store_name", &self.store_name)
153            .field("api_key_label", &self.api_key_label)
154            .field("rate_source", &self.rate_source)
155            .field("webhook_path", &self.webhook_path)
156            .field("default_admin_email", &self.default_admin_email)
157            .field("public_url", &self.public_url)
158            .field("local_docker", &self.local_docker)
159            .field(
160                "default_admin_password",
161                &self.default_admin_password.as_ref().map(|_| "[REDACTED]"),
162            )
163            .field(
164                "default_payment_signing_key",
165                &self
166                    .default_payment_signing_key
167                    .as_ref()
168                    .map(|_| "[REDACTED]"),
169            )
170            .finish()
171    }
172}
173
174#[derive(Clone, Debug)]
175pub(super) struct LocalBtcpayBootstrapSummary {
176    /// Human-facing app name.
177    pub app_display_name: String,
178    /// Local BTCPay base URL.
179    pub url: String,
180    /// Store id written to the app `.env`.
181    pub store_id: String,
182    /// Masked API key for logs.
183    pub api_key_masked: String,
184    /// Masked webhook secret for logs.
185    pub webhook_secret_masked: String,
186    /// App `.env` path updated by the bootstrap.
187    pub env_file: PathBuf,
188}
189
190pub(super) fn bootstrap_local_btcpay(
191    options: &LocalBtcpayBootstrapOptions,
192) -> Result<LocalBtcpayBootstrapSummary> {
193    let profile = &options.profile;
194    let btcpay_root = btcpay_checkout_root(&options.project_root)?;
195    ensure!(
196        btcpay_root.is_dir(),
197        "BTCPay checkout not found at {}",
198        btcpay_root.display()
199    );
200    ensure!(
201        options.project_root.join("Cargo.toml").is_file(),
202        "app checkout not found at {}",
203        options.project_root.display()
204    );
205    let mut app_env = EnvFile::read(&options.env_file)?;
206    let public_url = app_env
207        .effective_value("PUBLIC_URL")
208        .filter(|value| !value.trim().is_empty())
209        .unwrap_or_else(|| profile.public_url.clone());
210
211    let docker_env = docker_env_values(&profile.local_docker, &app_env)?;
212    write_btcpay_env(&btcpay_root.join(".env"), &docker_env)?;
213    run_build_script(&btcpay_root, &docker_env)?;
214    patch_local_btcpay_compose(&btcpay_root, &docker_env)?;
215    docker_compose_up(&btcpay_root, &docker_env)?;
216
217    let url = format!(
218        "http://{}:{}",
219        profile.local_docker.host, profile.local_docker.reverseproxy_http_port
220    );
221    let admin_email = app_env
222        .effective_value("LOCAL_BTCPAY_ADMIN_EMAIL")
223        .filter(|value| !value.trim().is_empty())
224        .unwrap_or_else(|| profile.default_admin_email.to_string());
225    let admin_password_key = "LOCAL_BTCPAY_ADMIN_PASSWORD";
226    let admin_password = app_env
227        .effective_value(admin_password_key)
228        .filter(|value| !value.trim().is_empty())
229        .or_else(|| {
230            profile
231                .default_admin_password
232                .as_deref()
233                .map(str::trim)
234                .filter(|value| !value.is_empty())
235                .map(ToOwned::to_owned)
236        })
237        .unwrap_or_else(generate_local_secret);
238    app_env.set(admin_password_key, admin_password.as_str());
239    app_env.write(&options.env_file)?;
240
241    let client = BtcpayClient::new(profile.clone(), url.clone(), admin_email, admin_password)?;
242    client.wait_until_ready()?;
243    let store_id_key = "BTCPAY_STORE_ID";
244    let api_key_key = "BTCPAY_API_KEY";
245    let webhook_secret_key = "BTCPAY_WEBHOOK_SECRET";
246    let current_store_id = app_env
247        .effective_value(store_id_key)
248        .filter(|value| !is_placeholder_value(value));
249    let current_api_key = app_env.effective_value(api_key_key);
250    let reusable_store_access = match (current_store_id.as_deref(), current_api_key.as_deref()) {
251        (Some(store_id), Some(api_key)) if !is_placeholder_value(api_key) => {
252            client.api_key_has_permissions(api_key, &required_api_permissions(store_id))?
253        }
254        _ => false,
255    };
256    let (store_id, api_key) = if reusable_store_access {
257        (
258            current_store_id.expect("validated reusable store id"),
259            current_api_key.expect("validated reusable API key"),
260        )
261    } else {
262        client.ensure_admin_user()?;
263        let store_id = client.ensure_store(&public_url)?;
264        let api_key = client.ensure_api_key(&store_id, current_api_key.as_deref())?;
265        (store_id, api_key)
266    };
267    client.ensure_onchain_wallet(&store_id, &api_key)?;
268    client.ensure_rate_settings(&store_id, &api_key)?;
269    let current_webhook_secret = app_env.effective_value(webhook_secret_key);
270    let webhook_secret = client.ensure_webhook(
271        &store_id,
272        &public_url,
273        &api_key,
274        current_webhook_secret.as_deref(),
275    )?;
276
277    app_env.set(store_id_key, store_id.as_str());
278    app_env.set(api_key_key, api_key.as_str());
279    app_env.set(webhook_secret_key, webhook_secret.as_str());
280    let signing_key = app_env
281        .effective_value("PAYMENT_SIGNING_KEY")
282        .filter(|value| !value.trim().is_empty())
283        .or_else(|| {
284            profile
285                .default_payment_signing_key
286                .as_deref()
287                .map(str::trim)
288                .filter(|value| !value.is_empty())
289                .map(ToOwned::to_owned)
290        })
291        .unwrap_or_else(generate_local_secret);
292    app_env.set("PAYMENT_SIGNING_KEY", signing_key.as_str());
293    app_env.write(&options.env_file)?;
294
295    Ok(LocalBtcpayBootstrapSummary {
296        app_display_name: profile.display_name().to_string(),
297        url,
298        store_id,
299        api_key_masked: mask_secret(&api_key),
300        webhook_secret_masked: mask_secret(&webhook_secret),
301        env_file: options.env_file.clone(),
302    })
303}
304
305fn generate_local_secret() -> String {
306    format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple())
307}
308
309#[derive(Clone, Debug, Eq, PartialEq)]
310struct LocalDockerConfig {
311    host: String,
312    reverseproxy_http_port: u16,
313    reverseproxy_https_port: u16,
314    nbitcoin_network: String,
315    btcpaygen_crypto1: String,
316    btcpaygen_reverseproxy: String,
317    btcpaygen_subname: String,
318    compose_project_name: String,
319}
320
321impl LocalDockerConfig {
322    fn new(compose_project_name: String) -> Self {
323        Self {
324            host: "127.0.0.1".to_owned(),
325            reverseproxy_http_port: 23_005,
326            reverseproxy_https_port: 23_006,
327            nbitcoin_network: "regtest".to_owned(),
328            btcpaygen_crypto1: "btc".to_owned(),
329            btcpaygen_reverseproxy: "nginx".to_owned(),
330            btcpaygen_subname: "btcpay".to_owned(),
331            compose_project_name,
332        }
333    }
334
335    fn validate(&self) -> Result<()> {
336        for (field, value) in [
337            ("host", self.host.as_str()),
338            ("nbitcoin_network", self.nbitcoin_network.as_str()),
339            ("btcpaygen_crypto1", self.btcpaygen_crypto1.as_str()),
340            (
341                "btcpaygen_reverseproxy",
342                self.btcpaygen_reverseproxy.as_str(),
343            ),
344            ("btcpaygen_subname", self.btcpaygen_subname.as_str()),
345            ("compose_project_name", self.compose_project_name.as_str()),
346        ] {
347            ensure!(
348                !value.is_empty() && value.trim() == value && !value.chars().any(char::is_control),
349                "payment profile local_docker.{field} must be a non-empty single-line value"
350            );
351        }
352        ensure!(
353            self.reverseproxy_http_port > 0,
354            "payment profile local_docker.reverseproxy_http_port must be greater than zero"
355        );
356        ensure!(
357            self.reverseproxy_https_port > 0,
358            "payment profile local_docker.reverseproxy_https_port must be greater than zero"
359        );
360        Ok(())
361    }
362}
363
364#[derive(Clone, Debug, Default)]
365struct EnvFile {
366    lines: Vec<String>,
367    values: BTreeMap<String, String>,
368}
369
370impl EnvFile {
371    fn read(path: &Path) -> Result<Self> {
372        if !path.exists() {
373            return Ok(Self {
374                lines: vec![
375                    "# Local payment secrets and machine-specific BTCPay values.".to_string(),
376                    "# Local dev bootstrap is managed by ch-rds.".to_string(),
377                    String::new(),
378                ],
379                values: BTreeMap::new(),
380            });
381        }
382
383        let text = fs::read_to_string(path)
384            .with_context(|| format!("failed to read {}", path.display()))?;
385        let mut values = BTreeMap::new();
386        let lines = text
387            .lines()
388            .map(|line| {
389                if let Some((key, value)) = parse_env_line(line) {
390                    values.insert(key.to_string(), value.to_string());
391                }
392                line.to_string()
393            })
394            .collect();
395        Ok(Self { lines, values })
396    }
397
398    fn get(&self, key: &str) -> Option<&str> {
399        self.values.get(key).map(String::as_str)
400    }
401
402    fn effective_value(&self, key: &str) -> Option<String> {
403        ch_config::environment::var(key)
404            .ok()
405            .filter(|value| !value.trim().is_empty())
406            .or_else(|| self.get(key).map(str::to_string))
407    }
408
409    fn set(&mut self, key: &str, value: &str) {
410        self.values.insert(key.to_string(), value.to_string());
411    }
412
413    fn write(&self, path: &Path) -> Result<()> {
414        let mut seen = BTreeSet::new();
415        let mut output = Vec::new();
416        for line in &self.lines {
417            if let Some((key, _)) = parse_env_line(line)
418                && let Some(value) = self.values.get(key)
419            {
420                output.push(format!("{key}={value}"));
421                seen.insert(key.to_string());
422                continue;
423            }
424            output.push(line.clone());
425        }
426        for (key, value) in &self.values {
427            if seen.contains(key) {
428                continue;
429            }
430            if output.last().is_some_and(|line| !line.is_empty()) {
431                output.push(String::new());
432            }
433            output.push(format!("{key}={value}"));
434        }
435        if let Some(parent) = path.parent() {
436            fs::create_dir_all(parent)
437                .with_context(|| format!("failed to create {}", parent.display()))?;
438        }
439        fs::write(path, format!("{}\n", output.join("\n")))
440            .with_context(|| format!("failed to write {}", path.display()))
441    }
442}
443
444fn parse_env_line(line: &str) -> Option<(&str, &str)> {
445    let trimmed = line.trim_start();
446    if trimmed.is_empty() || trimmed.starts_with('#') {
447        return None;
448    }
449    let (key, value) = line.split_once('=')?;
450    let key = key.trim();
451    if key.is_empty() {
452        return None;
453    }
454    Some((key, value.trim()))
455}
456
457fn docker_env_values(
458    config: &LocalDockerConfig,
459    app_env: &EnvFile,
460) -> Result<BTreeMap<String, String>> {
461    let mut values = BTreeMap::new();
462    values.insert("BTCPAY_IMAGE".to_string(), resolve_btcpay_image(app_env)?);
463    values.insert("BTCPAY_HOST".to_string(), config.host.clone());
464    values.insert(
465        "REVERSEPROXY_HTTP_PORT".to_string(),
466        config.reverseproxy_http_port.to_string(),
467    );
468    values.insert(
469        "REVERSEPROXY_HTTPS_PORT".to_string(),
470        config.reverseproxy_https_port.to_string(),
471    );
472    values.insert(
473        "NBITCOIN_NETWORK".to_string(),
474        config.nbitcoin_network.clone(),
475    );
476    values.insert(
477        "BTCPAYGEN_CRYPTO1".to_string(),
478        config.btcpaygen_crypto1.clone(),
479    );
480    values.insert(
481        "BTCPAYGEN_REVERSEPROXY".to_string(),
482        config.btcpaygen_reverseproxy.clone(),
483    );
484    values.insert(
485        "BTCPAYGEN_SUBNAME".to_string(),
486        config.btcpaygen_subname.clone(),
487    );
488    values.insert(
489        "COMPOSE_PROJECT_NAME".to_string(),
490        config.compose_project_name.clone(),
491    );
492    values.insert(
493        "BTCPAY_ADDITIONAL_HOSTS".to_string(),
494        local_btcpay_additional_hosts(
495            config.host.as_str(),
496            app_env
497                .effective_value("BTCPAY_ADDITIONAL_HOSTS")
498                .as_deref()
499                .unwrap_or_default(),
500        ),
501    );
502    for key in [
503        "BTCPAY_SSHAUTHORIZEDKEYS",
504        "BTCPAY_SSHTRUSTEDFINGERPRINTS",
505        "BTCPAY_SSHKEYFILE",
506    ] {
507        values.insert(
508            key.to_string(),
509            app_env.effective_value(key).unwrap_or_default(),
510        );
511    }
512    Ok(values)
513}
514
515fn resolve_btcpay_image(app_env: &EnvFile) -> Result<String> {
516    if let Some(image) = app_env.effective_value("BTCPAY_IMAGE") {
517        ensure!(
518            stable_btcpay_version_from_image(&image).is_some(),
519            "BTCPAY_IMAGE must use an official stable image such as btcpayserver/btcpayserver:2.4.3; release candidates are not allowed"
520        );
521        return Ok(image);
522    }
523
524    let client = Client::builder()
525        .timeout(Duration::from_secs(10))
526        .user_agent(concat!("ch-rds/", env!("CARGO_PKG_VERSION")))
527        .build()
528        .context("failed to create BTCPay release client")?;
529    let resolved = client
530        .get(BTCPAY_LATEST_STABLE_RELEASE_URL)
531        .send()
532        .and_then(reqwest::blocking::Response::error_for_status)
533        .and_then(reqwest::blocking::Response::json::<GithubRelease>)
534        .ok()
535        .and_then(|release| stable_btcpay_image_from_release(&release));
536
537    if let Some(image) = resolved {
538        return Ok(image);
539    }
540
541    tracing::warn!(
542        fallback_image = BTCPAY_STABLE_FALLBACK_IMAGE,
543        "Could not resolve the latest stable BTCPay release; using the pinned stable fallback"
544    );
545    Ok(BTCPAY_STABLE_FALLBACK_IMAGE.to_string())
546}
547
548fn stable_btcpay_image_from_release(release: &GithubRelease) -> Option<String> {
549    if release.draft || release.prerelease {
550        return None;
551    }
552    let version = release.tag_name.strip_prefix('v')?;
553    stable_btcpay_version(version).then(|| format!("btcpayserver/btcpayserver:{version}"))
554}
555
556fn stable_btcpay_version_from_image(image: &str) -> Option<&str> {
557    let version = image.strip_prefix("btcpayserver/btcpayserver:")?;
558    stable_btcpay_version(version).then_some(version)
559}
560
561fn stable_btcpay_version(version: &str) -> bool {
562    let mut components = version.split('.');
563    let valid_component = |component: &str| {
564        !component.is_empty()
565            && component.bytes().all(|byte| byte.is_ascii_digit())
566            && (component == "0" || !component.starts_with('0'))
567    };
568    let Some(major) = components.next() else {
569        return false;
570    };
571    let Some(minor) = components.next() else {
572        return false;
573    };
574    let Some(patch) = components.next() else {
575        return false;
576    };
577    components.next().is_none()
578        && valid_component(major)
579        && valid_component(minor)
580        && valid_component(patch)
581}
582
583fn write_btcpay_env(path: &Path, values: &BTreeMap<String, String>) -> Result<()> {
584    let mut lines = Vec::new();
585    for key in [
586        "BTCPAY_IMAGE",
587        "BTCPAY_HOST",
588        "REVERSEPROXY_HTTP_PORT",
589        "REVERSEPROXY_HTTPS_PORT",
590        "NBITCOIN_NETWORK",
591        "BTCPAYGEN_CRYPTO1",
592        "BTCPAYGEN_REVERSEPROXY",
593        "BTCPAYGEN_SUBNAME",
594        "COMPOSE_PROJECT_NAME",
595        "BTCPAY_ADDITIONAL_HOSTS",
596        "BTCPAY_SSHAUTHORIZEDKEYS",
597        "BTCPAY_SSHTRUSTEDFINGERPRINTS",
598        "BTCPAY_SSHKEYFILE",
599    ] {
600        lines.push(format!(
601            "{key}={}",
602            values.get(key).map(String::as_str).unwrap_or_default()
603        ));
604    }
605    fs::write(path, format!("{}\n", lines.join("\n")))
606        .with_context(|| format!("failed to write {}", path.display()))
607}
608
609fn run_build_script(btcpay_root: &Path, envs: &BTreeMap<String, String>) -> Result<()> {
610    let (program, args): (&str, Vec<String>) = if cfg!(windows) {
611        (
612            "powershell",
613            vec![
614                "-NoProfile".to_string(),
615                "-ExecutionPolicy".to_string(),
616                "Bypass".to_string(),
617                "-File".to_string(),
618                btcpay_root.join("build.ps1").display().to_string(),
619            ],
620        )
621    } else {
622        (
623            "bash",
624            vec![btcpay_root.join("build.sh").display().to_string()],
625        )
626    };
627    run_command("BTCPay build step", program, &args, btcpay_root, envs)
628}
629
630fn patch_local_btcpay_compose(btcpay_root: &Path, envs: &BTreeMap<String, String>) -> Result<()> {
631    let subname = required_env_value(envs, "BTCPAYGEN_SUBNAME")?;
632    let project_name = required_env_value(envs, "COMPOSE_PROJECT_NAME")?;
633    let compose_file = btcpay_root
634        .join("Generated")
635        .join(format!("docker-compose.{subname}.yml"));
636    let compose = fs::read_to_string(&compose_file)
637        .with_context(|| format!("failed to read {}", compose_file.display()))?;
638    let network = required_env_value(envs, "NBITCOIN_NETWORK")?;
639    let patched = namespace_local_container_names(
640        &enable_regtest_wallet_creation(
641            &compose.replace("\n        onion=tor:9050\n", "\n        onion=0\n"),
642            network,
643        ),
644        project_name,
645    );
646
647    let patched = configure_local_webhook_host(&patched)?;
648
649    if patched != compose {
650        fs::write(&compose_file, patched)
651            .with_context(|| format!("failed to write {}", compose_file.display()))?;
652    }
653
654    Ok(())
655}
656
657fn configure_local_webhook_host(compose: &str) -> Result<String> {
658    // The upstream entrypoint overwrites host.docker.internal with the bridge
659    // gateway. Docker Desktop needs the daemon-selected host gateway instead.
660    let service = "\n  btcpayserver:\n";
661    ensure!(
662        compose.contains(service),
663        "generated compose has no BTCPay service"
664    );
665    let configured =
666        "\n  btcpayserver:\n    extra_hosts:\n      - \"payment-host.internal:host-gateway\"\n";
667    if compose.contains(configured) {
668        return Ok(compose.to_owned());
669    }
670    Ok(compose.replace(service, configured))
671}
672
673fn enable_regtest_wallet_creation(compose: &str, network: &str) -> String {
674    if network != "regtest" {
675        return compose.to_owned();
676    }
677    compose.replace("CREATE_WALLET: \"false\"", "CREATE_WALLET: \"true\"")
678}
679
680fn namespace_local_container_names(compose: &str, project_name: &str) -> String {
681    let prefix = format!("{project_name}-");
682    let namespaced = compose
683        .lines()
684        .map(|line| {
685            let content = line.trim_start();
686            let indentation = &line[..line.len() - content.len()];
687            match content.strip_prefix("container_name: ") {
688                Some(name) if !name.starts_with(&prefix) => {
689                    format!("{indentation}container_name: {prefix}{name}")
690                }
691                _ => line.to_owned(),
692            }
693        })
694        .collect::<Vec<_>>()
695        .join("\n")
696        + if compose.ends_with('\n') { "\n" } else { "" };
697
698    namespaced
699        .replace(
700            "-notify-sighup nginx ",
701            &format!("-notify-sighup {prefix}nginx "),
702        )
703        .replace(
704            "-notify-sighup tor ",
705            &format!("-notify-sighup {prefix}tor "),
706        )
707        .replace(
708            "NGINX_DOCKER_GEN_CONTAINER: \"nginx-gen\"",
709            &format!("NGINX_DOCKER_GEN_CONTAINER: \"{prefix}nginx-gen\""),
710        )
711        .replace(
712            "NGINX_PROXY_CONTAINER: \"nginx\"",
713            &format!("NGINX_PROXY_CONTAINER: \"{prefix}nginx\""),
714        )
715}
716
717fn docker_compose_up(btcpay_root: &Path, envs: &BTreeMap<String, String>) -> Result<()> {
718    let subname = required_env_value(envs, "BTCPAYGEN_SUBNAME")?;
719    let project_name = required_env_value(envs, "COMPOSE_PROJECT_NAME")?;
720    let compose_file = btcpay_root
721        .join("Generated")
722        .join(format!("docker-compose.{subname}.yml"));
723    ensure!(
724        compose_file.is_file(),
725        "generated BTCPay compose file is missing at {}",
726        compose_file.display()
727    );
728
729    let args = vec![
730        "compose".to_string(),
731        "--project-name".to_string(),
732        project_name.to_string(),
733        "--env-file".to_string(),
734        ".env".to_string(),
735        "-f".to_string(),
736        compose_file.display().to_string(),
737        "up".to_string(),
738        "-d".to_string(),
739    ];
740    run_command(
741        "BTCPay docker compose up",
742        "docker",
743        &args,
744        btcpay_root,
745        envs,
746    )
747}
748
749fn run_command(
750    label: &str,
751    program: &str,
752    args: &[String],
753    cwd: &Path,
754    envs: &BTreeMap<String, String>,
755) -> Result<()> {
756    let mut command = Command::new(program);
757    command.current_dir(cwd).args(args).envs(envs);
758    command.env("COMPOSE_CONVERT_WINDOWS_PATHS", "1");
759    let status = command
760        .status()
761        .with_context(|| format!("failed to start {label}"))?;
762    ensure!(status.success(), "{label} exited with status {status}");
763    Ok(())
764}
765
766struct BtcpayClient {
767    profile: BtcpayDevProfile,
768    base_url: String,
769    admin_email: String,
770    admin_password: String,
771    client: Client,
772}
773
774impl BtcpayClient {
775    fn new(
776        profile: BtcpayDevProfile,
777        base_url: String,
778        admin_email: String,
779        admin_password: String,
780    ) -> Result<Self> {
781        let client = Client::builder()
782            .cookie_store(true)
783            .timeout(Duration::from_secs(30))
784            .build()
785            .context("failed to create BTCPay HTTP client")?;
786        Ok(Self {
787            profile,
788            base_url: base_url.trim_end_matches('/').to_string(),
789            admin_email,
790            admin_password,
791            client,
792        })
793    }
794
795    fn wait_until_ready(&self) -> Result<()> {
796        let deadline = Instant::now() + Duration::from_secs(180);
797        let mut last_error = None;
798        while Instant::now() < deadline {
799            match self
800                .client
801                .get(format!("{}/api/v1/stores", self.base_url))
802                .send()
803            {
804                Ok(response)
805                    if matches!(
806                        response.status(),
807                        StatusCode::OK | StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN
808                    ) =>
809                {
810                    return Ok(());
811                }
812                Ok(response) => {
813                    last_error = Some(anyhow!("BTCPay returned {}", response.status()));
814                }
815                Err(error) => {
816                    last_error = Some(error.into());
817                }
818            }
819            std::thread::sleep(Duration::from_secs(2));
820        }
821        Err(last_error.unwrap_or_else(|| anyhow!("BTCPay did not become ready in time")))
822    }
823
824    fn ensure_admin_user(&self) -> Result<()> {
825        if self.has_valid_admin_credentials()? {
826            return Ok(());
827        }
828
829        let response = self
830            .client
831            .post(format!("{}/api/v1/users", self.base_url))
832            .json(&json!({
833                "email": self.admin_email.as_str(),
834                "name": self.profile.display_name(),
835                "password": self.admin_password.as_str(),
836                "isAdministrator": true,
837                "sendInvitationEmail": false,
838            }))
839            .send()
840            .context("failed to create the local BTCPay administrator")?;
841        ensure!(
842            response.status() == StatusCode::CREATED || response.status() == StatusCode::OK,
843            "BTCPay administrator creation returned {}. The local BTCPay data may belong to different development credentials",
844            response.status()
845        );
846        ensure!(
847            self.has_valid_admin_credentials()?,
848            "BTCPay local admin bootstrap did not produce valid API credentials"
849        );
850        Ok(())
851    }
852
853    fn has_valid_admin_credentials(&self) -> Result<bool> {
854        let response = self
855            .admin_auth(
856                self.client
857                    .get(format!("{}/api/v1/users/me", self.base_url)),
858            )
859            .send()
860            .context("failed to check BTCPay admin credentials")?;
861        match response.status() {
862            StatusCode::OK => Ok(true),
863            StatusCode::UNAUTHORIZED | StatusCode::NOT_FOUND => Ok(false),
864            status => bail!("BTCPay admin credential check returned {status}"),
865        }
866    }
867
868    /// Reuse the named development store, or create it with administrator credentials.
869    fn ensure_store(&self, public_url: &str) -> Result<String> {
870        let path = "/api/v1/stores";
871        let response = self
872            .admin_auth(self.client.get(format!("{}{}", self.base_url, path)))
873            .send()
874            .with_context(|| format!("failed to request BTCPay {path}"))?;
875        let stores: Vec<Value> = read_btcpay_response(response, path)?;
876        for store in stores {
877            if store.get("name").and_then(Value::as_str) == Some(self.profile.store_name.as_str())
878                && let Some(id) = store.get("id").and_then(Value::as_str)
879            {
880                return Ok(id.to_string());
881            }
882        }
883
884        let payload = json!({
885            "name": self.profile.store_name.as_str(),
886            "website": public_url,
887            "defaultLang": "en",
888            "speedPolicy": "MediumSpeed",
889            "redirectAutomatically": false,
890            "anyoneCanCreateInvoice": false,
891        });
892        let response = self
893            .admin_auth(self.client.post(format!("{}{}", self.base_url, path)))
894            .json(&payload)
895            .send()
896            .with_context(|| format!("failed to request BTCPay {path}"))?;
897        let created: Value = read_btcpay_response(response, path)?;
898        created
899            .get("id")
900            .and_then(Value::as_str)
901            .map(str::to_string)
902            .ok_or_else(|| anyhow!("BTCPay store creation returned an unexpected response"))
903    }
904
905    fn ensure_onchain_wallet(&self, store_id: &str, api_key: &str) -> Result<()> {
906        let has_wallet = |payload: &Value| {
907            payload.get("enabled").and_then(Value::as_bool) == Some(true)
908                && payload.get("paymentMethodId").and_then(Value::as_str) == Some("BTC-CHAIN")
909                && payload
910                    .pointer("/config/accountDerivation")
911                    .and_then(Value::as_str)
912                    .is_some_and(|derivation| !derivation.trim().is_empty())
913        };
914        let wallet_path =
915            format!("/api/v1/stores/{store_id}/payment-methods/OnChain/BTC?includeConfig=true");
916        let generate_path =
917            format!("/api/v1/stores/{store_id}/payment-methods/OnChain/BTC/wallet/generate");
918        let deadline = Instant::now() + Duration::from_secs(180);
919
920        loop {
921            let response = self
922                .api_key_auth(
923                    api_key,
924                    self.client.get(format!("{}{}", self.base_url, wallet_path)),
925                )
926                .send()
927                .with_context(|| format!("failed to request BTCPay {wallet_path}"))?;
928            match response.status() {
929                status if status.is_success() => {
930                    let payload: Value = read_btcpay_response(response, wallet_path.as_str())?;
931                    if has_wallet(&payload) {
932                        return Ok(());
933                    }
934                }
935                StatusCode::NOT_FOUND | StatusCode::SERVICE_UNAVAILABLE => {}
936                _ => {
937                    let _: Value = read_btcpay_response(response, wallet_path.as_str())?;
938                }
939            }
940
941            let response = self
942                .api_key_auth(
943                    api_key,
944                    self.client
945                        .post(format!("{}{}", self.base_url, generate_path)),
946                )
947                .json(&json!({
948                    "label": self.profile.api_key_label.as_str(),
949                    "accountNumber": 0,
950                    "savePrivateKeys": false,
951                    "wordList": "English",
952                    "wordCount": 12,
953                    "scriptPubKeyType": "Segwit",
954                }))
955                .send()
956                .with_context(|| format!("failed to request BTCPay {generate_path}"))?;
957            if response.status() != StatusCode::SERVICE_UNAVAILABLE {
958                let payload: Value = read_btcpay_response(response, generate_path.as_str())?;
959                ensure!(
960                    has_wallet(&payload),
961                    "BTCPay on-chain wallet setup returned an unexpected response"
962                );
963                return Ok(());
964            }
965
966            ensure!(
967                Instant::now() < deadline,
968                "BTCPay on-chain services did not become ready in time"
969            );
970            std::thread::sleep(Duration::from_secs(2));
971        }
972    }
973
974    /// Apply the development exchange-rate settings through the store API key.
975    fn ensure_rate_settings(&self, store_id: &str, api_key: &str) -> Result<()> {
976        let path = format!("/api/v1/stores/{store_id}/rates/configuration/primary");
977        let settings = json!({
978            "spread": "0",
979            "preferredSource": self.profile.rate_source.as_str(),
980            "isCustomScript": false,
981            "effectiveScript": "",
982        });
983        let response = self
984            .api_key_auth(
985                api_key,
986                self.client.put(format!("{}{}", self.base_url, path)),
987            )
988            .json(&settings)
989            .send()
990            .with_context(|| format!("failed to request BTCPay {path}"))?;
991        let payload: Value = read_btcpay_response(response, &path)?;
992        ensure!(
993            payload.get("isCustomScript").and_then(Value::as_bool) == Some(false),
994            "BTCPay rate configuration returned an unexpected response"
995        );
996        Ok(())
997    }
998
999    /// Preserve a valid store key or create one with exactly the required permissions.
1000    fn ensure_api_key(&self, store_id: &str, current_key: Option<&str>) -> Result<String> {
1001        let required_permissions = required_api_permissions(store_id);
1002        if let Some(key) = current_key
1003            && !is_placeholder_value(key)
1004            && self.api_key_has_permissions(key, &required_permissions)?
1005        {
1006            return Ok(key.to_string());
1007        }
1008
1009        let path = "/api/v1/api-keys";
1010        let payload = json!({
1011            "label": self.profile.api_key_label.as_str(),
1012            "permissions": required_permissions,
1013        });
1014        let response = self
1015            .admin_auth(self.client.post(format!("{}{}", self.base_url, path)))
1016            .json(&payload)
1017            .send()
1018            .with_context(|| format!("failed to request BTCPay {path}"))?;
1019        let created: Value = read_btcpay_response(response, path)?;
1020        created
1021            .get("apiKey")
1022            .and_then(Value::as_str)
1023            .map(str::to_string)
1024            .ok_or_else(|| anyhow!("BTCPay API key creation returned an unexpected response"))
1025    }
1026
1027    /// Reuse the configured webhook secret or register the payment-event subscription.
1028    fn ensure_webhook(
1029        &self,
1030        store_id: &str,
1031        public_url: &str,
1032        api_key: &str,
1033        current_secret: Option<&str>,
1034    ) -> Result<String> {
1035        let webhook_url = btcpay_webhook_url(public_url, self.profile.webhook_path.as_str())?;
1036        if let Some(secret) = current_secret
1037            && !is_placeholder_value(secret)
1038            && self.webhook_exists(store_id, api_key, webhook_url.as_str())?
1039        {
1040            return Ok(secret.to_string());
1041        }
1042
1043        let secret = format!("{}_{}", Uuid::new_v4(), Uuid::new_v4());
1044        let path = format!("/api/v1/stores/{store_id}/webhooks");
1045        let payload = json!({
1046            "enabled": true,
1047            "automaticRedelivery": true,
1048            "url": webhook_url,
1049            "secret": secret,
1050            "authorizedEvents": {
1051                "everything": false,
1052                "specificEvents": [
1053                    "InvoiceCreated",
1054                    "InvoiceReceivedPayment",
1055                    "InvoiceProcessing",
1056                    "InvoiceSettled",
1057                    "InvoiceExpired",
1058                    "InvoiceInvalid"
1059                ]
1060            }
1061        });
1062        let response = self
1063            .api_key_auth(
1064                api_key,
1065                self.client.post(format!("{}{}", self.base_url, path)),
1066            )
1067            .json(&payload)
1068            .send()
1069            .with_context(|| format!("failed to request BTCPay {path}"))?;
1070        let created: Value = read_btcpay_response(response, &path)?;
1071        Ok(created
1072            .get("secret")
1073            .and_then(Value::as_str)
1074            .unwrap_or(secret.as_str())
1075            .to_string())
1076    }
1077
1078    fn api_key_has_permissions(&self, key: &str, required_permissions: &[String]) -> Result<bool> {
1079        let response = self
1080            .api_key_auth(
1081                key,
1082                self.client
1083                    .get(format!("{}/api/v1/api-keys/current", self.base_url)),
1084            )
1085            .send()
1086            .context("failed to check BTCPay API key")?;
1087        if response.status() == StatusCode::UNAUTHORIZED {
1088            return Ok(false);
1089        }
1090        let payload: Value = read_btcpay_response(response, "BTCPay API key check")?;
1091        let Some(permissions) = payload.get("permissions").and_then(Value::as_array) else {
1092            return Ok(false);
1093        };
1094        let permissions = permissions
1095            .iter()
1096            .filter_map(Value::as_str)
1097            .collect::<BTreeSet<_>>();
1098        Ok(required_permissions
1099            .iter()
1100            .all(|permission| permissions.contains(permission.as_str())))
1101    }
1102
1103    /// Check existing subscriptions before registering another webhook.
1104    fn webhook_exists(&self, store_id: &str, api_key: &str, webhook_url: &str) -> Result<bool> {
1105        let path = format!("/api/v1/stores/{store_id}/webhooks");
1106        let response = self
1107            .api_key_auth(
1108                api_key,
1109                self.client.get(format!("{}{}", self.base_url, path)),
1110            )
1111            .send()
1112            .with_context(|| format!("failed to request BTCPay {path}"))?;
1113        let webhooks: Vec<Value> = read_btcpay_response(response, &path)?;
1114        Ok(webhooks
1115            .iter()
1116            .any(|webhook| webhook.get("url").and_then(Value::as_str) == Some(webhook_url)))
1117    }
1118
1119    fn admin_auth(&self, request: RequestBuilder) -> RequestBuilder {
1120        let token = base64::engine::general_purpose::STANDARD
1121            .encode(format!("{}:{}", self.admin_email, self.admin_password));
1122        request.header(reqwest::header::AUTHORIZATION, format!("Basic {token}"))
1123    }
1124
1125    fn api_key_auth(&self, api_key: &str, request: RequestBuilder) -> RequestBuilder {
1126        request.header(reqwest::header::AUTHORIZATION, format!("token {api_key}"))
1127    }
1128}
1129
1130fn required_api_permissions(store_id: &str) -> [String; 5] {
1131    [
1132        format!("btcpay.store.cancreateinvoice:{store_id}"),
1133        format!("btcpay.store.canviewinvoices:{store_id}"),
1134        format!("btcpay.store.webhooks.canmodifywebhooks:{store_id}"),
1135        format!("btcpay.store.canviewstoresettings:{store_id}"),
1136        format!("btcpay.store.canmodifystoresettings:{store_id}"),
1137    ]
1138}
1139
1140/// Decode a BTCPay response only after preserving its HTTP failure and body context.
1141fn read_btcpay_response<T: for<'de> Deserialize<'de>>(
1142    response: reqwest::blocking::Response,
1143    label: &str,
1144) -> Result<T> {
1145    let status = response.status();
1146    let text = response
1147        .text()
1148        .with_context(|| format!("failed to read {label} response"))?;
1149    ensure!(
1150        status.is_success(),
1151        "{label} returned {status}: {}",
1152        text.chars().take(500).collect::<String>()
1153    );
1154    serde_json::from_str(&text)
1155        .with_context(|| format!("{label} returned an unexpected JSON response"))
1156}
1157
1158fn btcpay_webhook_url(public_url: &str, webhook_path: &str) -> Result<String> {
1159    let parsed = url::Url::parse(public_url)
1160        .with_context(|| format!("invalid public payment URL `{public_url}`"))?;
1161    let host = match parsed.host_str() {
1162        Some("127.0.0.1" | "localhost") => "payment-host.internal",
1163        Some(host) => host,
1164        None => "payment-host.internal",
1165    };
1166    let mut netloc = host.to_string();
1167    if let Some(port) = parsed.port() {
1168        netloc = format!("{netloc}:{port}");
1169    }
1170    let scheme = if parsed.scheme().is_empty() {
1171        "http"
1172    } else {
1173        parsed.scheme()
1174    };
1175    Ok(format!("{scheme}://{netloc}{webhook_path}"))
1176}
1177
1178fn required_env_value<'a>(values: &'a BTreeMap<String, String>, key: &str) -> Result<&'a str> {
1179    values
1180        .get(key)
1181        .map(String::as_str)
1182        .filter(|value| !value.trim().is_empty())
1183        .ok_or_else(|| anyhow!("missing required BTCPay env value {key}"))
1184}
1185
1186fn local_btcpay_additional_hosts(primary_host: &str, configured_hosts: &str) -> String {
1187    let mut hosts = configured_hosts
1188        .split(',')
1189        .map(str::trim)
1190        .filter(|host| !host.is_empty())
1191        .map(str::to_string)
1192        .collect::<Vec<_>>();
1193    if primary_host == "127.0.0.1" && !hosts.iter().any(|host| host == "localhost") {
1194        hosts.push("localhost".to_string());
1195    }
1196    if primary_host == "localhost" && !hosts.iter().any(|host| host == "127.0.0.1") {
1197        hosts.push("127.0.0.1".to_string());
1198    }
1199    hosts.join(",")
1200}
1201
1202fn is_placeholder_value(value: &str) -> bool {
1203    matches!(
1204        value.trim(),
1205        "" | "__change_me__" | "BTCPAY_STORE_ID_FROM_UI"
1206    )
1207}
1208
1209fn mask_secret(value: &str) -> String {
1210    if value.len() <= 8 {
1211        return "********".to_string();
1212    }
1213    format!("{}...{}", &value[..4], &value[value.len() - 4..])
1214}
1215
1216#[cfg(test)]
1217mod tests {
1218    use super::{
1219        BtcpayClient, BtcpayDevProfile, GithubRelease, btcpay_checkout_root, btcpay_webhook_url,
1220        configure_local_webhook_host, enable_regtest_wallet_creation,
1221        namespace_local_container_names, stable_btcpay_image_from_release,
1222        stable_btcpay_version_from_image,
1223    };
1224    use base64::Engine as _;
1225    use serde_json::{Value, json};
1226    use std::{
1227        collections::BTreeMap,
1228        io::{BufRead, BufReader, Read, Write},
1229        net::TcpListener,
1230        path::Path,
1231        thread::JoinHandle,
1232        time::Duration,
1233    };
1234
1235    type CapturedRequest = (String, BTreeMap<String, String>, Option<Value>);
1236
1237    fn serve_btcpay_requests(replies: Vec<Value>) -> (String, JoinHandle<Vec<CapturedRequest>>) {
1238        let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
1239        let base_url = format!("http://{}", listener.local_addr().unwrap());
1240        let server = std::thread::spawn(move || {
1241            let mut captured = Vec::new();
1242            for reply in replies {
1243                let (mut stream, _) = listener.accept().unwrap();
1244                stream
1245                    .set_read_timeout(Some(Duration::from_secs(5)))
1246                    .unwrap();
1247                let (line, headers, body) = {
1248                    let mut reader = BufReader::new(&mut stream);
1249                    let mut line = String::new();
1250                    reader.read_line(&mut line).unwrap();
1251                    let mut headers = BTreeMap::new();
1252                    loop {
1253                        let mut header = String::new();
1254                        reader.read_line(&mut header).unwrap();
1255                        if header == "\r\n" {
1256                            break;
1257                        }
1258                        let (name, value) = header.trim().split_once(':').unwrap();
1259                        headers.insert(name.to_ascii_lowercase(), value.trim().to_owned());
1260                    }
1261                    let length = headers
1262                        .get("content-length")
1263                        .map(|length| length.parse::<usize>().unwrap())
1264                        .unwrap_or(0);
1265                    let mut bytes = vec![0; length];
1266                    reader.read_exact(&mut bytes).unwrap();
1267                    let body = (!bytes.is_empty()).then(|| serde_json::from_slice(&bytes).unwrap());
1268                    (line.trim().to_owned(), headers, body)
1269                };
1270                captured.push((line, headers, body));
1271                let body = reply.to_string();
1272                write!(
1273                    stream,
1274                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
1275                    body.len(),
1276                )
1277                .unwrap();
1278            }
1279            captured
1280        });
1281        (base_url, server)
1282    }
1283
1284    #[test]
1285    fn bootstrap_preserves_request_order_credentials_permissions_and_payment_events() {
1286        let (base_url, server) = serve_btcpay_requests(vec![
1287            json!([]),
1288            json!({"id": "store-a"}),
1289            json!({"permissions": []}),
1290            json!({"apiKey": "new-store-key"}),
1291            json!({"isCustomScript": false}),
1292            json!([]),
1293            json!({"secret": "registered-secret"}),
1294        ]);
1295        let public_url = "https://merchant.example.invalid";
1296        let profile = BtcpayDevProfile::new("Fixture store", "admin@example.invalid", public_url);
1297        let client = BtcpayClient::new(
1298            profile,
1299            base_url,
1300            "admin@example.invalid".to_owned(),
1301            "fixture-password".to_owned(),
1302        )
1303        .unwrap();
1304        assert_eq!(client.ensure_store(public_url).unwrap(), "store-a");
1305        let key = client
1306            .ensure_api_key("store-a", Some("old-store-key"))
1307            .unwrap();
1308        assert_eq!(key, "new-store-key");
1309        client.ensure_rate_settings("store-a", &key).unwrap();
1310        assert_eq!(
1311            client
1312                .ensure_webhook("store-a", public_url, &key, Some("old-secret"))
1313                .unwrap(),
1314            "registered-secret",
1315        );
1316        let requests = server.join().unwrap();
1317        assert_eq!(
1318            requests
1319                .iter()
1320                .map(|request| request.0.as_str())
1321                .collect::<Vec<_>>(),
1322            [
1323                "GET /api/v1/stores HTTP/1.1",
1324                "POST /api/v1/stores HTTP/1.1",
1325                "GET /api/v1/api-keys/current HTTP/1.1",
1326                "POST /api/v1/api-keys HTTP/1.1",
1327                "PUT /api/v1/stores/store-a/rates/configuration/primary HTTP/1.1",
1328                "GET /api/v1/stores/store-a/webhooks HTTP/1.1",
1329                "POST /api/v1/stores/store-a/webhooks HTTP/1.1",
1330            ],
1331        );
1332        let admin = format!(
1333            "Basic {}",
1334            base64::engine::general_purpose::STANDARD
1335                .encode("admin@example.invalid:fixture-password"),
1336        );
1337        for index in [0, 1, 3] {
1338            assert_eq!(requests[index].1["authorization"], admin);
1339        }
1340        assert_eq!(requests[2].1["authorization"], "token old-store-key");
1341        for index in [4, 5, 6] {
1342            assert_eq!(requests[index].1["authorization"], "token new-store-key");
1343        }
1344        assert_eq!(
1345            requests[1].2,
1346            Some(json!({
1347                "name": "Fixture store", "website": public_url, "defaultLang": "en",
1348                "speedPolicy": "MediumSpeed", "redirectAutomatically": false,
1349                "anyoneCanCreateInvoice": false,
1350            }))
1351        );
1352        assert_eq!(
1353            requests[3].2,
1354            Some(json!({
1355                "label": "Fixture store local dev",
1356                "permissions": [
1357                    "btcpay.store.cancreateinvoice:store-a",
1358                    "btcpay.store.canviewinvoices:store-a",
1359                    "btcpay.store.webhooks.canmodifywebhooks:store-a",
1360                    "btcpay.store.canviewstoresettings:store-a",
1361                    "btcpay.store.canmodifystoresettings:store-a",
1362                ],
1363            }))
1364        );
1365        assert_eq!(
1366            requests[4].2,
1367            Some(json!({
1368                "spread": "0", "preferredSource": "kraken",
1369                "isCustomScript": false, "effectiveScript": "",
1370            }))
1371        );
1372        let webhook = requests[6].2.as_ref().unwrap();
1373        assert_eq!(
1374            webhook["url"],
1375            format!("{public_url}/api/payments/webhooks/btcpay")
1376        );
1377        assert_eq!(webhook["enabled"], true);
1378        assert_eq!(webhook["automaticRedelivery"], true);
1379        assert_eq!(
1380            webhook["authorizedEvents"],
1381            json!({
1382                "everything": false,
1383                "specificEvents": ["InvoiceCreated", "InvoiceReceivedPayment", "InvoiceProcessing",
1384                    "InvoiceSettled", "InvoiceExpired", "InvoiceInvalid"],
1385            })
1386        );
1387        assert_eq!(webhook["secret"].as_str().unwrap().len(), 73);
1388    }
1389
1390    #[test]
1391    fn matching_store_is_reused_without_creating_another_store() {
1392        let (base_url, server) = serve_btcpay_requests(vec![json!([
1393            {"name": "Another store", "id": "other"},
1394            {"name": "Fixture store", "id": "existing"},
1395        ])]);
1396        let public_url = "https://merchant.example.invalid";
1397        let client = BtcpayClient::new(
1398            BtcpayDevProfile::new("Fixture store", "admin@example.invalid", public_url),
1399            base_url,
1400            "admin@example.invalid".to_owned(),
1401            "fixture-password".to_owned(),
1402        )
1403        .unwrap();
1404        assert_eq!(client.ensure_store(public_url).unwrap(), "existing");
1405        let requests = server.join().unwrap();
1406        assert_eq!(requests.len(), 1);
1407        assert_eq!(requests[0].0, "GET /api/v1/stores HTTP/1.1");
1408        assert!(requests[0].2.is_none());
1409    }
1410
1411    #[test]
1412    fn local_webhooks_use_daemon_host_without_upstream_hostname_collision() {
1413        let compose = "services:\n  btcpayserver:\n    image: btcpay\n  other:\n    image: other\n";
1414        let configured = configure_local_webhook_host(compose).unwrap();
1415        assert!(configured.contains("payment-host.internal:host-gateway"));
1416        assert!(configured.ends_with("  other:\n    image: other\n"));
1417        assert_eq!(
1418            configure_local_webhook_host(&configured).unwrap(),
1419            configured
1420        );
1421        assert!(configure_local_webhook_host("services:\n").is_err());
1422        for host in ["localhost", "127.0.0.1"] {
1423            assert_eq!(
1424                btcpay_webhook_url(&format!("http://{host}:4032"), "/webhook").unwrap(),
1425                "http://payment-host.internal:4032/webhook"
1426            );
1427        }
1428        assert_eq!(
1429            btcpay_webhook_url("https://example.org", "/webhook").unwrap(),
1430            "https://example.org/webhook"
1431        );
1432    }
1433
1434    #[test]
1435    fn resolves_btcpay_checkout_next_to_the_app_repository() {
1436        assert_eq!(
1437            btcpay_checkout_root(Path::new("/workspace/ch-chirora"))
1438                .expect("app checkout should have a parent"),
1439            Path::new("/workspace/third-party/btcpay")
1440        );
1441    }
1442
1443    #[test]
1444    fn namespaces_generated_container_names_with_the_compose_project() {
1445        let compose = concat!(
1446            "services:\n",
1447            "  nginx:\n",
1448            "    container_name: nginx\n",
1449            "  nginx-gen:\n",
1450            "    container_name: nginx-gen\n",
1451            "    entrypoint: docker-gen -notify-sighup nginx -watch\n",
1452            "  letsencrypt:\n",
1453            "    environment:\n",
1454            "      NGINX_DOCKER_GEN_CONTAINER: \"nginx-gen\"\n",
1455            "      NGINX_PROXY_CONTAINER: \"nginx\"\n",
1456            "  tor:\n",
1457            "    container_name: tor\n",
1458            "  tor-gen:\n",
1459            "    entrypoint: docker-gen -notify-sighup tor -watch\n",
1460        );
1461
1462        assert_eq!(
1463            namespace_local_container_names(compose, "chirora-local-btcpay"),
1464            concat!(
1465                "services:\n",
1466                "  nginx:\n",
1467                "    container_name: chirora-local-btcpay-nginx\n",
1468                "  nginx-gen:\n",
1469                "    container_name: chirora-local-btcpay-nginx-gen\n",
1470                "    entrypoint: docker-gen -notify-sighup chirora-local-btcpay-nginx -watch\n",
1471                "  letsencrypt:\n",
1472                "    environment:\n",
1473                "      NGINX_DOCKER_GEN_CONTAINER: \"chirora-local-btcpay-nginx-gen\"\n",
1474                "      NGINX_PROXY_CONTAINER: \"chirora-local-btcpay-nginx\"\n",
1475                "  tor:\n",
1476                "    container_name: chirora-local-btcpay-tor\n",
1477                "  tor-gen:\n",
1478                "    entrypoint: docker-gen -notify-sighup chirora-local-btcpay-tor -watch\n",
1479            )
1480        );
1481    }
1482
1483    #[test]
1484    fn enables_default_bitcoin_wallet_only_for_regtest() {
1485        let compose = concat!(
1486            "services:\n",
1487            "  bitcoind:\n",
1488            "    environment:\n",
1489            "      CREATE_WALLET: \"false\"\n",
1490        );
1491
1492        assert_eq!(
1493            enable_regtest_wallet_creation(compose, "regtest"),
1494            compose.replace("false", "true")
1495        );
1496        assert_eq!(enable_regtest_wallet_creation(compose, "mainnet"), compose);
1497    }
1498
1499    #[test]
1500    fn accepts_latest_stable_release() {
1501        let release = GithubRelease {
1502            tag_name: "v2.4.0".to_string(),
1503            draft: false,
1504            prerelease: false,
1505        };
1506
1507        assert_eq!(
1508            stable_btcpay_image_from_release(&release).as_deref(),
1509            Some("btcpayserver/btcpayserver:2.4.0")
1510        );
1511    }
1512
1513    #[test]
1514    fn rejects_release_candidates_and_drafts() {
1515        for release in [
1516            GithubRelease {
1517                tag_name: "v2.4.3-rc4".to_string(),
1518                draft: false,
1519                prerelease: true,
1520            },
1521            GithubRelease {
1522                tag_name: "v2.4.1".to_string(),
1523                draft: true,
1524                prerelease: false,
1525            },
1526        ] {
1527            assert!(stable_btcpay_image_from_release(&release).is_none());
1528        }
1529    }
1530
1531    #[test]
1532    fn accepts_only_official_stable_image_overrides() {
1533        assert_eq!(
1534            stable_btcpay_version_from_image("btcpayserver/btcpayserver:2.4.0"),
1535            Some("2.4.0")
1536        );
1537        for image in [
1538            "btcpayserver/btcpayserver:2.4.3-rc4",
1539            "btcpayserver/btcpayserver:latest",
1540            "example.invalid/btcpay:2.4.0",
1541            "btcpayserver/btcpayserver:02.4.0",
1542        ] {
1543            assert!(stable_btcpay_version_from_image(image).is_none());
1544        }
1545    }
1546}