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