style: cargo fmt for v1.7.99-alpha release gate
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
144c4a2872
commit
83bb589ea6
@ -159,10 +159,7 @@ impl ApiHandler {
|
|||||||
/// Seller side (#46): mint a Lightning invoice for a paid catalog item so a
|
/// Seller side (#46): mint a Lightning invoice for a paid catalog item so a
|
||||||
/// buyer can pay from any external wallet. Path: GET /content/{id}/invoice.
|
/// buyer can pay from any external wallet. Path: GET /content/{id}/invoice.
|
||||||
/// Records a pending entitlement keyed by the invoice's payment hash.
|
/// Records a pending entitlement keyed by the invoice's payment hash.
|
||||||
pub(super) async fn handle_content_invoice(
|
pub(super) async fn handle_content_invoice(&self, path: &str) -> Result<Response<hyper::Body>> {
|
||||||
&self,
|
|
||||||
path: &str,
|
|
||||||
) -> Result<Response<hyper::Body>> {
|
|
||||||
let content_id = path
|
let content_id = path
|
||||||
.strip_prefix("/content/")
|
.strip_prefix("/content/")
|
||||||
.and_then(|s| s.strip_suffix("/invoice"))
|
.and_then(|s| s.strip_suffix("/invoice"))
|
||||||
@ -295,10 +292,7 @@ impl ApiHandler {
|
|||||||
/// Seller side (#46): issue a fresh on-chain address for a paid catalog item
|
/// Seller side (#46): issue a fresh on-chain address for a paid catalog item
|
||||||
/// so a buyer can pay on-chain. Path: GET /content/{id}/onchain. Records a
|
/// so a buyer can pay on-chain. Path: GET /content/{id}/onchain. Records a
|
||||||
/// pending entitlement keyed by the address; price doubles as expected amount.
|
/// pending entitlement keyed by the address; price doubles as expected amount.
|
||||||
pub(super) async fn handle_content_onchain(
|
pub(super) async fn handle_content_onchain(&self, path: &str) -> Result<Response<hyper::Body>> {
|
||||||
&self,
|
|
||||||
path: &str,
|
|
||||||
) -> Result<Response<hyper::Body>> {
|
|
||||||
let content_id = path
|
let content_id = path
|
||||||
.strip_prefix("/content/")
|
.strip_prefix("/content/")
|
||||||
.and_then(|s| s.strip_suffix("/onchain"))
|
.and_then(|s| s.strip_suffix("/onchain"))
|
||||||
|
|||||||
@ -382,23 +382,26 @@ impl RpcHandler {
|
|||||||
// Surface a real reason instead of the generic sanitized error (#30):
|
// Surface a real reason instead of the generic sanitized error (#30):
|
||||||
// the dial already tries FIPS/mesh then falls back to Tor, so a failure
|
// the dial already tries FIPS/mesh then falls back to Tor, so a failure
|
||||||
// here means the peer is genuinely unreachable on both transports.
|
// here means the peer is genuinely unreachable on both transports.
|
||||||
let (response, transport) =
|
let (response, transport) = match crate::fips::dial::PeerRequest::new(
|
||||||
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
fips_npub.as_deref(),
|
||||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
onion,
|
||||||
.header("X-Federation-DID", local_did)
|
&path,
|
||||||
.header("X-Payment-Token", token_str)
|
)
|
||||||
.timeout(std::time::Duration::from_secs(900))
|
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||||
.send_get()
|
.header("X-Federation-DID", local_did)
|
||||||
.await
|
.header("X-Payment-Token", token_str)
|
||||||
{
|
.timeout(std::time::Duration::from_secs(900))
|
||||||
Ok(v) => v,
|
.send_get()
|
||||||
Err(e) => {
|
.await
|
||||||
tracing::warn!("paid peer download dial failed for {}: {:#}", onion, e);
|
{
|
||||||
return Ok(serde_json::json!({
|
Ok(v) => v,
|
||||||
"error": "Could not reach the peer over mesh or Tor — it may be offline. Please try again."
|
Err(e) => {
|
||||||
}));
|
tracing::warn!("paid peer download dial failed for {}: {:#}", onion, e);
|
||||||
}
|
return Ok(serde_json::json!({
|
||||||
};
|
"error": "Could not reach the peer over mesh or Tor — it may be offline. Please try again."
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
// Record which transport actually reached the peer (B14).
|
// Record which transport actually reached the peer (B14).
|
||||||
let _ = crate::federation::record_peer_transport(
|
let _ = crate::federation::record_peer_transport(
|
||||||
&self.config.data_dir,
|
&self.config.data_dir,
|
||||||
@ -576,23 +579,26 @@ impl RpcHandler {
|
|||||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||||
|
|
||||||
let path = format!("/content/{}", content_id);
|
let path = format!("/content/{}", content_id);
|
||||||
let (response, transport) =
|
let (response, transport) = match crate::fips::dial::PeerRequest::new(
|
||||||
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
fips_npub.as_deref(),
|
||||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
onion,
|
||||||
.header("X-Federation-DID", local_did)
|
&path,
|
||||||
.header("X-Invoice-Hash", payment_hash.to_string())
|
)
|
||||||
.timeout(std::time::Duration::from_secs(900))
|
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||||
.send_get()
|
.header("X-Federation-DID", local_did)
|
||||||
.await
|
.header("X-Invoice-Hash", payment_hash.to_string())
|
||||||
{
|
.timeout(std::time::Duration::from_secs(900))
|
||||||
Ok(v) => v,
|
.send_get()
|
||||||
Err(e) => {
|
.await
|
||||||
tracing::warn!("invoice download dial failed for {}: {:#}", onion, e);
|
{
|
||||||
return Ok(serde_json::json!({
|
Ok(v) => v,
|
||||||
"error": "Could not reach the peer over mesh or Tor — it may be offline. Please try again."
|
Err(e) => {
|
||||||
}));
|
tracing::warn!("invoice download dial failed for {}: {:#}", onion, e);
|
||||||
}
|
return Ok(serde_json::json!({
|
||||||
};
|
"error": "Could not reach the peer over mesh or Tor — it may be offline. Please try again."
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
let _ = crate::federation::record_peer_transport(
|
let _ = crate::federation::record_peer_transport(
|
||||||
&self.config.data_dir,
|
&self.config.data_dir,
|
||||||
None,
|
None,
|
||||||
@ -697,7 +703,10 @@ impl RpcHandler {
|
|||||||
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
||||||
}
|
}
|
||||||
// Bitcoin addresses are alphanumeric; keep strictly so for safe path use.
|
// Bitcoin addresses are alphanumeric; keep strictly so for safe path use.
|
||||||
if address.is_empty() || address.len() > 100 || !address.chars().all(|c| c.is_ascii_alphanumeric()) {
|
if address.is_empty()
|
||||||
|
|| address.len() > 100
|
||||||
|
|| !address.chars().all(|c| c.is_ascii_alphanumeric())
|
||||||
|
{
|
||||||
return Err(anyhow::anyhow!("Invalid address"));
|
return Err(anyhow::anyhow!("Invalid address"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -754,23 +763,26 @@ impl RpcHandler {
|
|||||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||||
|
|
||||||
let path = format!("/content/{}", content_id);
|
let path = format!("/content/{}", content_id);
|
||||||
let (response, transport) =
|
let (response, transport) = match crate::fips::dial::PeerRequest::new(
|
||||||
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
fips_npub.as_deref(),
|
||||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
onion,
|
||||||
.header("X-Federation-DID", local_did)
|
&path,
|
||||||
.header("X-Onchain-Address", address.to_string())
|
)
|
||||||
.timeout(std::time::Duration::from_secs(900))
|
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||||
.send_get()
|
.header("X-Federation-DID", local_did)
|
||||||
.await
|
.header("X-Onchain-Address", address.to_string())
|
||||||
{
|
.timeout(std::time::Duration::from_secs(900))
|
||||||
Ok(v) => v,
|
.send_get()
|
||||||
Err(e) => {
|
.await
|
||||||
tracing::warn!("onchain download dial failed for {}: {:#}", onion, e);
|
{
|
||||||
return Ok(serde_json::json!({
|
Ok(v) => v,
|
||||||
"error": "Could not reach the peer over mesh or Tor — it may be offline. Please try again."
|
Err(e) => {
|
||||||
}));
|
tracing::warn!("onchain download dial failed for {}: {:#}", onion, e);
|
||||||
}
|
return Ok(serde_json::json!({
|
||||||
};
|
"error": "Could not reach the peer over mesh or Tor — it may be offline. Please try again."
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
let _ = crate::federation::record_peer_transport(
|
let _ = crate::federation::record_peer_transport(
|
||||||
&self.config.data_dir,
|
&self.config.data_dir,
|
||||||
None,
|
None,
|
||||||
|
|||||||
@ -268,12 +268,7 @@ impl RpcHandler {
|
|||||||
let removed_pubkey = federation::load_nodes(&self.config.data_dir)
|
let removed_pubkey = federation::load_nodes(&self.config.data_dir)
|
||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|nodes| {
|
.and_then(|nodes| nodes.into_iter().find(|n| n.did == did).map(|n| n.pubkey));
|
||||||
nodes
|
|
||||||
.into_iter()
|
|
||||||
.find(|n| n.did == did)
|
|
||||||
.map(|n| n.pubkey)
|
|
||||||
});
|
|
||||||
|
|
||||||
let nodes = federation::remove_node(&self.config.data_dir, did).await?;
|
let nodes = federation::remove_node(&self.config.data_dir, did).await?;
|
||||||
info!(did = %did, "Removed node from federation");
|
info!(did = %did, "Removed node from federation");
|
||||||
|
|||||||
@ -69,20 +69,23 @@ impl RpcHandler {
|
|||||||
let federation_id = client.join(invite_code).await?;
|
let federation_id = client.join(invite_code).await?;
|
||||||
|
|
||||||
// Try to label it from the federation meta (best-effort).
|
// Try to label it from the federation meta (best-effort).
|
||||||
let name = client
|
let name = client.info().await.ok().and_then(|i| {
|
||||||
.info()
|
i.get(&federation_id)
|
||||||
.await
|
.and_then(|e| e.get("meta"))
|
||||||
.ok()
|
.and_then(|m| {
|
||||||
.and_then(|i| {
|
m.get("federation_name")
|
||||||
i.get(&federation_id)
|
.or_else(|| m.get("federation_expiry_timestamp"))
|
||||||
.and_then(|e| e.get("meta"))
|
})
|
||||||
.and_then(|m| m.get("federation_name").or_else(|| m.get("federation_expiry_timestamp")))
|
.and_then(|v| v.as_str())
|
||||||
.and_then(|v| v.as_str())
|
.map(|s| s.to_string())
|
||||||
.map(|s| s.to_string())
|
});
|
||||||
});
|
|
||||||
|
|
||||||
let mut reg = fedimint_client::load_registry(&self.config.data_dir).await?;
|
let mut reg = fedimint_client::load_registry(&self.config.data_dir).await?;
|
||||||
if !reg.federations.iter().any(|f| f.federation_id == federation_id) {
|
if !reg
|
||||||
|
.federations
|
||||||
|
.iter()
|
||||||
|
.any(|f| f.federation_id == federation_id)
|
||||||
|
{
|
||||||
reg.federations.push(JoinedFederation {
|
reg.federations.push(JoinedFederation {
|
||||||
federation_id: federation_id.clone(),
|
federation_id: federation_id.clone(),
|
||||||
name,
|
name,
|
||||||
|
|||||||
@ -205,11 +205,7 @@ impl RpcHandler {
|
|||||||
let payment_hash_hex = body
|
let payment_hash_hex = body
|
||||||
.get("r_hash")
|
.get("r_hash")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.and_then(|b64| {
|
.and_then(|b64| base64::engine::general_purpose::STANDARD.decode(b64).ok())
|
||||||
base64::engine::general_purpose::STANDARD
|
|
||||||
.decode(b64)
|
|
||||||
.ok()
|
|
||||||
})
|
|
||||||
.map(hex::encode)
|
.map(hex::encode)
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
@ -317,9 +313,8 @@ impl RpcHandler {
|
|||||||
.get("output_details")
|
.get("output_details")
|
||||||
.and_then(|v| v.as_array())
|
.and_then(|v| v.as_array())
|
||||||
.map(|arr| {
|
.map(|arr| {
|
||||||
arr.iter().any(|o| {
|
arr.iter()
|
||||||
o.get("address").and_then(|a| a.as_str()) == Some(address)
|
.any(|o| o.get("address").and_then(|a| a.as_str()) == Some(address))
|
||||||
})
|
|
||||||
})
|
})
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
if pays_addr {
|
if pays_addr {
|
||||||
|
|||||||
@ -23,11 +23,10 @@ impl RpcHandler {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let (ollama_detected, models) = detect_ollama().await;
|
let (ollama_detected, models) = detect_ollama().await;
|
||||||
let claude_available = tokio::fs::metadata(
|
let claude_available =
|
||||||
self.config.data_dir.join("secrets/claude-api-key"),
|
tokio::fs::metadata(self.config.data_dir.join("secrets/claude-api-key"))
|
||||||
)
|
.await
|
||||||
.await
|
.is_ok();
|
||||||
.is_ok();
|
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"enabled": cfg.enabled,
|
"enabled": cfg.enabled,
|
||||||
"model": cfg.model,
|
"model": cfg.model,
|
||||||
@ -94,7 +93,10 @@ impl RpcHandler {
|
|||||||
.get("fire_at")
|
.get("fire_at")
|
||||||
.and_then(|v| v.as_i64())
|
.and_then(|v| v.as_i64())
|
||||||
.ok_or_else(|| anyhow::anyhow!("fire_at (unix seconds) is required"))?;
|
.ok_or_else(|| anyhow::anyhow!("fire_at (unix seconds) is required"))?;
|
||||||
let contact_id = p.get("contact_id").and_then(|v| v.as_u64()).map(|v| v as u32);
|
let contact_id = p
|
||||||
|
.get("contact_id")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.map(|v| v as u32);
|
||||||
let channel = p.get("channel").and_then(|v| v.as_u64()).map(|v| v as u8);
|
let channel = p.get("channel").and_then(|v| v.as_u64()).map(|v| v as u8);
|
||||||
if contact_id.is_none() && channel.is_none() {
|
if contact_id.is_none() && channel.is_none() {
|
||||||
anyhow::bail!("either contact_id or channel is required");
|
anyhow::bail!("either contact_id or channel is required");
|
||||||
@ -104,7 +106,10 @@ impl RpcHandler {
|
|||||||
let svc = service
|
let svc = service
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||||
let msg = svc.scheduler.add(contact_id, channel, body, fire_at).await?;
|
let msg = svc
|
||||||
|
.scheduler
|
||||||
|
.add(contact_id, channel, body, fire_at)
|
||||||
|
.await?;
|
||||||
Ok(serde_json::to_value(msg)?)
|
Ok(serde_json::to_value(msg)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -157,7 +162,9 @@ async fn detect_ollama() -> (bool, Vec<String>) {
|
|||||||
.map(|arr| {
|
.map(|arr| {
|
||||||
arr.iter()
|
arr.iter()
|
||||||
.filter_map(|m| {
|
.filter_map(|m| {
|
||||||
m.get("name").and_then(|n| n.as_str()).map(|s| s.to_string())
|
m.get("name")
|
||||||
|
.and_then(|n| n.as_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
})
|
})
|
||||||
|
|||||||
@ -116,7 +116,10 @@ impl RpcHandler {
|
|||||||
{
|
{
|
||||||
config.announce_block_headers = announce;
|
config.announce_block_headers = announce;
|
||||||
}
|
}
|
||||||
if let Some(receive) = params.get("receive_block_headers").and_then(|v| v.as_bool()) {
|
if let Some(receive) = params
|
||||||
|
.get("receive_block_headers")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
{
|
||||||
config.receive_block_headers = receive;
|
config.receive_block_headers = receive;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -28,8 +28,14 @@ impl RpcHandler {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
if let Some(obj) = value.as_object_mut() {
|
if let Some(obj) = value.as_object_mut() {
|
||||||
obj.insert("announce_block_headers".into(), config.announce_block_headers.into());
|
obj.insert(
|
||||||
obj.insert("receive_block_headers".into(), config.receive_block_headers.into());
|
"announce_block_headers".into(),
|
||||||
|
config.announce_block_headers.into(),
|
||||||
|
);
|
||||||
|
obj.insert(
|
||||||
|
"receive_block_headers".into(),
|
||||||
|
config.receive_block_headers.into(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(value)
|
Ok(value)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -469,7 +469,10 @@ async fn wait_for_stack_containers(
|
|||||||
PODMAN_STACK_PROBE_TIMEOUT,
|
PODMAN_STACK_PROBE_TIMEOUT,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
pending.push(format!("{}=restarting({}/{})", container, *attempts, MAX_RESTARTS));
|
pending.push(format!(
|
||||||
|
"{}=restarting({}/{})",
|
||||||
|
container, *attempts, MAX_RESTARTS
|
||||||
|
));
|
||||||
} else {
|
} else {
|
||||||
let logs = stack_container_logs(container, 40).await;
|
let logs = stack_container_logs(container, 40).await;
|
||||||
install_log(&format!(
|
install_log(&format!(
|
||||||
@ -1997,11 +2000,7 @@ async fn ensure_netbird_tls_cert(host_ip: &str) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn write_netbird_config_files(
|
async fn write_netbird_config_files(host_ip: &str, lan_ip: &str, resolver_ip: &str) -> Result<()> {
|
||||||
host_ip: &str,
|
|
||||||
lan_ip: &str,
|
|
||||||
resolver_ip: &str,
|
|
||||||
) -> Result<()> {
|
|
||||||
// netbird's dashboard uses window.crypto.subtle (OIDC PKCE), which browsers
|
// netbird's dashboard uses window.crypto.subtle (OIDC PKCE), which browsers
|
||||||
// only expose in a SECURE context — so the proxy serves HTTPS and every
|
// only expose in a SECURE context — so the proxy serves HTTPS and every
|
||||||
// origin here is https (issue #15: over plain http the dashboard threw
|
// origin here is https (issue #15: over plain http the dashboard threw
|
||||||
@ -2187,7 +2186,10 @@ async fn detect_netbird_public_host_ip() -> Option<String> {
|
|||||||
.await
|
.await
|
||||||
.ok()?;
|
.ok()?;
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
let ips: Vec<&str> = stdout.split_whitespace().filter(|s| s.contains('.')).collect();
|
let ips: Vec<&str> = stdout
|
||||||
|
.split_whitespace()
|
||||||
|
.filter(|s| s.contains('.'))
|
||||||
|
.collect();
|
||||||
|
|
||||||
// Prefer the LAN address as the canonical origin — that's what users browse
|
// Prefer the LAN address as the canonical origin — that's what users browse
|
||||||
// to on the local network. Baking the Tailscale 100.x address here broke
|
// to on the local network. Baking the Tailscale 100.x address here broke
|
||||||
|
|||||||
@ -398,8 +398,7 @@ impl RpcHandler {
|
|||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.map(|s| s.to_string());
|
.map(|s| s.to_string());
|
||||||
let secret_phrase = passphrase.unwrap_or_else(|| password.clone());
|
let secret_phrase = passphrase.unwrap_or_else(|| password.clone());
|
||||||
let reveal =
|
let reveal = crate::seed::load_seed_encrypted(&self.config.data_dir, &secret_phrase).await;
|
||||||
crate::seed::load_seed_encrypted(&self.config.data_dir, &secret_phrase).await;
|
|
||||||
password.zeroize();
|
password.zeroize();
|
||||||
let mnemonic = reveal.map_err(|_| {
|
let mnemonic = reveal.map_err(|_| {
|
||||||
anyhow::anyhow!(
|
anyhow::anyhow!(
|
||||||
|
|||||||
@ -92,7 +92,10 @@ fn cmd_sign(path: &str) -> Result<()> {
|
|||||||
|
|
||||||
let obj = value.as_object_mut().expect("checked above");
|
let obj = value.as_object_mut().expect("checked above");
|
||||||
obj.insert("signature".into(), serde_json::Value::String(signature));
|
obj.insert("signature".into(), serde_json::Value::String(signature));
|
||||||
obj.insert("signed_by".into(), serde_json::Value::String(signed_by.clone()));
|
obj.insert(
|
||||||
|
"signed_by".into(),
|
||||||
|
serde_json::Value::String(signed_by.clone()),
|
||||||
|
);
|
||||||
|
|
||||||
let pretty = serde_json::to_string_pretty(&value).context("serialize signed document")?;
|
let pretty = serde_json::to_string_pretty(&value).context("serialize signed document")?;
|
||||||
let tmp = format!("{path}.tmp");
|
let tmp = format!("{path}.tmp");
|
||||||
@ -107,8 +110,8 @@ fn cmd_sign(path: &str) -> Result<()> {
|
|||||||
/// Derive the release-root signing key from the mnemonic in env/stdin.
|
/// Derive the release-root signing key from the mnemonic in env/stdin.
|
||||||
fn load_release_root_key() -> Result<SigningKey> {
|
fn load_release_root_key() -> Result<SigningKey> {
|
||||||
let phrase = read_mnemonic()?;
|
let phrase = read_mnemonic()?;
|
||||||
let (_mnemonic, seed) =
|
let (_mnemonic, seed) = MasterSeed::from_mnemonic_words(phrase.trim())
|
||||||
MasterSeed::from_mnemonic_words(phrase.trim()).context("invalid release master mnemonic")?;
|
.context("invalid release master mnemonic")?;
|
||||||
seed::derive_release_root_ed25519(&seed).context("derive release-root")
|
seed::derive_release_root_ed25519(&seed).context("derive release-root")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -281,9 +281,15 @@ async fn fetch_one(client: &reqwest::Client, url: &str) -> anyhow::Result<AppCat
|
|||||||
crate::trust::SignatureStatus::Unsigned => {
|
crate::trust::SignatureStatus::Unsigned => {
|
||||||
debug!("app-catalog: unsigned (accepted during migration window)");
|
debug!("app-catalog: unsigned (accepted during migration window)");
|
||||||
}
|
}
|
||||||
crate::trust::SignatureStatus::Verified { signer_did, anchored } => {
|
crate::trust::SignatureStatus::Verified {
|
||||||
|
signer_did,
|
||||||
|
anchored,
|
||||||
|
} => {
|
||||||
if anchored {
|
if anchored {
|
||||||
info!("app-catalog: release-root signature verified ({})", signer_did);
|
info!(
|
||||||
|
"app-catalog: release-root signature verified ({})",
|
||||||
|
signer_did
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
warn!(
|
warn!(
|
||||||
"app-catalog: signature self-consistent but release-root anchor \
|
"app-catalog: signature self-consistent but release-root anchor \
|
||||||
|
|||||||
@ -235,7 +235,9 @@ fn build_fingerprint_stamp_path(data_dir: &Path, tag: &str) -> PathBuf {
|
|||||||
.chars()
|
.chars()
|
||||||
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
|
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
|
||||||
.collect();
|
.collect();
|
||||||
data_dir.join(".image-build").join(format!("{safe}.fingerprint"))
|
data_dir
|
||||||
|
.join(".image-build")
|
||||||
|
.join(format!("{safe}.fingerprint"))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn chown_for_rootless_container(uid_gid: &str, path: &str) -> Result<()> {
|
async fn chown_for_rootless_container(uid_gid: &str, path: &str) -> Result<()> {
|
||||||
@ -362,7 +364,16 @@ async fn ensure_running_container_ownership(name: &str) -> bool {
|
|||||||
|
|
||||||
// Repair inside the container's userns — podman maps to the right host uid.
|
// Repair inside the container's userns — podman maps to the right host uid.
|
||||||
let chown = tokio::process::Command::new("podman")
|
let chown = tokio::process::Command::new("podman")
|
||||||
.args(["exec", "-u", "0", name, "chown", "-R", &format!("{uid}:{gid}"), dest])
|
.args([
|
||||||
|
"exec",
|
||||||
|
"-u",
|
||||||
|
"0",
|
||||||
|
name,
|
||||||
|
"chown",
|
||||||
|
"-R",
|
||||||
|
&format!("{uid}:{gid}"),
|
||||||
|
dest,
|
||||||
|
])
|
||||||
.output()
|
.output()
|
||||||
.await;
|
.await;
|
||||||
match chown {
|
match chown {
|
||||||
@ -378,7 +389,9 @@ async fn ensure_running_container_ownership(name: &str) -> bool {
|
|||||||
"volume ownership repair failed: {}",
|
"volume ownership repair failed: {}",
|
||||||
String::from_utf8_lossy(&o.stderr).trim()
|
String::from_utf8_lossy(&o.stderr).trim()
|
||||||
),
|
),
|
||||||
Err(e) => tracing::warn!(container = %name, dest, "volume ownership repair errored: {e}"),
|
Err(e) => {
|
||||||
|
tracing::warn!(container = %name, dest, "volume ownership repair errored: {e}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
repaired
|
repaired
|
||||||
@ -4634,10 +4647,15 @@ app:
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn build_fingerprint_stamp_path_sanitizes_tag() {
|
fn build_fingerprint_stamp_path_sanitizes_tag() {
|
||||||
let p = build_fingerprint_stamp_path(Path::new("/var/lib/archipelago"), "localhost/bitcoin-ui:local");
|
let p = build_fingerprint_stamp_path(
|
||||||
|
Path::new("/var/lib/archipelago"),
|
||||||
|
"localhost/bitcoin-ui:local",
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
p,
|
p,
|
||||||
PathBuf::from("/var/lib/archipelago/.image-build/localhost_bitcoin_ui_local.fingerprint")
|
PathBuf::from(
|
||||||
|
"/var/lib/archipelago/.image-build/localhost_bitcoin_ui_local.fingerprint"
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -65,11 +65,13 @@ pub(super) async fn run_assist(
|
|||||||
"AssistQuery denied — sender not permitted by assistant policy"
|
"AssistQuery denied — sender not permitted by assistant policy"
|
||||||
);
|
);
|
||||||
// Silent on the wire (no airtime spent on denials); surface to the UI.
|
// Silent on the wire (no airtime spent on denials); surface to the UI.
|
||||||
let _ = state.event_tx.send(super::super::types::MeshEvent::AssistResponseReady {
|
let _ = state
|
||||||
req_id,
|
.event_tx
|
||||||
to_contact_id: asker,
|
.send(super::super::types::MeshEvent::AssistResponseReady {
|
||||||
error: Some("denied".to_string()),
|
req_id,
|
||||||
});
|
to_contact_id: asker,
|
||||||
|
error: Some("denied".to_string()),
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -77,22 +79,31 @@ pub(super) async fn run_assist(
|
|||||||
{
|
{
|
||||||
let mut inflight = state.assist_inflight.write().await;
|
let mut inflight = state.assist_inflight.write().await;
|
||||||
if !inflight.insert(asker) {
|
if !inflight.insert(asker) {
|
||||||
warn!(from = asker, "AssistQuery dropped — asker already has one in flight");
|
warn!(
|
||||||
|
from = asker,
|
||||||
|
"AssistQuery dropped — asker already has one in flight"
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = state.event_tx.send(super::super::types::MeshEvent::AssistQueryReceived {
|
let _ = state
|
||||||
from_contact_id: asker,
|
.event_tx
|
||||||
prompt: prompt.clone(),
|
.send(super::super::types::MeshEvent::AssistQueryReceived {
|
||||||
});
|
from_contact_id: asker,
|
||||||
|
prompt: prompt.clone(),
|
||||||
|
});
|
||||||
|
|
||||||
let (backend, configured_model) = {
|
let (backend, configured_model) = {
|
||||||
let a = state.assistant.read().await;
|
let a = state.assistant.read().await;
|
||||||
(a.backend.clone(), a.model.clone())
|
(a.backend.clone(), a.model.clone())
|
||||||
};
|
};
|
||||||
let is_claude = backend == "claude";
|
let is_claude = backend == "claude";
|
||||||
let default_model = if is_claude { CLAUDE_DEFAULT_MODEL } else { DEFAULT_MODEL };
|
let default_model = if is_claude {
|
||||||
|
CLAUDE_DEFAULT_MODEL
|
||||||
|
} else {
|
||||||
|
DEFAULT_MODEL
|
||||||
|
};
|
||||||
let model = model_override
|
let model = model_override
|
||||||
.or(configured_model)
|
.or(configured_model)
|
||||||
.unwrap_or_else(|| default_model.to_string());
|
.unwrap_or_else(|| default_model.to_string());
|
||||||
@ -108,20 +119,24 @@ pub(super) async fn run_assist(
|
|||||||
match result {
|
match result {
|
||||||
Ok(answer) => {
|
Ok(answer) => {
|
||||||
send_reply(&state, &reply, req_id, &answer).await;
|
send_reply(&state, &reply, req_id, &answer).await;
|
||||||
let _ = state.event_tx.send(super::super::types::MeshEvent::AssistResponseReady {
|
let _ = state
|
||||||
req_id,
|
.event_tx
|
||||||
to_contact_id: asker,
|
.send(super::super::types::MeshEvent::AssistResponseReady {
|
||||||
error: None,
|
req_id,
|
||||||
});
|
to_contact_id: asker,
|
||||||
|
error: None,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!(req_id, "AI query failed: {}", e);
|
warn!(req_id, "AI query failed: {}", e);
|
||||||
send_failure(&state, &reply, req_id, "AI unavailable").await;
|
send_failure(&state, &reply, req_id, "AI unavailable").await;
|
||||||
let _ = state.event_tx.send(super::super::types::MeshEvent::AssistResponseReady {
|
let _ = state
|
||||||
req_id,
|
.event_tx
|
||||||
to_contact_id: asker,
|
.send(super::super::types::MeshEvent::AssistResponseReady {
|
||||||
error: Some(e.to_string()),
|
req_id,
|
||||||
});
|
to_contact_id: asker,
|
||||||
|
error: Some(e.to_string()),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -219,7 +234,10 @@ async fn send_typed_chunks(state: &Arc<MeshState>, dest_contact_id: u32, req_id:
|
|||||||
let chunks: Vec<String> = if chars.is_empty() {
|
let chunks: Vec<String> = if chars.is_empty() {
|
||||||
vec![String::new()]
|
vec![String::new()]
|
||||||
} else {
|
} else {
|
||||||
chars.chunks(CHUNK_CHARS).map(|c| c.iter().collect()).collect()
|
chars
|
||||||
|
.chunks(CHUNK_CHARS)
|
||||||
|
.map(|c| c.iter().collect())
|
||||||
|
.collect()
|
||||||
};
|
};
|
||||||
let last = chunks.len().saturating_sub(1);
|
let last = chunks.len().saturating_sub(1);
|
||||||
for (i, chunk) in chunks.into_iter().enumerate() {
|
for (i, chunk) in chunks.into_iter().enumerate() {
|
||||||
@ -337,7 +355,10 @@ async fn call_claude(data_dir: &Path, model: &str, prompt: &str) -> anyhow::Resu
|
|||||||
let text = json
|
let text = json
|
||||||
.get("content")
|
.get("content")
|
||||||
.and_then(|c| c.as_array())
|
.and_then(|c| c.as_array())
|
||||||
.and_then(|arr| arr.iter().find_map(|b| b.get("text").and_then(|t| t.as_str())))
|
.and_then(|arr| {
|
||||||
|
arr.iter()
|
||||||
|
.find_map(|b| b.get("text").and_then(|t| t.as_str()))
|
||||||
|
})
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_string();
|
.to_string();
|
||||||
if text.trim().is_empty() {
|
if text.trim().is_empty() {
|
||||||
|
|||||||
@ -340,9 +340,7 @@ async fn handle_channel_payload(
|
|||||||
// seeded peer instead of creating a duplicate chat thread. Not stored as
|
// seeded peer instead of creating a duplicate chat thread. Not stored as
|
||||||
// a chat message.
|
// a chat message.
|
||||||
if let Ok(text) = std::str::from_utf8(payload) {
|
if let Ok(text) = std::str::from_utf8(payload) {
|
||||||
if let Some((did, ed_hex, x_hex)) =
|
if let Some((did, ed_hex, x_hex)) = super::super::protocol::parse_identity_broadcast(text) {
|
||||||
super::super::protocol::parse_identity_broadcast(text)
|
|
||||||
{
|
|
||||||
// Ignore our own identity echoed back by the radio/channel.
|
// Ignore our own identity echoed back by the radio/channel.
|
||||||
if ed_hex.eq_ignore_ascii_case(&state.our_ed_pubkey_hex) {
|
if ed_hex.eq_ignore_ascii_case(&state.our_ed_pubkey_hex) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -433,7 +433,10 @@ pub(super) async fn run_mesh_session(
|
|||||||
our_ed_pubkey_hex,
|
our_ed_pubkey_hex,
|
||||||
our_x25519_pubkey_hex,
|
our_x25519_pubkey_hex,
|
||||||
);
|
);
|
||||||
if let Err(e) = device.send_channel_text(0, identity_advert.as_bytes()).await {
|
if let Err(e) = device
|
||||||
|
.send_channel_text(0, identity_advert.as_bytes())
|
||||||
|
.await
|
||||||
|
{
|
||||||
warn!("Failed to broadcast archipelago identity: {}", e);
|
warn!("Failed to broadcast archipelago identity: {}", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -165,7 +165,11 @@ async fn fire_due(scheduler: &Arc<MeshScheduler>, state: &Arc<MeshState>) {
|
|||||||
if failed.contains(&m.id) {
|
if failed.contains(&m.id) {
|
||||||
m.attempts += 1;
|
m.attempts += 1;
|
||||||
if m.attempts >= MAX_ATTEMPTS {
|
if m.attempts >= MAX_ATTEMPTS {
|
||||||
warn!(id = m.id, attempts = m.attempts, "Dropping undeliverable scheduled message");
|
warn!(
|
||||||
|
id = m.id,
|
||||||
|
attempts = m.attempts,
|
||||||
|
"Dropping undeliverable scheduled message"
|
||||||
|
);
|
||||||
to_remove.push(m.id);
|
to_remove.push(m.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,7 +18,7 @@ const RESERVED_PORTS: &[u16] = &[
|
|||||||
4080, 8999, 50001, // Mempool stack
|
4080, 8999, 50001, // Mempool stack
|
||||||
23000, // BTCPay
|
23000, // BTCPay
|
||||||
8173, 8174, 8175, // Fedimint
|
8173, 8174, 8175, // Fedimint
|
||||||
8178, // Fedimint client daemon (fedimint-clientd REST)
|
8178, // Fedimint client daemon (fedimint-clientd REST)
|
||||||
8123, // Home Assistant
|
8123, // Home Assistant
|
||||||
3000, // Grafana
|
3000, // Grafana
|
||||||
11434, // Ollama
|
11434, // Ollama
|
||||||
|
|||||||
@ -292,9 +292,9 @@ impl Server {
|
|||||||
sm.update_data(data).await;
|
sm.update_data(data).await;
|
||||||
}
|
}
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(
|
||||||
continue
|
_,
|
||||||
}
|
)) => continue,
|
||||||
Err(_) => break, // sender dropped → mesh stopped
|
Err(_) => break, // sender dropped → mesh stopped
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -120,7 +120,8 @@ impl IrohProvider {
|
|||||||
// The event sender gates each request through the ecash `streaming` layer
|
// The event sender gates each request through the ecash `streaming` layer
|
||||||
// — free by default, paid only if the operator priced `content-download`
|
// — free by default, paid only if the operator priced `content-download`
|
||||||
// (Networking Profits → Settings). It also hard-disables peer writes.
|
// (Networking Profits → Settings). It also hard-disables peer writes.
|
||||||
let event_sender = super::paid::gated_event_sender(data_dir.to_path_buf(), (*store).clone());
|
let event_sender =
|
||||||
|
super::paid::gated_event_sender(data_dir.to_path_buf(), (*store).clone());
|
||||||
let blobs = BlobsProtocol::new(&store, Some(event_sender));
|
let blobs = BlobsProtocol::new(&store, Some(event_sender));
|
||||||
// Shape-A paid negotiation rides a second ALPN on the same endpoint so a
|
// Shape-A paid negotiation rides a second ALPN on the same endpoint so a
|
||||||
// downloader can pay (open a session) before the blob-GET above serves it.
|
// downloader can pay (open a session) before the blob-GET above serves it.
|
||||||
|
|||||||
@ -108,7 +108,9 @@ pub async fn init(
|
|||||||
if enabled {
|
if enabled {
|
||||||
warn!("swarm: swarm_enabled set but binary built without the `iroh-swarm` feature — staying origin-only");
|
warn!("swarm: swarm_enabled set but binary built without the `iroh-swarm` feature — staying origin-only");
|
||||||
}
|
}
|
||||||
let _ = RUNTIME.set(SwarmRuntime { providers: Vec::new() });
|
let _ = RUNTIME.set(SwarmRuntime {
|
||||||
|
providers: Vec::new(),
|
||||||
|
});
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -123,13 +125,10 @@ pub async fn init(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let discovery: Arc<dyn iroh_provider::ProviderDiscovery> =
|
let discovery: Arc<dyn iroh_provider::ProviderDiscovery> = Arc::new(
|
||||||
Arc::new(iroh_provider::NostrSeedDiscovery::new(
|
iroh_provider::NostrSeedDiscovery::new(relays.to_vec(), tor_proxy.map(str::to_string)),
|
||||||
relays.to_vec(),
|
);
|
||||||
tor_proxy.map(str::to_string),
|
let provider = Arc::new(iroh_provider::IrohProvider::new(data_dir, Some(discovery)).await?);
|
||||||
));
|
|
||||||
let provider =
|
|
||||||
Arc::new(iroh_provider::IrohProvider::new(data_dir, Some(discovery)).await?);
|
|
||||||
info!(
|
info!(
|
||||||
"swarm: iroh provider active (endpoint {}) — swarm-assist enabled, origin always wins",
|
"swarm: iroh provider active (endpoint {}) — swarm-assist enabled, origin always wins",
|
||||||
provider.endpoint_id()
|
provider.endpoint_id()
|
||||||
@ -231,7 +230,10 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
debug!("swarm: no provider served {} — falling back to origin", digest);
|
debug!(
|
||||||
|
"swarm: no provider served {} — falling back to origin",
|
||||||
|
digest
|
||||||
|
);
|
||||||
origin().await?;
|
origin().await?;
|
||||||
Ok(FetchSource::Origin)
|
Ok(FetchSource::Origin)
|
||||||
}
|
}
|
||||||
@ -248,7 +250,11 @@ mod tests {
|
|||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
fn digest_of(bytes: &[u8]) -> ContentDigest {
|
fn digest_of(bytes: &[u8]) -> ContentDigest {
|
||||||
ContentDigest::parse(&format!("blake3:{}", crate::content_hash::blake3_hex(bytes))).unwrap()
|
ContentDigest::parse(&format!(
|
||||||
|
"blake3:{}",
|
||||||
|
crate::content_hash::blake3_hex(bytes)
|
||||||
|
))
|
||||||
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Provider that writes a fixed payload (which may or may not match).
|
/// Provider that writes a fixed payload (which may or may not match).
|
||||||
@ -295,7 +301,10 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(src, FetchSource::Swarm);
|
assert_eq!(src, FetchSource::Swarm);
|
||||||
assert!(!origin_ran.load(Ordering::SeqCst), "origin must not run on swarm hit");
|
assert!(
|
||||||
|
!origin_ran.load(Ordering::SeqCst),
|
||||||
|
"origin must not run on swarm hit"
|
||||||
|
);
|
||||||
assert_eq!(tokio::fs::read(&dest).await.unwrap(), content);
|
assert_eq!(tokio::fs::read(&dest).await.unwrap(), content);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -316,7 +325,11 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(src, FetchSource::Origin, "tampered swarm bytes must not be accepted");
|
assert_eq!(
|
||||||
|
src,
|
||||||
|
FetchSource::Origin,
|
||||||
|
"tampered swarm bytes must not be accepted"
|
||||||
|
);
|
||||||
assert_eq!(tokio::fs::read(&dest).await.unwrap(), content);
|
assert_eq!(tokio::fs::read(&dest).await.unwrap(), content);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -343,8 +356,14 @@ mod tests {
|
|||||||
let content = b"second wins".to_vec();
|
let content = b"second wins".to_vec();
|
||||||
let digest = digest_of(&content);
|
let digest = digest_of(&content);
|
||||||
let providers = vec![
|
let providers = vec![
|
||||||
arc(FixedProvider { name: "miss", payload: None }),
|
arc(FixedProvider {
|
||||||
arc(FixedProvider { name: "hit", payload: Some(content.clone()) }),
|
name: "miss",
|
||||||
|
payload: None,
|
||||||
|
}),
|
||||||
|
arc(FixedProvider {
|
||||||
|
name: "hit",
|
||||||
|
payload: Some(content.clone()),
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
let src = fetch_content_addressed(&digest, &providers, &dest, || async {
|
let src = fetch_content_addressed(&digest, &providers, &dest, || async {
|
||||||
tokio::fs::write(&dest, b"origin").await?;
|
tokio::fs::write(&dest, b"origin").await?;
|
||||||
|
|||||||
@ -166,7 +166,9 @@ impl ProtocolHandler for PaidBlobsProtocol {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
let bytes = serde_json::to_vec(&response).map_err(AcceptError::from_err)?;
|
let bytes = serde_json::to_vec(&response).map_err(AcceptError::from_err)?;
|
||||||
send.write_all(&bytes).await.map_err(AcceptError::from_err)?;
|
send.write_all(&bytes)
|
||||||
|
.await
|
||||||
|
.map_err(AcceptError::from_err)?;
|
||||||
send.finish().map_err(AcceptError::from_err)?;
|
send.finish().map_err(AcceptError::from_err)?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@ -192,7 +194,9 @@ pub async fn negotiate_access(
|
|||||||
match negotiate_inner(endpoint, data_dir, peer, blake3_hex, policy).await {
|
match negotiate_inner(endpoint, data_dir, peer, blake3_hex, policy).await {
|
||||||
Ok(proceed) => proceed,
|
Ok(proceed) => proceed,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::debug!("paid-alpn: negotiation with {peer} failed ({e}) — proceeding (gate decides)");
|
tracing::debug!(
|
||||||
|
"paid-alpn: negotiation with {peer} failed ({e}) — proceeding (gate decides)"
|
||||||
|
);
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -265,7 +269,10 @@ mod tests {
|
|||||||
token: None,
|
token: None,
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&req).unwrap();
|
let json = serde_json::to_string(&req).unwrap();
|
||||||
assert!(!json.contains("token"), "absent token must be omitted: {json}");
|
assert!(
|
||||||
|
!json.contains("token"),
|
||||||
|
"absent token must be omitted: {json}"
|
||||||
|
);
|
||||||
let back: PaidRequest = serde_json::from_str(&json).unwrap();
|
let back: PaidRequest = serde_json::from_str(&json).unwrap();
|
||||||
assert_eq!(back.want, "abcd");
|
assert_eq!(back.want, "abcd");
|
||||||
assert!(back.token.is_none());
|
assert!(back.token.is_none());
|
||||||
@ -277,7 +284,8 @@ mod tests {
|
|||||||
want: "ff".into(),
|
want: "ff".into(),
|
||||||
token: Some("cashuAbc".into()),
|
token: Some("cashuAbc".into()),
|
||||||
};
|
};
|
||||||
let back: PaidRequest = serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap();
|
let back: PaidRequest =
|
||||||
|
serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap();
|
||||||
assert_eq!(back.token.as_deref(), Some("cashuAbc"));
|
assert_eq!(back.token.as_deref(), Some("cashuAbc"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -88,7 +88,8 @@ pub async fn auto_pay_token(
|
|||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
match ecash::build_payment_token(data_dir, accepted_mints, price_sats, policy.max_fee_sats).await
|
match ecash::build_payment_token(data_dir, accepted_mints, price_sats, policy.max_fee_sats)
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
Ok(token) => Ok(Some(token)),
|
Ok(token) => Ok(Some(token)),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
@ -191,7 +191,10 @@ mod tests {
|
|||||||
let hash = "b".repeat(64);
|
let hash = "b".repeat(64);
|
||||||
let json = serde_json::to_string(&advertisement_filter(&hash)).unwrap();
|
let json = serde_json::to_string(&advertisement_filter(&hash)).unwrap();
|
||||||
assert!(json.contains(&hash), "filter must target the hash d-tag");
|
assert!(json.contains(&hash), "filter must target the hash d-tag");
|
||||||
assert!(json.contains("30081"), "filter must constrain the seed kind");
|
assert!(
|
||||||
|
json.contains("30081"),
|
||||||
|
"filter must constrain the seed kind"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -212,10 +215,19 @@ mod tests {
|
|||||||
let a = Keys::generate();
|
let a = Keys::generate();
|
||||||
let b = Keys::generate();
|
let b = Keys::generate();
|
||||||
let hash = "d".repeat(64);
|
let hash = "d".repeat(64);
|
||||||
let e1 = advertisement_builder(&hash, "endpoint-A").sign_with_keys(&a).unwrap();
|
let e1 = advertisement_builder(&hash, "endpoint-A")
|
||||||
let e2 = advertisement_builder(&hash, "endpoint-A").sign_with_keys(&b).unwrap();
|
.sign_with_keys(&a)
|
||||||
let e3 = advertisement_builder(&hash, "endpoint-B").sign_with_keys(&b).unwrap();
|
.unwrap();
|
||||||
|
let e2 = advertisement_builder(&hash, "endpoint-A")
|
||||||
|
.sign_with_keys(&b)
|
||||||
|
.unwrap();
|
||||||
|
let e3 = advertisement_builder(&hash, "endpoint-B")
|
||||||
|
.sign_with_keys(&b)
|
||||||
|
.unwrap();
|
||||||
let ids = endpoint_ids_from_events([&e1, &e2, &e3]);
|
let ids = endpoint_ids_from_events([&e1, &e2, &e3]);
|
||||||
assert_eq!(ids, vec!["endpoint-A".to_string(), "endpoint-B".to_string()]);
|
assert_eq!(
|
||||||
|
ids,
|
||||||
|
vec!["endpoint-A".to_string(), "endpoint-B".to_string()]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,9 +28,7 @@ pub fn ed25519_pubkey_from_did_key(did: &str) -> Result<VerifyingKey> {
|
|||||||
if decoded.len() != 34 || decoded[0..2] != ED25519_MULTICODEC {
|
if decoded.len() != 34 || decoded[0..2] != ED25519_MULTICODEC {
|
||||||
return Err(anyhow!("not an Ed25519 did:key (bad multicodec prefix)"));
|
return Err(anyhow!("not an Ed25519 did:key (bad multicodec prefix)"));
|
||||||
}
|
}
|
||||||
let arr: [u8; 32] = decoded[2..]
|
let arr: [u8; 32] = decoded[2..].try_into().expect("length checked above");
|
||||||
.try_into()
|
|
||||||
.expect("length checked above");
|
|
||||||
VerifyingKey::from_bytes(&arr).context("invalid Ed25519 public key in did:key")
|
VerifyingKey::from_bytes(&arr).context("invalid Ed25519 public key in did:key")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -60,7 +60,13 @@ pub fn verify_detached(doc: &Value) -> Result<SignatureStatus> {
|
|||||||
let signed_by = obj
|
let signed_by = obj
|
||||||
.get(SIGNED_BY_FIELD)
|
.get(SIGNED_BY_FIELD)
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.ok_or_else(|| anyhow!("signed document has `{}` but no `{}`", SIGNATURE_FIELD, SIGNED_BY_FIELD))?;
|
.ok_or_else(|| {
|
||||||
|
anyhow!(
|
||||||
|
"signed document has `{}` but no `{}`",
|
||||||
|
SIGNATURE_FIELD,
|
||||||
|
SIGNED_BY_FIELD
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
let signer = did::ed25519_pubkey_from_did_key(signed_by)
|
let signer = did::ed25519_pubkey_from_did_key(signed_by)
|
||||||
.with_context(|| format!("invalid `{}` did:key", SIGNED_BY_FIELD))?;
|
.with_context(|| format!("invalid `{}` did:key", SIGNED_BY_FIELD))?;
|
||||||
@ -174,7 +180,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn tampered_payload_is_rejected() {
|
fn tampered_payload_is_rejected() {
|
||||||
let mut signed = sign_into(&test_key(), json!({"schema": 1, "n": 42}));
|
let mut signed = sign_into(&test_key(), json!({"schema": 1, "n": 42}));
|
||||||
signed.as_object_mut().unwrap().insert("n".into(), json!(43));
|
signed
|
||||||
|
.as_object_mut()
|
||||||
|
.unwrap()
|
||||||
|
.insert("n".into(), json!(43));
|
||||||
assert!(verify_detached(&signed).is_err());
|
assert!(verify_detached(&signed).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -489,8 +489,12 @@ pub async fn swap_between_mints(
|
|||||||
// The pay leg never completed — record the route failure so future
|
// The pay leg never completed — record the route failure so future
|
||||||
// payments can prefer a route with a track record.
|
// payments can prefer a route with a track record.
|
||||||
record_swap_failure(data_dir, from_mint, to_mint).await;
|
record_swap_failure(data_dir, from_mint, to_mint).await;
|
||||||
return Err(e)
|
return Err(e).with_context(|| {
|
||||||
.with_context(|| format!("melting source proofs at {} to pay target invoice", from_mint));
|
format!(
|
||||||
|
"melting source proofs at {} to pay target invoice",
|
||||||
|
from_mint
|
||||||
|
)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist the spend BEFORE claiming so a crash can't double-spend, and
|
// Persist the spend BEFORE claiming so a crash can't double-spend, and
|
||||||
@ -533,7 +537,10 @@ pub async fn swap_between_mints(
|
|||||||
wallet.record_tx(
|
wallet.record_tx(
|
||||||
TransactionType::Mint,
|
TransactionType::Mint,
|
||||||
minted,
|
minted,
|
||||||
&format!("Cross-mint swap {}→{}: claimed {} sats", from_mint, to_mint, minted),
|
&format!(
|
||||||
|
"Cross-mint swap {}→{}: claimed {} sats",
|
||||||
|
from_mint, to_mint, minted
|
||||||
|
),
|
||||||
to_mint,
|
to_mint,
|
||||||
from_mint,
|
from_mint,
|
||||||
);
|
);
|
||||||
@ -680,7 +687,11 @@ pub enum PaymentPlan {
|
|||||||
/// Pure and synchronous so it can be unit-tested without a live mint. It does
|
/// Pure and synchronous so it can be unit-tested without a live mint. It does
|
||||||
/// not know swap fees; `swap_between_mints` enforces the fee cap and bails (→
|
/// not know swap fees; `swap_between_mints` enforces the fee cap and bails (→
|
||||||
/// origin fallback) if the chosen source can't cover amount + fee.
|
/// origin fallback) if the chosen source can't cover amount + fee.
|
||||||
fn plan_payment(holdings: &[(String, u64)], accepted: &[(String, bool)], amount: u64) -> PaymentPlan {
|
fn plan_payment(
|
||||||
|
holdings: &[(String, u64)],
|
||||||
|
accepted: &[(String, bool)],
|
||||||
|
amount: u64,
|
||||||
|
) -> PaymentPlan {
|
||||||
let norm = |s: &str| s.trim_end_matches('/').to_string();
|
let norm = |s: &str| s.trim_end_matches('/').to_string();
|
||||||
let home = norm(&default_mint_url());
|
let home = norm(&default_mint_url());
|
||||||
let held = |mint: &str| -> u64 {
|
let held = |mint: &str| -> u64 {
|
||||||
@ -692,10 +703,8 @@ fn plan_payment(holdings: &[(String, u64)], accepted: &[(String, bool)], amount:
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 1. Direct: any accepted mint we already hold enough on. Prefer home.
|
// 1. Direct: any accepted mint we already hold enough on. Prefer home.
|
||||||
let mut direct: Vec<&(String, bool)> = accepted
|
let mut direct: Vec<&(String, bool)> =
|
||||||
.iter()
|
accepted.iter().filter(|(m, _)| held(m) >= amount).collect();
|
||||||
.filter(|(m, _)| held(m) >= amount)
|
|
||||||
.collect();
|
|
||||||
direct.sort_by_key(|(m, _)| norm(m) != home); // home (false) sorts first
|
direct.sort_by_key(|(m, _)| norm(m) != home); // home (false) sorts first
|
||||||
if let Some((mint, _)) = direct.first() {
|
if let Some((mint, _)) = direct.first() {
|
||||||
return PaymentPlan::Direct {
|
return PaymentPlan::Direct {
|
||||||
@ -761,7 +770,10 @@ pub async fn build_payment_token(
|
|||||||
|
|
||||||
match plan_payment(&holdings, &accepted, amount_sats) {
|
match plan_payment(&holdings, &accepted, amount_sats) {
|
||||||
PaymentPlan::Direct { mint_url } => {
|
PaymentPlan::Direct { mint_url } => {
|
||||||
debug!("Payment plan: direct from {} for {} sats", mint_url, amount_sats);
|
debug!(
|
||||||
|
"Payment plan: direct from {} for {} sats",
|
||||||
|
mint_url, amount_sats
|
||||||
|
);
|
||||||
send_token_at(data_dir, &mint_url, amount_sats).await
|
send_token_at(data_dir, &mint_url, amount_sats).await
|
||||||
}
|
}
|
||||||
PaymentPlan::Swap { from_mint, to_mint } => {
|
PaymentPlan::Swap { from_mint, to_mint } => {
|
||||||
@ -844,7 +856,10 @@ pub async fn resume_pending_swaps(data_dir: &Path) -> Result<u64> {
|
|||||||
let to = match MintClient::new(&swap.to_mint) {
|
let to = match MintClient::new(&swap.to_mint) {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("resume_pending_swaps: bad target mint {}: {}", swap.to_mint, e);
|
warn!(
|
||||||
|
"resume_pending_swaps: bad target mint {}: {}",
|
||||||
|
swap.to_mint, e
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -883,7 +898,10 @@ pub async fn resume_pending_swaps(data_dir: &Path) -> Result<u64> {
|
|||||||
swap.from_mint, swap.to_mint, minted
|
swap.from_mint, swap.to_mint, minted
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Err(e) => warn!("resume_pending_swaps: claim failed for {}: {}", swap.mint_quote_id, e),
|
Err(e) => warn!(
|
||||||
|
"resume_pending_swaps: claim failed for {}: {}",
|
||||||
|
swap.mint_quote_id, e
|
||||||
|
),
|
||||||
},
|
},
|
||||||
"ISSUED" => {
|
"ISSUED" => {
|
||||||
// Already claimed on a previous run — drop the journal entry.
|
// Already claimed on a previous run — drop the journal entry.
|
||||||
@ -940,14 +958,20 @@ async fn save_swap_liquidity(data_dir: &Path, liq: &SwapLiquidity) {
|
|||||||
/// Record that a swap route succeeded (best-effort; never fails the caller).
|
/// Record that a swap route succeeded (best-effort; never fails the caller).
|
||||||
async fn record_swap_success(data_dir: &Path, from_mint: &str, to_mint: &str) {
|
async fn record_swap_success(data_dir: &Path, from_mint: &str, to_mint: &str) {
|
||||||
let mut liq = load_swap_liquidity(data_dir).await;
|
let mut liq = load_swap_liquidity(data_dir).await;
|
||||||
liq.routes.entry(route_key(from_mint, to_mint)).or_default().successes += 1;
|
liq.routes
|
||||||
|
.entry(route_key(from_mint, to_mint))
|
||||||
|
.or_default()
|
||||||
|
.successes += 1;
|
||||||
save_swap_liquidity(data_dir, &liq).await;
|
save_swap_liquidity(data_dir, &liq).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record that a swap route failed (best-effort; never fails the caller).
|
/// Record that a swap route failed (best-effort; never fails the caller).
|
||||||
async fn record_swap_failure(data_dir: &Path, from_mint: &str, to_mint: &str) {
|
async fn record_swap_failure(data_dir: &Path, from_mint: &str, to_mint: &str) {
|
||||||
let mut liq = load_swap_liquidity(data_dir).await;
|
let mut liq = load_swap_liquidity(data_dir).await;
|
||||||
liq.routes.entry(route_key(from_mint, to_mint)).or_default().failures += 1;
|
liq.routes
|
||||||
|
.entry(route_key(from_mint, to_mint))
|
||||||
|
.or_default()
|
||||||
|
.failures += 1;
|
||||||
save_swap_liquidity(data_dir, &liq).await;
|
save_swap_liquidity(data_dir, &liq).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1574,17 +1598,38 @@ mod tests {
|
|||||||
wallet.add_proofs(
|
wallet.add_proofs(
|
||||||
"http://mint-a",
|
"http://mint-a",
|
||||||
vec![
|
vec![
|
||||||
Proof { amount: 10, id: "k".into(), secret: "s1".into(), c: "c".into() },
|
Proof {
|
||||||
Proof { amount: 5, id: "k".into(), secret: "s2".into(), c: "c".into() },
|
amount: 10,
|
||||||
|
id: "k".into(),
|
||||||
|
secret: "s1".into(),
|
||||||
|
c: "c".into(),
|
||||||
|
},
|
||||||
|
Proof {
|
||||||
|
amount: 5,
|
||||||
|
id: "k".into(),
|
||||||
|
secret: "s2".into(),
|
||||||
|
c: "c".into(),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
wallet.add_proofs(
|
wallet.add_proofs(
|
||||||
"http://mint-b",
|
"http://mint-b",
|
||||||
vec![Proof { amount: 7, id: "k".into(), secret: "s3".into(), c: "c".into() }],
|
vec![Proof {
|
||||||
|
amount: 7,
|
||||||
|
id: "k".into(),
|
||||||
|
secret: "s3".into(),
|
||||||
|
c: "c".into(),
|
||||||
|
}],
|
||||||
);
|
);
|
||||||
wallet.proofs[1].spent = true; // exclude the 5 on mint-a
|
wallet.proofs[1].spent = true; // exclude the 5 on mint-a
|
||||||
let by_mint = wallet.spendable_by_mint();
|
let by_mint = wallet.spendable_by_mint();
|
||||||
assert_eq!(by_mint, vec![("http://mint-a".to_string(), 10), ("http://mint-b".to_string(), 7)]);
|
assert_eq!(
|
||||||
|
by_mint,
|
||||||
|
vec![
|
||||||
|
("http://mint-a".to_string(), 10),
|
||||||
|
("http://mint-b".to_string(), 7)
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -1606,7 +1651,9 @@ mod tests {
|
|||||||
let accepted = vec![("https://b".into(), true)];
|
let accepted = vec![("https://b".into(), true)];
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
plan_payment(&holdings, &accepted, 50),
|
plan_payment(&holdings, &accepted, 50),
|
||||||
PaymentPlan::Direct { mint_url: "https://b".into() }
|
PaymentPlan::Direct {
|
||||||
|
mint_url: "https://b".into()
|
||||||
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1617,7 +1664,10 @@ mod tests {
|
|||||||
let accepted = vec![("https://b".into(), true)];
|
let accepted = vec![("https://b".into(), true)];
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
plan_payment(&holdings, &accepted, 50),
|
plan_payment(&holdings, &accepted, 50),
|
||||||
PaymentPlan::Swap { from_mint: "https://a".into(), to_mint: "https://b".into() }
|
PaymentPlan::Swap {
|
||||||
|
from_mint: "https://a".into(),
|
||||||
|
to_mint: "https://b".into()
|
||||||
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1626,7 +1676,10 @@ mod tests {
|
|||||||
// Seeder accepts only B, but B is not trusted → no swap, insufficient.
|
// Seeder accepts only B, but B is not trusted → no swap, insufficient.
|
||||||
let holdings = vec![("https://a".into(), 100)];
|
let holdings = vec![("https://a".into(), 100)];
|
||||||
let accepted = vec![("https://b".into(), false)];
|
let accepted = vec![("https://b".into(), false)];
|
||||||
assert_eq!(plan_payment(&holdings, &accepted, 50), PaymentPlan::Insufficient);
|
assert_eq!(
|
||||||
|
plan_payment(&holdings, &accepted, 50),
|
||||||
|
PaymentPlan::Insufficient
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -1635,7 +1688,10 @@ mod tests {
|
|||||||
// we hold neither accepted mint directly.
|
// we hold neither accepted mint directly.
|
||||||
let holdings = vec![("https://a".into(), 30), ("https://c".into(), 30)];
|
let holdings = vec![("https://a".into(), 30), ("https://c".into(), 30)];
|
||||||
let accepted = vec![("https://b".into(), true)];
|
let accepted = vec![("https://b".into(), true)];
|
||||||
assert_eq!(plan_payment(&holdings, &accepted, 50), PaymentPlan::Insufficient);
|
assert_eq!(
|
||||||
|
plan_payment(&holdings, &accepted, 50),
|
||||||
|
PaymentPlan::Insufficient
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -1646,7 +1702,9 @@ mod tests {
|
|||||||
let accepted = vec![("https://b".into(), true)];
|
let accepted = vec![("https://b".into(), true)];
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
plan_payment(&holdings, &accepted, 50),
|
plan_payment(&holdings, &accepted, 50),
|
||||||
PaymentPlan::Direct { mint_url: "https://b".into() }
|
PaymentPlan::Direct {
|
||||||
|
mint_url: "https://b".into()
|
||||||
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1744,7 +1802,10 @@ mod tests {
|
|||||||
record_swap_success(tmp.path(), "https://src", "https://liquid").await;
|
record_swap_success(tmp.path(), "https://src", "https://liquid").await;
|
||||||
|
|
||||||
// Seeder accepts both non-home mints; we only hold "https://src".
|
// Seeder accepts both non-home mints; we only hold "https://src".
|
||||||
let accepted = vec![("https://dry".into(), true), ("https://liquid".into(), true)];
|
let accepted = vec![
|
||||||
|
("https://dry".into(), true),
|
||||||
|
("https://liquid".into(), true),
|
||||||
|
];
|
||||||
let holdings = vec![("https://src".to_string(), 1000u64)];
|
let holdings = vec![("https://src".to_string(), 1000u64)];
|
||||||
|
|
||||||
// Mirror build_payment_token's ordering step, then plan.
|
// Mirror build_payment_token's ordering step, then plan.
|
||||||
|
|||||||
@ -88,7 +88,11 @@ pub async fn ensure_default_federation(data_dir: &Path) -> Result<()> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let mut reg = load_registry(data_dir).await?;
|
let mut reg = load_registry(data_dir).await?;
|
||||||
if !reg.federations.iter().any(|f| f.federation_id == federation_id) {
|
if !reg
|
||||||
|
.federations
|
||||||
|
.iter()
|
||||||
|
.any(|f| f.federation_id == federation_id)
|
||||||
|
{
|
||||||
reg.federations.push(JoinedFederation {
|
reg.federations.push(JoinedFederation {
|
||||||
federation_id,
|
federation_id,
|
||||||
name: None,
|
name: None,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user