Compare commits
No commits in common. "242baf5deb2d5c8baabab99bc6deef8fe3d60596" and "5f7e8dca807eb817743f77d5ffd0b63efc5b9dea" have entirely different histories.
242baf5deb
...
5f7e8dca80
@ -383,35 +383,10 @@ impl RpcHandler {
|
|||||||
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mint an ecash payment token, trying BOTH backends: Cashu first, then
|
// Mint ecash payment token
|
||||||
// Fedimint. The seller's verify_payment_token accepts either, so a node
|
let token_str = ecash::send_token(&self.config.data_dir, price_sats)
|
||||||
// whose balance lives in one system can still pay (#3). Surface the
|
.await
|
||||||
// Cashu error only if BOTH paths fail.
|
.context("Failed to create ecash payment token — check wallet balance")?;
|
||||||
let token_str = match ecash::send_token(&self.config.data_dir, price_sats).await {
|
|
||||||
Ok(t) => t,
|
|
||||||
Err(cashu_err) => {
|
|
||||||
match crate::wallet::fedimint_client::spend_from_any(
|
|
||||||
&self.config.data_dir,
|
|
||||||
price_sats,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok((notes, _fed_id)) => notes,
|
|
||||||
Err(fedi_err) => {
|
|
||||||
tracing::warn!(
|
|
||||||
"paid download: no ecash backend could pay {price_sats} sats \
|
|
||||||
(cashu: {cashu_err:#}; fedimint: {fedi_err:#})"
|
|
||||||
);
|
|
||||||
return Ok(serde_json::json!({
|
|
||||||
"error": format!(
|
|
||||||
"Couldn't pay {price_sats} sats from your ecash wallet \
|
|
||||||
(Cashu or Fedimint). Fund either wallet and try again."
|
|
||||||
)
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let (data, _) = self.state_manager.get_snapshot().await;
|
let (data, _) = self.state_manager.get_snapshot().await;
|
||||||
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||||
|
|||||||
@ -95,17 +95,12 @@ impl RpcHandler {
|
|||||||
if let Some(svc) = service.as_ref() {
|
if let Some(svc) = service.as_ref() {
|
||||||
let peers = svc.peers().await;
|
let peers = svc.peers().await;
|
||||||
let messages = svc.messages(None).await;
|
let messages = svc.messages(None).await;
|
||||||
// Collapse radio/federation twins into one conversation per identity
|
// Per-peer last message.
|
||||||
// so a node reachable both ways shows once, with its messages unioned
|
for peer in &peers {
|
||||||
// across both twin contact_ids (#12).
|
|
||||||
let groups = mesh::group_peer_twins(&peers);
|
|
||||||
for group in &groups {
|
|
||||||
let peer = &group.canonical;
|
|
||||||
// Newest message across ALL twin contact_ids in this group.
|
|
||||||
let last = messages
|
let last = messages
|
||||||
.iter()
|
.iter()
|
||||||
.rev()
|
.rev()
|
||||||
.find(|m| group.contact_ids.contains(&m.peer_contact_id));
|
.find(|m| m.peer_contact_id == peer.contact_id);
|
||||||
let is_federation = peer.contact_id & 0x8000_0000 != 0;
|
let is_federation = peer.contact_id & 0x8000_0000 != 0;
|
||||||
conversations.push(serde_json::json!({
|
conversations.push(serde_json::json!({
|
||||||
"id": format!("{}:{}", if is_federation { "federation" } else { "mesh" }, peer.contact_id),
|
"id": format!("{}:{}", if is_federation { "federation" } else { "mesh" }, peer.contact_id),
|
||||||
@ -168,16 +163,8 @@ impl RpcHandler {
|
|||||||
let filtered: Vec<_> = match kind {
|
let filtered: Vec<_> = match kind {
|
||||||
"mesh" | "federation" => {
|
"mesh" | "federation" => {
|
||||||
let contact_id: u32 = rest.parse().unwrap_or(0);
|
let contact_id: u32 = rest.parse().unwrap_or(0);
|
||||||
// Resolve this id's twin group and union messages across all of
|
|
||||||
// its contact_ids, so opening either twin shows the full thread
|
|
||||||
// (federation-injected + radio messages) (#12).
|
|
||||||
let ids: Vec<u32> = mesh::group_peer_twins(&svc.peers().await)
|
|
||||||
.into_iter()
|
|
||||||
.find(|g| g.contact_ids.contains(&contact_id))
|
|
||||||
.map(|g| g.contact_ids)
|
|
||||||
.unwrap_or_else(|| vec![contact_id]);
|
|
||||||
all.into_iter()
|
all.into_iter()
|
||||||
.filter(|m| ids.contains(&m.peer_contact_id))
|
.filter(|m| m.peer_contact_id == contact_id)
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
"channel" => {
|
"channel" => {
|
||||||
|
|||||||
@ -1133,13 +1133,9 @@ impl RpcHandler {
|
|||||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||||
let state = svc.shared_state();
|
let state = svc.shared_state();
|
||||||
let contacts = state.contacts.read().await;
|
let contacts = state.contacts.read().await;
|
||||||
let peer_vec: Vec<_> = state.peers.read().await.values().cloned().collect();
|
let peers = state.peers.read().await;
|
||||||
// Collapse radio/federation twins so a node reachable both ways shows as
|
|
||||||
// one contact instead of two (#12).
|
|
||||||
let groups = crate::mesh::group_peer_twins(&peer_vec);
|
|
||||||
let mut out: Vec<serde_json::Value> = Vec::new();
|
let mut out: Vec<serde_json::Value> = Vec::new();
|
||||||
for group in &groups {
|
for peer in peers.values() {
|
||||||
let peer = &group.canonical;
|
|
||||||
if let Some(pk) = peer.pubkey_hex.as_ref() {
|
if let Some(pk) = peer.pubkey_hex.as_ref() {
|
||||||
let entry = contacts.get(pk).cloned().unwrap_or_default();
|
let entry = contacts.get(pk).cloned().unwrap_or_default();
|
||||||
out.push(serde_json::json!({
|
out.push(serde_json::json!({
|
||||||
|
|||||||
@ -1903,14 +1903,6 @@ impl RpcHandler {
|
|||||||
|
|
||||||
self.set_install_phase("netbird", InstallPhase::WaitingHealthy)
|
self.set_install_phase("netbird", InstallPhase::WaitingHealthy)
|
||||||
.await;
|
.await;
|
||||||
// Containers being "running" is NOT the same as the embedded OIDC
|
|
||||||
// provider being ready (#10). The dashboard SPA opens right after install
|
|
||||||
// and, if it loads before /oauth2/.well-known is served, caches a bad
|
|
||||||
// auth state — the user appears logged-in but can't log out until it
|
|
||||||
// self-corrects. Wait (best-effort) for OIDC discovery to answer before
|
|
||||||
// we report Done, so the first dashboard load sees a ready provider.
|
|
||||||
wait_for_netbird_oidc_ready(Duration::from_secs(60)).await;
|
|
||||||
|
|
||||||
self.set_install_phase("netbird", InstallPhase::PostInstall)
|
self.set_install_phase("netbird", InstallPhase::PostInstall)
|
||||||
.await;
|
.await;
|
||||||
self.set_install_phase("netbird", InstallPhase::Done).await;
|
self.set_install_phase("netbird", InstallPhase::Done).await;
|
||||||
@ -1926,37 +1918,6 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Best-effort wait for NetBird's embedded OIDC provider to start serving its
|
|
||||||
/// discovery document. The management server publishes 8086:80 on the host and
|
|
||||||
/// is the issuer at `/oauth2`, so its `.well-known/openid-configuration` is the
|
|
||||||
/// signal that the dashboard's login/logout flow will work. Polls until a 2xx
|
|
||||||
/// or the timeout — NEVER fails the install (the stack is already running; this
|
|
||||||
/// only narrows the post-install race window in #10).
|
|
||||||
async fn wait_for_netbird_oidc_ready(timeout: Duration) {
|
|
||||||
let url = "http://127.0.0.1:8086/oauth2/.well-known/openid-configuration";
|
|
||||||
let client = match reqwest::Client::builder()
|
|
||||||
.timeout(Duration::from_secs(5))
|
|
||||||
.build()
|
|
||||||
{
|
|
||||||
Ok(c) => c,
|
|
||||||
Err(_) => return,
|
|
||||||
};
|
|
||||||
let deadline = tokio::time::Instant::now() + timeout;
|
|
||||||
loop {
|
|
||||||
if let Ok(resp) = client.get(url).send().await {
|
|
||||||
if resp.status().is_success() {
|
|
||||||
info!("NetBird OIDC discovery is ready");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if tokio::time::Instant::now() >= deadline {
|
|
||||||
info!("NetBird OIDC discovery not ready within timeout — proceeding anyway");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn read_or_generate_b64_secret(name: &str) -> String {
|
async fn read_or_generate_b64_secret(name: &str) -> String {
|
||||||
let path = format!("/var/lib/archipelago/secrets/{}", name);
|
let path = format!("/var/lib/archipelago/secrets/{}", name);
|
||||||
if let Ok(val) = tokio::fs::read_to_string(&path).await {
|
if let Ok(val) = tokio::fs::read_to_string(&path).await {
|
||||||
|
|||||||
@ -12,23 +12,8 @@ fn is_cashu_token(token: &str) -> bool {
|
|||||||
impl RpcHandler {
|
impl RpcHandler {
|
||||||
pub(super) async fn handle_wallet_ecash_balance(&self) -> Result<serde_json::Value> {
|
pub(super) async fn handle_wallet_ecash_balance(&self) -> Result<serde_json::Value> {
|
||||||
let wallet = ecash::load_wallet(&self.config.data_dir).await?;
|
let wallet = ecash::load_wallet(&self.config.data_dir).await?;
|
||||||
let cashu_sats = wallet.balance();
|
|
||||||
// Spendable Fedimint balance too, so callers (e.g. the pay-for-file
|
|
||||||
// pre-check) see funds available across BOTH backends (#3). Best-effort:
|
|
||||||
// if fmcd isn't installed/joined this is just 0, never an error.
|
|
||||||
let fedimint_sats = match fedimint_client::FedimintClient::from_node(&self.config.data_dir)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(client) => client.total_balance_sats().await.unwrap_or(0),
|
|
||||||
Err(_) => 0,
|
|
||||||
};
|
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
// `balance_sats` stays Cashu-only for back-compat; `total_sats` is the
|
"balance_sats": wallet.balance(),
|
||||||
// spendable amount across Cashu + Fedimint.
|
|
||||||
"balance_sats": cashu_sats,
|
|
||||||
"cashu_sats": cashu_sats,
|
|
||||||
"fedimint_sats": fedimint_sats,
|
|
||||||
"total_sats": cashu_sats + fedimint_sats,
|
|
||||||
"proof_count": wallet.proofs.iter().filter(|p| !p.spent && !p.reserved).count(),
|
"proof_count": wallet.proofs.iter().filter(|p| !p.spent && !p.reserved).count(),
|
||||||
"mint_url": wallet.mint_url,
|
"mint_url": wallet.mint_url,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@ -127,91 +127,6 @@ pub(crate) fn bind_federation_twins(peers: &mut std::collections::HashMap<u32, M
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One logical contact after collapsing cross-transport twins (see
|
|
||||||
/// [`group_peer_twins`]). A node reachable both over LoRa and over federation
|
|
||||||
/// has two `MeshPeer` rows (different `contact_id`s) but is one conversation.
|
|
||||||
pub(crate) struct PeerGroup {
|
|
||||||
/// The peer row the UI should address. The radio twin when one exists (so
|
|
||||||
/// `send_typed_wire` stays mesh-first — LoRa if reachable, else federation
|
|
||||||
/// via the bound arch key), otherwise the federation row. Gap-healed so its
|
|
||||||
/// name / `arch_pubkey_hex` / `did` are populated from whichever twin had
|
|
||||||
/// them, and `reachable` is the OR across the group.
|
|
||||||
pub canonical: MeshPeer,
|
|
||||||
/// Every `contact_id` in the group. The conversation's messages are the
|
|
||||||
/// union of those keyed by any of these ids — federation-injected messages
|
|
||||||
/// land on the federation twin's id, radio messages on the radio twin's.
|
|
||||||
pub contact_ids: Vec<u32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Collapse cross-transport twin peers into one conversation per identity.
|
|
||||||
///
|
|
||||||
/// The same node commonly appears twice in the peer table: a radio twin (low
|
|
||||||
/// `contact_id`, firmware routing key) and a federation twin (high
|
|
||||||
/// `contact_id`, archipelago key), correlated by [`bind_federation_twins`]
|
|
||||||
/// which copies `arch_pubkey_hex` onto the radio twin but leaves both rows.
|
|
||||||
/// Messages are keyed by `peer_contact_id`, so they split across the two ids:
|
|
||||||
/// a federation-injected message sits on the federation row while the user may
|
|
||||||
/// open the radio row and see an empty thread (the `.120`→`.89` symptom).
|
|
||||||
///
|
|
||||||
/// Group peers by `arch_pubkey_hex` when set, else treat each peer as its own
|
|
||||||
/// singleton group keyed by `contact_id`. Grouping is done ONLY here at surface
|
|
||||||
/// time — never re-keyed at bind time — so outbound routing keeps the distinct
|
|
||||||
/// per-twin `contact_id`s and stays mesh-first. First-seen order is preserved
|
|
||||||
/// for stable downstream sorting.
|
|
||||||
pub(crate) fn group_peer_twins(peers: &[MeshPeer]) -> Vec<PeerGroup> {
|
|
||||||
let mut order: Vec<String> = Vec::new();
|
|
||||||
let mut groups: std::collections::HashMap<String, Vec<MeshPeer>> =
|
|
||||||
std::collections::HashMap::new();
|
|
||||||
for p in peers {
|
|
||||||
let key = match p.arch_pubkey_hex.as_deref() {
|
|
||||||
Some(arch) if !arch.is_empty() => format!("arch:{}", arch.to_ascii_lowercase()),
|
|
||||||
_ => format!("cid:{}", p.contact_id),
|
|
||||||
};
|
|
||||||
if !groups.contains_key(&key) {
|
|
||||||
order.push(key.clone());
|
|
||||||
}
|
|
||||||
groups.entry(key).or_default().push(p.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut out = Vec::with_capacity(order.len());
|
|
||||||
for key in order {
|
|
||||||
let members = match groups.remove(&key) {
|
|
||||||
Some(m) if !m.is_empty() => m,
|
|
||||||
_ => continue,
|
|
||||||
};
|
|
||||||
let contact_ids: Vec<u32> = members.iter().map(|m| m.contact_id).collect();
|
|
||||||
// Canonical = the radio twin (lowest id below the federation base) when
|
|
||||||
// one exists, else the lowest id overall (a federation-only peer).
|
|
||||||
let canonical_src = members
|
|
||||||
.iter()
|
|
||||||
.filter(|m| m.contact_id < FEDERATION_CONTACT_ID_BASE)
|
|
||||||
.min_by_key(|m| m.contact_id)
|
|
||||||
.or_else(|| members.iter().min_by_key(|m| m.contact_id))
|
|
||||||
.expect("non-empty members");
|
|
||||||
let mut canonical = canonical_src.clone();
|
|
||||||
// Heal gaps from the twin: a radio row may lack the advert name, arch
|
|
||||||
// identity, or did that only the federation row carries.
|
|
||||||
if canonical.advert_name.trim().is_empty() {
|
|
||||||
if let Some(named) = members.iter().find(|m| !m.advert_name.trim().is_empty()) {
|
|
||||||
canonical.advert_name = named.advert_name.clone();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if canonical.arch_pubkey_hex.is_none() {
|
|
||||||
canonical.arch_pubkey_hex = members.iter().find_map(|m| m.arch_pubkey_hex.clone());
|
|
||||||
}
|
|
||||||
if canonical.did.is_none() {
|
|
||||||
canonical.did = members.iter().find_map(|m| m.did.clone());
|
|
||||||
}
|
|
||||||
// Reachable if ANY twin is reachable (radio path or off-radio federation).
|
|
||||||
canonical.reachable = members.iter().any(|m| m.reachable);
|
|
||||||
out.push(PeerGroup {
|
|
||||||
canonical,
|
|
||||||
contact_ids,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Upsert a mesh peer record representing a federation node so the UI can
|
/// Upsert a mesh peer record representing a federation node so the UI can
|
||||||
/// address it as a chat and `mesh.send-content` can route ContentRef to it.
|
/// address it as a chat and `mesh.send-content` can route ContentRef to it.
|
||||||
/// Existing entries (same contact_id) are updated in place, preserving any
|
/// Existing entries (same contact_id) are updated in place, preserving any
|
||||||
@ -1845,57 +1760,6 @@ async fn bitcoin_rpc_getblockheader_by_height(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn mk_peer(contact_id: u32, name: &str, arch: Option<&str>, reachable: bool) -> MeshPeer {
|
|
||||||
MeshPeer {
|
|
||||||
contact_id,
|
|
||||||
advert_name: name.to_string(),
|
|
||||||
did: None,
|
|
||||||
pubkey_hex: Some(format!("fw{contact_id}")),
|
|
||||||
arch_pubkey_hex: arch.map(|s| s.to_string()),
|
|
||||||
x25519_pubkey: None,
|
|
||||||
rssi: None,
|
|
||||||
snr: None,
|
|
||||||
last_heard: String::new(),
|
|
||||||
hops: 0,
|
|
||||||
last_advert: 0,
|
|
||||||
reachable,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_group_peer_twins_collapses_radio_and_federation() {
|
|
||||||
let radio = mk_peer(42, "Archy-X250-EXP", Some("ABCD"), false);
|
|
||||||
let fed = mk_peer(0x8000_0001, "Archy-X250-EXP", Some("abcd"), true);
|
|
||||||
let groups = group_peer_twins(&[radio, fed]);
|
|
||||||
assert_eq!(groups.len(), 1, "twins must collapse to one conversation");
|
|
||||||
let g = &groups[0];
|
|
||||||
// Canonical = the radio twin (mesh-first send), but reachability is the
|
|
||||||
// OR across twins (federation is reachable off-radio).
|
|
||||||
assert_eq!(g.canonical.contact_id, 42);
|
|
||||||
assert!(g.canonical.reachable);
|
|
||||||
// Both ids retained so messages can be unioned across them.
|
|
||||||
assert!(g.contact_ids.contains(&42) && g.contact_ids.contains(&0x8000_0001));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_group_peer_twins_keeps_distinct_identities_and_unbound_radio() {
|
|
||||||
// Two different identities + one radio peer that was never bound to a
|
|
||||||
// federation twin (arch = None) → three separate conversations.
|
|
||||||
let a = mk_peer(1, "Alice", Some("aa"), false);
|
|
||||||
let b = mk_peer(2, "Bob", Some("bb"), true);
|
|
||||||
let lonely = mk_peer(3, "Carol-radio", None, false);
|
|
||||||
let groups = group_peer_twins(&[a, b, lonely]);
|
|
||||||
assert_eq!(groups.len(), 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_group_peer_twins_federation_only_uses_federation_id() {
|
|
||||||
let fed = mk_peer(0x8000_00ff, "Arch Dev", Some("dead"), true);
|
|
||||||
let groups = group_peer_twins(&[fed]);
|
|
||||||
assert_eq!(groups.len(), 1);
|
|
||||||
assert_eq!(groups[0].canonical.contact_id, 0x8000_00ff);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_mesh_config_default() {
|
fn test_mesh_config_default() {
|
||||||
let config = MeshConfig::default();
|
let config = MeshConfig::default();
|
||||||
|
|||||||
@ -1118,24 +1118,6 @@ pub async fn verify_and_receive_payment(
|
|||||||
return Ok(received);
|
return Ok(received);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fedimint notes (#3): a buyer whose balance is in Fedimint pays with notes
|
|
||||||
// rather than a Cashu token. Cashu tokens all start with "cashu" (cashuA/
|
|
||||||
// cashuB, or the legacy form handled above), so anything else is treated as
|
|
||||||
// Fedimint notes and redeemed into a joined federation. reissue_into_any
|
|
||||||
// verifies the notes are unspent and credits this node's wallet.
|
|
||||||
if !token_str.starts_with("cashu") {
|
|
||||||
let (received, _fed_id) =
|
|
||||||
crate::wallet::fedimint_client::reissue_into_any(data_dir, token_str).await?;
|
|
||||||
if received < required_sats {
|
|
||||||
anyhow::bail!(
|
|
||||||
"Insufficient payment: {} sats, need {} sats",
|
|
||||||
received,
|
|
||||||
required_sats
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return Ok(received);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse and validate cashuA token
|
// Parse and validate cashuA token
|
||||||
let token = CashuToken::deserialize(token_str)?;
|
let token = CashuToken::deserialize(token_str)?;
|
||||||
let total = token.total_amount();
|
let total = token.total_amount();
|
||||||
@ -1525,7 +1507,6 @@ mod tests {
|
|||||||
description: "test".into(),
|
description: "test".into(),
|
||||||
mint_url: String::new(),
|
mint_url: String::new(),
|
||||||
peer: String::new(),
|
peer: String::new(),
|
||||||
kind: default_tx_kind(),
|
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&tx).unwrap();
|
let json = serde_json::to_string(&tx).unwrap();
|
||||||
assert!(json.contains("\"streamingpayment\""));
|
assert!(json.contains("\"streamingpayment\""));
|
||||||
|
|||||||
@ -191,75 +191,6 @@ pub async fn ensure_default_federation(data_dir: &Path) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spend `amount_sats` of Fedimint ecash from whichever joined federation can
|
|
||||||
/// cover it, returning the serialized notes (the X-Payment-Token a buyer hands
|
|
||||||
/// the seller) and the federation id that minted them. Federations are tried in
|
|
||||||
/// registry order (default first); only one with sufficient balance is used so
|
|
||||||
/// the resulting notes redeem cleanly on the other side. Errors clearly when no
|
|
||||||
/// federation is joined or none has the balance — the caller falls back to (or
|
|
||||||
/// from) the Cashu path.
|
|
||||||
pub async fn spend_from_any(data_dir: &Path, amount_sats: u64) -> Result<(String, String)> {
|
|
||||||
if amount_sats == 0 {
|
|
||||||
anyhow::bail!("payment amount must be greater than zero");
|
|
||||||
}
|
|
||||||
let _ = ensure_default_federation(data_dir).await;
|
|
||||||
let client = FedimintClient::from_node(data_dir).await?;
|
|
||||||
|
|
||||||
// Same union-of-sources approach as reissue_into_any: the persisted registry
|
|
||||||
// and what fmcd actually reports joined can drift, so consider both.
|
|
||||||
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 spend from");
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut last_err = None;
|
|
||||||
for fed_id in &fed_ids {
|
|
||||||
// Skip federations that can't cover the amount so we don't mint a
|
|
||||||
// partial/failed spend and leave dangling reserved notes.
|
|
||||||
match client.federation_balance_sats(fed_id).await {
|
|
||||||
Ok(bal) if bal >= amount_sats => {}
|
|
||||||
Ok(_) => continue,
|
|
||||||
Err(e) => {
|
|
||||||
last_err = Some(e);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
match client.spend(fed_id, amount_sats).await {
|
|
||||||
Ok(notes) => {
|
|
||||||
record_fedimint_tx(
|
|
||||||
data_dir,
|
|
||||||
crate::wallet::ecash::TransactionType::Send,
|
|
||||||
amount_sats,
|
|
||||||
fed_id,
|
|
||||||
"Sent Fedimint ecash",
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
return Ok((notes, fed_id.clone()));
|
|
||||||
}
|
|
||||||
Err(e) => last_err = Some(e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(last_err
|
|
||||||
.map(|e| anyhow::anyhow!("Fedimint spend failed across all federations: {e}"))
|
|
||||||
.unwrap_or_else(|| {
|
|
||||||
anyhow::anyhow!(
|
|
||||||
"No joined Fedimint federation has {amount_sats} sats available"
|
|
||||||
)
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Redeem received Fedimint notes into a joined federation. fmcd's reissue is
|
/// Redeem received Fedimint notes into a joined federation. fmcd's reissue is
|
||||||
/// per-federation, but a token only validates against the federation that
|
/// per-federation, but a token only validates against the federation that
|
||||||
/// minted it, so we try each joined federation (default first) and return the
|
/// minted it, so we try each joined federation (default first) and return the
|
||||||
|
|||||||
@ -39,44 +39,8 @@ claude-login.html, chown 1000:1000, restart, verify sha256+health). Recreate fro
|
|||||||
`2679725907` = federation_peer_contact_id(.120 pubkey 535fb91f…), name "Archy-X250-EXP".
|
`2679725907` = federation_peer_contact_id(.120 pubkey 535fb91f…), name "Archy-X250-EXP".
|
||||||
It's a **duplicate-contact SURFACING** problem (user confirmed doubles).
|
It's a **duplicate-contact SURFACING** problem (user confirmed doubles).
|
||||||
|
|
||||||
## SESSION 2 PROGRESS (2026-06-20, code-complete — NOT yet deployed; user held deploy)
|
## TODO (resume here)
|
||||||
All committed to local `main`; NOT pushed to gitea-vps2/origin yet, NOT sideloaded.
|
### #12 Fix duplicate mesh contacts ← user chose this NEXT
|
||||||
- **#12 dup contacts DONE** (`f92e442b`, +3 unit tests pass). Backend `group_peer_twins()`
|
|
||||||
helper (mesh/mod.rs) dedups by `arch_pubkey_hex`, radio twin = canonical send id, unions
|
|
||||||
messages; wired into conversations.list/messages + mesh.contacts-list. **KEY FINDING:**
|
|
||||||
conversations.list/messages have NO frontend consumer — the live chat list renders the
|
|
||||||
*frontend* merge `mergedPeers` (Mesh.vue), which matched twins by the `Archy-z6Mk…` advert
|
|
||||||
prefix that the device RENAME broke. Real fix = merge by `arch_pubkey_hex` (now exposed on the
|
|
||||||
MeshPeer TS type). Should also clear `.120→.89` and likely **#5** (Arch Mobile on .116, same bug).
|
|
||||||
- **Companion crash diagnostic SHIPPED** (`b3633ec5`): main.ts global handler now shows the REAL
|
|
||||||
error + keeps a 25-entry `window.__archyErrors` ring buffer + catches async/unhandledrejection.
|
|
||||||
Still need to deploy + repro on the optiplex node (read `window.__archyErrors` via chrome://inspect)
|
|
||||||
to get the actual throw. User says LAN/mobile-browser fine → Tailscale-WebView-specific.
|
|
||||||
- **#3 dual-ecash pay-for-file DONE** (`8f06d88f`, compiles): payer tries Cashu→Fedimint, seller
|
|
||||||
accepts both (verify_and_receive_payment: non-"cashu" = reissue_into_any), new
|
|
||||||
fedimint_client::spend_from_any(), wallet.ecash-balance reports total_sats. LIVE federation
|
|
||||||
validation pending (two nodes sharing a federation).
|
|
||||||
- **#2 mobile scroll cutoff DONE** (`a8c668ee`): DashboardMobileNav wrote `--mobile-tab-bar-height:0px`
|
|
||||||
when the bar was hidden/unlaid-out, defeating the `,88px` fallback → bar covered last row. Now never
|
|
||||||
writes 0 (removes var → fallback), re-measures on rAF + post-WebView-injection. Backup hypothesis if
|
|
||||||
it persists: `.dashboard-view` is `min-h-screen`(100vh) → mobile-browser toolbar overlap, switch to dvh.
|
|
||||||
|
|
||||||
DEPLOYED 2026-06-20 to ALL 6 nodes — binary sha `4a8f2198…` (release build of commit a6957a48 +
|
|
||||||
this handoff), FE rebuilt, all sha-verified + service active: .116(local) .198 .228 .89 .5 .120.
|
|
||||||
.5/.120 needed a 30-min timeout (slow DERP). #10 netbird OIDC gate also shipped in this build.
|
|
||||||
REMAINING VERIFICATION (on real hardware, user-side):
|
|
||||||
- #12/#5: open mesh chat on .116 (and .89/.120) — confirm a federated node shows ONCE with its
|
|
||||||
messages (no radio/federation double), and that "Arch Mobile" messages now surface.
|
|
||||||
- #1 companion crash: open the companion app to the optiplex node over Tailscale, reproduce the
|
|
||||||
crash, then read the REAL error from `window.__archyErrors` (chrome://inspect the WebView) or the
|
|
||||||
now-detailed toast. That error is what's needed to write the actual fix. Confirm which node = optiplex.
|
|
||||||
- #3: pay for a peer file when the buyer's balance is only in Fedimint (needs two nodes in a federation).
|
|
||||||
- #2: check Cloud/files bottom rows clear the tab bar on mobile browser.
|
|
||||||
Commits are LOCAL on main (f92e442b/b3633ec5/8f06d88f/a8c668ee/a6957a48 + docs) — NOT pushed to
|
|
||||||
gitea-vps2/origin (no version bump; bug-bash sideload only).
|
|
||||||
|
|
||||||
## TODO (original resume — #12 now DONE above)
|
|
||||||
### #12 Fix duplicate mesh contacts ← DONE this session (see SESSION 2 PROGRESS)
|
|
||||||
Root cause: `handle_mesh_contacts_list` (api/rpc/mesh/typed_messages.rs:1126) and
|
Root cause: `handle_mesh_contacts_list` (api/rpc/mesh/typed_messages.rs:1126) and
|
||||||
`handle_conversations_list` (api/rpc/mesh/status.rs:89) emit **one row per `state.peers` entry** with
|
`handle_conversations_list` (api/rpc/mesh/status.rs:89) emit **one row per `state.peers` entry** with
|
||||||
**no cross-transport dedup**. A node can have TWO peers: a radio peer (low contact_id, firmware key)
|
**no cross-transport dedup**. A node can have TWO peers: a radio peer (low contact_id, firmware key)
|
||||||
|
|||||||
@ -37,83 +37,10 @@ app.use(i18n)
|
|||||||
// templates instead of hard-coding a `v` prefix.
|
// templates instead of hard-coding a `v` prefix.
|
||||||
app.config.globalProperties.$ver = displayVersion
|
app.config.globalProperties.$ver = displayVersion
|
||||||
|
|
||||||
// Keep a small ring buffer of the most recent errors on `window.__archyErrors`
|
app.config.errorHandler = (err, _instance, info) => {
|
||||||
// so a crash that only reproduces inside a specific runtime (e.g. the Android
|
console.error('[Vue Error]', err, info)
|
||||||
// companion WebView, where there's no easy console) can be retrieved after the
|
const { error } = useToast()
|
||||||
// fact — read it from chrome://inspect, or we can surface it in a debug view.
|
error('Something went wrong. Please refresh the page.')
|
||||||
interface ArchyErrorEntry { when: string; source: string; message: string; info?: string; stack?: string }
|
|
||||||
const errorLog: ArchyErrorEntry[] = []
|
|
||||||
;(window as unknown as { __archyErrors?: ArchyErrorEntry[] }).__archyErrors = errorLog
|
|
||||||
|
|
||||||
// On-screen, screenshot-able error overlay. Built with plain DOM (not a Vue
|
|
||||||
// component) so it still appears when the crash is in Vue itself or in a runtime
|
|
||||||
// with no dev console (the Android companion WebView — chrome://inspect isn't
|
|
||||||
// always reachable). The user can read/screenshot the real error and the stack
|
|
||||||
// straight off the device, and "Copy" puts the full ring buffer on the clipboard.
|
|
||||||
function showErrorOverlay() {
|
|
||||||
const id = 'archy-error-overlay'
|
|
||||||
let box = document.getElementById(id)
|
|
||||||
if (!box) {
|
|
||||||
box = document.createElement('div')
|
|
||||||
box.id = id
|
|
||||||
box.style.cssText = [
|
|
||||||
'position:fixed', 'left:8px', 'right:8px', 'bottom:8px', 'z-index:2147483647',
|
|
||||||
'max-height:45vh', 'overflow:auto', 'background:rgba(20,0,0,0.92)', 'color:#ffd7d7',
|
|
||||||
'font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace', 'padding:10px 12px',
|
|
||||||
'border:1px solid #ff5a5a', 'border-radius:10px', 'white-space:pre-wrap',
|
|
||||||
'word-break:break-word', '-webkit-user-select:text', 'user-select:text',
|
|
||||||
].join(';')
|
|
||||||
document.body.appendChild(box)
|
|
||||||
}
|
|
||||||
const latest = errorLog[errorLog.length - 1]
|
|
||||||
const dump = errorLog
|
|
||||||
.map((e) => `[${e.source}] ${e.message}${e.info ? ` (${e.info})` : ''}${e.stack ? `\n${e.stack.split('\n').slice(0, 4).join('\n')}` : ''}`)
|
|
||||||
.join('\n──\n')
|
|
||||||
box.innerHTML = ''
|
|
||||||
const bar = document.createElement('div')
|
|
||||||
bar.style.cssText = 'display:flex;gap:8px;justify-content:space-between;align-items:center;margin-bottom:6px'
|
|
||||||
const title = document.createElement('b')
|
|
||||||
title.textContent = `⚠ App error (${errorLog.length}) — screenshot or Copy`
|
|
||||||
title.style.color = '#ff8a8a'
|
|
||||||
const btns = document.createElement('div')
|
|
||||||
const mkBtn = (label: string, fn: () => void) => {
|
|
||||||
const b = document.createElement('button')
|
|
||||||
b.textContent = label
|
|
||||||
b.style.cssText = 'margin-left:6px;background:#ff5a5a;color:#1a0000;border:0;border-radius:6px;padding:3px 9px;font-weight:700'
|
|
||||||
b.onclick = fn
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
btns.appendChild(mkBtn('Copy', () => { navigator.clipboard?.writeText(JSON.stringify(errorLog, null, 2)) }))
|
|
||||||
btns.appendChild(mkBtn('Dismiss', () => { box?.remove() }))
|
|
||||||
bar.appendChild(title); bar.appendChild(btns)
|
|
||||||
const body = document.createElement('div')
|
|
||||||
body.textContent = latest ? dump : 'no error captured'
|
|
||||||
box.appendChild(bar); box.appendChild(body)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function recordError(source: string, err: unknown, info?: string) {
|
|
||||||
const e = err as { message?: string; stack?: string } | undefined
|
|
||||||
const message = (e?.message ?? String(err)) || 'unknown error'
|
|
||||||
const entry: ArchyErrorEntry = { when: new Date().toISOString(), source, message, info, stack: e?.stack }
|
|
||||||
errorLog.push(entry)
|
|
||||||
if (errorLog.length > 25) errorLog.shift()
|
|
||||||
console.error(`[${source}]`, err, info ?? '')
|
|
||||||
// Surface the real message (truncated) instead of a generic toast — this is a
|
|
||||||
// test/bug-bash build, and "Something went wrong" hides exactly what we need.
|
|
||||||
const short = message.length > 140 ? `${message.slice(0, 140)}…` : message
|
|
||||||
try {
|
|
||||||
useToast().error(`Something went wrong: ${short}`)
|
|
||||||
} catch { /* toast itself failed — the console + ring buffer still have it */ }
|
|
||||||
// Always show the on-device overlay so the error is visible without a console.
|
|
||||||
try { showErrorOverlay() } catch { /* overlay is best-effort */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
app.config.errorHandler = (err, _instance, info) => recordError('Vue Error', err, info)
|
|
||||||
|
|
||||||
// Vue's errorHandler only catches errors raised synchronously inside Vue's
|
|
||||||
// lifecycle/reactivity. Async rejections and plain runtime errors (e.g. a JS
|
|
||||||
// API missing in an older WebView) slip past it, so catch those too.
|
|
||||||
window.addEventListener('error', (ev) => recordError('window.error', ev.error ?? ev.message))
|
|
||||||
window.addEventListener('unhandledrejection', (ev) => recordError('unhandledrejection', ev.reason))
|
|
||||||
|
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|||||||
@ -26,12 +26,6 @@ export interface MeshPeer {
|
|||||||
advert_name: string
|
advert_name: string
|
||||||
did: string | null
|
did: string | null
|
||||||
pubkey_hex: string | null
|
pubkey_hex: string | null
|
||||||
/** Verified archipelago ed25519 identity key. The backend binds this onto
|
|
||||||
* BOTH a node's twins — the federation peer natively and the radio twin via
|
|
||||||
* `bind_federation_twins` — so it's the most reliable key for collapsing the
|
|
||||||
* cross-transport duplicate (survives the device rename, unlike the advert
|
|
||||||
* name). Absent on radio peers that were never matched to a federation twin. */
|
|
||||||
arch_pubkey_hex?: string | null
|
|
||||||
rssi: number | null
|
rssi: number | null
|
||||||
snr: number | null
|
snr: number | null
|
||||||
last_heard: string
|
last_heard: string
|
||||||
|
|||||||
@ -560,20 +560,6 @@ function fedDidKeySuffix(did: string): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mergeKeyForPeer(peer: MeshPeer): { key: string; matchedFed: FedNodeInfo | null } {
|
function mergeKeyForPeer(peer: MeshPeer): { key: string; matchedFed: FedNodeInfo | null } {
|
||||||
// Strongest signal: the verified archipelago identity key. The backend binds
|
|
||||||
// it onto BOTH twins (federation peer natively, radio twin via
|
|
||||||
// `bind_federation_twins`), so grouping by it collapses the cross-transport
|
|
||||||
// duplicate regardless of advert name — this is what survives the Meshtastic
|
|
||||||
// device rename, which broke the `Archy-z6Mk…` did-prefix match below. Prefer
|
|
||||||
// the matching federation node's `did:` key so this stays consistent with the
|
|
||||||
// federation-only placeholder pass (which dedups on `did:<did>`); fall back to
|
|
||||||
// an `arch:` key only when no federation entry is known for the identity.
|
|
||||||
if (peer.arch_pubkey_hex) {
|
|
||||||
for (const fed of fedNodesByDid.value.values()) {
|
|
||||||
if (fed.pubkey === peer.arch_pubkey_hex) return { key: `did:${fed.did}`, matchedFed: fed }
|
|
||||||
}
|
|
||||||
return { key: `arch:${peer.arch_pubkey_hex.toLowerCase()}`, matchedFed: null }
|
|
||||||
}
|
|
||||||
if (peer.did) return { key: `did:${peer.did}`, matchedFed: fedNodesByDid.value.get(peer.did) ?? null }
|
if (peer.did) return { key: `did:${peer.did}`, matchedFed: fedNodesByDid.value.get(peer.did) ?? null }
|
||||||
// pubkey cross-ref: a federation node may share the archipelago pubkey
|
// pubkey cross-ref: a federation node may share the archipelago pubkey
|
||||||
// with this radio peer if it's the same physical node (rare today, since
|
// with this radio peer if it's the same physical node (rare today, since
|
||||||
|
|||||||
@ -916,16 +916,14 @@ async function payWithEcash() {
|
|||||||
downloading.value = item.id
|
downloading.value = item.id
|
||||||
purchaseError.value = null
|
purchaseError.value = null
|
||||||
try {
|
try {
|
||||||
// Check ecash balance first — across BOTH backends (Cashu + Fedimint), since
|
// Check ecash balance first
|
||||||
// the payment tries each in turn (#3). Fall back to the Cashu-only field for
|
|
||||||
// older backends that don't report `total_sats`.
|
|
||||||
try {
|
try {
|
||||||
const balanceRes = await rpcClient.call<{ total_sats?: number; balance_sats?: number }>({
|
const balanceRes = await rpcClient.call<{ balance_sats?: number }>({
|
||||||
method: 'wallet.ecash-balance',
|
method: 'wallet.ecash-balance',
|
||||||
})
|
})
|
||||||
const balance = balanceRes?.total_sats ?? balanceRes?.balance_sats ?? 0
|
const balance = balanceRes?.balance_sats ?? 0
|
||||||
if (balance < price) {
|
if (balance < price) {
|
||||||
purchaseError.value = `Insufficient ecash balance (${balance} sats across Cashu + Fedimint). Need ${price} sats. Fund either wallet, or pay from another wallet via QR.`
|
purchaseError.value = `Insufficient ecash balance (${balance} sats). Need ${price} sats. Fund your wallet, or pay from another wallet via QR.`
|
||||||
closePayModal()
|
closePayModal()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@ -188,17 +188,9 @@ defineExpose({
|
|||||||
|
|
||||||
function updateTabBarHeight() {
|
function updateTabBarHeight() {
|
||||||
if (typeof window === 'undefined') return
|
if (typeof window === 'undefined') return
|
||||||
const el = mobileTabBar.value
|
if (mobileTabBar.value) {
|
||||||
// offsetHeight is 0 when the bar is hidden (desktop `md:hidden`) or not yet
|
const height = mobileTabBar.value.offsetHeight
|
||||||
// laid out. Writing `--mobile-tab-bar-height: 0px` would DEFEAT the `, 88px`
|
document.documentElement.style.setProperty('--mobile-tab-bar-height', `${height}px`)
|
||||||
// fallback baked into the `.mobile-scroll-pad` clearance calc (an explicit
|
|
||||||
// 0px is still "set"), so the fixed tab bar ends up covering the last row of
|
|
||||||
// content — the Cloud/files "bottom elements cut off" bug. Only write a real
|
|
||||||
// measured height; otherwise remove the var so the fallback applies.
|
|
||||||
if (el && el.offsetHeight > 0) {
|
|
||||||
document.documentElement.style.setProperty('--mobile-tab-bar-height', `${el.offsetHeight}px`)
|
|
||||||
} else {
|
|
||||||
document.documentElement.style.removeProperty('--mobile-tab-bar-height')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -209,15 +201,10 @@ function onResize() {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
updateTabBarHeight()
|
updateTabBarHeight()
|
||||||
// Re-measure after the first paint: on mount the bar may not have its final
|
|
||||||
// laid-out height yet (fonts/safe-area padding still settling), which would
|
|
||||||
// leave the clearance var short.
|
|
||||||
requestAnimationFrame(updateTabBarHeight)
|
|
||||||
readSafeAreaTop()
|
readSafeAreaTop()
|
||||||
window.addEventListener('resize', onResize)
|
window.addEventListener('resize', onResize)
|
||||||
// Re-read after WebView injection has had time to run. The injected
|
// Re-read after WebView injection has had time to run
|
||||||
// safe-area-bottom padding changes the bar's height, so re-measure too.
|
setTimeout(readSafeAreaTop, 500)
|
||||||
setTimeout(() => { readSafeAreaTop(); updateTabBarHeight() }, 500)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user