Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19e01cd5de | ||
|
|
f458591132 | ||
|
|
6155539254 | ||
|
|
76e0f1f3b6 | ||
|
|
ba6ce2cdb6 | ||
|
|
8212049f57 | ||
|
|
5814f47659 | ||
|
|
94f5e892c3 | ||
|
|
a3b6467047 | ||
|
|
66db6497ec | ||
|
|
81be17f09f | ||
|
|
4237fb5e79 | ||
|
|
4302138b4f |
@@ -0,0 +1,23 @@
|
|||||||
|
# Blocking incident: Framework LND startup and false zero balance
|
||||||
|
|
||||||
|
Before starting or resuming work in this repository, read
|
||||||
|
`docs/incident-framework-lnd-startup.md` and check its status.
|
||||||
|
|
||||||
|
The user explicitly required this incident to take priority over later work.
|
||||||
|
While its status is OPEN:
|
||||||
|
|
||||||
|
- Surface this blocker at session start, including when the user asks for unrelated work.
|
||||||
|
- Prioritize investigation and repair on the actual Framework node. If access is
|
||||||
|
unavailable, ask for it and continue useful offline investigation; defer unrelated
|
||||||
|
work unless the user explicitly overrides this priority.
|
||||||
|
- A manual LND restart, a source patch, passing local tests, or publishing an OTA
|
||||||
|
does not resolve the incident. Do not mark it fixed until the Framework's startup,
|
||||||
|
Receive flow, and balance behavior are verified on the node, including a controlled
|
||||||
|
reboot with access and recovery arrangements in place.
|
||||||
|
- Preserve wallet identity, wallet/channel databases, credentials, and backups.
|
||||||
|
Never run wallet wipe/recreation as an automatic investigation or recovery step.
|
||||||
|
- Record evidence, changes, validation, and remaining work in the incident document.
|
||||||
|
|
||||||
|
This priority comes from the user's explicit instruction on 2026-09-15. It remains
|
||||||
|
in effect across sessions until the documented acceptance criteria are met or the
|
||||||
|
user explicitly changes it.
|
||||||
@@ -162,11 +162,25 @@ impl ApiHandler {
|
|||||||
r#"{"error":"This file is shared with the host's federation peers only. Federate with that node (exchange invites) so it recognizes you, then try again."}"#,
|
r#"{"error":"This file is shared with the host's federation peers only. Federate with that node (exchange invites) so it recognizes you, then try again."}"#,
|
||||||
),
|
),
|
||||||
)),
|
)),
|
||||||
Ok(content_server::ServeResult::NotFound) | Err(_) => Ok(build_response(
|
Ok(content_server::ServeResult::NotFound) => Ok(build_response(
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
"text/plain",
|
"text/plain",
|
||||||
hyper::Body::from("Content not found"),
|
hyper::Body::from("Content not found"),
|
||||||
)),
|
)),
|
||||||
|
// A server-side failure is NOT "not found": reporting it as a 404
|
||||||
|
// hid an unreadable file behind a silent, unlogged response, and a
|
||||||
|
// buyer's client re-sends a 404 over another transport. 5xx it, and
|
||||||
|
// say why in the journal.
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(content_id = %content_id, "content request failed: {e:#}");
|
||||||
|
Ok(build_response(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"application/json",
|
||||||
|
hyper::Body::from(
|
||||||
|
r#"{"error":"The seller could not read this file right now. You have not been charged."}"#,
|
||||||
|
),
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,9 +22,11 @@ const FILE_CATALOG_PROTOCOL: &str = "https://archipelago.dev/protocols/file-cata
|
|||||||
/// Best-effort reclaim of an ecash payment token that was minted but the sale
|
/// Best-effort reclaim of an ecash payment token that was minted but the sale
|
||||||
/// didn't complete (seller unreachable or couldn't redeem it), so the buyer
|
/// didn't complete (seller unreachable or couldn't redeem it), so the buyer
|
||||||
/// doesn't lose the value. For Fedimint the spender can reissue its own
|
/// doesn't lose the value. For Fedimint the spender can reissue its own
|
||||||
/// un-redeemed notes; for Cashu the proofs are received back. Fails silently if
|
/// un-redeemed notes; for Cashu the proofs are received back. Returns whether
|
||||||
/// the seller already claimed the token (then the value is genuinely gone).
|
/// the value came back: false if the seller already claimed the token (then
|
||||||
async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &str) {
|
/// the value is genuinely gone), so callers never tell the buyer they were
|
||||||
|
/// refunded when they weren't.
|
||||||
|
async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &str) -> bool {
|
||||||
let res = match backend {
|
let res = match backend {
|
||||||
"fedimint" => crate::wallet::fedimint_client::reissue_into_any(data_dir, token)
|
"fedimint" => crate::wallet::fedimint_client::reissue_into_any(data_dir, token)
|
||||||
.await
|
.await
|
||||||
@@ -32,13 +34,29 @@ async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &
|
|||||||
_ => ecash::receive_token(data_dir, token).await,
|
_ => ecash::receive_token(data_dir, token).await,
|
||||||
};
|
};
|
||||||
match res {
|
match res {
|
||||||
Ok(sats) => tracing::info!(
|
Ok(sats) => {
|
||||||
"paid download: reclaimed {sats} sats of unspent {backend} ecash after a failed sale"
|
tracing::info!(
|
||||||
),
|
"paid download: reclaimed {sats} sats of unspent {backend} ecash after a failed sale"
|
||||||
Err(e) => tracing::warn!(
|
);
|
||||||
"paid download: could not reclaim {backend} ecash (the peer may have already \
|
true
|
||||||
claimed it): {e:#}"
|
}
|
||||||
),
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"paid download: could not reclaim {backend} ecash (the peer may have already \
|
||||||
|
claimed it): {e:#}"
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What to tell the buyer about their payment after a failed sale.
|
||||||
|
fn refund_note(reclaimed: bool) -> &'static str {
|
||||||
|
if reclaimed {
|
||||||
|
"Your ecash was refunded to your wallet."
|
||||||
|
} else {
|
||||||
|
"The seller had already claimed the payment, so it could not be refunded \
|
||||||
|
automatically — contact the seller."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -564,9 +582,13 @@ impl RpcHandler {
|
|||||||
tracing::warn!("paid peer download dial failed for {}: {:#}", onion, e);
|
tracing::warn!("paid peer download dial failed for {}: {:#}", onion, e);
|
||||||
// The token was already minted/spent — reclaim it so the buyer
|
// The token was already minted/spent — reclaim it so the buyer
|
||||||
// doesn't lose the value when the seller was simply unreachable.
|
// doesn't lose the value when the seller was simply unreachable.
|
||||||
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
let reclaimed =
|
||||||
|
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||||
return Ok(serde_json::json!({
|
return Ok(serde_json::json!({
|
||||||
"error": "Could not reach the peer over mesh or Tor — it may be offline. Your ecash was refunded to your wallet. Please try again."
|
"error": format!(
|
||||||
|
"Could not reach the peer over mesh or Tor — it may be offline. {} Please try again.",
|
||||||
|
refund_note(reclaimed)
|
||||||
|
)
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -592,15 +614,19 @@ impl RpcHandler {
|
|||||||
);
|
);
|
||||||
// Seller couldn't redeem the token — reclaim it so the buyer keeps
|
// Seller couldn't redeem the token — reclaim it so the buyer keeps
|
||||||
// their funds (the spent-but-unredeemed-notes case the user hit).
|
// their funds (the spent-but-unredeemed-notes case the user hit).
|
||||||
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
let reclaimed =
|
||||||
|
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||||
|
// The 402 body is generic, so don't assert a cause — a seller that
|
||||||
|
// redeemed the token and then failed to deliver also lands here.
|
||||||
let hint = match used_backend {
|
let hint = match used_backend {
|
||||||
"fedimint" => "the seller isn't in the same Fedimint federation as you",
|
"fedimint" => "the seller may not be in the same Fedimint federation as you",
|
||||||
_ => "the seller doesn't accept your Cashu mint",
|
_ => "the seller may not accept your Cashu mint",
|
||||||
};
|
};
|
||||||
return Ok(serde_json::json!({
|
return Ok(serde_json::json!({
|
||||||
"error": format!(
|
"error": format!(
|
||||||
"Payment rejected by the seller — {hint}. Your ecash was refunded to \
|
"Payment not accepted by the seller — {hint}. {} Try the other ecash \
|
||||||
your wallet. Try the other ecash type, or use a shared mint/federation."
|
type, or use a shared mint/federation.",
|
||||||
|
refund_note(reclaimed)
|
||||||
)
|
)
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -609,9 +635,10 @@ impl RpcHandler {
|
|||||||
let status = response.status();
|
let status = response.status();
|
||||||
let body = response.text().await.unwrap_or_default();
|
let body = response.text().await.unwrap_or_default();
|
||||||
tracing::warn!("paid download: seller {onion} returned {status}: {body}");
|
tracing::warn!("paid download: seller {onion} returned {status}: {body}");
|
||||||
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
let reclaimed =
|
||||||
|
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||||
return Ok(serde_json::json!({
|
return Ok(serde_json::json!({
|
||||||
"error": format!("Peer returned an error ({status}). Your ecash was refunded to your wallet.")
|
"error": format!("Peer returned an error ({status}). {}", refund_note(reclaimed))
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,42 @@ struct LndChannelBalanceResponse {
|
|||||||
pending_open_local_balance: Option<LndAmount>,
|
pending_open_local_balance: Option<LndAmount>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reject unavailable LND data before it can be decoded as an empty, zero wallet.
|
||||||
|
async fn get_lnd_json<T: serde::de::DeserializeOwned>(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
url: &str,
|
||||||
|
macaroon_hex: &str,
|
||||||
|
) -> Result<T> {
|
||||||
|
client
|
||||||
|
.get(url)
|
||||||
|
.header("Grpc-Metadata-macaroon", macaroon_hex)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("LND is unavailable; balance could not be checked")?
|
||||||
|
.error_for_status()
|
||||||
|
.context("LND is not ready; balance could not be checked")?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("LND returned invalid wallet data")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn checked_balances(
|
||||||
|
wallet: LndBalanceResponse,
|
||||||
|
channels: LndChannelBalanceResponse,
|
||||||
|
) -> Result<(i64, i64, i64)> {
|
||||||
|
fn sats(value: Option<String>) -> Result<i64> {
|
||||||
|
let value = value.context("LND omitted a balance; balance is unavailable")?;
|
||||||
|
let amount: i64 = value.parse().context("LND returned an invalid balance")?;
|
||||||
|
anyhow::ensure!(amount >= 0, "LND returned a negative balance");
|
||||||
|
Ok(amount)
|
||||||
|
}
|
||||||
|
Ok((
|
||||||
|
sats(wallet.total_balance)?,
|
||||||
|
sats(channels.local_balance.and_then(|a| a.sat))?,
|
||||||
|
sats(channels.pending_open_local_balance.and_then(|a| a.sat))?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
impl RpcHandler {
|
impl RpcHandler {
|
||||||
pub(in crate::api::rpc) async fn handle_lnd_getinfo(&self) -> Result<serde_json::Value> {
|
pub(in crate::api::rpc) async fn handle_lnd_getinfo(&self) -> Result<serde_json::Value> {
|
||||||
let macaroon_bytes = read_lnd_admin_macaroon().await?;
|
let macaroon_bytes = read_lnd_admin_macaroon().await?;
|
||||||
@@ -85,45 +121,26 @@ impl RpcHandler {
|
|||||||
.build()
|
.build()
|
||||||
.context("Failed to create HTTP client")?;
|
.context("Failed to create HTTP client")?;
|
||||||
|
|
||||||
let get_info: LndGetInfoResponse = client
|
let get_info: LndGetInfoResponse = get_lnd_json(
|
||||||
.get(format!("{LND_REST_BASE_URL}/v1/getinfo"))
|
&client,
|
||||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
&format!("{LND_REST_BASE_URL}/v1/getinfo"),
|
||||||
.send()
|
&macaroon_hex,
|
||||||
.await
|
)
|
||||||
.context("LND REST connection failed")?
|
.await?;
|
||||||
.json()
|
let channel_balance: LndChannelBalanceResponse = get_lnd_json(
|
||||||
.await
|
&client,
|
||||||
.context("Failed to parse LND getinfo response")?;
|
&format!("{LND_REST_BASE_URL}/v1/balance/channels"),
|
||||||
|
&macaroon_hex,
|
||||||
let channel_balance: LndChannelBalanceResponse = match client
|
)
|
||||||
.get(format!("{LND_REST_BASE_URL}/v1/balance/channels"))
|
.await?;
|
||||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
let wallet_balance: LndBalanceResponse = get_lnd_json(
|
||||||
.send()
|
&client,
|
||||||
.await
|
&format!("{LND_REST_BASE_URL}/v1/balance/blockchain"),
|
||||||
{
|
&macaroon_hex,
|
||||||
Ok(resp) => resp.json().await.unwrap_or(LndChannelBalanceResponse {
|
)
|
||||||
local_balance: None,
|
.await?;
|
||||||
pending_open_local_balance: None,
|
let (balance_sats, channel_balance_sats, pending_open_balance) =
|
||||||
}),
|
checked_balances(wallet_balance, channel_balance)?;
|
||||||
Err(_) => LndChannelBalanceResponse {
|
|
||||||
local_balance: None,
|
|
||||||
pending_open_local_balance: None,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
let wallet_balance: LndBalanceResponse = match client
|
|
||||||
.get(format!("{LND_REST_BASE_URL}/v1/balance/blockchain"))
|
|
||||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(resp) => resp.json().await.unwrap_or(LndBalanceResponse {
|
|
||||||
total_balance: None,
|
|
||||||
}),
|
|
||||||
Err(_) => LndBalanceResponse {
|
|
||||||
total_balance: None,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
let (identity_pubkey, uris) = map_identity(&get_info);
|
let (identity_pubkey, uris) = map_identity(&get_info);
|
||||||
|
|
||||||
@@ -135,18 +152,9 @@ impl RpcHandler {
|
|||||||
num_peers: get_info.num_peers.unwrap_or(0),
|
num_peers: get_info.num_peers.unwrap_or(0),
|
||||||
synced_to_chain: get_info.synced_to_chain.unwrap_or(false),
|
synced_to_chain: get_info.synced_to_chain.unwrap_or(false),
|
||||||
block_height: get_info.block_height.unwrap_or(0),
|
block_height: get_info.block_height.unwrap_or(0),
|
||||||
balance_sats: wallet_balance
|
balance_sats,
|
||||||
.total_balance
|
channel_balance_sats,
|
||||||
.and_then(|s| s.parse().ok())
|
pending_open_balance,
|
||||||
.unwrap_or(0),
|
|
||||||
channel_balance_sats: channel_balance
|
|
||||||
.local_balance
|
|
||||||
.and_then(|a| a.sat.and_then(|s| s.parse().ok()))
|
|
||||||
.unwrap_or(0),
|
|
||||||
pending_open_balance: channel_balance
|
|
||||||
.pending_open_local_balance
|
|
||||||
.and_then(|a| a.sat.and_then(|s| s.parse().ok()))
|
|
||||||
.unwrap_or(0),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(serde_json::to_value(info)?)
|
Ok(serde_json::to_value(info)?)
|
||||||
@@ -268,6 +276,76 @@ impl RpcHandler {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unavailable_balances_are_not_zero() {
|
||||||
|
for body in [r#"{}"#, r#"{"code":14,"message":"wallet locked"}"#] {
|
||||||
|
assert!(checked_balances(
|
||||||
|
serde_json::from_str(body).unwrap(),
|
||||||
|
serde_json::from_str(body).unwrap(),
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
for value in ["bad", "-1", "9223372036854775808"] {
|
||||||
|
let wallet = LndBalanceResponse {
|
||||||
|
total_balance: Some(value.into()),
|
||||||
|
};
|
||||||
|
let channels = serde_json::from_str(
|
||||||
|
r#"{"local_balance":{"sat":"5"},"pending_open_local_balance":{"sat":"0"}}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(checked_balances(wallet, channels).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verified_zero_and_nonzero_balances_survive() {
|
||||||
|
for expected in [0, 42] {
|
||||||
|
let wallet = LndBalanceResponse {
|
||||||
|
total_balance: Some(expected.to_string()),
|
||||||
|
};
|
||||||
|
let channels = serde_json::from_value(serde_json::json!({
|
||||||
|
"local_balance":{"sat":expected.to_string()},
|
||||||
|
"pending_open_local_balance":{"sat":"0"}
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
checked_balances(wallet, channels).unwrap(),
|
||||||
|
(expected, expected, 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn locked_wallet_http_response_is_not_successful_getinfo() {
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
let (mut stream, _) = listener.accept().await.unwrap();
|
||||||
|
let mut buf = [0; 2048];
|
||||||
|
stream.read(&mut buf).await.unwrap();
|
||||||
|
let body =
|
||||||
|
r#"{"code":9,"message":"wallet locked, unlock it to enable full RPC access"}"#;
|
||||||
|
stream.write_all(format!(
|
||||||
|
"HTTP/1.1 503 Service Unavailable\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||||
|
body.len(), body
|
||||||
|
).as_bytes()).await.unwrap();
|
||||||
|
});
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.no_proxy()
|
||||||
|
.timeout(std::time::Duration::from_secs(2))
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
assert!(get_lnd_json::<LndGetInfoResponse>(
|
||||||
|
&client,
|
||||||
|
&format!("http://{addr}/v1/getinfo"),
|
||||||
|
"test"
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
server.await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
/// A real compressed secp256k1 pubkey shape: 66 hex characters.
|
/// A real compressed secp256k1 pubkey shape: 66 hex characters.
|
||||||
const GOOD_PUBKEY: &str = "03a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90";
|
const GOOD_PUBKEY: &str = "03a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90";
|
||||||
|
|
||||||
|
|||||||
@@ -96,129 +96,21 @@ pub async fn ensure_wallet_initialized() -> Result<()> {
|
|||||||
if file_exists_as_root(admin_macaroon).await && lnd_getinfo_ready(admin_macaroon).await {
|
if file_exists_as_root(admin_macaroon).await && lnd_getinfo_ready(admin_macaroon).await {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
match unlock_existing_wallet().await? {
|
unlock_existing_wallet_no_wipe().await?;
|
||||||
true => {
|
wait_for_admin_macaroon(admin_macaroon).await?;
|
||||||
wait_for_admin_macaroon(admin_macaroon).await?;
|
return Ok(());
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
false => {
|
|
||||||
// Every candidate password was actively rejected: this wallet was
|
|
||||||
// created with a password this node no longer has, so it can never
|
|
||||||
// auto-unlock unattended. Alpha nodes hold no real funds and a wallet
|
|
||||||
// locked with an unknown password is already inaccessible, so wipe +
|
|
||||||
// recreate it on the per-node secret to self-heal at boot.
|
|
||||||
recreate_wallet_destructively().await?;
|
|
||||||
wait_for_admin_macaroon(admin_macaroon).await?;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
init_wallet_via_rest().await?;
|
init_wallet_via_rest().await?;
|
||||||
wait_for_admin_macaroon(admin_macaroon).await
|
wait_for_admin_macaroon(admin_macaroon).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// LND data subdirectories holding wallet + channel + graph state. Removing them
|
|
||||||
/// returns LND to a NON_EXISTING wallet state. Funds-bearing data lives here too,
|
|
||||||
/// so deletion is destructive — only done once the wallet is already unrecoverable.
|
|
||||||
const LND_STATE_DIRS: &[&str] = &[
|
|
||||||
"/var/lib/archipelago/lnd/data/chain",
|
|
||||||
"/var/lib/archipelago/lnd/data/graph",
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Podman container name for the core LND app (see `compute_container_name`:
|
|
||||||
/// non-UI core apps keep their bare id). LND runs as a plain bridge-network
|
|
||||||
/// container, not a Quadlet unit, so it is restarted via `podman`, not systemctl.
|
|
||||||
const LND_CONTAINER: &str = "lnd";
|
|
||||||
|
|
||||||
/// Canonical on-host admin macaroon — same path the RPC layer reads.
|
/// Canonical on-host admin macaroon — same path the RPC layer reads.
|
||||||
const LND_ADMIN_MACAROON: &str =
|
const LND_ADMIN_MACAROON: &str =
|
||||||
"/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
"/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||||
|
|
||||||
/// Archipelago data dir (default; not overridden in prod). Holds the
|
|
||||||
/// `user-stopped.json` that gates health-monitor auto-restart.
|
|
||||||
const ARCHY_DATA_DIR: &str = "/var/lib/archipelago";
|
const ARCHY_DATA_DIR: &str = "/var/lib/archipelago";
|
||||||
|
|
||||||
/// Destroy an unrecoverable LND wallet and recreate a fresh one keyed to the
|
|
||||||
/// per-node secret. Suppresses health-monitor auto-restart for the wipe window,
|
|
||||||
/// stops LND, deletes its wallet/chain/graph state as root, restarts it, waits
|
|
||||||
/// for NON_EXISTING, then inits a fresh wallet. Destructive — only called when no
|
|
||||||
/// candidate password can open the existing wallet.
|
|
||||||
async fn recreate_wallet_destructively() -> Result<()> {
|
|
||||||
tracing::warn!(
|
|
||||||
"[lnd] wallet is locked with an unknown password and cannot auto-unlock; \
|
|
||||||
wiping and recreating it on the per-node secret (DESTRUCTIVE)"
|
|
||||||
);
|
|
||||||
|
|
||||||
// The health monitor restarts any container it sees stopped; mark LND
|
|
||||||
// user-stopped so it doesn't re-launch (and re-open the wallet) mid-wipe.
|
|
||||||
// Always cleared below so LND auto-recovers normally afterwards.
|
|
||||||
let data_dir = std::path::Path::new(ARCHY_DATA_DIR);
|
|
||||||
crate::crash_recovery::mark_user_stopped(data_dir, LND_CONTAINER).await;
|
|
||||||
let result = wipe_and_reinit_wallet().await;
|
|
||||||
crate::crash_recovery::clear_user_stopped(data_dir, LND_CONTAINER).await;
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn wipe_and_reinit_wallet() -> Result<()> {
|
|
||||||
podman_user_scoped(&["stop", LND_CONTAINER])
|
|
||||||
.await
|
|
||||||
.context("stopping lnd before wallet wipe")?;
|
|
||||||
|
|
||||||
for dir in LND_STATE_DIRS {
|
|
||||||
let status = host_sudo(&["rm", "-rf", dir])
|
|
||||||
.await
|
|
||||||
.with_context(|| format!("removing {dir}"))?;
|
|
||||||
if !status.success() {
|
|
||||||
anyhow::bail!("removing {dir} exited with {status}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
podman_user_scoped(&["start", LND_CONTAINER])
|
|
||||||
.await
|
|
||||||
.context("restarting lnd after wallet wipe")?;
|
|
||||||
|
|
||||||
wait_for_wallet_state("NON_EXISTING").await?;
|
|
||||||
init_wallet_via_rest().await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Run `podman <args>` inside a transient `systemd-run --user --scope`, matching
|
|
||||||
/// how the orchestrator/health-monitor manage rootless containers (keeps the
|
|
||||||
/// container out of the archipelago service's cgroup).
|
|
||||||
async fn podman_user_scoped(args: &[&str]) -> Result<()> {
|
|
||||||
let out = tokio::process::Command::new("systemd-run")
|
|
||||||
.args(["--user", "--scope", "--quiet", "--collect", "podman"])
|
|
||||||
.args(args)
|
|
||||||
.output()
|
|
||||||
.await
|
|
||||||
.with_context(|| format!("systemd-run --user --scope podman {}", args.join(" ")))?;
|
|
||||||
if !out.status.success() {
|
|
||||||
anyhow::bail!(
|
|
||||||
"podman {} failed: {}",
|
|
||||||
args.join(" "),
|
|
||||||
String::from_utf8_lossy(&out.stderr).trim()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Poll `/v1/state` until LND reports `target`, or time out after ~120s.
|
|
||||||
async fn wait_for_wallet_state(target: &str) -> Result<()> {
|
|
||||||
let client = reqwest::Client::builder()
|
|
||||||
.no_proxy()
|
|
||||||
.timeout(std::time::Duration::from_secs(5))
|
|
||||||
.danger_accept_invalid_certs(true)
|
|
||||||
.build()
|
|
||||||
.context("building LND REST client")?;
|
|
||||||
for _ in 0..120 {
|
|
||||||
if wallet_state(&client).await.as_deref() == Some(target) {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
|
||||||
}
|
|
||||||
anyhow::bail!("LND did not reach state {target} after wallet wipe")
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn file_exists_as_root(path: &str) -> bool {
|
async fn file_exists_as_root(path: &str) -> bool {
|
||||||
if std::path::Path::new(path).exists() {
|
if std::path::Path::new(path).exists() {
|
||||||
return true;
|
return true;
|
||||||
@@ -390,14 +282,8 @@ async fn unlock_existing_wallet_via_rest() -> Result<bool> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unlock an existing wallet WITHOUT the destructive fallback.
|
/// Unlock the existing wallet, preserving its identity and channel data when
|
||||||
///
|
/// passwords are unavailable or rejected. Used by boot and credential rotation.
|
||||||
/// `ensure_wallet_initialized` wipes and recreates a wallet no candidate
|
|
||||||
/// password can open — correct for a boot path that must self-heal, and exactly
|
|
||||||
/// wrong for macaroon rotation, which restarts LND against a wallet the operator
|
|
||||||
/// still wants. Rotation calls this instead, so there is no code path from
|
|
||||||
/// "rotate my credentials" to "delete my wallet": a rejected password surfaces
|
|
||||||
/// as an error the caller reports, never as a wipe.
|
|
||||||
pub(crate) async fn unlock_existing_wallet_no_wipe() -> Result<()> {
|
pub(crate) async fn unlock_existing_wallet_no_wipe() -> Result<()> {
|
||||||
match unlock_existing_wallet().await? {
|
match unlock_existing_wallet().await? {
|
||||||
true => Ok(()),
|
true => Ok(()),
|
||||||
@@ -538,7 +424,7 @@ async fn init_wallet_via_rest() -> Result<()> {
|
|||||||
{
|
{
|
||||||
UnlockerResponse::Value(seed) => seed,
|
UnlockerResponse::Value(seed) => seed,
|
||||||
UnlockerResponse::WalletAlreadyExists => {
|
UnlockerResponse::WalletAlreadyExists => {
|
||||||
unlock_existing_wallet().await?;
|
unlock_existing_wallet_no_wipe().await?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -569,7 +455,7 @@ async fn init_wallet_via_rest() -> Result<()> {
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
UnlockerResponse::WalletAlreadyExists => {
|
UnlockerResponse::WalletAlreadyExists => {
|
||||||
unlock_existing_wallet().await?;
|
unlock_existing_wallet_no_wipe().await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1864,7 +1864,7 @@ impl ProdContainerOrchestrator {
|
|||||||
// Durable installation record, consulted alongside the perishable
|
// Durable installation record, consulted alongside the perishable
|
||||||
// `was_running` snapshot for desired-state recovery below.
|
// `was_running` snapshot for desired-state recovery below.
|
||||||
let installed_apps = crate::crash_recovery::load_installed_apps(&self.data_dir).await;
|
let installed_apps = crate::crash_recovery::load_installed_apps(&self.data_dir).await;
|
||||||
let (manifests, container_name_by_app_id): (
|
let (mut manifests, container_name_by_app_id): (
|
||||||
Vec<LoadedManifest>,
|
Vec<LoadedManifest>,
|
||||||
std::collections::HashMap<String, String>,
|
std::collections::HashMap<String, String>,
|
||||||
) = {
|
) = {
|
||||||
@@ -1895,6 +1895,15 @@ impl ProdContainerOrchestrator {
|
|||||||
.collect();
|
.collect();
|
||||||
(filtered, names)
|
(filtered, names)
|
||||||
};
|
};
|
||||||
|
// Wallet readiness must not wait behind unrelated image pulls/builds.
|
||||||
|
// A running LND container can still be locked after boot; its post-start
|
||||||
|
// hook must run promptly. Reconcile Bitcoin first, then LND, before the
|
||||||
|
// rest of the catalog. Each app still honors stopped/uninstalled markers.
|
||||||
|
manifests.sort_by_key(|lm| match lm.manifest.app.id.as_str() {
|
||||||
|
"bitcoin-knots" | "bitcoin-core" | "bitcoin" => 0,
|
||||||
|
"lnd" => 1,
|
||||||
|
_ => 2,
|
||||||
|
});
|
||||||
// Live container names (any state), for the same recovery check.
|
// Live container names (any state), for the same recovery check.
|
||||||
let present_containers: std::collections::HashSet<String> = self
|
let present_containers: std::collections::HashSet<String> = self
|
||||||
.runtime
|
.runtime
|
||||||
@@ -6398,6 +6407,42 @@ app:
|
|||||||
assert!(cascade_pairs_for_report(&r, &none).is_empty());
|
assert!(cascade_pairs_for_report(&r, &none).is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reconcile_wallet_start_precedes_unrelated_failed_image_pull() {
|
||||||
|
let rt = Arc::new(MockRuntime::default());
|
||||||
|
rt.set_state("bitcoin-knots", ContainerState::Exited);
|
||||||
|
rt.set_state("lnd", ContainerState::Exited);
|
||||||
|
*rt.fail_pull.lock().unwrap() = Some("registry unreachable".into());
|
||||||
|
let mut orch = orch_with(rt.clone()).await;
|
||||||
|
orch.set_disk_gb_for_test(2000);
|
||||||
|
for id in ["unrelated", "lnd", "bitcoin-knots"] {
|
||||||
|
orch.insert_manifest_for_test(
|
||||||
|
pull_manifest(id, &format!("docker.io/example/{id}:1")),
|
||||||
|
PathBuf::from(format!("/tmp/{id}")),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
let report = orch.reconcile_all().await;
|
||||||
|
assert!(report.failures.iter().any(|(id, _)| id == "unrelated"));
|
||||||
|
let calls = rt.calls();
|
||||||
|
let bitcoin = calls
|
||||||
|
.iter()
|
||||||
|
.position(|c| c == "start_container:bitcoin-knots")
|
||||||
|
.unwrap();
|
||||||
|
let lnd = calls
|
||||||
|
.iter()
|
||||||
|
.position(|c| c == "start_container:lnd")
|
||||||
|
.unwrap();
|
||||||
|
let pull = calls
|
||||||
|
.iter()
|
||||||
|
.position(|c| c.starts_with("pull_image:"))
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
bitcoin < lnd && lnd < pull,
|
||||||
|
"wallet startup was delayed by unrelated recovery: {calls:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn reconcile_starts_exited_container() {
|
async fn reconcile_starts_exited_container() {
|
||||||
let rt = Arc::new(MockRuntime::default());
|
let rt = Arc::new(MockRuntime::default());
|
||||||
|
|||||||
@@ -5,13 +5,110 @@
|
|||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::future::Future;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Arc, LazyLock};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
use tracing::{debug, warn};
|
use tracing::{debug, warn};
|
||||||
|
|
||||||
const CATALOG_FILE: &str = "content/catalog.json";
|
const CATALOG_FILE: &str = "content/catalog.json";
|
||||||
const CONTENT_DIR: &str = "content/files";
|
const CONTENT_DIR: &str = "content/files";
|
||||||
|
|
||||||
|
/// How long a redeemed payment token keeps entitling its buyer to re-fetch the
|
||||||
|
/// item it paid for. Long enough to cover a buyer's transport fallback (FIPS →
|
||||||
|
/// Tor re-sends the same request, token included) and a manual retry; short
|
||||||
|
/// enough that the ledger stays tiny and a leaked token isn't a standing pass.
|
||||||
|
const REDEMPTION_TTL: Duration = Duration::from_secs(600);
|
||||||
|
|
||||||
|
/// One ledger slot per payment token (keyed by its SHA-256 — the raw bearer
|
||||||
|
/// token is never held here). The inner mutex serialises verification of the
|
||||||
|
/// same token; its value is the content id the token was redeemed for.
|
||||||
|
struct RedemptionSlot {
|
||||||
|
created_at: Instant,
|
||||||
|
redeemed_for: Arc<Mutex<Option<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
static REDEMPTIONS: LazyLock<Mutex<HashMap<String, RedemptionSlot>>> =
|
||||||
|
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||||
|
|
||||||
|
/// Decide whether `token` pays for `content_id`, redeeming it at most once.
|
||||||
|
///
|
||||||
|
/// Payment tokens are single-use: verifying one swaps its proofs at the mint,
|
||||||
|
/// so a second verification of the same token always fails "already spent".
|
||||||
|
/// A buyer's HTTP client can legitimately send the same request twice — its
|
||||||
|
/// FIPS attempt gets a 404/5xx and it re-sends over Tor — and without this
|
||||||
|
/// the seller redeemed the token on the first request, then answered the
|
||||||
|
/// retry `402 Payment required`: money taken, file never delivered.
|
||||||
|
///
|
||||||
|
/// So the first verification that succeeds is remembered (per token, per
|
||||||
|
/// item, for [`REDEMPTION_TTL`]) and later requests for the same item present
|
||||||
|
/// the same token are authorised without touching the mint again. Concurrent
|
||||||
|
/// requests with one token queue on the slot so only one runs `verify`.
|
||||||
|
/// A failed verification is not remembered — the slot is dropped so garbage
|
||||||
|
/// tokens can't accumulate and a legitimate retry gets a fresh attempt.
|
||||||
|
async fn authorize_payment<F, Fut>(token: &str, content_id: &str, verify: F) -> bool
|
||||||
|
where
|
||||||
|
F: FnOnce() -> Fut,
|
||||||
|
Fut: Future<Output = bool>,
|
||||||
|
{
|
||||||
|
let key = hex::encode(Sha256::digest(token.as_bytes()));
|
||||||
|
let redeemed_for = {
|
||||||
|
let mut ledger = REDEMPTIONS.lock().await;
|
||||||
|
ledger.retain(|_, s| s.created_at.elapsed() < REDEMPTION_TTL);
|
||||||
|
ledger
|
||||||
|
.entry(key.clone())
|
||||||
|
.or_insert_with(|| RedemptionSlot {
|
||||||
|
created_at: Instant::now(),
|
||||||
|
redeemed_for: Arc::new(Mutex::new(None)),
|
||||||
|
})
|
||||||
|
.redeemed_for
|
||||||
|
.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut state = redeemed_for.lock().await;
|
||||||
|
if state.as_deref() == Some(content_id) {
|
||||||
|
debug!(
|
||||||
|
"Payment token already redeemed for '{}' — serving without re-verifying",
|
||||||
|
content_id
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if verify().await {
|
||||||
|
*state = Some(content_id.to_string());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Keep a slot that already holds a redemption (this token paid for a
|
||||||
|
// different item); drop one that never verified anything.
|
||||||
|
let never_redeemed = state.is_none();
|
||||||
|
drop(state);
|
||||||
|
if never_redeemed {
|
||||||
|
REDEMPTIONS.lock().await.remove(&key);
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Confirm the node can actually hand the file over: it exists and this
|
||||||
|
/// process may read it. Must run BEFORE a payment is redeemed — a paid buyer
|
||||||
|
/// who then hits a read error has lost their token for nothing (2026-09-18:
|
||||||
|
/// filebrowser-owned `0640` files the node's service user couldn't open; the
|
||||||
|
/// stat calls passed, `fs::read` failed after the swap, the buyer got a 404).
|
||||||
|
/// Reading a byte (not just opening) also rejects a directory.
|
||||||
|
async fn ensure_servable(file_path: &Path) -> Result<()> {
|
||||||
|
use tokio::io::AsyncReadExt;
|
||||||
|
let mut file = fs::File::open(file_path)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("content file {} is not readable", file_path.display()))?;
|
||||||
|
let mut probe = [0u8; 1];
|
||||||
|
file.read(&mut probe)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("content file {} cannot be read", file_path.display()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ContentItem {
|
pub struct ContentItem {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -296,6 +393,31 @@ pub async fn serve_content(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify the file can be served BEFORE any payment is redeemed. The gate
|
||||||
|
// below swaps the buyer's token at the mint; failing to hand over the file
|
||||||
|
// after that takes their money and delivers nothing.
|
||||||
|
let file_path = content_file_path(data_dir, item);
|
||||||
|
if !file_path.exists() {
|
||||||
|
// The catalog entry survived (it's a separate JSON file) but its
|
||||||
|
// backing file is gone — most likely lost in an unrelated data-dir
|
||||||
|
// reset (a shared filebrowser file, 2026-07-01: two catalog entries
|
||||||
|
// outlived a filebrowser reinstall that wiped the files themselves).
|
||||||
|
// Leaving the entry in place would keep advertising it as available
|
||||||
|
// to every peer forever, each hitting the exact same dead end this
|
||||||
|
// one just did. Prune it so it stops being offered.
|
||||||
|
warn!(
|
||||||
|
content_id = %id,
|
||||||
|
filename = %item.filename,
|
||||||
|
"content catalog entry's file is missing on disk — pruning the stale entry"
|
||||||
|
);
|
||||||
|
prune_missing_content_entry(data_dir, id).await;
|
||||||
|
return Ok(ServeResult::NotFound);
|
||||||
|
}
|
||||||
|
if let Err(e) = ensure_servable(&file_path).await {
|
||||||
|
warn!(content_id = %id, "cannot serve content (payment not taken): {e:#}");
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
|
||||||
// Check access control
|
// Check access control
|
||||||
if !owner_session {
|
if !owner_session {
|
||||||
match &item.access {
|
match &item.access {
|
||||||
@@ -309,7 +431,10 @@ pub async fn serve_content(
|
|||||||
if let Some(token) = payment_token {
|
if let Some(token) = payment_token {
|
||||||
if (method_accepted(&item.access, "ecash")
|
if (method_accepted(&item.access, "ecash")
|
||||||
|| method_accepted(&item.access, "fedimint"))
|
|| method_accepted(&item.access, "fedimint"))
|
||||||
&& verify_payment_token(data_dir, token, *price_sats).await
|
&& authorize_payment(token, id, || {
|
||||||
|
verify_payment_token(data_dir, token, *price_sats)
|
||||||
|
})
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
authorized = true;
|
authorized = true;
|
||||||
}
|
}
|
||||||
@@ -336,24 +461,6 @@ pub async fn serve_content(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let file_path = content_file_path(data_dir, item);
|
|
||||||
if !file_path.exists() {
|
|
||||||
// The catalog entry survived (it's a separate JSON file) but its
|
|
||||||
// backing file is gone — most likely lost in an unrelated data-dir
|
|
||||||
// reset (a shared filebrowser file, 2026-07-01: two catalog entries
|
|
||||||
// outlived a filebrowser reinstall that wiped the files themselves).
|
|
||||||
// Leaving the entry in place would keep advertising it as available
|
|
||||||
// to every peer forever, each hitting the exact same dead end this
|
|
||||||
// one just did. Prune it so it stops being offered.
|
|
||||||
warn!(
|
|
||||||
content_id = %id,
|
|
||||||
filename = %item.filename,
|
|
||||||
"content catalog entry's file is missing on disk — pruning the stale entry"
|
|
||||||
);
|
|
||||||
prune_missing_content_entry(data_dir, id).await;
|
|
||||||
return Ok(ServeResult::NotFound);
|
|
||||||
}
|
|
||||||
|
|
||||||
let metadata = fs::metadata(&file_path)
|
let metadata = fs::metadata(&file_path)
|
||||||
.await
|
.await
|
||||||
.context("Failed to read file metadata")?;
|
.context("Failed to read file metadata")?;
|
||||||
@@ -725,3 +832,182 @@ mod prune_missing_content_tests {
|
|||||||
assert_eq!(reloaded.items[0].id, "present-item");
|
assert_eq!(reloaded.items[0].id, "present-item");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod paid_delivery_tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
|
||||||
|
/// A verifier that counts how often it actually runs.
|
||||||
|
fn counting(
|
||||||
|
calls: &Arc<AtomicUsize>,
|
||||||
|
result: bool,
|
||||||
|
) -> impl FnOnce() -> std::future::Ready<bool> {
|
||||||
|
let calls = calls.clone();
|
||||||
|
move || {
|
||||||
|
calls.fetch_add(1, Ordering::SeqCst);
|
||||||
|
std::future::ready(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn replayed_token_is_served_without_redeeming_twice() {
|
||||||
|
// The 2026-09-18 incident: the buyer's client re-sent the same request
|
||||||
|
// over Tor after the seller had already redeemed the token, and the
|
||||||
|
// second verification ("already spent") turned into a 402.
|
||||||
|
let calls = Arc::new(AtomicUsize::new(0));
|
||||||
|
assert!(authorize_payment("tok-replay", "item-a", counting(&calls, true)).await);
|
||||||
|
assert!(authorize_payment("tok-replay", "item-a", counting(&calls, true)).await);
|
||||||
|
assert_eq!(calls.load(Ordering::SeqCst), 1, "mint must be hit once");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn concurrent_requests_with_one_token_redeem_once() {
|
||||||
|
// FIPS attempt still in flight when the Tor fallback arrives.
|
||||||
|
let calls = Arc::new(AtomicUsize::new(0));
|
||||||
|
let slow = |calls: Arc<AtomicUsize>| {
|
||||||
|
move || async move {
|
||||||
|
calls.fetch_add(1, Ordering::SeqCst);
|
||||||
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (a, b) = tokio::join!(
|
||||||
|
authorize_payment("tok-concurrent", "item-a", slow(calls.clone())),
|
||||||
|
authorize_payment("tok-concurrent", "item-a", slow(calls.clone())),
|
||||||
|
);
|
||||||
|
assert!(a && b, "both requests must be served");
|
||||||
|
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn failed_verification_is_not_remembered() {
|
||||||
|
let calls = Arc::new(AtomicUsize::new(0));
|
||||||
|
assert!(!authorize_payment("tok-bad", "item-a", counting(&calls, false)).await);
|
||||||
|
// A retry gets a fresh attempt — and can succeed (e.g. mint was down).
|
||||||
|
assert!(authorize_payment("tok-bad", "item-a", counting(&calls, true)).await);
|
||||||
|
assert_eq!(calls.load(Ordering::SeqCst), 2);
|
||||||
|
let ledger = REDEMPTIONS.lock().await;
|
||||||
|
let key = hex::encode(Sha256::digest(b"tok-bad"));
|
||||||
|
assert!(ledger.contains_key(&key), "successful redemption is kept");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn failed_verification_leaves_no_ledger_entry() {
|
||||||
|
let calls = Arc::new(AtomicUsize::new(0));
|
||||||
|
assert!(!authorize_payment("tok-garbage", "item-a", counting(&calls, false)).await);
|
||||||
|
let key = hex::encode(Sha256::digest(b"tok-garbage"));
|
||||||
|
assert!(
|
||||||
|
!REDEMPTIONS.lock().await.contains_key(&key),
|
||||||
|
"garbage tokens must not accumulate"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn token_redeemed_for_one_item_does_not_unlock_another() {
|
||||||
|
let calls = Arc::new(AtomicUsize::new(0));
|
||||||
|
assert!(authorize_payment("tok-cross", "item-a", counting(&calls, true)).await);
|
||||||
|
// Item B is verified on its own merits (the real mint would say
|
||||||
|
// "already spent"); it must not ride on item A's redemption…
|
||||||
|
assert!(!authorize_payment("tok-cross", "item-b", counting(&calls, false)).await);
|
||||||
|
assert_eq!(calls.load(Ordering::SeqCst), 2);
|
||||||
|
// …and failing there must not revoke what the token already paid for.
|
||||||
|
assert!(authorize_payment("tok-cross", "item-a", counting(&calls, true)).await);
|
||||||
|
assert_eq!(calls.load(Ordering::SeqCst), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paid_item(id: &str, filename: &str) -> ContentItem {
|
||||||
|
ContentItem {
|
||||||
|
id: id.to_string(),
|
||||||
|
filename: filename.to_string(),
|
||||||
|
mime_type: "audio/mpeg".to_string(),
|
||||||
|
size_bytes: 4,
|
||||||
|
description: String::new(),
|
||||||
|
access: AccessControl::Paid {
|
||||||
|
price_sats: 10,
|
||||||
|
accepted: vec!["ecash".to_string()],
|
||||||
|
},
|
||||||
|
availability: Availability::AllPeers,
|
||||||
|
added_at: "2026-01-01T00:00:00Z".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unreadable_paid_file_errors_before_any_payment_is_redeemed() {
|
||||||
|
// Filebrowser-owned 0640 files the node's service user can't read:
|
||||||
|
// stat() succeeds, read() fails. That must surface as an error BEFORE
|
||||||
|
// the token is verified — never after the swap has taken the money.
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let data_dir = dir.path();
|
||||||
|
save_catalog(
|
||||||
|
data_dir,
|
||||||
|
&ContentCatalog {
|
||||||
|
items: vec![paid_item("locked", "locked.mp3")],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let files = data_dir.join("content").join("files");
|
||||||
|
tokio::fs::create_dir_all(&files).await.unwrap();
|
||||||
|
let file = files.join("locked.mp3");
|
||||||
|
tokio::fs::write(&file, b"data").await.unwrap();
|
||||||
|
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o000)).unwrap();
|
||||||
|
if std::fs::File::open(&file).is_ok() {
|
||||||
|
return; // running as root: permissions can't be enforced here
|
||||||
|
}
|
||||||
|
|
||||||
|
// A token that would fail verification if it were reached: getting
|
||||||
|
// PaymentRequired here would mean the gate ran before the file check.
|
||||||
|
let result = serve_content(
|
||||||
|
data_dir,
|
||||||
|
"locked",
|
||||||
|
Some("cashuBnot-a-real-token"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"unreadable file must be a server error, not 402/404"
|
||||||
|
);
|
||||||
|
let key = hex::encode(Sha256::digest(b"cashuBnot-a-real-token"));
|
||||||
|
assert!(
|
||||||
|
!REDEMPTIONS.lock().await.contains_key(&key),
|
||||||
|
"no redemption may be attempted for an unservable file"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn readable_paid_file_with_bad_token_still_requires_payment() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let data_dir = dir.path();
|
||||||
|
save_catalog(
|
||||||
|
data_dir,
|
||||||
|
&ContentCatalog {
|
||||||
|
items: vec![paid_item("ok", "ok.mp3")],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let files = data_dir.join("content").join("files");
|
||||||
|
tokio::fs::create_dir_all(&files).await.unwrap();
|
||||||
|
tokio::fs::write(files.join("ok.mp3"), b"data").await.unwrap();
|
||||||
|
|
||||||
|
let result = serve_content(
|
||||||
|
data_dir,
|
||||||
|
"ok",
|
||||||
|
Some("cashuBnot-a-real-token-2"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(result, ServeResult::PaymentRequired(10)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,14 @@
|
|||||||
Working backlog of forward-looking items not yet scoped into a dedicated plan
|
Working backlog of forward-looking items not yet scoped into a dedicated plan
|
||||||
doc. See [`ROADMAP.md`](ROADMAP.md) for the curated, public-facing direction.
|
doc. See [`ROADMAP.md`](ROADMAP.md) for the curated, public-facing direction.
|
||||||
|
|
||||||
|
## Blocking incident — before unrelated work
|
||||||
|
|
||||||
|
- **OPEN: Framework LND startup / missing Receive address / false zero balance.**
|
||||||
|
User requires investigation and a verified fix on the actual node before later
|
||||||
|
unrelated work. Access is pending; a manual LND restart is only a workaround.
|
||||||
|
See [incident evidence and closure criteria](incident-framework-lnd-startup.md)
|
||||||
|
and the repository `AGENTS.md` session-start instructions.
|
||||||
|
|
||||||
## Dev & build process (priority)
|
## Dev & build process (priority)
|
||||||
|
|
||||||
- Formalize the contributor workflow: releases, CI, maintainers, automated
|
- Formalize the contributor workflow: releases, CI, maintainers, automated
|
||||||
|
|||||||
@@ -0,0 +1,378 @@
|
|||||||
|
# Framework: LND startup, missing Receive address, false zero balance
|
||||||
|
|
||||||
|
**Status: OPEN — Framework startup and Cashu address verified live; source integration and final dashboard balance confirmation remain.**
|
||||||
|
|
||||||
|
Reported: 2026-09-15. Source inspected: main at `3b9b74da` (v1.8.17-alpha publication).
|
||||||
|
The Framework's installed version and exact incident time have not been verified.
|
||||||
|
|
||||||
|
## Mandatory priority across sessions
|
||||||
|
|
||||||
|
The user explicitly requested that this be investigated and fixed on the node
|
||||||
|
before resuming unrelated work in later sessions. `AGENTS.md` in the repository
|
||||||
|
and `/home/archipelago/.codex/AGENTS.md` carry this session-start priority.
|
||||||
|
Only live verification below, or an explicit user change of priority, clears it.
|
||||||
|
|
||||||
|
## Reported observations
|
||||||
|
|
||||||
|
- Framework stopped showing its Lightning address in Receive.
|
||||||
|
- After a restart, LND did not initialize and the UI displayed a balance of zero.
|
||||||
|
- Manually restarting LND restored operation.
|
||||||
|
- Node access will be supplied later. No Framework connection, restart, wallet
|
||||||
|
operation, or deployment was performed during this offline investigation.
|
||||||
|
- Still clarify whether the restart was a full reboot or management-service
|
||||||
|
restart, and which Receive item vanished: a Lightning invoice, an on-chain
|
||||||
|
address, or the Cashu tab's `@minibits.cash` address.
|
||||||
|
|
||||||
|
A successful manual restart is a workaround, not a root cause or durable fix.
|
||||||
|
The zero display does not establish that any funds were lost. Its relation to
|
||||||
|
v1.8.17-alpha is unknown; do not infer a release regression from timing alone.
|
||||||
|
|
||||||
|
## Confirmed source findings
|
||||||
|
|
||||||
|
### 1. LND errors can be presented as successful zero balances
|
||||||
|
|
||||||
|
`core/archipelago/src/api/rpc/lnd/info.rs`, `handle_lnd_getinfo`:
|
||||||
|
|
||||||
|
- `/v1/getinfo` is decoded without checking HTTP success. Its response fields are
|
||||||
|
optional, so an error object such as `{"code":14,"message":"wallet not ready"}`
|
||||||
|
can deserialize with every expected field absent instead of rejecting the call.
|
||||||
|
- Channel and blockchain balance requests suppress connection/JSON failures and
|
||||||
|
substitute responses with absent balances. HTTP status is not checked here either.
|
||||||
|
- Missing or unparsable balances become `0` through `unwrap_or(0)`.
|
||||||
|
- `neode-ui/src/views/Home.vue`, `loadWeb5Status`, treats this RPC response as
|
||||||
|
success, sets the wallet connected flag, overwrites prior balances, and can
|
||||||
|
persist the false zero in the wallet snapshot. Its existing failure handling
|
||||||
|
preserves prior balances only when the RPC actually rejects.
|
||||||
|
|
||||||
|
This is a confirmed code defect and a plausible explanation for the reported
|
||||||
|
display. It is not proof of the Framework's failure sequence.
|
||||||
|
|
||||||
|
Required fix: reject unsuccessful/incomplete LND balance responses or model
|
||||||
|
availability explicitly end to end. Never translate unavailable data into a
|
||||||
|
verified zero. Preserve known balances with a clear unavailable/stale indication;
|
||||||
|
show an unknown state when no valid balance is known. Genuine successful zeros
|
||||||
|
must still render as zero. Cover outage, partial failure, cold load, and recovery.
|
||||||
|
|
||||||
|
### 2. Startup readiness and wallet unlock need live evidence
|
||||||
|
|
||||||
|
- `main.rs` runs crash/container boot recovery before starting the reconciler.
|
||||||
|
- `crash_recovery.rs` can start existing containers directly.
|
||||||
|
- `container/prod_orchestrator.rs` runs LND post-start hooks on explicit restart
|
||||||
|
and on normal reconciliation of already-running containers. Therefore it is
|
||||||
|
incorrect to conclude that running containers categorically skip unlock.
|
||||||
|
- `container/lnd.rs::ensure_wallet_initialized` checks wallet existence and
|
||||||
|
`/v1/getinfo`, then attempts unlock. Its unlock wait budget is approximately ten
|
||||||
|
minutes; per-request timeouts can extend elapsed time. Historical comments
|
||||||
|
describe slow database startup and restart loops, but that is not Framework evidence.
|
||||||
|
- `health_monitor.rs` models LND's Bitcoin dependency. Container-running state
|
||||||
|
alone is not proof of wallet readiness, Bitcoin connectivity, or invoice readiness.
|
||||||
|
|
||||||
|
Investigate boot ordering, Bitcoin readiness, listener/port mapping, wallet unlock,
|
||||||
|
mount availability, stopped markers, restart counters, and actual reconcile logs.
|
||||||
|
|
||||||
|
### 3. Destructive automatic recovery exists; exclude it from diagnosis
|
||||||
|
|
||||||
|
`container/lnd.rs::ensure_wallet_initialized` calls
|
||||||
|
`recreate_wallet_destructively` when all candidate passwords are rejected. That
|
||||||
|
function can delete the LND chain and graph data directories. Its comment assumes
|
||||||
|
alpha wallets hold no real funds; that assumption must not guide this investigation.
|
||||||
|
|
||||||
|
No evidence establishes that it ran on Framework. Preserve the original wallet
|
||||||
|
and channels; rejected passwords must lead to a recoverable error, not automatic
|
||||||
|
wallet deletion. Review and disable this destructive fallback before using a
|
||||||
|
modified initialization path as a repair. The existing
|
||||||
|
`unlock_existing_wallet_no_wipe` demonstrates the non-destructive error behavior.
|
||||||
|
|
||||||
|
### 4. The missing address must be identified precisely
|
||||||
|
|
||||||
|
`ReceiveBitcoinModal.vue` generates Lightning invoices using `lnd.createinvoice`
|
||||||
|
after a readiness check, and Bitcoin addresses using `lnd.newaddress`. Its Cashu
|
||||||
|
Lightning address uses `wallet.ecash-lnaddress` and the Minibits service separately.
|
||||||
|
Do not assume the Minibits address disappears because LND is down. Trace the actual
|
||||||
|
tab and response once the user clarifies and the node can be inspected.
|
||||||
|
|
||||||
|
## Next session: live investigation order
|
||||||
|
|
||||||
|
1. Request Framework access and verify node identity without publishing its hostname,
|
||||||
|
address, credentials, or wallet identifiers. Do not substitute the development box.
|
||||||
|
2. Record installed backend/image versions, boot and incident timestamps, and exact
|
||||||
|
restart/action sequence. Capture current and previous-boot management/LND logs
|
||||||
|
before another restart can obscure evidence. Keep raw logs private and redact
|
||||||
|
secrets, invoices, wallet identifiers, and personally identifying data in summaries.
|
||||||
|
3. Read container/service state, restart counters, mounts, stopped markers, listener
|
||||||
|
mappings, Bitcoin readiness, LND wallet state, and authenticated API results.
|
||||||
|
Never dump container environments, macaroons, passwords, seeds, or wallet databases.
|
||||||
|
4. Compare HTTP status and data from LND getinfo/balance endpoints with the RPC and
|
||||||
|
visible Receive/balance state. Distinguish unavailable data, locked wallet,
|
||||||
|
syncing wallet, and genuine zero. Preserve last-known balance evidence privately.
|
||||||
|
5. Establish whether the manual restart ran a missing/failed hook, waited out a
|
||||||
|
dependency, refreshed networking/credentials, or masked another failure.
|
||||||
|
6. Implement the evidenced startup repair and unavailable-balance handling with
|
||||||
|
regressions. Preserve wallet/channel state and arrange recovery access before
|
||||||
|
deploying or deliberately rebooting the node.
|
||||||
|
|
||||||
|
## Acceptance criteria — all required to close
|
||||||
|
|
||||||
|
- [x] Root cause of Framework startup failure supported by node evidence.
|
||||||
|
- [x] Fix implemented and focused regression tests pass.
|
||||||
|
- [ ] Failed, locked, delayed, and partial LND responses never masquerade as a
|
||||||
|
fresh zero balance; genuine zero remains correct.
|
||||||
|
- [x] Existing wallet identity and channel state preserved through the repair.
|
||||||
|
- [x] Framework starts LND and reaches usable wallet readiness after a controlled
|
||||||
|
full reboot, without manually restarting LND.
|
||||||
|
- [ ] The originally affected Receive flow works after boot and after recovery;
|
||||||
|
outages show an actionable state and recover without requiring a page reload.
|
||||||
|
- [ ] Display confirmation pending; authenticated LND balances match pre-reboot values.
|
||||||
|
- [x] LND logs show no restart loop, repeated unlock failure, or wallet-recreation path.
|
||||||
|
- [ ] Evidence, tested versions, deployment, and limitations recorded here; user
|
||||||
|
informed of live results. Only then set status RESOLVED and clear the blockers.
|
||||||
|
|
||||||
|
## Work completed so far
|
||||||
|
|
||||||
|
2026-09-15: source investigation and persistent session-start instructions only.
|
||||||
|
No code fix, release, node deployment, or live reproduction for this incident yet.
|
||||||
|
|
||||||
|
## Live evidence captured 2026-09-15
|
||||||
|
|
||||||
|
Access was provided during the same session. Read-only inspection confirmed:
|
||||||
|
|
||||||
|
- Framework runs `1.8.17-alpha-dev`; the current full boot began at 18:40:09 UTC.
|
||||||
|
- LND opened its databases in 6.7 seconds and requested its wallet password at
|
||||||
|
18:40:20. It then rejected GetInfo/ChannelBalance/WalletBalance as wallet locked.
|
||||||
|
- The management service's first sequential reconcile pass was occupied by
|
||||||
|
unrelated image recovery, including a missing voice image from 18:40:24 and
|
||||||
|
later a missing Core Lightning image. Manifests are iterated from a HashMap;
|
||||||
|
wallet readiness has no initial priority. Boot recovery itself completed at
|
||||||
|
18:40:18; the first full app-reconcile report appeared at 18:44:34.
|
||||||
|
- The user's manual LND restart was recorded at 18:42:33. The replacement LND
|
||||||
|
process started at 18:42:40, requested its password at 18:43:05, and unlocked
|
||||||
|
at 18:43:07 through the explicit restart hook. This supports delayed unlock
|
||||||
|
behind unrelated recovery, rather than a missing wallet or bad password.
|
||||||
|
- At inspection, `/v1/state` reports SERVER_ACTIVE; getinfo reports chain and
|
||||||
|
graph sync and two active channels. Both authenticated balance endpoints
|
||||||
|
report nonzero balances. No wallet-recreation event was found in captured logs.
|
||||||
|
- The Minibits RPC separately fails with “The ecash wallet has no seed yet”.
|
||||||
|
`wallet/cashu_seed.json` and `wallet/minibits.json` are absent. The existing
|
||||||
|
ecash wallet is present with proofs and an August modification timestamp.
|
||||||
|
Do not overwrite it or generate an unrelated recovery identity. Still identify
|
||||||
|
which Receive item the user meant before declaring this part repaired.
|
||||||
|
|
||||||
|
Private raw evidence: `/home/archipelago/.local/state/archy-incidents/framework-lnd-20260915/`.
|
||||||
|
Files have mode 0600 and the directory 0700. Do not commit or publish raw logs.
|
||||||
|
|
||||||
|
Candidate changes on `investigate/framework-lnd-startup`:
|
||||||
|
|
||||||
|
- Run Bitcoin and LND reconciliation before unrelated image pulls/builds.
|
||||||
|
- Reject failed/incomplete LND balance responses instead of manufacturing zeros.
|
||||||
|
- Preserve known Home balances on invalid responses, visibly label unavailable
|
||||||
|
balances, and clear the warning after a successful refresh.
|
||||||
|
- Remove automatic destructive wallet recreation; failed unlock preserves data.
|
||||||
|
- Add backend outage/zero/ordering regressions and UI failure/recovery coverage.
|
||||||
|
|
||||||
|
These changes are not yet deployed or verified through a Framework reboot.
|
||||||
|
|
||||||
|
### Candidate validation and staging
|
||||||
|
|
||||||
|
Source fix commit: `4237fb5e` on `investigate/framework-lnd-startup`.
|
||||||
|
|
||||||
|
- 44 focused backend tests passed (including LND errors, genuine zero, startup ordering).
|
||||||
|
- 58 additional reconciliation/update tests passed.
|
||||||
|
- 12 Home UI tests passed, including outage/partial response/cold-load/recovery cases.
|
||||||
|
- Rust formatting, frontend type checking and production build passed.
|
||||||
|
- Optimized backend build passed in 8m02s.
|
||||||
|
- Both candidate artifacts were copied to Framework and SHA-256 matched locally.
|
||||||
|
- Private on-node baseline and static channel backup are under
|
||||||
|
`/var/lib/archipelago/support/framework-lnd-20260915/`, along with the previous
|
||||||
|
backend, dashboard, and `rollback.sh`. This directory is root-only.
|
||||||
|
- Candidate staged at `/tmp/archy-framework-candidate/`; not applied yet.
|
||||||
|
- A timing confirmation for the maintenance restart/full reboot was requested
|
||||||
|
because it interrupts all node services. Do not reboot while that is pending.
|
||||||
|
- SSH works through the temporary control socket
|
||||||
|
`/tmp/archy-framework-connection/control`. No SSH password was saved to disk.
|
||||||
|
- The supplied SSH password did not authenticate to the dashboard. Do not guess
|
||||||
|
additional passwords or alter dashboard authentication. Native LND diagnostics
|
||||||
|
are authenticated using its existing local macaroon without printing it.
|
||||||
|
|
||||||
|
Status remains OPEN until deployment and live boot/Receive/balance verification.
|
||||||
|
|
||||||
|
### Authorized deployment and full reboot — 2026-09-15
|
||||||
|
|
||||||
|
The user answered “yes please” to applying the staged fix and rebooting. Timing
|
||||||
|
approval is no longer pending. Applied the staged backend and dashboard after
|
||||||
|
rechecking both checksums and rollback copies. There were no pending channel
|
||||||
|
HTLCs at reboot. No wallet data, secrets, or recovery identities were replaced.
|
||||||
|
|
||||||
|
Live results:
|
||||||
|
|
||||||
|
- A different boot ID confirms a full reboot occurred.
|
||||||
|
- Running backend on disk matches candidate SHA-256
|
||||||
|
`5a354f76ebe619561eef0d318e4f41f177d04004682504d7434d632733f8e298`.
|
||||||
|
- Management service started around 19:23:57 UTC; LND asked for its wallet
|
||||||
|
password at 19:24:10 and logged automatic unlock at 19:24:18. No manual LND
|
||||||
|
restart or interactive unlock was used after this reboot.
|
||||||
|
- LND reports SERVER_ACTIVE and chain sync. Its identity and channel-point set
|
||||||
|
are identical to the private pre-reboot baseline; both channels are active.
|
||||||
|
- On-chain and Lightning balances exactly equal the pre-reboot values.
|
||||||
|
- LND container and systemd restart counts are zero after recovery.
|
||||||
|
- Public HTTP checks on the node returned 200 for the dashboard index and new
|
||||||
|
Home bundle; their bytes match the installed candidate, including the new
|
||||||
|
unavailable-balance notice.
|
||||||
|
- Captured post-reboot management and LND journals in the private local evidence
|
||||||
|
directory. Detailed before/after identity, channel, and balance records remain
|
||||||
|
in the root-only support directory on Framework.
|
||||||
|
|
||||||
|
The user was asked to refresh the dashboard and confirm the originally missing
|
||||||
|
Receive item and displayed balances. Keep OPEN until that reply is assessed;
|
||||||
|
Minibits seed absence was a separate finding and must not be mistaken for an
|
||||||
|
LND startup failure. Candidate is a direct node deployment, not a newly signed
|
||||||
|
fleet release. The source branch must be integrated before a subsequent release
|
||||||
|
can preserve this fix across the fleet.
|
||||||
|
|
||||||
|
### Cashu Receive follow-up
|
||||||
|
|
||||||
|
The user confirmed that the remaining error is specifically on the Ecash tab:
|
||||||
|
“Lightning address unavailable — you can still paste a token below.”
|
||||||
|
|
||||||
|
Read-only checks confirm Framework has an encrypted node master seed, existing
|
||||||
|
Cashu proofs, and neither `wallet/cashu_seed.json` nor `wallet/minibits.json`.
|
||||||
|
The existing Minibits handler requires an ecash seed, but setup was available
|
||||||
|
only through the Settings backup screen; Receive hid the actionable cause.
|
||||||
|
|
||||||
|
UI fix commit: `a3b64670`.
|
||||||
|
|
||||||
|
- Receive checks the non-secret seed status when registration fails.
|
||||||
|
- Unseeded wallets get the existing password/TOTP/backup-passphrase-verified setup
|
||||||
|
component directly in Receive, with import/restore controls excluded from this
|
||||||
|
focused setup screen. Setup derives from the saved node seed when present.
|
||||||
|
- The recovery words stay in the existing local reveal UI, are cleared on Done,
|
||||||
|
and are never emitted to Receive. Receive retries registration after Done.
|
||||||
|
- Seeded wallets with service outages get Retry, without offering a new identity.
|
||||||
|
- Ten focused Receive/backup tests and the production UI build passed.
|
||||||
|
- Deployed the dashboard change without restarting services; live HTTP index and
|
||||||
|
setup bundle returned 200 and byte-matched the candidate.
|
||||||
|
- Backed up original Cashu proofs to the root-only support directory as
|
||||||
|
`ecash-before-address-setup.json`. No seed or proof mutation was performed by
|
||||||
|
the assistant. Prior LND-fixed dashboard is also backed up there.
|
||||||
|
|
||||||
|
The user was asked to refresh Receive → Ecash → Set up address, authenticate in
|
||||||
|
that node UI, and click Done. Dashboard password is required to decrypt the node
|
||||||
|
seed; the SSH password did not authenticate to the dashboard. Do not request or
|
||||||
|
print recovery words, bypass authentication, or create an unrelated random seed.
|
||||||
|
After completion, verify saved seed/profile presence, registration success,
|
||||||
|
address display, and unchanged original proofs before closing the incident.
|
||||||
|
|
||||||
|
### Cashu setup completed and verified — 2026-09-15
|
||||||
|
|
||||||
|
The user initially reported a forgotten passphrase, then said “did it now”. No
|
||||||
|
independent-seed fallback was implemented or used. The user completed the existing
|
||||||
|
password-verified setup themselves; the assistant did not receive recovery words.
|
||||||
|
|
||||||
|
Read-only node verification confirmed:
|
||||||
|
|
||||||
|
- `wallet/cashu_seed.json` exists, is nonempty, and records source `node-seed`.
|
||||||
|
- `wallet/minibits.json` exists with a `@minibits.cash` address and no pending claims.
|
||||||
|
- The original ecash wallet file is byte-for-byte unchanged from the protected
|
||||||
|
pre-setup copy; every original proof is preserved.
|
||||||
|
- The registered address's public LNURL-pay metadata returns HTTP 200, tag
|
||||||
|
`payRequest`, an HTTPS callback, and a valid amount range. No invoice was paid
|
||||||
|
and no funded payment test was performed.
|
||||||
|
|
||||||
|
LND automatic startup and native balances were already verified after the full
|
||||||
|
reboot. Cashu setup and address registration are now also verified on Framework.
|
||||||
|
Do not ask for the forgotten passphrase again or propose a replacement Cashu seed.
|
||||||
|
|
||||||
|
Remaining: integrate the tested source branch before the next fleet release;
|
||||||
|
record final human confirmation of the rendered dashboard balance (native balances
|
||||||
|
match exactly, and UI failure/recovery regressions pass). Keep this follow-up
|
||||||
|
visible across sessions; do not rebuild/reboot/reinitialize a working wallet just
|
||||||
|
to repeat already completed checks.
|
||||||
|
|
||||||
|
### Backup copy and layout — 2026-09-15
|
||||||
|
|
||||||
|
At the user's request, shortened the ecash backup explanations and stacked each
|
||||||
|
card section's text and full-width action vertically. Kept the distinction
|
||||||
|
between node-derived and separate phrases, and the warning that a newly created
|
||||||
|
phrase covers future coins rather than existing legacy coins.
|
||||||
|
|
||||||
|
All 10 Receive/backup tests and the production UI build pass. Deployed the UI to
|
||||||
|
Framework without a restart; served index and backup-component bundle match the
|
||||||
|
build byte-for-byte. The prior UI is saved as `web-ui-before-backup-copy` in the
|
||||||
|
protected incident directory. Source integration and final rendered dashboard
|
||||||
|
balance confirmation remain pending as above.
|
||||||
|
|
||||||
|
### LNURL comment-length report — 2026-09-15
|
||||||
|
|
||||||
|
User reports a maximum-comment-length error in some sending wallets. Live
|
||||||
|
Framework address metadata advertises integer `commentAllowed: 100`. The QR
|
||||||
|
contains the address only; Archy's Receive UI does not add a comment. The
|
||||||
|
Minibits-hosted callback returned invoices for omitted/empty comments, 100 ASCII
|
||||||
|
characters, 101 ASCII characters, and 100 accented characters. These were unpaid
|
||||||
|
invoice requests at the advertised minimum amount; no funds were sent.
|
||||||
|
|
||||||
|
The callback did not reproduce the error, including beyond its advertised limit.
|
||||||
|
Sending-wallet validation against the advertised 100-character limit is therefore
|
||||||
|
a hypothesis, not a confirmed root cause. Asked which wallets fail and whether
|
||||||
|
an empty comment also fails. Need that result before selecting a code fix.
|
||||||
|
The service controls the advertised limit; changing local Receive text or QR
|
||||||
|
cannot raise it for other wallets.
|
||||||
|
|
||||||
|
### Primal Spark: automatic recipient note exceeds the address limit
|
||||||
|
|
||||||
|
User clarified that no comment was entered and the sender is Primal Spark.
|
||||||
|
Checked Framework's management journal over the preceding 20 minutes: no
|
||||||
|
comment-length errors, service active, and zero pending Minibits claims. Recent
|
||||||
|
claim polling connected to and disconnected from the relay normally. Historical
|
||||||
|
seed-authentication failures preceded the successful setup already documented.
|
||||||
|
|
||||||
|
The live address's Minibits `text/plain` description is **101 ASCII characters**,
|
||||||
|
while `commentAllowed` is **100**. Description template (address redacted):
|
||||||
|
`Pay to [ADDRESS] with Lightning. Receiver will receive ecash into Minibits Wallet.`
|
||||||
|
|
||||||
|
Primal Android source at `36939db97213e7f8eeefaa4adaf125d839fc662e`:
|
||||||
|
- `WalletTextParserImpl.handleLnUrlText` assigns the parsed description to
|
||||||
|
`DraftTx.noteRecipient`, including for Lightning-address input.
|
||||||
|
- `TransactionEditor` initializes its editable recipient note from that value.
|
||||||
|
- `SparkWalletServiceImpl` passes it untrimmed to `PrepareLnurlPayRequest.comment`.
|
||||||
|
- Breez Spark source at `8bb38ec292a590907360c4e7f2a4134b8f09de9e`,
|
||||||
|
`common/src/lnurl/pay.rs::validate_user_input`, rejects a comment exceeding the
|
||||||
|
limit with the exact reported error before requesting the callback.
|
||||||
|
|
||||||
|
This identifies a concrete compatibility failure: the address description can
|
||||||
|
become an automatic over-limit comment without the sender typing anything.
|
||||||
|
The user confirmed that explicitly clearing the prefilled recipient note made
|
||||||
|
the payment work, and supplied the same description observed in live metadata.
|
||||||
|
This confirms the automatic-comment compatibility failure. The installed Primal
|
||||||
|
platform/version was not captured. Node logs alone cannot show sender-side
|
||||||
|
validation or requests to the external Minibits callback.
|
||||||
|
|
||||||
|
Durable upstream correction: Primal should keep receiver metadata separate from
|
||||||
|
the sender's comment and enforce the limit on actual user comments. Minibits can
|
||||||
|
also shorten its description or raise its advertised comment limit. Archy does
|
||||||
|
not serve this external LNURL metadata; do not rename an existing wallet address,
|
||||||
|
rotate its seed, or claim that a local dashboard edit fixes this sender behavior.
|
||||||
|
|
||||||
|
### Primal workaround confirmed by user
|
||||||
|
|
||||||
|
The user confirmed successful payment after removing the automatic description.
|
||||||
|
The permanent sender-side correction is to leave the recipient comment empty by
|
||||||
|
default and retain receiver metadata only as display text. In Primal Android,
|
||||||
|
remove the assignment of the LNURL description to the draft recipient note in
|
||||||
|
`WalletTextParserImpl.handleLnUrlText`; also validate explicitly entered comments
|
||||||
|
against the endpoint's limit. No upstream change has been submitted or deployed.
|
||||||
|
Existing Framework addresses and wallet identities remain unchanged.
|
||||||
|
|
||||||
|
### Can Archy shorten the current address description?
|
||||||
|
|
||||||
|
Inspected Minibits' public wallet client (`src/services/minibitsService.ts`,
|
||||||
|
`updateWalletProfile`) and `WalletProfileRecord`. The supported profile update
|
||||||
|
fields are name, lud16, and avatar; there is no exposed LNURL description or
|
||||||
|
comment-limit setting. Its public web repository also contains no implementation
|
||||||
|
of the LNURL metadata endpoint or description template.
|
||||||
|
|
||||||
|
For the existing `@minibits.cash` address, no supported client-side mechanism
|
||||||
|
to shorten this text was found. Do not send guessed profile-update fields or
|
||||||
|
rename the address to disguise the problem. A Minibits server change could use
|
||||||
|
`Pay to [ADDRESS]`, well below the current limit. Controlling this metadata in
|
||||||
|
Archy would instead require an Archy-hosted LNURL service/address and correct
|
||||||
|
invoice metadata binding; rewriting the QR label or only proxying edited metadata
|
||||||
|
is insufficient. No wallet/profile mutations were made during this investigation.
|
||||||
@@ -3,6 +3,9 @@ import { ref, computed, onMounted } from 'vue'
|
|||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
import SeedRevealPanel from '@/components/SeedRevealPanel.vue'
|
import SeedRevealPanel from '@/components/SeedRevealPanel.vue'
|
||||||
|
|
||||||
|
defineProps<{ setupOnly?: boolean }>()
|
||||||
|
const emit = defineEmits<{ ready: [] }>()
|
||||||
|
|
||||||
// Ecash (Cashu) wallet backup card — the same shape as the node recovery
|
// Ecash (Cashu) wallet backup card — the same shape as the node recovery
|
||||||
// phrase and the Lightning seed cards, deliberately: a third reveal pattern
|
// phrase and the Lightning seed cards, deliberately: a third reveal pattern
|
||||||
// would be a third thing to learn.
|
// would be a third thing to learn.
|
||||||
@@ -102,12 +105,14 @@ async function submitReveal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closeReveal() {
|
function closeReveal() {
|
||||||
|
const established = revealedWords.value.length > 0
|
||||||
showRevealModal.value = false
|
showRevealModal.value = false
|
||||||
revealedWords.value = []
|
revealedWords.value = []
|
||||||
revealPassword.value = ''
|
revealPassword.value = ''
|
||||||
revealCode.value = ''
|
revealCode.value = ''
|
||||||
revealPassphrase.value = ''
|
revealPassphrase.value = ''
|
||||||
showRevealPassphrase.value = false
|
showRevealPassphrase.value = false
|
||||||
|
if (established) emit('ready')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyRevealedWords() {
|
async function copyRevealedWords() {
|
||||||
@@ -221,61 +226,54 @@ async function restoreFromPhrase() {
|
|||||||
Your ecash has no backup yet
|
Your ecash has no backup yet
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-start justify-between gap-4">
|
<div class="flex flex-col gap-3">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<h2 class="text-xl font-semibold text-white/96 mb-1">Ecash backup phrase</h2>
|
<h2 class="text-xl font-semibold text-white/96 mb-1">{{ setupOnly ? 'Set up your Cashu Lightning address' : 'Ecash backup phrase' }}</h2>
|
||||||
|
|
||||||
<p v-if="status?.active && status?.source === 'node-seed'" class="text-sm text-white/60">
|
<p v-if="status?.active && status?.source === 'node-seed'" class="text-sm leading-relaxed text-white/60">
|
||||||
Your ecash wallet has its own 24-word phrase, derived from this node's recovery
|
Your node's recovery phrase also recovers this ecash phrase. Reveal its 24 words
|
||||||
phrase — so the words you already wrote down cover your ecash too. Reveal it here
|
to restore in a compatible Cashu wallet without sharing your node's phrase.
|
||||||
if you want to restore your ecash into another wallet (Minibits, Nutstash,
|
|
||||||
<span class="font-mono">cdk-cli</span>) without handing over the node's own seed.
|
|
||||||
</p>
|
</p>
|
||||||
<p v-else-if="status?.active" class="text-sm text-white/60">
|
<p v-else-if="status?.active" class="text-sm leading-relaxed text-white/60">
|
||||||
Your ecash wallet has its own 24-word phrase. Reveal it to write it down, or to
|
Save your 24-word ecash phrase to restore this wallet here or in another
|
||||||
restore your ecash into another wallet (Minibits, Nutstash,
|
compatible Cashu wallet.
|
||||||
<span class="font-mono">cdk-cli</span>).
|
|
||||||
</p>
|
</p>
|
||||||
<p v-else class="text-sm text-white/60">
|
<p v-else class="text-sm leading-relaxed text-white/60">
|
||||||
Ecash is a bearer instrument: the coins live in a file on this node, and right now
|
If this node's coin file is lost, your ecash is lost. Set up a phrase to recover
|
||||||
nothing can bring them back if that file is lost. Setting up a backup phrase fixes
|
future coins; existing coins aren't covered.
|
||||||
that for every coin minted from then on.
|
|
||||||
<template v-if="status?.derivable_from_node_seed">
|
<template v-if="status?.derivable_from_node_seed">
|
||||||
It's derived from this node's recovery phrase, so there's nothing new to write down.
|
Your node's recovery phrase will also recover this phrase.
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
This node has no encrypted seed backup to derive from, so the phrase will be its
|
This node has no saved seed, so write down and keep the new phrase separately.
|
||||||
own — you'll need to write these words down and keep them.
|
|
||||||
</template>
|
</template>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p v-if="status?.source === 'independent' || status?.source === 'imported'" class="mt-2 text-xs text-orange-300/90">
|
<p v-if="status?.source === 'independent' || status?.source === 'imported'" class="mt-2 text-xs text-orange-300/90">
|
||||||
This wallet's phrase was <strong>not</strong> derived from the node's recovery
|
{{ status?.source === 'imported' ? 'This imported phrase' : 'This phrase' }} is separate
|
||||||
phrase{{ status?.source === 'imported' ? ' — it was imported' : '' }}, so restoring
|
from your node's backup. <strong>Only these words recover this ecash wallet.</strong>
|
||||||
the node will not bring the ecash back. Only these words will.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="shrink-0 glass-button rounded-lg px-4 py-2 text-sm font-medium"
|
class="w-full glass-button rounded-lg px-4 py-2 text-sm font-medium"
|
||||||
:class="!status?.active ? 'bg-orange-500/20 border-orange-400/30' : ''"
|
:class="!status?.active ? 'bg-orange-500/20 border-orange-400/30' : ''"
|
||||||
@click="openReveal"
|
@click="openReveal"
|
||||||
>{{ status?.active ? 'Reveal' : 'Set up backup' }}</button>
|
>{{ status?.active ? 'Reveal' : (setupOnly ? 'Set up address' : 'Set up backup') }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="status?.active" class="mt-4 pt-4 border-t border-white/10">
|
<div v-if="status?.active && !setupOnly" class="mt-4 pt-4 border-t border-white/10">
|
||||||
<div class="flex items-start justify-between gap-4">
|
<div class="flex flex-col gap-3">
|
||||||
<p class="text-sm text-white/60 min-w-0">
|
<p class="text-sm leading-relaxed text-white/60 min-w-0">
|
||||||
<span class="text-white/80 font-medium">Restore from this phrase.</span>
|
<span class="text-white/80 font-medium">Restore from this phrase.</span>
|
||||||
Asks your mint which coins it has signed for these words and puts back any that
|
Recover unspent coins from your mint. Safe to repeat; coins you already hold
|
||||||
are still unspent. Safe to run at any time — it never duplicates coins you already
|
won't be duplicated.
|
||||||
hold.
|
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="shrink-0 glass-button rounded-lg px-4 py-2 text-sm font-medium disabled:opacity-50"
|
class="w-full glass-button rounded-lg px-4 py-2 text-sm font-medium disabled:opacity-50"
|
||||||
:disabled="restoring"
|
:disabled="restoring"
|
||||||
@click="restoreFromPhrase"
|
@click="restoreFromPhrase"
|
||||||
>{{ restoring ? 'Scanning…' : 'Restore' }}</button>
|
>{{ restoring ? 'Scanning…' : 'Restore' }}</button>
|
||||||
@@ -284,16 +282,16 @@ async function restoreFromPhrase() {
|
|||||||
<p v-if="restoreError" role="alert" class="mt-3 text-xs alert-error px-3 py-2 rounded-lg">{{ restoreError }}</p>
|
<p v-if="restoreError" role="alert" class="mt-3 text-xs alert-error px-3 py-2 rounded-lg">{{ restoreError }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 pt-4 border-t border-white/10">
|
<div v-if="!setupOnly" class="mt-4 pt-4 border-t border-white/10">
|
||||||
<div class="flex items-start justify-between gap-4">
|
<div class="flex flex-col gap-3">
|
||||||
<p class="text-sm text-white/60 min-w-0">
|
<p class="text-sm leading-relaxed text-white/60 min-w-0">
|
||||||
<span class="text-white/80 font-medium">Use a phrase from another wallet.</span>
|
<span class="text-white/80 font-medium">Use a phrase from another wallet.</span>
|
||||||
Point this wallet at a phrase you already have — from Minibits, Nutstash or
|
Import a phrase from Minibits, Nutstash or <span class="font-mono">cdk-cli</span>
|
||||||
<span class="font-mono">cdk-cli</span> — so its coins can be restored here.
|
to restore its coins here.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="shrink-0 glass-button rounded-lg px-4 py-2 text-sm font-medium"
|
class="w-full glass-button rounded-lg px-4 py-2 text-sm font-medium"
|
||||||
@click="openImport"
|
@click="openImport"
|
||||||
>Import</button>
|
>Import</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -319,7 +317,7 @@ async function restoreFromPhrase() {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<p class="text-sm text-white/60 mb-4">
|
<p class="text-sm leading-relaxed text-white/60 mb-4">
|
||||||
Paste the 24-word phrase from the other wallet. The coins already in this wallet
|
Paste the 24-word phrase from the other wallet. The coins already in this wallet
|
||||||
stay spendable either way.
|
stay spendable either way.
|
||||||
</p>
|
</p>
|
||||||
@@ -376,9 +374,8 @@ async function restoreFromPhrase() {
|
|||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<template v-if="revealedWords.length === 0">
|
<template v-if="revealedWords.length === 0">
|
||||||
<p class="text-sm text-white/60 mb-4">
|
<p class="text-sm leading-relaxed text-white/60 mb-4">
|
||||||
Confirm your credentials to
|
Confirm your credentials to {{ status?.active ? 'reveal' : 'set up' }} your ecash phrase.
|
||||||
{{ status?.active ? 'display the 24-word ecash phrase' : 'derive and display your ecash backup phrase' }}.
|
|
||||||
</p>
|
</p>
|
||||||
<form @submit.prevent="submitReveal" class="space-y-3">
|
<form @submit.prevent="submitReveal" class="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -407,12 +404,12 @@ async function restoreFromPhrase() {
|
|||||||
<SeedRevealPanel :words="revealedWords" />
|
<SeedRevealPanel :words="revealedWords" />
|
||||||
<p class="text-xs text-white/40 mt-3">
|
<p class="text-xs text-white/40 mt-3">
|
||||||
<template v-if="revealedSource === 'node-seed'">
|
<template v-if="revealedSource === 'node-seed'">
|
||||||
Derived from this node's recovery phrase — restoring the node restores this
|
Your node's recovery phrase recovers this ecash wallet too. Use these words
|
||||||
ecash wallet too. These words also restore it into any NUT-13 wallet.
|
separately in a compatible Cashu (NUT-13) wallet.
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
This phrase is independent of the node's recovery phrase. It is the
|
Write these words down. They are the <strong>only</strong> way to recover
|
||||||
<strong>only</strong> way to restore this ecash wallet — write it down.
|
this ecash wallet; your node's phrase won't recover it.
|
||||||
</template>
|
</template>
|
||||||
</p>
|
</p>
|
||||||
<div class="flex gap-2 pt-4">
|
<div class="flex gap-2 pt-4">
|
||||||
|
|||||||
@@ -77,8 +77,13 @@
|
|||||||
<div v-else-if="lnAddressLoading" class="mb-4 text-center text-white/50 text-sm py-4">
|
<div v-else-if="lnAddressLoading" class="mb-4 text-center text-white/50 text-sm py-4">
|
||||||
{{ t('receiveBitcoin.lnAddressLoading') }}
|
{{ t('receiveBitcoin.lnAddressLoading') }}
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else-if="lnAddressNeedsSetup" class="mb-3">
|
||||||
|
<p class="text-sm text-white/70 mb-3">Set up this wallet's recovery phrase once to enable its Lightning address.</p>
|
||||||
|
<EcashSeedBackup setup-only @ready="loadLnAddress" />
|
||||||
|
</div>
|
||||||
<div v-else-if="lnAddressError" class="mb-3 text-xs text-white/40">
|
<div v-else-if="lnAddressError" class="mb-3 text-xs text-white/40">
|
||||||
{{ t('receiveBitcoin.lnAddressUnavailable') }}
|
{{ t('receiveBitcoin.lnAddressUnavailable') }}
|
||||||
|
<button type="button" class="glass-button rounded-lg px-3 py-2 ml-2" @click="loadLnAddress">Retry</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@@ -132,6 +137,7 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
import BaseModal from '@/components/BaseModal.vue'
|
import BaseModal from '@/components/BaseModal.vue'
|
||||||
import CopyButton from '@/components/CopyButton.vue'
|
import CopyButton from '@/components/CopyButton.vue'
|
||||||
|
import EcashSeedBackup from '@/components/EcashSeedBackup.vue'
|
||||||
import PaymentSuccessPane, { type SuccessRow } from '@/components/PaymentSuccessPane.vue'
|
import PaymentSuccessPane, { type SuccessRow } from '@/components/PaymentSuccessPane.vue'
|
||||||
import { explainReceiveAddressFailure } from '@/utils/bitcoinReceive'
|
import { explainReceiveAddressFailure } from '@/utils/bitcoinReceive'
|
||||||
import { useLightningRequired } from '@/composables/useLightningRequired'
|
import { useLightningRequired } from '@/composables/useLightningRequired'
|
||||||
@@ -214,6 +220,7 @@ const error = ref('')
|
|||||||
const lnAddress = ref('')
|
const lnAddress = ref('')
|
||||||
const lnAddressLoading = ref(false)
|
const lnAddressLoading = ref(false)
|
||||||
const lnAddressError = ref(false)
|
const lnAddressError = ref(false)
|
||||||
|
const lnAddressNeedsSetup = ref(false)
|
||||||
// A payment the backend fetched (and so already consumed at Minibits) but
|
// 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
|
// 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.
|
// operator should see it rather than have it be a silent, unbounded wait.
|
||||||
@@ -230,6 +237,7 @@ async function loadLnAddress() {
|
|||||||
if (lnAddress.value || lnAddressLoading.value) return
|
if (lnAddress.value || lnAddressLoading.value) return
|
||||||
lnAddressLoading.value = true
|
lnAddressLoading.value = true
|
||||||
lnAddressError.value = false
|
lnAddressError.value = false
|
||||||
|
lnAddressNeedsSetup.value = false
|
||||||
try {
|
try {
|
||||||
const res = await rpcClient.call<{ address?: string }>({
|
const res = await rpcClient.call<{ address?: string }>({
|
||||||
method: 'wallet.ecash-lnaddress',
|
method: 'wallet.ecash-lnaddress',
|
||||||
@@ -245,6 +253,16 @@ async function loadLnAddress() {
|
|||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
lnAddressError.value = true
|
lnAddressError.value = true
|
||||||
|
// A legacy wallet may hold valid proofs without having a recovery phrase.
|
||||||
|
// Use the existing authenticated setup flow; never silently create a new
|
||||||
|
// identity or send the user to an unexplained generic service error.
|
||||||
|
try {
|
||||||
|
const seedStatus = await rpcClient.call<{ active: boolean; can_activate: boolean }>({
|
||||||
|
method: 'wallet.ecash-seed-status',
|
||||||
|
timeout: 5000,
|
||||||
|
})
|
||||||
|
lnAddressNeedsSetup.value = seedStatus.active === false && seedStatus.can_activate === true
|
||||||
|
} catch { /* Keep the retryable service error when status is unavailable. */ }
|
||||||
} finally {
|
} finally {
|
||||||
lnAddressLoading.value = false
|
lnAddressLoading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,37 @@ describe('EcashSeedBackup reveal credentials (#127)', () => {
|
|||||||
document.body.innerHTML = ''
|
document.body.innerHTML = ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('signals readiness only after authenticated setup is finished and clears the words', async () => {
|
||||||
|
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
|
||||||
|
if (method === 'wallet.ecash-seed-status') {
|
||||||
|
return { active: false, can_activate: true, derivable_from_node_seed: true, source: null } as never
|
||||||
|
}
|
||||||
|
if (method === 'wallet.ecash-seed-reveal') {
|
||||||
|
return { words: [...Array(23).fill('abandon'), 'art'], source: 'node-seed' } as never
|
||||||
|
}
|
||||||
|
throw new Error('unexpected request')
|
||||||
|
})
|
||||||
|
wrapper = mount(EcashSeedBackup, { props: { setupOnly: true }, attachTo: document.body })
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.get('button').trigger('click')
|
||||||
|
const cancel = Array.from(document.body.querySelectorAll('button')).find(b => b.textContent === 'Cancel')!
|
||||||
|
cancel.click()
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.emitted('ready')).toBeUndefined()
|
||||||
|
await wrapper.get('button').trigger('click')
|
||||||
|
const password = document.body.querySelector<HTMLInputElement>('input[autocomplete="current-password"]')!
|
||||||
|
password.value = 'test-password'
|
||||||
|
password.dispatchEvent(new Event('input', { bubbles: true }))
|
||||||
|
document.body.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.emitted('ready')).toBeUndefined()
|
||||||
|
Array.from(document.body.querySelectorAll('button')).find(b => b.textContent === 'Done')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.emitted('ready')).toEqual([[]])
|
||||||
|
expect(document.body.querySelector('[aria-labelledby="reveal-ecash-seed-title"]')).toBeNull()
|
||||||
|
expect(document.body.textContent).not.toContain('abandon')
|
||||||
|
})
|
||||||
|
|
||||||
it('asks for a separate backup passphrase only after password decryption fails', async () => {
|
it('asks for a separate backup passphrase only after password decryption fails', async () => {
|
||||||
vi.mocked(rpcClient.call)
|
vi.mocked(rpcClient.call)
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { flushPromises, mount } from '@vue/test-utils'
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import ReceiveBitcoinModal from '../ReceiveBitcoinModal.vue'
|
import ReceiveBitcoinModal from '../ReceiveBitcoinModal.vue'
|
||||||
|
import EcashSeedBackup from '../EcashSeedBackup.vue'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
|
||||||
vi.mock('vue-router', () => ({
|
vi.mock('vue-router', () => ({
|
||||||
@@ -39,6 +40,51 @@ beforeEach(() => {
|
|||||||
// unmounts the dialog — but the RPC-eager tab switch is exactly the kind of
|
// 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.
|
// path a future change could regress, so it's worth pinning down.
|
||||||
describe('ReceiveBitcoinModal — ecash tab click', () => {
|
describe('ReceiveBitcoinModal — ecash tab click', () => {
|
||||||
|
it('offers authenticated setup for an unseeded wallet and retries the address after setup', async () => {
|
||||||
|
let active = false
|
||||||
|
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
|
||||||
|
if (method === 'wallet.ecash-lnaddress') {
|
||||||
|
if (!active) throw new Error('The ecash wallet has no seed yet')
|
||||||
|
return { address: 'someone@minibits.cash' } as never
|
||||||
|
}
|
||||||
|
if (method === 'wallet.ecash-seed-status') {
|
||||||
|
return { active, can_activate: true, derivable_from_node_seed: true, source: null } as never
|
||||||
|
}
|
||||||
|
return {} as never
|
||||||
|
})
|
||||||
|
const wrapper = mount(ReceiveBitcoinModal, { props: { show: true }, attachTo: document.body })
|
||||||
|
const tab = Array.from(document.body.querySelectorAll('button')).find(b => b.textContent?.toLowerCase().includes('ecash'))!
|
||||||
|
tab.click()
|
||||||
|
await flushPromises()
|
||||||
|
expect(document.body.textContent).toContain('Set up your Cashu Lightning address')
|
||||||
|
expect(document.body.textContent).not.toContain('receiveBitcoin.lnAddressUnavailable')
|
||||||
|
expect(vi.mocked(rpcClient.call).mock.calls.some(([r]) => r.method === 'wallet.ecash-seed-reveal')).toBe(false)
|
||||||
|
active = true
|
||||||
|
wrapper.findComponent(EcashSeedBackup).vm.$emit('ready')
|
||||||
|
await flushPromises()
|
||||||
|
expect(document.body.textContent).toContain('someone@minibits.cash')
|
||||||
|
expect(wrapper.emitted('close')).toBeFalsy()
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a seeded wallet on the retry path during a service outage', async () => {
|
||||||
|
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
|
||||||
|
if (method === 'wallet.ecash-seed-status') return { active: true, can_activate: true } as never
|
||||||
|
throw new Error('service unavailable')
|
||||||
|
})
|
||||||
|
const wrapper = mount(ReceiveBitcoinModal, { props: { show: true }, attachTo: document.body })
|
||||||
|
Array.from(document.body.querySelectorAll('button')).find(b => b.textContent?.toLowerCase().includes('ecash'))!.click()
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.findComponent(EcashSeedBackup).exists()).toBe(false)
|
||||||
|
expect(document.body.textContent).toContain('receiveBitcoin.lnAddressUnavailable')
|
||||||
|
const retry = Array.from(document.body.querySelectorAll('button')).find(b => b.textContent === 'Retry')!
|
||||||
|
expect(retry).toBeTruthy()
|
||||||
|
retry.click()
|
||||||
|
await flushPromises()
|
||||||
|
expect(vi.mocked(rpcClient.call).mock.calls.filter(([r]) => r.method === 'wallet.ecash-lnaddress')).toHaveLength(2)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
it('does not close/emit when the ecash tab is clicked and the RPC succeeds', async () => {
|
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)
|
vi.mocked(rpcClient.call).mockResolvedValue({ address: 'someone@minibits.cash' } as never)
|
||||||
|
|
||||||
|
|||||||
@@ -133,6 +133,7 @@
|
|||||||
class="order-2 lg:order-none"
|
class="order-2 lg:order-none"
|
||||||
:animate="animateCards"
|
:animate="animateCards"
|
||||||
:wallet-connected="walletConnected"
|
:wallet-connected="walletConnected"
|
||||||
|
:wallet-balance-unavailable="walletBalanceUnavailable"
|
||||||
:wallet-onchain="walletOnchain"
|
:wallet-onchain="walletOnchain"
|
||||||
:wallet-lightning="walletLightning"
|
:wallet-lightning="walletLightning"
|
||||||
:wallet-ecash="walletEcash"
|
:wallet-ecash="walletEcash"
|
||||||
@@ -685,6 +686,7 @@ async function devFaucet() { try { await rpcClient.call({ method: 'dev.faucet',
|
|||||||
// readout instead; a rail only becomes a number when a call actually
|
// readout instead; a rail only becomes a number when a call actually
|
||||||
// succeeds, so a real 0 is still a real 0.
|
// succeeds, so a real 0 is still a real 0.
|
||||||
const walletConnected = ref(false)
|
const walletConnected = ref(false)
|
||||||
|
const walletBalanceUnavailable = ref(false)
|
||||||
const walletOnchain = ref<number | null>(null)
|
const walletOnchain = ref<number | null>(null)
|
||||||
const walletLightning = ref<number | null>(null)
|
const walletLightning = ref<number | null>(null)
|
||||||
const walletEcash = ref<number | null>(null)
|
const walletEcash = ref<number | null>(null)
|
||||||
@@ -775,13 +777,24 @@ async function loadWeb5Status() {
|
|||||||
// call, which is what makes the card feel like an app launch.
|
// call, which is what makes the card feel like an app launch.
|
||||||
const balances = Promise.allSettled([
|
const balances = Promise.allSettled([
|
||||||
rpcClient.call<{ balance_sats: number; channel_balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000, dedup: true })
|
rpcClient.call<{ balance_sats: number; channel_balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000, dedup: true })
|
||||||
.then(res => { walletOnchain.value = res.balance_sats || 0; walletLightning.value = res.channel_balance_sats || 0; walletConnected.value = true; walletInfoFailures = 0 })
|
.then(res => {
|
||||||
|
if (!Number.isSafeInteger(res.balance_sats) || res.balance_sats < 0 ||
|
||||||
|
!Number.isSafeInteger(res.channel_balance_sats) || res.channel_balance_sats < 0) {
|
||||||
|
throw new Error('LND balance is unavailable')
|
||||||
|
}
|
||||||
|
walletOnchain.value = res.balance_sats
|
||||||
|
walletLightning.value = res.channel_balance_sats
|
||||||
|
walletConnected.value = true
|
||||||
|
walletBalanceUnavailable.value = false
|
||||||
|
walletInfoFailures = 0
|
||||||
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// A single slow poll must NOT flip the card to "disconnected" and
|
// A single slow poll must NOT flip the card to "disconnected" and
|
||||||
// hide balances the user already knows — busy nodes routinely blow
|
// hide balances the user already knows — busy nodes routinely blow
|
||||||
// the 5s budget mid-payment or during IO storms (a test node user
|
// the 5s budget mid-payment or during IO storms (a test node user
|
||||||
// report: balances vanished while a payment settled). Only call it
|
// report: balances vanished while a payment settled). Only call it
|
||||||
// disconnected after three consecutive failures (~30s of silence).
|
// disconnected after three consecutive failures (~30s of silence).
|
||||||
|
walletBalanceUnavailable.value = true
|
||||||
walletInfoFailures += 1
|
walletInfoFailures += 1
|
||||||
if (walletInfoFailures >= 3) walletConnected.value = false
|
if (walletInfoFailures >= 3) walletConnected.value = false
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -238,6 +238,47 @@ describe('Home tab cache (Task 2): system/update/storage groups + wallet freshne
|
|||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.each(['failure', 'missing', 'partial'])('preserves known balances during %s and clears the warning on recovery', async (failure) => {
|
||||||
|
const wrapper = mountHomeHost()
|
||||||
|
await settle()
|
||||||
|
const home = wrapper.findComponent(Home)
|
||||||
|
const refresh = () => (home.vm as unknown as { loadWeb5Status: () => Promise<void> }).loadWeb5Status()
|
||||||
|
rpcCallMock.mockImplementationOnce(async () => {
|
||||||
|
if (failure === 'failure') throw new Error('wallet locked')
|
||||||
|
return failure === 'partial' ? { balance_sats: 0 } : {}
|
||||||
|
})
|
||||||
|
await refresh()
|
||||||
|
await settle()
|
||||||
|
const card = wrapper.findComponent(HomeWalletCard)
|
||||||
|
expect(card.props('walletOnchain')).toBe(5000)
|
||||||
|
expect(card.props('walletLightning')).toBe(2500)
|
||||||
|
expect(card.find('[data-testid="wallet-balance-unavailable"]').text()).toContain('last known')
|
||||||
|
const snapshot = JSON.parse(localStorage.getItem('archy-wallet-snapshot-v1')!)
|
||||||
|
expect(snapshot.onchain).toBe(5000)
|
||||||
|
expect(snapshot.lightning).toBe(2500)
|
||||||
|
rpcCallMock.mockImplementationOnce(async () => ({ balance_sats: 0, channel_balance_sats: 0, synced_to_chain: true }))
|
||||||
|
await refresh()
|
||||||
|
await settle()
|
||||||
|
expect(card.props('walletOnchain')).toBe(0)
|
||||||
|
expect(card.props('walletLightning')).toBe(0)
|
||||||
|
expect(card.find('[data-testid="wallet-balance-unavailable"]').exists()).toBe(false)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows unknown rather than zero when the first LND request fails', async () => {
|
||||||
|
rpcCallMock.mockImplementation(async (request) => {
|
||||||
|
if (request.method === 'lnd.getinfo') throw new Error('wallet locked')
|
||||||
|
return defaultRpcCallImpl(request)
|
||||||
|
})
|
||||||
|
const wrapper = mountHomeHost()
|
||||||
|
await settle()
|
||||||
|
const card = wrapper.findComponent(HomeWalletCard)
|
||||||
|
expect(card.props('walletOnchain')).toBeNull()
|
||||||
|
expect(card.props('walletLightning')).toBeNull()
|
||||||
|
expect(card.find('[data-testid="wallet-balance-unavailable"]').text()).toContain('unavailable')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
it('no sessionStorage key exists for the wallet resource after a mount and reactivation cycle', async () => {
|
it('no sessionStorage key exists for the wallet resource after a mount and reactivation cycle', async () => {
|
||||||
const wrapper = mountHomeHost()
|
const wrapper = mountHomeHost()
|
||||||
await settle()
|
await settle()
|
||||||
|
|||||||
@@ -54,6 +54,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p v-if="walletBalanceUnavailable" data-testid="wallet-balance-unavailable" class="text-sm text-amber-200 mb-3" role="status">
|
||||||
|
{{ walletOnchain != null || walletLightning != null
|
||||||
|
? 'Bitcoin and Lightning balances could not be refreshed. Showing last known amounts.'
|
||||||
|
: 'Bitcoin and Lightning balances are unavailable while the wallet starts or reconnects.' }}
|
||||||
|
</p>
|
||||||
|
|
||||||
<!-- Incoming Transactions Panel -->
|
<!-- Incoming Transactions Panel -->
|
||||||
<transition name="incoming-tx-slide">
|
<transition name="incoming-tx-slide">
|
||||||
<div v-if="showIncomingTxPanel && incomingTransactions.length > 0" class="mb-4 rounded-xl overflow-hidden border border-green-500/20">
|
<div v-if="showIncomingTxPanel && incomingTransactions.length > 0" class="mb-4 rounded-xl overflow-hidden border border-green-500/20">
|
||||||
@@ -221,6 +227,7 @@ export interface WalletTransaction {
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
animate: boolean
|
animate: boolean
|
||||||
walletConnected: boolean
|
walletConnected: boolean
|
||||||
|
walletBalanceUnavailable?: boolean
|
||||||
// `null` = not loaded yet, `0` = genuinely empty. Keeping those apart is
|
// `null` = not loaded yet, `0` = genuinely empty. Keeping those apart is
|
||||||
// what lets the card show a pixel readout instead of claiming a figure.
|
// what lets the card show a pixel readout instead of claiming a figure.
|
||||||
walletOnchain: number | null
|
walletOnchain: number | null
|
||||||
|
|||||||
Reference in New Issue
Block a user