feat: streaming ecash payments + media playback overhaul
Cashu ecash protocol (BDHKE blind signatures, cashuA token format, mint HTTP client) replacing the stub wallet. TollGate-inspired streaming data payment system with step-based pricing (bytes/time/requests), session management with incremental top-ups, usage metering, and Nostr kind 10021 service advertisements. 13 new streaming.* RPC endpoints. Content server now verifies real Cashu tokens. Profits tracking includes streaming revenue. Frontend: GlobalAudioPlayer (persistent bottom bar across all pages), video lightbox with full controls, audio in MediaLightbox, free file previews (no blur), paid 10% audio/video previews, separated play vs download buttons in PeerFiles. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
216
core/archipelago/src/wallet/bdhke.rs
Normal file
216
core/archipelago/src/wallet/bdhke.rs
Normal file
@@ -0,0 +1,216 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
315
core/archipelago/src/wallet/cashu.rs
Normal file
315
core/archipelago/src/wallet/cashu.rs
Normal file
@@ -0,0 +1,315 @@
|
||||
//! Cashu token format (NUT-00) — serialization and deserialization.
|
||||
//!
|
||||
//! Supports 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>"
|
||||
//! }
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use bitcoin::secp256k1::PublicKey;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Prefix for V3 tokens.
|
||||
const CASHU_A_PREFIX: &str = "cashuA";
|
||||
|
||||
/// 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 token string.
|
||||
pub fn deserialize(token_str: &str) -> Result<Self> {
|
||||
let payload = token_str
|
||||
.strip_prefix(CASHU_A_PREFIX)
|
||||
.ok_or_else(|| anyhow::anyhow!("Token must start with '{}'", CASHU_A_PREFIX))?;
|
||||
|
||||
use base64::Engine;
|
||||
let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(payload)
|
||||
.or_else(|_| {
|
||||
// Try standard base64 as fallback (some implementations use it)
|
||||
base64::engine::general_purpose::URL_SAFE.decode(payload)
|
||||
})
|
||||
.or_else(|_| base64::engine::general_purpose::STANDARD.decode(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")?;
|
||||
|
||||
// Structural validation
|
||||
if token.token.is_empty() {
|
||||
anyhow::bail!("Token has no entries");
|
||||
}
|
||||
for entry in &token.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(token)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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_invalid_prefix() {
|
||||
let result = CashuToken::deserialize("cashuBabc123");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[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(),
|
||||
c: "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d94ec4da0e7f6c2b4e24".to_string(),
|
||||
};
|
||||
assert!(proof.c_as_pubkey().is_ok());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
461
core/archipelago/src/wallet/mint_client.rs
Normal file
461
core/archipelago/src/wallet/mint_client.rs
Normal file
@@ -0,0 +1,461 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,5 @@
|
||||
pub mod bdhke;
|
||||
pub mod cashu;
|
||||
pub mod ecash;
|
||||
pub mod mint_client;
|
||||
pub mod profits;
|
||||
|
||||
@@ -19,6 +19,9 @@ pub struct ProfitsSummary {
|
||||
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>,
|
||||
}
|
||||
@@ -38,6 +41,7 @@ pub struct ProfitEntry {
|
||||
pub enum ProfitSource {
|
||||
ContentSale,
|
||||
RoutingFee,
|
||||
StreamingRevenue,
|
||||
}
|
||||
|
||||
/// Load profits summary from disk.
|
||||
@@ -84,7 +88,7 @@ pub async fn record_content_sale(data_dir: &Path, amount_sats: u64, description:
|
||||
summary.recent.truncate(100);
|
||||
}
|
||||
summary.content_sales_sats += amount_sats;
|
||||
summary.total_sats = summary.content_sales_sats + summary.routing_fees_sats;
|
||||
summary.total_sats = summary.content_sales_sats + summary.routing_fees_sats + summary.streaming_revenue_sats;
|
||||
save_profits(data_dir, &summary).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -93,8 +97,9 @@ pub async fn record_content_sale(data_dir: &Path, amount_sats: u64, description:
|
||||
pub async fn get_networking_profits(data_dir: &Path) -> Result<ProfitsSummary> {
|
||||
let mut summary = load_profits(data_dir).await?;
|
||||
|
||||
// Also count ecash "receive" transactions as content sales revenue
|
||||
// Count ecash transactions by type
|
||||
let wallet = ecash::load_wallet(data_dir).await?;
|
||||
|
||||
let ecash_received: u64 = wallet
|
||||
.transactions
|
||||
.iter()
|
||||
@@ -102,11 +107,22 @@ pub async fn get_networking_profits(data_dir: &Path) -> Result<ProfitsSummary> {
|
||||
.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;
|
||||
}
|
||||
summary.total_sats = summary.content_sales_sats + summary.routing_fees_sats;
|
||||
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)
|
||||
}
|
||||
@@ -142,6 +158,7 @@ mod tests {
|
||||
total_sats: 5000,
|
||||
content_sales_sats: 3000,
|
||||
routing_fees_sats: 2000,
|
||||
streaming_revenue_sats: 0,
|
||||
recent: vec![ProfitEntry {
|
||||
source: ProfitSource::ContentSale,
|
||||
amount_sats: 3000,
|
||||
|
||||
Reference in New Issue
Block a user