feat(wallet): Ark protocol support via barkd sidecar
barkd (Ark wallet daemon, pinned 0.3.0, checksum-verified release binary) packaged as an installable app; thin HTTP bridge in wallet/ark_client.rs mirrors the fedimint_client pattern — the bark SDK stays out of the node binary. wallet.ark-* RPCs cover status/balance/address/send/invoice/ board/offboard/history/configure; Ark movements merge into the unified ecash history (kind="ark") and spendable Ark sats into total_sats. Signet defaults (Second's public Ark server) until Ark matures. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
//! Ark protocol RPCs — bridge to the `barkd` sidecar.
|
||||
//!
|
||||
//! Companion to the Cashu RPCs in [`super::wallet`] and the Fedimint RPCs in
|
||||
//! [`super::fedimint`]. Holding VTXOs, joining rounds and unilateral exits are
|
||||
//! delegated to the barkd container via [`crate::wallet::ark_client::ArkClient`];
|
||||
//! here we expose the node's JSON-RPC surface. barkd keeps its own movement
|
||||
//! history, so unlike Fedimint there is no local transaction log.
|
||||
|
||||
use super::RpcHandler;
|
||||
use crate::wallet::ark_client::{self, ArkClient};
|
||||
use anyhow::Result;
|
||||
|
||||
impl RpcHandler {
|
||||
/// `wallet.ark-status` — sidecar reachability, wallet fingerprint, network
|
||||
/// and Ark server parameters. Soft-fails into `available: false` so the
|
||||
/// settings UI can render an install/enable hint instead of an error.
|
||||
pub(super) async fn handle_wallet_ark_status(&self) -> Result<serde_json::Value> {
|
||||
let config = ark_client::load_config(&self.config.data_dir).await;
|
||||
let client = match ArkClient::from_node(&self.config.data_dir).await {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
return Ok(serde_json::json!({
|
||||
"available": false,
|
||||
"wallet_ready": false,
|
||||
"config": config,
|
||||
}))
|
||||
}
|
||||
};
|
||||
// Make sure the wallet exists before reporting (idempotent, cheap once
|
||||
// created).
|
||||
let _ = ark_client::ensure_wallet(&self.config.data_dir).await;
|
||||
|
||||
let wallet = client.wallet_info().await.ok();
|
||||
let info = client.ark_info().await.ok();
|
||||
Ok(serde_json::json!({
|
||||
"available": true,
|
||||
"wallet_ready": wallet.is_some(),
|
||||
"wallet": wallet,
|
||||
"ark_info": info,
|
||||
"config": config,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `wallet.ark-balance` — off-chain (spendable + pending) and on-chain
|
||||
/// sats. Soft-fails to zeros so unified balances still render.
|
||||
pub(super) async fn handle_wallet_ark_balance(&self) -> Result<serde_json::Value> {
|
||||
let client = match ArkClient::from_node(&self.config.data_dir).await {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
return Ok(serde_json::json!({
|
||||
"balance_sats": 0,
|
||||
"spendable_sats": 0,
|
||||
"pending_sats": 0,
|
||||
"onchain_sats": 0,
|
||||
}))
|
||||
}
|
||||
};
|
||||
let bal = client.balance().await.unwrap_or_else(|_| serde_json::json!({}));
|
||||
let sat = |key: &str| bal.get(key).and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let spendable = sat("spendable_sat");
|
||||
let pending = sat("pending_in_round_sat")
|
||||
+ sat("pending_board_sat")
|
||||
+ sat("pending_lightning_send_sat")
|
||||
+ sat("claimable_lightning_receive_sat")
|
||||
+ bal.get("pending_exit_sat").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let onchain = client
|
||||
.onchain_balance()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|b| {
|
||||
b.get("total_sat")
|
||||
.or_else(|| b.get("confirmed_sat"))
|
||||
.and_then(|v| v.as_u64())
|
||||
})
|
||||
.unwrap_or(0);
|
||||
Ok(serde_json::json!({
|
||||
"balance_sats": spendable,
|
||||
"spendable_sats": spendable,
|
||||
"pending_sats": pending,
|
||||
"onchain_sats": onchain,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `wallet.ark-address` — fresh Ark (`tark1…`) receive address; pass
|
||||
/// `{"onchain": true}` for an on-chain boarding address instead.
|
||||
pub(super) async fn handle_wallet_ark_address(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let _ = ark_client::ensure_wallet(&self.config.data_dir).await;
|
||||
let client = ArkClient::from_node(&self.config.data_dir).await?;
|
||||
let onchain = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("onchain"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let address = if onchain {
|
||||
client.onchain_address().await?
|
||||
} else {
|
||||
client.ark_address().await?
|
||||
};
|
||||
Ok(serde_json::json!({ "address": address, "onchain": onchain }))
|
||||
}
|
||||
|
||||
/// `wallet.ark-send` — pay an Ark address, BOLT11 invoice, LNURL or
|
||||
/// lightning address from Ark funds.
|
||||
pub(super) async fn handle_wallet_ark_send(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let destination = params
|
||||
.get("destination")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing destination"))?;
|
||||
// Optional for BOLT11 invoices that carry their own amount.
|
||||
let amount_sats = params.get("amount_sats").and_then(|v| v.as_u64());
|
||||
if amount_sats == Some(0) {
|
||||
return Err(anyhow::anyhow!("Amount must be greater than zero"));
|
||||
}
|
||||
let comment = params.get("comment").and_then(|v| v.as_str());
|
||||
|
||||
let client = ArkClient::from_node(&self.config.data_dir).await?;
|
||||
let movement = client.send(destination, amount_sats, comment).await?;
|
||||
Ok(serde_json::json!({
|
||||
"sent": true,
|
||||
"movement": movement,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `wallet.ark-invoice` — BOLT11 invoice that lands as Ark funds when paid.
|
||||
pub(super) async fn handle_wallet_ark_invoice(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let amount_sats = params
|
||||
.get("amount_sats")
|
||||
.and_then(|v| v.as_u64())
|
||||
.filter(|&v| v > 0)
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing amount_sats"))?;
|
||||
|
||||
let _ = ark_client::ensure_wallet(&self.config.data_dir).await;
|
||||
let client = ArkClient::from_node(&self.config.data_dir).await?;
|
||||
let res = client.lightning_invoice(amount_sats).await?;
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
/// `wallet.ark-board` — lift on-chain funds into Ark VTXOs. Omitting
|
||||
/// `amount_sats` boards everything.
|
||||
pub(super) async fn handle_wallet_ark_board(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let amount_sats = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("amount_sats"))
|
||||
.and_then(|v| v.as_u64());
|
||||
if amount_sats == Some(0) {
|
||||
return Err(anyhow::anyhow!("Amount must be greater than zero"));
|
||||
}
|
||||
let client = ArkClient::from_node(&self.config.data_dir).await?;
|
||||
let res = client.board(amount_sats).await?;
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
/// `wallet.ark-offboard` — collaboratively move all VTXOs back on-chain,
|
||||
/// optionally to a provided address (defaults to the wallet's own).
|
||||
pub(super) async fn handle_wallet_ark_offboard(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let address = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("address"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty());
|
||||
let client = ArkClient::from_node(&self.config.data_dir).await?;
|
||||
let res = client.offboard_all(address).await?;
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
/// `wallet.ark-history` — barkd movements mapped to the unified
|
||||
/// transaction shape (kind = "ark"), newest first.
|
||||
pub(super) async fn handle_wallet_ark_history(&self) -> Result<serde_json::Value> {
|
||||
let mut transactions = ark_client::load_ark_txs(&self.config.data_dir).await;
|
||||
transactions.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
|
||||
Ok(serde_json::json!({ "transactions": transactions }))
|
||||
}
|
||||
|
||||
/// `wallet.ark-configure` — set the Ark server / esplora / network used
|
||||
/// when the barkd wallet is (re)created. Does NOT migrate an existing
|
||||
/// wallet: barkd binds a wallet to its Ark server at creation.
|
||||
pub(super) async fn handle_wallet_ark_configure(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let mut config = ark_client::load_config(&self.config.data_dir).await;
|
||||
for (key, field) in [
|
||||
("network", &mut config.network as &mut String),
|
||||
("ark_server", &mut config.ark_server),
|
||||
("esplora", &mut config.esplora),
|
||||
] {
|
||||
if let Some(v) = params.get(key).and_then(|v| v.as_str()) {
|
||||
let v = v.trim();
|
||||
if !v.is_empty() {
|
||||
*field = v.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
if !matches!(config.network.as_str(), "signet" | "mainnet" | "regtest") {
|
||||
return Err(anyhow::anyhow!(
|
||||
"network must be one of: signet, mainnet, regtest"
|
||||
));
|
||||
}
|
||||
ark_client::save_config(&self.config.data_dir, &config).await?;
|
||||
Ok(serde_json::json!({ "config": config }))
|
||||
}
|
||||
}
|
||||
@@ -258,6 +258,17 @@ impl RpcHandler {
|
||||
"wallet.fedimint-leave" => self.handle_wallet_fedimint_leave(params).await,
|
||||
"wallet.fedimint-balance" => self.handle_wallet_fedimint_balance().await,
|
||||
|
||||
// Ark protocol (via barkd sidecar)
|
||||
"wallet.ark-status" => self.handle_wallet_ark_status().await,
|
||||
"wallet.ark-balance" => self.handle_wallet_ark_balance().await,
|
||||
"wallet.ark-address" => self.handle_wallet_ark_address(params).await,
|
||||
"wallet.ark-send" => self.handle_wallet_ark_send(params).await,
|
||||
"wallet.ark-invoice" => self.handle_wallet_ark_invoice(params).await,
|
||||
"wallet.ark-board" => self.handle_wallet_ark_board(params).await,
|
||||
"wallet.ark-offboard" => self.handle_wallet_ark_offboard(params).await,
|
||||
"wallet.ark-history" => self.handle_wallet_ark_history().await,
|
||||
"wallet.ark-configure" => self.handle_wallet_ark_configure(params).await,
|
||||
|
||||
// Container registries
|
||||
"registry.list" => self.handle_registry_list().await,
|
||||
"registry.add" => self.handle_registry_add(params).await,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod analytics;
|
||||
mod ark;
|
||||
mod auth;
|
||||
mod backup_rpc;
|
||||
mod bitcoin;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::RpcHandler;
|
||||
use crate::wallet::{ecash, fedimint_client, profits};
|
||||
use crate::wallet::{ark_client, ecash, fedimint_client, profits};
|
||||
use anyhow::Result;
|
||||
|
||||
/// A Cashu token (NUT-00 `cashuA`/`cashuB`, or our legacy `cashuSend_` form)
|
||||
@@ -21,13 +21,16 @@ impl RpcHandler {
|
||||
Ok(client) => client.total_balance_sats().await.unwrap_or(0),
|
||||
Err(_) => 0,
|
||||
};
|
||||
// Spendable Ark (barkd) balance, same best-effort contract.
|
||||
let ark_sats = ark_client::spendable_sats_or_zero(&self.config.data_dir).await;
|
||||
Ok(serde_json::json!({
|
||||
// `balance_sats` stays Cashu-only for back-compat; `total_sats` is the
|
||||
// spendable amount across Cashu + Fedimint.
|
||||
// spendable amount across Cashu + Fedimint + Ark.
|
||||
"balance_sats": cashu_sats,
|
||||
"cashu_sats": cashu_sats,
|
||||
"fedimint_sats": fedimint_sats,
|
||||
"total_sats": cashu_sats + fedimint_sats,
|
||||
"ark_sats": ark_sats,
|
||||
"total_sats": cashu_sats + fedimint_sats + ark_sats,
|
||||
"proof_count": wallet.proofs.iter().filter(|p| !p.spent && !p.reserved).count(),
|
||||
"mint_url": wallet.mint_url,
|
||||
}))
|
||||
@@ -181,6 +184,8 @@ impl RpcHandler {
|
||||
let wallet = ecash::load_wallet(&self.config.data_dir).await?;
|
||||
let mut transactions = wallet.transactions;
|
||||
transactions.extend(fedimint_client::load_fedimint_txs(&self.config.data_dir).await);
|
||||
// Ark movements from barkd (kind="ark"), best-effort like Fedimint.
|
||||
transactions.extend(ark_client::load_ark_txs(&self.config.data_dir).await);
|
||||
// Sort by RFC-3339 timestamp descending (string compare is valid for
|
||||
// same-offset RFC-3339), newest first.
|
||||
transactions.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
|
||||
|
||||
Reference in New Issue
Block a user