use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::discovery::DiscoveryClient;
use crate::nostr::{
AccessDetailsContent, EncryptedSpawnPodRequest, EncryptedTopUpPodRequest, ErrorResponseContent,
ProviderInfo, StatusRequestContent, StatusResponseContent, TopUpResponseContent,
};
const DEFAULT_RESPONSE_TIMEOUT_SECS: u64 = 60;
const DEFAULT_MESSAGE_TYPE: &str = "nip04";
pub struct PaygressClient {
discovery: DiscoveryClient,
response_timeout_secs: u64,
message_type: String,
}
impl PaygressClient {
pub async fn new(relays: Vec<String>, private_key: String) -> Result<Self> {
let discovery = DiscoveryClient::new_with_key(relays, private_key).await?;
Ok(Self {
discovery,
response_timeout_secs: DEFAULT_RESPONSE_TIMEOUT_SECS,
message_type: DEFAULT_MESSAGE_TYPE.to_string(),
})
}
pub fn with_response_timeout_secs(mut self, secs: u64) -> Self {
self.response_timeout_secs = secs;
self
}
pub fn with_message_type(mut self, message_type: impl Into<String>) -> Self {
self.message_type = message_type.into();
self
}
pub fn npub(&self) -> String {
self.discovery.get_npub()
}
pub fn discovery(&self) -> &DiscoveryClient {
&self.discovery
}
pub async fn list_offers(
&self,
filter: Option<crate::nostr::ProviderFilter>,
) -> Result<Vec<ProviderInfo>> {
self.discovery.list_providers(filter).await
}
pub async fn spawn(&self, provider_npub: &str, request: SpawnRequest) -> Result<SpawnOutcome> {
let payload = EncryptedSpawnPodRequest {
cashu_token: request.cashu_token,
pod_spec_id: request.pod_spec_id,
pod_image: request.pod_image,
ssh_username: request.ssh_username,
ssh_password: request.ssh_password,
template_slug: None,
replication: None,
primary_npub: None,
workload_id: None,
volume_encryption: None,
};
let json = serde_json::to_string(&payload)?;
self.send_and_parse(provider_npub, json, parse_spawn_response)
.await
}
pub async fn topup(&self, provider_npub: &str, request: TopupRequest) -> Result<TopupOutcome> {
let payload = EncryptedTopUpPodRequest {
pod_npub: request.pod_id,
cashu_token: request.cashu_token,
};
let json = serde_json::to_string(&payload)?;
self.send_and_parse(provider_npub, json, parse_topup_response)
.await
}
pub async fn status(&self, provider_npub: &str, pod_id: String) -> Result<StatusOutcome> {
let payload = StatusRequestContent { pod_id };
let json = serde_json::to_string(&payload)?;
self.send_and_parse(provider_npub, json, parse_status_response)
.await
}
async fn send_and_parse<T, F>(
&self,
provider_npub: &str,
request_json: String,
parser: F,
) -> Result<T>
where
F: FnOnce(&str) -> Result<T>,
{
self.discovery
.nostr()
.send_encrypted_private_message(provider_npub, request_json, &self.message_type)
.await
.context("send DM to provider")?;
let response = self
.discovery
.nostr()
.wait_for_decrypted_message(provider_npub, self.response_timeout_secs)
.await
.context("wait for provider response")?;
parser(&response.content)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpawnRequest {
pub cashu_token: String,
pub pod_spec_id: Option<String>,
pub pod_image: String,
pub ssh_username: String,
pub ssh_password: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TopupRequest {
pub pod_id: String,
pub cashu_token: String,
}
#[derive(Debug, Clone)]
pub enum SpawnOutcome {
Success(AccessDetailsContent),
Error(ErrorResponseContent),
Other(String),
}
#[derive(Debug, Clone)]
pub enum TopupOutcome {
Success(TopUpResponseContent),
Error(ErrorResponseContent),
Other(String),
}
#[derive(Debug, Clone)]
pub enum StatusOutcome {
Success(StatusResponseContent),
Error(ErrorResponseContent),
Other(String),
}
fn try_parse_error(content: &str) -> Option<ErrorResponseContent> {
let v: serde_json::Value = serde_json::from_str(content).ok()?;
if v.get("error_type").is_none() || v.get("message").is_none() {
return None;
}
serde_json::from_value(v).ok()
}
pub fn parse_spawn_response(content: &str) -> Result<SpawnOutcome> {
if let Some(err) = try_parse_error(content) {
return Ok(SpawnOutcome::Error(err));
}
if let Ok(details) = serde_json::from_str::<AccessDetailsContent>(content) {
return Ok(SpawnOutcome::Success(details));
}
Ok(SpawnOutcome::Other(content.to_string()))
}
pub fn parse_topup_response(content: &str) -> Result<TopupOutcome> {
if let Some(err) = try_parse_error(content) {
return Ok(TopupOutcome::Error(err));
}
if let Ok(resp) = serde_json::from_str::<TopUpResponseContent>(content) {
return Ok(TopupOutcome::Success(resp));
}
Ok(TopupOutcome::Other(content.to_string()))
}
pub fn parse_status_response(content: &str) -> Result<StatusOutcome> {
if let Some(err) = try_parse_error(content) {
return Ok(StatusOutcome::Error(err));
}
if let Ok(resp) = serde_json::from_str::<StatusResponseContent>(content) {
return Ok(StatusOutcome::Success(resp));
}
Ok(StatusOutcome::Other(content.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
fn err_json() -> String {
serde_json::to_string(&ErrorResponseContent {
error_type: "token_already_spent".to_string(),
message: "This Cashu token has already been spent".to_string(),
details: None,
})
.unwrap()
}
fn access_json() -> String {
serde_json::to_string(&AccessDetailsContent {
pod_npub: "container-42".to_string(),
node_port: 30042,
expires_at: "2026-04-30T00:00:00Z".to_string(),
cpu_millicores: 1000,
memory_mb: 1024,
pod_spec_name: "Basic".to_string(),
pod_spec_description: "1 vCPU, 1GB".to_string(),
instructions: vec!["ssh -p 30042 root@host".to_string()],
host_address: "host".to_string(),
template_ports: Vec::new(),
})
.unwrap()
}
fn topup_json() -> String {
serde_json::to_string(&TopUpResponseContent {
success: true,
pod_npub: "container-42".to_string(),
extended_duration_seconds: 3600,
new_expires_at: "2026-04-30T01:00:00Z".to_string(),
message: "extended".to_string(),
})
.unwrap()
}
fn status_json() -> String {
serde_json::to_string(&StatusResponseContent {
pod_id: "42".to_string(),
status: "Running".to_string(),
expires_at: "2026-04-30T00:00:00Z".to_string(),
time_remaining_seconds: 3600,
cpu_millicores: 1000,
memory_mb: 1024,
ssh_host: "1.2.3.4".to_string(),
ssh_port: 30042,
ssh_username: "root".to_string(),
})
.unwrap()
}
#[test]
fn spawn_success_round_trip() {
let out = parse_spawn_response(&access_json()).unwrap();
match out {
SpawnOutcome::Success(d) => assert_eq!(d.pod_npub, "container-42"),
other => panic!("expected Success, got {:?}", other),
}
}
#[test]
fn spawn_error_routes_to_error_variant() {
let out = parse_spawn_response(&err_json()).unwrap();
match out {
SpawnOutcome::Error(e) => {
assert_eq!(e.error_type, "token_already_spent");
}
other => panic!("expected Error, got {:?}", other),
}
}
#[test]
fn spawn_unknown_payload_routes_to_other() {
let out = parse_spawn_response(r#"{"weird":"future-thing"}"#).unwrap();
assert!(matches!(out, SpawnOutcome::Other(_)));
}
#[test]
fn topup_success_round_trip() {
let out = parse_topup_response(&topup_json()).unwrap();
match out {
TopupOutcome::Success(r) => assert_eq!(r.extended_duration_seconds, 3600),
other => panic!("expected Success, got {:?}", other),
}
}
#[test]
fn topup_error_routes_to_error_variant() {
let out = parse_topup_response(&err_json()).unwrap();
assert!(matches!(out, TopupOutcome::Error(_)));
}
#[test]
fn status_success_round_trip() {
let out = parse_status_response(&status_json()).unwrap();
match out {
StatusOutcome::Success(s) => assert_eq!(s.pod_id, "42"),
other => panic!("expected Success, got {:?}", other),
}
}
#[test]
fn status_error_routes_to_error_variant() {
let out = parse_status_response(&err_json()).unwrap();
assert!(matches!(out, StatusOutcome::Error(_)));
}
#[test]
fn error_with_details_parses_fully() {
let payload = serde_json::json!({
"error_type": "non_whitelisted_mint",
"message": "Mint https://attacker.example is not accepted",
"details": "operator-tunable"
})
.to_string();
match parse_spawn_response(&payload).unwrap() {
SpawnOutcome::Error(e) => {
assert_eq!(e.error_type, "non_whitelisted_mint");
assert_eq!(e.details.as_deref(), Some("operator-tunable"));
}
other => panic!("expected Error, got {:?}", other),
}
}
#[test]
fn malformed_json_does_not_panic() {
let out = parse_topup_response("definitely not json").unwrap();
assert!(matches!(out, TopupOutcome::Other(_)));
}
}