Skip to main content

paygress/
client.rs

1// Paygress consumer-side SDK (Unit 17 of the 12-month plan).
2//
3// `PaygressClient` is the canonical Rust SDK for talking to a
4// provider over Nostr DMs. It wraps `DiscoveryClient` (read-only
5// queries) plus the spawn/topup/status round-trip flows, and
6// exposes them as typed methods returning structured `*Outcome`
7// enums so embedders don't have to hand-roll JSON parsing.
8//
9// Today the CLI hand-rolls these flows in `src/cli/commands/{spawn,
10// topup,status}.rs`. A follow-up will refactor the CLI to consume
11// this SDK; this PR adds the surface so external Rust callers
12// (and the in-progress Python wrapper) can use it now.
13
14use 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
26/// Builder for a Paygress consumer SDK client. Wraps the existing
27/// `DiscoveryClient` with typed write-side operations.
28pub struct PaygressClient {
29    discovery: DiscoveryClient,
30    response_timeout_secs: u64,
31    message_type: String,
32}
33
34impl PaygressClient {
35    /// Construct against the given relays and a Nostr private key
36    /// (`nsec1...` or hex). The key is required for any operation
37    /// that sends a DM (spawn / topup / status); read-only queries
38    /// would also work without one but this constructor unifies the
39    /// path so callers don't need two clients.
40    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    /// Override how long each round-trip waits for a provider
50    /// response. Defaults to 60s.
51    pub fn with_response_timeout_secs(mut self, secs: u64) -> Self {
52        self.response_timeout_secs = secs;
53        self
54    }
55
56    /// Override the encryption mode used for outbound DMs
57    /// (`"nip04"` or `"nip17"`). Defaults to `nip04`. NIP-17
58    /// gift-wrap is sender-anonymous but supported by fewer relays.
59    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    /// Consumer's npub (handy for receipts).
65    pub fn npub(&self) -> String {
66        self.discovery.get_npub()
67    }
68
69    /// Underlying discovery client for read-only queries.
70    pub fn discovery(&self) -> &DiscoveryClient {
71        &self.discovery
72    }
73
74    /// Discover providers matching an optional filter.
75    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    /// Send a spawn request and wait for the provider's response.
83    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    /// Send a top-up request and wait for the provider's response.
102    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    /// Send a status query and wait for the provider's response.
113    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// ---------- request payloads ----------
147
148/// Inputs for a spawn request. Maps onto `EncryptedSpawnPodRequest`
149/// but the SDK type is the public-facing surface.
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct SpawnRequest {
152    /// Cashu token paying for the workload.
153    pub cashu_token: String,
154    /// Optional spec id (`basic`, `standard`, ...). Provider's
155    /// first spec is used if `None`.
156    pub pod_spec_id: Option<String>,
157    /// Container image to run.
158    pub pod_image: String,
159    pub ssh_username: String,
160    pub ssh_password: String,
161}
162
163/// Inputs for a top-up request.
164#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct TopupRequest {
166    /// Pod identifier as returned by [`AccessDetailsContent::pod_npub`]
167    /// (e.g. `container-1234`).
168    pub pod_id: String,
169    pub cashu_token: String,
170}
171
172// ---------- typed outcomes ----------
173
174/// Result of a spawn round-trip. Anything the provider sent that's
175/// neither an `AccessDetailsContent` nor an `ErrorResponseContent`
176/// surfaces as `Other(raw)` so callers can keep moving even when a
177/// provider speaks an evolved schema.
178#[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
199// ---------- response parsers ----------
200
201/// Try to parse a provider response as an `ErrorResponseContent`.
202/// Returns `Some(err)` only when the JSON has the discriminating
203/// `error_type` + `message` fields and parses cleanly.
204fn 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        // Provider sent something we can't even tokenize.
373        let out = parse_topup_response("definitely not json").unwrap();
374        assert!(matches!(out, TopupOutcome::Other(_)));
375    }
376}