signedby-sdk 1.0.1

SIGNEDBYME SDK - Human-Controlled Identity for Autonomous Agents
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
// sdk/wallet.rs - NWC Wallet Integration (Phase 9A.6)
//
// Per Bible Section 9A:
// - Agent initializes its own NWC (NIP-47) wallet during SDK setup (line 610)
// - Agent is responsible for its own Lightning wallet infrastructure
// - No specific wallet vendor required — Alby Hub, LNbits, Lightning node, any NIP-47 compliant
// - Publish Lightning address via NOSTR kind 0 profile event (lud16 field) (line 917)
// - Generate subscription invoices via Strike Business API (not NWC) (line 916)
// - Agent's NWC wallet receives 20% monthly subscription allocation (line 901)
// - Payment allocator queries relay for kind 0 by npub to find agent's Lightning address (line 577)

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;

/// Storage key for NWC connection URI
pub const KEY_NWC_URI: &str = "signedby_nwc_connection_uri";

/// Storage key for Lightning address
pub const KEY_LIGHTNING_ADDRESS: &str = "signedby_lightning_address";

/// Strike Business API base URL
pub const STRIKE_API_URL: &str = "https://api.strike.me/v1";

/// Default renewal window before expiry (72 hours)
pub const RENEWAL_WINDOW_SECS: u64 = 72 * 60 * 60;

/// NWC wallet manager for agent Lightning operations
/// 
/// NWC (NIP-47) is used ONLY for receiving payments (20% allocation).
/// Subscription invoices are generated via Strike Business API per Bible line 916.
pub struct NwcWallet {
    /// NOSTR client for publishing kind 0 profile
    nostr_client: NostrClient,
    /// NWC connection URI (stored for later NWC client creation)
    nwc_uri: Option<String>,
    /// Agent's Lightning address
    lightning_address: Option<String>,
    /// HTTP client for Strike API
    http_client: reqwest::Client,
}

/// Wallet initialization result
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WalletInitResult {
    pub success: bool,
    pub lightning_address: Option<String>,
    pub error: Option<String>,
}

/// Subscription invoice result
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SubscriptionInvoice {
    pub bolt11: String,
    pub amount_sats: u64,
    pub description: String,
    pub expires_at: u64,
}

/// Strike invoice request per Bible lines 522-523
#[derive(Debug, Serialize)]
struct StrikeInvoiceRequest {
    #[serde(rename = "correlationId")]
    correlation_id: String,
    description: String,
    amount: StrikeAmount,
}

#[derive(Debug, Serialize)]
struct StrikeAmount {
    amount: String,
    currency: String,
}

/// Strike invoice response
#[derive(Debug, Deserialize)]
struct StrikeInvoiceResponse {
    #[serde(rename = "invoiceId")]
    invoice_id: String,
    #[serde(rename = "lnInvoice")]
    ln_invoice: Option<String>,
}

/// Strike quote response (for getting BOLT11)
#[derive(Debug, Deserialize)]
struct StrikeQuoteResponse {
    #[serde(rename = "lnInvoice")]
    ln_invoice: String,
}

impl NwcWallet {
    /// Initialize NWC wallet with connection URI
    /// 
    /// Per Bible line 610: Agent initializes its own NWC (NIP-47) wallet during SDK setup
    /// Connection URI format: nostr+walletconnect://pubkey?relay=...&secret=...
    /// 
    /// Note: NWC is used ONLY for receiving payments, not generating subscription invoices.
    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,
        }
    }
    
    /// Create wallet without NWC connection (for Lightning address publishing only)
    pub fn without_nwc(nostr_client: NostrClient) -> Self {
        Self::new(nostr_client, None)
    }
    
    /// Store NWC connection URI in secure storage
    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))
    }
    
    /// Load NWC connection URI from secure storage
    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))
    }
    
    /// Publish Lightning address via NOSTR kind 0 profile event
    /// 
    /// Per Bible line 917: Publish Lightning address via NOSTR kind 0 profile
    /// Format per Bible lines 567-577: {"lud16": "agent_abc@agent-wallet-domain.com"}
    pub async fn publish_lightning_address<S: SecureStorage>(
        &mut self,
        identity: &AgentIdentity<S>,
        lightning_address: &str,
    ) -> Result<EventId> {
        // Validate Lightning address format (user@domain)
        if !lightning_address.contains('@') {
            return Err(anyhow!("Invalid Lightning address format: must be user@domain"));
        }
        
        // Load existing identity state
        let state = identity.load()?;
        
        // Build kind 0 metadata with lud16 field
        let metadata = Metadata::new()
            .lud16(lightning_address);
        
        // Create and sign the kind 0 event
        let event_builder = EventBuilder::metadata(&metadata);
        
        // Publish to relay
        let output = self.nostr_client.inner_client()
            .send_event_builder(event_builder)
            .await
            .map_err(|e| anyhow!("Failed to publish Lightning address: {}", e))?;
        
        // Store Lightning address locally
        self.lightning_address = Some(lightning_address.to_string());
        
        eprintln!("[wallet] Published Lightning address {} for npub {}", 
            lightning_address, state.agent_npub);
        
        Ok(output.val)
    }
    
    /// Generate subscription invoice via Strike Business API
    /// 
    /// Per Bible line 916: Generate subscription invoices via Strike Business API (not NWC)
    /// Per Bible lines 522-523: Use Strike Business API for invoice generation
    /// Human pays monthly subscription to SIGNEDBYME's operator Strike account
    /// 
    /// This method does NOT use NWC - subscriptions go to the operator's Strike account.
    pub async fn generate_subscription_invoice(
        &self,
        amount_sats: u64,
        strike_api_key: &str,
        correlation_id: &str,
    ) -> Result<SubscriptionInvoice> {
        // Step 1: Create invoice via Strike API
        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 {
                // Strike expects amount in the smallest unit
                // For BTC, this is satoshis
                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))?;
        
        // Step 2: Get the BOLT11 invoice via quote endpoint
        let quote_url = format!("{}/invoices/{}/quote", STRIKE_API_URL, invoice_response.invoice_id);
        
        let quote_response = self.http_client
            .post(&quote_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))?;
        
        // Calculate expiry (1 hour from now)
        let expires_at = current_timestamp() + 3600;
        
        Ok(SubscriptionInvoice {
            bolt11: quote.ln_invoice,
            amount_sats,
            description: "SIGNEDBYME monthly subscription".to_string(),
            expires_at,
        })
    }
    
    /// Watch subscription expiry and trigger renewal
    /// 
    /// Per Bible: Watch kind 28250 expires_at on NOSTR — trigger renewal 72 hours before expiry
    pub async fn check_subscription_renewal_needed<S: SecureStorage>(
        &self,
        identity: &AgentIdentity<S>,
    ) -> Result<bool> {
        // Get agent's npub
        let state = identity.load()?;
        let agent_npub = &state.agent_npub;
        
        // Query kind 28250 delegation events for this agent
        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); // No delegation = no renewal needed
        }
        
        // Find the most recent delegation event
        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);
                    }
                }
            }
        }
        
        // Check if within renewal window (72 hours before expiry)
        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)
    }
    
    /// Create invoice via NWC for receiving payment (20% allocation)
    /// 
    /// Per Bible line 901: Agent's NWC wallet receives 20% monthly subscription allocation
    /// 
    /// Note: This uses the agent's NWC wallet, NOT Strike.
    /// The NWC connection must be initialized with a valid connection URI.
    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"))?;
        
        // Parse and create NWC client
        let uri = NostrWalletConnectURI::parse(nwc_uri)
            .map_err(|e| anyhow!("Invalid NWC URI: {}", e))?;
        
        let nwc_client = nwc::NWC::new(uri);
        
        // Create invoice request - amount in millisats
        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)
    }
    
    /// Get wallet balance via NWC
    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))?;
        
        // Balance is in millisats, convert to sats
        Ok(balance / 1000)
    }
    
    /// Pay invoice via NWC
    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);
        
        // pay_invoice takes a BOLT11 string directly, returns preimage string
        let preimage = nwc_client.pay_invoice(bolt11.to_string()).await
            .map_err(|e| anyhow!("Failed to pay invoice via NWC: {}", e))?;
        
        Ok(preimage)
    }
    
    /// Get Lightning address
    pub fn lightning_address(&self) -> Option<&str> {
        self.lightning_address.as_deref()
    }
    
    /// Check if NWC is configured
    pub fn is_nwc_configured(&self) -> bool {
        self.nwc_uri.is_some()
    }
}

/// Parsed delegation content for expiry checking
#[derive(Debug, Deserialize)]
struct DelegationContent {
    expires_at: Option<String>,
}

/// Parse expiry timestamp (Unix seconds or ISO 8601)
fn parse_expiry(expires_at: &Option<String>) -> Option<u64> {
    let s = expires_at.as_ref()?;
    
    // Try Unix timestamp
    if let Ok(ts) = s.parse::<u64>() {
        return Some(ts);
    }
    
    // Try ISO 8601 (basic parsing)
    parse_iso8601(s)
}

/// Simple ISO 8601 parser
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)
}

/// Get current Unix timestamp
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() {
        // Valid
        assert!("user@domain.com".contains('@'));
        
        // Invalid
        assert!(!"invalid-address".contains('@'));
    }
}