Archipelago — open-source initial import
This commit is contained in:
@@ -0,0 +1,486 @@
|
||||
//! Thin HTTP bridge to the `barkd` sidecar container (Ark protocol).
|
||||
//!
|
||||
//! Same shape as [`super::fedimint_client`]: the heavy `bark-wallet` SDK stays
|
||||
//! OUT of this binary. The `barkd` daemon (in `apps/barkd`) holds the Ark
|
||||
//! wallet (VTXOs, rounds, unilateral exits) and we speak its REST API
|
||||
//! (`/api/v1/*`, Bearer auth). Endpoint/JSON shapes target barkd 0.3.0 and
|
||||
//! must be pinned to the vendored image tag.
|
||||
//!
|
||||
//! ARK is on-chain-anchored: VTXOs expire (`vtxo_expiry_delta` blocks) and the
|
||||
//! barkd daemon refreshes them by joining rounds on its own — the bridge never
|
||||
//! has to schedule anything. Unlike Cashu/Fedimint, funds survive the sidecar
|
||||
//! dying (the wallet mnemonic in barkd's datadir can unilaterally exit
|
||||
//! on-chain), so back up `/var/lib/archipelago/barkd`.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use tokio::fs;
|
||||
|
||||
const BARKD_TIMEOUT_SECS: u64 = 15;
|
||||
/// Send/board/offboard can wait on Ark round participation (signet rounds run
|
||||
/// every 5 minutes), so give mutating calls generous room.
|
||||
const BARKD_HEAVY_TIMEOUT_SECS: u64 = 120;
|
||||
|
||||
/// Default host port the `barkd` container is mapped to (its in-container
|
||||
/// REST port; 3535 is unused elsewhere on the node — see `port_allocator`).
|
||||
const DEFAULT_BARKD_URL: &str = "http://127.0.0.1:3535";
|
||||
|
||||
/// Shared secret between the barkd container and this bridge. The barkd
|
||||
/// manifest generates it via `generated_secrets: [{barkd-secret, hex32}]`; the
|
||||
/// container entrypoint installs it with `barkd secret refresh --secret` and
|
||||
/// the bridge derives the matching Bearer token from the same file.
|
||||
const BARKD_SECRET: &str = "barkd-secret";
|
||||
|
||||
/// Wallet configuration used when the bridge has to create the barkd wallet
|
||||
/// (first use). Persisted so operators can point at their own Ark server.
|
||||
/// Defaults target Second's public signet deployment while Ark matures —
|
||||
/// mainnet needs an explicit opt-in edit of `wallet/ark_config.json`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ArkConfig {
|
||||
pub network: String,
|
||||
pub ark_server: String,
|
||||
pub esplora: String,
|
||||
}
|
||||
|
||||
impl Default for ArkConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
network: "signet".to_string(),
|
||||
ark_server: "https://ark.signet.2nd.dev".to_string(),
|
||||
esplora: "https://esplora.signet.2nd.dev".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ARK_CONFIG_FILE: &str = "wallet/ark_config.json";
|
||||
|
||||
pub async fn load_config(data_dir: &Path) -> ArkConfig {
|
||||
match fs::read_to_string(data_dir.join(ARK_CONFIG_FILE)).await {
|
||||
Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
|
||||
Err(_) => ArkConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn save_config(data_dir: &Path, config: &ArkConfig) -> Result<()> {
|
||||
let dir = data_dir.join("wallet");
|
||||
fs::create_dir_all(&dir)
|
||||
.await
|
||||
.context("Failed to create wallet dir")?;
|
||||
let content = serde_json::to_string_pretty(config).context("Failed to serialize ark config")?;
|
||||
fs::write(data_dir.join(ARK_CONFIG_FILE), content)
|
||||
.await
|
||||
.context("Failed to write ark config")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Encode barkd's Bearer token from the raw 32-byte shared secret:
|
||||
/// base64url-nopad of `<version 0x00><32-byte secret>` (see barkd `AuthToken`).
|
||||
fn encode_auth_token(secret: &[u8; 32]) -> String {
|
||||
let mut buf = Vec::with_capacity(33);
|
||||
buf.push(0u8);
|
||||
buf.extend_from_slice(secret);
|
||||
URL_SAFE_NO_PAD.encode(&buf)
|
||||
}
|
||||
|
||||
fn secret_hex_to_token(hex: &str) -> Result<String> {
|
||||
let hex = hex.trim();
|
||||
if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
anyhow::bail!("barkd-secret must be exactly 64 hex characters");
|
||||
}
|
||||
let mut secret = [0u8; 32];
|
||||
for (i, byte) in secret.iter_mut().enumerate() {
|
||||
*byte = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).expect("validated hex");
|
||||
}
|
||||
Ok(encode_auth_token(&secret))
|
||||
}
|
||||
|
||||
/// HTTP client for a `barkd` instance.
|
||||
pub struct ArkClient {
|
||||
base_url: String,
|
||||
token: String,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl ArkClient {
|
||||
pub fn new(base_url: &str, token: &str) -> Result<Self> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(BARKD_HEAVY_TIMEOUT_SECS))
|
||||
.build()
|
||||
.context("Failed to build HTTP client for barkd")?;
|
||||
Ok(Self {
|
||||
base_url: base_url.trim_end_matches('/').to_string(),
|
||||
token: token.to_string(),
|
||||
client,
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve URL + auth token from env / node secret, with sane defaults.
|
||||
/// URL: `BARKD_URL` else the default mapped port. Token: `BARKD_TOKEN`
|
||||
/// (already-encoded Bearer token) else derived from the shared
|
||||
/// `barkd-secret` the manifest generated for the container.
|
||||
pub async fn from_node(data_dir: &Path) -> Result<Self> {
|
||||
let base_url = std::env::var("BARKD_URL").unwrap_or_else(|_| DEFAULT_BARKD_URL.to_string());
|
||||
let token = match std::env::var("BARKD_TOKEN") {
|
||||
Ok(t) if !t.is_empty() => t,
|
||||
_ => {
|
||||
let path = data_dir.join("secrets").join(BARKD_SECRET);
|
||||
let hex = fs::read_to_string(&path).await.context(
|
||||
"Ark wallet not configured (no BARKD_TOKEN and no barkd-secret \
|
||||
secret). Install the Ark (barkd) app.",
|
||||
)?;
|
||||
secret_hex_to_token(&hex)?
|
||||
}
|
||||
};
|
||||
Self::new(&base_url, &token)
|
||||
}
|
||||
|
||||
fn auth(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
req.bearer_auth(&self.token)
|
||||
}
|
||||
|
||||
async fn get(&self, path: &str) -> Result<serde_json::Value> {
|
||||
let url = format!("{}{}", self.base_url, path);
|
||||
let resp = self
|
||||
.auth(self.client.get(&url))
|
||||
.timeout(std::time::Duration::from_secs(BARKD_TIMEOUT_SECS))
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("barkd GET {path} failed (is it running?)"))?;
|
||||
Self::parse(resp, path).await
|
||||
}
|
||||
|
||||
async fn post(&self, path: &str, body: serde_json::Value) -> Result<serde_json::Value> {
|
||||
let url = format!("{}{}", self.base_url, path);
|
||||
let resp = self
|
||||
.auth(self.client.post(&url))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("barkd POST {path} failed (is it running?)"))?;
|
||||
Self::parse(resp, path).await
|
||||
}
|
||||
|
||||
async fn parse(resp: reqwest::Response, path: &str) -> Result<serde_json::Value> {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
if !status.is_success() {
|
||||
// barkd errors are `{"message": "..."}`; surface the message.
|
||||
let msg = serde_json::from_str::<serde_json::Value>(&text)
|
||||
.ok()
|
||||
.and_then(|v| v.get("message").and_then(|m| m.as_str()).map(String::from))
|
||||
.unwrap_or(text);
|
||||
anyhow::bail!("barkd {path} returned {status}: {msg}");
|
||||
}
|
||||
if text.is_empty() {
|
||||
return Ok(serde_json::json!({}));
|
||||
}
|
||||
serde_json::from_str(&text)
|
||||
.with_context(|| format!("barkd {path} returned non-JSON: {text}"))
|
||||
}
|
||||
|
||||
/// `GET /api/v1/wallet` — wallet info (fingerprint, network, config).
|
||||
/// Errors with "No wallet set" until `create_wallet` has run.
|
||||
pub async fn wallet_info(&self) -> Result<serde_json::Value> {
|
||||
self.get("/api/v1/wallet").await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/wallet/create` — create (or restore, with a mnemonic) the
|
||||
/// barkd wallet. Idempotent guard is on the caller (`ensure_wallet`).
|
||||
pub async fn create_wallet(&self, config: &ArkConfig) -> Result<serde_json::Value> {
|
||||
self.post(
|
||||
"/api/v1/wallet/create",
|
||||
serde_json::json!({
|
||||
"network": config.network,
|
||||
"ark_server": config.ark_server,
|
||||
"chain_source": { "esplora": { "url": config.esplora } },
|
||||
}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/wallet/balance` — off-chain balance breakdown, in sats.
|
||||
pub async fn balance(&self) -> Result<serde_json::Value> {
|
||||
self.get("/api/v1/wallet/balance").await
|
||||
}
|
||||
|
||||
/// Spendable off-chain sats (0 on any missing field, never an error once
|
||||
/// the call itself succeeds).
|
||||
pub async fn spendable_sats(&self) -> Result<u64> {
|
||||
let bal = self.balance().await?;
|
||||
Ok(bal
|
||||
.get("spendable_sat")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0))
|
||||
}
|
||||
|
||||
/// `GET /api/v1/onchain/balance` — the wallet's on-chain (boarding) funds.
|
||||
pub async fn onchain_balance(&self) -> Result<serde_json::Value> {
|
||||
self.get("/api/v1/onchain/balance").await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/wallet/addresses/next` — fresh Ark (`tark1…`) address.
|
||||
pub async fn ark_address(&self) -> Result<String> {
|
||||
let res = self
|
||||
.post("/api/v1/wallet/addresses/next", serde_json::json!({}))
|
||||
.await?;
|
||||
res.get("address")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.ok_or_else(|| anyhow::anyhow!("barkd address: no address in response"))
|
||||
}
|
||||
|
||||
/// `POST /api/v1/onchain/addresses/next` — fresh on-chain boarding address.
|
||||
pub async fn onchain_address(&self) -> Result<String> {
|
||||
let res = self
|
||||
.post("/api/v1/onchain/addresses/next", serde_json::json!({}))
|
||||
.await?;
|
||||
res.get("address")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.ok_or_else(|| anyhow::anyhow!("barkd onchain address: no address in response"))
|
||||
}
|
||||
|
||||
/// `POST /api/v1/wallet/send` — pay an Ark address, BOLT11 invoice, LNURL
|
||||
/// or lightning address from off-chain funds. Returns the movement barkd
|
||||
/// reports for the payment.
|
||||
pub async fn send(
|
||||
&self,
|
||||
destination: &str,
|
||||
amount_sats: Option<u64>,
|
||||
comment: Option<&str>,
|
||||
) -> Result<serde_json::Value> {
|
||||
self.post(
|
||||
"/api/v1/wallet/send",
|
||||
serde_json::json!({
|
||||
"destination": destination,
|
||||
"amount_sat": amount_sats,
|
||||
"comment": comment,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/lightning/receives/invoice` — BOLT11 invoice that lands
|
||||
/// as an Ark VTXO when paid.
|
||||
pub async fn lightning_invoice(&self, amount_sats: u64) -> Result<serde_json::Value> {
|
||||
self.post(
|
||||
"/api/v1/lightning/receives/invoice",
|
||||
serde_json::json!({ "amount_sat": amount_sats }),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/boards/board-amount` (or `board-all` when `amount_sats`
|
||||
/// is None) — lift on-chain funds into Ark VTXOs.
|
||||
pub async fn board(&self, amount_sats: Option<u64>) -> Result<serde_json::Value> {
|
||||
match amount_sats {
|
||||
Some(sats) => {
|
||||
self.post(
|
||||
"/api/v1/boards/board-amount",
|
||||
serde_json::json!({ "amount_sat": sats }),
|
||||
)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
self.post("/api/v1/boards/board-all", serde_json::json!({}))
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /api/v1/wallet/offboard/all` — move all VTXOs back on-chain via a
|
||||
/// collaborative round.
|
||||
pub async fn offboard_all(&self, address: Option<&str>) -> Result<serde_json::Value> {
|
||||
self.post(
|
||||
"/api/v1/wallet/offboard/all",
|
||||
serde_json::json!({ "address": address }),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/wallet/movements` — barkd's own movement history. This is
|
||||
/// authoritative (includes receives we never initiated), so unlike the
|
||||
/// Fedimint bridge there is no local tx log to maintain.
|
||||
pub async fn movements(&self) -> Result<Vec<serde_json::Value>> {
|
||||
let res = self.get("/api/v1/wallet/movements").await?;
|
||||
Ok(res.as_array().cloned().unwrap_or_default())
|
||||
}
|
||||
|
||||
/// `GET /api/v1/wallet/ark-info` — connected Ark server parameters.
|
||||
pub async fn ark_info(&self) -> Result<serde_json::Value> {
|
||||
self.get("/api/v1/wallet/ark-info").await
|
||||
}
|
||||
}
|
||||
|
||||
/// Idempotently make sure barkd has a wallet, creating one with the node's
|
||||
/// Ark config on first use. Best-effort no-op when the sidecar isn't
|
||||
/// installed/running yet — mirrors `fedimint_client::ensure_default_federation`.
|
||||
pub async fn ensure_wallet(data_dir: &Path) -> Result<()> {
|
||||
let client = match ArkClient::from_node(data_dir).await {
|
||||
Ok(c) => c,
|
||||
Err(_) => return Ok(()), // barkd not configured yet
|
||||
};
|
||||
if client.wallet_info().await.is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
let config = load_config(data_dir).await;
|
||||
match client.create_wallet(&config).await {
|
||||
Ok(_) => {
|
||||
tracing::info!(
|
||||
"created barkd Ark wallet ({} via {})",
|
||||
config.network,
|
||||
config.ark_server
|
||||
);
|
||||
// Persist the effective config so the settings UI shows what the
|
||||
// wallet was actually created with.
|
||||
let _ = save_config(data_dir, &config).await;
|
||||
}
|
||||
Err(e) => tracing::debug!("barkd wallet auto-create skipped: {e}"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Total spendable Ark sats, soft-failing to 0 when the sidecar is not
|
||||
/// installed or unreachable so unified balances still render.
|
||||
pub async fn spendable_sats_or_zero(data_dir: &Path) -> u64 {
|
||||
match ArkClient::from_node(data_dir).await {
|
||||
Ok(client) => client.spendable_sats().await.unwrap_or(0),
|
||||
Err(_) => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Map barkd movements into unified [`EcashTransaction`] history entries
|
||||
/// (kind = "ark"). Best-effort: empty on any error, never blocks history.
|
||||
pub async fn load_ark_txs(data_dir: &Path) -> Vec<crate::wallet::ecash::EcashTransaction> {
|
||||
let client = match ArkClient::from_node(data_dir).await {
|
||||
Ok(c) => c,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
let movements = match client.movements().await {
|
||||
Ok(m) => m,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
movements.iter().filter_map(movement_to_tx).collect()
|
||||
}
|
||||
|
||||
/// Convert one barkd `Movement` into an [`EcashTransaction`]. `None` for
|
||||
/// zero-delta movements (e.g. internal refreshes) so history stays meaningful.
|
||||
fn movement_to_tx(m: &serde_json::Value) -> Option<crate::wallet::ecash::EcashTransaction> {
|
||||
use crate::wallet::ecash::{EcashTransaction, TransactionType};
|
||||
|
||||
let delta = m.get("effective_balance_sat").and_then(|v| v.as_i64())?;
|
||||
if delta == 0 {
|
||||
return None;
|
||||
}
|
||||
let tx_type = if delta < 0 {
|
||||
TransactionType::Send
|
||||
} else {
|
||||
TransactionType::Receive
|
||||
};
|
||||
// `time` holds created/updated/completed; prefer the completion time.
|
||||
let timestamp = m
|
||||
.get("time")
|
||||
.and_then(|t| {
|
||||
t.get("completed_at")
|
||||
.or_else(|| t.get("updated_at"))
|
||||
.or_else(|| t.get("created_at"))
|
||||
})
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
// Describe via the recipient list (send) or receive source when present.
|
||||
let peer = m
|
||||
.get("sent_to")
|
||||
.or_else(|| m.get("received_on"))
|
||||
.and_then(|v| v.as_array())
|
||||
.and_then(|a| a.first())
|
||||
.and_then(|d| {
|
||||
d.get("destination")
|
||||
.or_else(|| d.get("address"))
|
||||
.or_else(|| d.get("invoice"))
|
||||
.and_then(|v| v.as_str())
|
||||
})
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let subsystem = m
|
||||
.get("subsystem")
|
||||
.map(|s| match s {
|
||||
serde_json::Value::String(v) => v.clone(),
|
||||
other => other
|
||||
.as_object()
|
||||
.and_then(|o| o.keys().next().cloned())
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let description = if delta < 0 {
|
||||
format!("Sent via Ark{}", suffix(&subsystem))
|
||||
} else {
|
||||
format!("Received via Ark{}", suffix(&subsystem))
|
||||
};
|
||||
Some(EcashTransaction {
|
||||
id: format!("ark-{}", m.get("id").and_then(|v| v.as_u64()).unwrap_or(0)),
|
||||
tx_type,
|
||||
amount_sats: delta.unsigned_abs(),
|
||||
timestamp,
|
||||
description,
|
||||
mint_url: String::new(),
|
||||
peer,
|
||||
kind: "ark".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn suffix(subsystem: &str) -> String {
|
||||
if subsystem.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" ({subsystem})")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn token_encoding_matches_barkd_format() {
|
||||
// barkd token = base64url-nopad(0x00 || secret); 33 bytes -> 44 chars.
|
||||
let token = secret_hex_to_token(&"ab".repeat(32)).unwrap();
|
||||
assert_eq!(token.len(), 44);
|
||||
let bytes = URL_SAFE_NO_PAD.decode(&token).unwrap();
|
||||
assert_eq!(bytes.len(), 33);
|
||||
assert_eq!(bytes[0], 0);
|
||||
assert_eq!(&bytes[1..], &[0xabu8; 32]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_rejects_bad_secret() {
|
||||
assert!(secret_hex_to_token("deadbeef").is_err(), "too short");
|
||||
assert!(secret_hex_to_token(&"zz".repeat(32)).is_err(), "not hex");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn movement_maps_to_history_entry() {
|
||||
let m = serde_json::json!({
|
||||
"id": 7,
|
||||
"effective_balance_sat": -1500,
|
||||
"time": { "completed_at": "2026-07-14T12:00:00Z" },
|
||||
"sent_to": [{ "destination": "tark1abc" }],
|
||||
"subsystem": "arkoor",
|
||||
});
|
||||
let tx = movement_to_tx(&m).expect("mapped");
|
||||
assert_eq!(tx.amount_sats, 1500);
|
||||
assert_eq!(tx.kind, "ark");
|
||||
assert_eq!(tx.peer, "tark1abc");
|
||||
assert!(matches!(
|
||||
tx.tx_type,
|
||||
crate::wallet::ecash::TransactionType::Send
|
||||
));
|
||||
|
||||
// Zero-delta refresh movements are dropped.
|
||||
let refresh = serde_json::json!({ "id": 8, "effective_balance_sat": 0 });
|
||||
assert!(movement_to_tx(&refresh).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
//! Blind Diffie-Hellman Key Exchange (BDHKE) for Cashu ecash.
|
||||
//!
|
||||
//! Implements NUT-00 cryptographic operations:
|
||||
//! - hash_to_curve: deterministic point derivation from secret
|
||||
//! - blind: create blinded message for mint signing
|
||||
//! - unblind: remove blinding factor from mint signature
|
||||
//! - verify: verify unblinded signature against mint pubkey
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use bitcoin::secp256k1::{PublicKey, Scalar, Secp256k1, SecretKey};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// Domain separator for hash_to_curve per NUT-00 spec.
|
||||
const DOMAIN_SEPARATOR: &[u8] = b"Secp256k1_HashToCurve_Cashu_";
|
||||
|
||||
/// Hash a message to a secp256k1 curve point (NUT-00).
|
||||
///
|
||||
/// Iteratively hashes `sha256(sha256(domain_separator || msg) || counter)` until
|
||||
/// the result is a valid x-coordinate on secp256k1. Prepends 0x02 to try as
|
||||
/// a compressed public key.
|
||||
pub fn hash_to_curve(message: &[u8]) -> Result<PublicKey> {
|
||||
let msg_hash = {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(DOMAIN_SEPARATOR);
|
||||
hasher.update(message);
|
||||
hasher.finalize()
|
||||
};
|
||||
|
||||
for counter in 0u32..65536 {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(msg_hash);
|
||||
hasher.update(counter.to_le_bytes());
|
||||
let hash = hasher.finalize();
|
||||
|
||||
// Try to construct a point: 0x02 || hash (compressed even-y format)
|
||||
let mut point_bytes = [0u8; 33];
|
||||
point_bytes[0] = 0x02;
|
||||
point_bytes[1..].copy_from_slice(&hash);
|
||||
|
||||
if let Ok(pk) = PublicKey::from_slice(&point_bytes) {
|
||||
return Ok(pk);
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow::anyhow!(
|
||||
"hash_to_curve: no valid point found after 65536 iterations"
|
||||
))
|
||||
}
|
||||
|
||||
/// Blinded message output from the client.
|
||||
pub struct BlindedMessage {
|
||||
/// The blinded point B_ = Y + r*G
|
||||
pub b_prime: PublicKey,
|
||||
/// The blinding factor (kept secret by client)
|
||||
pub r: SecretKey,
|
||||
/// The original secret
|
||||
pub secret: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Create a blinded message for the mint to sign.
|
||||
///
|
||||
/// Given a secret, computes Y = hash_to_curve(secret), picks random r,
|
||||
/// and returns B_ = Y + r*G along with the blinding factor r.
|
||||
pub fn blind_message(secret: &[u8], blinding_factor: &SecretKey) -> Result<BlindedMessage> {
|
||||
let secp = Secp256k1::new();
|
||||
|
||||
// Y = hash_to_curve(secret)
|
||||
let y = hash_to_curve(secret)?;
|
||||
|
||||
// r*G
|
||||
let r_pub = PublicKey::from_secret_key(&secp, blinding_factor);
|
||||
|
||||
// B_ = Y + r*G
|
||||
let b_prime = PublicKey::combine_keys(&[&y, &r_pub])
|
||||
.context("Failed to compute blinded message B_ = Y + r*G")?;
|
||||
|
||||
Ok(BlindedMessage {
|
||||
b_prime,
|
||||
r: *blinding_factor,
|
||||
secret: secret.to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Unblind a mint's blind signature to get the real signature.
|
||||
///
|
||||
/// Given C_ (blind signature from mint), r (our blinding factor), and K (mint's pubkey):
|
||||
/// C = C_ - r*K
|
||||
pub fn unblind_signature(
|
||||
c_prime: &PublicKey,
|
||||
r: &SecretKey,
|
||||
mint_pubkey: &PublicKey,
|
||||
) -> Result<PublicKey> {
|
||||
let secp = Secp256k1::new();
|
||||
|
||||
// Compute r*K
|
||||
let r_scalar =
|
||||
Scalar::from_be_bytes(r.secret_bytes()).expect("valid secret key is valid scalar");
|
||||
let r_times_k = mint_pubkey
|
||||
.mul_tweak(&secp, &r_scalar)
|
||||
.context("Failed to compute r*K")?;
|
||||
|
||||
// Negate to get -(r*K)
|
||||
let neg_r_times_k = r_times_k.negate(&secp);
|
||||
|
||||
// C = C_ + (-(r*K)) = C_ - r*K
|
||||
let c = PublicKey::combine_keys(&[c_prime, &neg_r_times_k])
|
||||
.context("Failed to compute C = C_ - r*K")?;
|
||||
|
||||
Ok(c)
|
||||
}
|
||||
|
||||
/// Verify that a proof (secret, C) is valid against a mint's public key K.
|
||||
///
|
||||
/// Checks: C == k * hash_to_curve(secret) — but since we don't have k (the mint's
|
||||
/// private key), we verify by checking that the DLEQ proof is valid, or by
|
||||
/// attempting to swap the token at the mint. This function provides a basic
|
||||
/// structural check that the proof components are well-formed.
|
||||
pub fn verify_proof_structure(secret: &[u8], c: &PublicKey) -> Result<bool> {
|
||||
// Verify that hash_to_curve(secret) produces a valid point
|
||||
let _y = hash_to_curve(secret)?;
|
||||
// Verify C is a valid public key (already guaranteed by type, but check non-identity)
|
||||
let c_bytes = c.serialize();
|
||||
if c_bytes.iter().all(|&b| b == 0) {
|
||||
return Ok(false);
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Construct the secret string for a Cashu proof.
|
||||
/// NUT-10 defines secret as a JSON array: ["P2PK", {nonce, data, tags}]
|
||||
/// For basic (non-P2PK) proofs, the secret is just a random hex string.
|
||||
pub fn generate_secret() -> Vec<u8> {
|
||||
let random_bytes: [u8; 32] = rand::random();
|
||||
hex::encode(random_bytes).into_bytes()
|
||||
}
|
||||
|
||||
/// Generate a random blinding factor.
|
||||
pub fn random_blinding_factor() -> SecretKey {
|
||||
let mut rng = rand::thread_rng();
|
||||
SecretKey::new(&mut rng)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_hash_to_curve_deterministic() {
|
||||
let msg = b"test_message";
|
||||
let p1 = hash_to_curve(msg).unwrap();
|
||||
let p2 = hash_to_curve(msg).unwrap();
|
||||
assert_eq!(p1, p2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hash_to_curve_different_messages() {
|
||||
let p1 = hash_to_curve(b"message_a").unwrap();
|
||||
let p2 = hash_to_curve(b"message_b").unwrap();
|
||||
assert_ne!(p1, p2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blind_unblind_roundtrip() {
|
||||
let secp = Secp256k1::new();
|
||||
let secret = b"test_secret";
|
||||
let r = random_blinding_factor();
|
||||
|
||||
// Simulate mint: k is mint's private key, K = k*G is public key
|
||||
let k = SecretKey::new(&mut rand::thread_rng());
|
||||
let k_pub = PublicKey::from_secret_key(&secp, &k);
|
||||
|
||||
// Client blinds
|
||||
let blinded = blind_message(secret, &r).unwrap();
|
||||
|
||||
// Mint signs: C_ = k * B_
|
||||
let k_scalar = Scalar::from_be_bytes(k.secret_bytes()).unwrap();
|
||||
let c_prime = blinded.b_prime.mul_tweak(&secp, &k_scalar).unwrap();
|
||||
|
||||
// Client unblinds: C = C_ - r*K
|
||||
let c = unblind_signature(&c_prime, &r, &k_pub).unwrap();
|
||||
|
||||
// Verify: C should equal k * hash_to_curve(secret)
|
||||
let y = hash_to_curve(secret).unwrap();
|
||||
let expected_c = y.mul_tweak(&secp, &k_scalar).unwrap();
|
||||
assert_eq!(c, expected_c);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_secret_length() {
|
||||
let secret = generate_secret();
|
||||
// 32 bytes hex-encoded = 64 chars
|
||||
assert_eq!(secret.len(), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_secret_unique() {
|
||||
let s1 = generate_secret();
|
||||
let s2 = generate_secret();
|
||||
assert_ne!(s1, s2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_proof_structure_valid() {
|
||||
let secret = generate_secret();
|
||||
let secp = Secp256k1::new();
|
||||
let k = SecretKey::new(&mut rand::thread_rng());
|
||||
let y = hash_to_curve(&secret).unwrap();
|
||||
let k_scalar = Scalar::from_be_bytes(k.secret_bytes()).unwrap();
|
||||
let c = y.mul_tweak(&secp, &k_scalar).unwrap();
|
||||
|
||||
assert!(verify_proof_structure(&secret, &c).unwrap());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
//! Cashu token format (NUT-00) — serialization and deserialization.
|
||||
//!
|
||||
//! Emits the cashuA (V3) token format:
|
||||
//! cashuA<base64url_encoded_json>
|
||||
//!
|
||||
//! Token JSON structure:
|
||||
//! {
|
||||
//! "token": [{ "mint": "<url>", "proofs": [{ "amount": u64, "id": "<keyset>", "secret": "<str>", "C": "<hex>" }] }],
|
||||
//! "memo": "<optional>"
|
||||
//! }
|
||||
//!
|
||||
//! Also accepts (decode-only) the cashuB (V4) CBOR format many wallets emit
|
||||
//! by default now:
|
||||
//! cashuB<base64url_encoded_cbor>
|
||||
//! CBOR map keys are the spec's single-letter names (t/i/p/a/s/c/m/u/d/w),
|
||||
//! not the JSON names above. `i` (keyset id) and `c` (signature) are raw
|
||||
//! bytes on the wire; we hex-encode them into `Proof` to match the V3
|
||||
//! convention so the rest of the wallet doesn't need to know which version
|
||||
//! a token arrived in.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use bitcoin::secp256k1::PublicKey;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Prefix for V3 (JSON) tokens.
|
||||
const CASHU_A_PREFIX: &str = "cashuA";
|
||||
/// Prefix for V4 (CBOR) tokens.
|
||||
const CASHU_B_PREFIX: &str = "cashuB";
|
||||
|
||||
/// Raw CBOR shape of a V4 proof — field names are the spec's map keys.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ProofV4 {
|
||||
a: u64,
|
||||
s: String,
|
||||
#[serde(with = "serde_bytes")]
|
||||
c: Vec<u8>,
|
||||
// DLEQ proof ("d") and witness ("w") aren't verified or stored by this
|
||||
// wallet; accept and discard them rather than fail on the field.
|
||||
}
|
||||
|
||||
/// Raw CBOR shape of a V4 token entry (one keyset's worth of proofs).
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TokenEntryV4 {
|
||||
#[serde(with = "serde_bytes")]
|
||||
i: Vec<u8>,
|
||||
p: Vec<ProofV4>,
|
||||
}
|
||||
|
||||
/// Raw CBOR shape of a full V4 token — single mint per token, unlike V3.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TokenV4 {
|
||||
t: Vec<TokenEntryV4>,
|
||||
m: String,
|
||||
#[serde(default)]
|
||||
u: Option<String>,
|
||||
#[serde(default, rename = "d")]
|
||||
memo: Option<String>,
|
||||
}
|
||||
|
||||
/// A single Cashu proof (a signed token for a specific denomination).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Proof {
|
||||
/// Denomination in the mint's unit (sats).
|
||||
pub amount: u64,
|
||||
/// Keyset ID (hex string, e.g. "009a1f293253e41e").
|
||||
pub id: String,
|
||||
/// The secret (random hex string or NUT-10 structured secret).
|
||||
pub secret: String,
|
||||
/// The unblinded signature C as hex-encoded compressed public key.
|
||||
#[serde(rename = "C")]
|
||||
pub c: String,
|
||||
}
|
||||
|
||||
impl Proof {
|
||||
/// Parse the C field as a secp256k1 PublicKey.
|
||||
pub fn c_as_pubkey(&self) -> Result<PublicKey> {
|
||||
let bytes = hex::decode(&self.c).context("Invalid hex in proof C field")?;
|
||||
PublicKey::from_slice(&bytes).context("Invalid public key in proof C field")
|
||||
}
|
||||
}
|
||||
|
||||
/// A group of proofs from a single mint.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TokenEntry {
|
||||
/// Mint URL.
|
||||
pub mint: String,
|
||||
/// Proofs from this mint.
|
||||
pub proofs: Vec<Proof>,
|
||||
}
|
||||
|
||||
/// The full cashuA token envelope.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CashuToken {
|
||||
/// Token entries grouped by mint.
|
||||
pub token: Vec<TokenEntry>,
|
||||
/// Optional memo.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub memo: Option<String>,
|
||||
/// Optional unit (e.g. "sat").
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub unit: Option<String>,
|
||||
}
|
||||
|
||||
impl CashuToken {
|
||||
/// Create a new token with proofs from a single mint.
|
||||
pub fn new(mint_url: &str, proofs: Vec<Proof>) -> Self {
|
||||
Self {
|
||||
token: vec![TokenEntry {
|
||||
mint: mint_url.to_string(),
|
||||
proofs,
|
||||
}],
|
||||
memo: None,
|
||||
unit: Some("sat".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Total value of all proofs across all mints.
|
||||
pub fn total_amount(&self) -> u64 {
|
||||
self.token
|
||||
.iter()
|
||||
.flat_map(|e| &e.proofs)
|
||||
.map(|p| p.amount)
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// All proofs across all mint entries.
|
||||
pub fn all_proofs(&self) -> Vec<&Proof> {
|
||||
self.token.iter().flat_map(|e| &e.proofs).collect()
|
||||
}
|
||||
|
||||
/// All unique mint URLs in this token.
|
||||
pub fn mint_urls(&self) -> Vec<&str> {
|
||||
self.token.iter().map(|e| e.mint.as_str()).collect()
|
||||
}
|
||||
|
||||
/// Encode as a cashuA token string.
|
||||
pub fn serialize(&self) -> Result<String> {
|
||||
let json = serde_json::to_string(self).context("Failed to serialize token JSON")?;
|
||||
use base64::Engine;
|
||||
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json.as_bytes());
|
||||
Ok(format!("{}{}", CASHU_A_PREFIX, encoded))
|
||||
}
|
||||
|
||||
/// Decode a cashuA (V3 JSON) or cashuB (V4 CBOR) token string.
|
||||
pub fn deserialize(token_str: &str) -> Result<Self> {
|
||||
if let Some(payload) = token_str.strip_prefix(CASHU_B_PREFIX) {
|
||||
return Self::deserialize_v4(payload);
|
||||
}
|
||||
|
||||
let payload = token_str.strip_prefix(CASHU_A_PREFIX).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Token must start with '{}' or '{}'",
|
||||
CASHU_A_PREFIX,
|
||||
CASHU_B_PREFIX
|
||||
)
|
||||
})?;
|
||||
|
||||
let decoded = decode_token_base64(payload).context("Invalid base64 in cashuA token")?;
|
||||
let json_str = String::from_utf8(decoded).context("Invalid UTF-8 in decoded token")?;
|
||||
let token: CashuToken =
|
||||
serde_json::from_str(&json_str).context("Invalid JSON in cashuA token")?;
|
||||
|
||||
token.validate()?;
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// Decode a cashuB (V4 CBOR) token payload (prefix already stripped).
|
||||
fn deserialize_v4(payload: &str) -> Result<Self> {
|
||||
let decoded = decode_token_base64(payload).context("Invalid base64 in cashuB token")?;
|
||||
|
||||
let v4: TokenV4 =
|
||||
ciborium::from_reader(decoded.as_slice()).context("Invalid CBOR in cashuB token")?;
|
||||
|
||||
let proofs =
|
||||
v4.t.into_iter()
|
||||
.flat_map(|entry| {
|
||||
let keyset_id = hex::encode(&entry.i);
|
||||
entry.p.into_iter().map(move |p| Proof {
|
||||
amount: p.a,
|
||||
id: keyset_id.clone(),
|
||||
secret: p.s,
|
||||
c: hex::encode(&p.c),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let token = CashuToken {
|
||||
token: vec![TokenEntry { mint: v4.m, proofs }],
|
||||
memo: v4.memo,
|
||||
unit: v4.u,
|
||||
};
|
||||
token.validate()?;
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// Structural validation shared by both token versions.
|
||||
fn validate(&self) -> Result<()> {
|
||||
if self.token.is_empty() {
|
||||
anyhow::bail!("Token has no entries");
|
||||
}
|
||||
for entry in &self.token {
|
||||
if entry.mint.is_empty() {
|
||||
anyhow::bail!("Token entry has empty mint URL");
|
||||
}
|
||||
if entry.proofs.is_empty() {
|
||||
anyhow::bail!("Token entry has no proofs");
|
||||
}
|
||||
for proof in &entry.proofs {
|
||||
if proof.amount == 0 {
|
||||
anyhow::bail!("Proof has zero amount");
|
||||
}
|
||||
if proof.secret.is_empty() {
|
||||
anyhow::bail!("Proof has empty secret");
|
||||
}
|
||||
if proof.c.is_empty() {
|
||||
anyhow::bail!("Proof has empty C");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode a token's base64 payload, trying URL-safe-no-pad first (the spec
|
||||
/// default) and falling back to other alphabets some implementations use.
|
||||
fn decode_token_base64(payload: &str) -> Result<Vec<u8>, base64::DecodeError> {
|
||||
use base64::Engine;
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(payload)
|
||||
.or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(payload))
|
||||
.or_else(|_| base64::engine::general_purpose::STANDARD.decode(payload))
|
||||
}
|
||||
|
||||
/// Keyset info returned by a mint.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct KeysetInfo {
|
||||
pub id: String,
|
||||
pub unit: String,
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
/// Mint keyset: maps denomination amounts to public keys.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MintKeyset {
|
||||
pub id: String,
|
||||
/// Map of amount (as string) to hex-encoded public key.
|
||||
pub keys: std::collections::HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl MintKeyset {
|
||||
/// Get the mint's public key for a given denomination amount.
|
||||
pub fn key_for_amount(&self, amount: u64) -> Result<PublicKey> {
|
||||
let amount_str = amount.to_string();
|
||||
let hex_key = self
|
||||
.keys
|
||||
.get(&amount_str)
|
||||
.ok_or_else(|| anyhow::anyhow!("No key for amount {} in keyset {}", amount, self.id))?;
|
||||
let bytes = hex::decode(hex_key).context("Invalid hex in mint pubkey")?;
|
||||
PublicKey::from_slice(&bytes).context("Invalid pubkey in mint keyset")
|
||||
}
|
||||
}
|
||||
|
||||
/// Blinded message sent to the mint during mint/swap.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BlindedMessageRequest {
|
||||
/// Amount for this output.
|
||||
pub amount: u64,
|
||||
/// Keyset ID to use.
|
||||
pub id: String,
|
||||
/// Blinded secret B_ as hex-encoded compressed pubkey.
|
||||
#[serde(rename = "B_")]
|
||||
pub b_prime: String,
|
||||
}
|
||||
|
||||
/// Blind signature returned by the mint.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BlindSignature {
|
||||
/// Amount signed.
|
||||
pub amount: u64,
|
||||
/// Keyset ID.
|
||||
pub id: String,
|
||||
/// Blind signature C_ as hex-encoded compressed pubkey.
|
||||
#[serde(rename = "C_")]
|
||||
pub c_prime: String,
|
||||
}
|
||||
|
||||
impl BlindSignature {
|
||||
/// Parse C_ as a secp256k1 PublicKey.
|
||||
pub fn c_prime_as_pubkey(&self) -> Result<PublicKey> {
|
||||
let bytes = hex::decode(&self.c_prime).context("Invalid hex in blind signature C_")?;
|
||||
PublicKey::from_slice(&bytes).context("Invalid pubkey in blind signature C_")
|
||||
}
|
||||
}
|
||||
|
||||
/// Split a target amount into powers of 2 (Cashu denomination scheme).
|
||||
/// E.g., 13 -> [1, 4, 8]
|
||||
pub fn amount_to_denominations(mut amount: u64) -> Vec<u64> {
|
||||
let mut denoms = Vec::new();
|
||||
let mut bit = 0;
|
||||
while amount > 0 {
|
||||
if amount & 1 == 1 {
|
||||
denoms.push(1u64 << bit);
|
||||
}
|
||||
amount >>= 1;
|
||||
bit += 1;
|
||||
}
|
||||
denoms
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_serialize_deserialize_roundtrip() {
|
||||
let token = CashuToken {
|
||||
token: vec![TokenEntry {
|
||||
mint: "http://127.0.0.1:8175".to_string(),
|
||||
proofs: vec![Proof {
|
||||
amount: 8,
|
||||
id: "009a1f293253e41e".to_string(),
|
||||
secret: "abcdef1234567890".to_string(),
|
||||
c: "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d94ec4da0e7f6c2b4e24"
|
||||
.to_string(),
|
||||
}],
|
||||
}],
|
||||
memo: Some("test token".to_string()),
|
||||
unit: Some("sat".to_string()),
|
||||
};
|
||||
|
||||
let encoded = token.serialize().unwrap();
|
||||
assert!(encoded.starts_with("cashuA"));
|
||||
|
||||
let decoded = CashuToken::deserialize(&encoded).unwrap();
|
||||
assert_eq!(decoded.total_amount(), 8);
|
||||
assert_eq!(decoded.token[0].mint, "http://127.0.0.1:8175");
|
||||
assert_eq!(decoded.token[0].proofs[0].secret, "abcdef1234567890");
|
||||
assert_eq!(decoded.memo, Some("test token".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_total_amount_multi_proof() {
|
||||
let token = CashuToken {
|
||||
token: vec![TokenEntry {
|
||||
mint: "http://mint".to_string(),
|
||||
proofs: vec![
|
||||
Proof {
|
||||
amount: 1,
|
||||
id: "id1".into(),
|
||||
secret: "s1".into(),
|
||||
c: "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d94ec4da0e7f6c2b4e24"
|
||||
.into(),
|
||||
},
|
||||
Proof {
|
||||
amount: 4,
|
||||
id: "id1".into(),
|
||||
secret: "s2".into(),
|
||||
c: "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d94ec4da0e7f6c2b4e24"
|
||||
.into(),
|
||||
},
|
||||
Proof {
|
||||
amount: 8,
|
||||
id: "id1".into(),
|
||||
secret: "s3".into(),
|
||||
c: "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d94ec4da0e7f6c2b4e24"
|
||||
.into(),
|
||||
},
|
||||
],
|
||||
}],
|
||||
memo: None,
|
||||
unit: None,
|
||||
};
|
||||
assert_eq!(token.total_amount(), 13);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_rejects_empty_token() {
|
||||
let bad = CashuToken {
|
||||
token: vec![],
|
||||
memo: None,
|
||||
unit: None,
|
||||
};
|
||||
let encoded = bad.serialize().unwrap();
|
||||
let result = CashuToken::deserialize(&encoded);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_rejects_unknown_prefix() {
|
||||
let result = CashuToken::deserialize("cashuZabc123");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_rejects_malformed_v4_cbor() {
|
||||
let result = CashuToken::deserialize("cashuBabc123");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_v4_cbor_token() {
|
||||
// Hand-built (not via our own encoder) to verify we actually match
|
||||
// the NUT-00 V4 wire format: single-letter CBOR map keys, raw-byte
|
||||
// keyset id ("i") and signature ("c").
|
||||
use base64::Engine;
|
||||
use ciborium::value::Value;
|
||||
|
||||
let keyset_id = vec![0x00u8, 0x9a, 0x1f, 0x29, 0x32, 0x53, 0xe4, 0x1e];
|
||||
let sig = hex::decode("02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d94ec4da0e7f6c2b4e24")
|
||||
.unwrap();
|
||||
|
||||
let proof = Value::Map(vec![
|
||||
(Value::from("a"), Value::from(8u64)),
|
||||
(Value::from("s"), Value::from("abcdef1234567890")),
|
||||
(Value::from("c"), Value::from(sig.clone())),
|
||||
]);
|
||||
let entry = Value::Map(vec![
|
||||
(Value::from("i"), Value::from(keyset_id.clone())),
|
||||
(Value::from("p"), Value::Array(vec![proof])),
|
||||
]);
|
||||
let token = Value::Map(vec![
|
||||
(Value::from("t"), Value::Array(vec![entry])),
|
||||
(Value::from("m"), Value::from("http://127.0.0.1:8175")),
|
||||
(Value::from("u"), Value::from("sat")),
|
||||
(Value::from("d"), Value::from("test token")),
|
||||
]);
|
||||
|
||||
let mut buf = Vec::new();
|
||||
ciborium::into_writer(&token, &mut buf).unwrap();
|
||||
let encoded = format!(
|
||||
"cashuB{}",
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&buf)
|
||||
);
|
||||
|
||||
let decoded = CashuToken::deserialize(&encoded).unwrap();
|
||||
assert_eq!(decoded.total_amount(), 8);
|
||||
assert_eq!(decoded.token[0].mint, "http://127.0.0.1:8175");
|
||||
assert_eq!(decoded.token[0].proofs[0].secret, "abcdef1234567890");
|
||||
assert_eq!(decoded.token[0].proofs[0].id, hex::encode(&keyset_id));
|
||||
assert_eq!(decoded.token[0].proofs[0].c, hex::encode(&sig));
|
||||
assert_eq!(decoded.memo, Some("test token".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_amount_to_denominations() {
|
||||
assert_eq!(amount_to_denominations(0), Vec::<u64>::new());
|
||||
assert_eq!(amount_to_denominations(1), vec![1]);
|
||||
assert_eq!(amount_to_denominations(13), vec![1, 4, 8]);
|
||||
assert_eq!(amount_to_denominations(21), vec![1, 4, 16]);
|
||||
assert_eq!(amount_to_denominations(64), vec![64]);
|
||||
assert_eq!(
|
||||
amount_to_denominations(255),
|
||||
vec![1, 2, 4, 8, 16, 32, 64, 128]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_amount_to_denominations_large() {
|
||||
let denoms = amount_to_denominations(1_000_000);
|
||||
let sum: u64 = denoms.iter().sum();
|
||||
assert_eq!(sum, 1_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_proof_c_as_pubkey() {
|
||||
let proof = Proof {
|
||||
amount: 1,
|
||||
id: "test".into(),
|
||||
secret: "s".into(),
|
||||
// Generator point G of secp256k1, compressed form. Always a
|
||||
// valid pubkey, so c_as_pubkey() must succeed.
|
||||
c: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798".to_string(),
|
||||
};
|
||||
assert!(proof.c_as_pubkey().is_ok());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,555 @@
|
||||
//! Thin HTTP bridge to the `fedimint-clientd` sidecar container.
|
||||
//!
|
||||
//! Keeps the heavy, fast-moving Fedimint client SDK OUT of this binary: the
|
||||
//! `fedimint-clientd` daemon (in `apps/fedimint-clientd`) holds the federation
|
||||
//! clients and ecash notes; we just speak its REST API (`/v2/*`, Bearer auth),
|
||||
//! mirroring how [`super::mint_client::MintClient`] speaks the Cashu NUT API.
|
||||
//!
|
||||
//! See `docs/dual-ecash-design.md`. Endpoint/JSON shapes target fedimint-clientd
|
||||
//! v0.3.x and must be pinned to the vendored image tag.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use tokio::fs;
|
||||
use tracing::debug;
|
||||
|
||||
const CLIENTD_TIMEOUT_SECS: u64 = 15;
|
||||
const CLIENTD_HEAVY_TIMEOUT_SECS: u64 = 60;
|
||||
|
||||
/// Default host port the `fedimint-clientd` container is mapped to (its own
|
||||
/// default 8080 collides with LND REST, so the manifest maps it to 8178).
|
||||
const DEFAULT_CLIENTD_URL: &str = "http://127.0.0.1:8178";
|
||||
|
||||
/// Federation joined out-of-the-box on every node. The fmcd container also
|
||||
/// auto-joins this at boot (`FMCD_INVITE_CODE` in the manifest); keep in sync.
|
||||
///
|
||||
/// The preferred default federation (guardian on .116, iroh transport).
|
||||
/// Validated: fmcd 0.8.2 joins it (federation_id 2debd071…73b76884). iroh does
|
||||
/// NAT traversal, so it's reachable fleet-wide — the right fleet default.
|
||||
/// CAVEAT: iroh is experimental and the connection can be flaky (esp. NAT
|
||||
/// hairpin when fmcd runs on .116 itself reaching .116's own WAN IP); validate
|
||||
/// reliability from a separate node. ensure_default_federation is best-effort.
|
||||
/// See docs/dual-ecash-design.md.
|
||||
pub const DEFAULT_FEDERATION_INVITE: &str = "fed11qgqyj3mfwfhksw309uuxywtxxfjrjc35xuexverpxdsnxcnrxucxvenzveskgc3kvvun2c34xp3k2ep38yunzdpexcekxe3hvd3rvvmx8pnrvdenx5mnzvtzqqqjqt0t6pc3s5z0ynqjw9s4njf6svwgu59kweawc0vvrddcjeemw6yyn4pcdp";
|
||||
|
||||
/// One joined federation, persisted locally so the list survives clientd being
|
||||
/// temporarily down. Balances are always read live from clientd.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JoinedFederation {
|
||||
pub federation_id: String,
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub struct FederationRegistry {
|
||||
pub federations: Vec<JoinedFederation>,
|
||||
}
|
||||
|
||||
const REGISTRY_FILE: &str = "wallet/fedimint_federations.json";
|
||||
|
||||
/// Shared HTTP-Basic password between the fmcd container and this bridge. The
|
||||
/// fedimint-clientd manifest generates it via `generated_secrets: [fmcd-password]`
|
||||
/// and injects it through `secret_env`; the bridge reads the same file in
|
||||
/// `from_node`. (Generation lives in `container::secrets`, not here — it's a
|
||||
/// generic, manifest-declared concern, not fedimint-specific.)
|
||||
const FMCD_PASSWORD_SECRET: &str = "fmcd-password";
|
||||
|
||||
pub async fn load_registry(data_dir: &Path) -> Result<FederationRegistry> {
|
||||
let path = data_dir.join(REGISTRY_FILE);
|
||||
if !path.exists() {
|
||||
return Ok(FederationRegistry::default());
|
||||
}
|
||||
let content = fs::read_to_string(&path)
|
||||
.await
|
||||
.context("Failed to read fedimint federation registry")?;
|
||||
Ok(serde_json::from_str(&content).unwrap_or_default())
|
||||
}
|
||||
|
||||
pub async fn save_registry(data_dir: &Path, reg: &FederationRegistry) -> Result<()> {
|
||||
let dir = data_dir.join("wallet");
|
||||
fs::create_dir_all(&dir)
|
||||
.await
|
||||
.context("Failed to create wallet dir")?;
|
||||
let content = serde_json::to_string_pretty(reg).context("Failed to serialize registry")?;
|
||||
fs::write(data_dir.join(REGISTRY_FILE), content)
|
||||
.await
|
||||
.context("Failed to write fedimint federation registry")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Local Fedimint transaction log. fmcd has no per-node history API, so we
|
||||
/// record each redeem/spend ourselves and merge it with the Cashu history in
|
||||
/// `wallet.ecash-history` — otherwise a Fedimint receive shows nowhere.
|
||||
const FEDIMINT_TX_FILE: &str = "wallet/fedimint_transactions.json";
|
||||
|
||||
/// Load the local Fedimint transaction log (newest entries last). Empty on any
|
||||
/// error — history is best-effort and must never block a wallet operation.
|
||||
pub async fn load_fedimint_txs(data_dir: &Path) -> Vec<crate::wallet::ecash::EcashTransaction> {
|
||||
match fs::read_to_string(data_dir.join(FEDIMINT_TX_FILE)).await {
|
||||
Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a Fedimint transaction to the local log so it appears in unified
|
||||
/// history with meaningful data. Best-effort: write failures are logged, not
|
||||
/// propagated, so they never fail the redeem/spend that produced the funds.
|
||||
pub async fn record_fedimint_tx(
|
||||
data_dir: &Path,
|
||||
tx_type: crate::wallet::ecash::TransactionType,
|
||||
amount_sats: u64,
|
||||
federation_id: &str,
|
||||
description: &str,
|
||||
) {
|
||||
let mut txs = load_fedimint_txs(data_dir).await;
|
||||
txs.push(crate::wallet::ecash::EcashTransaction {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
tx_type,
|
||||
amount_sats,
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
description: description.to_string(),
|
||||
// Cashu uses mint_url; for Fedimint we record the federation id in `peer`
|
||||
// so the UI can show which federation the funds moved through.
|
||||
mint_url: String::new(),
|
||||
peer: federation_id.to_string(),
|
||||
kind: "fedimint".to_string(),
|
||||
});
|
||||
// Cap the log so it can't grow unbounded.
|
||||
let len = txs.len();
|
||||
if len > 500 {
|
||||
txs.drain(0..len - 500);
|
||||
}
|
||||
if let Err(e) = fs::create_dir_all(data_dir.join("wallet")).await {
|
||||
tracing::warn!("fedimint tx log: could not create wallet dir: {e}");
|
||||
return;
|
||||
}
|
||||
match serde_json::to_string_pretty(&txs) {
|
||||
Ok(content) => {
|
||||
if let Err(e) = fs::write(data_dir.join(FEDIMINT_TX_FILE), content).await {
|
||||
tracing::warn!("fedimint tx log: write failed: {e}");
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!("fedimint tx log: serialize failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Idempotently ensure the node has joined the default federation and that it
|
||||
/// is tracked in the local registry. Best-effort: silently no-ops if clientd
|
||||
/// isn't installed/running yet. Joining is idempotent on the clientd side.
|
||||
pub async fn ensure_default_federation(data_dir: &Path) -> Result<()> {
|
||||
let client = match FedimintClient::from_node(data_dir).await {
|
||||
Ok(c) => c,
|
||||
Err(_) => return Ok(()), // clientd not configured yet
|
||||
};
|
||||
|
||||
// Fast path: if fmcd already reports a joined federation, do NOT re-issue the
|
||||
// POST /v2/admin/join. That call re-syncs federation config against the
|
||||
// guardians and adds seconds of latency — and ensure_default_federation runs
|
||||
// on every wallet.fedimint-list / spend / reissue, so the join was being paid
|
||||
// on each balance refresh (the "mints take ages to load" report). The cheap
|
||||
// GET /v2/admin/info is enough to confirm membership; just reconcile the local
|
||||
// registry against the live joined set and return.
|
||||
let joined = client.joined_federation_ids().await;
|
||||
if !joined.is_empty() {
|
||||
let mut reg = load_registry(data_dir).await?;
|
||||
let mut changed = false;
|
||||
for id in joined {
|
||||
if !reg.federations.iter().any(|f| f.federation_id == id) {
|
||||
reg.federations.push(JoinedFederation {
|
||||
federation_id: id,
|
||||
name: Some("Archipelago Federation".to_string()),
|
||||
});
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
save_registry(data_dir, ®).await?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Cold start only: nothing joined yet, so join the default federation once.
|
||||
let federation_id = match client.join(DEFAULT_FEDERATION_INVITE).await {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
debug!("default federation autojoin skipped: {e}");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let mut reg = load_registry(data_dir).await?;
|
||||
if !reg
|
||||
.federations
|
||||
.iter()
|
||||
.any(|f| f.federation_id == federation_id)
|
||||
{
|
||||
reg.federations.push(JoinedFederation {
|
||||
federation_id,
|
||||
name: Some("Archipelago Federation".to_string()),
|
||||
});
|
||||
save_registry(data_dir, ®).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Spend `amount_sats` of Fedimint ecash from whichever joined federation can
|
||||
/// cover it, returning the serialized notes (the X-Payment-Token a buyer hands
|
||||
/// the seller) and the federation id that minted them. Federations are tried in
|
||||
/// registry order (default first); only one with sufficient balance is used so
|
||||
/// the resulting notes redeem cleanly on the other side. Errors clearly when no
|
||||
/// federation is joined or none has the balance — the caller falls back to (or
|
||||
/// from) the Cashu path.
|
||||
pub async fn spend_from_any(data_dir: &Path, amount_sats: u64) -> Result<(String, String)> {
|
||||
if amount_sats == 0 {
|
||||
anyhow::bail!("payment amount must be greater than zero");
|
||||
}
|
||||
let _ = ensure_default_federation(data_dir).await;
|
||||
let client = FedimintClient::from_node(data_dir).await?;
|
||||
|
||||
// Same union-of-sources approach as reissue_into_any: the persisted registry
|
||||
// and what fmcd actually reports joined can drift, so consider both.
|
||||
let mut fed_ids: Vec<String> = Vec::new();
|
||||
if let Ok(reg) = load_registry(data_dir).await {
|
||||
for f in reg.federations {
|
||||
if !fed_ids.contains(&f.federation_id) {
|
||||
fed_ids.push(f.federation_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
for id in client.joined_federation_ids().await {
|
||||
if !fed_ids.contains(&id) {
|
||||
fed_ids.push(id);
|
||||
}
|
||||
}
|
||||
if fed_ids.is_empty() {
|
||||
anyhow::bail!("No Fedimint federation joined to spend from");
|
||||
}
|
||||
|
||||
let mut last_err = None;
|
||||
for fed_id in &fed_ids {
|
||||
// Skip federations that can't cover the amount so we don't mint a
|
||||
// partial/failed spend and leave dangling reserved notes.
|
||||
match client.federation_balance_sats(fed_id).await {
|
||||
Ok(bal) if bal >= amount_sats => {}
|
||||
Ok(_) => continue,
|
||||
Err(e) => {
|
||||
last_err = Some(e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
match client.spend(fed_id, amount_sats).await {
|
||||
Ok(notes) => {
|
||||
record_fedimint_tx(
|
||||
data_dir,
|
||||
crate::wallet::ecash::TransactionType::Send,
|
||||
amount_sats,
|
||||
fed_id,
|
||||
"Sent Fedimint ecash",
|
||||
)
|
||||
.await;
|
||||
return Ok((notes, fed_id.clone()));
|
||||
}
|
||||
Err(e) => last_err = Some(e),
|
||||
}
|
||||
}
|
||||
Err(last_err
|
||||
.map(|e| anyhow::anyhow!("Fedimint spend failed across all federations: {e}"))
|
||||
.unwrap_or_else(|| {
|
||||
anyhow::anyhow!("No joined Fedimint federation has {amount_sats} sats available")
|
||||
}))
|
||||
}
|
||||
|
||||
/// Redeem received Fedimint notes into a joined federation. fmcd's reissue is
|
||||
/// per-federation, but a token only validates against the federation that
|
||||
/// minted it, so we try each joined federation (default first) and return the
|
||||
/// first that accepts the notes, along with its id. Errors clearly when the
|
||||
/// fmcd sidecar isn't installed or no federation is joined — the Cashu path is
|
||||
/// handled separately by the caller.
|
||||
pub async fn reissue_into_any(data_dir: &Path, notes: &str) -> Result<(u64, String)> {
|
||||
// Make sure at least the default federation is tracked before we try.
|
||||
let _ = ensure_default_federation(data_dir).await;
|
||||
|
||||
let client = FedimintClient::from_node(data_dir).await?;
|
||||
|
||||
// Build the set of federations to try: the locally-persisted registry PLUS
|
||||
// every federation the fmcd sidecar actually reports joined. The two can
|
||||
// drift (a federation joined directly, before tracking, or a registry that
|
||||
// wasn't written), and a note only validates against the federation that
|
||||
// minted it — so we must try EVERY connected federation before giving up,
|
||||
// or a perfectly valid token is wrongly reported as failed.
|
||||
let mut fed_ids: Vec<String> = Vec::new();
|
||||
if let Ok(reg) = load_registry(data_dir).await {
|
||||
for f in reg.federations {
|
||||
if !fed_ids.contains(&f.federation_id) {
|
||||
fed_ids.push(f.federation_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
for id in client.joined_federation_ids().await {
|
||||
if !fed_ids.contains(&id) {
|
||||
fed_ids.push(id);
|
||||
}
|
||||
}
|
||||
if fed_ids.is_empty() {
|
||||
anyhow::bail!("No Fedimint federation joined to redeem these notes into");
|
||||
}
|
||||
|
||||
let mut already_redeemed = false;
|
||||
let mut last_err = None;
|
||||
for fed_id in &fed_ids {
|
||||
match client.reissue(fed_id, notes).await {
|
||||
Ok(sats) => {
|
||||
// Record the receive so it appears in unified ecash history.
|
||||
record_fedimint_tx(
|
||||
data_dir,
|
||||
crate::wallet::ecash::TransactionType::Receive,
|
||||
sats,
|
||||
fed_id,
|
||||
"Received Fedimint ecash",
|
||||
)
|
||||
.await;
|
||||
return Ok((sats, fed_id.clone()));
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = e.to_string().to_ascii_lowercase();
|
||||
// fmcd reports already-claimed notes as "We already reissued
|
||||
// these notes" (or "already spent"). That means the funds were
|
||||
// already redeemed INTO this node's wallet — they're safe, just
|
||||
// not new — so surface that clearly instead of a raw 500.
|
||||
if msg.contains("already reissued") || msg.contains("already spent") {
|
||||
already_redeemed = true;
|
||||
}
|
||||
last_err = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if already_redeemed {
|
||||
anyhow::bail!(
|
||||
"This ecash was already redeemed into your wallet — the notes are \
|
||||
already claimed, so no new balance was added. Check your Fedimint balance."
|
||||
);
|
||||
}
|
||||
Err(last_err
|
||||
.map(|e| {
|
||||
anyhow::anyhow!(
|
||||
"These notes didn't match any of your {} connected Fedimint \
|
||||
federation(s). You may need to join the federation that issued \
|
||||
them first. (last error: {e})",
|
||||
fed_ids.len()
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| anyhow::anyhow!("Fedimint reissue failed")))
|
||||
}
|
||||
|
||||
/// HTTP client for a `fedimint-clientd` instance.
|
||||
pub struct FedimintClient {
|
||||
base_url: String,
|
||||
password: String,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl FedimintClient {
|
||||
pub fn new(base_url: &str, password: &str) -> Result<Self> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(CLIENTD_HEAVY_TIMEOUT_SECS))
|
||||
.build()
|
||||
.context("Failed to build HTTP client for fedimint-clientd")?;
|
||||
Ok(Self::with_client(base_url, password, client))
|
||||
}
|
||||
|
||||
pub fn with_client(base_url: &str, password: &str, client: reqwest::Client) -> Self {
|
||||
Self {
|
||||
base_url: base_url.trim_end_matches('/').to_string(),
|
||||
password: password.to_string(),
|
||||
client,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve URL + password from env / node secret, with sane defaults.
|
||||
/// URL: `FEDIMINT_CLIENTD_URL` else the default mapped port.
|
||||
/// Password: `FEDIMINT_CLIENTD_PASSWORD` else `<data_dir>/fedimint-clientd/password`.
|
||||
pub async fn from_node(data_dir: &Path) -> Result<Self> {
|
||||
let base_url =
|
||||
std::env::var("FMCD_URL").unwrap_or_else(|_| DEFAULT_CLIENTD_URL.to_string());
|
||||
let password = match std::env::var("FMCD_PASSWORD") {
|
||||
Ok(p) if !p.is_empty() => p,
|
||||
_ => {
|
||||
// The shared secret the fmcd container also reads (manifest
|
||||
// secret_env: fmcd-password, resolved from <data_dir>/secrets).
|
||||
// Legacy <data_dir>/fmcd/password kept as a fallback.
|
||||
let shared = data_dir.join("secrets").join(FMCD_PASSWORD_SECRET);
|
||||
let legacy = data_dir.join("fmcd").join("password");
|
||||
let mut found = None;
|
||||
for candidate in [shared, legacy] {
|
||||
if let Ok(s) = fs::read_to_string(&candidate).await {
|
||||
let s = s.trim().to_string();
|
||||
if !s.is_empty() {
|
||||
found = Some(s);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
found.context(
|
||||
"Fedimint client not configured (no FMCD_PASSWORD and no \
|
||||
fmcd-password secret). Install the Fedimint client app.",
|
||||
)?
|
||||
}
|
||||
};
|
||||
Self::new(&base_url, &password)
|
||||
}
|
||||
|
||||
fn auth(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
// fmcd uses HTTP Basic auth with a fixed username `fmcd`.
|
||||
req.basic_auth("fmcd", Some(&self.password))
|
||||
}
|
||||
|
||||
async fn post(&self, path: &str, body: serde_json::Value) -> Result<serde_json::Value> {
|
||||
let url = format!("{}{}", self.base_url, path);
|
||||
let resp = self
|
||||
.auth(self.client.post(&url))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("fedimint-clientd POST {path} failed (is it running?)"))?;
|
||||
Self::parse(resp, path).await
|
||||
}
|
||||
|
||||
async fn get(&self, path: &str) -> Result<serde_json::Value> {
|
||||
let url = format!("{}{}", self.base_url, path);
|
||||
let resp = self
|
||||
.auth(self.client.get(&url))
|
||||
.timeout(std::time::Duration::from_secs(CLIENTD_TIMEOUT_SECS))
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("fedimint-clientd GET {path} failed (is it running?)"))?;
|
||||
Self::parse(resp, path).await
|
||||
}
|
||||
|
||||
async fn parse(resp: reqwest::Response, path: &str) -> Result<serde_json::Value> {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
if !status.is_success() {
|
||||
anyhow::bail!("fedimint-clientd {path} returned {status}: {text}");
|
||||
}
|
||||
if text.is_empty() {
|
||||
return Ok(serde_json::json!({}));
|
||||
}
|
||||
serde_json::from_str(&text)
|
||||
.with_context(|| format!("fedimint-clientd {path} returned non-JSON: {text}"))
|
||||
}
|
||||
|
||||
/// `GET /v2/admin/info` — per-federation holdings keyed by federationId.
|
||||
pub async fn info(&self) -> Result<serde_json::Value> {
|
||||
self.get("/v2/admin/info").await
|
||||
}
|
||||
|
||||
/// Every federation id the fmcd sidecar currently reports joined, read from
|
||||
/// `/v2/admin/info` (the authoritative live set — the locally-persisted
|
||||
/// registry can drift from it). Returns an empty vec on any error so callers
|
||||
/// can fall back to the registry rather than fail outright.
|
||||
pub async fn joined_federation_ids(&self) -> Vec<String> {
|
||||
match self.info().await {
|
||||
Ok(info) => info
|
||||
.as_object()
|
||||
.map(|m| m.keys().cloned().collect())
|
||||
.unwrap_or_default(),
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /v2/admin/join` — join a federation by invite code; returns its federationId.
|
||||
pub async fn join(&self, invite_code: &str) -> Result<String> {
|
||||
let res = self
|
||||
.post(
|
||||
"/v2/admin/join",
|
||||
serde_json::json!({ "inviteCode": invite_code, "useManualSecret": false }),
|
||||
)
|
||||
.await?;
|
||||
let id = res
|
||||
.get("thisFederationId")
|
||||
.or_else(|| res.get("federationId"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
match id {
|
||||
Some(id) => {
|
||||
debug!("joined fedimint federation {id}");
|
||||
Ok(id)
|
||||
}
|
||||
// Older/newer clientd may return the full info map; fall back to info().
|
||||
None => self.latest_federation_id().await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Total balance across all joined federations, in sats.
|
||||
pub async fn total_balance_sats(&self) -> Result<u64> {
|
||||
let info = self.info().await?;
|
||||
Ok(sum_msat(&info) / 1000)
|
||||
}
|
||||
|
||||
/// Balance of one federation in sats (0 if unknown).
|
||||
pub async fn federation_balance_sats(&self, federation_id: &str) -> Result<u64> {
|
||||
let info = self.info().await?;
|
||||
let msat = info
|
||||
.get(federation_id)
|
||||
.and_then(federation_msat)
|
||||
.unwrap_or(0);
|
||||
Ok(msat / 1000)
|
||||
}
|
||||
|
||||
/// `POST /v2/mint/spend` — prepare notes to send (ecash), in msat. Returns serialized notes.
|
||||
pub async fn spend(&self, federation_id: &str, amount_sats: u64) -> Result<String> {
|
||||
let res = self
|
||||
.post(
|
||||
"/v2/mint/spend",
|
||||
serde_json::json!({
|
||||
"federationId": federation_id,
|
||||
"amountMsat": amount_sats * 1000,
|
||||
"allowOverpay": true,
|
||||
"timeout": 3600,
|
||||
"includeInvite": false,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
res.get("notes")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| anyhow::anyhow!("fedimint spend: no notes in response"))
|
||||
}
|
||||
|
||||
/// `POST /v2/mint/reissue` — redeem received notes; returns reissued sats.
|
||||
pub async fn reissue(&self, federation_id: &str, notes: &str) -> Result<u64> {
|
||||
let res = self
|
||||
.post(
|
||||
"/v2/mint/reissue",
|
||||
serde_json::json!({ "federationId": federation_id, "notes": notes }),
|
||||
)
|
||||
.await?;
|
||||
let msat = res
|
||||
.get("amountMsat")
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| anyhow::anyhow!("fedimint reissue: no amountMsat in response"))?;
|
||||
Ok(msat / 1000)
|
||||
}
|
||||
|
||||
async fn latest_federation_id(&self) -> Result<String> {
|
||||
let info = self.info().await?;
|
||||
info.as_object()
|
||||
.and_then(|m| m.keys().next_back().cloned())
|
||||
.ok_or_else(|| anyhow::anyhow!("joined federation but clientd reported none"))
|
||||
}
|
||||
}
|
||||
|
||||
fn federation_msat(entry: &serde_json::Value) -> Option<u64> {
|
||||
entry
|
||||
.get("totalAmountMsat")
|
||||
.or_else(|| entry.get("totalMsat"))
|
||||
.and_then(|v| v.as_u64())
|
||||
}
|
||||
|
||||
fn sum_msat(info: &serde_json::Value) -> u64 {
|
||||
info.as_object()
|
||||
.map(|m| m.values().filter_map(federation_msat).sum())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
//! HTTP client for Cashu mint API (NUT-01 through NUT-06).
|
||||
//!
|
||||
//! Communicates with a Cashu-compatible mint for:
|
||||
//! - Keyset discovery (GET /v1/keys, /v1/keysets)
|
||||
//! - Mint quotes and minting (POST /v1/mint/quote/bolt11, /v1/mint/bolt11)
|
||||
//! - Melt quotes and melting (POST /v1/melt/quote/bolt11, /v1/melt/bolt11)
|
||||
//! - Token swaps (POST /v1/swap)
|
||||
//! - Proof state checks (POST /v1/checkstate)
|
||||
|
||||
use super::bdhke;
|
||||
use super::cashu::{
|
||||
amount_to_denominations, BlindSignature, BlindedMessageRequest, CashuToken, MintKeyset, Proof,
|
||||
};
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::debug;
|
||||
|
||||
/// Default timeout for mint API calls.
|
||||
const MINT_TIMEOUT_SECS: u64 = 10;
|
||||
/// Timeout for heavy operations (minting with Lightning payment).
|
||||
const MINT_HEAVY_TIMEOUT_SECS: u64 = 30;
|
||||
|
||||
/// Mint quote response (NUT-04).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MintQuote {
|
||||
pub quote: String,
|
||||
pub request: String, // BOLT11 Lightning invoice
|
||||
pub state: String, // "UNPAID", "PAID", "ISSUED"
|
||||
#[serde(default)]
|
||||
pub expiry: u64,
|
||||
}
|
||||
|
||||
/// Melt quote response (NUT-05).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MeltQuote {
|
||||
pub quote: String,
|
||||
pub amount: u64,
|
||||
pub fee_reserve: u64,
|
||||
pub state: String, // "UNPAID", "PENDING", "PAID"
|
||||
#[serde(default)]
|
||||
pub expiry: u64,
|
||||
}
|
||||
|
||||
/// Token state from checkstate (NUT-07).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProofState {
|
||||
#[serde(rename = "Y")]
|
||||
pub y: String,
|
||||
pub state: String, // "UNSPENT", "SPENT", "PENDING"
|
||||
}
|
||||
|
||||
/// Result of a swap operation.
|
||||
pub struct SwapResult {
|
||||
pub new_proofs: Vec<Proof>,
|
||||
}
|
||||
|
||||
/// Result of a mint operation.
|
||||
pub struct MintResult {
|
||||
pub proofs: Vec<Proof>,
|
||||
}
|
||||
|
||||
/// HTTP client for a single Cashu mint.
|
||||
pub struct MintClient {
|
||||
url: String,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl MintClient {
|
||||
/// Create a new mint client for the given mint URL.
|
||||
pub fn new(mint_url: &str) -> Result<Self> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(MINT_TIMEOUT_SECS))
|
||||
.build()
|
||||
.context("Failed to build HTTP client for mint")?;
|
||||
|
||||
Ok(Self {
|
||||
url: mint_url.trim_end_matches('/').to_string(),
|
||||
client,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a mint client with a custom reqwest client (e.g., for Tor proxy).
|
||||
pub fn with_client(mint_url: &str, client: reqwest::Client) -> Self {
|
||||
Self {
|
||||
url: mint_url.trim_end_matches('/').to_string(),
|
||||
client,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn url(&self) -> &str {
|
||||
&self.url
|
||||
}
|
||||
|
||||
// ── Keyset discovery (NUT-01, NUT-02) ──
|
||||
|
||||
/// Fetch the active keyset from the mint.
|
||||
pub async fn get_keys(&self) -> Result<Vec<MintKeyset>> {
|
||||
let url = format!("{}/v1/keys", self.url);
|
||||
let res = self
|
||||
.client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to fetch mint keys")?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
anyhow::bail!("Mint keys request failed: {}", res.status());
|
||||
}
|
||||
|
||||
let body: serde_json::Value = res.json().await.context("Failed to parse mint keys")?;
|
||||
let keysets: Vec<MintKeyset> = serde_json::from_value(
|
||||
body.get("keysets")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::json!([])),
|
||||
)
|
||||
.context("Failed to parse keysets")?;
|
||||
|
||||
Ok(keysets)
|
||||
}
|
||||
|
||||
/// Get the active keyset for the "sat" unit.
|
||||
pub async fn get_active_sat_keyset(&self) -> Result<MintKeyset> {
|
||||
let keysets = self.get_keys().await?;
|
||||
keysets
|
||||
.into_iter()
|
||||
.find(|k| {
|
||||
// Find active sat keyset — check keys map is non-empty
|
||||
!k.keys.is_empty()
|
||||
})
|
||||
.ok_or_else(|| anyhow::anyhow!("No active keyset found at mint {}", self.url))
|
||||
}
|
||||
|
||||
// ── Mint quotes (NUT-04) ──
|
||||
|
||||
/// Request a mint quote — returns a Lightning invoice to pay.
|
||||
pub async fn mint_quote(&self, amount: u64) -> Result<MintQuote> {
|
||||
let url = format!("{}/v1/mint/quote/bolt11", self.url);
|
||||
let res = self
|
||||
.client
|
||||
.post(&url)
|
||||
.json(&serde_json::json!({ "amount": amount, "unit": "sat" }))
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to request mint quote")?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Mint quote failed ({}): {}", status, body);
|
||||
}
|
||||
|
||||
res.json().await.context("Failed to parse mint quote")
|
||||
}
|
||||
|
||||
/// Check the status of a mint quote.
|
||||
pub async fn mint_quote_status(&self, quote_id: &str) -> Result<MintQuote> {
|
||||
let url = format!("{}/v1/mint/quote/bolt11/{}", self.url, quote_id);
|
||||
let res = self
|
||||
.client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to check mint quote status")?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
anyhow::bail!("Mint quote status check failed: {}", res.status());
|
||||
}
|
||||
|
||||
res.json()
|
||||
.await
|
||||
.context("Failed to parse mint quote status")
|
||||
}
|
||||
|
||||
/// Mint tokens after Lightning invoice has been paid.
|
||||
/// Performs BDHKE blinding, sends blinded messages to mint, unblinds signatures.
|
||||
pub async fn mint_tokens(&self, quote_id: &str, amount: u64) -> Result<MintResult> {
|
||||
let keyset = self.get_active_sat_keyset().await?;
|
||||
let denominations = amount_to_denominations(amount);
|
||||
|
||||
let mut blinded_messages = Vec::new();
|
||||
let mut blinding_data = Vec::new(); // (secret, blinding_factor, amount)
|
||||
|
||||
for &denom in &denominations {
|
||||
let secret = bdhke::generate_secret();
|
||||
let r = bdhke::random_blinding_factor();
|
||||
let blinded = bdhke::blind_message(&secret, &r)?;
|
||||
|
||||
blinded_messages.push(BlindedMessageRequest {
|
||||
amount: denom,
|
||||
id: keyset.id.clone(),
|
||||
b_prime: hex::encode(blinded.b_prime.serialize()),
|
||||
});
|
||||
blinding_data.push((secret, r, denom));
|
||||
}
|
||||
|
||||
let url = format!("{}/v1/mint/bolt11", self.url);
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(MINT_HEAVY_TIMEOUT_SECS))
|
||||
.build()
|
||||
.context("Failed to build client for mint operation")?;
|
||||
|
||||
let res = client
|
||||
.post(&url)
|
||||
.json(&serde_json::json!({
|
||||
"quote": quote_id,
|
||||
"outputs": blinded_messages,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to mint tokens")?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Mint tokens failed ({}): {}", status, body);
|
||||
}
|
||||
|
||||
let body: serde_json::Value = res.json().await.context("Failed to parse mint response")?;
|
||||
let signatures: Vec<BlindSignature> = serde_json::from_value(
|
||||
body.get("signatures")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::json!([])),
|
||||
)
|
||||
.context("Failed to parse blind signatures")?;
|
||||
|
||||
if signatures.len() != blinding_data.len() {
|
||||
anyhow::bail!(
|
||||
"Mint returned {} signatures, expected {}",
|
||||
signatures.len(),
|
||||
blinding_data.len()
|
||||
);
|
||||
}
|
||||
|
||||
// Unblind signatures to get real proofs
|
||||
let mut proofs = Vec::new();
|
||||
for (sig, (secret, r, amount)) in signatures.iter().zip(blinding_data.iter()) {
|
||||
let c_prime = sig.c_prime_as_pubkey()?;
|
||||
let mint_key = keyset.key_for_amount(*amount)?;
|
||||
let c = bdhke::unblind_signature(&c_prime, r, &mint_key)?;
|
||||
|
||||
proofs.push(Proof {
|
||||
amount: *amount,
|
||||
id: keyset.id.clone(),
|
||||
secret: String::from_utf8_lossy(secret).to_string(),
|
||||
c: hex::encode(c.serialize()),
|
||||
});
|
||||
}
|
||||
|
||||
debug!("Minted {} proofs totaling {} sats", proofs.len(), amount);
|
||||
Ok(MintResult { proofs })
|
||||
}
|
||||
|
||||
// ── Melt (NUT-05) ──
|
||||
|
||||
/// Request a melt quote — how much it costs to pay a Lightning invoice.
|
||||
pub async fn melt_quote(&self, bolt11: &str) -> Result<MeltQuote> {
|
||||
let url = format!("{}/v1/melt/quote/bolt11", self.url);
|
||||
let res = self
|
||||
.client
|
||||
.post(&url)
|
||||
.json(&serde_json::json!({ "request": bolt11, "unit": "sat" }))
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to request melt quote")?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Melt quote failed ({}): {}", status, body);
|
||||
}
|
||||
|
||||
res.json().await.context("Failed to parse melt quote")
|
||||
}
|
||||
|
||||
/// Melt tokens — pay a Lightning invoice using ecash proofs.
|
||||
pub async fn melt_tokens(&self, quote_id: &str, proofs: &[Proof]) -> Result<MeltQuote> {
|
||||
let url = format!("{}/v1/melt/bolt11", self.url);
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(MINT_HEAVY_TIMEOUT_SECS))
|
||||
.build()
|
||||
.context("Failed to build client for melt operation")?;
|
||||
|
||||
let res = client
|
||||
.post(&url)
|
||||
.json(&serde_json::json!({
|
||||
"quote": quote_id,
|
||||
"inputs": proofs,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to melt tokens")?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Melt failed ({}): {}", status, body);
|
||||
}
|
||||
|
||||
res.json().await.context("Failed to parse melt response")
|
||||
}
|
||||
|
||||
// ── Swap (NUT-03) ──
|
||||
|
||||
/// Swap proofs for new proofs of different denominations.
|
||||
/// This is how we "receive" a token — swap it for fresh proofs that only we know.
|
||||
pub async fn swap(&self, inputs: &[Proof], target_amounts: &[u64]) -> Result<SwapResult> {
|
||||
let keyset = self.get_active_sat_keyset().await?;
|
||||
|
||||
let mut blinded_messages = Vec::new();
|
||||
let mut blinding_data = Vec::new();
|
||||
|
||||
for &amount in target_amounts {
|
||||
let secret = bdhke::generate_secret();
|
||||
let r = bdhke::random_blinding_factor();
|
||||
let blinded = bdhke::blind_message(&secret, &r)?;
|
||||
|
||||
blinded_messages.push(BlindedMessageRequest {
|
||||
amount,
|
||||
id: keyset.id.clone(),
|
||||
b_prime: hex::encode(blinded.b_prime.serialize()),
|
||||
});
|
||||
blinding_data.push((secret, r, amount));
|
||||
}
|
||||
|
||||
let url = format!("{}/v1/swap", self.url);
|
||||
let res = self
|
||||
.client
|
||||
.post(&url)
|
||||
.json(&serde_json::json!({
|
||||
"inputs": inputs,
|
||||
"outputs": blinded_messages,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to swap tokens")?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Swap failed ({}): {}", status, body);
|
||||
}
|
||||
|
||||
let body: serde_json::Value = res.json().await.context("Failed to parse swap response")?;
|
||||
let signatures: Vec<BlindSignature> = serde_json::from_value(
|
||||
body.get("signatures")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::json!([])),
|
||||
)
|
||||
.context("Failed to parse swap signatures")?;
|
||||
|
||||
if signatures.len() != blinding_data.len() {
|
||||
anyhow::bail!(
|
||||
"Swap returned {} signatures, expected {}",
|
||||
signatures.len(),
|
||||
blinding_data.len()
|
||||
);
|
||||
}
|
||||
|
||||
let mut new_proofs = Vec::new();
|
||||
for (sig, (secret, r, amount)) in signatures.iter().zip(blinding_data.iter()) {
|
||||
let c_prime = sig.c_prime_as_pubkey()?;
|
||||
let mint_key = keyset.key_for_amount(*amount)?;
|
||||
let c = bdhke::unblind_signature(&c_prime, r, &mint_key)?;
|
||||
|
||||
new_proofs.push(Proof {
|
||||
amount: *amount,
|
||||
id: keyset.id.clone(),
|
||||
secret: String::from_utf8_lossy(secret).to_string(),
|
||||
c: hex::encode(c.serialize()),
|
||||
});
|
||||
}
|
||||
|
||||
debug!(
|
||||
"Swapped {} inputs for {} new proofs",
|
||||
inputs.len(),
|
||||
new_proofs.len()
|
||||
);
|
||||
Ok(SwapResult { new_proofs })
|
||||
}
|
||||
|
||||
// ── Check state (NUT-07) ──
|
||||
|
||||
/// Check whether proofs are spent, unspent, or pending.
|
||||
pub async fn check_state(&self, proofs: &[Proof]) -> Result<Vec<ProofState>> {
|
||||
// Compute Y = hash_to_curve(secret) for each proof
|
||||
let ys: Vec<String> = proofs
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let y = bdhke::hash_to_curve(p.secret.as_bytes())?;
|
||||
Ok(hex::encode(y.serialize()))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
|
||||
let url = format!("{}/v1/checkstate", self.url);
|
||||
let res = self
|
||||
.client
|
||||
.post(&url)
|
||||
.json(&serde_json::json!({ "Ys": ys }))
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to check proof state")?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
anyhow::bail!("Check state failed: {}", res.status());
|
||||
}
|
||||
|
||||
let body: serde_json::Value = res
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse checkstate response")?;
|
||||
let states: Vec<ProofState> =
|
||||
serde_json::from_value(body.get("states").cloned().unwrap_or(serde_json::json!([])))
|
||||
.context("Failed to parse proof states")?;
|
||||
|
||||
Ok(states)
|
||||
}
|
||||
|
||||
/// Receive a CashuToken by swapping its proofs for fresh ones.
|
||||
/// This prevents double-spend and ensures only we can spend the new proofs.
|
||||
pub async fn receive_token(&self, token: &CashuToken) -> Result<Vec<Proof>> {
|
||||
let mut all_new_proofs = Vec::new();
|
||||
|
||||
for entry in &token.token {
|
||||
if entry.mint != self.url {
|
||||
debug!(
|
||||
"Skipping proofs from different mint {} (ours: {})",
|
||||
entry.mint, self.url
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let total: u64 = entry.proofs.iter().map(|p| p.amount).sum();
|
||||
let target_amounts = amount_to_denominations(total);
|
||||
|
||||
let result = self.swap(&entry.proofs, &target_amounts).await?;
|
||||
all_new_proofs.extend(result.new_proofs);
|
||||
}
|
||||
|
||||
if all_new_proofs.is_empty() {
|
||||
anyhow::bail!("No proofs could be swapped — mint mismatch or empty token");
|
||||
}
|
||||
|
||||
Ok(all_new_proofs)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_mint_client_url_normalization() {
|
||||
let client = MintClient::new("http://mint.example.com/").unwrap();
|
||||
assert_eq!(client.url(), "http://mint.example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mint_client_url_no_trailing_slash() {
|
||||
let client = MintClient::new("http://mint.example.com").unwrap();
|
||||
assert_eq!(client.url(), "http://mint.example.com");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// WIP Cashu/ecash wallet — many helpers defined for future callers.
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod ark_client;
|
||||
pub mod bdhke;
|
||||
pub mod cashu;
|
||||
pub mod ecash;
|
||||
pub mod fedimint_client;
|
||||
pub mod mint_client;
|
||||
pub mod profits;
|
||||
@@ -0,0 +1,312 @@
|
||||
//! Networking profit tracking.
|
||||
//!
|
||||
//! Aggregates earnings from content sales (ecash) and Lightning routing fees.
|
||||
|
||||
use super::ecash;
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use tokio::fs;
|
||||
|
||||
const PROFITS_FILE: &str = "wallet/profits.json";
|
||||
|
||||
/// Earnings breakdown by source.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ProfitsSummary {
|
||||
/// Total earnings in sats from all sources.
|
||||
pub total_sats: u64,
|
||||
/// Earnings from ecash content sales.
|
||||
pub content_sales_sats: u64,
|
||||
/// Earnings from Lightning routing fees.
|
||||
pub routing_fees_sats: u64,
|
||||
/// Earnings from streaming data payments.
|
||||
#[serde(default)]
|
||||
pub streaming_revenue_sats: u64,
|
||||
/// Recent earning entries (newest first).
|
||||
pub recent: Vec<ProfitEntry>,
|
||||
}
|
||||
|
||||
/// A single profit event.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProfitEntry {
|
||||
pub source: ProfitSource,
|
||||
pub amount_sats: u64,
|
||||
pub timestamp: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ProfitSource {
|
||||
ContentSale,
|
||||
RoutingFee,
|
||||
StreamingRevenue,
|
||||
}
|
||||
|
||||
/// Load profits summary from disk.
|
||||
pub async fn load_profits(data_dir: &Path) -> Result<ProfitsSummary> {
|
||||
let path = data_dir.join(PROFITS_FILE);
|
||||
if !path.exists() {
|
||||
return Ok(ProfitsSummary::default());
|
||||
}
|
||||
let content = fs::read_to_string(&path)
|
||||
.await
|
||||
.context("Failed to read profits file")?;
|
||||
let summary: ProfitsSummary = serde_json::from_str(&content).unwrap_or_default();
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
/// Save profits summary to disk.
|
||||
#[allow(dead_code)]
|
||||
pub async fn save_profits(data_dir: &Path, summary: &ProfitsSummary) -> Result<()> {
|
||||
let dir = data_dir.join("wallet");
|
||||
fs::create_dir_all(&dir)
|
||||
.await
|
||||
.context("Failed to create wallet directory")?;
|
||||
let path = data_dir.join(PROFITS_FILE);
|
||||
let content = serde_json::to_string_pretty(summary).context("Failed to serialize profits")?;
|
||||
fs::write(&path, content)
|
||||
.await
|
||||
.context("Failed to write profits file")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record a single content sale, updating totals and the recent entries list.
|
||||
#[allow(dead_code)]
|
||||
pub async fn record_content_sale(
|
||||
data_dir: &Path,
|
||||
amount_sats: u64,
|
||||
description: &str,
|
||||
) -> Result<()> {
|
||||
let mut summary = load_profits(data_dir).await?;
|
||||
let entry = ProfitEntry {
|
||||
source: ProfitSource::ContentSale,
|
||||
amount_sats,
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
description: description.to_string(),
|
||||
};
|
||||
summary.recent.insert(0, entry);
|
||||
if summary.recent.len() > 100 {
|
||||
summary.recent.truncate(100);
|
||||
}
|
||||
summary.content_sales_sats += amount_sats;
|
||||
summary.total_sats =
|
||||
summary.content_sales_sats + summary.routing_fees_sats + summary.streaming_revenue_sats;
|
||||
save_profits(data_dir, &summary).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Compute a full profits summary including ecash receive transactions.
|
||||
pub async fn get_networking_profits(data_dir: &Path) -> Result<ProfitsSummary> {
|
||||
let mut summary = load_profits(data_dir).await?;
|
||||
|
||||
// Count ecash transactions by type
|
||||
let wallet = ecash::load_wallet(data_dir).await?;
|
||||
|
||||
let ecash_received: u64 = wallet
|
||||
.transactions
|
||||
.iter()
|
||||
.filter(|tx| matches!(tx.tx_type, ecash::TransactionType::Receive))
|
||||
.map(|tx| tx.amount_sats)
|
||||
.sum();
|
||||
|
||||
let streaming_received: u64 = wallet
|
||||
.transactions
|
||||
.iter()
|
||||
.filter(|tx| matches!(tx.tx_type, ecash::TransactionType::StreamingRevenue))
|
||||
.map(|tx| tx.amount_sats)
|
||||
.sum();
|
||||
|
||||
// Use the higher of tracked profits or ecash receives as content sales
|
||||
if ecash_received > summary.content_sales_sats {
|
||||
summary.content_sales_sats = ecash_received;
|
||||
}
|
||||
if streaming_received > summary.streaming_revenue_sats {
|
||||
summary.streaming_revenue_sats = streaming_received;
|
||||
}
|
||||
summary.total_sats =
|
||||
summary.content_sales_sats + summary.routing_fees_sats + summary.streaming_revenue_sats;
|
||||
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_profits_summary_default() {
|
||||
let summary = ProfitsSummary::default();
|
||||
assert_eq!(summary.total_sats, 0);
|
||||
assert_eq!(summary.content_sales_sats, 0);
|
||||
assert_eq!(summary.routing_fees_sats, 0);
|
||||
assert!(summary.recent.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_profits_returns_default_when_missing() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let summary = load_profits(tmp.path()).await.unwrap();
|
||||
assert_eq!(summary.total_sats, 0);
|
||||
assert_eq!(summary.content_sales_sats, 0);
|
||||
assert_eq!(summary.routing_fees_sats, 0);
|
||||
assert!(summary.recent.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_save_and_load_profits_roundtrip() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let summary = ProfitsSummary {
|
||||
total_sats: 5000,
|
||||
content_sales_sats: 3000,
|
||||
routing_fees_sats: 2000,
|
||||
streaming_revenue_sats: 0,
|
||||
recent: vec![ProfitEntry {
|
||||
source: ProfitSource::ContentSale,
|
||||
amount_sats: 3000,
|
||||
timestamp: "2025-06-01T00:00:00Z".to_string(),
|
||||
description: "Test sale".to_string(),
|
||||
}],
|
||||
};
|
||||
|
||||
save_profits(tmp.path(), &summary).await.unwrap();
|
||||
let loaded = load_profits(tmp.path()).await.unwrap();
|
||||
assert_eq!(loaded.total_sats, 5000);
|
||||
assert_eq!(loaded.content_sales_sats, 3000);
|
||||
assert_eq!(loaded.routing_fees_sats, 2000);
|
||||
assert_eq!(loaded.recent.len(), 1);
|
||||
assert_eq!(loaded.recent[0].amount_sats, 3000);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_save_profits_creates_wallet_dir() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let wallet_dir = tmp.path().join("wallet");
|
||||
assert!(!wallet_dir.exists());
|
||||
|
||||
save_profits(tmp.path(), &ProfitsSummary::default())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(wallet_dir.exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_record_content_sale() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
record_content_sale(tmp.path(), 500, "First sale")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let summary = load_profits(tmp.path()).await.unwrap();
|
||||
assert_eq!(summary.total_sats, 500);
|
||||
assert_eq!(summary.content_sales_sats, 500);
|
||||
assert_eq!(summary.routing_fees_sats, 0);
|
||||
assert_eq!(summary.recent.len(), 1);
|
||||
assert_eq!(summary.recent[0].amount_sats, 500);
|
||||
assert_eq!(summary.recent[0].description, "First sale");
|
||||
assert!(matches!(
|
||||
summary.recent[0].source,
|
||||
ProfitSource::ContentSale
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_record_multiple_content_sales() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
record_content_sale(tmp.path(), 100, "Sale 1")
|
||||
.await
|
||||
.unwrap();
|
||||
record_content_sale(tmp.path(), 200, "Sale 2")
|
||||
.await
|
||||
.unwrap();
|
||||
record_content_sale(tmp.path(), 300, "Sale 3")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let summary = load_profits(tmp.path()).await.unwrap();
|
||||
assert_eq!(summary.total_sats, 600);
|
||||
assert_eq!(summary.content_sales_sats, 600);
|
||||
assert_eq!(summary.recent.len(), 3);
|
||||
// Newest first (inserted at index 0)
|
||||
assert_eq!(summary.recent[0].description, "Sale 3");
|
||||
assert_eq!(summary.recent[1].description, "Sale 2");
|
||||
assert_eq!(summary.recent[2].description, "Sale 1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_record_content_sale_truncates_at_100() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
for i in 0..110 {
|
||||
record_content_sale(tmp.path(), 1, &format!("Sale {}", i))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let summary = load_profits(tmp.path()).await.unwrap();
|
||||
assert_eq!(summary.recent.len(), 100);
|
||||
assert_eq!(summary.total_sats, 110);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_networking_profits_empty() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let summary = get_networking_profits(tmp.path()).await.unwrap();
|
||||
assert_eq!(summary.total_sats, 0);
|
||||
assert_eq!(summary.content_sales_sats, 0);
|
||||
assert_eq!(summary.routing_fees_sats, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_networking_profits_includes_ecash_receives() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
// Simulate receiving ecash tokens
|
||||
ecash::receive_token(tmp.path(), "cashuSend_500_uuid1_1700000000")
|
||||
.await
|
||||
.unwrap();
|
||||
ecash::receive_token(tmp.path(), "cashuSend_300_uuid2_1700000001")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let summary = get_networking_profits(tmp.path()).await.unwrap();
|
||||
// ecash receives (800) should be reflected as content sales
|
||||
assert_eq!(summary.content_sales_sats, 800);
|
||||
assert_eq!(summary.total_sats, 800);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_networking_profits_uses_higher_of_tracked_or_ecash() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
// Record a larger tracked profit
|
||||
record_content_sale(tmp.path(), 2000, "Big sale")
|
||||
.await
|
||||
.unwrap();
|
||||
// Receive a smaller ecash amount
|
||||
ecash::receive_token(tmp.path(), "cashuSend_100_uuid_170")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let summary = get_networking_profits(tmp.path()).await.unwrap();
|
||||
// Should use tracked (2000) since it's larger than ecash receives (100)
|
||||
assert_eq!(summary.content_sales_sats, 2000);
|
||||
assert_eq!(summary.total_sats, 2000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_profit_source_serialization() {
|
||||
let entry = ProfitEntry {
|
||||
source: ProfitSource::RoutingFee,
|
||||
amount_sats: 42,
|
||||
timestamp: "2025-01-01T00:00:00Z".to_string(),
|
||||
description: "routing".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&entry).unwrap();
|
||||
assert!(json.contains("\"routing_fee\""));
|
||||
|
||||
let parsed: ProfitEntry = serde_json::from_str(&json).unwrap();
|
||||
assert!(matches!(parsed.source, ProfitSource::RoutingFee));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user