diff --git a/app-catalog/catalog.json b/app-catalog/catalog.json
index a3c3aaa3..ca1b6a22 100644
--- a/app-catalog/catalog.json
+++ b/app-catalog/catalog.json
@@ -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",
diff --git a/apps/barkd/Dockerfile b/apps/barkd/Dockerfile
new file mode 100644
index 00000000..6421fbd9
--- /dev/null
+++ b/apps/barkd/Dockerfile
@@ -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"]
diff --git a/apps/barkd/entrypoint.sh b/apps/barkd/entrypoint.sh
new file mode 100644
index 00000000..1f8481cd
--- /dev/null
+++ b/apps/barkd/entrypoint.sh
@@ -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
diff --git a/apps/barkd/manifest.yml b/apps/barkd/manifest.yml
new file mode 100644
index 00000000..5ba3d40d
--- /dev/null
+++ b/apps/barkd/manifest.yml
@@ -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
diff --git a/core/archipelago/src/api/rpc/ark.rs b/core/archipelago/src/api/rpc/ark.rs
new file mode 100644
index 00000000..27209f3f
--- /dev/null
+++ b/core/archipelago/src/api/rpc/ark.rs
@@ -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 {
+ 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 {
+ 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,
+ ) -> Result {
+ 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,
+ ) -> Result {
+ 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,
+ ) -> Result {
+ 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,
+ ) -> Result {
+ 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,
+ ) -> Result {
+ 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 {
+ 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,
+ ) -> Result {
+ 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 }))
+ }
+}
diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs
index 3b96d200..8396e501 100644
--- a/core/archipelago/src/api/rpc/dispatcher.rs
+++ b/core/archipelago/src/api/rpc/dispatcher.rs
@@ -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,
diff --git a/core/archipelago/src/api/rpc/mod.rs b/core/archipelago/src/api/rpc/mod.rs
index 3829072c..c86c564f 100644
--- a/core/archipelago/src/api/rpc/mod.rs
+++ b/core/archipelago/src/api/rpc/mod.rs
@@ -1,4 +1,5 @@
mod analytics;
+mod ark;
mod auth;
mod backup_rpc;
mod bitcoin;
diff --git a/core/archipelago/src/api/rpc/wallet.rs b/core/archipelago/src/api/rpc/wallet.rs
index d1d5217a..67e9c379 100644
--- a/core/archipelago/src/api/rpc/wallet.rs
+++ b/core/archipelago/src/api/rpc/wallet.rs
@@ -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));
diff --git a/core/archipelago/src/container/docker_packages.rs b/core/archipelago/src/container/docker_packages.rs
index 5cd5f7cb..374af211 100644
--- a/core/archipelago/src/container/docker_packages.rs
+++ b/core/archipelago/src/container/docker_packages.rs
@@ -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(),
diff --git a/core/archipelago/src/port_allocator.rs b/core/archipelago/src/port_allocator.rs
index 5075ff3e..dd667f76 100644
--- a/core/archipelago/src/port_allocator.rs
+++ b/core/archipelago/src/port_allocator.rs
@@ -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
diff --git a/core/archipelago/src/server.rs b/core/archipelago/src/server.rs
index 35ca03f5..9c5dd1d0 100644
--- a/core/archipelago/src/server.rs
+++ b/core/archipelago/src/server.rs
@@ -1597,6 +1597,7 @@ fn fallback_package_port(app_id: &str) -> Option {
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),
diff --git a/core/archipelago/src/wallet/ark_client.rs b/core/archipelago/src/wallet/ark_client.rs
new file mode 100644
index 00000000..9aa4ae4a
--- /dev/null
+++ b/core/archipelago/src/wallet/ark_client.rs
@@ -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 `<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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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::(&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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ self.get("/api/v1/onchain/balance").await
+ }
+
+ /// `POST /api/v1/wallet/addresses/next` — fresh Ark (`tark1…`) address.
+ pub async fn ark_address(&self) -> Result {
+ 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 {
+ 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,
+ comment: Option<&str>,
+ ) -> Result {
+ 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 {
+ 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) -> Result {
+ 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 {
+ 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> {
+ 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 {
+ 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 {
+ 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 {
+ 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());
+ }
+}
diff --git a/core/archipelago/src/wallet/mod.rs b/core/archipelago/src/wallet/mod.rs
index 098543f9..9f7f92f5 100644
--- a/core/archipelago/src/wallet/mod.rs
+++ b/core/archipelago/src/wallet/mod.rs
@@ -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;
diff --git a/neode-ui/mock-backend.js b/neode-ui/mock-backend.js
index b36d5f99..63f13aed 100755
--- a/neode-ui/mock-backend.js
+++ b/neode-ui/mock-backend.js
@@ -132,6 +132,11 @@ const SEED_WALLET = {
channel_sats: 8_250_000,
ecash_sats: 250_000,
ecash_tokens: 12,
+ // Ark (barkd sidecar) demo balances — signet defaults
+ ark_sats: 75_000,
+ ark_pending_sats: 0,
+ ark_onchain_sats: 20_000,
+ ark_config: { network: 'signet', ark_server: 'https://ark.signet.2nd.dev', esplora: 'https://esplora.signet.2nd.dev' },
block_height: 892451,
transactions: [
{ tx_hash: 'ab12cd34ef5678901234567890abcdef12345678', amount_sats: 2_000_000, direction: 'incoming', num_confirmations: 142, block_height: 892310, time_stamp: Math.floor(Date.now()/1000) - 86400, label: 'Channel funding', total_fees: 0, dest_addresses: [] },
@@ -2251,6 +2256,63 @@ app.post('/rpc/v1', (req, res) => {
const total = (mockState.federations || []).reduce((s, f) => s + (f.balance_sats || 0), 0)
return res.json({ result: { balance_sats: total } })
}
+ case 'wallet.ark-status': {
+ return res.json({
+ result: {
+ available: true,
+ wallet_ready: true,
+ wallet: { network: walletState.ark_config.network },
+ ark_info: { server: walletState.ark_config.ark_server },
+ config: walletState.ark_config,
+ },
+ })
+ }
+ case 'wallet.ark-balance': {
+ return res.json({
+ result: {
+ balance_sats: walletState.ark_sats,
+ spendable_sats: walletState.ark_sats,
+ pending_sats: walletState.ark_pending_sats,
+ onchain_sats: walletState.ark_onchain_sats,
+ },
+ })
+ }
+ case 'wallet.ark-address': {
+ const onchain = params?.onchain === true
+ const address = onchain ? 'tb1q' + randomHex(16) : 'tark1' + randomHex(28)
+ return res.json({ result: { address, onchain } })
+ }
+ case 'wallet.ark-board': {
+ const amt = params?.amount_sats ?? walletState.ark_onchain_sats
+ walletState.ark_onchain_sats = Math.max(0, walletState.ark_onchain_sats - amt)
+ walletState.ark_sats += amt
+ return res.json({ result: { boarded_sats: amt } })
+ }
+ case 'wallet.ark-offboard': {
+ const amt = walletState.ark_sats
+ walletState.ark_sats = 0
+ walletState.ark_onchain_sats += amt
+ return res.json({ result: { offboarded_sats: amt } })
+ }
+ case 'wallet.ark-send': {
+ const amt = params?.amount_sats || 1000
+ walletState.ark_sats = Math.max(0, walletState.ark_sats - amt)
+ return res.json({ result: { sent: true, movement: { id: 1, effective_balance_sat: -amt } } })
+ }
+ case 'wallet.ark-invoice': {
+ return res.json({ result: { invoice: 'lntbs' + randomHex(64) } })
+ }
+ case 'wallet.ark-history': {
+ return res.json({ result: { transactions: [] } })
+ }
+ case 'wallet.ark-configure': {
+ const cfg = { ...walletState.ark_config }
+ for (const key of ['network', 'ark_server', 'esplora']) {
+ if (typeof params?.[key] === 'string' && params[key].trim()) cfg[key] = params[key].trim()
+ }
+ walletState.ark_config = cfg
+ return res.json({ result: { config: cfg } })
+ }
case 'content.request-onchain': {
const price = params?.price_sats || 500
return res.json({
@@ -3296,6 +3358,8 @@ app.post('/rpc/v1', (req, res) => {
{ type: 'receive', amount_sats: 50000, timestamp: new Date(Date.now() - 86400000).toISOString(), note: 'Minted from Lightning' },
{ type: 'send', amount_sats: 5000, timestamp: new Date(Date.now() - 43200000).toISOString(), note: 'Sent ecash token' },
{ type: 'receive', amount_sats: 10000, timestamp: new Date(Date.now() - 3600000).toISOString(), note: 'Redeemed token' },
+ // Ark movement in the unified history shape (see wallet.ark-history / load_ark_txs)
+ { id: 'ark-1', tx_type: 'receive', type: 'receive', amount_sats: 75000, timestamp: new Date(Date.now() - 7200000).toISOString(), description: 'Received via Ark', note: 'Received via Ark', mint_url: '', peer: '', kind: 'ark' },
],
},
})
diff --git a/neode-ui/public/assets/img/app-icons/bark.png b/neode-ui/public/assets/img/app-icons/bark.png
new file mode 100644
index 00000000..e9be9a0b
Binary files /dev/null and b/neode-ui/public/assets/img/app-icons/bark.png differ
diff --git a/neode-ui/public/catalog.json b/neode-ui/public/catalog.json
index a3c3aaa3..ca1b6a22 100644
--- a/neode-ui/public/catalog.json
+++ b/neode-ui/public/catalog.json
@@ -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",
diff --git a/neode-ui/scripts/download-app-icons.js b/neode-ui/scripts/download-app-icons.js
index 6fa5ca2f..7cbb9d12 100755
--- a/neode-ui/scripts/download-app-icons.js
+++ b/neode-ui/scripts/download-app-icons.js
@@ -45,6 +45,10 @@ const customIconUrls = {
'fedimint': [
'https://raw.githubusercontent.com/fedibtc/fedimint-ui/master/apps/router/public/favicon.svg',
],
+ // Official bark project avatar (Ark protocol wallet daemon)
+ 'bark': [
+ 'https://gitlab.com/uploads/-/system/project/avatar/75519706/bark-smiling-square-white-2.jpg',
+ ],
}
const iconDir = path.join(__dirname, '../public/assets/img/app-icons')
diff --git a/neode-ui/src/components/TransactionsModal.vue b/neode-ui/src/components/TransactionsModal.vue
index 9eac5c34..ad28b79c 100644
--- a/neode-ui/src/components/TransactionsModal.vue
+++ b/neode-ui/src/components/TransactionsModal.vue
@@ -72,7 +72,7 @@
{{ kindLabel(tx) }}
@@ -111,7 +111,7 @@ interface WalletTransaction {
label: string
block_height: number
// Which rail the transaction happened on; absent = onchain (older backends)
- kind?: 'onchain' | 'lightning' | 'cashu' | 'fedimint'
+ kind?: 'onchain' | 'lightning' | 'cashu' | 'fedimint' | 'ark'
}
const props = defineProps<{
@@ -122,19 +122,23 @@ const props = defineProps<{
const emit = defineEmits<{ close: [] }>()
const { t } = useI18n()
-type FilterKey = 'all' | 'onchain' | 'lightning' | 'ecash'
-const filters: Array<{ key: FilterKey; label: string }> = [
+type FilterKey = 'all' | 'onchain' | 'lightning' | 'ecash' | 'ark'
+// The Ark chip only appears once an Ark transaction exists — most nodes
+// don't run the barkd sidecar.
+const filters = computed>(() => [
{ key: 'all', label: 'All' },
{ key: 'onchain', label: 'On-chain' },
{ key: 'lightning', label: '⚡ Lightning' },
{ key: 'ecash', label: 'Ecash' },
-]
+ ...(props.transactions.some(tx => tx.kind === 'ark') ? [{ key: 'ark' as const, label: 'Ark' }] : []),
+])
const activeFilter = ref('all')
function matchesFilter(tx: WalletTransaction, f: FilterKey): boolean {
if (f === 'all') return true
if (f === 'onchain') return isOnchain(tx)
if (f === 'lightning') return tx.kind === 'lightning'
+ if (f === 'ark') return tx.kind === 'ark'
return tx.kind === 'cashu' || tx.kind === 'fedimint'
}
@@ -157,6 +161,7 @@ function kindLabel(tx: WalletTransaction): string {
if (tx.kind === 'lightning') return '⚡ Lightning'
if (tx.kind === 'cashu') return 'Cashu'
if (tx.kind === 'fedimint') return 'Fedimint'
+ if (tx.kind === 'ark') return 'Ark'
return ''
}
diff --git a/neode-ui/src/components/WalletSettingsModal.vue b/neode-ui/src/components/WalletSettingsModal.vue
index 38f5a666..57d403fe 100644
--- a/neode-ui/src/components/WalletSettingsModal.vue
+++ b/neode-ui/src/components/WalletSettingsModal.vue
@@ -148,11 +148,107 @@
Joining federations lands with the Fedimint client backend.
+
+
+
+
+
+ Ark holds self-custodial off-chain bitcoin via an Ark server. Funds stay recoverable on-chain even if the server disappears.
+
+
Not installed
+
{{ arkStatus.config.network }}
+
+
+
Checking Ark wallet…
+
+
+
+ Install the Ark Wallet app from the app store to enable Ark payments.
+
+
+
+
+
+
Spendable
+
{{ (arkBalance?.spendable_sats ?? 0).toLocaleString() }} sats
+
+
+
Pending
+
{{ (arkBalance?.pending_sats ?? 0).toLocaleString() }} sats
+
+
+
On-chain
+
{{ (arkBalance?.onchain_sats ?? 0).toLocaleString() }} sats
+
+
+
+
+
+
+
+
+
+
+
{{ arkAddress }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Applied when the Ark wallet is created — an existing wallet stays bound to its server.
+
+
+
+ {{ arkError }}
+ {{ arkOk }}
+
+
+
+
+
+
+
diff --git a/neode-ui/src/stores/appLauncher.ts b/neode-ui/src/stores/appLauncher.ts
index 1ddcbbe4..21084b38 100644
--- a/neode-ui/src/stores/appLauncher.ts
+++ b/neode-ui/src/stores/appLauncher.ts
@@ -198,7 +198,6 @@ export interface NostrConsentRequest {
reject: () => void
}
-
export const useAppLauncherStore = defineStore('appLauncher', () => {
const isOpen = ref(false)
const url = ref('')
diff --git a/neode-ui/src/views/Home.vue b/neode-ui/src/views/Home.vue
index fcb963c3..e06072f6 100644
--- a/neode-ui/src/views/Home.vue
+++ b/neode-ui/src/views/Home.vue
@@ -133,6 +133,7 @@
:wallet-lightning="walletLightning"
:wallet-ecash="walletEcash"
:wallet-fedimint="walletFedimint"
+ :wallet-ark="walletArk"
:wallet-transactions="walletTransactions"
:is-dev="isDev"
@show-send="showSendModal = true"
@@ -515,6 +516,7 @@ const showSendModal = ref(false); const showReceiveModal = ref(false); const sho
async function devFaucet() { try { await rpcClient.call({ method: 'dev.faucet', params: { amount_sats: 1_000_000 } }); await loadWeb5Status() } catch { /* ignore */ } }
const walletConnected = ref(false); const walletOnchain = ref(0); const walletLightning = ref(0); const walletEcash = ref(0); const walletFedimint = ref(0)
+const walletArk = ref(0)
const walletTransactions = ref([])
// Overlay the explorer above the current page — never navigate away.
@@ -532,7 +534,7 @@ interface EcashTransaction {
description: string
mint_url: string
peer: string
- kind: 'cashu' | 'fedimint'
+ kind: 'cashu' | 'fedimint' | 'ark'
}
function ecashToWalletTransaction(tx: EcashTransaction): WalletTransaction {
@@ -557,6 +559,7 @@ async function loadWeb5Status() {
try { const res = await rpcClient.call<{ balance_sats: number; channel_balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000 }); walletOnchain.value = res.balance_sats || 0; walletLightning.value = res.channel_balance_sats || 0; walletConnected.value = true } catch { walletConnected.value = false }
try { const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.ecash-balance', timeout: 5000 }); walletEcash.value = res.balance_sats ?? 0 } catch { /* keep last-known balance */ }
try { const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.fedimint-balance', timeout: 5000 }); walletFedimint.value = res.balance_sats ?? 0 } catch { /* keep last-known balance */ }
+ try { const res = await rpcClient.call<{ spendable_sats: number }>({ method: 'wallet.ark-balance', timeout: 5000 }); walletArk.value = res.spendable_sats ?? 0 } catch { /* keep last-known balance */ }
// Merge LND transactions with ecash/Fedimint history (wallet.ecash-history
// already unifies both) — previously only LND transactions were fetched
// here, so any Cashu or Fedimint receive (e.g. a TollGate payment) never
diff --git a/neode-ui/src/views/home/HomeWalletCard.vue b/neode-ui/src/views/home/HomeWalletCard.vue
index 94c5fcbe..2c8d174b 100644
--- a/neode-ui/src/views/home/HomeWalletCard.vue
+++ b/neode-ui/src/views/home/HomeWalletCard.vue
@@ -136,6 +136,14 @@
{{ walletFedimint.toLocaleString() }} sats
+
+
+
+ ⚓
+ Ark
+
+
{{ (walletArk ?? 0).toLocaleString() }} sats
+