use anyhow::{Result, anyhow};
use nostr_sdk::prelude::*;
use nostr_sdk::nips::nip47::{NostrWalletConnectURI, MakeInvoiceRequestParams};
use serde::{Deserialize, Serialize};
use std::time::Duration;
use super::identity::AgentIdentity;
use super::nostr_client::NostrClient;
use super::storage::SecureStorage;
pub const KEY_NWC_URI: &str = "signedby_nwc_connection_uri";
pub const KEY_LIGHTNING_ADDRESS: &str = "signedby_lightning_address";
pub const STRIKE_API_URL: &str = "https://api.strike.me/v1";
pub const RENEWAL_WINDOW_SECS: u64 = 72 * 60 * 60;
pub struct NwcWallet {
nostr_client: NostrClient,
nwc_uri: Option<String>,
lightning_address: Option<String>,
http_client: reqwest::Client,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WalletInitResult {
pub success: bool,
pub lightning_address: Option<String>,
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SubscriptionInvoice {
pub bolt11: String,
pub amount_sats: u64,
pub description: String,
pub expires_at: u64,
}
#[derive(Debug, Serialize)]
struct StrikeInvoiceRequest {
#[serde(rename = "correlationId")]
correlation_id: String,
description: String,
amount: StrikeAmount,
}
#[derive(Debug, Serialize)]
struct StrikeAmount {
amount: String,
currency: String,
}
#[derive(Debug, Deserialize)]
struct StrikeInvoiceResponse {
#[serde(rename = "invoiceId")]
invoice_id: String,
#[serde(rename = "lnInvoice")]
ln_invoice: Option<String>,
}
#[derive(Debug, Deserialize)]
struct StrikeQuoteResponse {
#[serde(rename = "lnInvoice")]
ln_invoice: String,
}
impl NwcWallet {
pub fn new(nostr_client: NostrClient, nwc_connection_uri: Option<&str>) -> Self {
let http_client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.build()
.expect("Failed to create HTTP client");
Self {
nostr_client,
nwc_uri: nwc_connection_uri.map(String::from),
lightning_address: None,
http_client,
}
}
pub fn without_nwc(nostr_client: NostrClient) -> Self {
Self::new(nostr_client, None)
}
pub fn store_connection_uri<S: SecureStorage>(
storage: &S,
connection_uri: &str,
) -> Result<()> {
storage.store(KEY_NWC_URI, connection_uri.as_bytes())
.map_err(|e| anyhow!("Failed to store NWC URI: {}", e))
}
pub fn load_connection_uri<S: SecureStorage>(storage: &S) -> Result<Option<String>> {
if !storage.exists(KEY_NWC_URI) {
return Ok(None);
}
let bytes = storage.retrieve(KEY_NWC_URI)
.map_err(|e| anyhow!("Failed to load NWC URI: {}", e))?;
let uri = String::from_utf8(bytes)
.map_err(|e| anyhow!("Invalid NWC URI encoding: {}", e))?;
Ok(Some(uri))
}
pub async fn publish_lightning_address<S: SecureStorage>(
&mut self,
identity: &AgentIdentity<S>,
lightning_address: &str,
) -> Result<EventId> {
if !lightning_address.contains('@') {
return Err(anyhow!("Invalid Lightning address format: must be user@domain"));
}
let state = identity.load()?;
let metadata = Metadata::new()
.lud16(lightning_address);
let event_builder = EventBuilder::metadata(&metadata);
let output = self.nostr_client.inner_client()
.send_event_builder(event_builder)
.await
.map_err(|e| anyhow!("Failed to publish Lightning address: {}", e))?;
self.lightning_address = Some(lightning_address.to_string());
eprintln!("[wallet] Published Lightning address {} for npub {}",
lightning_address, state.agent_npub);
Ok(output.val)
}
pub async fn generate_subscription_invoice(
&self,
amount_sats: u64,
strike_api_key: &str,
correlation_id: &str,
) -> Result<SubscriptionInvoice> {
let create_url = format!("{}/invoices", STRIKE_API_URL);
let request = StrikeInvoiceRequest {
correlation_id: correlation_id.to_string(),
description: "SIGNEDBYME monthly subscription".to_string(),
amount: StrikeAmount {
amount: format!("{}", amount_sats),
currency: "SATS".to_string(),
},
};
let response = self.http_client
.post(&create_url)
.header("Authorization", format!("Bearer {}", strike_api_key))
.header("Content-Type", "application/json")
.json(&request)
.send()
.await
.map_err(|e| anyhow!("Strike API request failed: {}", e))?;
let status = response.status();
if !status.is_success() {
let error_text = response.text().await.unwrap_or_default();
return Err(anyhow!("Strike API returned {}: {}", status, error_text));
}
let invoice_response: StrikeInvoiceResponse = response.json().await
.map_err(|e| anyhow!("Failed to parse Strike response: {}", e))?;
let quote_url = format!("{}/invoices/{}/quote", STRIKE_API_URL, invoice_response.invoice_id);
let quote_response = self.http_client
.post("e_url)
.header("Authorization", format!("Bearer {}", strike_api_key))
.header("Content-Type", "application/json")
.send()
.await
.map_err(|e| anyhow!("Strike quote request failed: {}", e))?;
let quote_status = quote_response.status();
if !quote_status.is_success() {
let error_text = quote_response.text().await.unwrap_or_default();
return Err(anyhow!("Strike quote API returned {}: {}", quote_status, error_text));
}
let quote: StrikeQuoteResponse = quote_response.json().await
.map_err(|e| anyhow!("Failed to parse Strike quote response: {}", e))?;
let expires_at = current_timestamp() + 3600;
Ok(SubscriptionInvoice {
bolt11: quote.ln_invoice,
amount_sats,
description: "SIGNEDBYME monthly subscription".to_string(),
expires_at,
})
}
pub async fn check_subscription_renewal_needed<S: SecureStorage>(
&self,
identity: &AgentIdentity<S>,
) -> Result<bool> {
let state = identity.load()?;
let agent_npub = &state.agent_npub;
let events = self.nostr_client.poll_delegation_events(agent_npub).await
.map_err(|e| anyhow!("Failed to query delegation events: {}", e))?;
if events.is_empty() {
return Ok(false); }
let mut latest_expiry: Option<u64> = None;
for event in events {
if let Ok(content) = serde_json::from_str::<DelegationContent>(&event.content) {
if let Some(expires_at) = parse_expiry(&content.expires_at) {
if latest_expiry.is_none() || expires_at > latest_expiry.unwrap() {
latest_expiry = Some(expires_at);
}
}
}
}
if let Some(expiry) = latest_expiry {
let now = current_timestamp();
let renewal_threshold = expiry.saturating_sub(RENEWAL_WINDOW_SECS);
if now >= renewal_threshold && now < expiry {
eprintln!("[wallet] Subscription renewal needed: expires in {} hours",
(expiry - now) / 3600);
return Ok(true);
}
}
Ok(false)
}
pub async fn create_receive_invoice(
&self,
amount_sats: u64,
description: &str,
) -> Result<String> {
let nwc_uri = self.nwc_uri.as_ref()
.ok_or_else(|| anyhow!("NWC not initialized - provide connection URI"))?;
let uri = NostrWalletConnectURI::parse(nwc_uri)
.map_err(|e| anyhow!("Invalid NWC URI: {}", e))?;
let nwc_client = nwc::NWC::new(uri);
let amount_msats = amount_sats * 1000;
let params = MakeInvoiceRequestParams {
amount: amount_msats,
description: Some(description.to_string()),
description_hash: None,
expiry: Some(3600),
};
let response = nwc_client.make_invoice(params).await
.map_err(|e| anyhow!("Failed to create invoice via NWC: {}", e))?;
Ok(response.invoice)
}
pub async fn get_balance(&self) -> Result<u64> {
let nwc_uri = self.nwc_uri.as_ref()
.ok_or_else(|| anyhow!("NWC not initialized"))?;
let uri = NostrWalletConnectURI::parse(nwc_uri)
.map_err(|e| anyhow!("Invalid NWC URI: {}", e))?;
let nwc_client = nwc::NWC::new(uri);
let balance = nwc_client.get_balance().await
.map_err(|e| anyhow!("Failed to get balance via NWC: {}", e))?;
Ok(balance / 1000)
}
pub async fn pay_invoice(&self, bolt11: &str) -> Result<String> {
let nwc_uri = self.nwc_uri.as_ref()
.ok_or_else(|| anyhow!("NWC not initialized"))?;
let uri = NostrWalletConnectURI::parse(nwc_uri)
.map_err(|e| anyhow!("Invalid NWC URI: {}", e))?;
let nwc_client = nwc::NWC::new(uri);
let preimage = nwc_client.pay_invoice(bolt11.to_string()).await
.map_err(|e| anyhow!("Failed to pay invoice via NWC: {}", e))?;
Ok(preimage)
}
pub fn lightning_address(&self) -> Option<&str> {
self.lightning_address.as_deref()
}
pub fn is_nwc_configured(&self) -> bool {
self.nwc_uri.is_some()
}
}
#[derive(Debug, Deserialize)]
struct DelegationContent {
expires_at: Option<String>,
}
fn parse_expiry(expires_at: &Option<String>) -> Option<u64> {
let s = expires_at.as_ref()?;
if let Ok(ts) = s.parse::<u64>() {
return Some(ts);
}
parse_iso8601(s)
}
fn parse_iso8601(s: &str) -> Option<u64> {
let s = s.trim_end_matches('Z').split('+').next()?;
let parts: Vec<&str> = s.split('T').collect();
if parts.len() != 2 {
return None;
}
let date_parts: Vec<u32> = parts[0].split('-').filter_map(|p| p.parse().ok()).collect();
let time_parts: Vec<u32> = parts[1].split(':').filter_map(|p| p.parse().ok()).collect();
if date_parts.len() != 3 || time_parts.len() < 2 {
return None;
}
let year = date_parts[0] as u64;
let month = date_parts[1] as u64;
let day = date_parts[2] as u64;
let hour = time_parts[0] as u64;
let minute = time_parts[1] as u64;
let second = time_parts.get(2).copied().unwrap_or(0) as u64;
let years_since_1970 = year.saturating_sub(1970);
let days = years_since_1970 * 365 + years_since_1970 / 4
+ (month - 1) * 30 + day - 1;
Some(days * 86400 + hour * 3600 + minute * 60 + second)
}
fn current_timestamp() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_subscription_invoice_serialization() {
let invoice = SubscriptionInvoice {
bolt11: "lnbc100n1...".to_string(),
amount_sats: 10000,
description: "Test subscription".to_string(),
expires_at: 1700000000,
};
let json = serde_json::to_string(&invoice).unwrap();
assert!(json.contains("\"amount_sats\":10000"));
}
#[test]
fn test_parse_expiry_unix() {
let expiry = parse_expiry(&Some("1700000000".to_string()));
assert_eq!(expiry, Some(1700000000));
}
#[test]
fn test_parse_expiry_iso8601() {
let expiry = parse_expiry(&Some("2026-12-31T23:59:59Z".to_string()));
assert!(expiry.is_some());
}
#[test]
fn test_lightning_address_validation() {
assert!("user@domain.com".contains('@'));
assert!(!"invalid-address".contains('@'));
}
}