Skip to main content

paygress/
nostr.rs

1// Nostr client for receiving pod provisioning events with private messaging
2use anyhow::{Context, Result};
3use nostr_sdk::{Client, Keys, Filter, Kind, RelayPoolNotification, Url, EventBuilder, Tag, ToBech32, Timestamp};
4use nostr_sdk::nips::nip59::UnwrappedGift;
5use nostr_sdk::nips::nip04;
6use serde::{Deserialize, Serialize};
7use std::pin::Pin;
8use std::future::Future;
9use std::sync::Arc;
10use tokio::sync::Mutex;
11use tracing::{debug, error, info, warn};
12
13// Custom event kinds for Paygress provider discovery
14pub const KIND_PROVIDER_OFFER: u16 = 38383;
15pub const KIND_PROVIDER_HEARTBEAT: u16 = 38384;
16#[derive(Clone, Debug)]
17pub struct RelayConfig {
18    pub relays: Vec<String>,
19    pub private_key: Option<String>,
20}
21
22#[derive(Debug, Clone, Deserialize, Serialize)]
23pub struct NostrEvent {
24    pub id: String,
25    pub pubkey: String,
26    pub created_at: u64,
27    pub kind: u32,
28    pub tags: Vec<Vec<String>>,
29    pub content: String,
30    pub sig: String,
31    pub message_type: String, // "nip04" or "nip17" to track which method was used
32}
33
34
35#[derive(Clone)]
36pub struct NostrRelaySubscriber {
37    client: Client,
38    keys: Keys,
39    // config field removed - not used in current implementation
40}
41
42impl NostrRelaySubscriber {
43    pub async fn new(config: RelayConfig) -> Result<Self> {
44        let keys = match &config.private_key {
45            Some(private_key_hex) if !private_key_hex.is_empty() => {
46                // Parse as nsec format (nostr private key)
47                if private_key_hex.starts_with("nsec1") {
48                    Keys::parse(private_key_hex)
49                        .context("Invalid nsec private key format")?
50                } else {
51                    // Assume hex format for backward compatibility
52                    Keys::parse(private_key_hex)
53                        .context("Invalid private key format")?
54                }
55            }
56            _ => {
57                // Generate a new key if none provided
58                Keys::generate()
59            }
60        };
61
62        let client = Client::new(&keys);
63
64        // Add relays
65        for relay_url in &config.relays {
66            info!("Adding relay: {}", relay_url);
67            let url = Url::parse(relay_url)
68                .with_context(|| format!("Invalid relay URL: {}", relay_url))?;
69            client.add_relay(url).await?;
70        }
71
72        info!("Connecting to {} relays...", config.relays.len());
73        client.connect().await;
74        
75        // Wait a moment for connections to establish
76        tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await;
77        
78        info!("Connected to {} relays", config.relays.len());
79        info!("Service public key (npub): {}", keys.public_key().to_bech32().unwrap());
80
81        Ok(Self { client, keys })
82    }
83
84    pub fn public_key(&self) -> nostr_sdk::PublicKey {
85        self.keys.public_key()
86    }
87
88    pub async fn subscribe_to_pod_events<F>(&self, handler: F) -> Result<()>
89    where
90        F: Fn(NostrEvent) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send>> + Send + Sync + 'static,
91    {
92        // Subscribe to messages sent TO us (filter by p-tag)
93        let nip04_filter = Filter::new()
94            .kind(Kind::EncryptedDirectMessage)
95            .pubkeys(vec![self.keys.public_key()]) // Sets #p tag
96            .limit(0);
97
98        let nip17_filter = Filter::new()
99            .kind(Kind::GiftWrap)
100            .pubkeys(vec![self.keys.public_key()]) // Sets #p tag
101            .limit(0);
102
103        let _ = self.client.subscribe(vec![nip04_filter, nip17_filter], None).await;
104        info!("Subscribed to NIP-04 (Encrypted Direct Messages) and NIP-17 (Gift Wrap) messages for pod provisioning and top-up requests");
105
106        // Handle incoming events
107        self.client.handle_notifications(|notification| async {
108            if let RelayPoolNotification::Event { relay_url: _, subscription_id: _, event } = notification {
109                match event.kind {
110                    Kind::GiftWrap => {
111                        info!("Received NIP-17 Gift Wrap message: {}", event.id);
112                        
113                        // Unwrap the Gift Wrap to get the inner message
114                        match self.client.unwrap_gift_wrap(&event).await {
115                            Ok(UnwrappedGift { rumor, sender }) => {
116                                info!("Unwrapped Gift Wrap from sender: {}, rumor kind: {}", sender, rumor.kind);
117                                
118                                // Check if the rumor is a private direct message
119                                if rumor.kind == Kind::PrivateDirectMessage {
120                                    debug!("NIP-17 rumor is PrivateDirectMessage. Content length: {}", rumor.content.len());
121                                    
122                                    // Create a NostrEvent from the unwrapped rumor with NIP-17 flag
123                                    let nostr_event = NostrEvent {
124                                        id: rumor.id.map(|id| id.to_hex()).unwrap_or_else(|| "unknown".to_string()),
125                                        pubkey: rumor.pubkey.to_hex(),
126                                        created_at: rumor.created_at.as_u64(),
127                                        kind: rumor.kind.as_u32(),
128                                        tags: rumor.tags.iter().map(|tag| {
129                                            tag.as_vec().iter().map(|s| s.to_string()).collect()
130                                        }).collect(),
131                                        content: rumor.content,
132                                        sig: "unsigned".to_string(), // UnsignedEvent doesn't have a signature
133                                        message_type: "nip17".to_string(), // Flag to indicate NIP-17
134                                    };
135                                    
136                                    match handler(nostr_event).await {
137                                        Ok(()) => {
138                                            info!("Successfully processed NIP-17 private message: {}", event.id);
139                                        }
140                                        Err(e) => {
141                                            error!("Failed to process NIP-17 private message {}: {}", event.id, e);
142                                        }
143                                    }
144                                } else {
145                                    info!("Rumor is not a private direct message, kind: {}", rumor.kind);
146                                }
147                            }
148                            Err(e) => {
149                                error!("Failed to unwrap Gift Wrap {}: {}", event.id, e);
150                            }
151                        }
152                    }
153                    Kind::EncryptedDirectMessage => {
154                        info!("Received NIP-04 Encrypted Direct Message: {}", event.id);
155                        
156                        // Decrypt the NIP-04 message using NIP-04 module
157                        match self.keys.secret_key() {
158                            Ok(secret_key) => {
159                                match nip04::decrypt(&secret_key, &event.pubkey, &event.content) {
160                                    Ok(decrypted_content) => {
161                                        debug!("Decrypted NIP-04 message. Length: {}", decrypted_content.len());
162                                        
163                                        // Create a NostrEvent from the decrypted message with NIP-04 flag
164                                        let nostr_event = NostrEvent {
165                                            id: event.id.to_hex(),
166                                            pubkey: event.pubkey.to_hex(),
167                                            created_at: event.created_at.as_u64(),
168                                            kind: event.kind.as_u32(),
169                                            tags: event.tags.iter().map(|tag| {
170                                                tag.as_vec().iter().map(|s| s.to_string()).collect()
171                                            }).collect(),
172                                            content: decrypted_content,
173                                            sig: event.sig.to_string(),
174                                            message_type: "nip04".to_string(), // Flag to indicate NIP-04
175                                        };
176                                        
177                                        match handler(nostr_event).await {
178                                            Ok(()) => {
179                                                info!("Successfully processed NIP-04 private message: {}", event.id);
180                                            }
181                                            Err(e) => {
182                                                error!("Failed to process NIP-04 private message {}: {}", event.id, e);
183                                            }
184                                        }
185                                    }
186                                    Err(e) => {
187                                        error!("Failed to decrypt NIP-04 message {}: {}", event.id, e);
188                                    }
189                                }
190                            }
191                            Err(e) => {
192                                error!("Failed to get secret key for NIP-04 decryption: {}", e);
193                            }
194                        }
195                    }
196                    _ => {
197                        info!("Received unsupported event kind: {}", event.kind);
198                    }
199                }
200            }
201            Ok(false) // Continue listening
202        }).await?;
203
204        Ok(())
205    }
206
207    pub async fn publish_offer(&self, offer: OfferEventContent) -> Result<String> {
208        let content = serde_json::to_string(&offer)?;
209        info!("Publishing offer event with content: {}", content);
210        
211        let tags = vec![
212            Tag::hashtag("paygress"),
213            Tag::hashtag("offer"),
214        ];
215        
216        info!("Creating event with kind 999 and {} tags", tags.len());
217        let builder = EventBuilder::new(Kind::Custom(999), content, tags);
218        let event = builder.to_event(&self.keys)?;
219        let event_id = event.id.to_hex();
220        
221        info!("Event created with ID: {}", event_id);
222        info!("Sending offer event to relays: {}", event_id);
223        
224        match self.client.send_event(event).await {
225            Ok(res) => {
226                info!("✅ Successfully published offer event: {} and {:?}", event_id, res);
227                Ok(event_id)
228            }
229            Err(e) => {
230                error!("❌ Failed to send offer event: {}", e);
231                Err(e.into())
232            }
233        }
234    }
235
236    // Generic method to send an encrypted private message (supports both NIP-04 and NIP-17)
237    pub async fn send_encrypted_private_message(
238        &self,
239        receiver_pubkey: &str,
240        content: String,
241        message_type: &str,
242    ) -> Result<String> {
243        let receiver_pubkey_parsed = nostr_sdk::PublicKey::parse(receiver_pubkey)?;
244
245        match message_type {
246            "nip04" => {
247                match self.keys.secret_key() {
248                    Ok(secret_key) => {
249                        let encrypted_content = nip04::encrypt(&secret_key, &receiver_pubkey_parsed, &content)?;
250                        let receiver_tag = Tag::public_key(receiver_pubkey_parsed);
251                        let alt_tag = Tag::parse(&["alt", "Private Message"])?;
252                        
253                        let event_builder = EventBuilder::new(Kind::EncryptedDirectMessage, encrypted_content, [receiver_tag, alt_tag]);
254                        let event = event_builder.to_event(&self.keys)?;
255                        let event_id = self.client.send_event(event).await?;
256                        info!("Sent NIP-04 message to {}: {:?}", receiver_pubkey, event_id);
257                        Ok(event_id.to_hex())
258                    }
259                    Err(e) => {
260                        error!("Failed to get secret key for NIP-04 encryption: {}", e);
261                        Err(e.into())
262                    }
263                }
264            }
265            "nip17" | _ => {
266                // Default to NIP-17 if not specified or nip17
267                let event_id = self.client.send_private_msg(receiver_pubkey_parsed, content, None).await?;
268                info!("Sent NIP-17 message to {}: {:?}", receiver_pubkey, event_id);
269                Ok(event_id.to_hex())
270            }
271        }
272    }
273
274    // Send access details via private encrypted message
275    pub async fn send_access_details_private_message(
276        &self, 
277        request_pubkey: &str,
278        details: AccessDetailsContent,
279        message_type: &str
280    ) -> Result<String> {
281        let details_json = serde_json::to_string(&details)?;
282        self.send_encrypted_private_message(request_pubkey, details_json, message_type).await
283    }
284
285    // Send status response via private encrypted message
286    pub async fn send_status_response(
287        &self, 
288        request_pubkey: &str,
289        response: StatusResponseContent,
290        message_type: &str
291    ) -> Result<String> {
292        let response_json = serde_json::to_string(&response)?;
293        self.send_encrypted_private_message(request_pubkey, response_json, message_type).await
294    }
295
296    // Convenience helper to send error response with individual fields
297    pub async fn send_error_response(
298        &self,
299        request_pubkey: &str,
300        error_type: &str,
301        message: &str,
302        details: Option<&str>,
303        message_type: &str,
304    ) -> Result<String> {
305        let error = ErrorResponseContent {
306            error_type: error_type.to_string(),
307            message: message.to_string(),
308            details: details.map(|s| s.to_string()),
309        };
310        self.send_error_response_private_message(request_pubkey, error, message_type).await
311    }
312
313    // Send error response via private encrypted message
314    pub async fn send_error_response_private_message(
315        &self, 
316        request_pubkey: &str,
317        error: ErrorResponseContent,
318        message_type: &str
319    ) -> Result<String> {
320        let error_json = serde_json::to_string(&error)?;
321        self.send_encrypted_private_message(request_pubkey, error_json, message_type).await
322    }
323
324    // Send top-up response via private encrypted message
325    pub async fn send_topup_response_private_message(
326        &self, 
327        request_pubkey: &str,
328        response: TopUpResponseContent,
329        message_type: &str
330    ) -> Result<String> {
331        let response_json = serde_json::to_string(&response)?;
332        self.send_encrypted_private_message(request_pubkey, response_json, message_type).await
333    }
334
335
336
337    // Get the underlying Nostr client
338    pub fn client(&self) -> &Client {
339        &self.client
340    }
341
342    // NEW: Get service public key for users
343    pub fn get_service_public_key(&self) -> String {
344        self.keys.public_key().to_hex()
345    }
346
347    fn convert_event(&self, event: &nostr_sdk::Event) -> NostrEvent {
348        NostrEvent {
349            id: event.id.to_hex(),
350            pubkey: event.pubkey.to_hex(),
351            created_at: event.created_at.as_u64(),
352            kind: event.kind.as_u32(),
353            tags: event.tags.iter().map(|tag| {
354                tag.as_vec().iter().map(|s| s.to_string()).collect()
355            }).collect(),
356            content: event.content.clone(),
357            sig: event.sig.to_string(),
358            message_type: "unknown".to_string(), // Default value since this function doesn't know the context
359        }
360    }
361
362    /// Wait for a private decrypted message from a specific sender
363    pub async fn wait_for_decrypted_message(&self, sender_pubkey: &str, timeout_secs: u64) -> Result<NostrEvent> {
364        let sender_pk = nostr_sdk::PublicKey::parse(sender_pubkey)?;
365        let receiver_pk = self.keys.public_key();
366        
367        let (tx, mut rx) = tokio::sync::mpsc::channel(1);
368        let tx = Arc::new(Mutex::new(Some(tx)));
369        let client = self.client.clone();
370        let receiver_keys = self.keys.clone();
371        let timeout = tokio::time::Duration::from_secs(timeout_secs);
372        
373        // Subscribe to messages sent TO us
374        let filter = Filter::new()
375            .pubkeys(vec![receiver_pk])
376            .kinds(vec![Kind::EncryptedDirectMessage, Kind::GiftWrap]);
377        
378        let _ = client.subscribe(vec![filter], None).await;
379
380        // Use tokio::select to handle timeout and notification processing
381        let result = tokio::select! {
382            notification_res = client.handle_notifications(|notification| {
383                let tx = tx.clone();
384                let receiver_keys = receiver_keys.clone();
385                let sender_pk = sender_pk.clone();
386                let client = client.clone();
387                
388                async move {
389                    if let RelayPoolNotification::Event { event, .. } = notification {
390                        let mut event_to_send = None;
391                        
392                        match event.kind {
393                            Kind::GiftWrap => {
394                                // GiftWrap might be NIP-17
395                                if let Ok(UnwrappedGift { rumor, sender }) = client.unwrap_gift_wrap(&event).await {
396                                    if sender == sender_pk && rumor.kind == Kind::PrivateDirectMessage {
397                                        event_to_send = Some(NostrEvent {
398                                            id: rumor.id.map(|id| id.to_hex()).unwrap_or_default(),
399                                            pubkey: sender.to_hex(),
400                                            created_at: rumor.created_at.as_u64(),
401                                            kind: rumor.kind.as_u32(),
402                                            tags: rumor.tags.iter().map(|tag| tag.as_vec().iter().map(|s| s.to_string()).collect()).collect(),
403                                            content: rumor.content,
404                                            sig: String::new(),
405                                            message_type: "nip17".to_string(),
406                                        });
407                                    }
408                                }
409                            }
410                            Kind::EncryptedDirectMessage => {
411                                if event.pubkey == sender_pk {
412                                    if let Ok(secret_key) = receiver_keys.secret_key() {
413                                        if let Ok(content) = nip04::decrypt(&secret_key, &event.pubkey, &event.content) {
414                                            event_to_send = Some(NostrEvent {
415                                                id: event.id.to_hex(),
416                                                pubkey: event.pubkey.to_hex(),
417                                                created_at: event.created_at.as_u64(),
418                                                kind: event.kind.as_u32(),
419                                                tags: event.tags.iter().map(|tag| tag.as_vec().iter().map(|s| s.to_string()).collect()).collect(),
420                                                content,
421                                                sig: event.sig.to_string(),
422                                                message_type: "nip04".to_string(),
423                                            });
424                                        }
425                                    }
426                                }
427                            }
428                            _ => {}
429                        }
430                        
431                        if let Some(ev) = event_to_send {
432                            let mut lock = tx.lock().await;
433                            if let Some(sender) = lock.take() {
434                                let _ = sender.send(ev).await;
435                                return Ok(true); // Stop handling notifications
436                            }
437                        }
438                    }
439                    Ok(false)
440                }
441            }) => {
442                match notification_res {
443                    Ok(_) => rx.recv().await.ok_or_else(|| anyhow::anyhow!("Channel closed")),
444                    Err(e) => Err(anyhow::anyhow!("Notification handler error: {}", e)),
445                }
446            }
447            _ = tokio::time::sleep(timeout) => {
448                Err(anyhow::anyhow!("Timeout waiting for response from {}", sender_pubkey))
449            }
450        };
451        
452        result
453    }
454}
455
456pub fn default_relay_config() -> RelayConfig {
457    RelayConfig {
458        relays: vec![
459            "wss://relay.damus.io".to_string(),
460            "wss://nos.lol".to_string(),
461            "wss://relay.nostr.band".to_string(),
462        ],
463        private_key: None,
464    }
465}
466
467pub fn custom_relay_config(relays: Vec<String>, private_key: Option<String>) -> RelayConfig {
468    RelayConfig { relays, private_key }
469}
470
471#[derive(Debug, Clone, Serialize, Deserialize)]
472pub struct PodSpec {
473    pub id: String, // Unique identifier for this spec (e.g., "basic", "standard", "premium")
474    pub name: String, // Human-readable name (e.g., "Basic", "Standard", "Premium")
475    pub description: String, // Description of the spec
476    pub cpu_millicores: u64, // CPU in millicores (1000 millicores = 1 CPU core)
477    pub memory_mb: u64, // Memory in MB
478    pub rate_msats_per_sec: u64, // Payment rate for this spec
479}
480
481#[derive(Debug, Clone, Serialize, Deserialize)]
482pub struct OfferEventContent {
483    pub minimum_duration_seconds: u64,
484    pub whitelisted_mints: Vec<String>,
485    pub pod_specs: Vec<PodSpec>, // Multiple pod specifications offered
486}
487
488#[derive(Debug, Clone, Serialize, Deserialize)]
489pub struct AccessDetailsContent {
490    pub pod_npub: String, // Pod's NPUB identifier
491    pub node_port: u16, // SSH port for direct access
492    pub expires_at: String, // Pod expiration time
493    pub cpu_millicores: u64, // CPU allocation in millicores
494    pub memory_mb: u64, // Memory allocation in MB
495    pub pod_spec_name: String, // Human-readable spec name
496    pub pod_spec_description: String, // Spec description
497    pub instructions: Vec<String>, // SSH connection instructions
498}
499
500#[derive(Debug, Clone, Serialize, Deserialize)]
501pub struct ErrorResponseContent {
502    pub error_type: String, // Type of error (e.g., "insufficient_payment", "invalid_spec", "image_not_found")
503    pub message: String, // Human-readable error message
504    pub details: Option<String>, // Additional error details
505}
506
507#[derive(Debug, Clone, Serialize, Deserialize)]
508pub struct TopUpResponseContent {
509    pub success: bool,
510    pub pod_npub: String,
511    pub extended_duration_seconds: u64,
512    pub new_expires_at: String,
513    pub message: String,
514}
515
516// NEW: Encrypted request structure
517#[derive(Debug, Clone, Serialize, Deserialize)]
518pub struct EncryptedSpawnPodRequest {
519    pub cashu_token: String,
520    pub pod_spec_id: Option<String>, // Optional: Which pod spec to use (defaults to first available)
521    pub pod_image: String, // Required: Container image to use for the pod
522    pub ssh_username: String,
523    pub ssh_password: String,
524}
525
526// NEW: Encrypted top-up request structure
527#[derive(Debug, Clone, Serialize, Deserialize)]
528pub struct EncryptedTopUpPodRequest {
529    pub pod_npub: String,    // Pod's NPUB identifier
530    pub cashu_token: String,
531}
532
533// NEW: Helper function to send private message provisioning request
534pub async fn send_provisioning_request_private_message(
535    client: &Client,
536    service_pubkey: &str,
537    request: EncryptedSpawnPodRequest,
538) -> Result<String> {
539    let request_json = serde_json::to_string(&request)?;
540    
541    // Send as private message
542    let service_pubkey_parsed = nostr_sdk::PublicKey::parse(service_pubkey)?;
543    let event_id = client.send_private_msg(service_pubkey_parsed, request_json, None).await?;
544
545    Ok(event_id.to_hex())
546}
547
548// NEW: Helper function to parse private message content
549/// Unified request type for private messages
550#[derive(Debug, Clone, Serialize, Deserialize)]
551#[serde(untagged)]
552pub enum PrivateRequest {
553    Spawn(EncryptedSpawnPodRequest),
554    TopUp(EncryptedTopUpPodRequest),
555    Status(StatusRequestContent),
556}
557
558pub fn parse_private_message_content(content: &str) -> Result<PrivateRequest> {
559    match serde_json::from_str::<PrivateRequest>(content) {
560        Ok(request) => Ok(request),
561        Err(e) => {
562            // Provide detailed error information, but truncate content to avoid huge log strings
563            let truncated_content = if content.len() > 100 {
564                format!("{}...", &content[..100])
565            } else {
566                content.to_string()
567            };
568            Err(anyhow::anyhow!("JSON parsing failed: {}. Content: '{}'", e, truncated_content))
569        }
570    }
571}
572
573// ==================== Provider Discovery Structures ====================
574
575/// Capacity information for a provider
576#[derive(Debug, Clone, Serialize, Deserialize)]
577pub struct CapacityInfo {
578    pub cpu_available: u64,      // Available CPU in millicores
579    pub memory_mb_available: u64, // Available memory in MB
580    pub storage_gb_available: u64, // Available storage in GB
581}
582
583/// Provider offer content published to Nostr (Kind 38383)
584/// This is a replaceable event that describes what a provider offers
585#[derive(Debug, Clone, Serialize, Deserialize)]
586pub struct ProviderOfferContent {
587    pub provider_npub: String,
588    pub hostname: String,
589    pub location: Option<String>,
590    pub capabilities: Vec<String>,  // ["lxc", "vm"]
591    pub specs: Vec<PodSpec>,
592    pub whitelisted_mints: Vec<String>,
593    pub uptime_percent: f32,
594    pub total_jobs_completed: u64,
595    pub api_endpoint: Option<String>,
596}
597
598/// Heartbeat content published to Nostr (Kind 38384)
599/// Published every 60 seconds to prove liveness
600#[derive(Debug, Clone, Serialize, Deserialize)]
601pub struct HeartbeatContent {
602    pub provider_npub: String,
603    pub timestamp: u64,
604    pub active_workloads: u32,
605    pub available_capacity: CapacityInfo,
606}
607
608/// Provider info as seen by discovery clients
609#[derive(Debug, Clone, Serialize, Deserialize)]
610pub struct ProviderInfo {
611    pub npub: String,
612    pub hostname: String,
613    pub location: Option<String>,
614    pub capabilities: Vec<String>,
615    pub specs: Vec<PodSpec>,
616    pub whitelisted_mints: Vec<String>,
617    pub uptime_percent: f32,
618    pub total_jobs_completed: u64,
619    pub last_seen: u64,  // Timestamp of last heartbeat
620    pub is_online: bool,
621}
622
623/// Filter for querying providers
624#[derive(Debug, Clone, Default)]
625pub struct ProviderFilter {
626    pub capability: Option<String>,
627    pub min_uptime: Option<f32>,
628    pub min_memory_mb: Option<u64>,
629    pub min_cpu: Option<u64>,
630}
631
632impl NostrRelaySubscriber {
633    /// Publish a provider offer event (Kind 38383 - replaceable)
634    pub async fn publish_provider_offer(&self, offer: ProviderOfferContent) -> Result<String> {
635        let content = serde_json::to_string(&offer)?;
636        info!("Publishing provider offer for {}", offer.hostname);
637        
638        // Use "d" tag for replaceable event (NIP-33 parameterized replaceable)
639        let tags = vec![
640            Tag::hashtag("paygress"),
641            Tag::hashtag("compute"),
642            Tag::parse(&["d", &offer.provider_npub])?,
643        ];
644        
645        let builder = EventBuilder::new(Kind::Custom(KIND_PROVIDER_OFFER), content, tags);
646        let event = builder.to_event(&self.keys)?;
647        let event_id = event.id.to_hex();
648        
649        match self.client.send_event(event).await {
650            Ok(res) => {
651                info!("✅ Published provider offer: {} ({:?})", event_id, res);
652                Ok(event_id)
653            }
654            Err(e) => {
655                error!("❌ Failed to publish provider offer: {}", e);
656                Err(e.into())
657            }
658        }
659    }
660
661    /// Publish a heartbeat event (Kind 38384)
662    pub async fn publish_heartbeat(&self, heartbeat: HeartbeatContent) -> Result<String> {
663        let content = serde_json::to_string(&heartbeat)?;
664        
665        let tags = vec![
666            Tag::hashtag("paygress-heartbeat"),
667            Tag::public_key(nostr_sdk::PublicKey::parse(&heartbeat.provider_npub)?),
668        ];
669        
670        let builder = EventBuilder::new(Kind::Custom(KIND_PROVIDER_HEARTBEAT), content, tags);
671        let event = builder.to_event(&self.keys)?;
672        let event_id = event.id.to_hex();
673        
674        match self.client.send_event(event).await {
675            Ok(_) => {
676                info!("💓 Heartbeat published: {}", event_id);
677                Ok(event_id)
678            }
679            Err(e) => {
680                warn!("Failed to publish heartbeat: {}", e);
681                Err(e.into())
682            }
683        }
684    }
685
686    /// Query all provider offers from relays
687    pub async fn query_providers(&self) -> Result<Vec<ProviderOfferContent>> {
688        let filter = Filter::new()
689            .kind(Kind::Custom(KIND_PROVIDER_OFFER))
690            .hashtag("paygress");
691
692        let events = self.client.get_events_of(vec![filter], Some(std::time::Duration::from_secs(5))).await?;
693        
694        let mut providers = Vec::new();
695        for event in events {
696            match serde_json::from_str::<ProviderOfferContent>(&event.content) {
697                Ok(offer) => providers.push(offer),
698                Err(e) => {
699                    warn!("Failed to parse provider offer {}: {}", event.id, e);
700                }
701            }
702        }
703        
704        info!("Found {} providers", providers.len());
705        Ok(providers)
706    }
707
708    /// Query heartbeats for a specific provider since a given time
709    pub async fn query_heartbeats(&self, provider_npub: &str, since_secs: u64) -> Result<Vec<HeartbeatContent>> {
710        let provider_pubkey = nostr_sdk::PublicKey::parse(provider_npub)?;
711        
712        let filter = Filter::new()
713            .kind(Kind::Custom(KIND_PROVIDER_HEARTBEAT))
714            .author(provider_pubkey)
715            .since(Timestamp::from(since_secs));
716
717        let events = self.client.get_events_of(vec![filter], Some(std::time::Duration::from_secs(5))).await?;
718        
719        let mut heartbeats = Vec::new();
720        for event in events {
721            match serde_json::from_str::<HeartbeatContent>(&event.content) {
722                Ok(hb) => heartbeats.push(hb),
723                Err(e) => {
724                    warn!("Failed to parse heartbeat {}: {}", event.id, e);
725                }
726            }
727        }
728        
729        Ok(heartbeats)
730    }
731
732    /// Get the latest heartbeat for a provider (to check if online)
733    pub async fn get_latest_heartbeat(&self, provider_npub: &str) -> Result<Option<HeartbeatContent>> {
734        let provider_pubkey = nostr_sdk::PublicKey::parse(provider_npub)?;
735        
736        // Look for heartbeats in the last 5 minutes
737        let five_mins_ago = std::time::SystemTime::now()
738            .duration_since(std::time::UNIX_EPOCH)?
739            .as_secs() - 300;
740        
741        let filter = Filter::new()
742            .kind(Kind::Custom(KIND_PROVIDER_HEARTBEAT))
743            .author(provider_pubkey)
744            .since(Timestamp::from(five_mins_ago))
745            .limit(1);
746
747        let events = self.client.get_events_of(vec![filter], Some(std::time::Duration::from_secs(3))).await?;
748        
749        if let Some(event) = events.first() {
750            match serde_json::from_str::<HeartbeatContent>(&event.content) {
751                Ok(hb) => return Ok(Some(hb)),
752                Err(e) => warn!("Failed to parse heartbeat: {}", e),
753            }
754        }
755        
756        Ok(None)
757    }
758
759    /// Get the latest heartbeats for multiple providers in a single batch query
760    pub async fn get_latest_heartbeats_multi(&self, provider_npubs: Vec<String>) -> Result<std::collections::HashMap<String, HeartbeatContent>> {
761        if provider_npubs.is_empty() {
762            return Ok(std::collections::HashMap::new());
763        }
764
765        let mut pubkeys = Vec::new();
766        for npub in provider_npubs {
767            if let Ok(pk) = nostr_sdk::PublicKey::parse(&npub) {
768                pubkeys.push(pk);
769            }
770        }
771
772        // Look for heartbeats in the last 5 minutes
773        let five_mins_ago = std::time::SystemTime::now()
774            .duration_since(std::time::UNIX_EPOCH)?
775            .as_secs() - 300;
776        
777        // Query for ANY heartbeat from ANY of these authors
778        let filter = Filter::new()
779            .kind(Kind::Custom(KIND_PROVIDER_HEARTBEAT))
780            .authors(pubkeys)
781            .since(Timestamp::from(five_mins_ago));
782
783        // Use a short timeout of 3 seconds for fast feedback
784        let events = self.client.get_events_of(vec![filter], Some(std::time::Duration::from_secs(3))).await?;
785        
786        let mut heartbeats = std::collections::HashMap::new();
787        
788        // Process events, keeping only the latest for each provider
789        for event in events {
790            if let Ok(hb) = serde_json::from_str::<HeartbeatContent>(&event.content) {
791                match heartbeats.entry(hb.provider_npub.clone()) {
792                    std::collections::hash_map::Entry::Occupied(mut entry) => {
793                        let existing: &HeartbeatContent = entry.get();
794                        if hb.timestamp > existing.timestamp {
795                            entry.insert(hb);
796                        }
797                    }
798                    std::collections::hash_map::Entry::Vacant(entry) => {
799                        entry.insert(hb);
800                    }
801                }
802            }
803        }
804        
805        Ok(heartbeats)
806    }
807
808    /// Calculate uptime percentage for a provider over the last N days
809    pub async fn calculate_uptime(&self, provider_npub: &str, days: u32) -> Result<f32> {
810        let now = std::time::SystemTime::now()
811            .duration_since(std::time::UNIX_EPOCH)?
812            .as_secs();
813        let since = now - (days as u64 * 24 * 60 * 60);
814        
815        let heartbeats = self.query_heartbeats(provider_npub, since).await?;
816        
817        if heartbeats.is_empty() {
818            return Ok(0.0);
819        }
820        
821        // Expected heartbeats (one per minute)
822        let expected = (days as f32) * 24.0 * 60.0;
823        let actual = heartbeats.len() as f32;
824        
825        Ok((actual / expected * 100.0).min(100.0))
826    }
827}
828
829#[derive(Debug, Clone, Serialize, Deserialize)]
830pub struct StatusRequestContent {
831    pub pod_id: String, // Can be NPUB or container ID
832}
833
834#[derive(Debug, Clone, Serialize, Deserialize)]
835pub struct StatusResponseContent {
836    pub pod_id: String,
837    pub status: String,
838    pub expires_at: String,
839    pub time_remaining_seconds: u64,
840    pub cpu_millicores: u64,
841    pub memory_mb: u64,
842    pub ssh_host: String,
843    pub ssh_port: u16,
844    pub ssh_username: String,
845}