Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3768395e59 | ||
|
|
6041eb6306 | ||
|
|
3be6f45fe8 | ||
|
|
3f52e4cd78 | ||
|
|
76d565fb18 | ||
|
|
6effc6b574 | ||
|
|
db52c06a72 | ||
|
|
4b14b62e74 |
@@ -90,8 +90,9 @@ rustls-pemfile = "1.0"
|
||||
webpki = { package = "rustls-webpki", version = "0.101" }
|
||||
reqwest = { version = "0.11", default-features = false, features = ["json", "socks", "rustls-tls", "stream"] }
|
||||
|
||||
# Nostr (node discovery + NIP-44 encrypted peer handshake)
|
||||
nostr-sdk = { version = "0.44", features = ["nip04", "nip44"] }
|
||||
# Nostr (node discovery + NIP-44 encrypted peer handshake).
|
||||
# nip06: NIP-06 key derivation for the Minibits @minibits.cash profile flow.
|
||||
nostr-sdk = { version = "0.44", features = ["nip04", "nip06", "nip44"] }
|
||||
|
||||
# Backup encryption (DID identity export) + TOTP 2FA encryption
|
||||
argon2 = "0.5.3"
|
||||
|
||||
@@ -269,6 +269,8 @@ impl RpcHandler {
|
||||
"wallet.ecash-network" => self.handle_wallet_ecash_network().await,
|
||||
"wallet.ecash-set-network" => self.handle_wallet_ecash_set_network(params).await,
|
||||
"wallet.ecash-seed-status" => self.handle_wallet_ecash_seed_status().await,
|
||||
"wallet.ecash-lnaddress" => self.handle_wallet_ecash_lnaddress().await,
|
||||
"wallet.ecash-lnaddress-claim" => self.handle_wallet_ecash_lnaddress_claim().await,
|
||||
"wallet.ecash-seed-reveal" => self.handle_wallet_ecash_seed_reveal(params).await,
|
||||
"wallet.ecash-restore" => self.handle_wallet_ecash_restore(params).await,
|
||||
"wallet.ecash-seed-import" => self.handle_wallet_ecash_seed_import(params).await,
|
||||
|
||||
@@ -421,6 +421,30 @@ impl RpcHandler {
|
||||
}))
|
||||
}
|
||||
|
||||
/// `wallet.ecash-lnaddress` — the node's Minibits Lightning address
|
||||
/// (`<name>@minibits.cash`, LUD-16), derived from and authenticated by the
|
||||
/// ecash wallet's own seed. Registers the profile on first use; safe to call
|
||||
/// on every open of the Cashu receive screen (it is idempotent).
|
||||
pub(super) async fn handle_wallet_ecash_lnaddress(&self) -> Result<serde_json::Value> {
|
||||
crate::wallet::minibits::lnaddress(&self.config.data_dir).await
|
||||
}
|
||||
|
||||
/// `wallet.ecash-lnaddress-claim` — redeem any Lightning payments that
|
||||
/// arrived on the node's Minibits address as ecash. Returns the sats swept in
|
||||
/// (0 when nothing was waiting), so the UI can refresh its balance.
|
||||
/// `failed_count` is non-zero when a payment was fetched (and so already
|
||||
/// consumed server-side) but couldn't be redeemed yet — it stays queued
|
||||
/// and is retried automatically, but the UI should tell the operator
|
||||
/// rather than let it be a silent, unbounded wait.
|
||||
pub(super) async fn handle_wallet_ecash_lnaddress_claim(&self) -> Result<serde_json::Value> {
|
||||
let outcome = crate::wallet::minibits::claim_and_redeem(&self.config.data_dir).await?;
|
||||
Ok(serde_json::json!({
|
||||
"claimed_count": outcome.claimed_count,
|
||||
"received_sats": outcome.received_sats,
|
||||
"failed_count": outcome.failed_count,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_wallet_networking_profits(&self) -> Result<serde_json::Value> {
|
||||
let summary = profits::get_networking_profits(&self.config.data_dir).await?;
|
||||
Ok(serde_json::json!({
|
||||
|
||||
@@ -0,0 +1,800 @@
|
||||
//! The Minibits `@minibits.cash` Lightning address (LUD-16) for the node's
|
||||
//! ecash wallet.
|
||||
//!
|
||||
//! ## Why this exists next to the mint client
|
||||
//!
|
||||
//! The ecash wallet already talks to `mint.minibits.cash` as a plain Cashu
|
||||
//! mint (`wallet::mint_client`): mint/melt quotes, swap, receive. That gives the
|
||||
//! node ecash *from* Minibits, but not a *name* at Minibits. A human-readable
|
||||
//! Lightning address like `braveharbor42@minibits.cash` is a separate service —
|
||||
//! the Minibits profile API at `api.minibits.cash/v3` — and it is what lets any
|
||||
//! Lightning wallet pay this node by typing an address, with the payment landing
|
||||
//! as ecash.
|
||||
//!
|
||||
//! ## Identity: the ecash wallet *is* the Minibits wallet
|
||||
//!
|
||||
//! Minibits ties an address to a wallet by `seedHash`, and authenticates the
|
||||
//! wallet with a NIP-06 Nostr keypair. Both come from the *existing* NUT-13
|
||||
//! ecash phrase (`wallet::nut13`), so there is no second secret to back up:
|
||||
//!
|
||||
//! - `seedHash = sha256(mnemonic.to_seed(""))` — the exact bytes the Minibits
|
||||
//! app hashes, so restoring the same phrase in the Minibits app recovers the
|
||||
//! same address (and vice-versa).
|
||||
//! - Nostr keys via NIP-06 at `m/44'/1237'/0'/0/0`. `nostr_sdk::Keys::from_mnemonic`
|
||||
//! uses that path with an empty BIP-39 passphrase — byte-for-byte the derivation
|
||||
//! the Minibits app (nostr-tools `accountFromSeedWords`) does, verified against
|
||||
//! the crate's own NIP-06 test vector.
|
||||
//!
|
||||
//! ## Flow (all verified against the live v3 API)
|
||||
//!
|
||||
//! 1. `POST /auth/challenge {pubkey}` → `{challenge, createdAt}`.
|
||||
//! 2. Sign a NIP-42 kind-22242 event (`relay` + `challenge` tags, server's
|
||||
//! `createdAt`) with the Nostr key.
|
||||
//! 3. `POST /auth/verify {pubkey, challenge, signature}` → JWT access token.
|
||||
//! 4. `POST /profile {walletId, seedHash}` → the assigned `lud16`/`nip05`.
|
||||
//! Idempotent per pubkey: re-registering returns the existing address.
|
||||
//! 5. `POST /claim {seedHash}` → NIP-04-encrypted Cashu tokens for Lightning
|
||||
//! payments sent to the address; decrypt with the Nostr key + the server's
|
||||
//! Nostr pubkey, then redeem through `ecash::receive_token`.
|
||||
//!
|
||||
//! Only runs on the mainnet ecash network — Minibits is a mainnet service, and a
|
||||
//! testnet node must not register a profile or hit the production API.
|
||||
//!
|
||||
//! ## A claim can't be re-fetched — so nothing gets dropped
|
||||
//!
|
||||
//! `/claim` consumes a payment server-side the instant it's returned. A local
|
||||
//! failure after that point (mint briefly unreachable, a stale cached server
|
||||
//! key, a crash mid-loop) must not silently lose the coins, so every fetched
|
||||
//! token is persisted to `MinibitsState::pending_claims` *before* decrypt/
|
||||
//! redeem is attempted, and stays there — retried on every later poll — until
|
||||
//! it succeeds. `ClaimOutcome::failed_count` reports how many are still
|
||||
//! stuck so the caller can surface it instead of it being a log-only event.
|
||||
//! Separately, `ensure_mint_accepted` keeps the Minibits mint on the node's
|
||||
//! accepted-mints allow-list: the address is inherently backed by that one
|
||||
//! mint, so an operator-edited allow-list must never be able to cause this
|
||||
//! same kind of loss via `receive_token`'s mint check.
|
||||
|
||||
use super::ecash::{self, EcashNetwork};
|
||||
use super::nut13;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use base64::Engine;
|
||||
use nostr_sdk::nips::{nip04, nip06::FromMnemonic};
|
||||
use nostr_sdk::{EventBuilder, Kind, RelayUrl, Tag, TagKind, Timestamp, ToBech32};
|
||||
use rand::seq::SliceRandom;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::path::Path;
|
||||
use tokio::fs;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// Minibits profile/LNURL API. Confirmed live: `/v3/auth/challenge`,
|
||||
/// `/v3/profile`, `/v3/claim` (the older `/v2` host no longer serves profiles).
|
||||
const API_BASE: &str = "https://api.minibits.cash/v3";
|
||||
/// The relay named in the NIP-42 auth event. Matches the value the Minibits app
|
||||
/// sends and the relay the service publishes in its NIP-05 record.
|
||||
const RELAY_URL: &str = "wss://relay.minibits.cash";
|
||||
/// NIP-42 client authentication event kind.
|
||||
const AUTH_KIND: u16 = 22242;
|
||||
/// The Minibits service Nostr pubkey that NIP-04-encrypts claimed tokens. Used
|
||||
/// only as a fallback: the authoritative value is read from the address's own
|
||||
/// LUD-16 metadata (`nostrPubkey`) at claim time, so a Minibits key rotation
|
||||
/// does not strand claims.
|
||||
const FALLBACK_SERVER_NOSTR_PUBKEY: &str =
|
||||
"beeb48407a6f087ea8f76dc384a5d88c67ced9bd9fb0cdba90930210df3d92e7";
|
||||
/// Re-authenticate this long before the JWT actually expires, so a claim poll
|
||||
/// never races the expiry boundary.
|
||||
const TOKEN_EXPIRY_SKEP_SECS: i64 = 120;
|
||||
|
||||
const STATE_FILE: &str = "wallet/minibits.json";
|
||||
|
||||
/// Small word lists for the generated address name. Uniqueness comes from the
|
||||
/// numeric suffix plus the retry-on-collision below — the Minibits server rejects
|
||||
/// a name already taken by another wallet and we simply draw another, so these do
|
||||
/// not need to be exhaustive (the Minibits app ships lists hundreds long).
|
||||
const ADJECTIVES: &[&str] = &[
|
||||
"calm", "brave", "quiet", "solar", "rapid", "noble", "lunar", "vivid", "amber", "crisp",
|
||||
"eager", "fancy", "gentle", "happy", "jolly", "keen", "lucky", "mellow", "nimble", "proud",
|
||||
"quick", "rusty", "sunny", "tidy", "urban", "vital", "warm", "zesty", "bold", "clever",
|
||||
"daring", "epic", "fiery", "grand", "humble", "iron", "merry", "polar", "sleek", "wild",
|
||||
];
|
||||
const NOUNS: &[&str] = &[
|
||||
"harbor", "meadow", "canyon", "summit", "river", "forest", "island", "comet", "nebula",
|
||||
"orbit", "quartz", "maple", "willow", "falcon", "otter", "badger", "salmon", "crane",
|
||||
"ridge", "creek", "glade", "grove", "prairie", "delta", "cobalt", "onyx", "topaz", "ember",
|
||||
"anchor", "lantern", "beacon", "cabin", "drift", "signal", "thunder", "zephyr", "marble",
|
||||
"pebble", "sequoia", "tundra",
|
||||
];
|
||||
|
||||
/// Persistent state for the node's Minibits address.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct MinibitsState {
|
||||
/// The chosen wallet name (the `name` in `name@minibits.cash`).
|
||||
pub wallet_id: String,
|
||||
/// The full LUD-16 Lightning address, e.g. `braveharbor42@minibits.cash`.
|
||||
pub lud16: String,
|
||||
/// NIP-05 address (Minibits sets this equal to `lud16`).
|
||||
pub nip05: String,
|
||||
/// This node's NIP-06 Nostr pubkey (hex) the profile is bound to.
|
||||
pub nostr_pubkey: String,
|
||||
/// `sha256(seed)` — the wallet identifier Minibits keys claims on.
|
||||
pub seed_hash: String,
|
||||
/// Cached JWT access token.
|
||||
#[serde(default)]
|
||||
pub access_token: String,
|
||||
/// Access-token expiry (unix seconds); 0 when unknown/expired.
|
||||
#[serde(default)]
|
||||
pub access_expires: i64,
|
||||
/// Server Nostr pubkey used to decrypt claims, discovered from LUD-16.
|
||||
#[serde(default)]
|
||||
pub server_nostr_pubkey: String,
|
||||
#[serde(default)]
|
||||
pub created_at: String,
|
||||
/// Raw NIP-04-encrypted claim tokens fetched from `/claim` but not yet
|
||||
/// successfully redeemed. A claim is consumed server-side the instant
|
||||
/// `/claim` returns it, so it is stashed here *before* decrypt/redeem is
|
||||
/// attempted — a local failure (mint briefly down, bad cached server key,
|
||||
/// process crash mid-loop) then retries next poll instead of losing the
|
||||
/// coins outright.
|
||||
#[serde(default)]
|
||||
pub pending_claims: Vec<String>,
|
||||
}
|
||||
|
||||
/// A fresh Nostr keypair + seedHash derived from the node's ecash phrase.
|
||||
struct MinibitsIdentity {
|
||||
keys: nostr_sdk::Keys,
|
||||
seed_hash: String,
|
||||
}
|
||||
|
||||
/// Derive the Minibits identity (NIP-06 Nostr keys + seedHash) from the node's
|
||||
/// ecash mnemonic. Both are deterministic, so the address and claims are
|
||||
/// recoverable from the same 24 words the ecash already lives on.
|
||||
fn derive_identity(phrase: &str, seed: &[u8; 64]) -> Result<MinibitsIdentity> {
|
||||
let keys = nostr_sdk::Keys::from_mnemonic(phrase, None::<&str>)
|
||||
.map_err(|e| anyhow!("NIP-06 derivation failed: {e}"))?;
|
||||
let seed_hash = hex::encode(Sha256::digest(seed));
|
||||
Ok(MinibitsIdentity { keys, seed_hash })
|
||||
}
|
||||
|
||||
/// A Minibits profile record — the fields we read off every profile response.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ProfileRecord {
|
||||
#[serde(rename = "walletId")]
|
||||
wallet_id: String,
|
||||
#[serde(default)]
|
||||
nip05: String,
|
||||
#[serde(default)]
|
||||
lud16: Option<String>,
|
||||
#[serde(default)]
|
||||
pubkey: String,
|
||||
}
|
||||
|
||||
/// Turn a non-2xx Minibits response into a readable error, surfacing the
|
||||
/// server's `error.name`/`error.message` when present.
|
||||
fn minibits_error(status: reqwest::StatusCode, body: &str) -> anyhow::Error {
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(body) {
|
||||
if let Some(err) = v.get("error") {
|
||||
let name = err.get("name").and_then(|n| n.as_str()).unwrap_or("ERROR");
|
||||
let msg = err.get("message").and_then(|m| m.as_str()).unwrap_or("");
|
||||
return anyhow!("Minibits API error {status}: {name} {msg}");
|
||||
}
|
||||
}
|
||||
anyhow!(
|
||||
"Minibits API error {status}: {}",
|
||||
&body[..body.len().min(180)]
|
||||
)
|
||||
}
|
||||
|
||||
fn state_path(data_dir: &Path) -> std::path::PathBuf {
|
||||
data_dir.join(STATE_FILE)
|
||||
}
|
||||
|
||||
async fn load_state(data_dir: &Path) -> Result<Option<MinibitsState>> {
|
||||
let path = state_path(data_dir);
|
||||
match fs::read_to_string(&path).await {
|
||||
Ok(s) if s.trim().is_empty() => Ok(None),
|
||||
Ok(s) => match serde_json::from_str::<MinibitsState>(&s) {
|
||||
Ok(st) if st.wallet_id.is_empty() => Ok(None),
|
||||
Ok(st) => Ok(Some(st)),
|
||||
// Unlike the accepted-mints file, nothing here is a user-editable
|
||||
// security setting — it's a pure mirror of state Minibits already
|
||||
// holds server-side, and registration is idempotent per pubkey
|
||||
// (§ module docs), so re-registering after a corrupt/truncated
|
||||
// read always recovers the *same* address. A node whose disk
|
||||
// filled up mid-write (observed on archy-x250-pa3, 2026-09-08:
|
||||
// this file truncated to 0 bytes) must self-heal on the next open
|
||||
// rather than permanently show "Lightning address unavailable".
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Minibits: {} is corrupt/unreadable ({e}); treating as no profile yet and re-registering",
|
||||
path.display()
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
},
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e).with_context(|| format!("Failed to read {}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the state file 0600 — it holds a bearer JWT. Same sensitivity class as
|
||||
/// the ecash files it sits beside, so it gets the same owner-only mode.
|
||||
async fn save_state(data_dir: &Path, state: &MinibitsState) -> Result<()> {
|
||||
let path = state_path(data_dir);
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.await
|
||||
.context("Failed to create the wallet directory")?;
|
||||
}
|
||||
let content = serde_json::to_string_pretty(state)
|
||||
.context("Failed to serialize the Minibits profile")?;
|
||||
fs::write(&path, content)
|
||||
.await
|
||||
.with_context(|| format!("Failed to write {}", path.display()))?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
|
||||
.await
|
||||
.with_context(|| format!("Failed to chmod 0600 {}", path.display()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read the `exp` claim (unix seconds) from a JWT without verifying it — the
|
||||
/// token comes straight from Minibits over TLS; we only use the expiry to decide
|
||||
/// when to refresh.
|
||||
fn jwt_expiry(token: &str) -> Option<i64> {
|
||||
let payload = token.split('.').nth(1)?;
|
||||
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(payload)
|
||||
.ok()?;
|
||||
let v: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
|
||||
v.get("exp")?.as_i64()
|
||||
}
|
||||
|
||||
/// Run the NIP-42 challenge/verify dance and return the access token plus its
|
||||
/// expiry. Idempotent and cheap enough to redo whenever the cached token lapses.
|
||||
async fn authenticate(
|
||||
client: &reqwest::Client,
|
||||
keys: &nostr_sdk::Keys,
|
||||
) -> Result<(String, i64)> {
|
||||
let ch: serde_json::Value = client
|
||||
.post(format!("{API_BASE}/auth/challenge"))
|
||||
.json(&serde_json::json!({ "pubkey": keys.public_key().to_hex() }))
|
||||
.send()
|
||||
.await
|
||||
.context("Minibits auth challenge request failed")?
|
||||
.error_for_status()
|
||||
.context("Minibits auth challenge rejected")?
|
||||
.json()
|
||||
.await
|
||||
.context("Minibits auth challenge was not JSON")?;
|
||||
|
||||
let challenge = ch["challenge"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow!("Minibits challenge response missing 'challenge'"))?
|
||||
.to_string();
|
||||
let created_at = ch["createdAt"]
|
||||
.as_u64()
|
||||
.ok_or_else(|| anyhow!("Minibits challenge response missing 'createdAt'"))?;
|
||||
|
||||
// Sign a NIP-42 auth event, stamping the server's own createdAt so the
|
||||
// signature lines up with the challenge it was issued for.
|
||||
let unsigned = EventBuilder::new(Kind::from(AUTH_KIND), "")
|
||||
.tag(Tag::relay(
|
||||
RelayUrl::parse(RELAY_URL).context("Invalid Minibits relay URL")?,
|
||||
))
|
||||
.tag(Tag::custom(
|
||||
TagKind::custom("challenge"),
|
||||
vec![challenge.clone()],
|
||||
))
|
||||
.custom_created_at(Timestamp::from(created_at))
|
||||
.build(keys.public_key());
|
||||
let signed = unsigned
|
||||
.sign_with_keys(keys)
|
||||
.map_err(|e| anyhow!("Failed to sign the Minibits auth challenge: {e}"))?;
|
||||
|
||||
let tok: serde_json::Value = client
|
||||
.post(format!("{API_BASE}/auth/verify"))
|
||||
.json(&serde_json::json!({
|
||||
"pubkey": keys.public_key().to_hex(),
|
||||
"challenge": challenge,
|
||||
"signature": hex::encode(signed.sig.serialize()),
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.context("Minibits auth verify request failed")?
|
||||
.error_for_status()
|
||||
.context("Minibits auth verify rejected (bad challenge signature)")?
|
||||
.json()
|
||||
.await
|
||||
.context("Minibits auth verify was not JSON")?;
|
||||
|
||||
let access = tok["accessToken"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow!("Minibits verify response missing 'accessToken'"))?
|
||||
.to_string();
|
||||
let expires = jwt_expiry(&access).unwrap_or_else(|| {
|
||||
chrono::Utc::now().timestamp() + 3600 // conservative fallback
|
||||
});
|
||||
Ok((access, expires))
|
||||
}
|
||||
|
||||
/// True when the cached access token is missing or about to lapse.
|
||||
fn token_is_stale(state: &MinibitsState) -> bool {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
state.access_token.is_empty() || now + TOKEN_EXPIRY_SKEP_SECS >= state.access_expires
|
||||
}
|
||||
|
||||
/// Ensure we hold a valid access token, re-authenticating as needed and folding
|
||||
/// the fresh token back into `state` (which the caller persists).
|
||||
async fn ensure_token(
|
||||
client: &reqwest::Client,
|
||||
state: &mut MinibitsState,
|
||||
keys: &nostr_sdk::Keys,
|
||||
) -> Result<()> {
|
||||
if token_is_stale(state) {
|
||||
let (access, expires) = authenticate(client, keys).await?;
|
||||
state.access_token = access;
|
||||
state.access_expires = expires;
|
||||
debug!("Minibits: authenticated (token valid to {})", expires);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Draw a fresh readable wallet name, Minibits-style: adjective + noun + number.
|
||||
fn generate_wallet_id() -> String {
|
||||
let mut rng = rand::thread_rng();
|
||||
let adj = ADJECTIVES.choose(&mut rng).copied().unwrap_or("quiet");
|
||||
let noun = NOUNS.choose(&mut rng).copied().unwrap_or("harbor");
|
||||
let num = rand::Rng::gen_range(&mut rng, 1..=999);
|
||||
format!("{adj}{noun}{num}")
|
||||
}
|
||||
|
||||
/// Register the profile, returning the assigned address. Retries with a new name
|
||||
/// a handful of times if the generated name is already taken by another wallet.
|
||||
async fn register_profile(
|
||||
client: &reqwest::Client,
|
||||
access: &str,
|
||||
seed_hash: &str,
|
||||
) -> Result<ProfileRecord> {
|
||||
let mut last_err = None;
|
||||
for attempt in 0..6 {
|
||||
let wallet_id = generate_wallet_id();
|
||||
let resp = client
|
||||
.post(format!("{API_BASE}/profile"))
|
||||
.bearer_auth(access)
|
||||
.json(&serde_json::json!({ "walletId": wallet_id, "seedHash": seed_hash }))
|
||||
.send()
|
||||
.await
|
||||
.context("Minibits profile registration request failed")?;
|
||||
let status = resp.status();
|
||||
let body = resp
|
||||
.text()
|
||||
.await
|
||||
.context("Minibits profile response body read failed")?;
|
||||
if status.is_success() {
|
||||
let rec: ProfileRecord = serde_json::from_str(&body)
|
||||
.context("Minibits profile response was not the expected shape")?;
|
||||
return Ok(rec);
|
||||
}
|
||||
// Name collision → draw another. Anything else is fatal.
|
||||
let is_taken = body.contains("ALREADY_EXISTS") || body.contains("already");
|
||||
if is_taken {
|
||||
warn!("Minibits name '{wallet_id}' taken, retrying (attempt {attempt})");
|
||||
last_err = Some(minibits_error(status, &body));
|
||||
continue;
|
||||
}
|
||||
return Err(minibits_error(status, &body));
|
||||
}
|
||||
Err(last_err.unwrap_or_else(|| anyhow!("Could not register a free Minibits name")))
|
||||
}
|
||||
|
||||
/// Fetch the LUD-16 metadata for our own address and read the service's
|
||||
/// `nostrPubkey` — the key that NIP-04-encrypts claimed tokens.
|
||||
async fn discover_server_nostr_pubkey(
|
||||
client: &reqwest::Client,
|
||||
lud16: &str,
|
||||
) -> Result<String> {
|
||||
let (name, domain) = lud16
|
||||
.split_once('@')
|
||||
.ok_or_else(|| anyhow!("Malformed Minibits address '{lud16}'"))?;
|
||||
let url = format!("https://{domain}/.well-known/lnurlp/{name}");
|
||||
let md: serde_json::Value = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.context("Minibits LUD-16 metadata request failed")?
|
||||
.json()
|
||||
.await
|
||||
.context("Minibits LUD-16 metadata was not JSON")?;
|
||||
md.get("nostrPubkey")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| anyhow!("Minibits LUD-16 metadata missing 'nostrPubkey'"))
|
||||
}
|
||||
|
||||
/// Load the ecash phrase, or establish it from the node master seed when this
|
||||
/// node has not materialised one yet — but never fail an address request just
|
||||
/// because a phrase is not on disk; report that clearly instead.
|
||||
async fn ecash_phrase(data_dir: &Path) -> Result<(String, [u8; 64])> {
|
||||
let seed = nut13::load_seed(data_dir)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("The ecash wallet has no seed yet — restore or reveal it first"))?;
|
||||
Ok((seed.phrase(), seed.seed_bytes()))
|
||||
}
|
||||
|
||||
/// Get (registering on first use) the node's Minibits Lightning address.
|
||||
///
|
||||
/// On mainnet this registers a profile with the Minibits server the first time
|
||||
/// and caches it in `wallet/minibits.json`; later calls return the cached address
|
||||
/// and refresh the access token as needed. Registration is idempotent per pubkey,
|
||||
/// so a node that restores the same ecash phrase recovers the same address.
|
||||
pub async fn lnaddress(data_dir: &Path) -> Result<serde_json::Value> {
|
||||
let network = ecash::load_network(data_dir).await;
|
||||
if network == EcashNetwork::Testnet {
|
||||
return Err(anyhow!(
|
||||
"Minibits Lightning addresses are mainnet-only — switch the ecash network to mainnet to set one up"
|
||||
));
|
||||
}
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(20))
|
||||
.build()
|
||||
.context("Failed to build the Minibits HTTP client")?;
|
||||
|
||||
let (phrase, seed) = ecash_phrase(data_dir).await?;
|
||||
let identity = derive_identity(&phrase, &seed)?;
|
||||
|
||||
let mut state = match load_state(data_dir).await? {
|
||||
Some(st) => st,
|
||||
None => {
|
||||
info!("Minibits: no profile yet, registering a new @minibits.cash address");
|
||||
let (access, expires) = authenticate(&client, &identity.keys).await?;
|
||||
let rec = register_profile(&client, &access, &identity.seed_hash).await?;
|
||||
MinibitsState {
|
||||
wallet_id: rec.wallet_id.clone(),
|
||||
lud16: rec
|
||||
.lud16
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("{}@minibits.cash", rec.wallet_id)),
|
||||
nip05: rec.nip05.clone(),
|
||||
nostr_pubkey: rec.pubkey.clone(),
|
||||
seed_hash: identity.seed_hash.clone(),
|
||||
access_token: access,
|
||||
access_expires: expires,
|
||||
server_nostr_pubkey: String::new(),
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
pending_claims: Vec::new(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ensure_token(&client, &mut state, &identity.keys).await?;
|
||||
save_state(data_dir, &state).await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"address": state.lud16,
|
||||
"nip05": state.nip05,
|
||||
"wallet_id": state.wallet_id,
|
||||
"nostr_pubkey": state.nostr_pubkey,
|
||||
"npub": identity.keys.public_key().to_bech32().unwrap_or_default(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Outcome of a claim poll.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ClaimOutcome {
|
||||
pub claimed_count: usize,
|
||||
pub received_sats: u64,
|
||||
/// Claims that were fetched (and so already consumed server-side) but
|
||||
/// still haven't been redeemed after this poll — decrypt/redeem failed
|
||||
/// and they are queued in `pending_claims` for the next poll rather than
|
||||
/// dropped. Non-zero here means real, unswept value the operator should
|
||||
/// know about.
|
||||
pub failed_count: usize,
|
||||
}
|
||||
|
||||
const NO_CLAIMS: ClaimOutcome = ClaimOutcome {
|
||||
claimed_count: 0,
|
||||
received_sats: 0,
|
||||
failed_count: 0,
|
||||
};
|
||||
|
||||
/// Make sure the Minibits mint is on the accepted-mints allow-list.
|
||||
///
|
||||
/// `ecash::receive_token` checks the raw accepted-mints file directly (not
|
||||
/// the more lenient `ecash::is_mint_trusted`, which always trusts the default
|
||||
/// mint) — so an operator who edited their accepted-mints list (e.g. via the
|
||||
/// `streaming.configure-mints` RPC) and dropped the default mint would
|
||||
/// otherwise cause every Minibits claim to fail *after* the claim was already
|
||||
/// consumed server-side, permanently losing those coins with nothing but a
|
||||
/// log line to show for it. The Minibits Lightning address is inherently
|
||||
/// backed by this one mint — registering it already implies trusting the
|
||||
/// mint — so self-heal the allow-list here rather than let that combination
|
||||
/// silently strand funds.
|
||||
async fn ensure_mint_accepted(data_dir: &Path, mint_url: &str) -> Result<()> {
|
||||
let mut accepted = ecash::load_accepted_mints(data_dir).await?;
|
||||
if !accepted.mints.iter().any(|m| m == mint_url) {
|
||||
accepted.mints.push(mint_url.to_string());
|
||||
ecash::save_accepted_mints(data_dir, &accepted).await?;
|
||||
info!("Minibits: added {mint_url} to accepted mints (needed to redeem LN-address claims)");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Poll Minibits for Lightning payments sent to the node's address and redeem
|
||||
/// each into the ecash wallet.
|
||||
///
|
||||
/// Each claim is a NUT-00 token NIP-04-encrypted by the Minibits service to
|
||||
/// this wallet's Nostr key; decrypting it needs the service pubkey
|
||||
/// (discovered from our LUD-16 metadata, falling back to the known
|
||||
/// constant). A claim is consumed server-side the instant `/claim` returns
|
||||
/// it, so newly-fetched tokens are persisted to `state.pending_claims`
|
||||
/// *before* decrypt/redeem is attempted; a token that fails to decrypt or
|
||||
/// redeem stays in `pending_claims` and is retried on the next poll instead
|
||||
/// of being dropped, and `failed_count` tells the caller when that happened
|
||||
/// so it isn't purely a log-line event.
|
||||
pub async fn claim_and_redeem(data_dir: &Path) -> Result<ClaimOutcome> {
|
||||
let network = ecash::load_network(data_dir).await;
|
||||
if network == EcashNetwork::Testnet {
|
||||
return Ok(NO_CLAIMS);
|
||||
}
|
||||
ensure_mint_accepted(data_dir, &network.default_mint()).await?;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.context("Failed to build the Minibits HTTP client")?;
|
||||
|
||||
let (phrase, seed) = ecash_phrase(data_dir).await?;
|
||||
let identity = derive_identity(&phrase, &seed)?;
|
||||
|
||||
let mut state = match load_state(data_dir).await? {
|
||||
Some(st) => st,
|
||||
// Nothing is addressable until a profile exists; registering lazily here
|
||||
// means a payment could not have arrived, so claiming is a no-op.
|
||||
None => return Ok(NO_CLAIMS),
|
||||
};
|
||||
ensure_token(&client, &mut state, &identity.keys).await?;
|
||||
|
||||
// Discover (and cache) the service key that wraps claimed tokens.
|
||||
if state.server_nostr_pubkey.is_empty() {
|
||||
match discover_server_nostr_pubkey(&client, &state.lud16).await {
|
||||
Ok(pk) => state.server_nostr_pubkey = pk,
|
||||
Err(e) => {
|
||||
warn!("Minibits: could not read service Nostr pubkey ({e}); using fallback");
|
||||
state.server_nostr_pubkey = FALLBACK_SERVER_NOSTR_PUBKEY.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let server_pk = nostr_sdk::PublicKey::from_hex(&state.server_nostr_pubkey)
|
||||
.context("Service Nostr pubkey was not valid hex")?;
|
||||
|
||||
// Fetch anything new. A failure here is *not* fatal to the poll — the
|
||||
// operator may still have earlier claims sitting in `pending_claims` that
|
||||
// are worth retrying — so log and fall through instead of bailing out.
|
||||
let resp = client
|
||||
.post(format!("{API_BASE}/claim"))
|
||||
.bearer_auth(&state.access_token)
|
||||
.json(&serde_json::json!({ "seedHash": state.seed_hash }))
|
||||
.send()
|
||||
.await;
|
||||
match resp {
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
if status.is_success() {
|
||||
match serde_json::from_str::<Vec<serde_json::Value>>(&body) {
|
||||
Ok(claims) => {
|
||||
for claim in &claims {
|
||||
match claim.get("token").and_then(|t| t.as_str()) {
|
||||
Some(t) => state.pending_claims.push(t.to_string()),
|
||||
None => warn!("Minibits claim had no 'token' field; skipping"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("Minibits claim response was not the expected shape: {e}"),
|
||||
}
|
||||
} else {
|
||||
warn!("{}", minibits_error(status, &body));
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("Minibits claim request failed ({e}); retrying only previously-pending claims"),
|
||||
}
|
||||
|
||||
// Persist immediately: everything in `pending_claims` right now has
|
||||
// already been consumed server-side, whether it came from this fetch or
|
||||
// survived from an earlier failed attempt.
|
||||
save_state(data_dir, &state).await?;
|
||||
|
||||
if state.pending_claims.is_empty() {
|
||||
return Ok(NO_CLAIMS);
|
||||
}
|
||||
|
||||
let to_process = std::mem::take(&mut state.pending_claims);
|
||||
let mut redeemed = 0usize;
|
||||
let mut sats = 0u64;
|
||||
let mut still_pending = Vec::new();
|
||||
for enc in &to_process {
|
||||
let decoded = match nip04::decrypt(identity.keys.secret_key(), &server_pk, enc) {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
warn!("Minibits claim could not be decrypted ({e}); will retry next poll");
|
||||
still_pending.push(enc.clone());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match ecash::receive_token(data_dir, &decoded).await {
|
||||
Ok(got) => {
|
||||
redeemed += 1;
|
||||
sats += got;
|
||||
info!("Minibits: redeemed a claimed payment ({got} sats)");
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Minibits claim decrypted but failed to redeem ({e}); will retry next poll");
|
||||
still_pending.push(enc.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let failed_count = still_pending.len();
|
||||
state.pending_claims = still_pending;
|
||||
save_state(data_dir, &state).await?;
|
||||
|
||||
Ok(ClaimOutcome { claimed_count: redeemed, received_sats: sats, failed_count })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn derived_nostr_key_matches_the_nip06_vector() {
|
||||
// The Minibits app derives its Nostr key at m/44'/1237'/0'/0/0 with an
|
||||
// empty BIP-39 passphrase (nostr-tools accountFromSeedWords). Lock to the
|
||||
// crate's own NIP-06 secret-key vector so a nostr-sdk bump cannot silently
|
||||
// move our derivation and orphan the registered address.
|
||||
let phrase = "leader monkey parrot ring guide accident before fence cannon height naive bean";
|
||||
let keys = nostr_sdk::Keys::from_mnemonic(phrase, None::<&str>).unwrap();
|
||||
assert_eq!(
|
||||
hex::encode(keys.secret_key().as_secret_bytes()),
|
||||
"7f7ff03d123792d6ac594bfa67bf6d0c0ab55b6b1fdb6249303fe861f1ccba9a"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_hash_is_sha256_of_the_bip39_seed() {
|
||||
// Minibits hashes the *seed*, not the phrase — a regression here would
|
||||
// make the node register a profile that the Minibits app cannot recover.
|
||||
let phrase = "leader monkey parrot ring guide accident before fence cannon height naive bean";
|
||||
let m: bip39::Mnemonic = phrase.parse().unwrap();
|
||||
let seed = m.to_seed("");
|
||||
let want = hex::encode(Sha256::digest(seed));
|
||||
let id = derive_identity(phrase, &seed).unwrap();
|
||||
assert_eq!(id.seed_hash, want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_names_are_readable_and_bounded() {
|
||||
for _ in 0..200 {
|
||||
let n = generate_wallet_id();
|
||||
assert!(!n.is_empty());
|
||||
assert!(n.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()));
|
||||
// ends in at least one digit (the 1..=999 suffix)
|
||||
assert!(n.chars().last().map(|c| c.is_ascii_digit()).unwrap_or(false));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_token_when_missing_or_near_expiry() {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
assert!(token_is_stale(&MinibitsState::default()));
|
||||
assert!(token_is_stale(&MinibitsState {
|
||||
access_token: "x".into(),
|
||||
access_expires: now + 10, // inside the skew window
|
||||
..Default::default()
|
||||
}));
|
||||
assert!(!token_is_stale(&MinibitsState {
|
||||
access_token: "x".into(),
|
||||
access_expires: now + 3600,
|
||||
..Default::default()
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_mint_accepted_heals_a_dropped_default_mint() {
|
||||
// Regression guard: `ecash::receive_token` checks the raw accepted-mints
|
||||
// file, not the more lenient `is_mint_trusted` — so an operator-edited
|
||||
// allow-list that dropped the default mint must not be able to make
|
||||
// Minibits claims (already consumed server-side by the time redeem
|
||||
// runs) fail permanently and silently.
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let mint = "https://mint.minibits.cash/Bitcoin";
|
||||
ecash::save_accepted_mints(
|
||||
tmp.path(),
|
||||
&ecash::AcceptedMints {
|
||||
mints: vec!["https://mint.example.com".to_string()],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
ensure_mint_accepted(tmp.path(), mint).await.unwrap();
|
||||
|
||||
let accepted = ecash::load_accepted_mints(tmp.path()).await.unwrap();
|
||||
assert!(accepted.mints.iter().any(|m| m == mint));
|
||||
assert!(accepted.mints.iter().any(|m| m == "https://mint.example.com"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_mint_accepted_does_not_duplicate() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let mint = "https://mint.minibits.cash/Bitcoin";
|
||||
ensure_mint_accepted(tmp.path(), mint).await.unwrap();
|
||||
ensure_mint_accepted(tmp.path(), mint).await.unwrap();
|
||||
let accepted = ecash::load_accepted_mints(tmp.path()).await.unwrap();
|
||||
assert_eq!(accepted.mints.iter().filter(|m| *m == mint).count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_state_treats_empty_file_as_no_profile() {
|
||||
// Reproduces archy-x250-pa3, 2026-09-08: a disk-full write truncated
|
||||
// wallet/minibits.json to 0 bytes, which then made every
|
||||
// wallet.ecash-lnaddress call fail with "EOF while parsing a value"
|
||||
// instead of just re-registering (idempotent per pubkey, so safe).
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let path = tmp.path().join(STATE_FILE);
|
||||
tokio::fs::create_dir_all(path.parent().unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::fs::write(&path, b"").await.unwrap();
|
||||
|
||||
let st = load_state(tmp.path()).await.unwrap();
|
||||
assert!(st.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_state_treats_corrupt_json_as_no_profile() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let path = tmp.path().join(STATE_FILE);
|
||||
tokio::fs::create_dir_all(path.parent().unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::fs::write(&path, b"{ not valid json").await.unwrap();
|
||||
|
||||
let st = load_state(tmp.path()).await.unwrap();
|
||||
assert!(st.is_none());
|
||||
}
|
||||
|
||||
/// Live end-to-end against the production Minibits API: register a throwaway
|
||||
/// profile with a random ecash phrase and claim (nothing pending → 0). Run
|
||||
/// with `cargo test -- --ignored --nocapture`. It creates one disposable
|
||||
/// profile on the public service and holds no funds.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn registers_and_claims_against_live_minibits() {
|
||||
let dir = std::env::temp_dir().join(format!("mbtest-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
// A node has an ecash phrase before it has a Minibits profile; stand up
|
||||
// a fresh random one so registration derives a real identity.
|
||||
nut13::establish_independent(&dir).await.unwrap();
|
||||
|
||||
let info = lnaddress(&dir).await.expect("live registration failed");
|
||||
let addr = info["address"].as_str().unwrap().to_string();
|
||||
assert!(addr.ends_with("@minibits.cash"), "bad address {addr}");
|
||||
println!("registered live address: {addr}");
|
||||
|
||||
// A second call must return the same cached address, not register again.
|
||||
let again = lnaddress(&dir).await.unwrap();
|
||||
assert_eq!(again["address"].as_str().unwrap(), addr.as_str());
|
||||
|
||||
let out = claim_and_redeem(&dir).await.expect("live claim poll failed");
|
||||
println!("claim poll: {out:?}");
|
||||
assert_eq!(out.claimed_count, 0);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ pub mod bdhke;
|
||||
pub mod cashu;
|
||||
pub mod ecash;
|
||||
pub mod fedimint_client;
|
||||
pub mod minibits;
|
||||
pub mod mint_client;
|
||||
pub mod nut13;
|
||||
pub mod profits;
|
||||
|
||||
@@ -137,6 +137,18 @@ impl EcashSeed {
|
||||
self.mnemonic.words().map(|w| w.to_string()).collect()
|
||||
}
|
||||
|
||||
/// The phrase as a single string — the input to NUT-13 *and* to the NIP-06
|
||||
/// Nostr derivation the Minibits profile flow needs (`crate::wallet::minibits`).
|
||||
pub fn phrase(&self) -> String {
|
||||
self.mnemonic.to_string()
|
||||
}
|
||||
|
||||
/// The 64-byte BIP-39 seed. Same bytes Minibits hashes with SHA-256 to get
|
||||
/// its `seedHash`, so the two wallets agree on wallet identity.
|
||||
pub fn seed_bytes(&self) -> [u8; 64] {
|
||||
self.seed
|
||||
}
|
||||
|
||||
pub fn source(&self) -> SeedSource {
|
||||
self.source
|
||||
}
|
||||
|
||||
@@ -106,6 +106,30 @@
|
||||
|
||||
<!-- Ecash -->
|
||||
<div v-if="receiveMethod === 'ecash'">
|
||||
<!-- Shareable @minibits.cash Lightning address (LUD-16): any Lightning
|
||||
wallet can pay this node by address, and the sats land as ecash.
|
||||
Fetched on tab open; claimed payments are polled in while open. -->
|
||||
<div v-if="lnAddress" class="mb-4 p-3 bg-white/5 rounded-lg text-center">
|
||||
<p class="text-white/60 text-sm mb-2">{{ t('receiveBitcoin.lnAddressTitle') }}</p>
|
||||
<canvas ref="lnAddressQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas>
|
||||
<p class="text-white/50 text-xs mb-1">{{ t('receiveBitcoin.lnAddressLabel') }}</p>
|
||||
<p class="text-base font-mono text-white/95 break-all mb-2">{{ lnAddress }}</p>
|
||||
<CopyButton :value="lnAddress" :label="t('common.copy')" />
|
||||
<p class="text-white/40 text-xs mt-3 leading-relaxed">{{ t('receiveBitcoin.lnAddressHint') }}</p>
|
||||
<p v-if="lnClaimedSats > 0" class="text-green-400 text-sm mt-2">
|
||||
{{ t('receiveBitcoin.lnAddressReceived', { amount: lnClaimedSats.toLocaleString() }) }}
|
||||
</p>
|
||||
<p v-if="lnPendingClaims > 0" class="text-orange-400 text-sm mt-2">
|
||||
{{ t('receiveBitcoin.lnAddressPendingRetry', { count: lnPendingClaims }) }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-else-if="lnAddressLoading" class="mb-4 text-center text-white/50 text-sm py-4">
|
||||
{{ t('receiveBitcoin.lnAddressLoading') }}
|
||||
</div>
|
||||
<div v-else-if="lnAddressError" class="mb-3 text-xs text-white/40">
|
||||
{{ t('receiveBitcoin.lnAddressUnavailable') }}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="text-white/60 text-sm block mb-1">{{ t('receiveBitcoin.pasteEcashToken') }}</label>
|
||||
<textarea v-model="ecashToken" rows="3" placeholder="cashuB… (Cashu) or Fedimint notes" class="w-full input-glass font-mono"></textarea>
|
||||
@@ -175,6 +199,12 @@ watch(() => props.show, (open) => {
|
||||
arkAddress.value = ''
|
||||
ecashToken.value = ''
|
||||
ecashResult.value = ''
|
||||
stopLnClaimPoll()
|
||||
lnAddress.value = ''
|
||||
lnAddressLoading.value = false
|
||||
lnAddressError.value = false
|
||||
lnClaimedSats.value = 0
|
||||
lnPendingClaims.value = 0
|
||||
error.value = ''
|
||||
processing.value = false
|
||||
if (props.autoGenerate && receiveMethod.value === 'onchain') {
|
||||
@@ -193,9 +223,85 @@ const ecashResult = ref('')
|
||||
const onchainQrCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
const lightningQrCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
const arkQrCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
const lnAddressQrCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
const processing = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
// ── Minibits Lightning address (ecash receive) ──────────────────────────────
|
||||
// The ecash tab doubles as "receive onto my @minibits.cash address": the node
|
||||
// derives/registers it from its own ecash seed (wallet.ecash-lnaddress) and
|
||||
// sweeps any Lightning payments that land there back into ecash while the tab is
|
||||
// open (wallet.ecash-lnaddress-claim). A registration failure is never fatal —
|
||||
// the paste-token path below always works.
|
||||
const lnAddress = ref('')
|
||||
const lnAddressLoading = ref(false)
|
||||
const lnAddressError = ref(false)
|
||||
const lnClaimedSats = ref(0)
|
||||
// A payment the backend fetched (and so already consumed at Minibits) but
|
||||
// couldn't redeem yet — it's queued for automatic retry, not lost, but the
|
||||
// operator should see it rather than have it be a silent, unbounded wait.
|
||||
const lnPendingClaims = ref(0)
|
||||
let lnClaimTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
async function loadLnAddress() {
|
||||
if (lnAddress.value || lnAddressLoading.value) return
|
||||
lnAddressLoading.value = true
|
||||
lnAddressError.value = false
|
||||
try {
|
||||
const res = await rpcClient.call<{ address?: string }>({ method: 'wallet.ecash-lnaddress' })
|
||||
lnAddress.value = res?.address || ''
|
||||
if (lnAddress.value) {
|
||||
await nextTick()
|
||||
renderQr(lnAddress.value, lnAddressQrCanvas.value)
|
||||
startLnClaimPoll()
|
||||
} else {
|
||||
lnAddressError.value = true
|
||||
}
|
||||
} catch {
|
||||
lnAddressError.value = true
|
||||
} finally {
|
||||
lnAddressLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function stopLnClaimPoll() {
|
||||
if (lnClaimTimer) {
|
||||
clearInterval(lnClaimTimer)
|
||||
lnClaimTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function startLnClaimPoll() {
|
||||
stopLnClaimPoll()
|
||||
lnClaimTimer = setInterval(() => void pollLnClaims(), 8000)
|
||||
}
|
||||
|
||||
async function pollLnClaims() {
|
||||
if (!props.show || !lnAddress.value) {
|
||||
stopLnClaimPoll()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await rpcClient.call<{ received_sats?: number; failed_count?: number }>({
|
||||
method: 'wallet.ecash-lnaddress-claim',
|
||||
})
|
||||
if (res?.received_sats && res.received_sats > 0) {
|
||||
lnClaimedSats.value += res.received_sats
|
||||
emit('received')
|
||||
}
|
||||
lnPendingClaims.value = res?.failed_count || 0
|
||||
} catch {
|
||||
// Transient poll failure (offline, mint busy) — keep polling.
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(stopLnClaimPoll)
|
||||
|
||||
// Fetch the address the first time the operator opens the ecash tab.
|
||||
watch(receiveMethod, (m) => {
|
||||
if (m === 'ecash' && props.show) void loadLnAddress()
|
||||
})
|
||||
|
||||
// ── On-chain payment detection ────────────────────────────────────────────
|
||||
// The generated address is FRESH (lnd.newaddress), so any incoming wallet
|
||||
// transaction paying it is this receive — no baseline bookkeeping needed.
|
||||
@@ -309,12 +415,16 @@ async function renderQr(data: string, canvas: HTMLCanvasElement | null, prefix =
|
||||
|
||||
function close() {
|
||||
stopWatchingPayment()
|
||||
stopLnClaimPoll()
|
||||
paymentSeen.value = null
|
||||
invoiceResult.value = ''
|
||||
onchainAddress.value = ''
|
||||
arkAddress.value = ''
|
||||
ecashToken.value = ''
|
||||
ecashResult.value = ''
|
||||
lnAddress.value = ''
|
||||
lnClaimedSats.value = 0
|
||||
lnPendingClaims.value = 0
|
||||
error.value = ''
|
||||
emit('close')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// Real vue-i18n instance (unlike ReceiveBitcoinModal.test.ts, which mocks
|
||||
// `t` to a no-op and so cannot catch a bad message string). Operator report
|
||||
// (2026-09-08): clicking the Ecash tab closed the whole Receive modal, in
|
||||
// both the browser and the Android companion's WebView. Root cause: vue-i18n
|
||||
// treats a bare `@` as the start of "linked message" syntax — `en.json`'s
|
||||
// `receiveBitcoin.lnAddressLabel` ("Your @minibits.cash address:") isn't
|
||||
// valid linked-message syntax, so *compiling* that message throws a
|
||||
// SyntaxError the instant it's first rendered (i.e. the moment the address
|
||||
// loads), and the uncaught render-function error blanks the whole teleported
|
||||
// modal. Fixed by escaping it as `{'@'}` (the same pattern already used for
|
||||
// `settings.domainNamePlaceholder`). This test uses the real compiler so a
|
||||
// future bad interpolation string in this component fails fast in `npm test`
|
||||
// instead of only in a live browser.
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import ReceiveBitcoinModal from '../ReceiveBitcoinModal.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import i18n from '@/i18n'
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: { call: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useLightningRequired', () => ({
|
||||
useLightningRequired: () => ({
|
||||
requireLightningReady: vi.fn().mockResolvedValue(true),
|
||||
handleLightningFailure: vi.fn().mockReturnValue(false),
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('ReceiveBitcoinModal — ecash tab with the real vue-i18n compiler', () => {
|
||||
it('renders the Minibits address label without an uncaught render error', async () => {
|
||||
vi.mocked(rpcClient.call).mockImplementation(async ({ method }: { method: string }) => {
|
||||
if (method === 'wallet.ecash-lnaddress') {
|
||||
return { address: 'someone@minibits.cash' } as never
|
||||
}
|
||||
return { claimed_count: 0, received_sats: 0, failed_count: 0 } as never
|
||||
})
|
||||
|
||||
const wrapper = mount(ReceiveBitcoinModal, {
|
||||
props: { show: true },
|
||||
attachTo: document.body,
|
||||
global: { plugins: [i18n] },
|
||||
})
|
||||
let captured: unknown = null
|
||||
wrapper.vm.$.appContext.app.config.errorHandler = (err) => { captured = err }
|
||||
await flushPromises()
|
||||
|
||||
const ecashTab = Array.from(document.body.querySelectorAll('button')).find((b) =>
|
||||
b.textContent?.toLowerCase().includes('ecash'),
|
||||
)
|
||||
expect(ecashTab).toBeTruthy()
|
||||
ecashTab!.dispatchEvent(new Event('click', { bubbles: true }))
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
|
||||
expect(captured).toBeNull()
|
||||
expect(wrapper.emitted('close')).toBeFalsy()
|
||||
const dialog = document.body.querySelector('[role="dialog"]')
|
||||
expect(dialog).toBeTruthy()
|
||||
expect(dialog?.textContent).toContain('minibits.cash')
|
||||
expect(dialog?.textContent).toContain('someone@minibits.cash')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import ReceiveBitcoinModal from '../ReceiveBitcoinModal.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({ fullPath: '/dashboard' }),
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (key: string, params?: Record<string, unknown>) => (params ? `${key}:${JSON.stringify(params)}` : key) }),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: { call: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useLightningRequired', () => ({
|
||||
useLightningRequired: () => ({
|
||||
requireLightningReady: vi.fn().mockResolvedValue(true),
|
||||
handleLightningFailure: vi.fn().mockReturnValue(false),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Guards an operator report (2026-09-08): clicking the Ecash tab appeared to
|
||||
// close the whole Receive modal. Not reproduced here — the tab switch alone
|
||||
// (success or failure of wallet.ecash-lnaddress) never emits `close` or
|
||||
// unmounts the dialog — but the RPC-eager tab switch is exactly the kind of
|
||||
// path a future change could regress, so it's worth pinning down.
|
||||
describe('ReceiveBitcoinModal — ecash tab click', () => {
|
||||
it('does not close/emit when the ecash tab is clicked and the RPC succeeds', async () => {
|
||||
vi.mocked(rpcClient.call).mockResolvedValue({ address: 'someone@minibits.cash' } as never)
|
||||
|
||||
const wrapper = mount(ReceiveBitcoinModal, {
|
||||
props: { show: true },
|
||||
attachTo: document.body,
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const tabs = Array.from(document.body.querySelectorAll('button'))
|
||||
const ecashTab = tabs.find((b) => b.textContent?.toLowerCase().includes('ecash'))
|
||||
expect(ecashTab).toBeTruthy()
|
||||
|
||||
ecashTab!.dispatchEvent(new Event('click', { bubbles: true }))
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('close')).toBeFalsy()
|
||||
expect(document.body.querySelector('[role="dialog"]')).toBeTruthy()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not close/emit when the ecash tab is clicked and the RPC fails', async () => {
|
||||
vi.mocked(rpcClient.call).mockRejectedValue(new Error('boom'))
|
||||
|
||||
const wrapper = mount(ReceiveBitcoinModal, {
|
||||
props: { show: true },
|
||||
attachTo: document.body,
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const tabs = Array.from(document.body.querySelectorAll('button'))
|
||||
const ecashTab = tabs.find((b) => b.textContent?.toLowerCase().includes('ecash'))
|
||||
expect(ecashTab).toBeTruthy()
|
||||
|
||||
ecashTab!.dispatchEvent(new Event('click', { bubbles: true }))
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('close')).toBeFalsy()
|
||||
expect(document.body.querySelector('[role="dialog"]')).toBeTruthy()
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
// Every message string must survive vue-i18n's message compiler. Found the
|
||||
// hard way (2026-09-08): a bare `@` in a message is parsed as the start of
|
||||
// "linked message" syntax (`@:key`), so a literal `@` (an email/handle-style
|
||||
// placeholder, e.g. "user@example.com") throws a SyntaxError the first time
|
||||
// it's *rendered*, not at build time — see [[vue-i18n-bare-at-sign-crash]]
|
||||
// in project memory for the full incident (it blanked a whole modal in both
|
||||
// the browser and the Android companion's WebView). A literal `@`, `{`, `}`
|
||||
// or other message-syntax character must be escaped as e.g. `{'@'}`.
|
||||
//
|
||||
// This walks every string in every locale file and asks the real compiler
|
||||
// to parse it — no rendering, no component needed, so it's fast and catches
|
||||
// the whole class of bug regardless of which component ever ends up using
|
||||
// the string.
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import i18n from '@/i18n'
|
||||
import en from '../en.json'
|
||||
import es from '../es.json'
|
||||
|
||||
function collectStrings(obj: unknown, path: string, out: Array<[string, string]>) {
|
||||
if (typeof obj === 'string') {
|
||||
out.push([path, obj])
|
||||
} else if (obj && typeof obj === 'object') {
|
||||
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
|
||||
collectStrings(v, path ? `${path}.${k}` : k, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('locale messages compile', () => {
|
||||
it.each([
|
||||
['en', en],
|
||||
['es', es],
|
||||
])('every %s message string compiles under the real vue-i18n compiler', (_locale, messages) => {
|
||||
const strings: Array<[string, string]> = []
|
||||
collectStrings(messages, '', strings)
|
||||
expect(strings.length).toBeGreaterThan(100)
|
||||
|
||||
const failures: string[] = []
|
||||
for (const [path, msg] of strings) {
|
||||
try {
|
||||
i18n.global.t(path)
|
||||
} catch (e) {
|
||||
failures.push(`${path}: ${(e as Error).message.split('\n')[0]} (source: ${JSON.stringify(msg)})`)
|
||||
}
|
||||
}
|
||||
expect(failures).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -315,7 +315,7 @@
|
||||
"passwordNeedUppercase": "Password must contain at least one uppercase letter",
|
||||
"passwordNeedLowercase": "Password must contain at least one lowercase letter",
|
||||
"passwordNeedDigit": "Password must contain at least one digit",
|
||||
"passwordNeedSpecial": "Password must contain at least one special character (!@#$%^&* etc.)",
|
||||
"passwordNeedSpecial": "Password must contain at least one special character (!{'@'}#$%^&* etc.)",
|
||||
"setupFailed": "Setup failed",
|
||||
"verificationFailed": "Verification failed",
|
||||
"disableFailed": "Failed to disable 2FA",
|
||||
@@ -775,6 +775,13 @@
|
||||
"paymentConfirmed": "Payment confirmed",
|
||||
"transactionId": "Transaction ID",
|
||||
"pasteEcashToken": "Paste ecash token",
|
||||
"lnAddressTitle": "Or share your Minibits Lightning address",
|
||||
"lnAddressHint": "Anyone can pay you sats with any Lightning wallet by sending to this address — the sats arrive as ecash. Keep this screen open to receive them.",
|
||||
"lnAddressLabel": "Your {'@'}minibits.cash address:",
|
||||
"lnAddressLoading": "Setting up your Lightning address…",
|
||||
"lnAddressUnavailable": "Lightning address unavailable — you can still paste a token below.",
|
||||
"lnAddressReceived": "Received {amount} sats to your Lightning address!",
|
||||
"lnAddressPendingRetry": "A payment arrived but couldn't be redeemed yet ({count}) — retrying automatically, keep this screen open.",
|
||||
"processing": "Processing...",
|
||||
"generateAddress": "Generate Address",
|
||||
"createInvoice": "Create Invoice",
|
||||
|
||||
@@ -315,7 +315,7 @@
|
||||
"passwordNeedUppercase": "La contrase\u00f1a debe contener al menos una letra may\u00fascula",
|
||||
"passwordNeedLowercase": "La contrase\u00f1a debe contener al menos una letra min\u00fascula",
|
||||
"passwordNeedDigit": "La contrase\u00f1a debe contener al menos un d\u00edgito",
|
||||
"passwordNeedSpecial": "La contrase\u00f1a debe contener al menos un car\u00e1cter especial (!@#$%^&* etc.)",
|
||||
"passwordNeedSpecial": "La contrase\u00f1a debe contener al menos un car\u00e1cter especial (!{'@'}#$%^&* etc.)",
|
||||
"setupFailed": "La configuraci\u00f3n fall\u00f3",
|
||||
"verificationFailed": "La verificaci\u00f3n fall\u00f3",
|
||||
"disableFailed": "Error al deshabilitar 2FA",
|
||||
@@ -756,6 +756,13 @@
|
||||
"paymentConfirmed": "Pago confirmado",
|
||||
"transactionId": "ID de transacci\u00f3n",
|
||||
"pasteEcashToken": "Pegar token Ecash",
|
||||
"lnAddressTitle": "O comparte tu direcci\u00f3n Lightning de Minibits",
|
||||
"lnAddressHint": "Cualquier persona puede pagarte sats con cualquier billetera Lightning enviando a esta direcci\u00f3n \u2014 los sats llegan como ecash. Mant\u00e9n esta pantalla abierta para recibirlos.",
|
||||
"lnAddressLabel": "Su direcci\u00f3n {'@'}minibits.cash:",
|
||||
"lnAddressLoading": "Configurando su direcci\u00f3n Lightning\u2026",
|
||||
"lnAddressUnavailable": "Direcci\u00f3n Lightning no disponible \u2014 a\u00fan puede pegar un token abajo.",
|
||||
"lnAddressReceived": "\u00a1Recibi\u00f3 {amount} sats en su direcci\u00f3n Lightning!",
|
||||
"lnAddressPendingRetry": "Lleg\u00f3 un pago pero a\u00fan no se pudo canjear ({count}) \u2014 reintentando autom\u00e1ticamente, mantenga esta pantalla abierta.",
|
||||
"processing": "Procesando...",
|
||||
"generateAddress": "Generar direcci\u00f3n",
|
||||
"createInvoice": "Crear factura",
|
||||
|
||||
+17
-17
@@ -1,29 +1,29 @@
|
||||
{
|
||||
"changelog": [
|
||||
"**Lightning sends work again — v1.8.9's payment switch lost the fee budget.** Moving payments to LND 0.21's supported route (Router.SendPaymentV2) shipped without a fee limit, and the v2 API treats an absent limit as **zero allowed fees**: every real route carries a routing fee, so the pathfinder rejected them all and the wallet answered \"No route to the recipient\" on every send — all day, on healthy channels with plenty of liquidity. The router debug log made it unambiguous (`fee_limit=0 mSAT` on every failing wallet payment; the same payment succeeded by hand the moment a fee limit was set). Payments now carry lncli's default budget (the payment amount), the wallet's amount handling for zero-value invoices is preserved, and a unit test pins the limit can never be zero again.",
|
||||
"**A channel that drops its peer link now heals itself — on every node.** Restarting LND (an app update, a reboot, container churn) can leave a channel's peer connection down for hours while both endpoints keep the channel flagged disabled in the routing graph: the node looks perfectly healthy, the wallet shows balance, and every payment in either direction fails \"no route to the recipient\". Observed live: a node's only channel sat unroutable for ~17 hours after the LND 0.21.2 update, with no sign of it in any dashboard. The daemon now watches the channel graph as desired state — every open channel should have a live peer — and reconnects any that don't, using the peer's advertised addresses. Nodes without LND are untouched; an unreachable peer is retried gently, not hammered.",
|
||||
"**The Lightning wallet states the node's real funding state instead of \"you have no channel.\"** Trying to send while a freshly opened channel was still waiting for on-chain confirmations — or when all its balance sits on the far side — raised a modal that claimed the node had NO channel at all (the outbound sum is legitimately zero in both states), pointed the user at opening a second channel, and — for payment routing failures — even showed the *receiving* copy. The funding gate now reads the channel list it already fetched: a confirming channel gets \"it unlocks automatically once confirmed, nothing is needed from you\", a far-side balance gets \"you can receive, but there's nothing to send right now\", a routing/liquidity payment failure says so instead of claiming channel problems, and only a genuinely channel-less node keeps the open-one guidance."
|
||||
"**Cuprate now syncs without burning a core for days.** The app's shipped config now enables Cuprate's checkpoint-backed `fast_sync` path, raises the database cache to 8 GiB, and gives the container a 10 GiB memory limit so the cache has real headroom. A live comparison that motivated the change saw the affected node sit around 45% CPU while the corrected config held near low single digits at the same chain height and block rate. The restricted RPC remains fronted through the safe app gate/Tor path.",
|
||||
"**OpenWrt Gateway setup is documented from a real install, and two setup bugs are fixed.** The new guide walks a node operator through flashing a GL.iNet AX3000 to stock OpenWrt, pairing it with Archipelago, and installing TollGate pay-as-you-go WiFi. The installer now finds `opkg`/`apk` through the router's actual `PATH` instead of assuming `/usr/bin`, the UI no longer sends an empty password over a saved router connection, and the pinned TollGate package moves to `v0.5.0` with a native `.apk` install path where upstream provides one.",
|
||||
"**Release publishing now checks the public Gitea download links before a manifest goes live.** The publisher already fetched every artifact back and verified its size and SHA-256; this release adds a second guard for the release page itself, so a bad Gitea `ROOT_URL` or proxy setting cannot publish working files behind broken public HTTPS download links."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.8.10-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.10-alpha/archipelago",
|
||||
"current_version": "1.8.11-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.11-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.8.10-alpha",
|
||||
"sha256": "6c8bd41fed44cd999cb360c00e1b66a2d19d19812cc2b0c8a1677eec2a9579e6",
|
||||
"size_bytes": 64178056
|
||||
"new_version": "1.8.11-alpha",
|
||||
"sha256": "ae569054edd6b2491beb101815f6809bc00c95a7dbe86bd084bcb9a7c36e1853",
|
||||
"size_bytes": 64179264
|
||||
},
|
||||
{
|
||||
"current_version": "1.8.10-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.10-alpha/archipelago-frontend-1.8.10-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.10-alpha.tar.gz",
|
||||
"new_version": "1.8.10-alpha",
|
||||
"sha256": "6b25de8a8e1a4f7fe51594f9bbbe21f5820f417af47a8b309c2dbf8f8723b719",
|
||||
"size_bytes": 97736297
|
||||
"current_version": "1.8.11-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.11-alpha/archipelago-frontend-1.8.11-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.11-alpha.tar.gz",
|
||||
"new_version": "1.8.11-alpha",
|
||||
"sha256": "192fd0470b6ccf66e78c80b4a4c3af5468882b85d81959362a3bd11f88b9d71d",
|
||||
"size_bytes": 97741740
|
||||
}
|
||||
],
|
||||
"release_date": "2026-09-01",
|
||||
"signature": "b69926bcb1851ff7d6a5b24519cd4a8015aab4ed4b588ee989d8ce6e3beaeb2cc0eb38078f522ded0d389fe53b7dbcdbf3f40c534b4bfafa5cf4a2ab2c59e40f",
|
||||
"release_date": "2026-09-07",
|
||||
"signature": "6449ce6ef35a4ef4fa6d0923bb58a2bff52ea5430d5532496e8f0af9ed52eaec293f19d7bec272dc9bc1af5fb2cdfa0e46068c827a9a4dd9cbef92d1c5845301",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"version": "1.8.10-alpha"
|
||||
"version": "1.8.11-alpha"
|
||||
}
|
||||
|
||||
+3244
-3242
File diff suppressed because one or more lines are too long
+17
-17
@@ -1,29 +1,29 @@
|
||||
{
|
||||
"changelog": [
|
||||
"**Lightning sends work again — v1.8.9's payment switch lost the fee budget.** Moving payments to LND 0.21's supported route (Router.SendPaymentV2) shipped without a fee limit, and the v2 API treats an absent limit as **zero allowed fees**: every real route carries a routing fee, so the pathfinder rejected them all and the wallet answered \"No route to the recipient\" on every send — all day, on healthy channels with plenty of liquidity. The router debug log made it unambiguous (`fee_limit=0 mSAT` on every failing wallet payment; the same payment succeeded by hand the moment a fee limit was set). Payments now carry lncli's default budget (the payment amount), the wallet's amount handling for zero-value invoices is preserved, and a unit test pins the limit can never be zero again.",
|
||||
"**A channel that drops its peer link now heals itself — on every node.** Restarting LND (an app update, a reboot, container churn) can leave a channel's peer connection down for hours while both endpoints keep the channel flagged disabled in the routing graph: the node looks perfectly healthy, the wallet shows balance, and every payment in either direction fails \"no route to the recipient\". Observed live: a node's only channel sat unroutable for ~17 hours after the LND 0.21.2 update, with no sign of it in any dashboard. The daemon now watches the channel graph as desired state — every open channel should have a live peer — and reconnects any that don't, using the peer's advertised addresses. Nodes without LND are untouched; an unreachable peer is retried gently, not hammered.",
|
||||
"**The Lightning wallet states the node's real funding state instead of \"you have no channel.\"** Trying to send while a freshly opened channel was still waiting for on-chain confirmations — or when all its balance sits on the far side — raised a modal that claimed the node had NO channel at all (the outbound sum is legitimately zero in both states), pointed the user at opening a second channel, and — for payment routing failures — even showed the *receiving* copy. The funding gate now reads the channel list it already fetched: a confirming channel gets \"it unlocks automatically once confirmed, nothing is needed from you\", a far-side balance gets \"you can receive, but there's nothing to send right now\", a routing/liquidity payment failure says so instead of claiming channel problems, and only a genuinely channel-less node keeps the open-one guidance."
|
||||
"**Cuprate now syncs without burning a core for days.** The app's shipped config now enables Cuprate's checkpoint-backed `fast_sync` path, raises the database cache to 8 GiB, and gives the container a 10 GiB memory limit so the cache has real headroom. A live comparison that motivated the change saw the affected node sit around 45% CPU while the corrected config held near low single digits at the same chain height and block rate. The restricted RPC remains fronted through the safe app gate/Tor path.",
|
||||
"**OpenWrt Gateway setup is documented from a real install, and two setup bugs are fixed.** The new guide walks a node operator through flashing a GL.iNet AX3000 to stock OpenWrt, pairing it with Archipelago, and installing TollGate pay-as-you-go WiFi. The installer now finds `opkg`/`apk` through the router's actual `PATH` instead of assuming `/usr/bin`, the UI no longer sends an empty password over a saved router connection, and the pinned TollGate package moves to `v0.5.0` with a native `.apk` install path where upstream provides one.",
|
||||
"**Release publishing now checks the public Gitea download links before a manifest goes live.** The publisher already fetched every artifact back and verified its size and SHA-256; this release adds a second guard for the release page itself, so a bad Gitea `ROOT_URL` or proxy setting cannot publish working files behind broken public HTTPS download links."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.8.10-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.10-alpha/archipelago",
|
||||
"current_version": "1.8.11-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.11-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.8.10-alpha",
|
||||
"sha256": "6c8bd41fed44cd999cb360c00e1b66a2d19d19812cc2b0c8a1677eec2a9579e6",
|
||||
"size_bytes": 64178056
|
||||
"new_version": "1.8.11-alpha",
|
||||
"sha256": "ae569054edd6b2491beb101815f6809bc00c95a7dbe86bd084bcb9a7c36e1853",
|
||||
"size_bytes": 64179264
|
||||
},
|
||||
{
|
||||
"current_version": "1.8.10-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.10-alpha/archipelago-frontend-1.8.10-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.10-alpha.tar.gz",
|
||||
"new_version": "1.8.10-alpha",
|
||||
"sha256": "6b25de8a8e1a4f7fe51594f9bbbe21f5820f417af47a8b309c2dbf8f8723b719",
|
||||
"size_bytes": 97736297
|
||||
"current_version": "1.8.11-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.11-alpha/archipelago-frontend-1.8.11-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.11-alpha.tar.gz",
|
||||
"new_version": "1.8.11-alpha",
|
||||
"sha256": "192fd0470b6ccf66e78c80b4a4c3af5468882b85d81959362a3bd11f88b9d71d",
|
||||
"size_bytes": 97741740
|
||||
}
|
||||
],
|
||||
"release_date": "2026-09-01",
|
||||
"signature": "b69926bcb1851ff7d6a5b24519cd4a8015aab4ed4b588ee989d8ce6e3beaeb2cc0eb38078f522ded0d389fe53b7dbcdbf3f40c534b4bfafa5cf4a2ab2c59e40f",
|
||||
"release_date": "2026-09-07",
|
||||
"signature": "6449ce6ef35a4ef4fa6d0923bb58a2bff52ea5430d5532496e8f0af9ed52eaec293f19d7bec272dc9bc1af5fb2cdfa0e46068c827a9a4dd9cbef92d1c5845301",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"version": "1.8.10-alpha"
|
||||
"version": "1.8.11-alpha"
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"changelog": [
|
||||
"**Cuprate now syncs without burning a core for days.** The app's shipped config now enables Cuprate's checkpoint-backed `fast_sync` path, raises the database cache to 8 GiB, and gives the container a 10 GiB memory limit so the cache has real headroom. A live comparison that motivated the change saw the affected node sit around 45% CPU while the corrected config held near low single digits at the same chain height and block rate. The restricted RPC remains fronted through the safe app gate/Tor path.",
|
||||
"**OpenWrt Gateway setup is documented from a real install, and two setup bugs are fixed.** The new guide walks a node operator through flashing a GL.iNet AX3000 to stock OpenWrt, pairing it with Archipelago, and installing TollGate pay-as-you-go WiFi. The installer now finds `opkg`/`apk` through the router's actual `PATH` instead of assuming `/usr/bin`, the UI no longer sends an empty password over a saved router connection, and the pinned TollGate package moves to `v0.5.0` with a native `.apk` install path where upstream provides one.",
|
||||
"**Release publishing now checks the public Gitea download links before a manifest goes live.** The publisher already fetched every artifact back and verified its size and SHA-256; this release adds a second guard for the release page itself, so a bad Gitea `ROOT_URL` or proxy setting cannot publish working files behind broken public HTTPS download links."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.8.11-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.11-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.8.11-alpha",
|
||||
"sha256": "ae569054edd6b2491beb101815f6809bc00c95a7dbe86bd084bcb9a7c36e1853",
|
||||
"size_bytes": 64179264
|
||||
},
|
||||
{
|
||||
"current_version": "1.8.11-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.11-alpha/archipelago-frontend-1.8.11-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.11-alpha.tar.gz",
|
||||
"new_version": "1.8.11-alpha",
|
||||
"sha256": "192fd0470b6ccf66e78c80b4a4c3af5468882b85d81959362a3bd11f88b9d71d",
|
||||
"size_bytes": 97741740
|
||||
}
|
||||
],
|
||||
"release_date": "2026-09-07",
|
||||
"signature": "6449ce6ef35a4ef4fa6d0923bb58a2bff52ea5430d5532496e8f0af9ed52eaec293f19d7bec272dc9bc1af5fb2cdfa0e46068c827a9a4dd9cbef92d1c5845301",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"version": "1.8.11-alpha"
|
||||
}
|
||||
Reference in New Issue
Block a user