Compare commits
3 Commits
0f2e6f6aaf
...
af816c61a5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af816c61a5 | ||
|
|
63611a4453 | ||
|
|
7831e68d13 |
@ -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
|
||||
|
||||
@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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 ®.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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user