1use 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#[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 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 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 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 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 pub app_display_name: String,
178 pub url: String,
180 pub store_id: String,
182 pub api_key_masked: String,
184 pub webhook_secret_masked: String,
186 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 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 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 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 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 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 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
1140fn 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}