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