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
//! HTTP client for NpubCash API
use std::sync::Arc;
use cdk_http_client::{HttpClient, RawResponse};
use tracing::instrument;
use crate::auth::JwtAuthProvider;
use crate::error::{Error, Result};
use crate::types::{Quote, QuotesResponse};
const API_PATHS_QUOTES: &str = "/api/v2/wallet/quotes";
const PAGINATION_LIMIT: usize = 50;
const THROTTLE_DELAY_MS: u64 = 200;
/// Main client for interacting with the NpubCash API
pub struct NpubCashClient {
base_url: String,
auth_provider: Arc<JwtAuthProvider>,
http_client: HttpClient,
}
impl std::fmt::Debug for NpubCashClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("NpubCashClient")
.field("base_url", &self.base_url)
.field("auth_provider", &self.auth_provider)
.finish_non_exhaustive()
}
}
impl NpubCashClient {
/// Create a new NpubCash client
///
/// # Arguments
///
/// * `base_url` - Base URL of the NpubCash service (e.g., <https://npubx.cash>)
/// * `auth_provider` - Authentication provider for signing requests
pub fn new(base_url: String, auth_provider: Arc<JwtAuthProvider>) -> Self {
Self {
base_url,
auth_provider,
http_client: HttpClient::new(),
}
}
/// Fetch quotes, optionally filtered by timestamp
///
/// # Arguments
///
/// * `since` - Optional Unix timestamp to fetch quotes from. If `None`, fetches all quotes.
///
/// # Errors
///
/// Returns an error if the API request fails or authentication fails
///
/// # Examples
///
/// ```no_run
/// # use cdk_npubcash::{NpubCashClient, JwtAuthProvider};
/// # use nostr_sdk::Keys;
/// # use std::sync::Arc;
/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
/// # let base_url = "https://npubx.cash".to_string();
/// # let keys = Keys::generate();
/// # let auth_provider = Arc::new(JwtAuthProvider::new(base_url.clone(), keys));
/// # let client = NpubCashClient::new(base_url, auth_provider);
/// // Fetch all quotes
/// let all_quotes = client.get_quotes(None).await?;
///
/// // Fetch quotes since a specific timestamp
/// let recent_quotes = client.get_quotes(Some(1234567890)).await?;
/// # Ok(())
/// # }
/// ```
#[instrument(skip(self))]
pub async fn get_quotes(&self, since: Option<u64>) -> Result<Vec<Quote>> {
if let Some(ts) = since {
tracing::debug!("Fetching quotes since timestamp: {}", ts);
} else {
tracing::debug!("Fetching all quotes");
}
self.fetch_paginated_quotes(since).await
}
/// Fetch quotes with pagination support
///
/// This method handles automatic pagination, fetching all available quotes
/// matching the criteria. It throttles requests to avoid overwhelming the API.
///
/// # Arguments
///
/// * `since` - Optional timestamp to filter quotes created after this time
///
/// # Errors
///
/// Returns an error if any page fetch fails
async fn fetch_paginated_quotes(&self, since: Option<u64>) -> Result<Vec<Quote>> {
let mut all_quotes = Vec::new();
let mut offset = 0;
loop {
// Build the URL for this page
let url = self.build_quotes_url(offset, since)?;
// Fetch the current page
let response: QuotesResponse = self.authenticated_get(url.as_str()).await?;
// Collect quotes from this page
let fetched_count = response.data.quotes.len();
all_quotes.extend(response.data.quotes);
tracing::debug!(
"Fetched {} quotes. Total fetched: {}",
fetched_count,
all_quotes.len()
);
// Check if we should continue paginating
offset += PAGINATION_LIMIT;
if !Self::should_fetch_next_page(offset, response.metadata.total) {
break;
}
// Throttle to avoid overwhelming the API
self.throttle_request().await;
}
tracing::info!(
"Successfully fetched a total of {} quotes",
all_quotes.len()
);
Ok(all_quotes)
}
/// Build the URL for fetching quotes with pagination and filters
fn build_quotes_url(&self, offset: usize, since: Option<u64>) -> Result<url::Url> {
let mut url = url::Url::parse(&format!("{}{}", self.base_url, API_PATHS_QUOTES))?;
// Add pagination parameters
url.query_pairs_mut()
.append_pair("offset", &offset.to_string())
.append_pair("limit", &PAGINATION_LIMIT.to_string());
// Add optional timestamp filter
if let Some(since_val) = since {
url.query_pairs_mut()
.append_pair("since", &since_val.to_string());
}
Ok(url)
}
/// Set the mint URL for the user
///
/// Updates the default mint URL used by the NpubCash server when creating quotes.
///
/// # Arguments
///
/// * `mint_url` - URL of the Cashu mint to use
///
/// # Errors
///
/// Returns an error if the API request fails or authentication fails.
/// Returns `UnsupportedEndpoint` if the server doesn't support this feature.
#[instrument(skip(self, mint_url))]
pub async fn set_mint_url(
&self,
mint_url: impl Into<String>,
) -> Result<crate::types::UserResponse> {
use serde::Serialize;
const MINT_URL_PATH: &str = "/api/v2/user/mint";
#[derive(Serialize)]
struct MintUrlPayload {
mint_url: String,
}
let url = format!("{}{}", self.base_url, MINT_URL_PATH);
let payload = MintUrlPayload {
mint_url: mint_url.into(),
};
// Get NIP-98 authentication header (not JWT Bearer)
let auth_header = self.auth_provider.get_nip98_auth_header(&url, "PATCH")?;
// Send PATCH request
let response = self
.http_client
.patch(&url)
.header("Authorization", auth_header)
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header("User-Agent", "cdk-npubcash/0.13.0")
.json(&payload)
.send()
.await?;
let status = response.status();
// Handle error responses
if !response.is_success() {
let error_text = response.text().await.unwrap_or_default();
return Err(Error::Api {
message: error_text,
status,
});
}
// Get response text for debugging
let response_text = response.text().await?;
tracing::debug!("set_mint_url response: {}", response_text);
// Parse JSON response
serde_json::from_str(&response_text).map_err(|e| {
tracing::error!("Failed to parse response: {} - Body: {}", e, response_text);
Error::Custom(format!("JSON parse error: {e}"))
})
}
/// Determine if we should fetch the next page of results
const fn should_fetch_next_page(current_offset: usize, total_available: usize) -> bool {
current_offset < total_available
}
/// Throttle requests to avoid overwhelming the API
async fn throttle_request(&self) {
tracing::debug!("Throttling for {}ms...", THROTTLE_DELAY_MS);
tokio::time::sleep(tokio::time::Duration::from_millis(THROTTLE_DELAY_MS)).await;
}
/// Make an authenticated GET request to the API
///
/// This method handles authentication, sends the request, and parses the response.
///
/// # Arguments
///
/// * `url` - Full URL to request
///
/// # Errors
///
/// Returns an error if authentication fails, request fails, or response parsing fails
async fn authenticated_get<T>(&self, url: &str) -> Result<T>
where
T: serde::de::DeserializeOwned,
{
const METHOD: &str = "GET";
// Extract URL for authentication (without query parameters)
let url_for_auth = crate::extract_auth_url(url)?;
// Get authentication token
let auth_token = self
.auth_provider
.get_auth_token(&url_for_auth, METHOD)
.await?;
// Send the HTTP request with authentication headers
tracing::debug!("Making {} request to {}", METHOD, url);
let response = self
.http_client
.get(url)
.header("Authorization", auth_token)
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header("User-Agent", "cdk-npubcash/0.13.0")
.send()
.await?;
tracing::debug!("Response status: {}", response.status());
// Parse and return the JSON response
self.parse_response(response).await
}
/// Parse the HTTP response and deserialize the JSON body
async fn parse_response<T>(&self, response: RawResponse) -> Result<T>
where
T: serde::de::DeserializeOwned,
{
let status = response.status();
// Get the response text
let response_text = response.text().await?;
// Handle error status codes
if !(200..300).contains(&status) {
tracing::debug!("Error response ({}): {}", status, response_text);
return Err(Error::Api {
message: response_text,
status,
});
}
// Parse successful JSON response
tracing::debug!("Response body: {}", response_text);
let data = serde_json::from_str::<T>(&response_text).map_err(|e| {
tracing::error!("JSON parse error: {} - Body: {}", e, response_text);
Error::Custom(format!("JSON parse error: {e}"))
})?;
tracing::debug!("Request successful");
Ok(data)
}
}