bark-rest 0.2.2

a REST server built on top of the bark-wallet crate
use std::collections::HashSet;
use std::str::FromStr;

use axum::extract::{Path, Query, State};
use axum::routing::{get, post};
use axum::{debug_handler, Json, Router};
use anyhow::Context;
use bitcoin::FeeRate;
use tracing::info;
use utoipa::OpenApi;

use bark::onchain::ChainSync;
use bark::vtxo::{FilterVtxos, VtxoFilter};
use bitcoin_ext::FeeRateExt;

use crate::ServerState;
use crate::error::{self, HandlerResult, ContextExt, badarg, not_found};

#[derive(OpenApi)]
#[openapi(
	paths(
		get_exit_status_by_vtxo_id,
		get_all_exit_status,
		exit_start_vtxos,
		exit_start_all,
		exit_progress,
		exit_claim_vtxos,
		exit_claim_all,
	),
	components(schemas(
		bark_json::web::ExitStatusRequest,
		bark_json::cli::ExitTransactionStatus,
		bark_json::web::ExitStartRequest,
		bark_json::web::ExitStartResponse,
		bark_json::web::ExitProgressRequest,
		bark_json::cli::ExitProgressResponse,
		bark_json::web::ExitClaimAllRequest,
		bark_json::web::ExitClaimVtxosRequest,
		bark_json::web::ExitClaimResponse,
	)),
	tags((name = "exits", description = "Move bitcoin back on-chain without server cooperation."))
)]
pub struct ExitsApiDoc;

pub fn router() -> Router<ServerState> {
	Router::new()
		.route("/status/{vtxo_id}", get(get_exit_status_by_vtxo_id))
		.route("/status", get(get_all_exit_status))
		.route("/start/vtxos", post(exit_start_vtxos))
		.route("/start/all", post(exit_start_all))
		.route("/progress", post(exit_progress))
		.route("/claim/vtxos", post(exit_claim_vtxos))
		.route("/claim/all", post(exit_claim_all))
}

#[utoipa::path(
	get,
	path = "/status/{vtxo_id}",
	summary = "Get exit status",
	params(
		("vtxo_id" = String, Path, description = "The VTXO to check the exit status of"),
		("history" = Option<bool>, Query, description = "Whether to include the detailed history of the exit process"),
		("transactions" = Option<bool>, Query, description = "Whether to include the exit transactions and their CPFP children")
	),
	responses(
		(status = 200, description = "Returns the exit status", body = bark_json::cli::ExitTransactionStatus),
		(status = 404, description = "VTXO wasn't found", body = error::NotFoundError),
		(status = 500, description = "Internal server error", body = error::InternalServerError)
	),
	description = "Returns the current state of an emergency exit for the specified VTXO, \
		including which phase the exit is in (start, processing, awaiting-delta, \
		claimable, claim-in-progress, or claimed). Optionally includes the full state \
		transition history and the exit transaction packages with their CPFP children.",
	tag = "exits"
)]
#[debug_handler]
pub async fn get_exit_status_by_vtxo_id(
	State(state): State<ServerState>,
	Path(vtxo): Path<String>,
	Query(query): Query<bark_json::web::ExitStatusRequest>,
) -> HandlerResult<Json<bark_json::cli::ExitTransactionStatus>> {
	let wallet = state.require_wallet()?;

	let vtxo_id = ark::VtxoId::from_str(&vtxo).badarg("Invalid VTXO ID")?;

	let status = wallet.exit_mgr().get_exit_status(
		vtxo_id,
		query.history.unwrap_or(false),
		query.transactions.unwrap_or(false)
	).await.context("Failed to get exit status")?;

	match status {
		None => not_found!([vtxo_id], "VTXO not found"),
		Some(status) => Ok(axum::Json(status.into())),
	}
}

#[utoipa::path(
	get,
	path = "/status",
	summary = "List all exit statuses",
	params(
		("history" = Option<bool>, Query, description = "Whether to include the detailed history of the exit process"),
		("transactions" = Option<bool>, Query, description = "Whether to include the exit transactions and their CPFP children")
	),
	responses(
		(status = 200, description = "Returns all exit statuses", body = Vec<bark_json::cli::ExitTransactionStatus>),
		(status = 500, description = "Internal server error", body = error::InternalServerError)
	),
	description = "Returns the current state of every emergency exit in the wallet. Each \
		entry includes which phase the exit is in (start, processing, awaiting-delta, \
		claimable, claim-in-progress, or claimed), and optionally the full state \
		transition history and the exit transaction packages with their CPFP children.",
	tag = "exits"
)]
#[debug_handler]
pub async fn get_all_exit_status(
	State(state): State<ServerState>,
	Query(query): Query<bark_json::web::ExitStatusRequest>,
) -> HandlerResult<Json<Vec<bark_json::cli::ExitTransactionStatus>>> {
	let wallet = state.require_wallet()?;

	let exit_vtxos = wallet.exit_mgr().get_exit_vtxos().await;
	let mut statuses = Vec::with_capacity(exit_vtxos.len());

	for e in &exit_vtxos {
		let status = wallet.exit_mgr().get_exit_status(
			e.id(),
			query.history.unwrap_or(false),
			query.transactions.unwrap_or(false)
		).await.badarg("Failed to get exit status")?.unwrap();

		statuses.push(bark_json::cli::ExitTransactionStatus::from(status));
	}

	Ok(axum::Json(statuses))
}

#[utoipa::path(
	post,
	path = "/start/vtxos",
	summary = "Start exit for specific VTXOs",
	request_body = bark_json::web::ExitStartRequest,
	responses(
		(status = 200, description = "Exit started successfully", body = bark_json::web::ExitStartResponse),
		(status = 400, description = "No VTXO IDs provided, or one of the provided VTXO \
			IDs is invalid", body = error::BadRequestError),
		(status = 404, description = "One the VTXOs wasn't found", body = error::NotFoundError),
		(status = 500, description = "Internal server error", body = error::InternalServerError)
	),
	description = "Registers the specified VTXOs for emergency exit. The daemon \
		automatically progresses registered exits in the background at the cadence \
		defined by `SLOW_INTERVAL`, creating and broadcasting the required \
		transactions in sequence. Once all exit transactions are confirmed and the \
		timelock has elapsed, call `claim` to sweep the resulting outputs to an \
		on-chain address.",
	tag = "exits"
)]
#[debug_handler]
pub async fn exit_start_vtxos(
	State(state): State<ServerState>,
	Json(body): Json<bark_json::web::ExitStartRequest>,
) -> HandlerResult<Json<bark_json::web::ExitStartResponse>> {
	let wallet = state.require_wallet()?;

	if body.vtxos.is_empty() {
		badarg!("No VTXO IDs provided");
	}

	let mut vtxo_ids = Vec::new();
	for s in body.vtxos {
		let id = ark::VtxoId::from_str(&s).badarg("Invalid VTXO ID")?;
		wallet.get_vtxo_by_id(id).await.not_found([id], "VTXO not found")?;
		vtxo_ids.push(id);
	}

	let filter = VtxoFilter::new(&wallet).include_many(vtxo_ids);

	let spendable = wallet.spendable_vtxos_with(&filter).await
		.context("Error fetching spendable VTXOs")?;
	let inround = {
		let mut vtxos = wallet.pending_round_input_vtxos().await
			.context("Error fetching pending round input VTXOs")?;
		filter.filter_vtxos(&mut vtxos).await?;
		vtxos
	};

	let vtxos = spendable.into_iter().chain(inround)
		.map(|v| v.vtxo).collect::<Vec<_>>();

	wallet.exit_mgr().start_exit_for_vtxos(&vtxos).await
		.context("Failed to start exit for VTXOs")?;

	Ok(axum::Json(bark_json::web::ExitStartResponse {
		message: "Exit started successfully".to_string(),
	}))
}

#[utoipa::path(
	post,
	path = "/start/all",
	summary = "Start exit for all VTXOs",
	responses(
		(status = 200, description = "Exit started successfully", body = bark_json::web::ExitStartResponse),
		(status = 500, description = "Internal server error", body = error::InternalServerError)
	),
	description = "Registers all wallet VTXOs for emergency exit. The daemon \
		automatically progresses registered exits in the background at the cadence \
		defined by `SLOW_INTERVAL`, creating and broadcasting the required \
		transactions in sequence. Once all exit transactions are confirmed and the \
		timelock has elapsed, call `claim` to sweep the resulting outputs to an \
		on-chain address.",
	tag = "exits"
)]
#[debug_handler]
pub async fn exit_start_all(
	State(state): State<ServerState>,
) -> HandlerResult<Json<bark_json::web::ExitStartResponse>> {
	let wallet = state.require_wallet()?;

	wallet.exit_mgr().start_exit_for_entire_wallet().await
		.context("Failed to start exit for entire wallet")?;

	Ok(axum::Json(bark_json::web::ExitStartResponse {
		message: "Exit started successfully".to_string(),
	}))
}


#[utoipa::path(
	post,
	path = "/progress",
	summary = "Progress exits",
	request_body = bark_json::web::ExitProgressRequest,
	responses(
		(status = 200, description = "Returns the exit progress", body = bark_json::cli::ExitProgressResponse),
		(status = 500, description = "Internal server error", body = error::InternalServerError)
	),
	description = "Triggers all in-progress exits to advance. The daemon already progresses exits \
		automatically in the background—use this endpoint when you want immediate progress rather \
		than waiting for the next automatic cycle. On each call, the endpoint syncs transaction \
		statuses, advances the exit state machine, and creates or fee-bumps CPFP children for any \
		exit transactions that need them. The on-chain wallet must have sufficient bitcoin to cover \
		transaction fees.",
	tag = "exits"
)]
#[debug_handler]
pub async fn exit_progress(
	State(state): State<ServerState>,
	Json(body): Json<bark_json::web::ExitProgressRequest>,
) -> HandlerResult<Json<bark_json::cli::ExitProgressResponse>> {
	let wallet = state.require_wallet()?;

	let onchain = state.require_onchain()?;
	let mut onchain_lock = onchain.write().await;

	let fee_rate = body.fee_rate.map(FeeRate::from_sat_per_kvb_ceil);

	onchain_lock.sync(wallet.chain()).await
		.context("error syncing on-chain wallet")?;

	let result = wallet.exit_mgr().progress_exits_with_bdk(&wallet, &mut *onchain_lock, fee_rate).await
		.context("error making progress on exit process")?;

	let done = !wallet.exit_mgr().has_pending_exits().await;
	let claimable_height = wallet.exit_mgr().all_claimable_at_height().await;
	let exits = result.unwrap_or_default();

	Ok(axum::Json(bark_json::cli::ExitProgressResponse {
		done,
		claimable_height,
		exits: exits.into_iter().map(|e| e.into()).collect::<Vec<_>>(),
		error: None,
	}))
}

async fn inner_claim_vtxos(
	state: &ServerState,
	address: bitcoin::Address,
	vtxos: &[bark::exit::ExitVtxo],
	fee_rate: Option<FeeRate>,
) -> HandlerResult<Json<bark_json::web::ExitClaimResponse>> {
	let wallet = state.require_wallet()?;
	let onchain = state.require_onchain()?;

	let address_spk = address.script_pubkey();
	let psbt = wallet.exit_mgr().drain_exits(vtxos, &wallet, address, fee_rate).await
		.context("Failed to drain exits")?;
	let tx = psbt.extract_tx()
		.context("Failed to extract transaction")?;
	wallet.chain().broadcast_tx(&tx).await
		.context("Failed to broadcast transaction")?;
	info!("Drain transaction broadcasted: {}", tx.compute_txid());

	let mut onchain_lock = onchain.write().await;

	// Commit the transaction to the wallet if the claim destination is ours
	if onchain_lock.is_mine(address_spk) {
		info!("Adding claim transaction to wallet: {}", tx.compute_txid());
		onchain_lock.apply_unconfirmed_txs([(tx, bark::time::timestamp_secs())]);
	}

	Ok(axum::Json(bark_json::web::ExitClaimResponse {
		message: "Exit claimed successfully".to_string(),
	}))
}

#[utoipa::path(
	post,
	path = "/claim/vtxos",
	summary = "Claim specific exited VTXOs",
	request_body = bark_json::web::ExitClaimVtxosRequest,
	responses(
		(status = 200, description = "Exit claimed successfully", body = bark_json::web::ExitClaimResponse),
		(status = 400, description = "One of the provided VTXO isn't spendable, or \
			the provided destination address is invalid", body = error::BadRequestError),
		(status = 500, description = "Internal server error", body = error::InternalServerError)
	),
	description = "Sweeps the specified claimable exit outputs into a single on-chain \
		transaction sent to the specified address. Unlike `progress`, the daemon does \
		not claim automatically—this endpoint must be called manually. Poll the \
		`status` endpoint or call `progress` and check for `done: true` to know when \
		VTXOs are ready to claim. This is the final step of the emergency exit \
		process—the bitcoin is not considered back on-chain until this transaction \
		confirms.",
	tag = "exits"
)]
#[debug_handler]
pub async fn exit_claim_vtxos(
	State(state): State<ServerState>,
	Json(body): Json<bark_json::web::ExitClaimVtxosRequest>,
) -> HandlerResult<Json<bark_json::web::ExitClaimResponse>> {
	let wallet = state.require_wallet()?;

	let network = wallet.network().await?;
	let address = bitcoin::Address::from_str(&body.destination)
		.badarg("Invalid destination address")?
		.require_network(network)
		.badarg("Address is not valid for configured network")?;

	let claimable = wallet.exit_mgr().list_claimable().await;
	let vtxos = {
		let mut vtxo_ids = HashSet::new();
		for s in body.vtxos {
			let id = ark::VtxoId::from_str(&s).badarg("Invalid VTXO ID")?;
			wallet.get_vtxo_by_id(id).await.not_found([id], "VTXO not found")?;
			vtxo_ids.insert(id);
		}

		let vtxos = claimable.into_iter()
			.filter(|v| vtxo_ids.remove(&v.id()))
			.collect::<Vec<_>>();

		for id in vtxo_ids {
			badarg!("Unspendable VTXO provided: {}", id);
		}
		vtxos
	};

	let fee_rate = body.fee_rate.map(FeeRate::from_sat_per_kvb_ceil);

	inner_claim_vtxos(&state, address, &vtxos, fee_rate).await
}

#[utoipa::path(
	post,
	path = "/claim/all",
	summary = "Claim all exited VTXOs",
	request_body = bark_json::web::ExitClaimAllRequest,
	responses(
		(status = 200, description = "Exit claimed successfully", body = bark_json::web::ExitClaimResponse),
		(status = 400, description = "The provided destination address is invalid", body = error::BadRequestError),
		(status = 500, description = "Internal server error", body = error::InternalServerError)
	),
	description = "Sweeps all claimable exit outputs into a single on-chain transaction \
		sent to the specified address. Unlike `progress`, the daemon does not claim \
		automatically—this endpoint must be called manually. Poll the `status` endpoint \
		or call `progress` and check for `done: true` to know when VTXOs are ready to \
		claim. This is the final step of the emergency exit process—the bitcoin is not \
		considered back on-chain until this transaction confirms.",
	tag = "exits"
)]
#[debug_handler]
pub async fn exit_claim_all(
	State(state): State<ServerState>,
	Json(body): Json<bark_json::web::ExitClaimAllRequest>,
) -> HandlerResult<Json<bark_json::web::ExitClaimResponse>> {
	let wallet = state.require_wallet()?;
	let network = wallet.network().await?;

	let address = bitcoin::Address::from_str(&body.destination)
		.badarg("Invalid destination address")?
		.require_network(network)
		.badarg("Address is not valid for configured network")?;

	let vtxos = wallet.exit_mgr().list_claimable().await;

	let fee_rate = body.fee_rate.map(FeeRate::from_sat_per_kvb_ceil);

	inner_claim_vtxos(&state, address, &vtxos, fee_rate).await
}