integration: preserve deployed 1.8.0 OTA work

This commit is contained in:
archipelago
2026-06-30 05:08:17 -04:00
parent f4f45c1a09
commit df9d3a55be
39 changed files with 1066 additions and 203 deletions
+1 -2
View File
@@ -176,8 +176,7 @@ impl RpcHandler {
// launch_port_reachable() below would otherwise upgrade an exited backend
// back to "running". The reconcile guard keeps these backends down, so the
// marker is authoritative here.
let user_stopped =
crate::crash_recovery::load_user_stopped(&self.config.data_dir).await;
let user_stopped = crate::crash_recovery::load_user_stopped(&self.config.data_dir).await;
if data.server_info.status_info.containers_scanned && !data.package_data.is_empty() {
let mut containers = Vec::with_capacity(data.package_data.len());
for (id, pkg) in &data.package_data {
+9 -3
View File
@@ -429,11 +429,15 @@ impl RpcHandler {
},
Some("fedimint") => match mint_fedimint().await {
Ok((notes, fed)) => {
tracing::info!("paid download: spending {price_sats} sats Fedimint notes from {fed}");
tracing::info!(
"paid download: spending {price_sats} sats Fedimint notes from {fed}"
);
(notes, "fedimint")
}
Err(e) => {
tracing::warn!("paid download: fedimint spend failed for {price_sats} sats: {e:#}");
tracing::warn!(
"paid download: fedimint spend failed for {price_sats} sats: {e:#}"
);
return Ok(serde_json::json!({ "error": format!(
"Couldn't pay {price_sats} sats from your Fedimint wallet: {e}. \
Fund it, or choose Cashu."
@@ -457,7 +461,9 @@ impl RpcHandler {
},
},
};
tracing::info!("paid download: paying {price_sats} sats to {onion} via {used_backend} ecash");
tracing::info!(
"paid download: paying {price_sats} sats to {onion} via {used_backend} ecash"
);
let (data, _) = self.state_manager.get_snapshot().await;
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
@@ -19,7 +19,10 @@ impl RpcHandler {
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
(svc.assistant_config().await, svc.assistant_denied_askers().await)
(
svc.assistant_config().await,
svc.assistant_denied_askers().await,
)
};
let (ollama_detected, models) = detect_ollama().await;
@@ -257,12 +257,14 @@ mod tests {
Some("28.4")
);
assert_eq!(
image_tag("146.59.87.168:3000/lfg2025/bitcoin-knots:29.3.knots20260508")
.as_deref(),
image_tag("146.59.87.168:3000/lfg2025/bitcoin-knots:29.3.knots20260508").as_deref(),
Some("29.3.knots20260508")
);
// No tag => None (don't mistake the registry port for a tag).
assert_eq!(image_tag("146.59.87.168:3000/lfg2025/bitcoin"), None);
assert_eq!(image_tag("docker.io/library/redis:7"), Some("7".to_string()));
assert_eq!(
image_tag("docker.io/library/redis:7"),
Some("7".to_string())
);
}
}
+5 -6
View File
@@ -16,12 +16,11 @@ impl RpcHandler {
// 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,
};
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!({
// `balance_sats` stays Cashu-only for back-compat; `total_sats` is the
// spendable amount across Cashu + Fedimint.
@@ -220,7 +220,9 @@ pub fn catalog_manifest_values() -> Vec<(String, serde_json::Value)> {
/// `version` field), if covered. Used to decide whether an install-time
/// selection should pin (older) or track-latest (default).
pub fn catalog_default_version(app_id: &str) -> Option<String> {
entry_for(app_id).map(|e| e.version).filter(|v| !v.is_empty())
entry_for(app_id)
.map(|e| e.version)
.filter(|v| !v.is_empty())
}
/// Curated, selectable versions for an app per the remote catalog. Empty when
@@ -261,8 +263,8 @@ pub fn catalog_image_for_version(
format!("{repo}:{version}")
}
};
let same_repo =
crate::container::image_versions::image_without_registry_or_tag(&candidate) == manifest_repo;
let same_repo = crate::container::image_versions::image_without_registry_or_tag(&candidate)
== manifest_repo;
if same_repo {
Some(candidate)
} else {
@@ -294,7 +294,7 @@ mod tests {
}
async fn wait_for_status_calls(rt: &CountingRuntime, expected: u32) -> u32 {
for _ in 0..100 {
for _ in 0..1000 {
let count = rt.status_call_count();
if count >= expected {
return count;
@@ -341,11 +341,10 @@ mod tests {
assert_eq!(wait_for_status_calls(&rt, 1).await, 1);
tokio::time::sleep(Duration::from_millis(20)).await;
wait_for_status_calls(&rt, 2).await;
let count = wait_for_status_calls(&rt, 2).await;
assert_eq!(
rt.status_call_count(),
2,
assert!(
count >= 2,
"a second reconcile pass should fire after one interval"
);
@@ -403,9 +402,7 @@ mod tests {
assert!(first >= 1, "initial pass should have touched the runtime");
tokio::time::sleep(Duration::from_millis(20)).await;
tokio::task::yield_now().await;
tokio::task::yield_now().await;
let second = rt.status_call_count();
let second = wait_for_status_calls(&rt, first + 1).await;
assert!(
second > first,
"loop should have fired a second pass after the interval"
+4 -1
View File
@@ -336,7 +336,10 @@ async fn image_created_unix(image: &str) -> Option<i64> {
if !out.status.success() {
return None;
}
String::from_utf8_lossy(&out.stdout).trim().parse::<i64>().ok()
String::from_utf8_lossy(&out.stdout)
.trim()
.parse::<i64>()
.ok()
}
/// Newest modification time (Unix seconds) across all files under `dir`,
+1 -6
View File
@@ -85,12 +85,7 @@ pub async fn run_post_install(manifest: &AppManifest, container_name: &str, data
}
}
async fn run_step(
step: &HookStep,
container: &str,
app_id: &str,
data_dir: &Path,
) -> Result<()> {
async fn run_step(step: &HookStep, container: &str, app_id: &str, data_dir: &Path) -> Result<()> {
match step {
HookStep::Exec { exec } => {
let mut args: Vec<&str> = Vec::with_capacity(exec.len() + 2);
@@ -302,9 +302,8 @@ async fn chown_for_rootless_container(uid_gid: &str, path: &str) -> Result<()> {
/// journal on every pass. Keyed by Id so a recreated container retries afresh.
fn unrepairable_ownership() -> &'static std::sync::Mutex<std::collections::HashSet<(String, String)>>
{
static SET: std::sync::OnceLock<
std::sync::Mutex<std::collections::HashSet<(String, String)>>,
> = std::sync::OnceLock::new();
static SET: std::sync::OnceLock<std::sync::Mutex<std::collections::HashSet<(String, String)>>> =
std::sync::OnceLock::new();
SET.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()))
}
@@ -1266,9 +1265,13 @@ impl ProdContainerOrchestrator {
.get(&app_id)
.map(|lm| lm.manifest_dir.clone())
.unwrap_or_else(|| root.join(&app_id));
state
.manifests
.insert(app_id.clone(), LoadedManifest { manifest: m, manifest_dir });
state.manifests.insert(
app_id.clone(),
LoadedManifest {
manifest: m,
manifest_dir,
},
);
overlaid += 1;
}
}
@@ -3409,7 +3412,11 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
{
tracing::debug!(container = %name, error = %err, "quadlet stop skipped/failed");
}
match self.runtime.stop_container_with_grace(&name, grace_secs).await {
match self
.runtime
.stop_container_with_grace(&name, grace_secs)
.await
{
Ok(()) => Ok(()),
Err(err) => {
let stuck_stopping = self
@@ -3919,15 +3926,17 @@ app:
// ("release", no digit) ship — the app then vanished from the
// orchestrator and a stack install half-fell-back to the legacy path.
// Fail loudly here instead.
let m = AppManifest::from_file(&mf).unwrap_or_else(|e| {
panic!("shipped manifest {} must be valid: {e}", mf.display())
});
let m = AppManifest::from_file(&mf)
.unwrap_or_else(|e| panic!("shipped manifest {} must be valid: {e}", mf.display()));
let id = m.app.id.clone();
let is_build = m.app.container.build.is_some();
let value = serde_json::to_value(&m).expect("manifest serializes to JSON");
let overlay = catalog_manifest_to_overlay(&id, value);
if is_build {
assert!(overlay.is_none(), "{id}: build-source app must defer to disk");
assert!(
overlay.is_none(),
"{id}: build-source app must defer to disk"
);
} else {
assert!(
overlay.is_some(),
@@ -4010,7 +4019,8 @@ app:
runtime,
PathBuf::from("/nonexistent-for-tests"),
);
orch.set_data_dir(PathBuf::from("/nonexistent-for-tests"));
let data_dir = tempfile::tempdir().unwrap().keep();
orch.set_data_dir(data_dir);
// Redirect the bitcoin-ui pre-start hook to a test-scoped
// tmpdir, seeded with a fake password file. Shared across
// every test in this module (OnceLock), so the hook can run
@@ -4294,22 +4304,25 @@ app:
let rt = Arc::new(MockRuntime::default());
rt.mark_image_present("archy-bitcoin-ui:local");
let orch = orch_with(rt.clone()).await;
orch.insert_manifest_for_test(
build_manifest(
"bitcoin-ui",
"/opt/archy/docker/bitcoin-ui",
"archy-bitcoin-ui:local",
),
PathBuf::from("/opt/archy/apps/bitcoin-ui"),
)
.await;
let build_context = tempfile::tempdir().unwrap();
std::fs::write(build_context.path().join("Dockerfile"), "FROM scratch\n").unwrap();
let build_context = build_context.path().to_string_lossy().into_owned();
let manifest = build_manifest("bitcoin-ui", &build_context, "archy-bitcoin-ui:local");
let fingerprint = fingerprint_build_context(Path::new(&build_context))
.expect("test build context must be fingerprintable");
let stamp_path = build_fingerprint_stamp_path(&orch.data_dir, "archy-bitcoin-ui:local");
std::fs::create_dir_all(stamp_path.parent().unwrap()).unwrap();
std::fs::write(&stamp_path, fingerprint).unwrap();
orch.insert_manifest_for_test(manifest, PathBuf::from("/opt/archy/apps/bitcoin-ui"))
.await;
orch.install("bitcoin-ui").await.unwrap();
let calls = rt.calls();
assert!(calls
.iter()
.any(|c| c == "image_exists:archy-bitcoin-ui:local"));
// Build must NOT be invoked because the image is already there.
// Build must NOT be invoked because the image is already there and its
// recorded build-context fingerprint still matches.
assert!(!calls.iter().any(|c| c.starts_with("build_image:")));
}
@@ -4370,7 +4383,7 @@ app:
let rt = Arc::new(MockRuntime::default());
let orch = orch_with(rt.clone()).await;
let data_dir = tempfile::tempdir().unwrap();
let data_dir = tempfile::tempdir_in("/var/lib/archipelago").unwrap();
let id_u = std::process::Command::new("id").arg("-u").output().unwrap();
let id_g = std::process::Command::new("id").arg("-g").output().unwrap();
let uid = String::from_utf8_lossy(&id_u.stdout).trim().to_string();
@@ -4400,7 +4413,7 @@ app:
let rt = Arc::new(MockRuntime::default());
let orch = orch_with(rt.clone()).await;
let data_dir = tempfile::tempdir().unwrap();
let data_dir = tempfile::tempdir_in("/var/lib/archipelago").unwrap();
orch.insert_manifest_for_test(
pull_manifest_with_generated_file(
"exampleapp",
@@ -4427,7 +4440,7 @@ app:
let rt = Arc::new(MockRuntime::default());
let orch = orch_with(rt.clone()).await;
let data_dir = tempfile::tempdir().unwrap();
let data_dir = tempfile::tempdir_in("/var/lib/archipelago").unwrap();
let config_path = data_dir.path().join("config.yaml");
std::fs::write(&config_path, "key: operator\n").unwrap();
@@ -4452,7 +4465,7 @@ app:
let rt = Arc::new(MockRuntime::default());
let orch = orch_with(rt.clone()).await;
let data_dir = tempfile::tempdir().unwrap();
let data_dir = tempfile::tempdir_in("/var/lib/archipelago").unwrap();
let config_path = data_dir.path().join("config.yaml");
std::fs::write(&config_path, "key: old\n").unwrap();
+4 -1
View File
@@ -1300,7 +1300,10 @@ app:
let u = QuadletUnit::from_manifest(&m, "x");
// tmpfs entry is dropped from bind_mounts; bind entry survives.
assert_eq!(u.bind_mounts.len(), 1);
assert_eq!(u.bind_mounts[0].host, PathBuf::from("/var/lib/archipelago/x"));
assert_eq!(
u.bind_mounts[0].host,
PathBuf::from("/var/lib/archipelago/x")
);
}
#[test]
+8 -2
View File
@@ -169,7 +169,10 @@ mod tests {
let hash = std::fs::read_to_string(dir.path().join("admin")).unwrap();
let pw = std::fs::read_to_string(dir.path().join("admin.pw")).unwrap();
assert!(hash.starts_with("$2"), "bcrypt hash shape");
assert!(bcrypt::verify(pw.trim(), hash.trim()).unwrap(), "pw matches hash");
assert!(
bcrypt::verify(pw.trim(), hash.trim()).unwrap(),
"pw matches hash"
);
for f in ["tok", "admin", "admin.pw"] {
let mode = std::fs::metadata(dir.path().join(f))
@@ -189,7 +192,10 @@ mod tests {
let first = std::fs::read_to_string(dir.path().join("tok")).unwrap();
ensure_generated_secrets(dir.path(), &m).unwrap();
let second = std::fs::read_to_string(dir.path().join("tok")).unwrap();
assert_eq!(first, second, "a present readable secret is never rewritten");
assert_eq!(
first, second,
"a present readable secret is never rewritten"
);
}
#[test]
@@ -172,11 +172,8 @@ mod tests {
fn with_tmp_data_dir<F: FnOnce()>(f: F) {
let mut counter = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
*counter += 1;
let dir = std::env::temp_dir().join(format!(
"archy-vc-test-{}-{}",
std::process::id(),
*counter
));
let dir =
std::env::temp_dir().join(format!("archy-vc-test-{}-{}", std::process::id(), *counter));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::env::set_var("ARCHIPELAGO_DATA_DIR", &dir);
@@ -244,10 +241,7 @@ mod tests {
assert!(!is_downgrade("28.4", "28.4"));
// Knots date-suffixed strings compare on major.minor only.
assert!(is_downgrade("29.3.knots20260508", "28.1.knots20251010"));
assert!(!is_downgrade(
"29.3.knots20260101",
"29.3.knots20260508"
));
assert!(!is_downgrade("29.3.knots20260101", "29.3.knots20260508"));
}
#[test]
+3 -1
View File
@@ -153,7 +153,9 @@ pub async fn read_owned(
onion: &str,
content_id: &str,
) -> Option<(String, Vec<u8>)> {
let bytes = fs::read(bytes_path(data_dir, onion, content_id)).await.ok()?;
let bytes = fs::read(bytes_path(data_dir, onion, content_id))
.await
.ok()?;
let mime = load_index(data_dir)
.await
.items
+3 -1
View File
@@ -296,7 +296,9 @@ pub(crate) async fn notify_join(
status = %resp.status(),
"peer-joined notification rejected; will retry"
),
Err(e) => tracing::warn!(attempt, error = %e, "peer-joined notification failed; will retry"),
Err(e) => {
tracing::warn!(attempt, error = %e, "peer-joined notification failed; will retry")
}
}
tokio::time::sleep(std::time::Duration::from_secs(10 * attempt as u64)).await;
}
+8
View File
@@ -1358,6 +1358,14 @@ mod tests {
host_port_ready: None,
healthy: true,
},
ContainerHealth {
name: "indeedhub-minio".into(),
app_id: "indeedhub-minio".into(),
state: "running".into(),
podman_health: None,
host_port_ready: None,
healthy: true,
},
ContainerHealth {
name: "indeedhub-api".into(),
app_id: "indeedhub-api".into(),
+4 -1
View File
@@ -181,7 +181,10 @@ async fn is_sender_allowed(
match peers.get(&sender_contact_id) {
// Match identity on the bound archipelago key (stable, advert/
// federation-verified), not the firmware routing key.
Some(p) => (p.identity_pubkey_hex().map(|s| s.to_string()), p.did.clone()),
Some(p) => (
p.identity_pubkey_hex().map(|s| s.to_string()),
p.did.clone(),
),
None => (None, None),
}
};
+67 -8
View File
@@ -314,17 +314,66 @@ pub(super) async fn try_chunk_reassemble(
/// Look up a peer by pubkey hex prefix. Returns (contact_id, display_name).
pub(super) async fn resolve_peer(state: &Arc<MeshState>, sender_prefix: &str) -> (u32, String) {
let peers = state.peers.read().await;
peers
.values()
.find(|p| {
{
let peers = state.peers.read().await;
if let Some(peer) = peers.values().find(|p| {
p.pubkey_hex
.as_ref()
.map(|k| k.starts_with(sender_prefix))
.unwrap_or(false)
})
.map(|p| (p.contact_id, p.advert_name.clone()))
.unwrap_or((0, sender_prefix.to_string()))
}) {
return (peer.contact_id, peer.advert_name.clone());
}
}
if let Some((node_num, pubkey_hex, name)) = meshtastic_peer_from_prefix(sender_prefix) {
let peer = MeshPeer {
contact_id: node_num,
advert_name: name.clone(),
did: None,
pubkey_hex: Some(pubkey_hex),
arch_pubkey_hex: None,
x25519_pubkey: None,
rssi: None,
snr: None,
last_heard: chrono::Utc::now().to_rfc3339(),
hops: 0xff,
last_advert: 0,
reachable: true,
};
let is_new = {
let mut peers = state.peers.write().await;
peers.insert(node_num, peer.clone()).is_none()
};
state.update_peer_count().await;
let _ = state.event_tx.send(if is_new {
MeshEvent::PeerDiscovered(peer)
} else {
MeshEvent::PeerUpdated(peer)
});
return (node_num, name);
}
(0, sender_prefix.to_string())
}
fn meshtastic_peer_from_prefix(sender_prefix: &str) -> Option<(u32, String, String)> {
if sender_prefix.len() < 12 {
return None;
}
let bytes = hex::decode(&sender_prefix[..12]).ok()?;
if bytes.len() != 6 || bytes[4] != b'm' || bytes[5] != b'e' {
return None;
}
let node_num = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
if node_num == 0 || node_num == u32::MAX {
return None;
}
let mut full_key = [0u8; 32];
full_key[..4].copy_from_slice(&node_num.to_le_bytes());
full_key[4..15].copy_from_slice(b"meshtastic:");
let name = format!("Meshtastic !{:08x}", node_num);
Some((node_num, hex::encode(full_key), name))
}
/// Store a plain-text (non-typed) message and emit an event.
@@ -333,6 +382,16 @@ pub(super) async fn store_plain_message(
contact_id: u32,
peer_name: &str,
text: &str,
) {
store_plain_message_with_encryption(state, contact_id, peer_name, text, false).await;
}
pub(super) async fn store_plain_message_with_encryption(
state: &Arc<MeshState>,
contact_id: u32,
peer_name: &str,
text: &str,
encrypted: bool,
) {
let msg_id = state.next_id().await;
let msg = MeshMessage {
@@ -343,7 +402,7 @@ pub(super) async fn store_plain_message(
plaintext: text.to_string(),
timestamp: chrono::Utc::now().to_rfc3339(),
delivered: true,
encrypted: false,
encrypted,
message_type: "text".to_string(),
typed_payload: None,
sender_pubkey: None,
+8 -3
View File
@@ -4,7 +4,8 @@ use super::super::message_types::TypedEnvelope;
use super::super::protocol;
use super::decode::{
handle_identity_received, is_mc_chunk_frame, resolve_peer, store_plain_message,
try_base64_typed, try_chunk_reassemble, try_decrypt_base64, try_decrypt_ratchet_base64,
store_plain_message_with_encryption, try_base64_typed, try_chunk_reassemble,
try_decrypt_base64, try_decrypt_ratchet_base64,
};
use super::dispatch::handle_typed_message;
use super::MeshState;
@@ -62,11 +63,12 @@ pub(super) async fn handle_frame(
return true; // Signal caller to sync immediately
}
protocol::RESP_CONTACT_MSG_V3 => {
protocol::RESP_CONTACT_MSG_V3 | protocol::RESP_CONTACT_MSG_V3_E2E => {
// Direct message received (v3 format) — check for typed envelope first
match protocol::parse_contact_msg_v3_raw(&frame.data) {
Ok((sender_prefix, payload, _snr)) => {
if !payload.is_empty() {
let encrypted = frame.code == protocol::RESP_CONTACT_MSG_V3_E2E;
let (contact_id, name) = resolve_peer(state, &sender_prefix).await;
if TypedEnvelope::is_typed(&payload) {
handle_typed_message(&payload, contact_id, &name, state).await;
@@ -86,7 +88,10 @@ pub(super) async fn handle_frame(
handle_typed_message(&decoded, contact_id, &name, state).await;
} else if !payload.starts_with(b"MC") {
let text = String::from_utf8_lossy(&payload).to_string();
store_plain_message(state, contact_id, &name, &text).await;
store_plain_message_with_encryption(
state, contact_id, &name, &text, encrypted,
)
.await;
info!(from = %sender_prefix, "Received mesh DM (v3)");
}
}
+19 -1
View File
@@ -407,8 +407,13 @@ async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc<MeshState>)
// user-controlled feature; until then every firmware contact is
// surfaced. `radio_contact_blocklist` is retained but unused.
let mut peers = state.peers.write().await;
let is_meshtastic = matches!(device.device_type(), DeviceType::Meshtastic);
for (idx, contact) in contacts.iter().enumerate() {
let contact_id = idx as u32;
let contact_id = if is_meshtastic {
meshtastic_contact_id(&contact.public_key_hex).unwrap_or(idx as u32)
} else {
idx as u32
};
let existing = peers.get(&contact_id);
let peer = super::super::types::MeshPeer {
contact_id,
@@ -482,6 +487,19 @@ async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc<MeshState>)
}
}
fn meshtastic_contact_id(public_key_hex: &str) -> Option<u32> {
let bytes = hex::decode(public_key_hex).ok()?;
if bytes.len() < 15 || &bytes[4..15] != b"meshtastic:" {
return None;
}
let node_num = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
if node_num == 0 || node_num == u32::MAX {
None
} else {
Some(node_num)
}
}
/// Drain any queued messages from the device.
/// Returns `true` if a write/communication error occurred (for failure tracking).
async fn sync_queued_messages(
+342 -69
View File
@@ -22,6 +22,7 @@ const START2: u8 = 0xc3;
const TO_RADIO_MAX: usize = 512;
const BROADCAST_NUM: u32 = 0xffff_ffff;
const TEXT_MESSAGE_APP: u32 = 1;
const POSITION_APP: u32 = 3;
/// Meshtastic PortNum for NodeInfo (identity) packets — used to actively
/// advertise ourselves over the air so neighbours discover us, the parity
/// equivalent of meshcore's self-advert.
@@ -33,6 +34,8 @@ const ADMIN_SET_OWNER_FIELD: u64 = 32;
/// Meshtastic firmware caps long_name at ~40 bytes and short_name at 4 bytes.
const MESHTASTIC_LONG_NAME_MAX: usize = 39;
const MESHTASTIC_SHORT_NAME_MAX: usize = 4;
const STALE_RX_SECS: u32 = 24 * 60 * 60;
const PLAUSIBLE_RX_EPOCH_SECS: u32 = 1_700_000_000; // 2023-11-14
const TO_RADIO_PACKET: u64 = 1;
const TO_RADIO_WANT_CONFIG_ID: u64 = 3;
@@ -45,6 +48,11 @@ const FROM_RADIO_NODE_INFO: u64 = 4;
const FROM_RADIO_CONFIG: u64 = 5;
const FROM_RADIO_CONFIG_COMPLETE_ID: u64 = 7;
const FROM_RADIO_REBOOTED: u64 = 8;
/// Upper bound for a single Meshtastic serial API protobuf frame. The serial
/// stream can contain firmware log text, so this is also used to reject false
/// 0x94c3 markers found inside logs instead of waiting forever for a bogus
/// length.
const FROM_RADIO_MAX: usize = 4096;
/// AdminMessage.set_config oneof field number (carries a `Config`). NB: 33 is
/// `set_channel` — `set_config` is 34 (verified against meshtastic/protobufs).
@@ -53,6 +61,15 @@ const ADMIN_SET_CONFIG_FIELD: u64 = 34;
const ADMIN_SET_CHANNEL_FIELD: u64 = 33;
/// FromRadio.channel (field 10): a `Channel` streamed during want_config.
const FROM_RADIO_CHANNEL: u64 = 10;
const FROM_RADIO_QUEUE_STATUS: u64 = 11;
const FROM_RADIO_XMODEM_PACKET: u64 = 12;
const FROM_RADIO_METADATA: u64 = 13;
const FROM_RADIO_MQTT_CLIENT_PROXY_MESSAGE: u64 = 14;
const FROM_RADIO_FILE_INFO: u64 = 15;
const FROM_RADIO_CLIENT_NOTIFICATION: u64 = 16;
const FROM_RADIO_DEVICE_UI_CONFIG: u64 = 17;
const FROM_RADIO_LOCKDOWN_STATUS: u64 = 18;
const FROM_RADIO_REGION_PRESETS: u64 = 19;
/// Channel.role value for the PRIMARY channel (broadcasts ride here).
const CHANNEL_ROLE_PRIMARY: u64 = 1;
/// Config.lora oneof field number (carries a `LoRaConfig`).
@@ -386,7 +403,23 @@ impl MeshtasticDevice {
}
pub async fn send_self_advert(&mut self) -> Result<()> {
self.send_to_radio(&encode_heartbeat()).await
self.send_to_radio(&encode_heartbeat()).await?;
self.send_time_broadcast().await
}
/// Broadcast a minimal Position payload carrying current epoch time. The
/// Meshtastic protobuf explicitly documents `Position.time` as the path for
/// phone/API clients to set time on mesh devices without GPS/RTC. This keeps
/// stock Meshtastic clients from rendering incoming Archipelago-originated
/// packets as Jan 1 1970 when their radio clock is unset.
pub async fn send_time_broadcast(&mut self) -> Result<()> {
let now = now_unix_secs();
let mut position = Vec::new();
encode_fixed32_field(4, now, &mut position);
encode_fixed32_field(7, now, &mut position);
let packet = encode_mesh_packet(BROADCAST_NUM, POSITION_APP, &position);
self.send_to_radio(&encode_to_radio_variant(TO_RADIO_PACKET, &packet))
.await
}
/// Build our own `User` protobuf (id/long_name/short_name) for a NodeInfo
@@ -595,8 +628,10 @@ impl MeshtasticDevice {
// Bound memory if it's a pure-debug flood with no frames:
// keep only from the last possible frame-start marker.
if self.read_buf.len() > 64 * 1024 {
if let Some(pos) =
self.read_buf.windows(2).rposition(|w| w == [START1, START2])
if let Some(pos) = self
.read_buf
.windows(2)
.rposition(|w| w == [START1, START2])
{
self.read_buf.drain(..pos);
} else {
@@ -653,7 +688,17 @@ impl MeshtasticDevice {
}
None
}
FROM_RADIO_CONFIG_COMPLETE_ID | FROM_RADIO_REBOOTED => None,
FROM_RADIO_CONFIG_COMPLETE_ID
| FROM_RADIO_REBOOTED
| FROM_RADIO_QUEUE_STATUS
| FROM_RADIO_XMODEM_PACKET
| FROM_RADIO_METADATA
| FROM_RADIO_MQTT_CLIENT_PROXY_MESSAGE
| FROM_RADIO_FILE_INFO
| FROM_RADIO_CLIENT_NOTIFICATION
| FROM_RADIO_DEVICE_UI_CONFIG
| FROM_RADIO_LOCKDOWN_STATUS
| FROM_RADIO_REGION_PRESETS => None,
other => {
debug!(
field = other,
@@ -700,73 +745,135 @@ impl MeshtasticDevice {
}
fn packet_to_inbound_frame(&mut self, data: &[u8]) -> Option<InboundFrame> {
let packet = parse_mesh_packet(data)?;
if packet.portnum != TEXT_MESSAGE_APP || packet.payload.is_empty() {
return None;
}
let from = packet.from.unwrap_or(0);
if Some(from) == self.node_num {
return None;
}
info!(
from = format!("!{:08x}", from),
len = packet.payload.len(),
pki = packet.pki_encrypted,
"Meshtastic received text packet over the air"
);
// Record E2E status: a `pki_encrypted` packet (or one carrying the
// sender's `public_key`) proves this DM arrived end-to-end encrypted via
// the PKI, not the shared channel PSK. We learn the sender's key here too
// — but keep it OUT of the routing `public_key_hex` (synthetic) so the
// device interface stays identical to meshcore's and the two remain
// hot-swappable behind the mesh listener.
if let Some(pk) = packet.public_key.as_ref() {
self.peer_pubkeys.entry(from).or_insert_with(|| pk.clone());
}
if packet.pki_encrypted {
debug!(node = from, "Meshtastic DM received end-to-end encrypted (PKI)");
}
let from_key = synthetic_pubkey(from);
self.contacts.entry(from).or_insert_with(|| ParsedContact {
public_key_hex: hex::encode(synthetic_pubkey(from)),
advert_name: format!("Meshtastic !{:08x}", from),
last_advert: 0,
contact_type: 1,
path_len: 0xff,
flags: 0,
});
let mut payload = Vec::with_capacity(15 + packet.payload.len());
payload.push(0); // SNR unknown
payload.extend_from_slice(&[0, 0]); // reserved
payload.extend_from_slice(&from_key[..6]);
payload.push(0xff); // unknown/flood path
payload.push(0); // text type
payload.extend_from_slice(&0u32.to_le_bytes());
payload.extend_from_slice(&packet.payload);
Some(InboundFrame {
code: super::protocol::RESP_CONTACT_MSG_V3,
data: payload,
bytes_consumed: 0,
})
packet_to_inbound_frame(
data,
self.node_num,
&mut self.contacts,
&mut self.peer_pubkeys,
)
}
}
fn packet_to_inbound_frame(
data: &[u8],
local_node_num: Option<u32>,
contacts: &mut HashMap<u32, ParsedContact>,
peer_pubkeys: &mut HashMap<u32, Vec<u8>>,
) -> Option<InboundFrame> {
let packet = parse_mesh_packet(data)?;
if packet.portnum != TEXT_MESSAGE_APP || packet.payload.is_empty() {
return None;
}
if packet_is_stale(packet.rx_time) {
debug!(
from = ?packet.from.map(|n| format!("!{:08x}", n)),
rx_time = ?packet.rx_time,
"Dropping stale Meshtastic text packet from radio backlog"
);
return None;
}
let from = packet.from.unwrap_or(0);
if Some(from) == local_node_num {
return None;
}
info!(
from = format!("!{:08x}", from),
len = packet.payload.len(),
pki = packet.pki_encrypted,
"Meshtastic received text packet over the air"
);
// Record E2E status without overwriting the synthetic routing key used by
// the shared mesh listener.
if let Some(pk) = packet.public_key.as_ref() {
peer_pubkeys.entry(from).or_insert_with(|| pk.clone());
}
if packet.pki_encrypted {
debug!(
node = from,
"Meshtastic DM received end-to-end encrypted (PKI)"
);
}
let from_key = synthetic_pubkey(from);
contacts.entry(from).or_insert_with(|| ParsedContact {
public_key_hex: hex::encode(synthetic_pubkey(from)),
advert_name: format!("Meshtastic !{:08x}", from),
last_advert: 0,
contact_type: 1,
path_len: 0xff,
flags: 0,
});
let mut payload = Vec::with_capacity(15 + packet.payload.len());
payload.push(0); // SNR unknown
payload.extend_from_slice(&[0, 0]); // reserved
payload.extend_from_slice(&from_key[..6]);
payload.push(0xff); // unknown/flood path
payload.push(0); // text type
payload.extend_from_slice(&packet.rx_time.unwrap_or_else(now_unix_secs).to_le_bytes());
payload.extend_from_slice(&packet.payload);
Some(InboundFrame {
code: if packet.pki_encrypted {
super::protocol::RESP_CONTACT_MSG_V3_E2E
} else {
super::protocol::RESP_CONTACT_MSG_V3
},
data: payload,
bytes_consumed: 0,
})
}
fn packet_is_stale(rx_time: Option<u32>) -> bool {
let Some(rx_time) = rx_time else {
return false;
};
// Radios without GPS/RTC can report tiny nonzero epoch values until their
// clock is set. Treat those as unknown, not stale, or live LoRa packets from
// stock Meshtastic peers disappear before reaching mesh.messages.
if rx_time < PLAUSIBLE_RX_EPOCH_SECS {
return false;
}
let now = now_unix_secs();
if now < PLAUSIBLE_RX_EPOCH_SECS || rx_time > now.saturating_add(60) {
return false;
}
rx_time.saturating_add(STALE_RX_SECS) < now
}
fn decode_serial_frame(buf: &mut Vec<u8>) -> Option<Vec<u8>> {
let start = buf.windows(2).position(|w| w == [START1, START2])?;
if start > 0 {
buf.drain(..start);
loop {
let start = buf.windows(2).position(|w| w == [START1, START2])?;
if start > 0 {
buf.drain(..start);
}
if buf.len() < 4 {
return None;
}
let len = u16::from_be_bytes([buf[2], buf[3]]) as usize;
if len == 0 || len > FROM_RADIO_MAX {
debug!(
len,
head = %hex::encode(&buf[..buf.len().min(16)]),
"Discarding invalid Meshtastic serial frame marker"
);
buf.drain(..1);
continue;
}
if buf.len() < 4 + len {
return None;
}
let payload = buf[4..4 + len].to_vec();
if decode_top_level_variant(&payload).is_none() {
debug!(
len,
head = %hex::encode(&buf[..buf.len().min(16)]),
"Discarding invalid Meshtastic serial frame payload"
);
buf.drain(..1);
continue;
}
buf.drain(..4 + len);
return Some(payload);
}
if buf.len() < 4 {
return None;
}
let len = u16::from_be_bytes([buf[2], buf[3]]) as usize;
if buf.len() < 4 + len {
return None;
}
let payload = buf[4..4 + len].to_vec();
buf.drain(..4 + len);
Some(payload)
}
fn encode_want_config() -> Vec<u8> {
@@ -844,9 +951,7 @@ fn parse_primary_channel(data: &[u8]) -> Option<(String, Vec<u8>)> {
j = sn;
match (sf, sv) {
(2, FieldValue::Bytes(p)) => psk = p.to_vec(),
(3, FieldValue::Bytes(n)) => {
name = String::from_utf8_lossy(n).to_string()
}
(3, FieldValue::Bytes(n)) => name = String::from_utf8_lossy(n).to_string(),
_ => {}
}
}
@@ -918,6 +1023,8 @@ fn encode_mesh_packet(to: u32, portnum: u32, payload: &[u8]) -> Vec<u8> {
let mut packet = Vec::new();
encode_fixed32_field(2, to, &mut packet);
encode_len_field(4, &decoded, &mut packet);
encode_fixed32_field(6, next_packet_id(), &mut packet);
encode_fixed32_field(7, now_unix_secs(), &mut packet);
packet
}
@@ -949,6 +1056,15 @@ fn decode_top_level_variant(buf: &[u8]) -> Option<(u64, &[u8])> {
| FROM_RADIO_NODE_INFO
| FROM_RADIO_CONFIG
| FROM_RADIO_CHANNEL
| FROM_RADIO_QUEUE_STATUS
| FROM_RADIO_XMODEM_PACKET
| FROM_RADIO_METADATA
| FROM_RADIO_MQTT_CLIENT_PROXY_MESSAGE
| FROM_RADIO_FILE_INFO
| FROM_RADIO_CLIENT_NOTIFICATION
| FROM_RADIO_DEVICE_UI_CONFIG
| FROM_RADIO_LOCKDOWN_STATUS
| FROM_RADIO_REGION_PRESETS
) {
return Some((field, &buf[idx..end]));
}
@@ -1056,6 +1172,9 @@ struct ParsedPacket {
from: Option<u32>,
portnum: u32,
payload: Vec<u8>,
#[allow(dead_code)]
id: Option<u32>,
rx_time: Option<u32>,
/// MeshPacket.pki_encrypted (field 17): the firmware decrypted this packet
/// with the PKI (Curve25519) key, i.e. it arrived end-to-end encrypted
/// rather than via the shared channel PSK.
@@ -1068,6 +1187,8 @@ fn parse_mesh_packet(data: &[u8]) -> Option<ParsedPacket> {
let mut idx = 0;
let mut from = None;
let mut decoded = None;
let mut id = None;
let mut rx_time = None;
let mut pki_encrypted = false;
let mut public_key = None;
while idx < data.len() {
@@ -1076,6 +1197,8 @@ fn parse_mesh_packet(data: &[u8]) -> Option<ParsedPacket> {
match (field, value) {
(1, FieldValue::Fixed32(v)) => from = Some(v),
(4, FieldValue::Bytes(b)) => decoded = Some(b),
(6, FieldValue::Fixed32(v)) => id = Some(v),
(7, FieldValue::Fixed32(v)) if v != 0 => rx_time = Some(v),
(16, FieldValue::Bytes(b)) if !b.is_empty() => public_key = Some(b.to_vec()),
(17, FieldValue::Varint(v)) => pki_encrypted = v != 0,
_ => {}
@@ -1098,11 +1221,30 @@ fn parse_mesh_packet(data: &[u8]) -> Option<ParsedPacket> {
from,
portnum,
payload,
id,
rx_time,
pki_encrypted,
public_key,
})
}
fn now_unix_secs() -> u32 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as u32)
.unwrap_or(0)
}
fn next_packet_id() -> u32 {
static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(1);
let ctr = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0);
nanos ^ ctr.rotate_left(16)
}
enum FieldValue<'a> {
Varint(u64),
Fixed32(u32),
@@ -1204,3 +1346,134 @@ fn synthetic_pubkey(node_num: u32) -> [u8; 32] {
out[4..15].copy_from_slice(b"meshtastic:");
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mesh::protocol;
fn serial_frame(payload: &[u8]) -> Vec<u8> {
let mut frame = Vec::new();
frame.push(START1);
frame.push(START2);
frame.extend_from_slice(&(payload.len() as u16).to_be_bytes());
frame.extend_from_slice(payload);
frame
}
#[test]
fn decode_serial_frame_skips_false_marker_with_impossible_length() {
let valid_payload = encode_varint_field(FROM_RADIO_CONFIG_COMPLETE_ID, 1);
let mut buf = vec![b'l', b'o', b'g', START1, START2, 0xff, 0xff, b'x'];
buf.extend_from_slice(&serial_frame(&valid_payload));
let decoded = decode_serial_frame(&mut buf).expect("valid frame after false marker");
assert_eq!(decoded, valid_payload);
}
#[test]
fn decode_serial_frame_skips_false_marker_with_invalid_payload() {
let valid_payload = encode_varint_field(FROM_RADIO_CONFIG_COMPLETE_ID, 1);
let mut buf = vec![START1, START2, 0x00, 0x03, b'b', b'a', b'd'];
buf.extend_from_slice(&serial_frame(&valid_payload));
let decoded = decode_serial_frame(&mut buf).expect("valid frame after invalid payload");
assert_eq!(decoded, valid_payload);
}
#[test]
fn decode_serial_frame_accepts_queue_status_variant() {
let mut queue_status = Vec::new();
encode_len_field(
FROM_RADIO_QUEUE_STATUS,
&[0x10, 0x0e, 0x18, 0x10],
&mut queue_status,
);
let mut buf = serial_frame(&queue_status);
let decoded =
decode_serial_frame(&mut buf).expect("queue status is a valid FromRadio frame");
assert_eq!(decoded, queue_status);
}
#[test]
fn encode_mesh_packet_sets_nonzero_id_and_time() {
let before = now_unix_secs();
let packet = encode_mesh_packet(0x1122_3344, TEXT_MESSAGE_APP, b"hello");
let after = now_unix_secs();
let parsed = parse_mesh_packet(&packet).expect("packet should parse");
assert_eq!(parsed.portnum, TEXT_MESSAGE_APP);
assert_eq!(parsed.payload, b"hello");
assert!(parsed.id.unwrap_or(0) != 0);
let rx_time = parsed.rx_time.expect("rx_time should be set");
assert!(rx_time >= before.saturating_sub(1));
assert!(rx_time <= after.saturating_add(1));
}
#[test]
fn packet_to_inbound_frame_accepts_stock_peer_with_unset_clock() {
let from = 0x0000_3ccc;
let mut contacts = HashMap::new();
let mut peer_pubkeys = HashMap::new();
let mut decoded = Vec::new();
encode_varint_field_into(1, TEXT_MESSAGE_APP as u64, &mut decoded);
encode_len_field(2, b"hello from 3ccc", &mut decoded);
let mut packet = Vec::new();
encode_fixed32_field(1, from, &mut packet);
encode_fixed32_field(2, BROADCAST_NUM, &mut packet);
encode_len_field(4, &decoded, &mut packet);
encode_fixed32_field(7, 12_345, &mut packet);
let frame =
packet_to_inbound_frame(&packet, Some(0x1111_1111), &mut contacts, &mut peer_pubkeys)
.expect("live packet with unset radio clock must not be dropped");
assert_eq!(frame.code, protocol::RESP_CONTACT_MSG_V3);
let (sender_prefix, payload, _snr) =
protocol::parse_contact_msg_v3_raw(&frame.data).unwrap();
assert_eq!(sender_prefix, "cc3c00006d65");
assert_eq!(payload, b"hello from 3ccc");
assert!(contacts.contains_key(&from));
}
#[test]
fn packet_to_inbound_frame_accepts_recent_meshtastic_backlog() {
let from = 0x433e_3ccc;
let mut contacts = HashMap::new();
let mut peer_pubkeys = HashMap::new();
let mut decoded = Vec::new();
encode_varint_field_into(1, TEXT_MESSAGE_APP as u64, &mut decoded);
encode_len_field(2, b"recent backlog", &mut decoded);
let mut packet = Vec::new();
encode_fixed32_field(1, from, &mut packet);
encode_fixed32_field(2, BROADCAST_NUM, &mut packet);
encode_len_field(4, &decoded, &mut packet);
encode_fixed32_field(7, now_unix_secs().saturating_sub(60 * 60), &mut packet);
let frame =
packet_to_inbound_frame(&packet, Some(0x1111_1111), &mut contacts, &mut peer_pubkeys)
.expect("recent radio backlog must surface in mesh.messages");
let (sender_prefix, payload, _snr) =
protocol::parse_contact_msg_v3_raw(&frame.data).unwrap();
assert_eq!(sender_prefix, "cc3c3e436d65");
assert_eq!(payload, b"recent backlog");
}
#[test]
fn stale_filter_keeps_packets_from_radios_with_unset_clock() {
assert!(!packet_is_stale(None));
assert!(!packet_is_stale(Some(0)));
assert!(!packet_is_stale(Some(12_345)));
}
#[test]
fn stale_filter_drops_only_plausibly_old_packets() {
let old = now_unix_secs().saturating_sub(STALE_RX_SECS + 60);
if old >= PLAUSIBLE_RX_EPOCH_SECS {
assert!(packet_is_stale(Some(old)));
}
}
}
+20 -13
View File
@@ -1109,15 +1109,13 @@ impl MeshService {
// (FIPS→Tor) instead of handing it to a radio that physically cannot
// deliver it. Reachable radio peers stay on the mesh; oversized
// envelopes (file shares etc.) always take the federation path.
let radio_federated_unreachable = !is_federation_synthetic
&& !exceeds_lora
&& {
let peers = self.state.peers.read().await;
peers
.get(&contact_id)
.map(|p| !p.reachable && p.arch_pubkey_hex.is_some())
.unwrap_or(false)
};
let radio_federated_unreachable = !is_federation_synthetic && !exceeds_lora && {
let peers = self.state.peers.read().await;
peers
.get(&contact_id)
.map(|p| !p.reachable && p.arch_pubkey_hex.is_some())
.unwrap_or(false)
};
if is_federation_synthetic || exceeds_lora || radio_federated_unreachable {
// Resolve the peer's pubkey/did. Prefer the live mesh peer table,
// but fall back to federation storage for federation-synthetic ids
@@ -1508,9 +1506,12 @@ impl MeshService {
// it stays backward compatible. (Federation/Tor sends already sign in
// `send_typed_wire_via_federation`.) `with_seq` is applied after signing
// — seq is not covered by the signature.
let envelope =
TypedEnvelope::new_signed(MeshMessageType::Text, text.as_bytes().to_vec(), &self.signing_key)
.with_seq(seq);
let envelope = TypedEnvelope::new_signed(
MeshMessageType::Text,
text.as_bytes().to_vec(),
&self.signing_key,
)
.with_seq(seq);
let wire = envelope.to_wire()?;
self.send_typed_wire(contact_id, wire, "text", text, None, seq)
.await
@@ -1653,7 +1654,13 @@ impl MeshService {
/// Recently-denied `!ai` askers (newest first) so the UI can offer to allow
/// them. Cleared implicitly as new denials rotate older ones out.
pub async fn assistant_denied_askers(&self) -> Vec<listener::DeniedAsker> {
self.state.assist_denied.read().await.iter().cloned().collect()
self.state
.assist_denied
.read()
.await
.iter()
.cloned()
.collect()
}
/// Update the mesh-AI assistant settings live (no listener restart) and
+5
View File
@@ -64,6 +64,11 @@ pub const RESP_CONTACT_MSG_V3: u8 = 0x10;
pub const RESP_CHANNEL_MSG_V3: u8 = 0x11;
pub const RESP_CHANNEL_INFO: u8 = 0x12;
pub const RESP_STATS: u8 = 0x18;
/// Archipelago-internal synthetic response code used by the Meshtastic adapter
/// for text DMs that the firmware reports as PKI-encrypted. Meshcore firmware
/// never emits this code; it lets the shared listener persist the E2E badge
/// without changing the on-wire Meshcore frame format.
pub const RESP_CONTACT_MSG_V3_E2E: u8 = 0x13;
// --- Push notification codes (device -> host, async, >= 0x80) ---
pub const PUSH_CONTACT_ADVERT: u8 = 0x80;
+5 -6
View File
@@ -462,8 +462,9 @@ impl Server {
let local_onion = snap.server_info.tor_address.clone().unwrap_or_default();
let local_pubkey = snap.server_info.pubkey.clone();
let local_name = snap.server_info.name.clone();
let local_fips_npub =
crate::identity::fips_npub(&identity_dir).await.unwrap_or(None);
let local_fips_npub = crate::identity::fips_npub(&identity_dir)
.await
.unwrap_or(None);
let mut ok = 0usize;
let mut healed = 0usize;
for node in &nodes {
@@ -486,10 +487,8 @@ impl Server {
// Without this, a node that joined everyone stays
// invisible to the whole fleet until a manual
// re-add (the "X250-EXP missing everywhere" case).
let they_list_us = state
.federated_peers
.iter()
.any(|h| h.did == local_did);
let they_list_us =
state.federated_peers.iter().any(|h| h.did == local_did);
if !they_list_us && !local_onion.is_empty() {
crate::federation::notify_join(
&node.onion,
@@ -256,9 +256,7 @@ pub async fn spend_from_any(data_dir: &Path, amount_sats: u64) -> Result<(String
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"
)
anyhow::anyhow!("No joined Fedimint federation has {amount_sats} sats available")
}))
}