Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13909e28bf | ||
|
|
97488c83f7 | ||
|
|
46f7ac3fcf | ||
|
|
76ad14ef64 | ||
|
|
15b99a65e0 | ||
|
|
c49de3eb01 | ||
|
|
2f78fb6907 | ||
|
|
2fce4fb842 | ||
|
|
24be2e9e69 | ||
|
|
768c358546 | ||
|
|
128c13e965 | ||
|
|
5642aae530 | ||
|
|
6fa0aa46a9 | ||
|
|
bdb9826aba |
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,223 @@
|
||||
//! Ark protocol RPCs — bridge to the `barkd` sidecar.
|
||||
//!
|
||||
//! Companion to the Cashu RPCs in [`super::wallet`] and the Fedimint RPCs in
|
||||
//! [`super::fedimint`]. Holding VTXOs, joining rounds and unilateral exits are
|
||||
//! delegated to the barkd container via [`crate::wallet::ark_client::ArkClient`];
|
||||
//! here we expose the node's JSON-RPC surface. barkd keeps its own movement
|
||||
//! history, so unlike Fedimint there is no local transaction log.
|
||||
|
||||
use super::RpcHandler;
|
||||
use crate::wallet::ark_client::{self, ArkClient};
|
||||
use anyhow::Result;
|
||||
|
||||
impl RpcHandler {
|
||||
/// `wallet.ark-status` — sidecar reachability, wallet fingerprint, network
|
||||
/// and Ark server parameters. Soft-fails into `available: false` so the
|
||||
/// settings UI can render an install/enable hint instead of an error.
|
||||
pub(super) async fn handle_wallet_ark_status(&self) -> Result<serde_json::Value> {
|
||||
let config = ark_client::load_config(&self.config.data_dir).await;
|
||||
let client = match ArkClient::from_node(&self.config.data_dir).await {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
return Ok(serde_json::json!({
|
||||
"available": false,
|
||||
"wallet_ready": false,
|
||||
"config": config,
|
||||
}))
|
||||
}
|
||||
};
|
||||
// Make sure the wallet exists before reporting (idempotent, cheap once
|
||||
// created).
|
||||
let _ = ark_client::ensure_wallet(&self.config.data_dir).await;
|
||||
|
||||
let wallet = client.wallet_info().await.ok();
|
||||
let info = client.ark_info().await.ok();
|
||||
Ok(serde_json::json!({
|
||||
"available": true,
|
||||
"wallet_ready": wallet.is_some(),
|
||||
"wallet": wallet,
|
||||
"ark_info": info,
|
||||
"config": config,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `wallet.ark-balance` — off-chain (spendable + pending) and on-chain
|
||||
/// sats. Soft-fails to zeros so unified balances still render.
|
||||
pub(super) async fn handle_wallet_ark_balance(&self) -> Result<serde_json::Value> {
|
||||
let client = match ArkClient::from_node(&self.config.data_dir).await {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
return Ok(serde_json::json!({
|
||||
"balance_sats": 0,
|
||||
"spendable_sats": 0,
|
||||
"pending_sats": 0,
|
||||
"onchain_sats": 0,
|
||||
}))
|
||||
}
|
||||
};
|
||||
let bal = client.balance().await.unwrap_or_else(|_| serde_json::json!({}));
|
||||
let sat = |key: &str| bal.get(key).and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let spendable = sat("spendable_sat");
|
||||
let pending = sat("pending_in_round_sat")
|
||||
+ sat("pending_board_sat")
|
||||
+ sat("pending_lightning_send_sat")
|
||||
+ sat("claimable_lightning_receive_sat")
|
||||
+ bal.get("pending_exit_sat").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let onchain = client
|
||||
.onchain_balance()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|b| {
|
||||
b.get("total_sat")
|
||||
.or_else(|| b.get("confirmed_sat"))
|
||||
.and_then(|v| v.as_u64())
|
||||
})
|
||||
.unwrap_or(0);
|
||||
Ok(serde_json::json!({
|
||||
"balance_sats": spendable,
|
||||
"spendable_sats": spendable,
|
||||
"pending_sats": pending,
|
||||
"onchain_sats": onchain,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `wallet.ark-address` — fresh Ark (`tark1…`) receive address; pass
|
||||
/// `{"onchain": true}` for an on-chain boarding address instead.
|
||||
pub(super) async fn handle_wallet_ark_address(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let _ = ark_client::ensure_wallet(&self.config.data_dir).await;
|
||||
let client = ArkClient::from_node(&self.config.data_dir).await?;
|
||||
let onchain = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("onchain"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let address = if onchain {
|
||||
client.onchain_address().await?
|
||||
} else {
|
||||
client.ark_address().await?
|
||||
};
|
||||
Ok(serde_json::json!({ "address": address, "onchain": onchain }))
|
||||
}
|
||||
|
||||
/// `wallet.ark-send` — pay an Ark address, BOLT11 invoice, LNURL or
|
||||
/// lightning address from Ark funds.
|
||||
pub(super) async fn handle_wallet_ark_send(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let destination = params
|
||||
.get("destination")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing destination"))?;
|
||||
// Optional for BOLT11 invoices that carry their own amount.
|
||||
let amount_sats = params.get("amount_sats").and_then(|v| v.as_u64());
|
||||
if amount_sats == Some(0) {
|
||||
return Err(anyhow::anyhow!("Amount must be greater than zero"));
|
||||
}
|
||||
let comment = params.get("comment").and_then(|v| v.as_str());
|
||||
|
||||
let client = ArkClient::from_node(&self.config.data_dir).await?;
|
||||
let movement = client.send(destination, amount_sats, comment).await?;
|
||||
Ok(serde_json::json!({
|
||||
"sent": true,
|
||||
"movement": movement,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `wallet.ark-invoice` — BOLT11 invoice that lands as Ark funds when paid.
|
||||
pub(super) async fn handle_wallet_ark_invoice(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let amount_sats = params
|
||||
.get("amount_sats")
|
||||
.and_then(|v| v.as_u64())
|
||||
.filter(|&v| v > 0)
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing amount_sats"))?;
|
||||
|
||||
let _ = ark_client::ensure_wallet(&self.config.data_dir).await;
|
||||
let client = ArkClient::from_node(&self.config.data_dir).await?;
|
||||
let res = client.lightning_invoice(amount_sats).await?;
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
/// `wallet.ark-board` — lift on-chain funds into Ark VTXOs. Omitting
|
||||
/// `amount_sats` boards everything.
|
||||
pub(super) async fn handle_wallet_ark_board(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let amount_sats = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("amount_sats"))
|
||||
.and_then(|v| v.as_u64());
|
||||
if amount_sats == Some(0) {
|
||||
return Err(anyhow::anyhow!("Amount must be greater than zero"));
|
||||
}
|
||||
let client = ArkClient::from_node(&self.config.data_dir).await?;
|
||||
let res = client.board(amount_sats).await?;
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
/// `wallet.ark-offboard` — collaboratively move all VTXOs back on-chain,
|
||||
/// optionally to a provided address (defaults to the wallet's own).
|
||||
pub(super) async fn handle_wallet_ark_offboard(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let address = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("address"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty());
|
||||
let client = ArkClient::from_node(&self.config.data_dir).await?;
|
||||
let res = client.offboard_all(address).await?;
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
/// `wallet.ark-history` — barkd movements mapped to the unified
|
||||
/// transaction shape (kind = "ark"), newest first.
|
||||
pub(super) async fn handle_wallet_ark_history(&self) -> Result<serde_json::Value> {
|
||||
let mut transactions = ark_client::load_ark_txs(&self.config.data_dir).await;
|
||||
transactions.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
|
||||
Ok(serde_json::json!({ "transactions": transactions }))
|
||||
}
|
||||
|
||||
/// `wallet.ark-configure` — set the Ark server / esplora / network used
|
||||
/// when the barkd wallet is (re)created. Does NOT migrate an existing
|
||||
/// wallet: barkd binds a wallet to its Ark server at creation.
|
||||
pub(super) async fn handle_wallet_ark_configure(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let mut config = ark_client::load_config(&self.config.data_dir).await;
|
||||
for (key, field) in [
|
||||
("network", &mut config.network as &mut String),
|
||||
("ark_server", &mut config.ark_server),
|
||||
("esplora", &mut config.esplora),
|
||||
] {
|
||||
if let Some(v) = params.get(key).and_then(|v| v.as_str()) {
|
||||
let v = v.trim();
|
||||
if !v.is_empty() {
|
||||
*field = v.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
if !matches!(config.network.as_str(), "signet" | "mainnet" | "regtest") {
|
||||
return Err(anyhow::anyhow!(
|
||||
"network must be one of: signet, mainnet, regtest"
|
||||
));
|
||||
}
|
||||
ark_client::save_config(&self.config.data_dir, &config).await?;
|
||||
Ok(serde_json::json!({ "config": config }))
|
||||
}
|
||||
}
|
||||
@@ -258,6 +258,17 @@ impl RpcHandler {
|
||||
"wallet.fedimint-leave" => self.handle_wallet_fedimint_leave(params).await,
|
||||
"wallet.fedimint-balance" => self.handle_wallet_fedimint_balance().await,
|
||||
|
||||
// Ark protocol (via barkd sidecar)
|
||||
"wallet.ark-status" => self.handle_wallet_ark_status().await,
|
||||
"wallet.ark-balance" => self.handle_wallet_ark_balance().await,
|
||||
"wallet.ark-address" => self.handle_wallet_ark_address(params).await,
|
||||
"wallet.ark-send" => self.handle_wallet_ark_send(params).await,
|
||||
"wallet.ark-invoice" => self.handle_wallet_ark_invoice(params).await,
|
||||
"wallet.ark-board" => self.handle_wallet_ark_board(params).await,
|
||||
"wallet.ark-offboard" => self.handle_wallet_ark_offboard(params).await,
|
||||
"wallet.ark-history" => self.handle_wallet_ark_history().await,
|
||||
"wallet.ark-configure" => self.handle_wallet_ark_configure(params).await,
|
||||
|
||||
// Container registries
|
||||
"registry.list" => self.handle_registry_list().await,
|
||||
"registry.add" => self.handle_registry_add(params).await,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod analytics;
|
||||
mod ark;
|
||||
mod auth;
|
||||
mod backup_rpc;
|
||||
mod bitcoin;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::RpcHandler;
|
||||
use crate::wallet::{ecash, fedimint_client, profits};
|
||||
use crate::wallet::{ark_client, ecash, fedimint_client, profits};
|
||||
use anyhow::Result;
|
||||
|
||||
/// A Cashu token (NUT-00 `cashuA`/`cashuB`, or our legacy `cashuSend_` form)
|
||||
@@ -21,13 +21,16 @@ impl RpcHandler {
|
||||
Ok(client) => client.total_balance_sats().await.unwrap_or(0),
|
||||
Err(_) => 0,
|
||||
};
|
||||
// Spendable Ark (barkd) balance, same best-effort contract.
|
||||
let ark_sats = ark_client::spendable_sats_or_zero(&self.config.data_dir).await;
|
||||
Ok(serde_json::json!({
|
||||
// `balance_sats` stays Cashu-only for back-compat; `total_sats` is the
|
||||
// spendable amount across Cashu + Fedimint.
|
||||
// spendable amount across Cashu + Fedimint + Ark.
|
||||
"balance_sats": cashu_sats,
|
||||
"cashu_sats": cashu_sats,
|
||||
"fedimint_sats": fedimint_sats,
|
||||
"total_sats": cashu_sats + fedimint_sats,
|
||||
"ark_sats": ark_sats,
|
||||
"total_sats": cashu_sats + fedimint_sats + ark_sats,
|
||||
"proof_count": wallet.proofs.iter().filter(|p| !p.spent && !p.reserved).count(),
|
||||
"mint_url": wallet.mint_url,
|
||||
}))
|
||||
@@ -181,6 +184,8 @@ impl RpcHandler {
|
||||
let wallet = ecash::load_wallet(&self.config.data_dir).await?;
|
||||
let mut transactions = wallet.transactions;
|
||||
transactions.extend(fedimint_client::load_fedimint_txs(&self.config.data_dir).await);
|
||||
// Ark movements from barkd (kind="ark"), best-effort like Fedimint.
|
||||
transactions.extend(ark_client::load_ark_txs(&self.config.data_dir).await);
|
||||
// Sort by RFC-3339 timestamp descending (string compare is valid for
|
||||
// same-offset RFC-3339), newest first.
|
||||
transactions.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
|
||||
|
||||
@@ -80,19 +80,11 @@ pub struct Config {
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Detect primary host IP (first non-loopback IPv4)
|
||||
/// Detect primary host IP (default-route interface, not `hostname -I` order)
|
||||
async fn detect_host_ip() -> Result<String> {
|
||||
let output = tokio::process::Command::new("hostname")
|
||||
.args(["-I"])
|
||||
.output()
|
||||
Ok(crate::host_ip::primary_host_ipv4()
|
||||
.await
|
||||
.context("Failed to run hostname -I")?;
|
||||
let s = String::from_utf8_lossy(&output.stdout);
|
||||
let ip = s
|
||||
.split_whitespace()
|
||||
.find(|s| !s.starts_with("127.") && s.contains('.'))
|
||||
.unwrap_or("127.0.0.1");
|
||||
Ok(ip.to_string())
|
||||
.unwrap_or_else(|| "127.0.0.1".to_string()))
|
||||
}
|
||||
|
||||
pub async fn load() -> Result<Self> {
|
||||
|
||||
@@ -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(),
|
||||
@@ -689,22 +696,11 @@ async fn netbird_configured_launch_url() -> Option<String> {
|
||||
PodmanClient::lan_address_for("netbird")
|
||||
}
|
||||
|
||||
/// First address from `hostname -I` — the node's primary host IP. Mirrors the
|
||||
/// orchestrator's `detect_host_ip` so launch URLs match the cert/config the
|
||||
/// orchestrator renders for `{{HOST_IP}}`.
|
||||
/// The node's primary host IP. Mirrors the orchestrator's `detect_host_ip`
|
||||
/// so launch URLs match the cert/config the orchestrator renders for
|
||||
/// `{{HOST_IP}}`.
|
||||
async fn first_host_ip() -> Option<String> {
|
||||
let out = tokio::process::Command::new("hostname")
|
||||
.arg("-I")
|
||||
.output()
|
||||
.await
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
String::from_utf8_lossy(&out.stdout)
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.map(ToOwned::to_owned)
|
||||
crate::host_ip::primary_host_ipv4().await
|
||||
}
|
||||
|
||||
async fn reachable_lan_address(app_id: &str, candidate: Option<String>) -> Option<String> {
|
||||
|
||||
@@ -3087,16 +3087,7 @@ impl ProdContainerOrchestrator {
|
||||
}
|
||||
|
||||
async fn detect_host_ip() -> Option<String> {
|
||||
let output = tokio::process::Command::new("hostname")
|
||||
.arg("-I")
|
||||
.output()
|
||||
.await
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
stdout.split_whitespace().next().map(|s| s.to_string())
|
||||
crate::host_ip::primary_host_ipv4().await
|
||||
}
|
||||
|
||||
async fn detect_host_mdns() -> String {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
//! Primary host LAN IPv4 detection.
|
||||
//!
|
||||
//! `hostname -I` lists addresses in interface-creation order, so once a VPN
|
||||
//! or bridge interface exists (NetBird's WireGuard tunnel, br-tollgate, …)
|
||||
//! its address can sort ahead of the real NIC — a fresh-ISO node handed out
|
||||
//! `https://10.44.0.1:8087` as NetBird's launch URL instead of the LAN IP.
|
||||
//! The main routing table's default route names the physical uplink even when
|
||||
//! a VPN is active (NetBird/Tailscale steer traffic via policy-routing rules
|
||||
//! in separate tables, not by replacing the main-table default), so that is
|
||||
//! the authoritative source, with `hostname -I` kept only as the last resort
|
||||
//! for hosts with no default route at all.
|
||||
|
||||
/// The node's primary LAN IPv4, as a string.
|
||||
///
|
||||
/// Resolution order:
|
||||
/// 1. `src`/`dev` of the main-table default route (`ip -4 route show default`)
|
||||
/// 2. source address of a connected UDP socket (never transmits)
|
||||
/// 3. first non-loopback IPv4 from `hostname -I` (legacy behaviour)
|
||||
pub(crate) async fn primary_host_ipv4() -> Option<String> {
|
||||
if let Some(ip) = default_route_ip().await {
|
||||
return Some(ip);
|
||||
}
|
||||
if let Some(ip) = udp_route_ip() {
|
||||
return Some(ip);
|
||||
}
|
||||
hostname_i_ip().await
|
||||
}
|
||||
|
||||
async fn default_route_ip() -> Option<String> {
|
||||
let out = tokio::process::Command::new("ip")
|
||||
.args(["-4", "route", "show", "default"])
|
||||
.output()
|
||||
.await
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let route = String::from_utf8_lossy(&out.stdout);
|
||||
if let Some(ip) = parse_route_src(&route) {
|
||||
return Some(ip);
|
||||
}
|
||||
// No `src` hint on the route — resolve the device's global address.
|
||||
let dev = parse_route_dev(&route)?;
|
||||
let out = tokio::process::Command::new("ip")
|
||||
.args(["-4", "-o", "addr", "show", "dev", &dev, "scope", "global"])
|
||||
.output()
|
||||
.await
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
parse_addr_inet(&String::from_utf8_lossy(&out.stdout))
|
||||
}
|
||||
|
||||
fn parse_route_src(route: &str) -> Option<String> {
|
||||
field_after(route.lines().next()?, "src")
|
||||
}
|
||||
|
||||
fn parse_route_dev(route: &str) -> Option<String> {
|
||||
field_after(route.lines().next()?, "dev")
|
||||
}
|
||||
|
||||
fn field_after(line: &str, key: &str) -> Option<String> {
|
||||
let mut words = line.split_whitespace();
|
||||
while let Some(w) = words.next() {
|
||||
if w == key {
|
||||
return words.next().map(ToOwned::to_owned);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_addr_inet(out: &str) -> Option<String> {
|
||||
let cidr = field_after(out.lines().next()?, "inet")?;
|
||||
Some(cidr.split('/').next().unwrap_or(&cidr).to_string())
|
||||
}
|
||||
|
||||
/// A connected UDP socket's local address is the source IP the kernel would
|
||||
/// use to reach the peer; nothing is sent. Can still land on a tunnel IP when
|
||||
/// a VPN policy-routes all traffic, hence only a fallback.
|
||||
fn udp_route_ip() -> Option<String> {
|
||||
let sock = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
|
||||
sock.connect("8.8.8.8:80").ok()?;
|
||||
match sock.local_addr().ok()?.ip() {
|
||||
std::net::IpAddr::V4(v4) if !v4.is_loopback() && !v4.is_unspecified() => {
|
||||
Some(v4.to_string())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn hostname_i_ip() -> Option<String> {
|
||||
let out = tokio::process::Command::new("hostname")
|
||||
.arg("-I")
|
||||
.output()
|
||||
.await
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
String::from_utf8_lossy(&out.stdout)
|
||||
.split_whitespace()
|
||||
.find(|s| !s.starts_with("127.") && s.contains('.'))
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn route_src_wins() {
|
||||
let route = "default via 192.168.1.254 dev wlp3s0 proto dhcp src 192.168.1.116 metric 600";
|
||||
assert_eq!(parse_route_src(route).as_deref(), Some("192.168.1.116"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_dev_without_src() {
|
||||
let route = "default via 192.168.1.1 dev enp0s31f6 proto static";
|
||||
assert_eq!(parse_route_src(route), None);
|
||||
assert_eq!(parse_route_dev(route).as_deref(), Some("enp0s31f6"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn addr_inet_strips_prefix() {
|
||||
let out = "3: wlp3s0 inet 192.168.1.65/24 brd 192.168.1.255 scope global dynamic noprefixroute wlp3s0\\ valid_lft 85328sec preferred_lft 85328sec";
|
||||
assert_eq!(parse_addr_inet(out).as_deref(), Some("192.168.1.65"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_route_table() {
|
||||
assert_eq!(parse_route_src(""), None);
|
||||
assert_eq!(parse_route_dev(""), None);
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,7 @@ mod electrs_status;
|
||||
mod federation;
|
||||
mod fips;
|
||||
mod health_monitor;
|
||||
mod host_ip;
|
||||
mod identity;
|
||||
mod identity_manager;
|
||||
mod marketplace;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1597,6 +1597,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),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
<meta http-equiv="Pragma" content="no-cache">
|
||||
<meta http-equiv="Expires" content="0">
|
||||
<title id="pageTitle">Bitcoin Node - Archipelago</title>
|
||||
<link rel="stylesheet" href="/tailwind.css">
|
||||
<!-- Relative: the shell is served at / in the container but under
|
||||
/app/bitcoin-ui/ in the public demo — absolute paths 404 there. -->
|
||||
<link rel="stylesheet" href="tailwind.css">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
@@ -339,7 +341,7 @@
|
||||
<div class="logo-gradient-border">
|
||||
<img
|
||||
id="implLogo"
|
||||
src="/assets/img/app-icons/bitcoin-knots.webp"
|
||||
src="assets/img/app-icons/bitcoin-knots.webp"
|
||||
alt="Bitcoin Node"
|
||||
class="w-16 h-16"
|
||||
style="object-fit: contain;"
|
||||
@@ -984,8 +986,8 @@
|
||||
? 'Enhanced Bitcoin node implementation'
|
||||
: 'Reference Bitcoin node implementation';
|
||||
const icon = isKnots
|
||||
? '/assets/img/app-icons/bitcoin-knots.webp'
|
||||
: '/assets/img/app-icons/bitcoin-core.svg';
|
||||
? 'assets/img/app-icons/bitcoin-knots.webp'
|
||||
: 'assets/img/app-icons/bitcoin-core.svg';
|
||||
const pageTitle = document.getElementById('pageTitle');
|
||||
const implName = document.getElementById('implName');
|
||||
const implTagline = document.getElementById('implTagline');
|
||||
|
||||
@@ -386,7 +386,7 @@
|
||||
<section class="glass-card">
|
||||
<div class="header">
|
||||
<div class="logo-gradient-border">
|
||||
<img src="/assets/img/app-icons/fedimint.jpg" alt="Fedimint Guardian">
|
||||
<img src="assets/img/app-icons/fedimint.jpg" alt="Fedimint Guardian">
|
||||
</div>
|
||||
<div class="title">
|
||||
<h1>Fedimint Guardian</h1>
|
||||
|
||||
@@ -91,7 +91,10 @@ http {
|
||||
}
|
||||
|
||||
# Proxy FileBrowser API to mock backend (demo mode)
|
||||
location /app/filebrowser/ {
|
||||
# ^~ on every /app/ prefix: the .css/.js/.img cache regex below must
|
||||
# never swallow app-shell assets (they live on the backend, not in the
|
||||
# web root — without ^~ nginx prefers the regex and 404s them).
|
||||
location ^~ /app/filebrowser/ {
|
||||
client_max_body_size 10G;
|
||||
proxy_pass http://neode-backend:5959;
|
||||
proxy_http_version 1.1;
|
||||
@@ -103,7 +106,7 @@ http {
|
||||
# IndeeHub: reverse-proxy the real site same-origin, strip framing headers,
|
||||
# and rewrite its absolute asset paths (/assets, /, src, href) to the
|
||||
# /app/indeedhub/ prefix so the SPA loads inside the iframe.
|
||||
location /app/indeedhub/ {
|
||||
location ^~ /app/indeedhub/ {
|
||||
proxy_pass https://indee.tx1138.com/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host indee.tx1138.com;
|
||||
@@ -129,7 +132,7 @@ http {
|
||||
# Proxy every other app UI (/app/<id>/) to the mock backend, which serves
|
||||
# the per-app mock UIs (bitcoin-ui, electrumx, lnd, fedimint) and the
|
||||
# generic "Not available in the demo" notice for the rest.
|
||||
location /app/ {
|
||||
location ^~ /app/ {
|
||||
proxy_pass http://neode-backend:5959;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
+128
-7
@@ -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: [] },
|
||||
@@ -1244,7 +1249,8 @@ const DOCKER_UI = path.join(__dirname, '..', 'docker')
|
||||
for (const [prefixes, dir] of [
|
||||
[['/app/bitcoin-core', '/app/bitcoin-knots', '/app/bitcoin-ui'], 'bitcoin-ui'],
|
||||
[['/app/electrumx', '/app/electrs', '/app/archy-electrs-ui'], 'electrs-ui'],
|
||||
[['/app/lnd', '/app/lnd-ui', '/app/archy-lnd-ui', '/app/thunderhub'], 'lnd-ui'],
|
||||
// lnd deliberately NOT here: the real lnd-ui shell reads poorly in the demo
|
||||
// iframe, so /app/lnd/ gets a DEMO_APP_PAGES placeholder dashboard instead.
|
||||
[['/app/fedimint', '/app/fedimintd'], 'fedimint-ui'],
|
||||
]) {
|
||||
for (const p of prefixes) app.use(p, express.static(path.join(DOCKER_UI, dir)))
|
||||
@@ -2190,6 +2196,15 @@ app.post('/rpc/v1', (req, res) => {
|
||||
case 'content.download-peer-paid':
|
||||
case 'content.download-peer-invoice':
|
||||
case 'content.download-peer-onchain': {
|
||||
// Deduct the price from the chosen rail so demo balances react.
|
||||
const paid = params?.price_sats || 0
|
||||
if (paid > 0 && method === 'content.download-peer-paid') {
|
||||
if (params?.method === 'ark') walletState.ark_sats = Math.max(0, walletState.ark_sats - paid)
|
||||
else if (params?.method === 'fedimint') {
|
||||
const fed = (mockState.federations || [])[0]
|
||||
if (fed) fed.balance_sats = Math.max(0, (fed.balance_sats || 0) - paid)
|
||||
} else walletState.ecash_sats = Math.max(0, walletState.ecash_sats - paid)
|
||||
}
|
||||
const filename = params?.filename || 'demo-content'
|
||||
const body = Buffer.from(
|
||||
`Archipelago demo — "${filename}"\n\nThis is sample paid content delivered over the ` +
|
||||
@@ -2251,6 +2266,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({
|
||||
@@ -2356,20 +2428,34 @@ app.post('/rpc/v1', (req, res) => {
|
||||
}
|
||||
|
||||
case 'network.dns-status': {
|
||||
const dns = mockState.dns || { provider: 'system', servers: ['1.1.1.1', '9.9.9.9'], doh_enabled: false }
|
||||
return res.json({
|
||||
result: {
|
||||
provider: 'system',
|
||||
servers: ['1.1.1.1', '9.9.9.9'],
|
||||
doh_enabled: false,
|
||||
provider: dns.provider,
|
||||
servers: dns.servers,
|
||||
doh_enabled: dns.doh_enabled,
|
||||
doh_url: null,
|
||||
resolv_conf_servers: ['1.1.1.1', '9.9.9.9'],
|
||||
resolv_conf_servers: dns.servers,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
case 'network.configure-dns': {
|
||||
console.log(`[Network] DNS configured: ${params?.provider}`)
|
||||
return res.json({ result: { success: true } })
|
||||
const dnsProviders = {
|
||||
system: ['192.168.4.1'],
|
||||
cloudflare: ['1.1.1.1', '1.0.0.1'],
|
||||
google: ['8.8.8.8', '8.8.4.4'],
|
||||
quad9: ['9.9.9.9', '149.112.112.112'],
|
||||
mullvad: ['194.242.2.2'],
|
||||
}
|
||||
const provider = params?.provider || 'system'
|
||||
const servers = provider === 'custom'
|
||||
? (Array.isArray(params?.servers) ? params.servers : [])
|
||||
: (dnsProviders[provider] || dnsProviders.system)
|
||||
const doh_enabled = ['cloudflare', 'google', 'quad9', 'mullvad'].includes(provider)
|
||||
mockState.dns = { provider, servers, doh_enabled }
|
||||
console.log(`[Network] DNS configured: ${provider} → ${servers.join(', ')}`)
|
||||
return res.json({ result: { provider, servers, doh_enabled } })
|
||||
}
|
||||
|
||||
case 'network.accept-request': {
|
||||
@@ -3253,10 +3339,15 @@ app.post('/rpc/v1', (req, res) => {
|
||||
// Wallet / Ecash (Fedimint)
|
||||
// =====================================================================
|
||||
case 'wallet.ecash-balance': {
|
||||
const fedSats = (mockState.federations || []).reduce((s, f) => s + (f.balance_sats || 0), 0)
|
||||
return res.json({
|
||||
result: {
|
||||
balance_sats: walletState.ecash_sats,
|
||||
balance_msat: walletState.ecash_sats * 1000,
|
||||
cashu_sats: walletState.ecash_sats,
|
||||
fedimint_sats: fedSats,
|
||||
ark_sats: walletState.ark_sats,
|
||||
total_sats: walletState.ecash_sats + fedSats + walletState.ark_sats,
|
||||
token_count: walletState.ecash_tokens,
|
||||
federations: [
|
||||
{ federation_id: 'fed1-demo', name: 'Archy Signet Mint', balance_msat: walletState.ecash_sats * 1000, gateway_active: true },
|
||||
@@ -3296,6 +3387,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' },
|
||||
],
|
||||
},
|
||||
})
|
||||
@@ -4677,6 +4770,34 @@ app.get('/app/thunderhub/api/forwards', (req, res) => res.json(MOCK_LND_DATA.for
|
||||
// something plausible in the in-app iframe. Registered before the generic
|
||||
// /app/:id notice handler so these win.
|
||||
const DEMO_APP_PAGES = {
|
||||
// Placeholder LND dashboard — the real lnd-ui shell reads poorly inside the
|
||||
// demo iframe. Numbers stay consistent with the /proxy/lnd/v1/* mocks.
|
||||
lnd: () => demoAppShell('Lightning Network Daemon', 'archipelago-lnd · v0.18.3-beta · signet', '/assets/img/app-icons/lnd.png', `
|
||||
<div class="grid">
|
||||
<div class="card"><div class="k">Status</div><div class="v"><span class="badge">Running · synced</span></div></div>
|
||||
<div class="card"><div class="k">On-chain</div><div class="v">2,450,000 sats</div></div>
|
||||
<div class="card"><div class="k">Lightning (local)</div><div class="v">8,250,000 sats</div></div>
|
||||
<div class="card"><div class="k">Inbound capacity</div><div class="v">11,750,000 sats</div></div>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div class="card"><div class="k">Channels</div><div class="v">4 active</div></div>
|
||||
<div class="card"><div class="k">Peers</div><div class="v">11</div></div>
|
||||
<div class="card"><div class="k">Block height</div><div class="v">902,418</div></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="k" style="margin-bottom:6px">Channels</div>
|
||||
<table>
|
||||
<tr><th>Peer</th><th>Capacity</th><th>Local / Remote</th><th style="width:30%">Balance</th></tr>
|
||||
<tr><td>ACINQ</td><td>5,000,000</td><td>2,450,000 / 2,550,000</td><td><div class="bar"><i style="width:49%"></i></div></td></tr>
|
||||
<tr><td>Voltage</td><td>10,000,000</td><td>4,500,000 / 5,500,000</td><td><div class="bar"><i style="width:45%"></i></div></td></tr>
|
||||
<tr><td>Kraken</td><td>3,000,000</td><td>1,800,000 / 1,200,000</td><td><div class="bar"><i style="width:60%"></i></div></td></tr>
|
||||
<tr><td>Wallet of Satoshi</td><td>2,000,000</td><td>1,200,000 / 800,000</td><td><div class="bar"><i style="width:60%"></i></div></td></tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card" style="margin-top:14px">
|
||||
<div class="k" style="margin-bottom:6px">Node URI</div>
|
||||
<div class="v mono">02c9f0a1…e47b@lnd7f3a2c9d1b4e8f6.onion:9735</div>
|
||||
</div>`),
|
||||
'btcpay-server': () => demoAppShell('BTCPay Server', 'Self-hosted payment processor · signet', '/assets/img/app-icons/btcpay-server.png', `
|
||||
<div class="grid">
|
||||
<div class="card"><div class="k">Store</div><div class="v">Archipelago Shop</div></div>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -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",
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
<!-- Method tabs -->
|
||||
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
|
||||
<button
|
||||
v-for="m in (['onchain', 'lightning', 'ecash'] as const)"
|
||||
v-for="m in (['onchain', 'lightning', 'ecash', 'ark'] as const)"
|
||||
:key="m"
|
||||
@click="receiveMethod = m"
|
||||
class="flex-1 px-2 py-1.5 rounded text-xs font-medium capitalize transition-colors"
|
||||
:class="receiveMethod === m ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
||||
>{{ m === 'onchain' ? t('receiveBitcoin.onChain') : m === 'lightning' ? t('receiveBitcoin.lightning') : t('receiveBitcoin.ecash') }}</button>
|
||||
>{{ m === 'onchain' ? t('receiveBitcoin.onChain') : m === 'lightning' ? t('receiveBitcoin.lightning') : m === 'ecash' ? t('receiveBitcoin.ecash') : 'Ark' }}</button>
|
||||
</div>
|
||||
|
||||
<!-- Lightning -->
|
||||
@@ -43,6 +43,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ark -->
|
||||
<div v-if="receiveMethod === 'ark'">
|
||||
<div v-if="arkAddress" class="mb-3 p-3 bg-white/5 rounded-lg text-center">
|
||||
<canvas ref="arkQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas>
|
||||
<p class="text-white/50 text-xs mb-2">Your Ark address</p>
|
||||
<p class="text-sm font-mono text-white/90 break-all">{{ arkAddress }}</p>
|
||||
<button @click="copyText(arkAddress)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">{{ t('common.copy') }}</button>
|
||||
</div>
|
||||
<div v-else class="mb-3 text-center">
|
||||
<p class="text-white/50 text-sm mb-2">Generate a fresh Ark address to receive off-chain sats instantly.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ecash -->
|
||||
<div v-if="receiveMethod === 'ecash'">
|
||||
<div class="mb-3">
|
||||
@@ -57,7 +70,7 @@
|
||||
<div class="flex gap-3">
|
||||
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
|
||||
<button @click="receive" :disabled="processing" class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
|
||||
{{ processing ? t('receiveBitcoin.processing') : receiveMethod === 'onchain' ? t('receiveBitcoin.generateAddress') : receiveMethod === 'lightning' ? t('receiveBitcoin.createInvoice') : t('receiveBitcoin.receive') }}
|
||||
{{ processing ? t('receiveBitcoin.processing') : receiveMethod === 'onchain' ? t('receiveBitcoin.generateAddress') : receiveMethod === 'lightning' ? t('receiveBitcoin.createInvoice') : receiveMethod === 'ark' ? 'Get Ark address' : t('receiveBitcoin.receive') }}
|
||||
</button>
|
||||
</div>
|
||||
</BaseModal>
|
||||
@@ -75,15 +88,17 @@ const { t } = useI18n()
|
||||
defineProps<{ show: boolean }>()
|
||||
const emit = defineEmits<{ close: []; received: [] }>()
|
||||
|
||||
const receiveMethod = ref<'lightning' | 'onchain' | 'ecash'>('onchain')
|
||||
const receiveMethod = ref<'lightning' | 'onchain' | 'ecash' | 'ark'>('onchain')
|
||||
const invoiceAmount = ref<number>(0)
|
||||
const invoiceMemo = ref('')
|
||||
const invoiceResult = ref('')
|
||||
const onchainAddress = ref('')
|
||||
const arkAddress = ref('')
|
||||
const ecashToken = ref('')
|
||||
const ecashResult = ref('')
|
||||
const onchainQrCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
const lightningQrCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
const arkQrCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
const processing = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
@@ -102,6 +117,7 @@ async function renderQr(data: string, canvas: HTMLCanvasElement | null, prefix =
|
||||
function close() {
|
||||
invoiceResult.value = ''
|
||||
onchainAddress.value = ''
|
||||
arkAddress.value = ''
|
||||
ecashToken.value = ''
|
||||
ecashResult.value = ''
|
||||
error.value = ''
|
||||
@@ -131,6 +147,11 @@ async function receive() {
|
||||
}
|
||||
onchainAddress.value = res.address
|
||||
nextTick(() => renderQr(res.address, onchainQrCanvas.value, 'bitcoin:'))
|
||||
} else if (receiveMethod.value === 'ark') {
|
||||
const res = await rpcClient.call<{ address: string }>({ method: 'wallet.ark-address' })
|
||||
if (!res.address) throw new Error('barkd did not return an Ark address')
|
||||
arkAddress.value = res.address
|
||||
nextTick(() => renderQr(res.address, arkQrCanvas.value))
|
||||
} else {
|
||||
if (!ecashToken.value.trim()) { error.value = t('receiveBitcoin.pasteAnEcashToken'); return }
|
||||
// The backend auto-detects the token type: a Cashu token (cashuA/B…) is
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
<!-- Method tabs -->
|
||||
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
|
||||
<button
|
||||
v-for="m in (['auto', 'lightning', 'onchain', 'ecash'] as const)"
|
||||
v-for="m in (['auto', 'lightning', 'onchain', 'ecash', 'ark'] as const)"
|
||||
:key="m"
|
||||
@click="sendMethod = m"
|
||||
class="flex-1 px-2 py-1.5 rounded text-xs font-medium capitalize transition-colors"
|
||||
:class="sendMethod === m ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
||||
>{{ m === 'onchain' ? t('sendBitcoin.onChain') : m === 'lightning' ? t('sendBitcoin.lightning') : m === 'ecash' ? t('sendBitcoin.ecash') : t('sendBitcoin.auto') }}</button>
|
||||
>{{ m === 'onchain' ? t('sendBitcoin.onChain') : m === 'lightning' ? t('sendBitcoin.lightning') : m === 'ecash' ? t('sendBitcoin.ecash') : m === 'ark' ? 'Ark' : t('sendBitcoin.auto') }}</button>
|
||||
</div>
|
||||
|
||||
<div v-if="sendMethod === 'auto'" class="mb-3 p-2 bg-white/5 rounded-lg">
|
||||
@@ -22,9 +22,9 @@
|
||||
|
||||
<div v-if="effectiveMethod !== 'ecash'" class="mb-3">
|
||||
<label class="text-white/60 text-sm block mb-1">
|
||||
{{ effectiveMethod === 'lightning' ? t('sendBitcoin.lightningInvoice') : t('sendBitcoin.bitcoinAddress') }}
|
||||
{{ effectiveMethod === 'lightning' ? t('sendBitcoin.lightningInvoice') : effectiveMethod === 'ark' ? 'Ark address, invoice or lightning address' : t('sendBitcoin.bitcoinAddress') }}
|
||||
</label>
|
||||
<textarea v-model="dest" rows="2" :placeholder="effectiveMethod === 'lightning' ? 'lnbc...' : 'bc1...'" class="w-full input-glass font-mono"></textarea>
|
||||
<textarea v-model="dest" rows="2" :placeholder="effectiveMethod === 'lightning' ? 'lnbc...' : effectiveMethod === 'ark' ? 'tark1… / lnbc… / user@lnaddress' : 'bc1...'" class="w-full input-glass font-mono"></textarea>
|
||||
</div>
|
||||
|
||||
<div v-if="ecashToken && effectiveMethod === 'ecash'" class="mb-3 p-2 bg-white/5 rounded-lg">
|
||||
@@ -39,6 +39,9 @@
|
||||
<div v-if="resultHash" class="mb-3 alert-success">
|
||||
<p class="text-xs">{{ t('sendBitcoin.paidHash', { hash: resultHash }) }}</p>
|
||||
</div>
|
||||
<div v-if="resultArk" class="mb-3 alert-success">
|
||||
<p class="text-xs">{{ resultArk }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="mb-3 alert-error">{{ error }}</div>
|
||||
|
||||
@@ -62,13 +65,14 @@ const { t } = useI18n()
|
||||
const props = defineProps<{ show: boolean }>()
|
||||
const emit = defineEmits<{ close: []; sent: [] }>()
|
||||
|
||||
const sendMethod = ref<'auto' | 'lightning' | 'onchain' | 'ecash'>('auto')
|
||||
const sendMethod = ref<'auto' | 'lightning' | 'onchain' | 'ecash' | 'ark'>('auto')
|
||||
const amount = ref<number>(0)
|
||||
const dest = ref('')
|
||||
const processing = ref(false)
|
||||
const error = ref('')
|
||||
const resultTxid = ref('')
|
||||
const resultHash = ref('')
|
||||
const resultArk = ref('')
|
||||
const ecashToken = ref('')
|
||||
|
||||
const effectiveMethod = computed(() => {
|
||||
@@ -84,6 +88,7 @@ function close() {
|
||||
error.value = ''
|
||||
resultTxid.value = ''
|
||||
resultHash.value = ''
|
||||
resultArk.value = ''
|
||||
ecashToken.value = ''
|
||||
emit('close')
|
||||
}
|
||||
@@ -99,10 +104,20 @@ async function send() {
|
||||
ecashToken.value = ''
|
||||
resultTxid.value = ''
|
||||
resultHash.value = ''
|
||||
resultArk.value = ''
|
||||
|
||||
const method = effectiveMethod.value
|
||||
try {
|
||||
if (method === 'ecash') {
|
||||
if (method === 'ark') {
|
||||
if (!dest.value.trim()) { error.value = 'Enter an Ark address, invoice or lightning address'; return }
|
||||
await rpcClient.call<{ sent: boolean }>({
|
||||
method: 'wallet.ark-send',
|
||||
params: { destination: dest.value.trim(), amount_sats: amount.value },
|
||||
// Ark sends can wait on round participation.
|
||||
timeout: 130000,
|
||||
})
|
||||
resultArk.value = `Sent ${amount.value.toLocaleString()} sats via Ark`
|
||||
} else if (method === 'ecash') {
|
||||
const res = await rpcClient.call<{ token: string }>({
|
||||
method: 'wallet.ecash-send',
|
||||
params: { amount_sats: amount.value },
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<template>
|
||||
<BaseModal :show="show" :title="t('transactions.title')" max-width="max-w-2xl" content-class="max-h-[90vh] flex flex-col" @close="close">
|
||||
<!-- Mobile: cap at ~60% of the LIVE visible viewport (not dvh — see
|
||||
syncViewportHeightVar in main.ts) so the tx list doesn't fill the screen. -->
|
||||
<BaseModal :show="show" :title="t('transactions.title')" max-width="max-w-2xl" content-class="max-h-[calc(var(--visual-viewport-height,100dvh)*0.6)] md:max-h-[90vh] flex flex-col" @close="close">
|
||||
<!-- Rail filter: instant ecash micro-payments pile up fast and bury
|
||||
on-chain/Lightning rows; chips keep the standard txs reachable. -->
|
||||
<div v-if="transactions.length > 0" class="flex gap-1.5 mb-3 shrink-0 flex-wrap">
|
||||
@@ -72,7 +74,7 @@
|
||||
<span
|
||||
v-else
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium"
|
||||
:class="tx.kind === 'lightning' ? 'bg-yellow-500/15 text-yellow-400' : tx.kind === 'cashu' ? 'bg-purple-500/15 text-purple-400' : 'bg-blue-500/15 text-blue-400'"
|
||||
:class="tx.kind === 'lightning' ? 'bg-yellow-500/15 text-yellow-400' : tx.kind === 'cashu' ? 'bg-purple-500/15 text-purple-400' : tx.kind === 'ark' ? 'bg-teal-500/15 text-teal-400' : 'bg-blue-500/15 text-blue-400'"
|
||||
>
|
||||
{{ kindLabel(tx) }}
|
||||
</span>
|
||||
@@ -111,7 +113,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 +124,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<Array<{ key: FilterKey; label: string }>>(() => [
|
||||
{ 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<FilterKey>('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 +163,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 ''
|
||||
}
|
||||
|
||||
|
||||
@@ -148,11 +148,107 @@
|
||||
Joining federations lands with the Fedimint client backend.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- ===================== Ark ===================== -->
|
||||
<div v-show="activeTab === 'ark'">
|
||||
<div class="flex items-start gap-2 mb-4">
|
||||
<p class="text-white/60 text-sm flex-1">
|
||||
Ark holds self-custodial off-chain bitcoin via an Ark server. Funds stay recoverable on-chain even if the server disappears.
|
||||
</p>
|
||||
<span v-if="arkStatus && !arkStatus.available" class="shrink-0 text-[10px] px-2 py-0.5 rounded-full font-medium bg-orange-500/15 text-orange-400">Not installed</span>
|
||||
<span v-else-if="arkStatus?.config?.network && arkStatus.config.network !== 'mainnet'" class="shrink-0 text-[10px] px-2 py-0.5 rounded-full font-medium bg-teal-500/15 text-teal-400">{{ arkStatus.config.network }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingArk" class="py-6 text-center text-white/50 text-sm">Checking Ark wallet…</div>
|
||||
|
||||
<template v-else>
|
||||
<p v-if="arkStatus && !arkStatus.available" class="text-white/40 text-sm text-center py-2 mb-4">
|
||||
Install the <span class="text-white/70">Ark Wallet</span> app from the app store to enable Ark payments.
|
||||
</p>
|
||||
|
||||
<!-- Balances -->
|
||||
<div v-if="arkStatus?.available" class="grid grid-cols-3 gap-2 mb-4">
|
||||
<div class="p-3 bg-white/5 rounded-lg text-center">
|
||||
<p class="text-[11px] text-white/40 mb-1">Spendable</p>
|
||||
<p class="text-sm text-teal-400 font-medium">{{ (arkBalance?.spendable_sats ?? 0).toLocaleString() }} sats</p>
|
||||
</div>
|
||||
<div class="p-3 bg-white/5 rounded-lg text-center">
|
||||
<p class="text-[11px] text-white/40 mb-1">Pending</p>
|
||||
<p class="text-sm text-white/70 font-medium">{{ (arkBalance?.pending_sats ?? 0).toLocaleString() }} sats</p>
|
||||
</div>
|
||||
<div class="p-3 bg-white/5 rounded-lg text-center">
|
||||
<p class="text-[11px] text-white/40 mb-1">On-chain</p>
|
||||
<p class="text-sm text-white/70 font-medium">{{ (arkBalance?.onchain_sats ?? 0).toLocaleString() }} sats</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Receive address -->
|
||||
<div v-if="arkStatus?.available" class="mb-4">
|
||||
<div class="flex gap-2">
|
||||
<button @click="fetchArkAddress(false)" :disabled="arkBusy" class="flex-1 glass-button px-3 py-2 rounded-lg text-xs font-medium disabled:opacity-50">Ark address</button>
|
||||
<button @click="fetchArkAddress(true)" :disabled="arkBusy" class="flex-1 glass-button px-3 py-2 rounded-lg text-xs font-medium disabled:opacity-50">On-chain (boarding) address</button>
|
||||
</div>
|
||||
<div v-if="arkAddress" class="mt-2 flex items-center gap-2 p-3 bg-white/5 rounded-lg">
|
||||
<span class="text-xs font-mono text-white/90 break-all flex-1">{{ arkAddress }}</span>
|
||||
<button @click="copyArkAddress" class="p-2 rounded-lg hover:bg-white/10 text-white/50 hover:text-white shrink-0" :title="arkCopied ? 'Copied' : 'Copy'">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Board / offboard -->
|
||||
<div v-if="arkStatus?.available" class="flex gap-2 mb-4">
|
||||
<button
|
||||
@click="boardArk"
|
||||
:disabled="arkBusy || (arkBalance?.onchain_sats ?? 0) === 0"
|
||||
class="flex-1 glass-button px-3 py-2 rounded-lg text-xs font-medium disabled:opacity-50"
|
||||
title="Lift on-chain funds into Ark"
|
||||
>{{ arkBoarding ? 'Boarding…' : 'Board on-chain funds' }}</button>
|
||||
<button
|
||||
@click="offboardArk"
|
||||
:disabled="arkBusy || (arkBalance?.spendable_sats ?? 0) === 0"
|
||||
class="flex-1 glass-button px-3 py-2 rounded-lg text-xs font-medium disabled:opacity-50"
|
||||
title="Move all Ark funds back on-chain"
|
||||
>{{ arkOffboarding ? 'Offboarding…' : 'Offboard to on-chain' }}</button>
|
||||
</div>
|
||||
|
||||
<!-- Server configuration -->
|
||||
<div class="mb-3 space-y-2">
|
||||
<label class="text-white/60 text-sm block">Ark server</label>
|
||||
<input v-model="arkConfig.ark_server" type="text" placeholder="https://ark.signet.2nd.dev" class="w-full input-glass font-mono" />
|
||||
<label class="text-white/60 text-sm block">Esplora (chain source)</label>
|
||||
<input v-model="arkConfig.esplora" type="text" placeholder="https://esplora.signet.2nd.dev" class="w-full input-glass font-mono" />
|
||||
<label class="text-white/60 text-sm block">Network</label>
|
||||
<select v-model="arkConfig.network" class="w-full input-glass">
|
||||
<option value="signet">signet</option>
|
||||
<option value="mainnet">mainnet</option>
|
||||
<option value="regtest">regtest</option>
|
||||
</select>
|
||||
<p class="text-[11px] text-white/40">
|
||||
Applied when the Ark wallet is created — an existing wallet stays bound to its server.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="arkError" class="mb-3 alert-error">{{ arkError }}</div>
|
||||
<div v-if="arkOk" class="mb-3 text-xs text-green-400">{{ arkOk }}</div>
|
||||
|
||||
<div class="flex gap-3 mt-4">
|
||||
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
|
||||
<button
|
||||
@click="saveArkConfig"
|
||||
:disabled="arkBusy || !arkConfig.ark_server.trim() || !arkConfig.esplora.trim()"
|
||||
class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
|
||||
>{{ savingArk ? 'Saving…' : 'Save' }}</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import BaseModal from '@/components/BaseModal.vue'
|
||||
@@ -167,8 +263,9 @@ const tabs = [
|
||||
{ key: 'channels' as const, label: 'Channels' },
|
||||
{ key: 'cashu' as const, label: 'Cashu Mints' },
|
||||
{ key: 'fedimint' as const, label: 'Fedimint Federations' },
|
||||
{ key: 'ark' as const, label: 'Ark' },
|
||||
]
|
||||
const activeTab = ref<'channels' | 'cashu' | 'fedimint'>('channels')
|
||||
const activeTab = ref<'channels' | 'cashu' | 'fedimint' | 'ark'>('channels')
|
||||
|
||||
// Backed by wallet.fedimint-list / -join / -leave (fedimint-clientd HTTP bridge).
|
||||
// Join degrades gracefully with a clear error if the Fedimint client app isn't installed.
|
||||
@@ -194,12 +291,37 @@ const joiningFed = ref(false)
|
||||
const fedError = ref('')
|
||||
const fedJoinedOk = ref(false)
|
||||
|
||||
// ---- Ark (barkd sidecar, wallet.ark-* RPCs) ----
|
||||
interface ArkStatus {
|
||||
available: boolean
|
||||
wallet_ready: boolean
|
||||
config?: { network: string; ark_server: string; esplora: string }
|
||||
}
|
||||
interface ArkBalance {
|
||||
spendable_sats: number
|
||||
pending_sats: number
|
||||
onchain_sats: number
|
||||
}
|
||||
const arkStatus = ref<ArkStatus | null>(null)
|
||||
const arkBalance = ref<ArkBalance | null>(null)
|
||||
const arkConfig = ref({ network: 'signet', ark_server: '', esplora: '' })
|
||||
const arkAddress = ref('')
|
||||
const arkCopied = ref(false)
|
||||
const loadingArk = ref(false)
|
||||
const savingArk = ref(false)
|
||||
const arkBoarding = ref(false)
|
||||
const arkOffboarding = ref(false)
|
||||
const arkError = ref('')
|
||||
const arkOk = ref('')
|
||||
const arkBusy = computed(() => savingArk.value || arkBoarding.value || arkOffboarding.value)
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(open) => {
|
||||
if (open) {
|
||||
loadMints()
|
||||
if (fedimintBackendReady) loadFederations()
|
||||
loadArk()
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -309,10 +431,111 @@ async function joinFederation() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadArk() {
|
||||
loadingArk.value = true
|
||||
arkError.value = ''
|
||||
arkOk.value = ''
|
||||
arkAddress.value = ''
|
||||
try {
|
||||
const status = await rpcClient.call<ArkStatus>({ method: 'wallet.ark-status' })
|
||||
arkStatus.value = status
|
||||
if (status.config) arkConfig.value = { ...status.config }
|
||||
if (status.available) {
|
||||
arkBalance.value = await rpcClient.call<ArkBalance>({ method: 'wallet.ark-balance' })
|
||||
} else {
|
||||
arkBalance.value = null
|
||||
}
|
||||
} catch {
|
||||
arkStatus.value = { available: false, wallet_ready: false }
|
||||
arkBalance.value = null
|
||||
} finally {
|
||||
loadingArk.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchArkAddress(onchain: boolean) {
|
||||
arkError.value = ''
|
||||
arkOk.value = ''
|
||||
try {
|
||||
const res = await rpcClient.call<{ address: string }>({
|
||||
method: 'wallet.ark-address',
|
||||
params: { onchain },
|
||||
})
|
||||
arkAddress.value = res.address
|
||||
arkCopied.value = false
|
||||
} catch (err: unknown) {
|
||||
arkError.value = err instanceof Error ? err.message : 'Failed to get address'
|
||||
}
|
||||
}
|
||||
|
||||
async function copyArkAddress() {
|
||||
if (!arkAddress.value) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(arkAddress.value)
|
||||
arkCopied.value = true
|
||||
} catch {
|
||||
/* clipboard unavailable (http) — the address is selectable */
|
||||
}
|
||||
}
|
||||
|
||||
async function boardArk() {
|
||||
arkBoarding.value = true
|
||||
arkError.value = ''
|
||||
arkOk.value = ''
|
||||
try {
|
||||
// Boards everything; waits on Ark round participation, so give it room.
|
||||
await rpcClient.call({ method: 'wallet.ark-board', timeout: 130000 })
|
||||
arkOk.value = 'Boarding started — funds appear as spendable once the round confirms.'
|
||||
arkBalance.value = await rpcClient.call<ArkBalance>({ method: 'wallet.ark-balance' })
|
||||
emit('changed')
|
||||
} catch (err: unknown) {
|
||||
arkError.value = err instanceof Error ? err.message : 'Failed to board funds'
|
||||
} finally {
|
||||
arkBoarding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function offboardArk() {
|
||||
arkOffboarding.value = true
|
||||
arkError.value = ''
|
||||
arkOk.value = ''
|
||||
try {
|
||||
await rpcClient.call({ method: 'wallet.ark-offboard', timeout: 130000 })
|
||||
arkOk.value = 'Offboard requested — funds return on-chain with the next round.'
|
||||
arkBalance.value = await rpcClient.call<ArkBalance>({ method: 'wallet.ark-balance' })
|
||||
emit('changed')
|
||||
} catch (err: unknown) {
|
||||
arkError.value = err instanceof Error ? err.message : 'Failed to offboard funds'
|
||||
} finally {
|
||||
arkOffboarding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveArkConfig() {
|
||||
savingArk.value = true
|
||||
arkError.value = ''
|
||||
arkOk.value = ''
|
||||
try {
|
||||
const res = await rpcClient.call<{ config: ArkStatus['config'] }>({
|
||||
method: 'wallet.ark-configure',
|
||||
params: { ...arkConfig.value },
|
||||
})
|
||||
if (res.config) arkConfig.value = { ...res.config }
|
||||
arkOk.value = 'Ark configuration saved.'
|
||||
emit('changed')
|
||||
} catch (err: unknown) {
|
||||
arkError.value = err instanceof Error ? err.message : 'Failed to save Ark configuration'
|
||||
} finally {
|
||||
savingArk.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
mintError.value = ''
|
||||
mintsSavedOk.value = false
|
||||
fedError.value = ''
|
||||
arkError.value = ''
|
||||
arkOk.value = ''
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -198,7 +198,6 @@ export interface NostrConsentRequest {
|
||||
reject: () => void
|
||||
}
|
||||
|
||||
|
||||
export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
const isOpen = ref(false)
|
||||
const url = ref('')
|
||||
|
||||
@@ -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<WalletTransaction[]>([])
|
||||
|
||||
// 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
|
||||
|
||||
@@ -441,16 +441,16 @@
|
||||
switch to the other if it has enough balance. -->
|
||||
<div class="space-y-2">
|
||||
<button
|
||||
v-for="b in (['cashu', 'fedimint'] as const)"
|
||||
v-for="b in (['cashu', 'fedimint', 'ark'] as const)"
|
||||
:key="b"
|
||||
@click="ecashPlan.chosen = b"
|
||||
:disabled="ecashBalanceOf(b) < getItemPrice(payItem.access)"
|
||||
class="w-full px-4 py-3 rounded-xl flex items-center gap-3 text-left border transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
:class="ecashPlan.chosen === b ? 'border-green-400/70 bg-green-400/10' : 'border-white/10 bg-white/5 hover:bg-white/10'"
|
||||
>
|
||||
<span class="text-xl shrink-0">{{ b === 'cashu' ? '🥜' : '🤝' }}</span>
|
||||
<span class="text-xl shrink-0">{{ b === 'cashu' ? '🥜' : b === 'fedimint' ? '🤝' : '⚓' }}</span>
|
||||
<span class="flex-1 min-w-0">
|
||||
<span class="block text-base text-white">{{ b === 'cashu' ? 'Cashu' : 'Fedimint' }}</span>
|
||||
<span class="block text-base text-white">{{ b === 'cashu' ? 'Cashu' : b === 'fedimint' ? 'Fedimint' : 'Ark' }}</span>
|
||||
<span class="block text-xs text-white/50">Balance: {{ ecashBalanceOf(b).toLocaleString() }} sats<span v-if="ecashBalanceOf(b) < getItemPrice(payItem.access)"> · not enough</span></span>
|
||||
</span>
|
||||
<svg v-if="ecashPlan.chosen === b" class="w-5 h-5 text-green-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -707,10 +707,11 @@ const payMode = ref<'choose' | 'ecash-confirm' | 'qr'>('choose')
|
||||
// Ecash confirmation step: after the user picks "pay from this node's ecash",
|
||||
// we look at both balances, decide which backend covers the price, and show a
|
||||
// confirm screen so they see (and can switch) which ecash is spent (#3).
|
||||
type EcashBackend = 'cashu' | 'fedimint'
|
||||
type EcashBackend = 'cashu' | 'fedimint' | 'ark'
|
||||
const ecashPlan = ref<{
|
||||
cashu: number
|
||||
fedimint: number
|
||||
ark: number
|
||||
total: number
|
||||
chosen: EcashBackend | null
|
||||
} | null>(null)
|
||||
@@ -1135,7 +1136,7 @@ async function pollOnchain(address: string) {
|
||||
/** Spendable balance for a given ecash backend in the current plan. */
|
||||
function ecashBalanceOf(b: EcashBackend): number {
|
||||
if (!ecashPlan.value) return 0
|
||||
return b === 'cashu' ? ecashPlan.value.cashu : ecashPlan.value.fedimint
|
||||
return b === 'cashu' ? ecashPlan.value.cashu : b === 'fedimint' ? ecashPlan.value.fedimint : ecashPlan.value.ark
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1152,23 +1153,25 @@ async function prepareEcashPay() {
|
||||
try {
|
||||
let cashu = 0
|
||||
let fedimint = 0
|
||||
let ark = 0
|
||||
try {
|
||||
const res = await rpcClient.call<{ cashu_sats?: number; fedimint_sats?: number; total_sats?: number; balance_sats?: number }>({
|
||||
const res = await rpcClient.call<{ cashu_sats?: number; fedimint_sats?: number; ark_sats?: number; total_sats?: number; balance_sats?: number }>({
|
||||
method: 'wallet.ecash-balance',
|
||||
})
|
||||
cashu = res?.cashu_sats ?? res?.balance_sats ?? 0
|
||||
fedimint = res?.fedimint_sats ?? 0
|
||||
ark = res?.ark_sats ?? 0
|
||||
} catch {
|
||||
// Couldn't read balances — let the user try anyway (auto backend).
|
||||
}
|
||||
const total = cashu + fedimint
|
||||
// Prefer Cashu when it covers the price, else Fedimint, else leave null
|
||||
// (insufficient — shown in the confirm screen, Confirm disabled).
|
||||
const total = cashu + fedimint + ark
|
||||
// Prefer Cashu when it covers the price, else Fedimint, else Ark, else
|
||||
// leave null (insufficient — shown in the confirm screen, Confirm disabled).
|
||||
const chosen: EcashBackend | null =
|
||||
cashu >= price ? 'cashu' : fedimint >= price ? 'fedimint' : null
|
||||
ecashPlan.value = { cashu, fedimint, total, chosen }
|
||||
cashu >= price ? 'cashu' : fedimint >= price ? 'fedimint' : ark >= price ? 'ark' : null
|
||||
ecashPlan.value = { cashu, fedimint, ark, total, chosen }
|
||||
if (!chosen) {
|
||||
purchaseError.value = `Not enough ecash: Cashu ${cashu} + Fedimint ${fedimint} sats, need ${price}. Fund a wallet, or pay another way.`
|
||||
purchaseError.value = `Not enough funds: Cashu ${cashu} + Fedimint ${fedimint} + Ark ${ark} sats, need ${price}. Fund a wallet, or pay another way.`
|
||||
}
|
||||
payMode.value = 'ecash-confirm'
|
||||
} finally {
|
||||
|
||||
@@ -630,7 +630,11 @@ async function applyDnsConfig(customServers: string) {
|
||||
const params: { provider: DnsProviderValue; servers?: string[] } = { provider }
|
||||
if (provider === 'custom') { params.servers = customServers.split(',').map(s => s.trim()).filter(s => s.length > 0) }
|
||||
const res = await rpcClient.configureDns(params)
|
||||
networkData.value.dnsProvider = res.provider; networkData.value.dnsServers = res.servers; networkData.value.dnsDoH = res.doh_enabled
|
||||
// Never trust the response shape: an undefined `servers` used to reach the
|
||||
// dnsDisplayLabel computed and crash the whole page render on `.length`.
|
||||
networkData.value.dnsProvider = res?.provider ?? provider
|
||||
networkData.value.dnsServers = Array.isArray(res?.servers) ? res.servers : (params.servers ?? [])
|
||||
networkData.value.dnsDoH = !!res?.doh_enabled
|
||||
showDnsModal.value = false
|
||||
} catch (e) { dnsError.value = e instanceof Error ? e.message : 'DNS configuration failed.' } finally { dnsApplying.value = false }
|
||||
}
|
||||
|
||||
@@ -136,6 +136,14 @@
|
||||
</div>
|
||||
<span class="text-blue-400 text-sm font-medium">{{ walletFedimint.toLocaleString() }} sats</span>
|
||||
</div>
|
||||
<!-- Only rendered once barkd reports a balance — most nodes don't run the Ark sidecar -->
|
||||
<div v-if="(walletArk ?? 0) > 0" class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="w-5 h-5 text-base leading-none flex items-center justify-center" role="img" aria-label="Ark">⚓</span>
|
||||
<span class="text-sm text-white/80">Ark</span>
|
||||
</div>
|
||||
<span class="text-teal-400 text-sm font-medium">{{ (walletArk ?? 0).toLocaleString() }} sats</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="home-card-buttons grid gap-2 mt-auto pt-4 shrink-0" :class="isDev ? 'grid-cols-4' : 'grid-cols-3'">
|
||||
<button @click="$emit('showSend')" class="home-card-btn px-3 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
|
||||
@@ -173,7 +181,7 @@ export 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<{
|
||||
@@ -183,6 +191,7 @@ const props = defineProps<{
|
||||
walletLightning: number
|
||||
walletEcash: number
|
||||
walletFedimint: number
|
||||
walletArk?: number
|
||||
walletTransactions: WalletTransaction[]
|
||||
isDev: boolean
|
||||
}>()
|
||||
|
||||
@@ -94,6 +94,7 @@
|
||||
</Teleport>
|
||||
|
||||
<!-- WiFi Scan Modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showWifiModal" class="fixed inset-0 bg-black/60 backdrop-blur-md z-50 flex items-center justify-center p-4" @click.self="$emit('closeWifi')">
|
||||
<div class="glass-card p-6 w-full max-w-md">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
@@ -161,8 +162,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- DNS Configuration Modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showDnsModal" class="fixed inset-0 bg-black/60 backdrop-blur-md z-50 flex items-center justify-center p-4" @click.self="$emit('closeDns')">
|
||||
<div class="glass-card p-6 w-full max-w-md">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
@@ -223,6 +226,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user