use std::collections::{BTreeMap, BTreeSet};
use std::env;
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, Instant};
use anyhow::{Context, Result, anyhow, bail, ensure};
use base64::Engine;
use reqwest::StatusCode;
use reqwest::blocking::{Client, RequestBuilder};
use serde::Deserialize;
use serde_json::{Value, json};
use uuid::Uuid;
const BTCPAY_LATEST_STABLE_RELEASE_URL: &str =
"https://api.github.com/repos/btcpayserver/btcpayserver/releases/latest";
const BTCPAY_STABLE_FALLBACK_IMAGE: &str = "btcpayserver/btcpayserver:2.4.3";
#[derive(Deserialize)]
struct GithubRelease {
tag_name: String,
draft: bool,
prerelease: bool,
}
#[derive(Clone, Eq, PartialEq)]
pub struct BtcpayDevProfile {
display_name: String,
store_name: String,
api_key_label: String,
rate_source: String,
webhook_path: String,
default_admin_email: String,
public_url: String,
local_docker: LocalDockerConfig,
default_admin_password: Option<String>,
default_payment_signing_key: Option<String>,
}
impl BtcpayDevProfile {
pub fn new(
display_name: impl Into<String>,
default_admin_email: impl Into<String>,
public_url: impl Into<String>,
) -> Self {
let display_name = display_name.into();
Self {
store_name: display_name.clone(),
api_key_label: format!("{display_name} local dev"),
rate_source: "kraken".to_owned(),
webhook_path: "/api/payments/webhooks/btcpay".to_owned(),
default_admin_email: default_admin_email.into(),
public_url: public_url.into(),
local_docker: LocalDockerConfig::new(display_name.to_ascii_lowercase()),
display_name,
default_admin_password: None,
default_payment_signing_key: None,
}
}
pub fn with_local_ports(mut self, http: u16, https: u16) -> Self {
self.local_docker.reverseproxy_http_port = http;
self.local_docker.reverseproxy_https_port = https;
self
}
pub fn with_compose_project_name(mut self, name: impl Into<String>) -> Self {
self.local_docker.compose_project_name = name.into();
self
}
pub fn with_webhook_path(mut self, path: impl Into<String>) -> Self {
self.webhook_path = path.into();
self
}
pub(super) fn validate(&self) -> Result<()> {
for (field, value) in [
("display_name", self.display_name.as_str()),
("store_name", self.store_name.as_str()),
("api_key_label", self.api_key_label.as_str()),
("rate_source", self.rate_source.as_str()),
("default_admin_email", self.default_admin_email.as_str()),
("public_url", self.public_url.as_str()),
] {
ensure!(
!value.is_empty() && value.trim() == value && !value.chars().any(char::is_control),
"payment profile {field} must be a non-empty single-line value"
);
}
ensure!(
self.webhook_path.starts_with('/')
&& !self.webhook_path.chars().any(char::is_whitespace),
"payment profile webhook_path must be an absolute path without whitespace"
);
let public_url = url::Url::parse(&self.public_url)
.context("payment profile public_url must be an absolute URL")?;
ensure!(
matches!(public_url.scheme(), "http" | "https")
&& public_url.host_str().is_some()
&& public_url.username().is_empty()
&& public_url.password().is_none(),
"payment profile public_url must be an HTTP(S) URL without credentials"
);
self.local_docker.validate()?;
Ok(())
}
pub(super) fn display_name(&self) -> &str {
&self.display_name
}
}
#[derive(Clone, Debug)]
pub(super) struct LocalBtcpayBootstrapOptions {
profile: BtcpayDevProfile,
project_root: PathBuf,
env_file: PathBuf,
}
impl LocalBtcpayBootstrapOptions {
pub(super) fn new(profile: BtcpayDevProfile, project_root: impl Into<PathBuf>) -> Self {
let project_root = project_root.into();
Self {
profile,
env_file: project_root.join(".env"),
project_root,
}
}
}
fn btcpay_checkout_root(project_root: &Path) -> Result<PathBuf> {
let checkout_root = project_root
.parent()
.ok_or_else(|| anyhow!("app checkout has no parent directory"))?;
Ok(checkout_root.join("third-party").join("btcpay"))
}
impl fmt::Debug for BtcpayDevProfile {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("BtcpayDevProfile")
.field("display_name", &self.display_name)
.field("store_name", &self.store_name)
.field("api_key_label", &self.api_key_label)
.field("rate_source", &self.rate_source)
.field("webhook_path", &self.webhook_path)
.field("default_admin_email", &self.default_admin_email)
.field("public_url", &self.public_url)
.field("local_docker", &self.local_docker)
.field(
"default_admin_password",
&self.default_admin_password.as_ref().map(|_| "[REDACTED]"),
)
.field(
"default_payment_signing_key",
&self
.default_payment_signing_key
.as_ref()
.map(|_| "[REDACTED]"),
)
.finish()
}
}
#[derive(Clone, Debug)]
pub(super) struct LocalBtcpayBootstrapSummary {
pub app_display_name: String,
pub url: String,
pub store_id: String,
pub api_key_masked: String,
pub webhook_secret_masked: String,
pub env_file: PathBuf,
}
pub(super) fn bootstrap_local_btcpay(
options: &LocalBtcpayBootstrapOptions,
) -> Result<LocalBtcpayBootstrapSummary> {
let profile = &options.profile;
let btcpay_root = btcpay_checkout_root(&options.project_root)?;
ensure!(
btcpay_root.is_dir(),
"BTCPay checkout not found at {}",
btcpay_root.display()
);
ensure!(
options.project_root.join("Cargo.toml").is_file(),
"app checkout not found at {}",
options.project_root.display()
);
let mut app_env = EnvFile::read(&options.env_file)?;
let public_url = app_env
.effective_value("PUBLIC_URL")
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| profile.public_url.clone());
let docker_env = docker_env_values(&profile.local_docker, &app_env)?;
write_btcpay_env(&btcpay_root.join(".env"), &docker_env)?;
run_build_script(&btcpay_root, &docker_env)?;
patch_local_btcpay_compose(&btcpay_root, &docker_env)?;
docker_compose_up(&btcpay_root, &docker_env)?;
let url = format!(
"http://{}:{}",
profile.local_docker.host, profile.local_docker.reverseproxy_http_port
);
let admin_email = app_env
.effective_value("LOCAL_BTCPAY_ADMIN_EMAIL")
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| profile.default_admin_email.to_string());
let admin_password_key = "LOCAL_BTCPAY_ADMIN_PASSWORD";
let admin_password = app_env
.effective_value(admin_password_key)
.filter(|value| !value.trim().is_empty())
.or_else(|| {
profile
.default_admin_password
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
.unwrap_or_else(generate_local_secret);
app_env.set(admin_password_key, admin_password.as_str());
app_env.write(&options.env_file)?;
let client = BtcpayClient::new(profile.clone(), url.clone(), admin_email, admin_password)?;
client.wait_until_ready()?;
let store_id_key = "BTCPAY_STORE_ID";
let api_key_key = "BTCPAY_API_KEY";
let webhook_secret_key = "BTCPAY_WEBHOOK_SECRET";
let current_store_id = app_env
.effective_value(store_id_key)
.filter(|value| !is_placeholder_value(value));
let current_api_key = app_env.effective_value(api_key_key);
let reusable_store_access = match (current_store_id.as_deref(), current_api_key.as_deref()) {
(Some(store_id), Some(api_key)) if !is_placeholder_value(api_key) => {
client.api_key_has_permissions(api_key, &required_api_permissions(store_id))?
}
_ => false,
};
let (store_id, api_key) = if reusable_store_access {
(
current_store_id.expect("validated reusable store id"),
current_api_key.expect("validated reusable API key"),
)
} else {
client.ensure_admin_user()?;
let store_id = client.ensure_store(&public_url)?;
let api_key = client.ensure_api_key(&store_id, current_api_key.as_deref())?;
(store_id, api_key)
};
client.ensure_onchain_wallet(&store_id, &api_key)?;
client.ensure_rate_settings(&store_id, &api_key)?;
let current_webhook_secret = app_env.effective_value(webhook_secret_key);
let webhook_secret = client.ensure_webhook(
&store_id,
&public_url,
&api_key,
current_webhook_secret.as_deref(),
)?;
app_env.set(store_id_key, store_id.as_str());
app_env.set(api_key_key, api_key.as_str());
app_env.set(webhook_secret_key, webhook_secret.as_str());
let signing_key = app_env
.effective_value("PAYMENT_SIGNING_KEY")
.filter(|value| !value.trim().is_empty())
.or_else(|| {
profile
.default_payment_signing_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
.unwrap_or_else(generate_local_secret);
app_env.set("PAYMENT_SIGNING_KEY", signing_key.as_str());
app_env.write(&options.env_file)?;
Ok(LocalBtcpayBootstrapSummary {
app_display_name: profile.display_name().to_string(),
url,
store_id,
api_key_masked: mask_secret(&api_key),
webhook_secret_masked: mask_secret(&webhook_secret),
env_file: options.env_file.clone(),
})
}
fn generate_local_secret() -> String {
format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple())
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct LocalDockerConfig {
host: String,
reverseproxy_http_port: u16,
reverseproxy_https_port: u16,
nbitcoin_network: String,
btcpaygen_crypto1: String,
btcpaygen_reverseproxy: String,
btcpaygen_subname: String,
compose_project_name: String,
}
impl LocalDockerConfig {
fn new(compose_project_name: String) -> Self {
Self {
host: "127.0.0.1".to_owned(),
reverseproxy_http_port: 23_005,
reverseproxy_https_port: 23_006,
nbitcoin_network: "regtest".to_owned(),
btcpaygen_crypto1: "btc".to_owned(),
btcpaygen_reverseproxy: "nginx".to_owned(),
btcpaygen_subname: "btcpay".to_owned(),
compose_project_name,
}
}
fn validate(&self) -> Result<()> {
for (field, value) in [
("host", self.host.as_str()),
("nbitcoin_network", self.nbitcoin_network.as_str()),
("btcpaygen_crypto1", self.btcpaygen_crypto1.as_str()),
(
"btcpaygen_reverseproxy",
self.btcpaygen_reverseproxy.as_str(),
),
("btcpaygen_subname", self.btcpaygen_subname.as_str()),
("compose_project_name", self.compose_project_name.as_str()),
] {
ensure!(
!value.is_empty() && value.trim() == value && !value.chars().any(char::is_control),
"payment profile local_docker.{field} must be a non-empty single-line value"
);
}
ensure!(
self.reverseproxy_http_port > 0,
"payment profile local_docker.reverseproxy_http_port must be greater than zero"
);
ensure!(
self.reverseproxy_https_port > 0,
"payment profile local_docker.reverseproxy_https_port must be greater than zero"
);
Ok(())
}
}
#[derive(Clone, Debug, Default)]
struct EnvFile {
lines: Vec<String>,
values: BTreeMap<String, String>,
}
impl EnvFile {
fn read(path: &Path) -> Result<Self> {
if !path.exists() {
return Ok(Self {
lines: vec![
"# Local payment secrets and machine-specific BTCPay values.".to_string(),
"# Local dev bootstrap is managed by ch-rds.".to_string(),
String::new(),
],
values: BTreeMap::new(),
});
}
let text = fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path.display()))?;
let mut values = BTreeMap::new();
let lines = text
.lines()
.map(|line| {
if let Some((key, value)) = parse_env_line(line) {
values.insert(key.to_string(), value.to_string());
}
line.to_string()
})
.collect();
Ok(Self { lines, values })
}
fn get(&self, key: &str) -> Option<&str> {
self.values.get(key).map(String::as_str)
}
fn effective_value(&self, key: &str) -> Option<String> {
env::var(key)
.ok()
.filter(|value| !value.trim().is_empty())
.or_else(|| self.get(key).map(str::to_string))
}
fn set(&mut self, key: &str, value: &str) {
self.values.insert(key.to_string(), value.to_string());
}
fn write(&self, path: &Path) -> Result<()> {
let mut seen = BTreeSet::new();
let mut output = Vec::new();
for line in &self.lines {
if let Some((key, _)) = parse_env_line(line)
&& let Some(value) = self.values.get(key)
{
output.push(format!("{key}={value}"));
seen.insert(key.to_string());
continue;
}
output.push(line.clone());
}
for (key, value) in &self.values {
if seen.contains(key) {
continue;
}
if output.last().is_some_and(|line| !line.is_empty()) {
output.push(String::new());
}
output.push(format!("{key}={value}"));
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
fs::write(path, format!("{}\n", output.join("\n")))
.with_context(|| format!("failed to write {}", path.display()))
}
}
fn parse_env_line(line: &str) -> Option<(&str, &str)> {
let trimmed = line.trim_start();
if trimmed.is_empty() || trimmed.starts_with('#') {
return None;
}
let (key, value) = line.split_once('=')?;
let key = key.trim();
if key.is_empty() {
return None;
}
Some((key, value.trim()))
}
fn docker_env_values(
config: &LocalDockerConfig,
app_env: &EnvFile,
) -> Result<BTreeMap<String, String>> {
let mut values = BTreeMap::new();
values.insert("BTCPAY_IMAGE".to_string(), resolve_btcpay_image(app_env)?);
values.insert("BTCPAY_HOST".to_string(), config.host.clone());
values.insert(
"REVERSEPROXY_HTTP_PORT".to_string(),
config.reverseproxy_http_port.to_string(),
);
values.insert(
"REVERSEPROXY_HTTPS_PORT".to_string(),
config.reverseproxy_https_port.to_string(),
);
values.insert(
"NBITCOIN_NETWORK".to_string(),
config.nbitcoin_network.clone(),
);
values.insert(
"BTCPAYGEN_CRYPTO1".to_string(),
config.btcpaygen_crypto1.clone(),
);
values.insert(
"BTCPAYGEN_REVERSEPROXY".to_string(),
config.btcpaygen_reverseproxy.clone(),
);
values.insert(
"BTCPAYGEN_SUBNAME".to_string(),
config.btcpaygen_subname.clone(),
);
values.insert(
"COMPOSE_PROJECT_NAME".to_string(),
config.compose_project_name.clone(),
);
values.insert(
"BTCPAY_ADDITIONAL_HOSTS".to_string(),
local_btcpay_additional_hosts(
config.host.as_str(),
app_env
.effective_value("BTCPAY_ADDITIONAL_HOSTS")
.as_deref()
.unwrap_or_default(),
),
);
for key in [
"BTCPAY_SSHAUTHORIZEDKEYS",
"BTCPAY_SSHTRUSTEDFINGERPRINTS",
"BTCPAY_SSHKEYFILE",
] {
values.insert(
key.to_string(),
app_env.effective_value(key).unwrap_or_default(),
);
}
Ok(values)
}
fn resolve_btcpay_image(app_env: &EnvFile) -> Result<String> {
if let Some(image) = app_env.effective_value("BTCPAY_IMAGE") {
ensure!(
stable_btcpay_version_from_image(&image).is_some(),
"BTCPAY_IMAGE must use an official stable image such as btcpayserver/btcpayserver:2.4.3; release candidates are not allowed"
);
return Ok(image);
}
let client = Client::builder()
.timeout(Duration::from_secs(10))
.user_agent(concat!("ch-rds/", env!("CARGO_PKG_VERSION")))
.build()
.context("failed to create BTCPay release client")?;
let resolved = client
.get(BTCPAY_LATEST_STABLE_RELEASE_URL)
.send()
.and_then(reqwest::blocking::Response::error_for_status)
.and_then(reqwest::blocking::Response::json::<GithubRelease>)
.ok()
.and_then(|release| stable_btcpay_image_from_release(&release));
if let Some(image) = resolved {
return Ok(image);
}
tracing::warn!(
fallback_image = BTCPAY_STABLE_FALLBACK_IMAGE,
"Could not resolve the latest stable BTCPay release; using the pinned stable fallback"
);
Ok(BTCPAY_STABLE_FALLBACK_IMAGE.to_string())
}
fn stable_btcpay_image_from_release(release: &GithubRelease) -> Option<String> {
if release.draft || release.prerelease {
return None;
}
let version = release.tag_name.strip_prefix('v')?;
stable_btcpay_version(version).then(|| format!("btcpayserver/btcpayserver:{version}"))
}
fn stable_btcpay_version_from_image(image: &str) -> Option<&str> {
let version = image.strip_prefix("btcpayserver/btcpayserver:")?;
stable_btcpay_version(version).then_some(version)
}
fn stable_btcpay_version(version: &str) -> bool {
let mut components = version.split('.');
let valid_component = |component: &str| {
!component.is_empty()
&& component.bytes().all(|byte| byte.is_ascii_digit())
&& (component == "0" || !component.starts_with('0'))
};
let Some(major) = components.next() else {
return false;
};
let Some(minor) = components.next() else {
return false;
};
let Some(patch) = components.next() else {
return false;
};
components.next().is_none()
&& valid_component(major)
&& valid_component(minor)
&& valid_component(patch)
}
fn write_btcpay_env(path: &Path, values: &BTreeMap<String, String>) -> Result<()> {
let mut lines = Vec::new();
for key in [
"BTCPAY_IMAGE",
"BTCPAY_HOST",
"REVERSEPROXY_HTTP_PORT",
"REVERSEPROXY_HTTPS_PORT",
"NBITCOIN_NETWORK",
"BTCPAYGEN_CRYPTO1",
"BTCPAYGEN_REVERSEPROXY",
"BTCPAYGEN_SUBNAME",
"COMPOSE_PROJECT_NAME",
"BTCPAY_ADDITIONAL_HOSTS",
"BTCPAY_SSHAUTHORIZEDKEYS",
"BTCPAY_SSHTRUSTEDFINGERPRINTS",
"BTCPAY_SSHKEYFILE",
] {
lines.push(format!(
"{key}={}",
values.get(key).map(String::as_str).unwrap_or_default()
));
}
fs::write(path, format!("{}\n", lines.join("\n")))
.with_context(|| format!("failed to write {}", path.display()))
}
fn run_build_script(btcpay_root: &Path, envs: &BTreeMap<String, String>) -> Result<()> {
let (program, args): (&str, Vec<String>) = if cfg!(windows) {
(
"powershell",
vec![
"-NoProfile".to_string(),
"-ExecutionPolicy".to_string(),
"Bypass".to_string(),
"-File".to_string(),
btcpay_root.join("build.ps1").display().to_string(),
],
)
} else {
(
"bash",
vec![btcpay_root.join("build.sh").display().to_string()],
)
};
run_command("BTCPay build step", program, &args, btcpay_root, envs)
}
fn patch_local_btcpay_compose(btcpay_root: &Path, envs: &BTreeMap<String, String>) -> Result<()> {
let subname = required_env_value(envs, "BTCPAYGEN_SUBNAME")?;
let project_name = required_env_value(envs, "COMPOSE_PROJECT_NAME")?;
let compose_file = btcpay_root
.join("Generated")
.join(format!("docker-compose.{subname}.yml"));
let compose = fs::read_to_string(&compose_file)
.with_context(|| format!("failed to read {}", compose_file.display()))?;
let network = required_env_value(envs, "NBITCOIN_NETWORK")?;
let patched = namespace_local_container_names(
&enable_regtest_wallet_creation(
&compose.replace("\n onion=tor:9050\n", "\n onion=0\n"),
network,
),
project_name,
);
let patched = configure_local_webhook_host(&patched)?;
if patched != compose {
fs::write(&compose_file, patched)
.with_context(|| format!("failed to write {}", compose_file.display()))?;
}
Ok(())
}
fn configure_local_webhook_host(compose: &str) -> Result<String> {
let service = "\n btcpayserver:\n";
ensure!(
compose.contains(service),
"generated compose has no BTCPay service"
);
let configured =
"\n btcpayserver:\n extra_hosts:\n - \"payment-host.internal:host-gateway\"\n";
if compose.contains(configured) {
return Ok(compose.to_owned());
}
Ok(compose.replace(service, configured))
}
fn enable_regtest_wallet_creation(compose: &str, network: &str) -> String {
if network != "regtest" {
return compose.to_owned();
}
compose.replace("CREATE_WALLET: \"false\"", "CREATE_WALLET: \"true\"")
}
fn namespace_local_container_names(compose: &str, project_name: &str) -> String {
let prefix = format!("{project_name}-");
let namespaced = compose
.lines()
.map(|line| {
let content = line.trim_start();
let indentation = &line[..line.len() - content.len()];
match content.strip_prefix("container_name: ") {
Some(name) if !name.starts_with(&prefix) => {
format!("{indentation}container_name: {prefix}{name}")
}
_ => line.to_owned(),
}
})
.collect::<Vec<_>>()
.join("\n")
+ if compose.ends_with('\n') { "\n" } else { "" };
namespaced
.replace(
"-notify-sighup nginx ",
&format!("-notify-sighup {prefix}nginx "),
)
.replace(
"-notify-sighup tor ",
&format!("-notify-sighup {prefix}tor "),
)
.replace(
"NGINX_DOCKER_GEN_CONTAINER: \"nginx-gen\"",
&format!("NGINX_DOCKER_GEN_CONTAINER: \"{prefix}nginx-gen\""),
)
.replace(
"NGINX_PROXY_CONTAINER: \"nginx\"",
&format!("NGINX_PROXY_CONTAINER: \"{prefix}nginx\""),
)
}
fn docker_compose_up(btcpay_root: &Path, envs: &BTreeMap<String, String>) -> Result<()> {
let subname = required_env_value(envs, "BTCPAYGEN_SUBNAME")?;
let project_name = required_env_value(envs, "COMPOSE_PROJECT_NAME")?;
let compose_file = btcpay_root
.join("Generated")
.join(format!("docker-compose.{subname}.yml"));
ensure!(
compose_file.is_file(),
"generated BTCPay compose file is missing at {}",
compose_file.display()
);
let args = vec![
"compose".to_string(),
"--project-name".to_string(),
project_name.to_string(),
"--env-file".to_string(),
".env".to_string(),
"-f".to_string(),
compose_file.display().to_string(),
"up".to_string(),
"-d".to_string(),
];
run_command(
"BTCPay docker compose up",
"docker",
&args,
btcpay_root,
envs,
)
}
fn run_command(
label: &str,
program: &str,
args: &[String],
cwd: &Path,
envs: &BTreeMap<String, String>,
) -> Result<()> {
let mut command = Command::new(program);
command.current_dir(cwd).args(args).envs(envs);
command.env("COMPOSE_CONVERT_WINDOWS_PATHS", "1");
let status = command
.status()
.with_context(|| format!("failed to start {label}"))?;
ensure!(status.success(), "{label} exited with status {status}");
Ok(())
}
struct BtcpayClient {
profile: BtcpayDevProfile,
base_url: String,
admin_email: String,
admin_password: String,
client: Client,
}
impl BtcpayClient {
fn new(
profile: BtcpayDevProfile,
base_url: String,
admin_email: String,
admin_password: String,
) -> Result<Self> {
let client = Client::builder()
.cookie_store(true)
.timeout(Duration::from_secs(30))
.build()
.context("failed to create BTCPay HTTP client")?;
Ok(Self {
profile,
base_url: base_url.trim_end_matches('/').to_string(),
admin_email,
admin_password,
client,
})
}
fn wait_until_ready(&self) -> Result<()> {
let deadline = Instant::now() + Duration::from_secs(180);
let mut last_error = None;
while Instant::now() < deadline {
match self
.client
.get(format!("{}/api/v1/stores", self.base_url))
.send()
{
Ok(response)
if matches!(
response.status(),
StatusCode::OK | StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN
) =>
{
return Ok(());
}
Ok(response) => {
last_error = Some(anyhow!("BTCPay returned {}", response.status()));
}
Err(error) => {
last_error = Some(error.into());
}
}
std::thread::sleep(Duration::from_secs(2));
}
Err(last_error.unwrap_or_else(|| anyhow!("BTCPay did not become ready in time")))
}
fn ensure_admin_user(&self) -> Result<()> {
if self.has_valid_admin_credentials()? {
return Ok(());
}
let response = self
.client
.post(format!("{}/api/v1/users", self.base_url))
.json(&json!({
"email": self.admin_email.as_str(),
"name": self.profile.display_name(),
"password": self.admin_password.as_str(),
"isAdministrator": true,
"sendInvitationEmail": false,
}))
.send()
.context("failed to create the local BTCPay administrator")?;
ensure!(
response.status() == StatusCode::CREATED || response.status() == StatusCode::OK,
"BTCPay administrator creation returned {}. The local BTCPay data may belong to different development credentials",
response.status()
);
ensure!(
self.has_valid_admin_credentials()?,
"BTCPay local admin bootstrap did not produce valid API credentials"
);
Ok(())
}
fn has_valid_admin_credentials(&self) -> Result<bool> {
let response = self
.admin_auth(
self.client
.get(format!("{}/api/v1/users/me", self.base_url)),
)
.send()
.context("failed to check BTCPay admin credentials")?;
match response.status() {
StatusCode::OK => Ok(true),
StatusCode::UNAUTHORIZED | StatusCode::NOT_FOUND => Ok(false),
status => bail!("BTCPay admin credential check returned {status}"),
}
}
fn ensure_store(&self, public_url: &str) -> Result<String> {
let path = "/api/v1/stores";
let response = self
.admin_auth(self.client.get(format!("{}{}", self.base_url, path)))
.send()
.with_context(|| format!("failed to request BTCPay {path}"))?;
let stores: Vec<Value> = read_btcpay_response(response, path)?;
for store in stores {
if store.get("name").and_then(Value::as_str) == Some(self.profile.store_name.as_str())
&& let Some(id) = store.get("id").and_then(Value::as_str)
{
return Ok(id.to_string());
}
}
let payload = json!({
"name": self.profile.store_name.as_str(),
"website": public_url,
"defaultLang": "en",
"speedPolicy": "MediumSpeed",
"redirectAutomatically": false,
"anyoneCanCreateInvoice": false,
});
let response = self
.admin_auth(self.client.post(format!("{}{}", self.base_url, path)))
.json(&payload)
.send()
.with_context(|| format!("failed to request BTCPay {path}"))?;
let created: Value = read_btcpay_response(response, path)?;
created
.get("id")
.and_then(Value::as_str)
.map(str::to_string)
.ok_or_else(|| anyhow!("BTCPay store creation returned an unexpected response"))
}
fn ensure_onchain_wallet(&self, store_id: &str, api_key: &str) -> Result<()> {
let has_wallet = |payload: &Value| {
payload.get("enabled").and_then(Value::as_bool) == Some(true)
&& payload.get("paymentMethodId").and_then(Value::as_str) == Some("BTC-CHAIN")
&& payload
.pointer("/config/accountDerivation")
.and_then(Value::as_str)
.is_some_and(|derivation| !derivation.trim().is_empty())
};
let wallet_path =
format!("/api/v1/stores/{store_id}/payment-methods/OnChain/BTC?includeConfig=true");
let generate_path =
format!("/api/v1/stores/{store_id}/payment-methods/OnChain/BTC/wallet/generate");
let deadline = Instant::now() + Duration::from_secs(180);
loop {
let response = self
.api_key_auth(
api_key,
self.client.get(format!("{}{}", self.base_url, wallet_path)),
)
.send()
.with_context(|| format!("failed to request BTCPay {wallet_path}"))?;
match response.status() {
status if status.is_success() => {
let payload: Value = read_btcpay_response(response, wallet_path.as_str())?;
if has_wallet(&payload) {
return Ok(());
}
}
StatusCode::NOT_FOUND | StatusCode::SERVICE_UNAVAILABLE => {}
_ => {
let _: Value = read_btcpay_response(response, wallet_path.as_str())?;
}
}
let response = self
.api_key_auth(
api_key,
self.client
.post(format!("{}{}", self.base_url, generate_path)),
)
.json(&json!({
"label": self.profile.api_key_label.as_str(),
"accountNumber": 0,
"savePrivateKeys": false,
"wordList": "English",
"wordCount": 12,
"scriptPubKeyType": "Segwit",
}))
.send()
.with_context(|| format!("failed to request BTCPay {generate_path}"))?;
if response.status() != StatusCode::SERVICE_UNAVAILABLE {
let payload: Value = read_btcpay_response(response, generate_path.as_str())?;
ensure!(
has_wallet(&payload),
"BTCPay on-chain wallet setup returned an unexpected response"
);
return Ok(());
}
ensure!(
Instant::now() < deadline,
"BTCPay on-chain services did not become ready in time"
);
std::thread::sleep(Duration::from_secs(2));
}
}
fn ensure_rate_settings(&self, store_id: &str, api_key: &str) -> Result<()> {
let path = format!("/api/v1/stores/{store_id}/rates/configuration/primary");
let settings = json!({
"spread": "0",
"preferredSource": self.profile.rate_source.as_str(),
"isCustomScript": false,
"effectiveScript": "",
});
let response = self
.api_key_auth(
api_key,
self.client.put(format!("{}{}", self.base_url, path)),
)
.json(&settings)
.send()
.with_context(|| format!("failed to request BTCPay {path}"))?;
let payload: Value = read_btcpay_response(response, &path)?;
ensure!(
payload.get("isCustomScript").and_then(Value::as_bool) == Some(false),
"BTCPay rate configuration returned an unexpected response"
);
Ok(())
}
fn ensure_api_key(&self, store_id: &str, current_key: Option<&str>) -> Result<String> {
let required_permissions = required_api_permissions(store_id);
if let Some(key) = current_key
&& !is_placeholder_value(key)
&& self.api_key_has_permissions(key, &required_permissions)?
{
return Ok(key.to_string());
}
let path = "/api/v1/api-keys";
let payload = json!({
"label": self.profile.api_key_label.as_str(),
"permissions": required_permissions,
});
let response = self
.admin_auth(self.client.post(format!("{}{}", self.base_url, path)))
.json(&payload)
.send()
.with_context(|| format!("failed to request BTCPay {path}"))?;
let created: Value = read_btcpay_response(response, path)?;
created
.get("apiKey")
.and_then(Value::as_str)
.map(str::to_string)
.ok_or_else(|| anyhow!("BTCPay API key creation returned an unexpected response"))
}
fn ensure_webhook(
&self,
store_id: &str,
public_url: &str,
api_key: &str,
current_secret: Option<&str>,
) -> Result<String> {
let webhook_url = btcpay_webhook_url(public_url, self.profile.webhook_path.as_str())?;
if let Some(secret) = current_secret
&& !is_placeholder_value(secret)
&& self.webhook_exists(store_id, api_key, webhook_url.as_str())?
{
return Ok(secret.to_string());
}
let secret = format!("{}_{}", Uuid::new_v4(), Uuid::new_v4());
let path = format!("/api/v1/stores/{store_id}/webhooks");
let payload = json!({
"enabled": true,
"automaticRedelivery": true,
"url": webhook_url,
"secret": secret,
"authorizedEvents": {
"everything": false,
"specificEvents": [
"InvoiceCreated",
"InvoiceReceivedPayment",
"InvoiceProcessing",
"InvoiceSettled",
"InvoiceExpired",
"InvoiceInvalid"
]
}
});
let response = self
.api_key_auth(
api_key,
self.client.post(format!("{}{}", self.base_url, path)),
)
.json(&payload)
.send()
.with_context(|| format!("failed to request BTCPay {path}"))?;
let created: Value = read_btcpay_response(response, &path)?;
Ok(created
.get("secret")
.and_then(Value::as_str)
.unwrap_or(secret.as_str())
.to_string())
}
fn api_key_has_permissions(&self, key: &str, required_permissions: &[String]) -> Result<bool> {
let response = self
.api_key_auth(
key,
self.client
.get(format!("{}/api/v1/api-keys/current", self.base_url)),
)
.send()
.context("failed to check BTCPay API key")?;
if response.status() == StatusCode::UNAUTHORIZED {
return Ok(false);
}
let payload: Value = read_btcpay_response(response, "BTCPay API key check")?;
let Some(permissions) = payload.get("permissions").and_then(Value::as_array) else {
return Ok(false);
};
let permissions = permissions
.iter()
.filter_map(Value::as_str)
.collect::<BTreeSet<_>>();
Ok(required_permissions
.iter()
.all(|permission| permissions.contains(permission.as_str())))
}
fn webhook_exists(&self, store_id: &str, api_key: &str, webhook_url: &str) -> Result<bool> {
let path = format!("/api/v1/stores/{store_id}/webhooks");
let response = self
.api_key_auth(
api_key,
self.client.get(format!("{}{}", self.base_url, path)),
)
.send()
.with_context(|| format!("failed to request BTCPay {path}"))?;
let webhooks: Vec<Value> = read_btcpay_response(response, &path)?;
Ok(webhooks
.iter()
.any(|webhook| webhook.get("url").and_then(Value::as_str) == Some(webhook_url)))
}
fn admin_auth(&self, request: RequestBuilder) -> RequestBuilder {
let token = base64::engine::general_purpose::STANDARD
.encode(format!("{}:{}", self.admin_email, self.admin_password));
request.header(reqwest::header::AUTHORIZATION, format!("Basic {token}"))
}
fn api_key_auth(&self, api_key: &str, request: RequestBuilder) -> RequestBuilder {
request.header(reqwest::header::AUTHORIZATION, format!("token {api_key}"))
}
}
fn required_api_permissions(store_id: &str) -> [String; 5] {
[
format!("btcpay.store.cancreateinvoice:{store_id}"),
format!("btcpay.store.canviewinvoices:{store_id}"),
format!("btcpay.store.webhooks.canmodifywebhooks:{store_id}"),
format!("btcpay.store.canviewstoresettings:{store_id}"),
format!("btcpay.store.canmodifystoresettings:{store_id}"),
]
}
fn read_btcpay_response<T: for<'de> Deserialize<'de>>(
response: reqwest::blocking::Response,
label: &str,
) -> Result<T> {
let status = response.status();
let text = response
.text()
.with_context(|| format!("failed to read {label} response"))?;
ensure!(
status.is_success(),
"{label} returned {status}: {}",
text.chars().take(500).collect::<String>()
);
serde_json::from_str(&text)
.with_context(|| format!("{label} returned an unexpected JSON response"))
}
fn btcpay_webhook_url(public_url: &str, webhook_path: &str) -> Result<String> {
let parsed = url::Url::parse(public_url)
.with_context(|| format!("invalid public payment URL `{public_url}`"))?;
let host = match parsed.host_str() {
Some("127.0.0.1" | "localhost") => "payment-host.internal",
Some(host) => host,
None => "payment-host.internal",
};
let mut netloc = host.to_string();
if let Some(port) = parsed.port() {
netloc = format!("{netloc}:{port}");
}
let scheme = if parsed.scheme().is_empty() {
"http"
} else {
parsed.scheme()
};
Ok(format!("{scheme}://{netloc}{webhook_path}"))
}
fn required_env_value<'a>(values: &'a BTreeMap<String, String>, key: &str) -> Result<&'a str> {
values
.get(key)
.map(String::as_str)
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| anyhow!("missing required BTCPay env value {key}"))
}
fn local_btcpay_additional_hosts(primary_host: &str, configured_hosts: &str) -> String {
let mut hosts = configured_hosts
.split(',')
.map(str::trim)
.filter(|host| !host.is_empty())
.map(str::to_string)
.collect::<Vec<_>>();
if primary_host == "127.0.0.1" && !hosts.iter().any(|host| host == "localhost") {
hosts.push("localhost".to_string());
}
if primary_host == "localhost" && !hosts.iter().any(|host| host == "127.0.0.1") {
hosts.push("127.0.0.1".to_string());
}
hosts.join(",")
}
fn is_placeholder_value(value: &str) -> bool {
matches!(
value.trim(),
"" | "__change_me__" | "BTCPAY_STORE_ID_FROM_UI"
)
}
fn mask_secret(value: &str) -> String {
if value.len() <= 8 {
return "********".to_string();
}
format!("{}...{}", &value[..4], &value[value.len() - 4..])
}
#[cfg(test)]
mod tests {
use super::{
BtcpayClient, BtcpayDevProfile, GithubRelease, btcpay_checkout_root, btcpay_webhook_url,
configure_local_webhook_host, enable_regtest_wallet_creation,
namespace_local_container_names, stable_btcpay_image_from_release,
stable_btcpay_version_from_image,
};
use base64::Engine as _;
use serde_json::{Value, json};
use std::{
collections::BTreeMap,
io::{BufRead, BufReader, Read, Write},
net::TcpListener,
path::Path,
thread::JoinHandle,
time::Duration,
};
type CapturedRequest = (String, BTreeMap<String, String>, Option<Value>);
fn serve_btcpay_requests(replies: Vec<Value>) -> (String, JoinHandle<Vec<CapturedRequest>>) {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let server = std::thread::spawn(move || {
let mut captured = Vec::new();
for reply in replies {
let (mut stream, _) = listener.accept().unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
let (line, headers, body) = {
let mut reader = BufReader::new(&mut stream);
let mut line = String::new();
reader.read_line(&mut line).unwrap();
let mut headers = BTreeMap::new();
loop {
let mut header = String::new();
reader.read_line(&mut header).unwrap();
if header == "\r\n" {
break;
}
let (name, value) = header.trim().split_once(':').unwrap();
headers.insert(name.to_ascii_lowercase(), value.trim().to_owned());
}
let length = headers
.get("content-length")
.map(|length| length.parse::<usize>().unwrap())
.unwrap_or(0);
let mut bytes = vec![0; length];
reader.read_exact(&mut bytes).unwrap();
let body = (!bytes.is_empty()).then(|| serde_json::from_slice(&bytes).unwrap());
(line.trim().to_owned(), headers, body)
};
captured.push((line, headers, body));
let body = reply.to_string();
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len(),
)
.unwrap();
}
captured
});
(base_url, server)
}
#[test]
fn bootstrap_preserves_request_order_credentials_permissions_and_payment_events() {
let (base_url, server) = serve_btcpay_requests(vec![
json!([]),
json!({"id": "store-a"}),
json!({"permissions": []}),
json!({"apiKey": "new-store-key"}),
json!({"isCustomScript": false}),
json!([]),
json!({"secret": "registered-secret"}),
]);
let public_url = "https://merchant.example.invalid";
let profile = BtcpayDevProfile::new("Fixture store", "admin@example.invalid", public_url);
let client = BtcpayClient::new(
profile,
base_url,
"admin@example.invalid".to_owned(),
"fixture-password".to_owned(),
)
.unwrap();
assert_eq!(client.ensure_store(public_url).unwrap(), "store-a");
let key = client
.ensure_api_key("store-a", Some("old-store-key"))
.unwrap();
assert_eq!(key, "new-store-key");
client.ensure_rate_settings("store-a", &key).unwrap();
assert_eq!(
client
.ensure_webhook("store-a", public_url, &key, Some("old-secret"))
.unwrap(),
"registered-secret",
);
let requests = server.join().unwrap();
assert_eq!(
requests
.iter()
.map(|request| request.0.as_str())
.collect::<Vec<_>>(),
[
"GET /api/v1/stores HTTP/1.1",
"POST /api/v1/stores HTTP/1.1",
"GET /api/v1/api-keys/current HTTP/1.1",
"POST /api/v1/api-keys HTTP/1.1",
"PUT /api/v1/stores/store-a/rates/configuration/primary HTTP/1.1",
"GET /api/v1/stores/store-a/webhooks HTTP/1.1",
"POST /api/v1/stores/store-a/webhooks HTTP/1.1",
],
);
let admin = format!(
"Basic {}",
base64::engine::general_purpose::STANDARD
.encode("admin@example.invalid:fixture-password"),
);
for index in [0, 1, 3] {
assert_eq!(requests[index].1["authorization"], admin);
}
assert_eq!(requests[2].1["authorization"], "token old-store-key");
for index in [4, 5, 6] {
assert_eq!(requests[index].1["authorization"], "token new-store-key");
}
assert_eq!(
requests[1].2,
Some(json!({
"name": "Fixture store", "website": public_url, "defaultLang": "en",
"speedPolicy": "MediumSpeed", "redirectAutomatically": false,
"anyoneCanCreateInvoice": false,
}))
);
assert_eq!(
requests[3].2,
Some(json!({
"label": "Fixture store local dev",
"permissions": [
"btcpay.store.cancreateinvoice:store-a",
"btcpay.store.canviewinvoices:store-a",
"btcpay.store.webhooks.canmodifywebhooks:store-a",
"btcpay.store.canviewstoresettings:store-a",
"btcpay.store.canmodifystoresettings:store-a",
],
}))
);
assert_eq!(
requests[4].2,
Some(json!({
"spread": "0", "preferredSource": "kraken",
"isCustomScript": false, "effectiveScript": "",
}))
);
let webhook = requests[6].2.as_ref().unwrap();
assert_eq!(
webhook["url"],
format!("{public_url}/api/payments/webhooks/btcpay")
);
assert_eq!(webhook["enabled"], true);
assert_eq!(webhook["automaticRedelivery"], true);
assert_eq!(
webhook["authorizedEvents"],
json!({
"everything": false,
"specificEvents": ["InvoiceCreated", "InvoiceReceivedPayment", "InvoiceProcessing",
"InvoiceSettled", "InvoiceExpired", "InvoiceInvalid"],
})
);
assert_eq!(webhook["secret"].as_str().unwrap().len(), 73);
}
#[test]
fn matching_store_is_reused_without_creating_another_store() {
let (base_url, server) = serve_btcpay_requests(vec![json!([
{"name": "Another store", "id": "other"},
{"name": "Fixture store", "id": "existing"},
])]);
let public_url = "https://merchant.example.invalid";
let client = BtcpayClient::new(
BtcpayDevProfile::new("Fixture store", "admin@example.invalid", public_url),
base_url,
"admin@example.invalid".to_owned(),
"fixture-password".to_owned(),
)
.unwrap();
assert_eq!(client.ensure_store(public_url).unwrap(), "existing");
let requests = server.join().unwrap();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].0, "GET /api/v1/stores HTTP/1.1");
assert!(requests[0].2.is_none());
}
#[test]
fn local_webhooks_use_daemon_host_without_upstream_hostname_collision() {
let compose = "services:\n btcpayserver:\n image: btcpay\n other:\n image: other\n";
let configured = configure_local_webhook_host(compose).unwrap();
assert!(configured.contains("payment-host.internal:host-gateway"));
assert!(configured.ends_with(" other:\n image: other\n"));
assert_eq!(
configure_local_webhook_host(&configured).unwrap(),
configured
);
assert!(configure_local_webhook_host("services:\n").is_err());
for host in ["localhost", "127.0.0.1"] {
assert_eq!(
btcpay_webhook_url(&format!("http://{host}:4032"), "/webhook").unwrap(),
"http://payment-host.internal:4032/webhook"
);
}
assert_eq!(
btcpay_webhook_url("https://example.org", "/webhook").unwrap(),
"https://example.org/webhook"
);
}
#[test]
fn resolves_btcpay_checkout_next_to_the_app_repository() {
assert_eq!(
btcpay_checkout_root(Path::new("/workspace/ch-chirora"))
.expect("app checkout should have a parent"),
Path::new("/workspace/third-party/btcpay")
);
}
#[test]
fn namespaces_generated_container_names_with_the_compose_project() {
let compose = concat!(
"services:\n",
" nginx:\n",
" container_name: nginx\n",
" nginx-gen:\n",
" container_name: nginx-gen\n",
" entrypoint: docker-gen -notify-sighup nginx -watch\n",
" letsencrypt:\n",
" environment:\n",
" NGINX_DOCKER_GEN_CONTAINER: \"nginx-gen\"\n",
" NGINX_PROXY_CONTAINER: \"nginx\"\n",
" tor:\n",
" container_name: tor\n",
" tor-gen:\n",
" entrypoint: docker-gen -notify-sighup tor -watch\n",
);
assert_eq!(
namespace_local_container_names(compose, "chirora-local-btcpay"),
concat!(
"services:\n",
" nginx:\n",
" container_name: chirora-local-btcpay-nginx\n",
" nginx-gen:\n",
" container_name: chirora-local-btcpay-nginx-gen\n",
" entrypoint: docker-gen -notify-sighup chirora-local-btcpay-nginx -watch\n",
" letsencrypt:\n",
" environment:\n",
" NGINX_DOCKER_GEN_CONTAINER: \"chirora-local-btcpay-nginx-gen\"\n",
" NGINX_PROXY_CONTAINER: \"chirora-local-btcpay-nginx\"\n",
" tor:\n",
" container_name: chirora-local-btcpay-tor\n",
" tor-gen:\n",
" entrypoint: docker-gen -notify-sighup chirora-local-btcpay-tor -watch\n",
)
);
}
#[test]
fn enables_default_bitcoin_wallet_only_for_regtest() {
let compose = concat!(
"services:\n",
" bitcoind:\n",
" environment:\n",
" CREATE_WALLET: \"false\"\n",
);
assert_eq!(
enable_regtest_wallet_creation(compose, "regtest"),
compose.replace("false", "true")
);
assert_eq!(enable_regtest_wallet_creation(compose, "mainnet"), compose);
}
#[test]
fn accepts_latest_stable_release() {
let release = GithubRelease {
tag_name: "v2.4.0".to_string(),
draft: false,
prerelease: false,
};
assert_eq!(
stable_btcpay_image_from_release(&release).as_deref(),
Some("btcpayserver/btcpayserver:2.4.0")
);
}
#[test]
fn rejects_release_candidates_and_drafts() {
for release in [
GithubRelease {
tag_name: "v2.4.3-rc4".to_string(),
draft: false,
prerelease: true,
},
GithubRelease {
tag_name: "v2.4.1".to_string(),
draft: true,
prerelease: false,
},
] {
assert!(stable_btcpay_image_from_release(&release).is_none());
}
}
#[test]
fn accepts_only_official_stable_image_overrides() {
assert_eq!(
stable_btcpay_version_from_image("btcpayserver/btcpayserver:2.4.0"),
Some("2.4.0")
);
for image in [
"btcpayserver/btcpayserver:2.4.3-rc4",
"btcpayserver/btcpayserver:latest",
"example.invalid/btcpay:2.4.0",
"btcpayserver/btcpayserver:02.4.0",
] {
assert!(stable_btcpay_version_from_image(image).is_none());
}
}
}