1use anyhow::{Context, Result};
15use serde::{Deserialize, Serialize};
16
17use crate::discovery::DiscoveryClient;
18use crate::nostr::{
19 AccessDetailsContent, EncryptedSpawnPodRequest, EncryptedTopUpPodRequest, ErrorResponseContent,
20 ProviderInfo, StatusRequestContent, StatusResponseContent, TopUpResponseContent,
21};
22
23const DEFAULT_RESPONSE_TIMEOUT_SECS: u64 = 60;
24const DEFAULT_MESSAGE_TYPE: &str = "nip04";
25
26pub struct PaygressClient {
29 discovery: DiscoveryClient,
30 response_timeout_secs: u64,
31 message_type: String,
32}
33
34impl PaygressClient {
35 pub async fn new(relays: Vec<String>, private_key: String) -> Result<Self> {
41 let discovery = DiscoveryClient::new_with_key(relays, private_key).await?;
42 Ok(Self {
43 discovery,
44 response_timeout_secs: DEFAULT_RESPONSE_TIMEOUT_SECS,
45 message_type: DEFAULT_MESSAGE_TYPE.to_string(),
46 })
47 }
48
49 pub fn with_response_timeout_secs(mut self, secs: u64) -> Self {
52 self.response_timeout_secs = secs;
53 self
54 }
55
56 pub fn with_message_type(mut self, message_type: impl Into<String>) -> Self {
60 self.message_type = message_type.into();
61 self
62 }
63
64 pub fn npub(&self) -> String {
66 self.discovery.get_npub()
67 }
68
69 pub fn discovery(&self) -> &DiscoveryClient {
71 &self.discovery
72 }
73
74 pub async fn list_offers(
76 &self,
77 filter: Option<crate::nostr::ProviderFilter>,
78 ) -> Result<Vec<ProviderInfo>> {
79 self.discovery.list_providers(filter).await
80 }
81
82 pub async fn spawn(&self, provider_npub: &str, request: SpawnRequest) -> Result<SpawnOutcome> {
84 let payload = EncryptedSpawnPodRequest {
85 cashu_token: request.cashu_token,
86 pod_spec_id: request.pod_spec_id,
87 pod_image: request.pod_image,
88 ssh_username: request.ssh_username,
89 ssh_password: request.ssh_password,
90 template_slug: None,
91 replication: None,
92 primary_npub: None,
93 workload_id: None,
94 volume_encryption: None,
95 };
96 let json = serde_json::to_string(&payload)?;
97 self.send_and_parse(provider_npub, json, parse_spawn_response)
98 .await
99 }
100
101 pub async fn topup(&self, provider_npub: &str, request: TopupRequest) -> Result<TopupOutcome> {
103 let payload = EncryptedTopUpPodRequest {
104 pod_npub: request.pod_id,
105 cashu_token: request.cashu_token,
106 };
107 let json = serde_json::to_string(&payload)?;
108 self.send_and_parse(provider_npub, json, parse_topup_response)
109 .await
110 }
111
112 pub async fn status(&self, provider_npub: &str, pod_id: String) -> Result<StatusOutcome> {
114 let payload = StatusRequestContent { pod_id };
115 let json = serde_json::to_string(&payload)?;
116 self.send_and_parse(provider_npub, json, parse_status_response)
117 .await
118 }
119
120 async fn send_and_parse<T, F>(
121 &self,
122 provider_npub: &str,
123 request_json: String,
124 parser: F,
125 ) -> Result<T>
126 where
127 F: FnOnce(&str) -> Result<T>,
128 {
129 self.discovery
130 .nostr()
131 .send_encrypted_private_message(provider_npub, request_json, &self.message_type)
132 .await
133 .context("send DM to provider")?;
134
135 let response = self
136 .discovery
137 .nostr()
138 .wait_for_decrypted_message(provider_npub, self.response_timeout_secs)
139 .await
140 .context("wait for provider response")?;
141
142 parser(&response.content)
143 }
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct SpawnRequest {
152 pub cashu_token: String,
154 pub pod_spec_id: Option<String>,
157 pub pod_image: String,
159 pub ssh_username: String,
160 pub ssh_password: String,
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct TopupRequest {
166 pub pod_id: String,
169 pub cashu_token: String,
170}
171
172#[derive(Debug, Clone)]
179pub enum SpawnOutcome {
180 Success(AccessDetailsContent),
181 Error(ErrorResponseContent),
182 Other(String),
183}
184
185#[derive(Debug, Clone)]
186pub enum TopupOutcome {
187 Success(TopUpResponseContent),
188 Error(ErrorResponseContent),
189 Other(String),
190}
191
192#[derive(Debug, Clone)]
193pub enum StatusOutcome {
194 Success(StatusResponseContent),
195 Error(ErrorResponseContent),
196 Other(String),
197}
198
199fn try_parse_error(content: &str) -> Option<ErrorResponseContent> {
205 let v: serde_json::Value = serde_json::from_str(content).ok()?;
206 if v.get("error_type").is_none() || v.get("message").is_none() {
207 return None;
208 }
209 serde_json::from_value(v).ok()
210}
211
212pub fn parse_spawn_response(content: &str) -> Result<SpawnOutcome> {
213 if let Some(err) = try_parse_error(content) {
214 return Ok(SpawnOutcome::Error(err));
215 }
216 if let Ok(details) = serde_json::from_str::<AccessDetailsContent>(content) {
217 return Ok(SpawnOutcome::Success(details));
218 }
219 Ok(SpawnOutcome::Other(content.to_string()))
220}
221
222pub fn parse_topup_response(content: &str) -> Result<TopupOutcome> {
223 if let Some(err) = try_parse_error(content) {
224 return Ok(TopupOutcome::Error(err));
225 }
226 if let Ok(resp) = serde_json::from_str::<TopUpResponseContent>(content) {
227 return Ok(TopupOutcome::Success(resp));
228 }
229 Ok(TopupOutcome::Other(content.to_string()))
230}
231
232pub fn parse_status_response(content: &str) -> Result<StatusOutcome> {
233 if let Some(err) = try_parse_error(content) {
234 return Ok(StatusOutcome::Error(err));
235 }
236 if let Ok(resp) = serde_json::from_str::<StatusResponseContent>(content) {
237 return Ok(StatusOutcome::Success(resp));
238 }
239 Ok(StatusOutcome::Other(content.to_string()))
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245
246 fn err_json() -> String {
247 serde_json::to_string(&ErrorResponseContent {
248 error_type: "token_already_spent".to_string(),
249 message: "This Cashu token has already been spent".to_string(),
250 details: None,
251 })
252 .unwrap()
253 }
254
255 fn access_json() -> String {
256 serde_json::to_string(&AccessDetailsContent {
257 pod_npub: "container-42".to_string(),
258 node_port: 30042,
259 expires_at: "2026-04-30T00:00:00Z".to_string(),
260 cpu_millicores: 1000,
261 memory_mb: 1024,
262 pod_spec_name: "Basic".to_string(),
263 pod_spec_description: "1 vCPU, 1GB".to_string(),
264 instructions: vec!["ssh -p 30042 root@host".to_string()],
265 host_address: "host".to_string(),
266 template_ports: Vec::new(),
267 })
268 .unwrap()
269 }
270
271 fn topup_json() -> String {
272 serde_json::to_string(&TopUpResponseContent {
273 success: true,
274 pod_npub: "container-42".to_string(),
275 extended_duration_seconds: 3600,
276 new_expires_at: "2026-04-30T01:00:00Z".to_string(),
277 message: "extended".to_string(),
278 })
279 .unwrap()
280 }
281
282 fn status_json() -> String {
283 serde_json::to_string(&StatusResponseContent {
284 pod_id: "42".to_string(),
285 status: "Running".to_string(),
286 expires_at: "2026-04-30T00:00:00Z".to_string(),
287 time_remaining_seconds: 3600,
288 cpu_millicores: 1000,
289 memory_mb: 1024,
290 ssh_host: "1.2.3.4".to_string(),
291 ssh_port: 30042,
292 ssh_username: "root".to_string(),
293 })
294 .unwrap()
295 }
296
297 #[test]
298 fn spawn_success_round_trip() {
299 let out = parse_spawn_response(&access_json()).unwrap();
300 match out {
301 SpawnOutcome::Success(d) => assert_eq!(d.pod_npub, "container-42"),
302 other => panic!("expected Success, got {:?}", other),
303 }
304 }
305
306 #[test]
307 fn spawn_error_routes_to_error_variant() {
308 let out = parse_spawn_response(&err_json()).unwrap();
309 match out {
310 SpawnOutcome::Error(e) => {
311 assert_eq!(e.error_type, "token_already_spent");
312 }
313 other => panic!("expected Error, got {:?}", other),
314 }
315 }
316
317 #[test]
318 fn spawn_unknown_payload_routes_to_other() {
319 let out = parse_spawn_response(r#"{"weird":"future-thing"}"#).unwrap();
320 assert!(matches!(out, SpawnOutcome::Other(_)));
321 }
322
323 #[test]
324 fn topup_success_round_trip() {
325 let out = parse_topup_response(&topup_json()).unwrap();
326 match out {
327 TopupOutcome::Success(r) => assert_eq!(r.extended_duration_seconds, 3600),
328 other => panic!("expected Success, got {:?}", other),
329 }
330 }
331
332 #[test]
333 fn topup_error_routes_to_error_variant() {
334 let out = parse_topup_response(&err_json()).unwrap();
335 assert!(matches!(out, TopupOutcome::Error(_)));
336 }
337
338 #[test]
339 fn status_success_round_trip() {
340 let out = parse_status_response(&status_json()).unwrap();
341 match out {
342 StatusOutcome::Success(s) => assert_eq!(s.pod_id, "42"),
343 other => panic!("expected Success, got {:?}", other),
344 }
345 }
346
347 #[test]
348 fn status_error_routes_to_error_variant() {
349 let out = parse_status_response(&err_json()).unwrap();
350 assert!(matches!(out, StatusOutcome::Error(_)));
351 }
352
353 #[test]
354 fn error_with_details_parses_fully() {
355 let payload = serde_json::json!({
356 "error_type": "non_whitelisted_mint",
357 "message": "Mint https://attacker.example is not accepted",
358 "details": "operator-tunable"
359 })
360 .to_string();
361 match parse_spawn_response(&payload).unwrap() {
362 SpawnOutcome::Error(e) => {
363 assert_eq!(e.error_type, "non_whitelisted_mint");
364 assert_eq!(e.details.as_deref(), Some("operator-tunable"));
365 }
366 other => panic!("expected Error, got {:?}", other),
367 }
368 }
369
370 #[test]
371 fn malformed_json_does_not_panic() {
372 let out = parse_topup_response("definitely not json").unwrap();
374 assert!(matches!(out, TopupOutcome::Other(_)));
375 }
376}