Compare commits

...

3 Commits

Author SHA1 Message Date
archipelago
af816c61a5 fix(ui): reliable federation-join feedback (90s timeout + re-check + success)
Joining a Fedimint federation is heavy and routinely outlasts the default 15s
client timeout while still succeeding server-side, so the UI wrongly showed
failure. Bump the join timeout to 90s, and on any error re-check the list: if a
new federation appeared the join worked — show 'Federation joined.' instead of
a misleading error.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:43:30 -04:00
archipelago
63611a4453 fix(mesh): honour explicit !ai allowlist for unauthenticated stock clients
A stock meshcore client (e.g. a phone) can't sign our typed envelopes, so it is
never 'authenticated' — which meant ticking it as an allowed assistant contact
had no effect and !ai stayed denied. The explicit per-contact allowlist is a
deliberate operator opt-in for a specific key, so match it regardless of
authentication, keyed on the asker's resolved identity (bound archipelago key,
else firmware routing key — how meshcore addresses the contact). The spoofable
federation-trust-list match still requires authentication.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:43:30 -04:00
archipelago
7831e68d13 fix(wallet): redeem across all federations, unified ecash history, fmcd healthcheck
- reissue_into_any now tries the UNION of the local registry AND fmcd's live
  joined set (/v2/admin/info) before failing, so a valid Fedimint token isn't
  wrongly rejected when the registry has drifted. On all-fail it returns a
  friendly message: notes already redeemed into this wallet (funds safe) vs
  didn't match any connected federation.
- Unified transaction history: a local Fedimint tx log (recorded on each
  successful redeem) is merged with the Cashu history in wallet.ecash-history,
  newest-first, each tagged kind=cashu|fedimint. Previously a Fedimint receive
  appeared nowhere.
- fedimint-clientd healthcheck -> type:tcp. It was probing /health, which fmcd
  doesn't serve (only /v2/*), pinning the container in (starting) forever; the
  TCP probe is skipped by the Quadlet renderer (host-side lifecycle verifies),
  so it reports running. Cosmetic for ecash, which worked throughout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:43:29 -04:00
6 changed files with 197 additions and 21 deletions

View File

@ -69,10 +69,15 @@ app:
# join reliability from a real second node before relying on auto-bundle.
- FMCD_INVITE_CODE=fed11qgqyj3mfwfhksw309uuxywtxxfjrjc35xuexverpxdsnxcnrxucxvenzveskgc3kvvun2c34xp3k2ep38yunzdpexcekxe3hvd3rvvmx8pnrvdenx5mnzvtzqqqjqt0t6pc3s5z0ynqjw9s4njf6svwgu59kweawc0vvrddcjeemw6yyn4pcdp
# fmcd serves only authenticated /v2/* routes — there is no unauthenticated
# /health endpoint, so an http probe to /health 404s forever and pins the
# container in "(starting)". fmcd's own image also ships neither curl nor wget.
# Use a TCP probe: the Quadlet renderer skips it (no HealthCmd emitted) and the
# host-side lifecycle layer verifies reachability, so the container reports
# "running" instead of a perpetual false-negative "(starting)".
health_check:
type: http
endpoint: http://localhost:8080
path: /health
type: tcp
endpoint: localhost:8080
interval: 30s
timeout: 5s
retries: 3

View File

@ -161,9 +161,17 @@ impl RpcHandler {
}
pub(super) async fn handle_wallet_ecash_history(&self) -> Result<serde_json::Value> {
// Unified history: Cashu transactions (tagged kind="cashu") + the local
// Fedimint transaction log (kind="fedimint"), newest first. Previously
// only Cashu was returned, so a Fedimint receive showed up nowhere.
let wallet = ecash::load_wallet(&self.config.data_dir).await?;
let mut transactions = wallet.transactions;
transactions.extend(fedimint_client::load_fedimint_txs(&self.config.data_dir).await);
// Sort by RFC-3339 timestamp descending (string compare is valid for
// same-offset RFC-3339), newest first.
transactions.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
Ok(serde_json::json!({
"transactions": wallet.transactions,
"transactions": transactions,
}))
}

View File

@ -200,15 +200,20 @@ async fn is_sender_allowed(
}
}
// Explicit per-contact allowlist: a listed pubkey may ask regardless of the
// trusted_only policy — but only when the message is authenticated, so a
// spoofed packet claiming an allowlisted key can't slip through.
if authenticated {
if let Some(ref pk) = pubkey_hex {
let allowed = state.assistant.read().await.allowed_contacts.clone();
if allowed.iter().any(|a| a.eq_ignore_ascii_case(pk)) {
return true;
}
// Explicit per-contact allowlist: the operator deliberately ticked THIS
// contact, so honour it even for an unauthenticated radio asker. A stock
// meshcore client (e.g. a phone) can't sign our typed envelopes, so it can
// never be `authenticated` — gating the allowlist on authentication made
// ticking such a contact have no effect. We match the asker's resolved
// identity key: the bound archipelago key if we know it, else the firmware
// routing key (`pubkey_hex`), which is how meshcore addresses the contact
// and what the UI adds to the allowlist for a keyless radio peer. This is a
// narrow, explicit opt-in for a specific key — the spoofable federation-
// trust-list match below still requires authentication.
if let Some(ref pk) = pubkey_hex {
let allowed = state.assistant.read().await.allowed_contacts.clone();
if allowed.iter().any(|a| a.eq_ignore_ascii_case(pk)) {
return true;
}
}

View File

@ -42,6 +42,16 @@ pub struct EcashTransaction {
/// Peer identifier (DID, pubkey, or onion) if applicable.
#[serde(default)]
pub peer: String,
/// Which ecash system this entry belongs to: "cashu" or "fedimint". Lets the
/// unified history/UI label each transaction. Defaults to "cashu" so legacy
/// stored entries (written before dual-ecash) read back correctly.
#[serde(default = "default_tx_kind")]
pub kind: String,
}
/// Default `kind` for transactions persisted before the dual-ecash split.
pub fn default_tx_kind() -> String {
"cashu".to_string()
}
/// A stored proof with metadata.
@ -190,6 +200,7 @@ impl WalletState {
description: description.to_string(),
mint_url: mint_url.to_string(),
peer: peer.to_string(),
kind: "cashu".to_string(),
});
}

View File

@ -105,6 +105,62 @@ pub async fn save_registry(data_dir: &Path, reg: &FederationRegistry) -> Result<
Ok(())
}
/// Local Fedimint transaction log. fmcd has no per-node history API, so we
/// record each redeem/spend ourselves and merge it with the Cashu history in
/// `wallet.ecash-history` — otherwise a Fedimint receive shows nowhere.
const FEDIMINT_TX_FILE: &str = "wallet/fedimint_transactions.json";
/// Load the local Fedimint transaction log (newest entries last). Empty on any
/// error — history is best-effort and must never block a wallet operation.
pub async fn load_fedimint_txs(data_dir: &Path) -> Vec<crate::wallet::ecash::EcashTransaction> {
match fs::read_to_string(data_dir.join(FEDIMINT_TX_FILE)).await {
Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
Err(_) => Vec::new(),
}
}
/// Append a Fedimint transaction to the local log so it appears in unified
/// history with meaningful data. Best-effort: write failures are logged, not
/// propagated, so they never fail the redeem/spend that produced the funds.
pub async fn record_fedimint_tx(
data_dir: &Path,
tx_type: crate::wallet::ecash::TransactionType,
amount_sats: u64,
federation_id: &str,
description: &str,
) {
let mut txs = load_fedimint_txs(data_dir).await;
txs.push(crate::wallet::ecash::EcashTransaction {
id: uuid::Uuid::new_v4().to_string(),
tx_type,
amount_sats,
timestamp: chrono::Utc::now().to_rfc3339(),
description: description.to_string(),
// Cashu uses mint_url; for Fedimint we record the federation id in `peer`
// so the UI can show which federation the funds moved through.
mint_url: String::new(),
peer: federation_id.to_string(),
kind: "fedimint".to_string(),
});
// Cap the log so it can't grow unbounded.
let len = txs.len();
if len > 500 {
txs.drain(0..len - 500);
}
if let Err(e) = fs::create_dir_all(data_dir.join("wallet")).await {
tracing::warn!("fedimint tx log: could not create wallet dir: {e}");
return;
}
match serde_json::to_string_pretty(&txs) {
Ok(content) => {
if let Err(e) = fs::write(data_dir.join(FEDIMINT_TX_FILE), content).await {
tracing::warn!("fedimint tx log: write failed: {e}");
}
}
Err(e) => tracing::warn!("fedimint tx log: serialize failed: {e}"),
}
}
/// Idempotently ensure the node has joined the default federation and that it
/// is tracked in the local registry. Best-effort: silently no-ops if clientd
/// isn't installed/running yet. Joining is idempotent on the clientd side.
@ -146,19 +202,76 @@ pub async fn reissue_into_any(data_dir: &Path, notes: &str) -> Result<(u64, Stri
let _ = ensure_default_federation(data_dir).await;
let client = FedimintClient::from_node(data_dir).await?;
let reg = load_registry(data_dir).await?;
if reg.federations.is_empty() {
// Build the set of federations to try: the locally-persisted registry PLUS
// every federation the fmcd sidecar actually reports joined. The two can
// drift (a federation joined directly, before tracking, or a registry that
// wasn't written), and a note only validates against the federation that
// minted it — so we must try EVERY connected federation before giving up,
// or a perfectly valid token is wrongly reported as failed.
let mut fed_ids: Vec<String> = Vec::new();
if let Ok(reg) = load_registry(data_dir).await {
for f in reg.federations {
if !fed_ids.contains(&f.federation_id) {
fed_ids.push(f.federation_id);
}
}
}
for id in client.joined_federation_ids().await {
if !fed_ids.contains(&id) {
fed_ids.push(id);
}
}
if fed_ids.is_empty() {
anyhow::bail!("No Fedimint federation joined to redeem these notes into");
}
let mut already_redeemed = false;
let mut last_err = None;
for fed in &reg.federations {
match client.reissue(&fed.federation_id, notes).await {
Ok(sats) => return Ok((sats, fed.federation_id.clone())),
Err(e) => last_err = Some(e),
for fed_id in &fed_ids {
match client.reissue(fed_id, notes).await {
Ok(sats) => {
// Record the receive so it appears in unified ecash history.
record_fedimint_tx(
data_dir,
crate::wallet::ecash::TransactionType::Receive,
sats,
fed_id,
"Received Fedimint ecash",
)
.await;
return Ok((sats, fed_id.clone()));
}
Err(e) => {
let msg = e.to_string().to_ascii_lowercase();
// fmcd reports already-claimed notes as "We already reissued
// these notes" (or "already spent"). That means the funds were
// already redeemed INTO this node's wallet — they're safe, just
// not new — so surface that clearly instead of a raw 500.
if msg.contains("already reissued") || msg.contains("already spent") {
already_redeemed = true;
}
last_err = Some(e);
}
}
}
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("Fedimint reissue failed")))
if already_redeemed {
anyhow::bail!(
"This ecash was already redeemed into your wallet — the notes are \
already claimed, so no new balance was added. Check your Fedimint balance."
);
}
Err(last_err
.map(|e| {
anyhow::anyhow!(
"These notes didn't match any of your {} connected Fedimint \
federation(s). You may need to join the federation that issued \
them first. (last error: {e})",
fed_ids.len()
)
})
.unwrap_or_else(|| anyhow::anyhow!("Fedimint reissue failed")))
}
/// HTTP client for a `fedimint-clientd` instance.
@ -263,6 +376,20 @@ impl FedimintClient {
self.get("/v2/admin/info").await
}
/// Every federation id the fmcd sidecar currently reports joined, read from
/// `/v2/admin/info` (the authoritative live set — the locally-persisted
/// registry can drift from it). Returns an empty vec on any error so callers
/// can fall back to the registry rather than fail outright.
pub async fn joined_federation_ids(&self) -> Vec<String> {
match self.info().await {
Ok(info) => info
.as_object()
.map(|m| m.keys().cloned().collect())
.unwrap_or_default(),
Err(_) => Vec::new(),
}
}
/// `POST /v2/admin/join` — join a federation by invite code; returns its federationId.
pub async fn join(&self, invite_code: &str) -> Result<String> {
let res = self

View File

@ -120,6 +120,7 @@
</div>
<div v-if="fedError" class="mb-3 alert-error">{{ fedError }}</div>
<div v-if="fedJoinedOk" class="mb-3 text-xs text-green-400">Federation joined.</div>
<div class="flex gap-3 mt-4">
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
@ -178,6 +179,7 @@ const federations = ref<Federation[]>([])
const inviteCode = ref('')
const joiningFed = ref(false)
const fedError = ref('')
const fedJoinedOk = ref(false)
watch(
() => props.show,
@ -259,18 +261,36 @@ async function loadFederations() {
async function joinFederation() {
if (!fedimintBackendReady || !inviteCode.value.trim()) return
const before = federations.value.length
joiningFed.value = true
fedError.value = ''
fedJoinedOk.value = false
try {
await rpcClient.call<{ federation_id: string }>({
method: 'wallet.fedimint-join',
params: { invite_code: inviteCode.value.trim() },
// Joining a federation is heavy (downloads the federation config + joins
// the consensus); it routinely takes longer than the default 15s. Give it
// headroom past the backend's own 60s clientd timeout.
timeout: 90000,
})
inviteCode.value = ''
await loadFederations()
fedJoinedOk.value = true
emit('changed')
} catch (err: unknown) {
fedError.value = err instanceof Error ? err.message : 'Failed to join federation'
// A slow join often still completes server-side after the client gives up,
// so don't cry failure blindly re-check the list. If a new federation
// appeared, the join actually worked; surface success instead of a scary
// (and wrong) timeout error.
await loadFederations()
if (federations.value.length > before) {
inviteCode.value = ''
fedJoinedOk.value = true
emit('changed')
} else {
fedError.value = err instanceof Error ? err.message : 'Failed to join federation'
}
} finally {
joiningFed.value = false
}