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 cb95a64d..ddd783e3 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 d0328b82..32730469 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 f99ceb32..a3fe1f0a 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 58fc5eda..8f00a74e 100644 --- a/core/archipelago/src/server.rs +++ b/core/archipelago/src/server.rs @@ -1590,6 +1590,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/scripts/image-versions.sh b/scripts/image-versions.sh index a2f17e22..d8f25951 100644 --- a/scripts/image-versions.sh +++ b/scripts/image-versions.sh @@ -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"