bark-rest 0.7.0

a REST server built on top of the bark-wallet crate

#[macro_use]
extern crate anyhow;

pub mod api;
pub mod auth;
pub mod config;
pub mod error;

mod notifications;

pub use crate::config::Config;
use crate::notifications::NotificationManager;
pub use axum::http;

use crate::auth::AuthToken;
use crate::error::{ErrorResponse, unprocessable};

use std::collections::HashMap;
use std::pin::Pin;
use std::sync::Arc;

use anyhow::Context;
use axum::routing::get;
use chrono::{DateTime, Utc};
use log::{error, warn, info};
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use axum::http::{header, Method, HeaderValue};
use tower_http::cors::CorsLayer;
use utoipa::{Modify, OpenApi};
use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
use utoipa_axum::router::OpenApiRouter;

use bark::Wallet;
use bark::onchain::OnchainWalletTrait;
use bark_json::web::CreateWalletRequest;

type BoxFuture<T> =
	Pin<Box<dyn Future<Output = T> + Send + 'static>>;

pub type OnWalletCreate = dyn Fn(CreateWalletRequest)
	-> BoxFuture<anyhow::Result<Wallet>> + Send + Sync;

pub type OnWalletDelete = dyn Fn()
	-> BoxFuture<anyhow::Result<()>> + Send + Sync;

/// A hook that returns the wallet's BIP-39 mnemonic phrase.
pub type OnGetMnemonic = dyn Fn()
	-> BoxFuture<anyhow::Result<String>> + Send + Sync;

const CRATE_VERSION : &'static str = env!("CARGO_PKG_VERSION");

// NB please keep below 1000 chars for crates.io publish
const API_DESCRIPTION: &str = "\
A simple REST API for barkd, a wallet daemon for integrating bitcoin payments into your app over HTTP. Supports self-custodial Lightning, Ark, and on-chain out of the box.

barkd is a long-running daemon best suited for always-on or high-connectivity environments like nodes, servers, desktops, and point-of-sale terminals.

All endpoints return JSON. Amounts are denominated in satoshis.";

#[derive(OpenApi)]
#[openapi(
	paths(
		ping,
	),
	nest(
		(path = "/api/v1/boards", api = api::v1::boards::BoardsApiDoc),
		(path = "/api/v1/exits", api = api::v1::exits::ExitsApiDoc),
		(path = "/api/v1/fees", api = api::v1::fees::FeesApiDoc),
		(path = "/api/v1/history", api = api::v1::history::HistoryApiDoc),
		(path = "/api/v1/lightning", api = api::v1::lightning::LightningApiDoc),
		(path = "/api/v1/message", api = api::v1::message::MessageApiDoc),
		(path = "/api/v1/onchain", api = api::v1::onchain::OnchainApiDoc),
		(path = "/api/v1/wallet", api = api::v1::wallet::WalletApiDoc),
		(path = "/api/v1/bitcoin", api = api::v1::bitcoin::BitcoinApiDoc),
		(path = "/api/v1/notifications", api = api::v1::notifications::NotificationsApiDoc),
	),
	info(
		title = "barkd REST API",
		version = CRATE_VERSION,
		description = API_DESCRIPTION,
	),
	security(
		("bearer" = []),
	),
	modifiers(&BearerSecurity),
)]
pub struct ApiDoc;

struct BearerSecurity;

impl Modify for BearerSecurity {
	fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
		let components = openapi.components.get_or_insert_with(Default::default);
		components.add_security_scheme(
			"bearer",
			SecurityScheme::Http(
				HttpBuilder::new()
					.scheme(HttpAuthScheme::Bearer)
					.bearer_format("AuthToken")
					.description(Some("Base64url-encoded auth token"))
					.build(),
			),
		);
	}
}

fn cors_layer(config: &Config) -> CorsLayer {
	if config.allowed_origins.is_empty() {
		return CorsLayer::new(); // deny all cross-origin
	}

	// Origins are validated at config construction time.
	let origins: Vec<HeaderValue> = config.allowed_origins.iter()
		.map(|o| o.parse().expect("pre-validated"))
		.collect();

	CorsLayer::new()
		.allow_origin(origins)
		.allow_methods([Method::GET, Method::POST, Method::DELETE])
		.allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION])
}

async fn shutdown_signal(shutdown: CancellationToken) {
	shutdown.cancelled().await;
}

/// A server that serves a REST API for the bark [Wallet]
pub struct RestServer {
	shutdown: CancellationToken,
	jh: JoinHandle<()>,
}

#[derive(Clone)]
pub struct ServerWallet {
	wallet: Wallet,
	notification_mngr: NotificationManager,
}

impl ServerWallet {
	pub fn new(wallet: Wallet, shutdown: CancellationToken) -> Self {
		Self {
			wallet: wallet.clone(),
			notification_mngr: NotificationManager::start(wallet, shutdown),
		}
	}

	/// Stop the wallet's background tasks, waking any request waiting on them.
	pub fn stop(&self) {
		self.notification_mngr.stop();
		self.wallet.stop_daemon();
	}

	/// Stop the wallet's background tasks and wait until they have finished.
	pub async fn stop_wait(&self) -> anyhow::Result<()> {
		let notification_res = self.notification_mngr.stop_wait().await;
		let wallet_res = self.wallet.stop_daemon_wait().await;

		notification_res?;
		wallet_res
	}
}

impl std::ops::Deref for ServerWallet {
	type Target = Wallet;

	fn deref(&self) -> &Self::Target {
		&self.wallet
	}
}

/// Shared state held by the REST server.
///
/// Construct via [`ServerState::builder`].
///
/// The handlers share the state as an [Arc], so the fields themselves don't
/// need to be individually shareable.
pub struct ServerState {
	wallet: parking_lot::RwLock<Option<ServerWallet>>,
	shutdown: CancellationToken,
	auth_token: Option<AuthToken>,

	/// A hook to be called when a wallet is created, returning a
	/// [Wallet] to be added to the server state
	on_wallet_create: Option<Box<OnWalletCreate>>,
	/// A hook to be called when a wallet is deleted,
	///in addition to removing the wallet from the server state
	on_wallet_delete: Option<Box<OnWalletDelete>>,
	/// A hook to be called to retrieve the wallet's mnemonic phrase.
	/// When `None`, the mnemonic endpoint responds with 404.
	on_get_mnemonic: Option<Box<OnGetMnemonic>>,

	/// Serializes wallet creation and deletion.
	///
	/// A delete takes the wallet out of [Self::wallet] before it stops the
	/// background tasks and wipes the files. Without this lock a concurrent
	/// create would see the empty state and build a wallet whose files the
	/// wipe then removes.
	wallet_lifecycle: tokio::sync::Mutex<()>,

	/// A map of websocket tickets to their expiration time
	///
	/// Note: this map is only stored in memory and not persisted
	/// to the database, any server restart will clear the map.
	websocket_tickets: tokio::sync::RwLock<HashMap<String, DateTime<Utc>>>,
}

/// Builder for [`ServerState`].
///
/// ```ignore
/// let state = ServerState::builder()
///     .wallet(server_wallet)
///     .auth_token(token)
///     .on_wallet_create(create_hook)
///     .build();
/// ```
pub struct ServerStateBuilder {
	wallet: Option<Wallet>,
	auth_token: Option<AuthToken>,
	on_wallet_create: Option<Box<OnWalletCreate>>,
	on_wallet_delete: Option<Box<OnWalletDelete>>,
	on_get_mnemonic: Option<Box<OnGetMnemonic>>,
}

impl ServerStateBuilder {
	pub fn new() -> Self {
		Self {
			wallet: None,
			auth_token: None,
			on_wallet_create: None,
			on_wallet_delete: None,
			on_get_mnemonic: None,
		}
	}

	pub fn wallet(mut self, wallet: impl Into<Option<Wallet>>) -> Self {
		self.wallet = wallet.into();
		self
	}

	pub fn auth_token(mut self, token: impl Into<Option<AuthToken>>) -> Self {
		self.auth_token = token.into();
		self
	}

	pub fn on_wallet_create(mut self, hook: impl Into<Option<Box<OnWalletCreate>>>) -> Self {
		self.on_wallet_create = hook.into();
		self
	}

	pub fn on_wallet_delete(mut self, hook: impl Into<Option<Box<OnWalletDelete>>>) -> Self {
		self.on_wallet_delete = hook.into();
		self
	}

	pub fn on_get_mnemonic(mut self, hook: impl Into<Option<Box<OnGetMnemonic>>>) -> Self {
		self.on_get_mnemonic = hook.into();
		self
	}

	pub fn build(self, shutdown: CancellationToken) -> ServerState {
		let wallet_opt = match &self.wallet {
			Some(wallet) => Some(ServerWallet::new(wallet.clone(), shutdown.clone())),
			None => None,
		};

		ServerState {
			wallet: parking_lot::RwLock::new(wallet_opt),
			shutdown: shutdown,
			auth_token: self.auth_token,
			on_wallet_create: self.on_wallet_create,
			on_wallet_delete: self.on_wallet_delete,
			on_get_mnemonic: self.on_get_mnemonic,
			wallet_lifecycle: tokio::sync::Mutex::new(()),
			websocket_tickets: tokio::sync::RwLock::new(HashMap::new()),
		}
	}
}

impl Default for ServerStateBuilder {
	fn default() -> Self {
		Self::new()
	}
}

impl ServerState {
	pub fn builder() -> ServerStateBuilder {
		ServerStateBuilder::new()
	}

	pub fn require_wallet(&self) -> Result<Wallet, ErrorResponse> {
		let wallet_opt = self.wallet.read();
		let Some(wallet) = wallet_opt.as_ref() else {
			unprocessable!("No wallet set");
		};
		Ok(wallet.wallet.clone())
	}

	pub fn require_onchain(&self) -> Result<Arc<tokio::sync::RwLock<dyn OnchainWalletTrait>>, ErrorResponse> {
		let onchain_opt = self.require_wallet()?.onchain();
		let Some(onchain) = onchain_opt.as_ref() else {
			unprocessable!("No onchain wallet configured");
		};
		Ok(onchain.clone())
	}

	pub fn require_notifications(&self) -> Result<NotificationManager, ErrorResponse> {
		let wallet_opt = self.wallet.read();
		let Some(wallet) = wallet_opt.as_ref() else {
			unprocessable!("No wallet set");
		};
		Ok(wallet.notification_mngr.clone())
	}

	pub fn auth_token(&self) -> Option<&AuthToken> {
		self.auth_token.as_ref()
	}
}

impl RestServer {
	/// Start a new [RestServer] with the given config and [ServerState].
	///
	/// Build the state via [`ServerState::builder`]. The state is shared with
	/// the request handlers through the [Arc], so the caller can keep a handle
	/// on it.
	pub async fn start(
		config: &Config,
		state: Arc<ServerState>,
		shutdown: CancellationToken,
	) -> anyhow::Result<Self> {
		let (router, _api) = OpenApiRouter::with_openapi(ApiDoc::openapi())
			.split_for_parts();

		let socket_addr = config.socket_addr();

		if state.auth_token().is_none() {
			warn!("No auth token configured — all authentication is disabled");
		}

		let router = router
			.route("/ping", get(ping))
			.nest("/api/v1", api::v1::router(&state));
		#[cfg(feature = "swagger-ui")]
		let router = router
			.merge(utoipa_swagger_ui::SwaggerUi::new("/swagger-ui")
				.url("/api-docs/openapi.json", _api.clone()),
			);
		let router = router
			.layer(cors_layer(config))
			.layer(axum::middleware::from_fn(error::log_errors))
			.with_state(state)
			.fallback(error::route_not_found);

		// Run the server
		log::info!("Server starting on http://{}", socket_addr);

		let listener = tokio::net::TcpListener::bind(socket_addr).await
			.context("Failed to bind to address")?;

		let shutdown2 = shutdown.clone();
		let jh = tokio::spawn(async move {
			if let Err(e) = axum::serve(listener, router.into_make_service())
				.with_graceful_shutdown(shutdown_signal(shutdown2)).await
			{
				error!("Error running server: {:#}", e);
			} else {
				info!("Server stopped running");
			}
		});

		Ok(RestServer { shutdown, jh })
	}

	/// Stop the REST server
	pub fn stop(&self) {
		self.shutdown.cancel();
	}

	/// Stop the REST server and wait for it to shut down
	pub async fn stop_wait(self) -> anyhow::Result<()> {
		self.stop();
		self.jh.await?;
		Ok(())
	}
}

#[utoipa::path(
	get,
	path = "/ping",
	summary = "Ping",
	security(()),
	extensions(
		("x-hidden" = json!(true))
	),
	responses(
		(status = 200, description = "Returns pong")
	)
)]
pub async fn ping() -> &'static str { "pong" }