Merge pull request 'feat(wallet): Ark protocol support (barkd sidecar) + wallet UI' (#78) from ark-wallet-barkd into main
All checks were successful
Demo images / Build & push demo images (push) Successful in 2m38s
All checks were successful
Demo images / Build & push demo images (push) Successful in 2m38s
This commit is contained in:
commit
24be2e9e69
@ -298,6 +298,25 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "barkd",
|
||||
"title": "Ark Wallet",
|
||||
"version": "0.3.0",
|
||||
"description": "Ark protocol wallet daemon (barkd). Lets the node hold self-custodial off-chain bitcoin via an Ark server; the wallet talks to it over a local REST API. Signet by default while Ark matures.",
|
||||
"icon": "/assets/img/app-icons/bark.png",
|
||||
"author": "Second",
|
||||
"category": "money",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/barkd:0.3.0",
|
||||
"repoUrl": "https://gitlab.com/ark-bitcoin/bark",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
"3535:3535"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/barkd:/data"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jellyfin",
|
||||
"title": "Jellyfin",
|
||||
|
||||
32
apps/barkd/Dockerfile
Normal file
32
apps/barkd/Dockerfile
Normal file
@ -0,0 +1,32 @@
|
||||
# barkd — Ark protocol wallet daemon (https://gitlab.com/ark-bitcoin/bark).
|
||||
# No official upstream image exists (their GitLab registry is empty), so we
|
||||
# package the pinned, checksum-verified release binary ourselves and push to
|
||||
# the node registry — same approach as fmcd. Keep the version in lockstep with
|
||||
# the REST shapes coded in core/archipelago/src/wallet/ark_client.rs (0.3.0).
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
ARG BARKD_VERSION=0.3.0
|
||||
ARG BARKD_SHA256=8562fa27386bae666ed62fa95c92d40f7bdb20d22525f75799adfc16adaaedb3
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends ca-certificates curl && \
|
||||
curl -fsSL "https://gitlab.com/api/v4/projects/ark-bitcoin%2Fbark/packages/generic/release-assets/bark-${BARKD_VERSION}/barkd-${BARKD_VERSION}-linux-x86_64" \
|
||||
-o /usr/local/bin/barkd && \
|
||||
echo "${BARKD_SHA256} /usr/local/bin/barkd" | sha256sum -c - && \
|
||||
chmod a+x /usr/local/bin/barkd && \
|
||||
apt-get purge -y curl && apt-get autoremove -y && \
|
||||
apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod a+x /entrypoint.sh
|
||||
|
||||
# The wallet itself is created over REST by the node's Ark bridge
|
||||
# (wallet.ark-* RPCs) — the container just runs the daemon.
|
||||
ENV BARKD_DATADIR=/data \
|
||||
BARKD_BIND_HOST=0.0.0.0 \
|
||||
BARKD_BIND_PORT=3535
|
||||
|
||||
EXPOSE 3535
|
||||
VOLUME /data
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
15
apps/barkd/entrypoint.sh
Normal file
15
apps/barkd/entrypoint.sh
Normal file
@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
# Install the node-provided auth secret (64-char hex from the manifest's
|
||||
# generated barkd-secret) so the wallet bridge can derive the matching Bearer
|
||||
# token, then start the daemon. Without BARKD_SECRET, barkd generates its own
|
||||
# random token in the datadir and the bridge won't authenticate — so treat a
|
||||
# failed refresh as fatal rather than starting an unreachable daemon.
|
||||
set -eu
|
||||
|
||||
if [ -n "${BARKD_SECRET:-}" ]; then
|
||||
# `secret refresh` prints the Bearer token on stdout — never log it.
|
||||
barkd secret refresh --secret "$BARKD_SECRET" >/dev/null
|
||||
unset BARKD_SECRET
|
||||
fi
|
||||
|
||||
exec barkd
|
||||
76
apps/barkd/manifest.yml
Normal file
76
apps/barkd/manifest.yml
Normal file
@ -0,0 +1,76 @@
|
||||
app:
|
||||
id: barkd
|
||||
name: Ark Wallet
|
||||
version: 0.3.0
|
||||
description: Ark protocol wallet daemon (barkd). Lets the node hold self-custodial off-chain bitcoin via an Ark server; the wallet talks to it over a local REST API. Signet by default while Ark matures.
|
||||
|
||||
container:
|
||||
# barkd packaged from the pinned upstream release binary (no usable
|
||||
# upstream image exists — their registry is empty). Built from
|
||||
# apps/barkd/Dockerfile and pushed to the node registry. Pin the tag to
|
||||
# match the REST shapes coded in core/archipelago/src/wallet/ark_client.rs
|
||||
# (validated against barkd 0.3.0 on signet, 2026-07-14).
|
||||
image: 146.59.87.168:3000/lfg2025/barkd:0.3.0
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
# The entrypoint installs the shared secret below via `barkd secret
|
||||
# refresh` (so the wallet bridge can derive the matching Bearer token) and
|
||||
# execs the daemon. The Ark wallet itself is created over REST by the
|
||||
# bridge on first use (wallet.ark-* RPCs) with the node's ark_config
|
||||
# (default: Second's public signet server) — no host provisioning needed.
|
||||
generated_secrets:
|
||||
- name: barkd-secret
|
||||
kind: hex32
|
||||
secret_env:
|
||||
- key: BARKD_SECRET
|
||||
secret_file: barkd-secret
|
||||
data_uid: "1000:1000"
|
||||
|
||||
dependencies:
|
||||
- storage: 1Gi
|
||||
|
||||
resources:
|
||||
# barkd is a single wallet daemon (SQLite + a gRPC conn to the Ark server
|
||||
# + esplora polling); steady state is tiny. Cap it so a stuck sync can't
|
||||
# starve the node.
|
||||
cpu_limit: 1
|
||||
memory_limit: 512Mi
|
||||
disk_limit: 1Gi
|
||||
|
||||
security:
|
||||
readonly_root: true
|
||||
# Needs outbound HTTPS to the Ark server (ark.signet.2nd.dev) and the
|
||||
# esplora chain source, plus the published REST port for the wallet
|
||||
# bridge. No inbound requirements beyond that.
|
||||
network_policy: bridge
|
||||
|
||||
ports:
|
||||
# barkd REST bound to 3535 in-container (BARKD_BIND_PORT); 3535 is free on
|
||||
# the host (see port_allocator.rs). The Rust bridge targets
|
||||
# http://127.0.0.1:3535.
|
||||
- host: 3535
|
||||
container: 3535
|
||||
protocol: tcp
|
||||
|
||||
volumes:
|
||||
# Holds the wallet DB, mnemonic and auth token. ARK funds are recoverable
|
||||
# on-chain from this datadir (unilateral exit) — include it in backups.
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/barkd
|
||||
target: /data
|
||||
options: [rw]
|
||||
|
||||
environment:
|
||||
- BARKD_DATADIR=/data
|
||||
- BARKD_BIND_HOST=0.0.0.0
|
||||
- BARKD_BIND_PORT=3535
|
||||
|
||||
# All /api/v1/* routes require the Bearer token, so an HTTP probe would 401
|
||||
# forever — use a TCP probe like fmcd (the host-side lifecycle layer
|
||||
# verifies reachability).
|
||||
health_check:
|
||||
type: tcp
|
||||
endpoint: localhost:3535
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
223
core/archipelago/src/api/rpc/ark.rs
Normal file
223
core/archipelago/src/api/rpc/ark.rs
Normal file
@ -0,0 +1,223 @@
|
||||
//! Ark protocol RPCs — bridge to the `barkd` sidecar.
|
||||
//!
|
||||
//! Companion to the Cashu RPCs in [`super::wallet`] and the Fedimint RPCs in
|
||||
//! [`super::fedimint`]. Holding VTXOs, joining rounds and unilateral exits are
|
||||
//! delegated to the barkd container via [`crate::wallet::ark_client::ArkClient`];
|
||||
//! here we expose the node's JSON-RPC surface. barkd keeps its own movement
|
||||
//! history, so unlike Fedimint there is no local transaction log.
|
||||
|
||||
use super::RpcHandler;
|
||||
use crate::wallet::ark_client::{self, ArkClient};
|
||||
use anyhow::Result;
|
||||
|
||||
impl RpcHandler {
|
||||
/// `wallet.ark-status` — sidecar reachability, wallet fingerprint, network
|
||||
/// and Ark server parameters. Soft-fails into `available: false` so the
|
||||
/// settings UI can render an install/enable hint instead of an error.
|
||||
pub(super) async fn handle_wallet_ark_status(&self) -> Result<serde_json::Value> {
|
||||
let config = ark_client::load_config(&self.config.data_dir).await;
|
||||
let client = match ArkClient::from_node(&self.config.data_dir).await {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
return Ok(serde_json::json!({
|
||||
"available": false,
|
||||
"wallet_ready": false,
|
||||
"config": config,
|
||||
}))
|
||||
}
|
||||
};
|
||||
// Make sure the wallet exists before reporting (idempotent, cheap once
|
||||
// created).
|
||||
let _ = ark_client::ensure_wallet(&self.config.data_dir).await;
|
||||
|
||||
let wallet = client.wallet_info().await.ok();
|
||||
let info = client.ark_info().await.ok();
|
||||
Ok(serde_json::json!({
|
||||
"available": true,
|
||||
"wallet_ready": wallet.is_some(),
|
||||
"wallet": wallet,
|
||||
"ark_info": info,
|
||||
"config": config,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `wallet.ark-balance` — off-chain (spendable + pending) and on-chain
|
||||
/// sats. Soft-fails to zeros so unified balances still render.
|
||||
pub(super) async fn handle_wallet_ark_balance(&self) -> Result<serde_json::Value> {
|
||||
let client = match ArkClient::from_node(&self.config.data_dir).await {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
return Ok(serde_json::json!({
|
||||
"balance_sats": 0,
|
||||
"spendable_sats": 0,
|
||||
"pending_sats": 0,
|
||||
"onchain_sats": 0,
|
||||
}))
|
||||
}
|
||||
};
|
||||
let bal = client.balance().await.unwrap_or_else(|_| serde_json::json!({}));
|
||||
let sat = |key: &str| bal.get(key).and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let spendable = sat("spendable_sat");
|
||||
let pending = sat("pending_in_round_sat")
|
||||
+ sat("pending_board_sat")
|
||||
+ sat("pending_lightning_send_sat")
|
||||
+ sat("claimable_lightning_receive_sat")
|
||||
+ bal.get("pending_exit_sat").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let onchain = client
|
||||
.onchain_balance()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|b| {
|
||||
b.get("total_sat")
|
||||
.or_else(|| b.get("confirmed_sat"))
|
||||
.and_then(|v| v.as_u64())
|
||||
})
|
||||
.unwrap_or(0);
|
||||
Ok(serde_json::json!({
|
||||
"balance_sats": spendable,
|
||||
"spendable_sats": spendable,
|
||||
"pending_sats": pending,
|
||||
"onchain_sats": onchain,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `wallet.ark-address` — fresh Ark (`tark1…`) receive address; pass
|
||||
/// `{"onchain": true}` for an on-chain boarding address instead.
|
||||
pub(super) async fn handle_wallet_ark_address(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let _ = ark_client::ensure_wallet(&self.config.data_dir).await;
|
||||
let client = ArkClient::from_node(&self.config.data_dir).await?;
|
||||
let onchain = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("onchain"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let address = if onchain {
|
||||
client.onchain_address().await?
|
||||
} else {
|
||||
client.ark_address().await?
|
||||
};
|
||||
Ok(serde_json::json!({ "address": address, "onchain": onchain }))
|
||||
}
|
||||
|
||||
/// `wallet.ark-send` — pay an Ark address, BOLT11 invoice, LNURL or
|
||||
/// lightning address from Ark funds.
|
||||
pub(super) async fn handle_wallet_ark_send(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let destination = params
|
||||
.get("destination")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing destination"))?;
|
||||
// Optional for BOLT11 invoices that carry their own amount.
|
||||
let amount_sats = params.get("amount_sats").and_then(|v| v.as_u64());
|
||||
if amount_sats == Some(0) {
|
||||
return Err(anyhow::anyhow!("Amount must be greater than zero"));
|
||||
}
|
||||
let comment = params.get("comment").and_then(|v| v.as_str());
|
||||
|
||||
let client = ArkClient::from_node(&self.config.data_dir).await?;
|
||||
let movement = client.send(destination, amount_sats, comment).await?;
|
||||
Ok(serde_json::json!({
|
||||
"sent": true,
|
||||
"movement": movement,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `wallet.ark-invoice` — BOLT11 invoice that lands as Ark funds when paid.
|
||||
pub(super) async fn handle_wallet_ark_invoice(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let amount_sats = params
|
||||
.get("amount_sats")
|
||||
.and_then(|v| v.as_u64())
|
||||
.filter(|&v| v > 0)
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing amount_sats"))?;
|
||||
|
||||
let _ = ark_client::ensure_wallet(&self.config.data_dir).await;
|
||||
let client = ArkClient::from_node(&self.config.data_dir).await?;
|
||||
let res = client.lightning_invoice(amount_sats).await?;
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
/// `wallet.ark-board` — lift on-chain funds into Ark VTXOs. Omitting
|
||||
/// `amount_sats` boards everything.
|
||||
pub(super) async fn handle_wallet_ark_board(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let amount_sats = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("amount_sats"))
|
||||
.and_then(|v| v.as_u64());
|
||||
if amount_sats == Some(0) {
|
||||
return Err(anyhow::anyhow!("Amount must be greater than zero"));
|
||||
}
|
||||
let client = ArkClient::from_node(&self.config.data_dir).await?;
|
||||
let res = client.board(amount_sats).await?;
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
/// `wallet.ark-offboard` — collaboratively move all VTXOs back on-chain,
|
||||
/// optionally to a provided address (defaults to the wallet's own).
|
||||
pub(super) async fn handle_wallet_ark_offboard(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let address = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("address"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty());
|
||||
let client = ArkClient::from_node(&self.config.data_dir).await?;
|
||||
let res = client.offboard_all(address).await?;
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
/// `wallet.ark-history` — barkd movements mapped to the unified
|
||||
/// transaction shape (kind = "ark"), newest first.
|
||||
pub(super) async fn handle_wallet_ark_history(&self) -> Result<serde_json::Value> {
|
||||
let mut transactions = ark_client::load_ark_txs(&self.config.data_dir).await;
|
||||
transactions.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
|
||||
Ok(serde_json::json!({ "transactions": transactions }))
|
||||
}
|
||||
|
||||
/// `wallet.ark-configure` — set the Ark server / esplora / network used
|
||||
/// when the barkd wallet is (re)created. Does NOT migrate an existing
|
||||
/// wallet: barkd binds a wallet to its Ark server at creation.
|
||||
pub(super) async fn handle_wallet_ark_configure(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let mut config = ark_client::load_config(&self.config.data_dir).await;
|
||||
for (key, field) in [
|
||||
("network", &mut config.network as &mut String),
|
||||
("ark_server", &mut config.ark_server),
|
||||
("esplora", &mut config.esplora),
|
||||
] {
|
||||
if let Some(v) = params.get(key).and_then(|v| v.as_str()) {
|
||||
let v = v.trim();
|
||||
if !v.is_empty() {
|
||||
*field = v.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
if !matches!(config.network.as_str(), "signet" | "mainnet" | "regtest") {
|
||||
return Err(anyhow::anyhow!(
|
||||
"network must be one of: signet, mainnet, regtest"
|
||||
));
|
||||
}
|
||||
ark_client::save_config(&self.config.data_dir, &config).await?;
|
||||
Ok(serde_json::json!({ "config": config }))
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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),
|
||||
|
||||
479
core/archipelago/src/wallet/ark_client.rs
Normal file
479
core/archipelago/src/wallet/ark_client.rs
Normal file
@ -0,0 +1,479 @@
|
||||
//! Thin HTTP bridge to the `barkd` sidecar container (Ark protocol).
|
||||
//!
|
||||
//! Same shape as [`super::fedimint_client`]: the heavy `bark-wallet` SDK stays
|
||||
//! OUT of this binary. The `barkd` daemon (in `apps/barkd`) holds the Ark
|
||||
//! wallet (VTXOs, rounds, unilateral exits) and we speak its REST API
|
||||
//! (`/api/v1/*`, Bearer auth). Endpoint/JSON shapes target barkd 0.3.0 and
|
||||
//! must be pinned to the vendored image tag.
|
||||
//!
|
||||
//! ARK is on-chain-anchored: VTXOs expire (`vtxo_expiry_delta` blocks) and the
|
||||
//! barkd daemon refreshes them by joining rounds on its own — the bridge never
|
||||
//! has to schedule anything. Unlike Cashu/Fedimint, funds survive the sidecar
|
||||
//! dying (the wallet mnemonic in barkd's datadir can unilaterally exit
|
||||
//! on-chain), so back up `/var/lib/archipelago/barkd`.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use tokio::fs;
|
||||
|
||||
const BARKD_TIMEOUT_SECS: u64 = 15;
|
||||
/// Send/board/offboard can wait on Ark round participation (signet rounds run
|
||||
/// every 5 minutes), so give mutating calls generous room.
|
||||
const BARKD_HEAVY_TIMEOUT_SECS: u64 = 120;
|
||||
|
||||
/// Default host port the `barkd` container is mapped to (its in-container
|
||||
/// REST port; 3535 is unused elsewhere on the node — see `port_allocator`).
|
||||
const DEFAULT_BARKD_URL: &str = "http://127.0.0.1:3535";
|
||||
|
||||
/// Shared secret between the barkd container and this bridge. The barkd
|
||||
/// manifest generates it via `generated_secrets: [{barkd-secret, hex32}]`; the
|
||||
/// container entrypoint installs it with `barkd secret refresh --secret` and
|
||||
/// the bridge derives the matching Bearer token from the same file.
|
||||
const BARKD_SECRET: &str = "barkd-secret";
|
||||
|
||||
/// Wallet configuration used when the bridge has to create the barkd wallet
|
||||
/// (first use). Persisted so operators can point at their own Ark server.
|
||||
/// Defaults target Second's public signet deployment while Ark matures —
|
||||
/// mainnet needs an explicit opt-in edit of `wallet/ark_config.json`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ArkConfig {
|
||||
pub network: String,
|
||||
pub ark_server: String,
|
||||
pub esplora: String,
|
||||
}
|
||||
|
||||
impl Default for ArkConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
network: "signet".to_string(),
|
||||
ark_server: "https://ark.signet.2nd.dev".to_string(),
|
||||
esplora: "https://esplora.signet.2nd.dev".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ARK_CONFIG_FILE: &str = "wallet/ark_config.json";
|
||||
|
||||
pub async fn load_config(data_dir: &Path) -> ArkConfig {
|
||||
match fs::read_to_string(data_dir.join(ARK_CONFIG_FILE)).await {
|
||||
Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
|
||||
Err(_) => ArkConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn save_config(data_dir: &Path, config: &ArkConfig) -> Result<()> {
|
||||
let dir = data_dir.join("wallet");
|
||||
fs::create_dir_all(&dir)
|
||||
.await
|
||||
.context("Failed to create wallet dir")?;
|
||||
let content = serde_json::to_string_pretty(config).context("Failed to serialize ark config")?;
|
||||
fs::write(data_dir.join(ARK_CONFIG_FILE), content)
|
||||
.await
|
||||
.context("Failed to write ark config")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Encode barkd's Bearer token from the raw 32-byte shared secret:
|
||||
/// base64url-nopad of `<version 0x00><32-byte secret>` (see barkd `AuthToken`).
|
||||
fn encode_auth_token(secret: &[u8; 32]) -> String {
|
||||
let mut buf = Vec::with_capacity(33);
|
||||
buf.push(0u8);
|
||||
buf.extend_from_slice(secret);
|
||||
URL_SAFE_NO_PAD.encode(&buf)
|
||||
}
|
||||
|
||||
fn secret_hex_to_token(hex: &str) -> Result<String> {
|
||||
let hex = hex.trim();
|
||||
if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
anyhow::bail!("barkd-secret must be exactly 64 hex characters");
|
||||
}
|
||||
let mut secret = [0u8; 32];
|
||||
for (i, byte) in secret.iter_mut().enumerate() {
|
||||
*byte = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).expect("validated hex");
|
||||
}
|
||||
Ok(encode_auth_token(&secret))
|
||||
}
|
||||
|
||||
/// HTTP client for a `barkd` instance.
|
||||
pub struct ArkClient {
|
||||
base_url: String,
|
||||
token: String,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl ArkClient {
|
||||
pub fn new(base_url: &str, token: &str) -> Result<Self> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(BARKD_HEAVY_TIMEOUT_SECS))
|
||||
.build()
|
||||
.context("Failed to build HTTP client for barkd")?;
|
||||
Ok(Self {
|
||||
base_url: base_url.trim_end_matches('/').to_string(),
|
||||
token: token.to_string(),
|
||||
client,
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve URL + auth token from env / node secret, with sane defaults.
|
||||
/// URL: `BARKD_URL` else the default mapped port. Token: `BARKD_TOKEN`
|
||||
/// (already-encoded Bearer token) else derived from the shared
|
||||
/// `barkd-secret` the manifest generated for the container.
|
||||
pub async fn from_node(data_dir: &Path) -> Result<Self> {
|
||||
let base_url = std::env::var("BARKD_URL").unwrap_or_else(|_| DEFAULT_BARKD_URL.to_string());
|
||||
let token = match std::env::var("BARKD_TOKEN") {
|
||||
Ok(t) if !t.is_empty() => t,
|
||||
_ => {
|
||||
let path = data_dir.join("secrets").join(BARKD_SECRET);
|
||||
let hex = fs::read_to_string(&path).await.context(
|
||||
"Ark wallet not configured (no BARKD_TOKEN and no barkd-secret \
|
||||
secret). Install the Ark (barkd) app.",
|
||||
)?;
|
||||
secret_hex_to_token(&hex)?
|
||||
}
|
||||
};
|
||||
Self::new(&base_url, &token)
|
||||
}
|
||||
|
||||
fn auth(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
req.bearer_auth(&self.token)
|
||||
}
|
||||
|
||||
async fn get(&self, path: &str) -> Result<serde_json::Value> {
|
||||
let url = format!("{}{}", self.base_url, path);
|
||||
let resp = self
|
||||
.auth(self.client.get(&url))
|
||||
.timeout(std::time::Duration::from_secs(BARKD_TIMEOUT_SECS))
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("barkd GET {path} failed (is it running?)"))?;
|
||||
Self::parse(resp, path).await
|
||||
}
|
||||
|
||||
async fn post(&self, path: &str, body: serde_json::Value) -> Result<serde_json::Value> {
|
||||
let url = format!("{}{}", self.base_url, path);
|
||||
let resp = self
|
||||
.auth(self.client.post(&url))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("barkd POST {path} failed (is it running?)"))?;
|
||||
Self::parse(resp, path).await
|
||||
}
|
||||
|
||||
async fn parse(resp: reqwest::Response, path: &str) -> Result<serde_json::Value> {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
if !status.is_success() {
|
||||
// barkd errors are `{"message": "..."}`; surface the message.
|
||||
let msg = serde_json::from_str::<serde_json::Value>(&text)
|
||||
.ok()
|
||||
.and_then(|v| v.get("message").and_then(|m| m.as_str()).map(String::from))
|
||||
.unwrap_or(text);
|
||||
anyhow::bail!("barkd {path} returned {status}: {msg}");
|
||||
}
|
||||
if text.is_empty() {
|
||||
return Ok(serde_json::json!({}));
|
||||
}
|
||||
serde_json::from_str(&text)
|
||||
.with_context(|| format!("barkd {path} returned non-JSON: {text}"))
|
||||
}
|
||||
|
||||
/// `GET /api/v1/wallet` — wallet info (fingerprint, network, config).
|
||||
/// Errors with "No wallet set" until `create_wallet` has run.
|
||||
pub async fn wallet_info(&self) -> Result<serde_json::Value> {
|
||||
self.get("/api/v1/wallet").await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/wallet/create` — create (or restore, with a mnemonic) the
|
||||
/// barkd wallet. Idempotent guard is on the caller (`ensure_wallet`).
|
||||
pub async fn create_wallet(&self, config: &ArkConfig) -> Result<serde_json::Value> {
|
||||
self.post(
|
||||
"/api/v1/wallet/create",
|
||||
serde_json::json!({
|
||||
"network": config.network,
|
||||
"ark_server": config.ark_server,
|
||||
"chain_source": { "esplora": { "url": config.esplora } },
|
||||
}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/wallet/balance` — off-chain balance breakdown, in sats.
|
||||
pub async fn balance(&self) -> Result<serde_json::Value> {
|
||||
self.get("/api/v1/wallet/balance").await
|
||||
}
|
||||
|
||||
/// Spendable off-chain sats (0 on any missing field, never an error once
|
||||
/// the call itself succeeds).
|
||||
pub async fn spendable_sats(&self) -> Result<u64> {
|
||||
let bal = self.balance().await?;
|
||||
Ok(bal.get("spendable_sat").and_then(|v| v.as_u64()).unwrap_or(0))
|
||||
}
|
||||
|
||||
/// `GET /api/v1/onchain/balance` — the wallet's on-chain (boarding) funds.
|
||||
pub async fn onchain_balance(&self) -> Result<serde_json::Value> {
|
||||
self.get("/api/v1/onchain/balance").await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/wallet/addresses/next` — fresh Ark (`tark1…`) address.
|
||||
pub async fn ark_address(&self) -> Result<String> {
|
||||
let res = self.post("/api/v1/wallet/addresses/next", serde_json::json!({})).await?;
|
||||
res.get("address")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.ok_or_else(|| anyhow::anyhow!("barkd address: no address in response"))
|
||||
}
|
||||
|
||||
/// `POST /api/v1/onchain/addresses/next` — fresh on-chain boarding address.
|
||||
pub async fn onchain_address(&self) -> Result<String> {
|
||||
let res = self.post("/api/v1/onchain/addresses/next", serde_json::json!({})).await?;
|
||||
res.get("address")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.ok_or_else(|| anyhow::anyhow!("barkd onchain address: no address in response"))
|
||||
}
|
||||
|
||||
/// `POST /api/v1/wallet/send` — pay an Ark address, BOLT11 invoice, LNURL
|
||||
/// or lightning address from off-chain funds. Returns the movement barkd
|
||||
/// reports for the payment.
|
||||
pub async fn send(
|
||||
&self,
|
||||
destination: &str,
|
||||
amount_sats: Option<u64>,
|
||||
comment: Option<&str>,
|
||||
) -> Result<serde_json::Value> {
|
||||
self.post(
|
||||
"/api/v1/wallet/send",
|
||||
serde_json::json!({
|
||||
"destination": destination,
|
||||
"amount_sat": amount_sats,
|
||||
"comment": comment,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/lightning/receives/invoice` — BOLT11 invoice that lands
|
||||
/// as an Ark VTXO when paid.
|
||||
pub async fn lightning_invoice(&self, amount_sats: u64) -> Result<serde_json::Value> {
|
||||
self.post(
|
||||
"/api/v1/lightning/receives/invoice",
|
||||
serde_json::json!({ "amount_sat": amount_sats }),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/boards/board-amount` (or `board-all` when `amount_sats`
|
||||
/// is None) — lift on-chain funds into Ark VTXOs.
|
||||
pub async fn board(&self, amount_sats: Option<u64>) -> Result<serde_json::Value> {
|
||||
match amount_sats {
|
||||
Some(sats) => {
|
||||
self.post(
|
||||
"/api/v1/boards/board-amount",
|
||||
serde_json::json!({ "amount_sat": sats }),
|
||||
)
|
||||
.await
|
||||
}
|
||||
None => self.post("/api/v1/boards/board-all", serde_json::json!({})).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /api/v1/wallet/offboard/all` — move all VTXOs back on-chain via a
|
||||
/// collaborative round.
|
||||
pub async fn offboard_all(&self, address: Option<&str>) -> Result<serde_json::Value> {
|
||||
self.post(
|
||||
"/api/v1/wallet/offboard/all",
|
||||
serde_json::json!({ "address": address }),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/wallet/movements` — barkd's own movement history. This is
|
||||
/// authoritative (includes receives we never initiated), so unlike the
|
||||
/// Fedimint bridge there is no local tx log to maintain.
|
||||
pub async fn movements(&self) -> Result<Vec<serde_json::Value>> {
|
||||
let res = self.get("/api/v1/wallet/movements").await?;
|
||||
Ok(res.as_array().cloned().unwrap_or_default())
|
||||
}
|
||||
|
||||
/// `GET /api/v1/wallet/ark-info` — connected Ark server parameters.
|
||||
pub async fn ark_info(&self) -> Result<serde_json::Value> {
|
||||
self.get("/api/v1/wallet/ark-info").await
|
||||
}
|
||||
}
|
||||
|
||||
/// Idempotently make sure barkd has a wallet, creating one with the node's
|
||||
/// Ark config on first use. Best-effort no-op when the sidecar isn't
|
||||
/// installed/running yet — mirrors `fedimint_client::ensure_default_federation`.
|
||||
pub async fn ensure_wallet(data_dir: &Path) -> Result<()> {
|
||||
let client = match ArkClient::from_node(data_dir).await {
|
||||
Ok(c) => c,
|
||||
Err(_) => return Ok(()), // barkd not configured yet
|
||||
};
|
||||
if client.wallet_info().await.is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
let config = load_config(data_dir).await;
|
||||
match client.create_wallet(&config).await {
|
||||
Ok(_) => {
|
||||
tracing::info!(
|
||||
"created barkd Ark wallet ({} via {})",
|
||||
config.network,
|
||||
config.ark_server
|
||||
);
|
||||
// Persist the effective config so the settings UI shows what the
|
||||
// wallet was actually created with.
|
||||
let _ = save_config(data_dir, &config).await;
|
||||
}
|
||||
Err(e) => tracing::debug!("barkd wallet auto-create skipped: {e}"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Total spendable Ark sats, soft-failing to 0 when the sidecar is not
|
||||
/// installed or unreachable so unified balances still render.
|
||||
pub async fn spendable_sats_or_zero(data_dir: &Path) -> u64 {
|
||||
match ArkClient::from_node(data_dir).await {
|
||||
Ok(client) => client.spendable_sats().await.unwrap_or(0),
|
||||
Err(_) => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Map barkd movements into unified [`EcashTransaction`] history entries
|
||||
/// (kind = "ark"). Best-effort: empty on any error, never blocks history.
|
||||
pub async fn load_ark_txs(data_dir: &Path) -> Vec<crate::wallet::ecash::EcashTransaction> {
|
||||
let client = match ArkClient::from_node(data_dir).await {
|
||||
Ok(c) => c,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
let movements = match client.movements().await {
|
||||
Ok(m) => m,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
movements
|
||||
.iter()
|
||||
.filter_map(movement_to_tx)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Convert one barkd `Movement` into an [`EcashTransaction`]. `None` for
|
||||
/// zero-delta movements (e.g. internal refreshes) so history stays meaningful.
|
||||
fn movement_to_tx(m: &serde_json::Value) -> Option<crate::wallet::ecash::EcashTransaction> {
|
||||
use crate::wallet::ecash::{EcashTransaction, TransactionType};
|
||||
|
||||
let delta = m.get("effective_balance_sat").and_then(|v| v.as_i64())?;
|
||||
if delta == 0 {
|
||||
return None;
|
||||
}
|
||||
let tx_type = if delta < 0 {
|
||||
TransactionType::Send
|
||||
} else {
|
||||
TransactionType::Receive
|
||||
};
|
||||
// `time` holds created/updated/completed; prefer the completion time.
|
||||
let timestamp = m
|
||||
.get("time")
|
||||
.and_then(|t| {
|
||||
t.get("completed_at")
|
||||
.or_else(|| t.get("updated_at"))
|
||||
.or_else(|| t.get("created_at"))
|
||||
})
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
// Describe via the recipient list (send) or receive source when present.
|
||||
let peer = m
|
||||
.get("sent_to")
|
||||
.or_else(|| m.get("received_on"))
|
||||
.and_then(|v| v.as_array())
|
||||
.and_then(|a| a.first())
|
||||
.and_then(|d| {
|
||||
d.get("destination")
|
||||
.or_else(|| d.get("address"))
|
||||
.or_else(|| d.get("invoice"))
|
||||
.and_then(|v| v.as_str())
|
||||
})
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let subsystem = m
|
||||
.get("subsystem")
|
||||
.map(|s| match s {
|
||||
serde_json::Value::String(v) => v.clone(),
|
||||
other => other
|
||||
.as_object()
|
||||
.and_then(|o| o.keys().next().cloned())
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let description = if delta < 0 {
|
||||
format!("Sent via Ark{}", suffix(&subsystem))
|
||||
} else {
|
||||
format!("Received via Ark{}", suffix(&subsystem))
|
||||
};
|
||||
Some(EcashTransaction {
|
||||
id: format!("ark-{}", m.get("id").and_then(|v| v.as_u64()).unwrap_or(0)),
|
||||
tx_type,
|
||||
amount_sats: delta.unsigned_abs(),
|
||||
timestamp,
|
||||
description,
|
||||
mint_url: String::new(),
|
||||
peer,
|
||||
kind: "ark".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn suffix(subsystem: &str) -> String {
|
||||
if subsystem.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" ({subsystem})")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn token_encoding_matches_barkd_format() {
|
||||
// barkd token = base64url-nopad(0x00 || secret); 33 bytes -> 44 chars.
|
||||
let token = secret_hex_to_token(&"ab".repeat(32)).unwrap();
|
||||
assert_eq!(token.len(), 44);
|
||||
let bytes = URL_SAFE_NO_PAD.decode(&token).unwrap();
|
||||
assert_eq!(bytes.len(), 33);
|
||||
assert_eq!(bytes[0], 0);
|
||||
assert_eq!(&bytes[1..], &[0xabu8; 32]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_rejects_bad_secret() {
|
||||
assert!(secret_hex_to_token("deadbeef").is_err(), "too short");
|
||||
assert!(secret_hex_to_token(&"zz".repeat(32)).is_err(), "not hex");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn movement_maps_to_history_entry() {
|
||||
let m = serde_json::json!({
|
||||
"id": 7,
|
||||
"effective_balance_sat": -1500,
|
||||
"time": { "completed_at": "2026-07-14T12:00:00Z" },
|
||||
"sent_to": [{ "destination": "tark1abc" }],
|
||||
"subsystem": "arkoor",
|
||||
});
|
||||
let tx = movement_to_tx(&m).expect("mapped");
|
||||
assert_eq!(tx.amount_sats, 1500);
|
||||
assert_eq!(tx.kind, "ark");
|
||||
assert_eq!(tx.peer, "tark1abc");
|
||||
assert!(matches!(
|
||||
tx.tx_type,
|
||||
crate::wallet::ecash::TransactionType::Send
|
||||
));
|
||||
|
||||
// Zero-delta refresh movements are dropped.
|
||||
let refresh = serde_json::json!({ "id": 8, "effective_balance_sat": 0 });
|
||||
assert!(movement_to_tx(&refresh).is_none());
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -132,6 +132,11 @@ const SEED_WALLET = {
|
||||
channel_sats: 8_250_000,
|
||||
ecash_sats: 250_000,
|
||||
ecash_tokens: 12,
|
||||
// Ark (barkd sidecar) demo balances — signet defaults
|
||||
ark_sats: 75_000,
|
||||
ark_pending_sats: 0,
|
||||
ark_onchain_sats: 20_000,
|
||||
ark_config: { network: 'signet', ark_server: 'https://ark.signet.2nd.dev', esplora: 'https://esplora.signet.2nd.dev' },
|
||||
block_height: 892451,
|
||||
transactions: [
|
||||
{ tx_hash: 'ab12cd34ef5678901234567890abcdef12345678', amount_sats: 2_000_000, direction: 'incoming', num_confirmations: 142, block_height: 892310, time_stamp: Math.floor(Date.now()/1000) - 86400, label: 'Channel funding', total_fees: 0, dest_addresses: [] },
|
||||
@ -2251,6 +2256,63 @@ app.post('/rpc/v1', (req, res) => {
|
||||
const total = (mockState.federations || []).reduce((s, f) => s + (f.balance_sats || 0), 0)
|
||||
return res.json({ result: { balance_sats: total } })
|
||||
}
|
||||
case 'wallet.ark-status': {
|
||||
return res.json({
|
||||
result: {
|
||||
available: true,
|
||||
wallet_ready: true,
|
||||
wallet: { network: walletState.ark_config.network },
|
||||
ark_info: { server: walletState.ark_config.ark_server },
|
||||
config: walletState.ark_config,
|
||||
},
|
||||
})
|
||||
}
|
||||
case 'wallet.ark-balance': {
|
||||
return res.json({
|
||||
result: {
|
||||
balance_sats: walletState.ark_sats,
|
||||
spendable_sats: walletState.ark_sats,
|
||||
pending_sats: walletState.ark_pending_sats,
|
||||
onchain_sats: walletState.ark_onchain_sats,
|
||||
},
|
||||
})
|
||||
}
|
||||
case 'wallet.ark-address': {
|
||||
const onchain = params?.onchain === true
|
||||
const address = onchain ? 'tb1q' + randomHex(16) : 'tark1' + randomHex(28)
|
||||
return res.json({ result: { address, onchain } })
|
||||
}
|
||||
case 'wallet.ark-board': {
|
||||
const amt = params?.amount_sats ?? walletState.ark_onchain_sats
|
||||
walletState.ark_onchain_sats = Math.max(0, walletState.ark_onchain_sats - amt)
|
||||
walletState.ark_sats += amt
|
||||
return res.json({ result: { boarded_sats: amt } })
|
||||
}
|
||||
case 'wallet.ark-offboard': {
|
||||
const amt = walletState.ark_sats
|
||||
walletState.ark_sats = 0
|
||||
walletState.ark_onchain_sats += amt
|
||||
return res.json({ result: { offboarded_sats: amt } })
|
||||
}
|
||||
case 'wallet.ark-send': {
|
||||
const amt = params?.amount_sats || 1000
|
||||
walletState.ark_sats = Math.max(0, walletState.ark_sats - amt)
|
||||
return res.json({ result: { sent: true, movement: { id: 1, effective_balance_sat: -amt } } })
|
||||
}
|
||||
case 'wallet.ark-invoice': {
|
||||
return res.json({ result: { invoice: 'lntbs' + randomHex(64) } })
|
||||
}
|
||||
case 'wallet.ark-history': {
|
||||
return res.json({ result: { transactions: [] } })
|
||||
}
|
||||
case 'wallet.ark-configure': {
|
||||
const cfg = { ...walletState.ark_config }
|
||||
for (const key of ['network', 'ark_server', 'esplora']) {
|
||||
if (typeof params?.[key] === 'string' && params[key].trim()) cfg[key] = params[key].trim()
|
||||
}
|
||||
walletState.ark_config = cfg
|
||||
return res.json({ result: { config: cfg } })
|
||||
}
|
||||
case 'content.request-onchain': {
|
||||
const price = params?.price_sats || 500
|
||||
return res.json({
|
||||
@ -3296,6 +3358,8 @@ app.post('/rpc/v1', (req, res) => {
|
||||
{ type: 'receive', amount_sats: 50000, timestamp: new Date(Date.now() - 86400000).toISOString(), note: 'Minted from Lightning' },
|
||||
{ type: 'send', amount_sats: 5000, timestamp: new Date(Date.now() - 43200000).toISOString(), note: 'Sent ecash token' },
|
||||
{ type: 'receive', amount_sats: 10000, timestamp: new Date(Date.now() - 3600000).toISOString(), note: 'Redeemed token' },
|
||||
// Ark movement in the unified history shape (see wallet.ark-history / load_ark_txs)
|
||||
{ id: 'ark-1', tx_type: 'receive', type: 'receive', amount_sats: 75000, timestamp: new Date(Date.now() - 7200000).toISOString(), description: 'Received via Ark', note: 'Received via Ark', mint_url: '', peer: '', kind: 'ark' },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
BIN
neode-ui/public/assets/img/app-icons/bark.png
Normal file
BIN
neode-ui/public/assets/img/app-icons/bark.png
Normal file
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')
|
||||
|
||||
@ -72,7 +72,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 +111,7 @@ interface WalletTransaction {
|
||||
label: string
|
||||
block_height: number
|
||||
// Which rail the transaction happened on; absent = onchain (older backends)
|
||||
kind?: 'onchain' | 'lightning' | 'cashu' | 'fedimint'
|
||||
kind?: 'onchain' | 'lightning' | 'cashu' | 'fedimint' | 'ark'
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
@ -122,19 +122,23 @@ const props = defineProps<{
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
const { t } = useI18n()
|
||||
|
||||
type FilterKey = 'all' | 'onchain' | 'lightning' | 'ecash'
|
||||
const filters: Array<{ key: FilterKey; label: string }> = [
|
||||
type FilterKey = 'all' | 'onchain' | 'lightning' | 'ecash' | 'ark'
|
||||
// The Ark chip only appears once an Ark transaction exists — most nodes
|
||||
// don't run the barkd sidecar.
|
||||
const filters = computed<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 +161,7 @@ function kindLabel(tx: WalletTransaction): string {
|
||||
if (tx.kind === 'lightning') return '⚡ Lightning'
|
||||
if (tx.kind === 'cashu') return 'Cashu'
|
||||
if (tx.kind === 'fedimint') return 'Fedimint'
|
||||
if (tx.kind === 'ark') return 'Ark'
|
||||
return ''
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}>()
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user