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 axum::response::Redirect;
use anyhow::Context;
use bitcoin::FeeRate;
use tracing::info;
use utoipa::OpenApi;
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_exit_status_by_vtxo_id_deprecated,
get_all_exit_status,
get_all_exit_status_deprecated,
get_live_exit_status,
get_finished_exits,
exit_start_vtxos,
exit_start_all,
exit_progress,
exit_claim_vtxos,
exit_claim_all,
exit_cancel,
),
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,
bark_json::web::ExitCancelResponse,
)),
tags((name = "exits", description = "Move bitcoin back on-chain without server cooperation."))
)]
pub struct ExitsApiDoc;
#[allow(deprecated)]
pub fn router() -> Router<ServerState> {
Router::new()
.route("/status", get(get_all_exit_status_deprecated))
.route("/status/all", get(get_all_exit_status))
.route("/status/live", get(get_live_exit_status))
.route("/status/finished", get(get_finished_exits))
.route("/status/vtxo/{vtxo_id}", get(get_exit_status_by_vtxo_id))
.route("/status/{vtxo_id}", get(get_exit_status_by_vtxo_id_deprecated))
.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))
.route("/cancel/{vtxo_id}", post(exit_cancel))
}
async fn inner_vtxo_exit_status(
state: &ServerState,
vtxo: String,
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/vtxo/{vtxo_id}",
summary = "Get VTXO 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 exit status for the given VTXO, live or finished. Optionally \
includes the state history and the exit transactions 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>> {
inner_vtxo_exit_status(&state, vtxo, query).await
}
#[utoipa::path(
get,
path = "/status/{vtxo_id}",
summary = "Get exit status (deprecated)",
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 = "Deprecated: use `GET /exits/status/vtxo/{vtxo_id}` instead.",
tag = "exits"
)]
#[debug_handler]
#[deprecated = "use GET /exits/status/vtxo/{vtxo_id} instead"]
pub async fn get_exit_status_by_vtxo_id_deprecated(
State(state): State<ServerState>,
Path(vtxo): Path<String>,
Query(query): Query<bark_json::web::ExitStatusRequest>,
) -> HandlerResult<Json<bark_json::cli::ExitTransactionStatus>> {
inner_vtxo_exit_status(&state, vtxo, query).await
}
#[utoipa::path(
get,
path = "/status/all",
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 every exit, live and finished. Optionally includes each exit's \
state history and its transactions 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 statuses = wallet.exit_mgr().list_all(
query.history.unwrap_or(false),
query.transactions.unwrap_or(false),
).await.context("Failed to list exits")?;
Ok(axum::Json(statuses.into_iter().map(Into::into).collect()))
}
#[utoipa::path(
get,
path = "/status",
summary = "List all exit statuses (deprecated)",
responses(
(status = 308, description = "Permanent redirect to `/exits/status/all`"),
),
description = "Deprecated: redirects to `GET /exits/status/all`.",
tag = "exits"
)]
#[debug_handler]
#[deprecated = "use GET /exits/status/all instead"]
pub async fn get_all_exit_status_deprecated() -> Redirect {
Redirect::permanent("/api/v1/exits/status/all")
}
#[utoipa::path(
get,
path = "/status/live",
summary = "List live 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 the live exit statuses", body = Vec<bark_json::cli::ExitTransactionStatus>),
(status = 500, description = "Internal server error", body = error::InternalServerError)
),
description = "Returns exits that are still progressing. Optionally includes each exit's \
state history and its transactions with their CPFP children.",
tag = "exits"
)]
#[debug_handler]
pub async fn get_live_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 statuses = wallet.exit_mgr().list_live(
query.history.unwrap_or(false),
query.transactions.unwrap_or(false),
).await.context("Failed to list live exits")?;
Ok(axum::Json(statuses.into_iter().map(Into::into).collect()))
}
#[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 fee_rate = body.fee_rate.map(FeeRate::from_sat_per_kvb_ceil);
wallet.sync_onchain().await
.context("error syncing on-chain wallet")?;
let result = wallet.exit_mgr().progress_exits_with_cpfp(&wallet, 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 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());
if let Some(w) = wallet.onchain() {
let mut g = w.write().await;
if g.is_mine(&address_spk).await.context("wallet error: is_mine")? {
info!("Adding claim transaction to wallet: {}", tx.compute_txid());
g.register_tx(&tx).await.context("failed to register claim tx in onchain wallet")?;
}
}
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
}
#[utoipa::path(
post,
path = "/cancel/{vtxo_id}",
summary = "Cancel an exit",
params(
("vtxo_id" = String, Path, description = "The VTXO whose unilateral exit should be canceled"),
),
responses(
(status = 200, description = "Exit canceled successfully", body = bark_json::web::ExitCancelResponse),
(status = 400, description = "The VTXO ID is invalid, or the exit can no longer be \
canceled because its final transaction has already been broadcast", body = error::BadRequestError),
(status = 404, description = "The VTXO has no exit", body = error::NotFoundError),
(status = 500, description = "Internal server error", body = error::InternalServerError)
),
description = "Aborts an in-progress emergency exit while it is still safe to do so—before \
its final transaction has been broadcast. Exit transactions are ordered topologically and \
only the final one moves the VTXO on-chain, so an exit can still be canceled even after its \
shared ancestor transactions are in the mempool or a block. Canceling leaves the VTXO \
spendable, so a fresh exit can be started for it later. Before canceling, the endpoint \
verifies directly against the chain that the final transaction hasn't been broadcast; \
nothing is rebroadcast in the process. Canceling an already-canceled exit succeeds as a \
no-op, so retries are safe. Note the daemon auto-progresses exits at the cadence defined \
by `SLOW_INTERVAL`, so cancel promptly once an exit reaches a state you no longer wish \
to pursue.",
tag = "exits"
)]
#[debug_handler]
pub async fn exit_cancel(
State(state): State<ServerState>,
Path(vtxo): Path<String>,
) -> HandlerResult<Json<bark_json::web::ExitCancelResponse>> {
let wallet = state.require_wallet()?;
let vtxo_id = ark::VtxoId::from_str(&vtxo).badarg("Invalid VTXO ID")?;
if let Err(e) = wallet.exit_mgr().cancel_exit(vtxo_id).await {
match e {
bark::exit::ExitError::NotExiting { .. } => {
not_found!([vtxo_id], "No exit found for VTXO");
},
bark::exit::ExitError::CannotCancelExit { state, .. } => {
badarg!("Exit can no longer be canceled (state: {})", state);
},
bark::exit::ExitError::ExitTxAlreadyBroadcast { txid, .. } => {
badarg!("Exit can no longer be canceled (final exit tx {} has already been broadcast)", txid);
},
other => {
return Err(anyhow::Error::from(other)
.context("Failed to cancel exit").into());
},
}
}
Ok(axum::Json(bark_json::web::ExitCancelResponse {
message: "Exit canceled successfully".to_string(),
}))
}
#[utoipa::path(
get,
path = "/status/finished",
summary = "List finished exits",
params(
("history" = Option<bool>, Query, description = "Whether to include the detailed state history of each finished exit"),
("transactions" = Option<bool>, Query, description = "Whether to include the exit transactions and their CPFP children"),
),
responses(
(status = 200, description = "Returns the finished exits", body = Vec<bark_json::cli::ExitTransactionStatus>),
(status = 500, description = "Internal server error", body = error::InternalServerError)
),
description = "Returns exits that reached a terminal state: claimed, aborted because the \
VTXO was already spent, or canceled. Finished exits are dropped from active tracking—\
they are never progressed—but they're retained for auditing and surfaced here.",
tag = "exits"
)]
#[debug_handler]
pub async fn get_finished_exits(
State(state): State<ServerState>,
Query(query): Query<bark_json::web::ExitStatusRequest>,
) -> HandlerResult<Json<Vec<bark_json::cli::ExitTransactionStatus>>> {
let wallet = state.require_wallet()?;
let statuses = wallet.exit_mgr().list_finished(
query.history.unwrap_or(false),
query.transactions.unwrap_or(false),
).await.context("Failed to list finished exits")?;
Ok(axum::Json(statuses.into_iter().map(Into::into).collect()))
}