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<_>>()
}))
}
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;
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
}