Skip to main content

walletkit_core/issuers/
pop_backend_client.rs

1use crate::error::WalletKitError;
2use crate::http_request::Request;
3use reqwest::Method;
4use serde::{Deserialize, Serialize};
5
6/// Request payload for registering or unregistering a recovery binding.
7///
8/// Serialized as JSON with `leafIndex` (camelCase) to match the `PoP` backend API.
9#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, uniffi::Record)]
10pub struct ManageRecoveryBindingRequest {
11    /// Hex-encoded subject identifier of the recovery binding.
12    pub sub: String,
13    /// The authenticator's leaf index in the World ID Merkle tree.
14    #[serde(rename = "leafIndex")]
15    pub leaf_index: u64,
16    /// The signature of the recovery agent update.
17    pub signature: String,
18    /// The nonce of the recovery agent update.
19    pub nonce: String,
20    /// The checksummed hex address of the recovery agent (e.g. `"0x1234…"`).
21    #[serde(rename = "recoveryAgent")]
22    pub recovery_agent: String,
23}
24
25#[derive(Deserialize)]
26struct ChallengeResponse {
27    challenge: String,
28}
29
30#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, uniffi::Record)]
31pub struct RecoveryBindingResponse {
32    #[serde(rename = "recoveryAgent")]
33    pub recovery_agent: Option<String>,
34    #[serde(rename = "pendingRecoveryAgent")]
35    pub pending_recovery_agent: Option<String>,
36    #[serde(rename = "executeAfter")]
37    pub execute_after: Option<String>,
38}
39
40/// Low-level HTTP client for the Proof-of-Personhood (`PoP`) backend API.
41///
42/// Handles the REST calls for recovery binding management and challenge retrieval.
43/// All requests that mutate state require a security token (signature) and a
44/// challenge, passed via `X-Auth-Signature` and `X-Auth-Challenge` headers.
45///
46/// This client is not meant to be used directly — prefer [`super::RecoveryBindingManager`],
47/// which orchestrates the full challenge-response signing flow.
48pub struct PopBackendClient {
49    request: Request,
50    base_url: String,
51}
52
53impl PopBackendClient {
54    /// Creates a new client targeting the given base URL.
55    #[must_use]
56    pub fn new(base_url: String, user_agent: String) -> Self {
57        let request = Request::new(user_agent);
58        Self { request, base_url }
59    }
60}
61
62impl PopBackendClient {
63    /// Registers a new recovery binding via `POST /api/v1/recovery-binding`.
64    ///
65    /// # Errors
66    ///
67    /// * [`WalletKitError::DebugReportNotFound`] — HTTP 404 Not Found.
68    /// * [`WalletKitError::NotEligibleForRecovery`] — HTTP 412 Precondition Failed.
69    /// * [`WalletKitError::NetworkError`] — any other non-success status; the response body is in
70    ///   `error` and the HTTP status in `status`. This includes conflicts (e.g. HTTP 409) and
71    ///   server errors.
72    pub async fn bind_recovery_agent(
73        &self,
74        request: ManageRecoveryBindingRequest,
75        security_token: String,
76        challenge: String,
77    ) -> Result<(), WalletKitError> {
78        let url: String = format!("{}/api/v1/recovery-binding", self.base_url);
79        let response = self
80            .request
81            .post(&url)
82            .json(&request)
83            .header("X-Auth-Signature", security_token)
84            .header("X-Auth-Challenge", challenge)
85            .send()
86            .await?;
87
88        let response_status = response.status();
89        match response_status {
90            reqwest::StatusCode::CREATED | reqwest::StatusCode::OK => Ok(()),
91            reqwest::StatusCode::NOT_FOUND => Err(WalletKitError::DebugReportNotFound),
92            reqwest::StatusCode::PRECONDITION_FAILED => {
93                Err(WalletKitError::NotEligibleForRecovery)
94            }
95            _ => {
96                let error_message = response
97                    .text()
98                    .await
99                    .unwrap_or_else(|e| format!("Unknown error: {e:?}"));
100                Err(WalletKitError::NetworkError {
101                    url,
102                    error: error_message,
103                    status: Some(response_status.as_u16()),
104                })
105            }
106        }
107    }
108
109    /// Removes a recovery binding via `DELETE /api/v1/recovery-binding`.
110    ///
111    /// # Errors
112    ///
113    /// * [`WalletKitError::AccountDoesNotExist`] — HTTP 404 (no binding found).
114    /// * [`WalletKitError::NetworkError`] — any other non-success status.
115    pub async fn unbind_recovery_agent(
116        &self,
117        request: ManageRecoveryBindingRequest,
118        security_token: String,
119        challenge: String,
120    ) -> Result<(), WalletKitError> {
121        let url: String = format!("{}/api/v1/recovery-binding", self.base_url);
122        let response = self
123            .request
124            .req(Method::DELETE, url.as_str())
125            .json(&request)
126            .header("X-Auth-Signature", security_token)
127            .header("X-Auth-Challenge", challenge)
128            .send()
129            .await?;
130        let response_status = response.status();
131        match response_status {
132            reqwest::StatusCode::OK => Ok(()),
133            reqwest::StatusCode::NOT_FOUND => {
134                Err(WalletKitError::RecoveryBindingDoesNotExist)
135            }
136            _ => {
137                let error_message = response
138                    .text()
139                    .await
140                    .unwrap_or_else(|e| format!("Unknown error: {e:?}"));
141                Err(WalletKitError::NetworkError {
142                    url,
143                    error: error_message,
144                    status: Some(response_status.as_u16()),
145                })
146            }
147        }
148    }
149
150    /// Fetches a one-time challenge string via `GET /api/v1/challenge`.
151    ///
152    /// The returned challenge is used as input to the commitment signature
153    /// required by the register/unregister endpoints.
154    ///
155    /// # Errors
156    ///
157    /// * [`WalletKitError::NetworkError`] — non-success HTTP status.
158    /// * [`WalletKitError::SerializationError`] — response body is not valid JSON.
159    pub async fn get_challenge(&self) -> Result<String, WalletKitError> {
160        let url = format!("{}/api/v1/challenge", self.base_url);
161        let response = self.request.get(url.as_str()).send().await?;
162
163        let response_status = response.status();
164        if !response_status.is_success() {
165            let error_message = response
166                .text()
167                .await
168                .unwrap_or_else(|e| format!("Unknown error: {e:?}"));
169            return Err(WalletKitError::NetworkError {
170                url,
171                status: Some(response_status.as_u16()),
172                error: error_message,
173            });
174        }
175
176        let challenge_response: ChallengeResponse =
177            response
178                .json()
179                .await
180                .map_err(|e| WalletKitError::SerializationError {
181                    error: format!("Failed to parse challenge response: {e}"),
182                })?;
183
184        Ok(challenge_response.challenge)
185    }
186
187    /// Fetches a recovery binding via `GET /api/v1/recovery-binding`.
188    ///
189    /// # Errors
190    ///
191    /// * [`WalletKitError::NetworkError`] — non-success HTTP status.
192    /// * [`WalletKitError::SerializationError`] — response body is not valid JSON.
193    /// * [`WalletKitError::RecoveryBindingDoesNotExist`] — HTTP 404 (no binding found).
194    pub async fn get_recovery_binding(
195        &self,
196        leaf_index: u64,
197    ) -> Result<RecoveryBindingResponse, WalletKitError> {
198        let url = format!(
199            "{}/api/v1/recovery-binding?leafIndex={leaf_index}",
200            self.base_url
201        );
202        let response = self.request.get(url.as_str()).send().await?;
203
204        let status = response.status();
205        if status.is_success() {
206            let recovery_binding: RecoveryBindingResponse = response.json().await?;
207            return Ok(recovery_binding);
208        }
209        if status == reqwest::StatusCode::NOT_FOUND {
210            return Err(WalletKitError::RecoveryBindingDoesNotExist);
211        }
212        let error_message = response
213            .text()
214            .await
215            .unwrap_or_else(|e| format!("Unknown error: {e:?}"));
216        Err(WalletKitError::NetworkError {
217            url,
218            error: error_message,
219            status: Some(status.as_u16()),
220        })
221    }
222}
223
224#[cfg(test)]
225mod tests {
226
227    use super::*;
228
229    #[tokio::test]
230    async fn test_register_recovery_agent_success() {
231        let mut server = mockito::Server::new_async().await;
232        let url = server.url();
233
234        let recovery_agent = "0x1234567890abcdef".to_string();
235        let request = ManageRecoveryBindingRequest {
236            sub: "test-sub-123".to_string(),
237            leaf_index: 42,
238            signature: "0x1234567890abcdef".to_string(),
239            nonce: "0x1234567890abcdef1".to_string(),
240            recovery_agent: recovery_agent.clone(),
241        };
242
243        let mock = server
244            .mock("POST", "/api/v1/recovery-binding")
245            .match_body(mockito::Matcher::Json(serde_json::json!({
246                "sub": "test-sub-123",
247                "leafIndex": 42,
248                "signature": "0x1234567890abcdef",
249                "nonce": "0x1234567890abcdef1",
250                "recoveryAgent": "0x1234567890abcdef",
251            })))
252            .match_header("X-Auth-Signature", "security_token")
253            .match_header("X-Auth-Challenge", "challenge")
254            .with_status(201)
255            .with_body("{}")
256            .create_async()
257            .await;
258
259        let pop_api_client =
260            PopBackendClient::new(url.clone(), "test-user-agent".to_string());
261
262        let result = pop_api_client
263            .bind_recovery_agent(
264                request,
265                "security_token".to_string(),
266                "challenge".to_string(),
267            )
268            .await;
269
270        assert!(result.is_ok(), "Expected success but got error: {result:?}");
271        mock.assert_async().await;
272        drop(server);
273    }
274
275    #[tokio::test]
276    async fn test_register_recovery_agent_no_debug_report() {
277        let mut server = mockito::Server::new_async().await;
278        let url = server.url();
279
280        let recovery_agent = "0x1234567890abcdef".to_string();
281        let request = ManageRecoveryBindingRequest {
282            sub: "test-sub-123".to_string(),
283            leaf_index: 42,
284            signature: "0x1234567890abcdef".to_string(),
285            nonce: "0x1234567890abcdef1".to_string(),
286            recovery_agent: recovery_agent.clone(),
287        };
288
289        let mock = server
290            .mock("POST", "/api/v1/recovery-binding")
291            .match_body(mockito::Matcher::Json(serde_json::json!({
292                "sub": "test-sub-123",
293                "leafIndex": 42,
294                "signature": "0x1234567890abcdef",
295                "nonce": "0x1234567890abcdef1",
296                "recoveryAgent": "0x1234567890abcdef",
297            })))
298            .match_header("X-Auth-Signature", "security_token")
299            .match_header("X-Auth-Challenge", "challenge")
300            .with_status(404)
301            .with_body("{\"error\":\"DebugReportNotFound\"}")
302            .create_async()
303            .await;
304
305        let pop_api_client =
306            PopBackendClient::new(url.clone(), "test-user-agent".to_string());
307
308        let result = pop_api_client
309            .bind_recovery_agent(
310                request,
311                "security_token".to_string(),
312                "challenge".to_string(),
313            )
314            .await;
315
316        assert!(result.is_err(), "Expected error but got success");
317        let err = result.unwrap_err();
318        assert!(
319            matches!(err, WalletKitError::DebugReportNotFound),
320            "Expected DebugReportNotFound error, got: {err:?}"
321        );
322        mock.assert_async().await;
323        drop(server);
324    }
325
326    #[tokio::test]
327    async fn test_unregister_recovery_agent_success() {
328        let mut server = mockito::Server::new_async().await;
329        let url = server.url();
330
331        let request = ManageRecoveryBindingRequest {
332            sub: "test-sub-123".to_string(),
333            leaf_index: 42,
334            signature: "0x1234567890abcdef".to_string(),
335            nonce: "0x1234567890abcdef1".to_string(),
336            recovery_agent: "0x0000000000000000000000000000000000000000".to_string(),
337        };
338
339        let mock = server
340            .mock("DELETE", "/api/v1/recovery-binding")
341            .match_body(mockito::Matcher::Json(serde_json::json!({
342                "sub": "test-sub-123",
343                "leafIndex": 42,
344                "signature": "0x1234567890abcdef",
345                "nonce": "0x1234567890abcdef1",
346                "recoveryAgent": "0x0000000000000000000000000000000000000000",
347            })))
348            .match_header("X-Auth-Signature", "security_token")
349            .match_header("X-Auth-Challenge", "challenge")
350            .with_status(200)
351            .with_body("{}")
352            .create_async()
353            .await;
354
355        let pop_api_client =
356            PopBackendClient::new(url.clone(), "test-user-agent".to_string());
357
358        let result = pop_api_client
359            .unbind_recovery_agent(
360                request,
361                "security_token".to_string(),
362                "challenge".to_string(),
363            )
364            .await;
365
366        assert!(result.is_ok(), "Expected success but got error: {result:?}");
367        mock.assert_async().await;
368        drop(server);
369    }
370
371    #[tokio::test]
372    async fn test_unregister_recovery_agent_not_found() {
373        let mut server = mockito::Server::new_async().await;
374        let url = server.url();
375
376        let recovery_agent = "0x0000000000000000000000000000000000000000".to_string();
377        let request = ManageRecoveryBindingRequest {
378            sub: "test-sub-123".to_string(),
379            leaf_index: 42,
380            signature: "0x1234567890abcdef".to_string(),
381            nonce: "0x1234567890abcdef1".to_string(),
382            recovery_agent: recovery_agent.clone(),
383        };
384
385        let mock = server
386            .mock("DELETE", "/api/v1/recovery-binding")
387            .match_body(mockito::Matcher::Json(serde_json::json!({
388                "sub": "test-sub-123",
389                "leafIndex": 42,
390                "signature": "0x1234567890abcdef",
391                "nonce": "0x1234567890abcdef1",
392                "recoveryAgent": "0x0000000000000000000000000000000000000000",
393            })))
394            .match_header("X-Auth-Signature", "security_token")
395            .match_header("X-Auth-Challenge", "challenge")
396            .with_status(404)
397            .with_body("Recovery agent not found")
398            .create_async()
399            .await;
400
401        let pop_api_client =
402            PopBackendClient::new(url.clone(), "test-user-agent".to_string());
403
404        let result = pop_api_client
405            .unbind_recovery_agent(
406                request,
407                "security_token".to_string(),
408                "challenge".to_string(),
409            )
410            .await;
411
412        assert!(result.is_err(), "Expected error but got success");
413        let err = result.unwrap_err();
414        assert!(
415            matches!(err, WalletKitError::RecoveryBindingDoesNotExist),
416            "Expected RecoveryBindingDoesNotExist error, got: {err:?}"
417        );
418        mock.assert_async().await;
419        drop(server);
420    }
421
422    #[tokio::test]
423    async fn test_get_recovery_binding_success() {
424        let mut server = mockito::Server::new_async().await;
425        let url = server.url();
426
427        let mock = server
428            .mock("GET", "/api/v1/recovery-binding?leafIndex=42")
429            .with_status(200)
430            .with_body("{\"recoveryAgent\": \"0x1234567890abcdef\"}")
431            .create_async()
432            .await;
433
434        let pop_api_client =
435            PopBackendClient::new(url.clone(), "test-user-agent".to_string());
436
437        let result = pop_api_client.get_recovery_binding(42).await;
438        assert!(result.is_ok(), "Expected success but got error: {result:?}");
439        mock.assert_async().await;
440        assert_eq!(
441            result.unwrap().recovery_agent,
442            Some("0x1234567890abcdef".to_string())
443        );
444        drop(server);
445    }
446
447    #[tokio::test]
448    async fn test_get_recovery_binding_not_found() {
449        let mut server = mockito::Server::new_async().await;
450        let url = server.url();
451
452        let mock = server
453            .mock("GET", "/api/v1/recovery-binding?leafIndex=42")
454            .with_status(404)
455            .with_body("Recovery agent not found")
456            .create_async()
457            .await;
458
459        let pop_api_client =
460            PopBackendClient::new(url.clone(), "test-user-agent".to_string());
461
462        let result = pop_api_client.get_recovery_binding(42).await;
463        assert!(result.is_err(), "Expected error but got success");
464        let err = result.unwrap_err();
465        assert!(
466            matches!(err, WalletKitError::RecoveryBindingDoesNotExist),
467            "Expected RecoveryBindingDoesNotExist error, got: {err:?}"
468        );
469        mock.assert_async().await;
470        drop(server);
471    }
472
473    #[tokio::test]
474    async fn test_get_recovery_binding_no_pending_recovery_agent() {
475        let mut server = mockito::Server::new_async().await;
476        let url = server.url();
477
478        let mock = server
479            .mock("GET", "/api/v1/recovery-binding?leafIndex=42")
480            .with_status(200)
481            .with_body("{\"recoveryAgent\": \"0x1234567890abcdef\"}")
482            .create_async()
483            .await;
484
485        let pop_api_client =
486            PopBackendClient::new(url.clone(), "test-user-agent".to_string());
487
488        let result = pop_api_client.get_recovery_binding(42).await;
489        assert!(result.is_ok(), "Expected success but got error: {result:?}");
490        mock.assert_async().await;
491        let recovery_binding = result.unwrap();
492        assert_eq!(recovery_binding.pending_recovery_agent, None);
493        assert_eq!(recovery_binding.execute_after, None);
494        assert_eq!(
495            recovery_binding.recovery_agent,
496            Some("0x1234567890abcdef".to_string())
497        );
498        drop(server);
499    }
500
501    #[tokio::test]
502    async fn test_get_recovery_binding_with_pending_recovery_agent() {
503        let mut server = mockito::Server::new_async().await;
504        let url = server.url();
505
506        let mock = server
507            .mock("GET", "/api/v1/recovery-binding?leafIndex=42")
508            .with_status(200)
509            .with_body("{\"recoveryAgent\": \"0x0000000000000000000000000000000000000001\", \"pendingRecoveryAgent\": \"0x0000000000000000000000000000000000000000\", \"executeAfter\": \"0x01\"}")
510            .create_async()
511            .await;
512
513        let pop_api_client =
514            PopBackendClient::new(url.clone(), "test-user-agent".to_string());
515
516        let result = pop_api_client.get_recovery_binding(42).await;
517        assert!(result.is_ok(), "Expected success but got error: {result:?}");
518        mock.assert_async().await;
519        let recovery_binding = result.unwrap();
520        assert_eq!(
521            recovery_binding.pending_recovery_agent,
522            Some("0x0000000000000000000000000000000000000000".to_string())
523        );
524        assert_eq!(recovery_binding.execute_after, Some("0x01".to_string()));
525        assert_eq!(
526            recovery_binding.recovery_agent,
527            Some("0x0000000000000000000000000000000000000001".to_string())
528        );
529        drop(server);
530    }
531}