use std::str::FromStr;
use anyhow::Context;
use axum::extract::{Path, Query, State};
use axum::routing::{get, post};
use axum::{Json, Router, debug_handler};
use bitcoin::Amount;
use tracing::info;
use utoipa::OpenApi;
use ark::VtxoId;
use ark::lightning::{Bolt11Invoice, Offer};
use ark::ProtocolEncoding;
use bark::lnurllib::lightning_address::LightningAddress;
use bark::onchain::GetAddress;
use bark::subsystem::RoundMovement;
use bark::vtxo::VtxoFilter;
use bark_json::web::PendingRoundInfo;
use crate::{ServerState, error};
use crate::error::{ContextExt, HandlerResult, badarg, not_found};
pub fn router() -> Router<ServerState> {
#[allow(deprecated)]
Router::new()
.route("/", get(wallet_exists).post(create_wallet).delete(wallet_delete))
.route("/connected", get(connected))
.route("/create", post(create_wallet))
.route("/ark-info", get(ark_info))
.route("/next-round", get(next_round))
.route("/addresses/next", post(address))
.route("/addresses/index/{index}", get(peek_address))
.route("/balance", get(balance))
.route("/vtxos", get(vtxos))
.route("/vtxos/{id}", get(get_vtxo))
.route("/vtxos/{id}/encoded", get(get_vtxo_encoded))
.route("/movements", get(movements))
.route("/history", get(history))
.route("/send", post(send))
.route("/refresh/vtxos", post(refresh_vtxos))
.route("/refresh/all", post(refresh_all))
.route("/refresh/counterparty", post(refresh_counterparty))
.route("/offboard/vtxos", post(offboard_vtxos))
.route("/offboard/all", post(offboard_all))
.route("/send-onchain", post(send_onchain))
.route("/rounds", get(pending_rounds))
.route("/sync", post(sync))
.route("/sync/mailbox", post(sync_mailbox))
.route("/import-vtxo", post(import_vtxo))
}
#[derive(OpenApi)]
#[openapi(
paths(
wallet_exists,
wallet_delete,
connected,
create_wallet,
ark_info,
next_round,
address,
peek_address,
balance,
vtxos,
get_vtxo,
get_vtxo_encoded,
movements,
history,
send,
refresh_vtxos,
refresh_all,
refresh_counterparty,
offboard_vtxos,
offboard_all,
send_onchain,
pending_rounds,
sync,
sync_mailbox,
import_vtxo,
),
components(schemas(
bark_json::web::ArkAddressResponse,
bark_json::web::WalletExistsResponse,
bark_json::web::WalletDeleteRequest,
bark_json::web::WalletDeleteResponse,
bark_json::web::ConnectedResponse,
bark_json::web::CreateWalletRequest,
bark_json::web::CreateWalletResponse,
bark_json::cli::ArkInfo,
bark_json::cli::NextRoundStart,
bark_json::web::VtxosQuery,
bark_json::cli::Balance,
bark_json::primitives::WalletVtxoInfo,
bark_json::web::EncodedVtxoResponse,
bark_json::movements::Movement,
bark_json::web::SendRequest,
bark_json::web::SendResponse,
bark_json::web::RefreshRequest,
bark_json::web::OffboardVtxosRequest,
bark_json::web::OffboardAllRequest,
bark_json::web::ImportVtxoRequest,
bark_json::web::MailboxSyncResponse,
bark_json::web::PendingRoundInfo,
bark_json::cli::RoundStatus,
error::InternalServerError,
error::NotFoundError,
error::BadRequestError,
)),
tags(
(name = "wallet", description = "Manage Ark balances and VTXOs, send payments via Ark, LN, and on-chain."),
)
)]
pub struct WalletApiDoc;
#[utoipa::path(
get,
path = "/connected",
summary = "Check server connection",
responses(
(status = 200, description = "Returns whether the wallet is connected to an Ark server", body = bark_json::web::ConnectedResponse),
(status = 500, description = "Internal server error", body = error::InternalServerError)
),
description = "Checks whether the wallet has an active connection to the Ark server. \
Returns `true` if the wallet can reach the server and retrieve its configuration, \
`false` otherwise. The background daemon checks the server connection every second, \
so this reflects the most recent known state.",
tag = "wallet"
)]
#[debug_handler]
pub async fn connected(State(state): State<ServerState>) -> HandlerResult<Json<bark_json::web::ConnectedResponse>> {
let wallet = state.require_wallet()?;
Ok(axum::Json(bark_json::web::ConnectedResponse {
connected: wallet.ark_info().await?.is_some(),
}))
}
#[utoipa::path(
get,
path = "",
responses(
(status = 200, description = "Wallet existence status", body = bark_json::web::WalletExistsResponse),
(status = 500, description = "Internal server error", body = error::InternalServerError)
),
tag = "wallet"
)]
#[debug_handler]
pub async fn wallet_exists(State(state): State<ServerState>) -> HandlerResult<Json<bark_json::web::WalletExistsResponse>> {
let wallet = state.wallet.read();
Ok(Json(bark_json::web::WalletExistsResponse {
fingerprint: wallet.as_ref().map(|w| w.wallet.fingerprint().to_string()),
}))
}
#[utoipa::path(
delete,
path = "",
request_body = bark_json::web::WalletDeleteRequest,
responses(
(status = 200, description = "Wallet deletion status", body = bark_json::web::WalletDeleteResponse),
(status = 400, description = "Invalid request", body = error::BadRequestError),
(status = 500, description = "Internal server error", body = error::InternalServerError)
),
tag = "wallet"
)]
#[debug_handler]
pub async fn wallet_delete(State(state): State<ServerState>, Json(req): Json<bark_json::web::WalletDeleteRequest>) -> HandlerResult<Json<bark_json::web::WalletDeleteResponse>> {
if !req.dangerous {
badarg!("deletion not confirmed: set dangerous=true");
}
{
let wallet = state.wallet.read();
let Some(w) = wallet.as_ref() else {
return Ok(Json(bark_json::web::WalletDeleteResponse {
deleted: false,
message: "No wallet to delete".to_string(),
}));
};
if w.wallet.fingerprint().to_string() != req.fingerprint {
badarg!("Fingerprint does not match the loaded wallet");
}
}
let Some(hook) = state.on_wallet_delete.as_ref() else {
badarg!("No wallet deletion hook configured");
};
hook().await.context("Couldn't delete wallet")?;
state.wallet.write().take();
Ok(Json(bark_json::web::WalletDeleteResponse {
deleted: true,
message: "Wallet deleted".to_string(),
}))
}
#[utoipa::path(
post,
path = "/create",
summary = "Create a wallet",
responses(
(status = 200, description = "Wallet created successfully", body = bark_json::web::CreateWalletResponse),
(status = 500, description = "Internal server error", body = error::InternalServerError)
),
description = "Creates a new wallet with the specified Ark server and chain source \
configuration. Fails if a wallet already exists. Returns the wallet fingerprint \
on success.",
tag = "wallet"
)]
#[debug_handler]
pub async fn create_wallet(
State(state): State<ServerState>,
Json(req): Json<bark_json::web::CreateWalletRequest>,
) -> HandlerResult<Json<bark_json::web::CreateWalletResponse>> {
if state.wallet.read().is_some() {
badarg!("Wallet already set");
}
if let Some(on_wallet_create) = state.on_wallet_create.as_ref() {
let wallet = on_wallet_create(req).await?;
let fingerprint = wallet.wallet.fingerprint().to_string();
let _ = state.wallet.write().insert(wallet);
Ok(axum::Json(bark_json::web::CreateWalletResponse { fingerprint }))
} else {
badarg!("No wallet creation hook set");
}
}
#[utoipa::path(
get,
path = "/ark-info",
summary = "Get Ark server info",
responses(
(status = 200, description = "Returns the Ark info", body = bark_json::cli::ArkInfo),
(status = 404, description = "Wallet not connected to an Ark server", body = error::NotFoundError),
(status = 500, description = "Internal server error", body = error::InternalServerError)
),
description = "Returns the Ark server's configuration parameters, including network, \
public key, round interval, VTXO expiry and exit deltas, fee settings, and \
Lightning support details.",
tag = "wallet"
)]
#[debug_handler]
pub async fn ark_info(State(state): State<ServerState>) -> HandlerResult<Json<bark_json::cli::ArkInfo>> {
let wallet = state.require_wallet()?;
let ark_info = wallet.ark_info().await?;
match ark_info {
Some(ark_info) => Ok(axum::Json(ark_info.into())),
None => not_found!(["ark server"], "Wallet not connected to an Ark server"),
}
}
#[utoipa::path(
get,
path = "/next-round",
summary = "Get next round time",
responses(
(status = 200, description = "Returns the next round start time", body = bark_json::cli::NextRoundStart),
(status = 404, description = "Wallet not connected to an Ark server", body = error::NotFoundError),
(status = 500, description = "Internal server error", body = error::InternalServerError)
),
description = "Queries the Ark server for the next scheduled round start time and returns \
it in RFC 3339 format.",
tag = "wallet"
)]
#[debug_handler]
pub async fn next_round(State(state): State<ServerState>) -> HandlerResult<Json<bark_json::cli::NextRoundStart>> {
let wallet = state.require_wallet()?;
let time = wallet.next_round_start_time().await?;
Ok(axum::Json(bark_json::cli::NextRoundStart { start_time: time.into() }))
}
#[utoipa::path(
post,
path = "/addresses/next",
summary = "Generate Ark address",
responses(
(status = 200, description = "Returns the Ark address", body = bark_json::web::ArkAddressResponse),
(status = 500, description = "Internal server error", body = error::InternalServerError)
),
description = "Generates a new Ark receiving address. Each call returns the next unused \
address from the wallet's HD keychain.",
tag = "wallet"
)]
#[debug_handler]
pub async fn address(
State(state): State<ServerState>,
) -> HandlerResult<Json<bark_json::web::ArkAddressResponse>> {
let wallet = state.require_wallet()?;
let ark_address = wallet.new_address().await
.context("Failed to generate new address")?;
Ok(axum::Json(bark_json::web::ArkAddressResponse {
address: ark_address.to_string(),
}))
}
#[utoipa::path(
get,
path = "/addresses/index/{index}",
summary = "Get Ark address by index",
params(
("index" = u32, Path, description = "Index for the address.")
),
responses(
(status = 200, description = "Returns the Ark address", body = bark_json::web::ArkAddressResponse),
(status = 500, description = "Internal server error", body = error::InternalServerError)
),
description = "Returns a previously generated Ark address by its derivation index. Only \
addresses that have already been generated are available.",
tag = "wallet"
)]
#[debug_handler]
pub async fn peek_address(
State(state): State<ServerState>,
Path(index): Path<u32>,
) -> HandlerResult<Json<bark_json::web::ArkAddressResponse>> {
let wallet = state.require_wallet()?;
let ark_address = wallet.peek_address(index).await
.with_context(|| format!("Failed to get address at index {}", index))?;
Ok(axum::Json(bark_json::web::ArkAddressResponse {
address: ark_address.to_string(),
}))
}
#[utoipa::path(
get,
path = "/balance",
summary = "Get wallet balance",
responses(
(status = 200, description = "Returns the wallet balance", body = bark_json::cli::Balance),
(status = 500, description = "Internal server error", body = error::InternalServerError)
),
description = "Returns the wallet balance broken down by category: spendable sats \
available for immediate use, sats pending in an Ark round, sats locked in outgoing \
or incoming Lightning payments, sats awaiting board confirmation, and sats in a \
pending exit. The balance is computed from local state, which the background daemon \
keeps reasonably fresh (Lightning syncs every second, mailbox and boards every \
30 seconds). For the most up-to-date figures, call `sync` before this endpoint.",
tag = "wallet"
)]
#[debug_handler]
pub async fn balance(State(state): State<ServerState>) -> HandlerResult<Json<bark_json::cli::Balance>> {
let wallet = state.require_wallet()?;
let balance = wallet.balance().await
.context("Failed to get wallet balance")?;
Ok(axum::Json(balance.into()))
}
#[utoipa::path(
get,
path = "/vtxos",
summary = "List VTXOs",
params(
("all" = Option<bool>, Query, description = "Return all VTXOs regardless of their state. If not provided, returns only non-spent VTXOs.")
),
responses(
(status = 200, description = "Returns the wallet VTXOs", body = Vec<bark_json::primitives::WalletVtxoInfo>),
(status = 500, description = "Internal server error", body = error::InternalServerError)
),
description = "Returns VTXOs held by the wallet, including their state and expiry \
information. By default returns only non-spent VTXOs. Set `all=true` to include \
all VTXOs regardless of state.",
tag = "wallet"
)]
#[debug_handler]
pub async fn vtxos(
State(state): State<ServerState>,
Query(query): Query<bark_json::web::VtxosQuery>,
) -> HandlerResult<Json<Vec<bark_json::primitives::WalletVtxoInfo>>> {
let wallet = state.require_wallet()?;
let wallet_vtxos = if query.all.unwrap_or(false) {
wallet.all_vtxos().await.context("Failed to get all VTXOs")?
} else {
wallet.vtxos().await.context("Failed to get VTXOs")?
};
let vtxo_infos = wallet_vtxos
.into_iter()
.map(bark_json::primitives::WalletVtxoInfo::from)
.collect::<Vec<_>>();
Ok(axum::Json(vtxo_infos))
}
#[utoipa::path(
get,
path = "/vtxos/{id}",
summary = "Get VTXO detail",
params(
("id" = String, Path, description = "VTXO identifier formatted as `txid:vout`.")
),
responses(
(status = 200, description = "Returns the VTXO detail", body = bark_json::primitives::WalletVtxoInfo),
(status = 400, description = "Invalid VTXO id", body = error::BadRequestError),
(status = 404, description = "VTXO not found", body = error::NotFoundError),
(status = 500, description = "Internal server error", body = error::InternalServerError)
),
description = "Returns detail for a single VTXO. To get the hex-encoded serialization \
use `GET /vtxos/{id}/encoded`.",
tag = "wallet"
)]
#[debug_handler]
pub async fn get_vtxo(
State(state): State<ServerState>,
Path(id): Path<String>,
) -> HandlerResult<Json<bark_json::primitives::WalletVtxoInfo>> {
let wallet = state.require_wallet()?;
let vtxo_id = VtxoId::from_str(&id).badarg("Invalid VTXO id")?;
let wallet_vtxo = wallet.get_vtxo_by_id(vtxo_id).await
.not_found([vtxo_id], "VTXO not found")?;
Ok(axum::Json(wallet_vtxo.into()))
}
#[utoipa::path(
get,
path = "/vtxos/{id}/encoded",
summary = "Get encoded VTXO",
params(
("id" = String, Path, description = "VTXO identifier formatted as `txid:vout`.")
),
responses(
(status = 200, description = "Returns the hex-encoded serialized VTXO", body = bark_json::web::EncodedVtxoResponse),
(status = 400, description = "Invalid VTXO id", body = error::BadRequestError),
(status = 404, description = "VTXO not found", body = error::NotFoundError),
(status = 500, description = "Internal server error", body = error::InternalServerError)
),
description = "Returns the hex-encoded serialization of a VTXO. The `encoded` field \
can be passed to `POST /wallet/import-vtxo` to re-import this VTXO.",
tag = "wallet"
)]
#[debug_handler]
pub async fn get_vtxo_encoded(
State(state): State<ServerState>,
Path(id): Path<String>,
) -> HandlerResult<Json<bark_json::web::EncodedVtxoResponse>> {
let wallet = state.require_wallet()?;
let vtxo_id = VtxoId::from_str(&id).badarg("Invalid VTXO id")?;
let vtxo = wallet.get_full_vtxo(vtxo_id).await
.not_found([vtxo_id], "VTXO not found")?;
let encoded = bark_json::primitives::EncodedVtxo(vtxo.serialize_hex());
Ok(axum::Json(bark_json::web::EncodedVtxoResponse { encoded }))
}
#[utoipa::path(
get,
path = "/movements",
summary = "List movements (deprecated)",
responses(
(status = 200, description = "Returns the wallet movements", body = Vec<bark_json::movements::Movement>),
(status = 500, description = "Internal server error", body = error::InternalServerError)
),
description = "Deprecated: Use history instead",
tag = "wallet",
)]
#[debug_handler]
#[deprecated(note = "Use `history` instead")]
pub async fn movements(State(state): State<ServerState>) -> HandlerResult<Json<Vec<bark_json::movements::Movement>>> {
let wallet = state.require_wallet()?;
#[allow(deprecated)]
let movements = wallet.movements().await.context("Failed to get movements")?;
let json_movements = movements
.into_iter()
.map(|m| bark_json::movements::Movement::try_from(m)
.context("Failed to convert movement to JSON")
).collect::<Result<Vec<_>, _>>()?;
Ok(axum::Json(json_movements))
}
#[utoipa::path(
get,
path = "/history",
summary = "Get wallet history (deprecated)",
responses(
(status = 200, description = "Returns the wallet history", body = Vec<bark_json::movements::Movement>),
(status = 500, description = "Internal server error", body = error::InternalServerError)
),
description = "Deprecated: use `GET /api/v1/history` instead.",
tag = "wallet"
)]
#[debug_handler]
#[deprecated(note = "Use `GET /api/v1/history` instead")]
pub async fn history(
state: State<ServerState>,
) -> HandlerResult<Json<Vec<bark_json::movements::Movement>>> {
crate::api::v1::history::list(state).await
}
#[utoipa::path(
get,
path = "/rounds",
summary = "List round participations",
responses(
(status = 200, description = "Returns the wallet pending rounds", body = Vec<bark_json::web::PendingRoundInfo>),
(status = 500, description = "Internal server error", body = error::InternalServerError)
),
description = "Returns all active round participations and their current status. A round \
participation is created when you call one of the `refresh` endpoints and persists \
until the round's funding transaction is confirmed on-chain (2 confirmations on \
mainnet, 1 on testnet). The list can contain multiple entries—for example, a \
previous round awaiting on-chain confirmation alongside a newly submitted round \
waiting for the next server round to start. Confirmed and failed rounds are \
removed automatically by the background daemon.",
tag = "wallet"
)]
#[debug_handler]
pub async fn pending_rounds(
State(state): State<ServerState>,
) -> HandlerResult<Json<Vec<bark_json::web::PendingRoundInfo>>> {
let wallet = state.require_wallet()?;
let round_state_ids = wallet.pending_round_state_ids().await
.context("Failed to get pending rounds")?;
let mut infos = Vec::with_capacity(round_state_ids.len());
for id in round_state_ids {
let Some(mut round) = wallet.lock_wait_round_state(id).await?
else {
continue;
};
let sync = round.state_mut().sync(&wallet).await;
infos.push(PendingRoundInfo::new(&round, sync));
}
Ok(axum::Json(infos))
}
#[utoipa::path(
post,
path = "/send",
summary = "Send a payment",
request_body = bark_json::web::SendRequest,
responses(
(status = 200, description = "Payment sent successfully", body = bark_json::web::SendResponse),
(status = 400, description = "The provided destination is not a valid Ark address, \
BOLT11 invoice, BOLT12 offer, or Lightning address", body = error::BadRequestError),
(status = 500, description = "Internal server error", body = error::InternalServerError)
),
description = "Sends an Ark or Lightning payment to the specified destination. Accepts \
an Ark address, BOLT11 invoice, BOLT12 offer, or Lightning address. Ark address \
payments are settled instantly via an out-of-round (arkoor) transaction. The \
`amount_sat` field is required for Ark addresses and Lightning addresses but \
optional for invoices and offers that already encode an amount. Comments are \
only supported for Lightning addresses. To send to an on-chain bitcoin address, \
use `send-onchain` instead.",
tag = "wallet"
)]
#[debug_handler]
pub async fn send(
State(state): State<ServerState>,
Json(body): Json<bark_json::web::SendRequest>,
) -> HandlerResult<Json<bark_json::web::SendResponse>> {
let wallet = state.require_wallet()?;
let amount = body.amount_sat.map(|a| Amount::from_sat(a));
if let Ok(addr) = ark::Address::from_str(&body.destination) {
let amount = amount.context("amount missing")?;
info!("Sending arkoor payment of {} to address {}", amount, addr);
wallet.send_arkoor_payment(&addr, amount).await?;
} else if let Ok(inv) = Bolt11Invoice::from_str(&body.destination) {
if body.comment.is_some() {
badarg!("comment is not supported for BOLT-11 invoices");
}
wallet.pay_lightning_invoice(inv, amount).await?;
} else if let Ok(offer) = Offer::from_str(&body.destination) {
if body.comment.is_some() {
badarg!("comment is not supported for BOLT-12 offers");
}
wallet.pay_lightning_offer(offer, amount).await?;
} else if let Ok(addr) = LightningAddress::from_str(&body.destination) {
let amount = amount.badarg("amount is required for Lightning addresses")?;
wallet.pay_lightning_address(&addr, amount, body.comment).await?;
} else if let Ok(addr) = bitcoin::Address::from_str(&body.destination) {
let _checked_addr = addr
.require_network(wallet.network().await?)
.context("bitcoin address is not valid for configured network")?;
let _amount = amount.context("amount missing")?;
return Err(anyhow!("offboards are temporarily disabled").into());
} else {
badarg!("Argument is not a valid destination. Supported are: \
VTXO pubkeys, bolt11 invoices, bolt12 offers and lightning addresses");
}
Ok(axum::Json(bark_json::web::SendResponse {
message: "Payment sent successfully".to_string(),
}))
}
#[utoipa::path(
post,
path = "/refresh/vtxos",
summary = "Refresh specific VTXOs",
request_body = bark_json::web::RefreshRequest,
responses(
(status = 200, description = "Returns the refresh result", body = bark_json::web::PendingRoundInfo),
(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 refresh in the next Ark round. The \
input VTXOs are locked immediately and will be forfeited once the round completes, \
yielding new VTXOs with a fresh expiry. The background daemon automatically \
participates in the round and progresses it to completion. Use the `rounds` \
endpoint to track progress.",
tag = "wallet"
)]
#[debug_handler]
pub async fn refresh_vtxos(
State(state): State<ServerState>,
Json(body): Json<bark_json::web::RefreshRequest>,
) -> HandlerResult<Json<bark_json::web::PendingRoundInfo>> {
let wallet = state.require_wallet()?;
if body.vtxos.is_empty() {
badarg!("No VTXO IDs provided");
}
let vtxo_ids = body.vtxos
.into_iter()
.map(|s| ark::VtxoId::from_str(&s).badarg("Invalid VTXO id"))
.collect::<Result<Vec<_>, _>>()?;
let participation = wallet
.build_refresh_participation(vtxo_ids).await
.context("Failed to build round participation")?;
match participation {
Some(participation) => {
let mut round = wallet
.join_next_round(participation, Some(RoundMovement::Refresh)).await
.context("Failed to store round participation")?;
let sync = round.state_mut().sync(&wallet).await;
Ok(axum::Json(PendingRoundInfo::new(&round, sync)))
}
None => {
badarg!("No VTXOs to refresh");
}
}
}
#[utoipa::path(
post,
path = "/refresh/all",
summary = "Refresh all VTXOs",
responses(
(status = 200, description = "Returns the refresh result", body = bark_json::web::PendingRoundInfo),
(status = 500, description = "Internal server error", body = error::InternalServerError)
),
description = "Registers all spendable VTXOs for refresh in the next Ark round. The \
input VTXOs are locked immediately and will be forfeited once the round completes, \
yielding new VTXOs with a fresh expiry. The background daemon automatically \
participates in the round and progresses it to completion. Use the `rounds` \
endpoint to track progress.",
tag = "wallet"
)]
#[debug_handler]
pub async fn refresh_all(
State(state): State<ServerState>,
) -> HandlerResult<Json<bark_json::web::PendingRoundInfo>> {
let wallet = state.require_wallet()?;
let vtxos = wallet
.spendable_vtxos().await
.context("Failed to get spendable VTXOs")?;
let participation = wallet
.build_refresh_participation(vtxos).await
.context("Failed to build round participation")?;
match participation {
Some(participation) => {
let mut round = wallet
.join_next_round(participation, Some(RoundMovement::Refresh)).await
.context("Failed to store round participation")?;
let sync = round.state_mut().sync(&wallet).await;
Ok(axum::Json(PendingRoundInfo::new(&round, sync)))
}
None => {
badarg!("No VTXOs to refresh");
}
}
}
#[utoipa::path(
post,
path = "/refresh/counterparty",
summary = "Refresh received VTXOs",
responses(
(status = 200, description = "Returns the refresh result", body = bark_json::web::PendingRoundInfo),
(status = 404, description = "There is no VTXO to refresh", body = error::NotFoundError),
(status = 500, description = "Internal server error", body = error::InternalServerError)
),
description = "Registers all out-of-round VTXOs held by the wallet for refresh in the \
next Ark round. Refreshing replaces out-of-round VTXOs under arkoor trust \
assumptions with trustless, in-round VTXOs. Out-of-round VTXOs whose entire \
transaction chain originates from your own in-round VTXOs are excluded. The \
background daemon automatically participates in the round and progresses it \
to completion. Use the `rounds` endpoint to track progress.",
tag = "wallet"
)]
#[debug_handler]
pub async fn refresh_counterparty(
State(state): State<ServerState>,
) -> HandlerResult<Json<bark_json::web::PendingRoundInfo>> {
let wallet = state.require_wallet()?;
let filter = VtxoFilter::new(&wallet).counterparty();
let vtxos = wallet
.spendable_vtxos_with(&filter).await
.context("Failed to get VTXOs")?;
let participation = wallet
.build_refresh_participation(vtxos).await
.context("Failed to build round participation")?;
match participation {
Some(participation) => {
let mut round = wallet
.join_next_round(participation, Some(RoundMovement::Refresh)).await
.context("Failed to store round participation")?;
let sync = round.state_mut().sync(&wallet).await;
Ok(axum::Json(PendingRoundInfo::new(&round, sync)))
}
None => {
not_found!(Vec::<String>::new(), "No VTXO to refresh");
}
}
}
#[utoipa::path(
post,
path = "/offboard/vtxos",
summary = "Offboard specific VTXOs",
request_body = bark_json::web::OffboardVtxosRequest,
responses(
(status = 200, description = "Returns the offboard transaction id",
body = bark_json::cli::OffboardResult),
(status = 400, description = "No VTXO IDs provided, or one of the provided \
VTXO IDs is invalid, or destination address is invalid", body = error::BadRequestError),
(status = 404, description = "One the VTXOs wasn't found", body = error::NotFoundError),
(status = 500, description = "Internal server error")
),
description = "Cooperatively moves the specified VTXOs off the Ark protocol to an \
on-chain address. Each VTXO is offboarded in full—partial amounts are not \
supported. The on-chain transaction fee is deducted from the total, and the \
remaining amount is sent to the destination. If no address is specified, the \
wallet generates a new on-chain address. To send a specific amount on-chain, \
use `send-onchain` instead.",
tag = "wallet"
)]
#[debug_handler]
pub async fn offboard_vtxos(
State(state): State<ServerState>,
Json(body): Json<bark_json::web::OffboardVtxosRequest>,
) -> HandlerResult<Json<bark_json::cli::OffboardResult>> {
let wallet = state.require_wallet()?;
let onchain = state.require_onchain()?;
if body.vtxos.is_empty() {
badarg!("No VTXO IDs provided");
}
let address = if let Some(addr) = body.address {
let network = wallet.network().await?;
bitcoin::Address::from_str(&addr)
.badarg("invalid destination address")?
.require_network(network)
.badarg("address is not valid for configured network")?
} else {
onchain.write().await.address().await?
};
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 offboard_txid = wallet.offboard_vtxos(vtxo_ids, address).await?;
Ok(axum::Json(bark_json::cli::OffboardResult { offboard_txid }))
}
#[utoipa::path(
post,
path = "/offboard/all",
summary = "Offboard all VTXOs",
request_body = bark_json::web::OffboardAllRequest,
responses(
(status = 200, description = "Returns the offboard transaction id",
body = bark_json::cli::OffboardResult),
(status = 500, description = "Internal server error", body = error::InternalServerError)
),
description = "Cooperatively moves all spendable VTXOs off the Ark protocol to an \
on-chain address. Each VTXO is offboarded in full—partial amounts are not \
supported. The on-chain transaction fee is deducted from the total, and the \
remaining amount is sent to the destination. If no address is specified, the \
wallet generates a new on-chain address. To send a specific amount on-chain, \
use `send-onchain` instead.",
tag = "wallet"
)]
#[debug_handler]
pub async fn offboard_all(
State(state): State<ServerState>,
Json(body): Json<bark_json::web::OffboardAllRequest>,
) -> HandlerResult<Json<bark_json::cli::OffboardResult>> {
let wallet = state.require_wallet()?;
let onchain = state.require_onchain()?;
let address = if let Some(addr) = body.address {
let network = wallet.network().await?;
bitcoin::Address::from_str(&addr)
.badarg("invalid destination address")?
.require_network(network)
.badarg("address is not valid for configured network")?
} else {
onchain.write().await.address().await?
};
let offboard_txid = wallet.offboard_all(address).await?;
Ok(axum::Json(bark_json::cli::OffboardResult { offboard_txid }))
}
#[utoipa::path(
post,
path = "/send-onchain",
summary = "Send on-chain from Ark balance",
request_body = bark_json::web::SendOnchainRequest,
responses(
(status = 200, description = "Returns the offboard transaction id",
body = bark_json::cli::OffboardResult),
(status = 500, description = "Internal server error", body = error::InternalServerError)
),
description = "Sends the specified amount to an on-chain address using the wallet's \
off-chain Ark balance. The on-chain transaction fee is paid on top of the \
specified amount. Internally creates an out-of-round transaction to consolidate \
VTXOs into the exact amount needed, then cooperatively sends the on-chain \
payment via the Ark server. To offboard entire VTXOs without specifying an \
amount, use `offboard/vtxos` or `offboard/all` instead.",
tag = "wallet"
)]
#[debug_handler]
pub async fn send_onchain(
State(state): State<ServerState>,
Json(body): Json<bark_json::web::SendOnchainRequest>,
) -> HandlerResult<Json<bark_json::cli::OffboardResult>> {
let wallet = state.require_wallet()?;
let addr = bitcoin::Address::from_str(&body.destination)
.badarg("invalid destination address")?
.require_network(wallet.network().await?)
.badarg("address is not valid for configured network")?;
let amount = Amount::from_sat(body.amount_sat);
let offboard_txid = wallet.send_onchain(addr, amount).await?;
Ok(axum::Json(bark_json::cli::OffboardResult { offboard_txid }))
}
#[utoipa::path(
post,
path = "/sync",
summary = "Sync wallet",
responses(
(status = 200, description = "Wallet was successfully synced"),
),
description = "Triggers an immediate sync of the wallet's off-chain state. Updates \
on-chain fee rates, processes incoming arkoor payments, resolves outgoing and \
incoming Lightning payments, and progresses pending rounds and boards toward \
confirmation. The background daemon already runs these operations automatically \
(e.g., Lightning every second, mailbox and boards every 30 seconds), but calling \
`sync` forces all of them to run immediately.",
tag = "wallet"
)]
#[debug_handler]
pub async fn sync(State(state): State<ServerState>) -> HandlerResult<()> {
let wallet = state.require_wallet()?;
wallet.sync().await;
Ok(())
}
#[utoipa::path(
post,
path = "/sync/mailbox",
summary = "Sync mailbox only",
responses(
(status = 200, description = "Returns the mailbox tip after sync", body = bark_json::web::MailboxSyncResponse),
(status = 500, description = "Internal server error", body = error::InternalServerError)
),
description = "Triggers an immediate mailbox sync without running any of the other \
off-chain sync steps. Fetches any pending mailbox messages from the Ark server, \
processes them (incoming arkoor payments, lightning receive notifications), and \
returns the new mailbox checkpoint (tip). Useful in `daemon_manual_sync` mode \
where background mailbox subscription is disabled and the operator needs a \
granular way to pull incoming events.",
tag = "wallet"
)]
#[debug_handler]
pub async fn sync_mailbox(
State(state): State<ServerState>,
) -> HandlerResult<Json<bark_json::web::MailboxSyncResponse>> {
let wallet = state.require_wallet()?;
wallet.sync_mailbox().await
.context("failed to sync mailbox")?;
let checkpoint = wallet.get_mailbox_checkpoint().await
.context("failed to read mailbox checkpoint")?;
Ok(axum::Json(bark_json::web::MailboxSyncResponse { checkpoint }))
}
#[utoipa::path(
post,
path = "/import-vtxo",
summary = "Import a VTXO",
request_body = bark_json::web::ImportVtxoRequest,
responses(
(status = 200, description = "VTXO imported successfully", body = Vec<bark_json::primitives::WalletVtxoInfo>),
(status = 400, description = "Invalid VTXO hex or VTXO not owned by wallet", body = error::BadRequestError),
(status = 500, description = "Internal server error", body = error::InternalServerError)
),
description = "Imports hex-encoded serialized VTXOs into the wallet. Validates that \
each VTXO is anchored on-chain, owned by this wallet, and has not expired. \
Useful for restoring VTXOs after database loss or re-importing from the \
server mailbox. The operation is idempotent.",
tag = "wallet"
)]
#[debug_handler]
pub async fn import_vtxo(
State(state): State<ServerState>,
Json(body): Json<bark_json::web::ImportVtxoRequest>,
) -> HandlerResult<Json<Vec<bark_json::primitives::WalletVtxoInfo>>> {
let wallet = state.require_wallet()?;
if body.vtxos.is_empty() {
badarg!("No VTXOs provided");
}
let mut imported = Vec::with_capacity(body.vtxos.len());
for vtxo_hex in body.vtxos {
let vtxo = ark::Vtxo::deserialize_hex(&vtxo_hex).badarg("invalid vtxo hex")?;
let vtxo_id = vtxo.id();
wallet.import_vtxo(&vtxo).await.context("Failed to import VTXO")?;
let wallet_vtxo = wallet.get_vtxo_by_id(vtxo_id).await.context("Failed to get imported VTXO")?;
imported.push(wallet_vtxo.into());
}
Ok(axum::Json(imported))
}