use base64::Engine;
use cashu::nuts::{Proof, SwapRequest};
use serde::{Deserialize, Serialize};
use super::balance_update::{
encode_sig_all_signature_bundle, nutshell_0_20_sig_all_message_hash_hex, BalanceUpdateMessage,
UnsignedBalanceUpdate,
};
#[cfg(feature = "wallet")]
use super::bindings::{complete_funding_swap, compute_channel_from_token, create_funding_swap};
use super::bridge::Payment;
use super::client_storage::{ClientChannelFunding, ClientChannelState, ClientPaymentState};
pub trait SpilmanClientHost {
fn save_channel_funding(&self, channel_id: &str, funding: ClientChannelFunding);
fn get_channel_funding(&self, channel_id: &str) -> Option<ClientChannelFunding>;
fn get_payment_state(&self, channel_id: &str) -> Option<ClientPaymentState>;
fn record_payment(&self, channel_id: &str, state: ClientPaymentState);
fn get_channel_state(&self, channel_id: &str) -> ClientChannelState;
fn mark_channel_closed(&self, channel_id: &str);
fn list_channel_ids(&self) -> Vec<String>;
fn delete_channel(&self, channel_id: &str);
fn now_seconds(&self) -> u64;
fn compute_channel_secret(
&self,
sender_pubkey_hex: &str,
receiver_pubkey_hex: &str,
) -> Result<String, String>;
fn sign_with_tweaked_key(
&self,
signer_pubkey_hex: &str,
message_hex: &str,
tweak_scalar_hex: &str,
) -> Result<String, String>;
}
pub trait SpilmanClientNetworking {
fn call_mint_swap(&self, mint_url: &str, swap_request_json: &str) -> Result<String, String>;
}
#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
pub trait SpilmanClientAsyncNetworking {
async fn call_mint_swap(
&self,
mint_url: &str,
swap_request_json: &str,
) -> Result<String, String>;
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenChannelResult {
pub channel_id: String,
pub capacity: u64,
pub funding_token_amount: u64,
pub mint_url: String,
pub sender_pubkey_hex: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClientChannelInfo {
pub channel_id: String,
pub capacity: u64,
pub funding_token_amount: u64,
pub mint_url: String,
pub current_balance: u64,
pub payment_count: u64,
pub state: ClientChannelState,
}
#[derive(Debug)]
pub struct SpilmanClientBridge<H: SpilmanClientHost, N: SpilmanClientNetworking> {
host: H,
#[allow(dead_code)] networking: N,
}
#[cfg(feature = "wallet")]
fn normalize_mint_error_string(raw: String) -> String {
serde_json::from_str::<serde_json::Value>(&raw)
.map(|value| value.to_string())
.unwrap_or(raw)
}
impl<H: SpilmanClientHost, N: SpilmanClientNetworking> SpilmanClientBridge<H, N> {
pub fn new(host: H, networking: N) -> Self {
Self { host, networking }
}
#[cfg(feature = "wallet")]
pub fn open_channel_from_token(
&self,
token_string: &str,
receiver_pubkey_hex: &str,
sender_pubkey_hex: &str,
expiry_timestamp: u64,
keyset_info_json: &str,
max_amount: u64,
) -> Result<OpenChannelResult, String> {
let channel_secret_hex = self
.host
.compute_channel_secret(sender_pubkey_hex, receiver_pubkey_hex)?;
let compute_result = compute_channel_from_token(
token_string,
receiver_pubkey_hex,
sender_pubkey_hex,
&channel_secret_hex,
expiry_timestamp,
keyset_info_json,
max_amount,
)?;
let compute_json: serde_json::Value = serde_json::from_str(&compute_result)
.map_err(|e| format!("Failed to parse compute result: {}", e))?;
let capacity = compute_json["capacity"]
.as_u64()
.ok_or("Missing 'capacity' in compute result")?;
let funding_token_amount = compute_json["funding_token_amount"]
.as_u64()
.ok_or("Missing 'funding_token_amount' in compute result")?;
let mint_url = compute_json["mint_url"]
.as_str()
.ok_or("Missing 'mint_url' in compute result")?
.to_string();
let params_json = compute_json["params_json"]
.as_str()
.ok_or("Missing 'params_json' in compute result")?;
let proofs_json = compute_json["proofs_json"]
.as_str()
.ok_or("Missing 'proofs_json' in compute result")?;
let swap_result = create_funding_swap(
params_json,
&channel_secret_hex,
keyset_info_json,
proofs_json,
)?;
let swap_json: serde_json::Value = serde_json::from_str(&swap_result)
.map_err(|e| format!("Failed to parse swap result: {}", e))?;
let swap_request_json = swap_json["swap_request_json"]
.as_str()
.ok_or("Missing 'swap_request_json' in swap result")?;
let funding_secrets_json = swap_json["funding_secrets_json"]
.as_str()
.ok_or("Missing 'funding_secrets_json' in swap result")?;
let swap_response_json = self
.networking
.call_mint_swap(&mint_url, swap_request_json)
.map_err(normalize_mint_error_string)?;
let complete_result =
complete_funding_swap(&swap_response_json, funding_secrets_json, keyset_info_json)?;
let complete_json: serde_json::Value = serde_json::from_str(&complete_result)
.map_err(|e| format!("Failed to parse complete result: {}", e))?;
let funding_proofs_json = complete_json["funding_proofs_json"]
.as_str()
.ok_or("Missing 'funding_proofs_json' in complete result")?;
let channel_id = super::bindings::channel_parameters_get_channel_id(
params_json,
&channel_secret_hex,
keyset_info_json,
)?;
let funding = ClientChannelFunding {
params_json: params_json.to_string(),
funding_proofs_json: funding_proofs_json.to_string(),
channel_secret_hex: channel_secret_hex.clone(),
keyset_info_json: keyset_info_json.to_string(),
sender_pubkey_hex: sender_pubkey_hex.to_string(),
capacity,
funding_token_amount,
mint_url: mint_url.clone(),
created_at: self.host.now_seconds(),
};
self.host.save_channel_funding(&channel_id, funding);
Ok(OpenChannelResult {
channel_id,
capacity,
funding_token_amount,
mint_url,
sender_pubkey_hex: sender_pubkey_hex.to_string(),
})
}
#[cfg(feature = "wallet")]
#[allow(clippy::too_many_arguments)]
pub async fn open_channel_from_token_async<AN: SpilmanClientAsyncNetworking>(
&self,
token_string: &str,
receiver_pubkey_hex: &str,
sender_pubkey_hex: &str,
expiry_timestamp: u64,
keyset_info_json: &str,
max_amount: u64,
async_networking: &AN,
) -> Result<OpenChannelResult, String> {
let channel_secret_hex = self
.host
.compute_channel_secret(sender_pubkey_hex, receiver_pubkey_hex)?;
let compute_result = compute_channel_from_token(
token_string,
receiver_pubkey_hex,
sender_pubkey_hex,
&channel_secret_hex,
expiry_timestamp,
keyset_info_json,
max_amount,
)?;
let compute_json: serde_json::Value = serde_json::from_str(&compute_result)
.map_err(|e| format!("Failed to parse compute result: {}", e))?;
let capacity = compute_json["capacity"]
.as_u64()
.ok_or("Missing 'capacity' in compute result")?;
let funding_token_amount = compute_json["funding_token_amount"]
.as_u64()
.ok_or("Missing 'funding_token_amount' in compute result")?;
let mint_url = compute_json["mint_url"]
.as_str()
.ok_or("Missing 'mint_url' in compute result")?
.to_string();
let params_json = compute_json["params_json"]
.as_str()
.ok_or("Missing 'params_json' in compute result")?;
let proofs_json = compute_json["proofs_json"]
.as_str()
.ok_or("Missing 'proofs_json' in compute result")?;
let swap_result = create_funding_swap(
params_json,
&channel_secret_hex,
keyset_info_json,
proofs_json,
)?;
let swap_json: serde_json::Value = serde_json::from_str(&swap_result)
.map_err(|e| format!("Failed to parse swap result: {}", e))?;
let swap_request_json = swap_json["swap_request_json"]
.as_str()
.ok_or("Missing 'swap_request_json' in swap result")?;
let funding_secrets_json = swap_json["funding_secrets_json"]
.as_str()
.ok_or("Missing 'funding_secrets_json' in swap result")?;
let swap_response_json = async_networking
.call_mint_swap(&mint_url, swap_request_json)
.await
.map_err(normalize_mint_error_string)?;
let complete_result =
complete_funding_swap(&swap_response_json, funding_secrets_json, keyset_info_json)?;
let complete_json: serde_json::Value = serde_json::from_str(&complete_result)
.map_err(|e| format!("Failed to parse complete result: {}", e))?;
let funding_proofs_json = complete_json["funding_proofs_json"]
.as_str()
.ok_or("Missing 'funding_proofs_json' in complete result")?;
let channel_id = super::bindings::channel_parameters_get_channel_id(
params_json,
&channel_secret_hex,
keyset_info_json,
)?;
let funding = ClientChannelFunding {
params_json: params_json.to_string(),
funding_proofs_json: funding_proofs_json.to_string(),
channel_secret_hex: channel_secret_hex.clone(),
keyset_info_json: keyset_info_json.to_string(),
sender_pubkey_hex: sender_pubkey_hex.to_string(),
capacity,
funding_token_amount,
mint_url: mint_url.clone(),
created_at: self.host.now_seconds(),
};
self.host.save_channel_funding(&channel_id, funding);
Ok(OpenChannelResult {
channel_id,
capacity,
funding_token_amount,
mint_url,
sender_pubkey_hex: sender_pubkey_hex.to_string(),
})
}
pub fn create_payment(&self, channel_id: &str, balance: u64) -> Result<Payment, String> {
self.create_payment_internal(channel_id, balance, false, false)
}
pub fn create_payment_with_funding(
&self,
channel_id: &str,
balance: u64,
) -> Result<Payment, String> {
self.create_payment_internal(channel_id, balance, true, false)
}
fn create_payment_internal(
&self,
channel_id: &str,
balance: u64,
include_funding: bool,
include_nutshell_0_20_signature: bool,
) -> Result<Payment, String> {
let funding = self
.host
.get_channel_funding(channel_id)
.ok_or_else(|| format!("Channel not found: {}", channel_id))?;
if self.host.get_channel_state(channel_id) == ClientChannelState::Closed {
return Err(format!("Channel is closed: {}", channel_id));
}
if balance > funding.capacity {
return Err(format!(
"Balance {} exceeds channel capacity {}",
balance, funding.capacity
));
}
let unsigned = self.create_unsigned_balance_update(channel_id, balance, &funding)?;
let balance_update = self.sign_balance_update(unsigned, &funding.sender_pubkey_hex)?;
let mut signature = balance_update.signature.to_string();
if include_nutshell_0_20_signature {
let compatibility_signature = self.create_nutshell_0_20_signature(balance, &funding)?;
signature = encode_sig_all_signature_bundle(&signature, &compatibility_signature);
}
let payment_state = self.host.get_payment_state(channel_id);
let payment_count = payment_state.map(|s| s.payment_count).unwrap_or(0) + 1;
self.host.record_payment(
channel_id,
ClientPaymentState {
balance,
signature: signature.clone(),
payment_count,
last_payment_at: self.host.now_seconds(),
},
);
if include_funding {
let params: serde_json::Value = serde_json::from_str(&funding.params_json)
.map_err(|e| format!("Failed to parse params: {}", e))?;
let funding_proofs: Vec<Proof> = serde_json::from_str(&funding.funding_proofs_json)
.map_err(|e| format!("Failed to parse funding proofs: {}", e))?;
Ok(Payment::with_funding(
channel_id.to_string(),
balance,
signature,
params,
funding_proofs,
))
} else {
Ok(Payment::new(channel_id.to_string(), balance, signature))
}
}
pub fn build_payment_header(
&self,
channel_id: &str,
balance: u64,
include_funding: bool,
) -> Result<String, String> {
let payment = self.create_payment_internal(channel_id, balance, include_funding, false)?;
let header_json =
serde_json::to_string(&payment).map_err(|e| format!("Failed to serialize: {}", e))?;
Ok(base64::prelude::BASE64_STANDARD.encode(header_json))
}
pub fn get_channel_info(&self, channel_id: &str) -> Option<ClientChannelInfo> {
let funding = self.host.get_channel_funding(channel_id)?;
let payment_state = self.host.get_payment_state(channel_id);
let current_balance = payment_state.as_ref().map(|s| s.balance).unwrap_or(0);
let payment_count = payment_state.as_ref().map(|s| s.payment_count).unwrap_or(0);
Some(ClientChannelInfo {
channel_id: channel_id.to_string(),
capacity: funding.capacity,
funding_token_amount: funding.funding_token_amount,
mint_url: funding.mint_url,
current_balance,
payment_count,
state: self.host.get_channel_state(channel_id),
})
}
pub fn list_channels(&self) -> Vec<String> {
self.host.list_channel_ids()
}
pub fn close_channel(&self, channel_id: &str) {
self.host.mark_channel_closed(channel_id);
}
pub fn delete_channel(&self, channel_id: &str) {
self.host.delete_channel(channel_id);
}
pub fn create_cooperative_close_request(
&self,
channel_id: &str,
final_balance: u64,
) -> Result<Payment, String> {
self.create_payment_internal(channel_id, final_balance, false, true)
}
pub fn process_cooperative_close_response(&self, response_json: &str) -> Result<(), String> {
let response: serde_json::Value = serde_json::from_str(response_json)
.map_err(|e| format!("Failed to parse close response: {}", e))?;
let channel_id = response["channel_id"]
.as_str()
.ok_or("Missing 'channel_id' in close response")?;
self.host.mark_channel_closed(channel_id);
Ok(())
}
pub fn create_unsigned_balance_update(
&self,
channel_id: &str,
balance: u64,
funding: &ClientChannelFunding,
) -> Result<UnsignedBalanceUpdate, String> {
UnsignedBalanceUpdate::new(channel_id, balance, funding)
}
pub fn sign_balance_update(
&self,
unsigned: UnsignedBalanceUpdate,
sender_pubkey_hex: &str,
) -> Result<BalanceUpdateMessage, String> {
let signature_hex = self.host.sign_with_tweaked_key(
sender_pubkey_hex,
&unsigned.message_hex,
&unsigned.tweak_scalar_hex,
)?;
unsigned.sign(&signature_hex)
}
fn create_nutshell_0_20_signature(
&self,
balance: u64,
funding: &ClientChannelFunding,
) -> Result<String, String> {
let unsigned = super::bindings::create_unsigned_balance_update(
&funding.params_json,
&funding.keyset_info_json,
&funding.channel_secret_hex,
&funding.funding_proofs_json,
balance,
)?;
let unsigned: serde_json::Value = serde_json::from_str(&unsigned)
.map_err(|error| format!("Failed to parse unsigned update: {error}"))?;
let swap: SwapRequest = serde_json::from_str(
unsigned["unsigned_swap_request_json"]
.as_str()
.ok_or("Missing unsigned_swap_request_json")?,
)
.map_err(|error| format!("Failed to parse unsigned swap request: {error}"))?;
let tweak_scalar_hex = unsigned["tweak_scalar_hex"]
.as_str()
.ok_or("Missing tweak_scalar_hex")?;
self.host.sign_with_tweaked_key(
&funding.sender_pubkey_hex,
&nutshell_0_20_sig_all_message_hash_hex(&swap),
tweak_scalar_hex,
)
}
}
pub fn base64_decode(input: &str) -> Result<String, String> {
let bytes = base64::prelude::BASE64_STANDARD
.decode(input.trim())
.map_err(|e| format!("Base64 decode failed: {}", e))?;
String::from_utf8(bytes).map_err(|e| format!("Invalid UTF-8 in base64 decode: {}", e))
}