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:
Dorian 2026-07-14 21:56:21 +01:00
parent 621636492b
commit bdb9826aba
14 changed files with 881 additions and 3 deletions

View File

@ -298,6 +298,25 @@
]
}
},
{
"id": "barkd",
"title": "Ark Wallet",
"version": "0.3.0",
"description": "Ark protocol wallet daemon (barkd). Lets the node hold self-custodial off-chain bitcoin via an Ark server; the wallet talks to it over a local REST API. Signet by default while Ark matures.",
"icon": "/assets/img/app-icons/bark.png",
"author": "Second",
"category": "money",
"dockerImage": "146.59.87.168:3000/lfg2025/barkd:0.3.0",
"repoUrl": "https://gitlab.com/ark-bitcoin/bark",
"containerConfig": {
"ports": [
"3535:3535"
],
"volumes": [
"/var/lib/archipelago/barkd:/data"
]
}
},
{
"id": "jellyfin",
"title": "Jellyfin",

32
apps/barkd/Dockerfile Normal file
View File

@ -0,0 +1,32 @@
# barkd — Ark protocol wallet daemon (https://gitlab.com/ark-bitcoin/bark).
# No official upstream image exists (their GitLab registry is empty), so we
# package the pinned, checksum-verified release binary ourselves and push to
# the node registry — same approach as fmcd. Keep the version in lockstep with
# the REST shapes coded in core/archipelago/src/wallet/ark_client.rs (0.3.0).
FROM debian:bookworm-slim
ARG BARKD_VERSION=0.3.0
ARG BARKD_SHA256=8562fa27386bae666ed62fa95c92d40f7bdb20d22525f75799adfc16adaaedb3
RUN apt-get update && \
apt-get install -y --no-install-recommends ca-certificates curl && \
curl -fsSL "https://gitlab.com/api/v4/projects/ark-bitcoin%2Fbark/packages/generic/release-assets/bark-${BARKD_VERSION}/barkd-${BARKD_VERSION}-linux-x86_64" \
-o /usr/local/bin/barkd && \
echo "${BARKD_SHA256} /usr/local/bin/barkd" | sha256sum -c - && \
chmod a+x /usr/local/bin/barkd && \
apt-get purge -y curl && apt-get autoremove -y && \
apt-get clean && rm -rf /var/lib/apt/lists/*
COPY entrypoint.sh /entrypoint.sh
RUN chmod a+x /entrypoint.sh
# The wallet itself is created over REST by the node's Ark bridge
# (wallet.ark-* RPCs) — the container just runs the daemon.
ENV BARKD_DATADIR=/data \
BARKD_BIND_HOST=0.0.0.0 \
BARKD_BIND_PORT=3535
EXPOSE 3535
VOLUME /data
ENTRYPOINT ["/entrypoint.sh"]

15
apps/barkd/entrypoint.sh Normal file
View File

@ -0,0 +1,15 @@
#!/bin/sh
# Install the node-provided auth secret (64-char hex from the manifest's
# generated barkd-secret) so the wallet bridge can derive the matching Bearer
# token, then start the daemon. Without BARKD_SECRET, barkd generates its own
# random token in the datadir and the bridge won't authenticate — so treat a
# failed refresh as fatal rather than starting an unreachable daemon.
set -eu
if [ -n "${BARKD_SECRET:-}" ]; then
# `secret refresh` prints the Bearer token on stdout — never log it.
barkd secret refresh --secret "$BARKD_SECRET" >/dev/null
unset BARKD_SECRET
fi
exec barkd

76
apps/barkd/manifest.yml Normal file
View File

@ -0,0 +1,76 @@
app:
id: barkd
name: Ark Wallet
version: 0.3.0
description: Ark protocol wallet daemon (barkd). Lets the node hold self-custodial off-chain bitcoin via an Ark server; the wallet talks to it over a local REST API. Signet by default while Ark matures.
container:
# barkd packaged from the pinned upstream release binary (no usable
# upstream image exists — their registry is empty). Built from
# apps/barkd/Dockerfile and pushed to the node registry. Pin the tag to
# match the REST shapes coded in core/archipelago/src/wallet/ark_client.rs
# (validated against barkd 0.3.0 on signet, 2026-07-14).
image: 146.59.87.168:3000/lfg2025/barkd:0.3.0
pull_policy: if-not-present
network: archy-net
# The entrypoint installs the shared secret below via `barkd secret
# refresh` (so the wallet bridge can derive the matching Bearer token) and
# execs the daemon. The Ark wallet itself is created over REST by the
# bridge on first use (wallet.ark-* RPCs) with the node's ark_config
# (default: Second's public signet server) — no host provisioning needed.
generated_secrets:
- name: barkd-secret
kind: hex32
secret_env:
- key: BARKD_SECRET
secret_file: barkd-secret
data_uid: "1000:1000"
dependencies:
- storage: 1Gi
resources:
# barkd is a single wallet daemon (SQLite + a gRPC conn to the Ark server
# + esplora polling); steady state is tiny. Cap it so a stuck sync can't
# starve the node.
cpu_limit: 1
memory_limit: 512Mi
disk_limit: 1Gi
security:
readonly_root: true
# Needs outbound HTTPS to the Ark server (ark.signet.2nd.dev) and the
# esplora chain source, plus the published REST port for the wallet
# bridge. No inbound requirements beyond that.
network_policy: bridge
ports:
# barkd REST bound to 3535 in-container (BARKD_BIND_PORT); 3535 is free on
# the host (see port_allocator.rs). The Rust bridge targets
# http://127.0.0.1:3535.
- host: 3535
container: 3535
protocol: tcp
volumes:
# Holds the wallet DB, mnemonic and auth token. ARK funds are recoverable
# on-chain from this datadir (unilateral exit) — include it in backups.
- type: bind
source: /var/lib/archipelago/barkd
target: /data
options: [rw]
environment:
- BARKD_DATADIR=/data
- BARKD_BIND_HOST=0.0.0.0
- BARKD_BIND_PORT=3535
# All /api/v1/* routes require the Bearer token, so an HTTP probe would 401
# forever — use a TCP probe like fmcd (the host-side lifecycle layer
# verifies reachability).
health_check:
type: tcp
endpoint: localhost:3535
interval: 30s
timeout: 5s
retries: 3

View File

@ -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 }))
}
}

View File

@ -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,

View File

@ -1,4 +1,5 @@
mod analytics;
mod ark;
mod auth;
mod backup_rpc;
mod bitcoin;

View File

@ -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));

View File

@ -372,6 +372,13 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
repo: "https://github.com/minmoto/fmcd".to_string(),
tier: "",
},
"barkd" | "bark" => AppMetadata {
title: "Ark Wallet".to_string(),
description: "Ark protocol wallet daemon (barkd) — self-custodial off-chain bitcoin via an Ark server (signet)".to_string(),
icon: "/assets/img/app-icons/bark.png".to_string(),
repo: "https://gitlab.com/ark-bitcoin/bark".to_string(),
tier: "",
},
"morphos" | "morphos-server" => AppMetadata {
title: "Morphos".to_string(),
description: "Self-hosted file converter".to_string(),

View File

@ -19,6 +19,7 @@ const RESERVED_PORTS: &[u16] = &[
23000, // BTCPay
8173, 8174, 8175, // Fedimint
8178, // Fedimint client daemon (fedimint-clientd REST)
3535, // Ark wallet daemon (barkd REST)
8123, // Home Assistant
3000, // Grafana
11434, // Ollama

View File

@ -1590,6 +1590,7 @@ fn fallback_package_port(app_id: &str) -> Option<u16> {
match app_id {
"fedimint" | "fedimintd" => Some(8175),
"fedimint-clientd" => Some(8178),
"barkd" => Some(3535),
"filebrowser" => Some(8083),
"indeedhub" => Some(7778),
"nginx-proxy-manager" => Some(8081),

View File

@ -0,0 +1,479 @@
//! Thin HTTP bridge to the `barkd` sidecar container (Ark protocol).
//!
//! Same shape as [`super::fedimint_client`]: the heavy `bark-wallet` SDK stays
//! OUT of this binary. The `barkd` daemon (in `apps/barkd`) holds the Ark
//! wallet (VTXOs, rounds, unilateral exits) and we speak its REST API
//! (`/api/v1/*`, Bearer auth). Endpoint/JSON shapes target barkd 0.3.0 and
//! must be pinned to the vendored image tag.
//!
//! ARK is on-chain-anchored: VTXOs expire (`vtxo_expiry_delta` blocks) and the
//! barkd daemon refreshes them by joining rounds on its own — the bridge never
//! has to schedule anything. Unlike Cashu/Fedimint, funds survive the sidecar
//! dying (the wallet mnemonic in barkd's datadir can unilaterally exit
//! on-chain), so back up `/var/lib/archipelago/barkd`.
use anyhow::{Context, Result};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use serde::{Deserialize, Serialize};
use std::path::Path;
use tokio::fs;
const BARKD_TIMEOUT_SECS: u64 = 15;
/// Send/board/offboard can wait on Ark round participation (signet rounds run
/// every 5 minutes), so give mutating calls generous room.
const BARKD_HEAVY_TIMEOUT_SECS: u64 = 120;
/// Default host port the `barkd` container is mapped to (its in-container
/// REST port; 3535 is unused elsewhere on the node — see `port_allocator`).
const DEFAULT_BARKD_URL: &str = "http://127.0.0.1:3535";
/// Shared secret between the barkd container and this bridge. The barkd
/// manifest generates it via `generated_secrets: [{barkd-secret, hex32}]`; the
/// container entrypoint installs it with `barkd secret refresh --secret` and
/// the bridge derives the matching Bearer token from the same file.
const BARKD_SECRET: &str = "barkd-secret";
/// Wallet configuration used when the bridge has to create the barkd wallet
/// (first use). Persisted so operators can point at their own Ark server.
/// Defaults target Second's public signet deployment while Ark matures —
/// mainnet needs an explicit opt-in edit of `wallet/ark_config.json`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArkConfig {
pub network: String,
pub ark_server: String,
pub esplora: String,
}
impl Default for ArkConfig {
fn default() -> Self {
Self {
network: "signet".to_string(),
ark_server: "https://ark.signet.2nd.dev".to_string(),
esplora: "https://esplora.signet.2nd.dev".to_string(),
}
}
}
const ARK_CONFIG_FILE: &str = "wallet/ark_config.json";
pub async fn load_config(data_dir: &Path) -> ArkConfig {
match fs::read_to_string(data_dir.join(ARK_CONFIG_FILE)).await {
Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
Err(_) => ArkConfig::default(),
}
}
pub async fn save_config(data_dir: &Path, config: &ArkConfig) -> Result<()> {
let dir = data_dir.join("wallet");
fs::create_dir_all(&dir)
.await
.context("Failed to create wallet dir")?;
let content = serde_json::to_string_pretty(config).context("Failed to serialize ark config")?;
fs::write(data_dir.join(ARK_CONFIG_FILE), content)
.await
.context("Failed to write ark config")?;
Ok(())
}
/// Encode barkd's Bearer token from the raw 32-byte shared secret:
/// base64url-nopad of `<version 0x00><32-byte secret>` (see barkd `AuthToken`).
fn encode_auth_token(secret: &[u8; 32]) -> String {
let mut buf = Vec::with_capacity(33);
buf.push(0u8);
buf.extend_from_slice(secret);
URL_SAFE_NO_PAD.encode(&buf)
}
fn secret_hex_to_token(hex: &str) -> Result<String> {
let hex = hex.trim();
if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
anyhow::bail!("barkd-secret must be exactly 64 hex characters");
}
let mut secret = [0u8; 32];
for (i, byte) in secret.iter_mut().enumerate() {
*byte = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).expect("validated hex");
}
Ok(encode_auth_token(&secret))
}
/// HTTP client for a `barkd` instance.
pub struct ArkClient {
base_url: String,
token: String,
client: reqwest::Client,
}
impl ArkClient {
pub fn new(base_url: &str, token: &str) -> Result<Self> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(BARKD_HEAVY_TIMEOUT_SECS))
.build()
.context("Failed to build HTTP client for barkd")?;
Ok(Self {
base_url: base_url.trim_end_matches('/').to_string(),
token: token.to_string(),
client,
})
}
/// Resolve URL + auth token from env / node secret, with sane defaults.
/// URL: `BARKD_URL` else the default mapped port. Token: `BARKD_TOKEN`
/// (already-encoded Bearer token) else derived from the shared
/// `barkd-secret` the manifest generated for the container.
pub async fn from_node(data_dir: &Path) -> Result<Self> {
let base_url = std::env::var("BARKD_URL").unwrap_or_else(|_| DEFAULT_BARKD_URL.to_string());
let token = match std::env::var("BARKD_TOKEN") {
Ok(t) if !t.is_empty() => t,
_ => {
let path = data_dir.join("secrets").join(BARKD_SECRET);
let hex = fs::read_to_string(&path).await.context(
"Ark wallet not configured (no BARKD_TOKEN and no barkd-secret \
secret). Install the Ark (barkd) app.",
)?;
secret_hex_to_token(&hex)?
}
};
Self::new(&base_url, &token)
}
fn auth(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
req.bearer_auth(&self.token)
}
async fn get(&self, path: &str) -> Result<serde_json::Value> {
let url = format!("{}{}", self.base_url, path);
let resp = self
.auth(self.client.get(&url))
.timeout(std::time::Duration::from_secs(BARKD_TIMEOUT_SECS))
.send()
.await
.with_context(|| format!("barkd GET {path} failed (is it running?)"))?;
Self::parse(resp, path).await
}
async fn post(&self, path: &str, body: serde_json::Value) -> Result<serde_json::Value> {
let url = format!("{}{}", self.base_url, path);
let resp = self
.auth(self.client.post(&url))
.json(&body)
.send()
.await
.with_context(|| format!("barkd POST {path} failed (is it running?)"))?;
Self::parse(resp, path).await
}
async fn parse(resp: reqwest::Response, path: &str) -> Result<serde_json::Value> {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if !status.is_success() {
// barkd errors are `{"message": "..."}`; surface the message.
let msg = serde_json::from_str::<serde_json::Value>(&text)
.ok()
.and_then(|v| v.get("message").and_then(|m| m.as_str()).map(String::from))
.unwrap_or(text);
anyhow::bail!("barkd {path} returned {status}: {msg}");
}
if text.is_empty() {
return Ok(serde_json::json!({}));
}
serde_json::from_str(&text)
.with_context(|| format!("barkd {path} returned non-JSON: {text}"))
}
/// `GET /api/v1/wallet` — wallet info (fingerprint, network, config).
/// Errors with "No wallet set" until `create_wallet` has run.
pub async fn wallet_info(&self) -> Result<serde_json::Value> {
self.get("/api/v1/wallet").await
}
/// `POST /api/v1/wallet/create` — create (or restore, with a mnemonic) the
/// barkd wallet. Idempotent guard is on the caller (`ensure_wallet`).
pub async fn create_wallet(&self, config: &ArkConfig) -> Result<serde_json::Value> {
self.post(
"/api/v1/wallet/create",
serde_json::json!({
"network": config.network,
"ark_server": config.ark_server,
"chain_source": { "esplora": { "url": config.esplora } },
}),
)
.await
}
/// `GET /api/v1/wallet/balance` — off-chain balance breakdown, in sats.
pub async fn balance(&self) -> Result<serde_json::Value> {
self.get("/api/v1/wallet/balance").await
}
/// Spendable off-chain sats (0 on any missing field, never an error once
/// the call itself succeeds).
pub async fn spendable_sats(&self) -> Result<u64> {
let bal = self.balance().await?;
Ok(bal.get("spendable_sat").and_then(|v| v.as_u64()).unwrap_or(0))
}
/// `GET /api/v1/onchain/balance` — the wallet's on-chain (boarding) funds.
pub async fn onchain_balance(&self) -> Result<serde_json::Value> {
self.get("/api/v1/onchain/balance").await
}
/// `POST /api/v1/wallet/addresses/next` — fresh Ark (`tark1…`) address.
pub async fn ark_address(&self) -> Result<String> {
let res = self.post("/api/v1/wallet/addresses/next", serde_json::json!({})).await?;
res.get("address")
.and_then(|v| v.as_str())
.map(String::from)
.ok_or_else(|| anyhow::anyhow!("barkd address: no address in response"))
}
/// `POST /api/v1/onchain/addresses/next` — fresh on-chain boarding address.
pub async fn onchain_address(&self) -> Result<String> {
let res = self.post("/api/v1/onchain/addresses/next", serde_json::json!({})).await?;
res.get("address")
.and_then(|v| v.as_str())
.map(String::from)
.ok_or_else(|| anyhow::anyhow!("barkd onchain address: no address in response"))
}
/// `POST /api/v1/wallet/send` — pay an Ark address, BOLT11 invoice, LNURL
/// or lightning address from off-chain funds. Returns the movement barkd
/// reports for the payment.
pub async fn send(
&self,
destination: &str,
amount_sats: Option<u64>,
comment: Option<&str>,
) -> Result<serde_json::Value> {
self.post(
"/api/v1/wallet/send",
serde_json::json!({
"destination": destination,
"amount_sat": amount_sats,
"comment": comment,
}),
)
.await
}
/// `POST /api/v1/lightning/receives/invoice` — BOLT11 invoice that lands
/// as an Ark VTXO when paid.
pub async fn lightning_invoice(&self, amount_sats: u64) -> Result<serde_json::Value> {
self.post(
"/api/v1/lightning/receives/invoice",
serde_json::json!({ "amount_sat": amount_sats }),
)
.await
}
/// `POST /api/v1/boards/board-amount` (or `board-all` when `amount_sats`
/// is None) — lift on-chain funds into Ark VTXOs.
pub async fn board(&self, amount_sats: Option<u64>) -> Result<serde_json::Value> {
match amount_sats {
Some(sats) => {
self.post(
"/api/v1/boards/board-amount",
serde_json::json!({ "amount_sat": sats }),
)
.await
}
None => self.post("/api/v1/boards/board-all", serde_json::json!({})).await,
}
}
/// `POST /api/v1/wallet/offboard/all` — move all VTXOs back on-chain via a
/// collaborative round.
pub async fn offboard_all(&self, address: Option<&str>) -> Result<serde_json::Value> {
self.post(
"/api/v1/wallet/offboard/all",
serde_json::json!({ "address": address }),
)
.await
}
/// `GET /api/v1/wallet/movements` — barkd's own movement history. This is
/// authoritative (includes receives we never initiated), so unlike the
/// Fedimint bridge there is no local tx log to maintain.
pub async fn movements(&self) -> Result<Vec<serde_json::Value>> {
let res = self.get("/api/v1/wallet/movements").await?;
Ok(res.as_array().cloned().unwrap_or_default())
}
/// `GET /api/v1/wallet/ark-info` — connected Ark server parameters.
pub async fn ark_info(&self) -> Result<serde_json::Value> {
self.get("/api/v1/wallet/ark-info").await
}
}
/// Idempotently make sure barkd has a wallet, creating one with the node's
/// Ark config on first use. Best-effort no-op when the sidecar isn't
/// installed/running yet — mirrors `fedimint_client::ensure_default_federation`.
pub async fn ensure_wallet(data_dir: &Path) -> Result<()> {
let client = match ArkClient::from_node(data_dir).await {
Ok(c) => c,
Err(_) => return Ok(()), // barkd not configured yet
};
if client.wallet_info().await.is_ok() {
return Ok(());
}
let config = load_config(data_dir).await;
match client.create_wallet(&config).await {
Ok(_) => {
tracing::info!(
"created barkd Ark wallet ({} via {})",
config.network,
config.ark_server
);
// Persist the effective config so the settings UI shows what the
// wallet was actually created with.
let _ = save_config(data_dir, &config).await;
}
Err(e) => tracing::debug!("barkd wallet auto-create skipped: {e}"),
}
Ok(())
}
/// Total spendable Ark sats, soft-failing to 0 when the sidecar is not
/// installed or unreachable so unified balances still render.
pub async fn spendable_sats_or_zero(data_dir: &Path) -> u64 {
match ArkClient::from_node(data_dir).await {
Ok(client) => client.spendable_sats().await.unwrap_or(0),
Err(_) => 0,
}
}
/// Map barkd movements into unified [`EcashTransaction`] history entries
/// (kind = "ark"). Best-effort: empty on any error, never blocks history.
pub async fn load_ark_txs(data_dir: &Path) -> Vec<crate::wallet::ecash::EcashTransaction> {
let client = match ArkClient::from_node(data_dir).await {
Ok(c) => c,
Err(_) => return Vec::new(),
};
let movements = match client.movements().await {
Ok(m) => m,
Err(_) => return Vec::new(),
};
movements
.iter()
.filter_map(movement_to_tx)
.collect()
}
/// Convert one barkd `Movement` into an [`EcashTransaction`]. `None` for
/// zero-delta movements (e.g. internal refreshes) so history stays meaningful.
fn movement_to_tx(m: &serde_json::Value) -> Option<crate::wallet::ecash::EcashTransaction> {
use crate::wallet::ecash::{EcashTransaction, TransactionType};
let delta = m.get("effective_balance_sat").and_then(|v| v.as_i64())?;
if delta == 0 {
return None;
}
let tx_type = if delta < 0 {
TransactionType::Send
} else {
TransactionType::Receive
};
// `time` holds created/updated/completed; prefer the completion time.
let timestamp = m
.get("time")
.and_then(|t| {
t.get("completed_at")
.or_else(|| t.get("updated_at"))
.or_else(|| t.get("created_at"))
})
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
// Describe via the recipient list (send) or receive source when present.
let peer = m
.get("sent_to")
.or_else(|| m.get("received_on"))
.and_then(|v| v.as_array())
.and_then(|a| a.first())
.and_then(|d| {
d.get("destination")
.or_else(|| d.get("address"))
.or_else(|| d.get("invoice"))
.and_then(|v| v.as_str())
})
.unwrap_or_default()
.to_string();
let subsystem = m
.get("subsystem")
.map(|s| match s {
serde_json::Value::String(v) => v.clone(),
other => other
.as_object()
.and_then(|o| o.keys().next().cloned())
.unwrap_or_default(),
})
.unwrap_or_default();
let description = if delta < 0 {
format!("Sent via Ark{}", suffix(&subsystem))
} else {
format!("Received via Ark{}", suffix(&subsystem))
};
Some(EcashTransaction {
id: format!("ark-{}", m.get("id").and_then(|v| v.as_u64()).unwrap_or(0)),
tx_type,
amount_sats: delta.unsigned_abs(),
timestamp,
description,
mint_url: String::new(),
peer,
kind: "ark".to_string(),
})
}
fn suffix(subsystem: &str) -> String {
if subsystem.is_empty() {
String::new()
} else {
format!(" ({subsystem})")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn token_encoding_matches_barkd_format() {
// barkd token = base64url-nopad(0x00 || secret); 33 bytes -> 44 chars.
let token = secret_hex_to_token(&"ab".repeat(32)).unwrap();
assert_eq!(token.len(), 44);
let bytes = URL_SAFE_NO_PAD.decode(&token).unwrap();
assert_eq!(bytes.len(), 33);
assert_eq!(bytes[0], 0);
assert_eq!(&bytes[1..], &[0xabu8; 32]);
}
#[test]
fn token_rejects_bad_secret() {
assert!(secret_hex_to_token("deadbeef").is_err(), "too short");
assert!(secret_hex_to_token(&"zz".repeat(32)).is_err(), "not hex");
}
#[test]
fn movement_maps_to_history_entry() {
let m = serde_json::json!({
"id": 7,
"effective_balance_sat": -1500,
"time": { "completed_at": "2026-07-14T12:00:00Z" },
"sent_to": [{ "destination": "tark1abc" }],
"subsystem": "arkoor",
});
let tx = movement_to_tx(&m).expect("mapped");
assert_eq!(tx.amount_sats, 1500);
assert_eq!(tx.kind, "ark");
assert_eq!(tx.peer, "tark1abc");
assert!(matches!(
tx.tx_type,
crate::wallet::ecash::TransactionType::Send
));
// Zero-delta refresh movements are dropped.
let refresh = serde_json::json!({ "id": 8, "effective_balance_sat": 0 });
assert!(movement_to_tx(&refresh).is_none());
}
}

View File

@ -1,6 +1,7 @@
// WIP Cashu/ecash wallet — many helpers defined for future callers.
#![allow(dead_code)]
pub mod ark_client;
pub mod bdhke;
pub mod cashu;
pub mod ecash;

View File

@ -64,6 +64,13 @@ FEDIMINT_GATEWAY_IMAGE="$ARCHY_REGISTRY/gatewayd:v0.10.0"
# federation first (the interim default is node-local). See docs/dual-ecash-design.md.
FMCD_IMAGE="$ARCHY_REGISTRY/fmcd:0.8.1"
# Ark (bark)
# barkd = Ark wallet daemon, packaged from the pinned upstream release binary
# (apps/barkd/Dockerfile). Signet-only default config; keep the tag in
# lockstep with core/archipelago/src/wallet/ark_client.rs REST shapes. Not in
# the bundled CONTAINER_IMAGES list — install via the barkd app manifest.
BARKD_IMAGE="$ARCHY_REGISTRY/barkd:0.3.0"
# Media
REDIS_IMAGE="$ARCHY_REGISTRY/redis:7.4.8"