Skip to main content

bsv_messagebox_client/
payment_requests.rs

1//! Payment Request System — Rust parity with TypeScript PeerPayClient.
2//!
3//! Implements the three-mailbox payment request flow:
4//! - `payment_requests` — incoming payment requests
5//! - `payment_request_responses` — responses to sent requests
6//! - `payment_inbox` — actual payments (existing PeerPay)
7//!
8//! HMAC-based request proofs tie each request to the sender's identity,
9//! preventing replay and ensuring directionality.
10
11use std::sync::Arc;
12
13use bsv::auth::utils::create_nonce;
14use bsv::primitives::public_key::PublicKey;
15use bsv::remittance::types::PeerMessage;
16use bsv::wallet::interfaces::{CreateHmacArgs, VerifyHmacArgs, WalletInterface};
17use bsv::wallet::types::{Counterparty, CounterpartyType, Protocol};
18
19use crate::client::MessageBoxClient;
20use crate::error::MessageBoxError;
21use crate::types::{
22    IncomingPaymentRequest, PaymentRequestLimits, PaymentRequestMessage, PaymentRequestResponse,
23    PaymentRequestResult, PAYMENT_REQUESTS_MESSAGEBOX, PAYMENT_REQUEST_RESPONSES_MESSAGEBOX,
24};
25
26/// Build the protocol for payment request auth HMACs.
27fn payment_request_auth_protocol() -> Protocol {
28    Protocol {
29        security_level: 2,
30        protocol: "payment request auth".to_string(),
31    }
32}
33
34/// Convert a hex string to raw bytes.
35fn hex_to_bytes(hex: &str) -> Result<Vec<u8>, MessageBoxError> {
36    hex::decode(hex).map_err(|e| MessageBoxError::Auth(format!("hex decode: {e}")))
37}
38
39/// Convert raw bytes to lowercase hex string.
40fn bytes_to_hex(bytes: &[u8]) -> String {
41    hex::encode(bytes)
42}
43
44impl<W: WalletInterface + Clone + 'static + Send + Sync> MessageBoxClient<W> {
45    // -----------------------------------------------------------------------
46    // Sending payment requests
47    // -----------------------------------------------------------------------
48
49    /// Send a payment request to `recipient` for `amount` satoshis.
50    ///
51    /// Generates a unique request ID and HMAC proof tying the request to the
52    /// sender's identity. Returns the request ID and proof (needed for
53    /// cancellation).
54    ///
55    /// Matches TS `PeerPayClient.requestPayment()`.
56    pub async fn request_payment(
57        &self,
58        recipient: &str,
59        amount: u64,
60        description: &str,
61        expires_at: u64,
62    ) -> Result<PaymentRequestResult, MessageBoxError> {
63        if amount == 0 {
64            return Err(MessageBoxError::Validation(
65                "Payment request amount must be greater than 0".to_string(),
66            ));
67        }
68
69        // Generate unique request ID
70        let request_id = create_nonce(self.wallet())
71            .await
72            .map_err(|e| MessageBoxError::Auth(format!("create_nonce request_id: {e}")))?;
73
74        let sender_identity_key = self.get_identity_key().await?;
75
76        // Create HMAC proof: data = requestId + recipient (as UTF-8 bytes)
77        let proof_data = format!("{}{}", request_id, recipient);
78        let hmac_result = self
79            .wallet()
80            .create_hmac(
81                CreateHmacArgs {
82                    data: proof_data.as_bytes().to_vec(),
83                    protocol_id: payment_request_auth_protocol(),
84                    key_id: request_id.clone(),
85                    counterparty: Counterparty {
86                        counterparty_type: CounterpartyType::Other,
87                        public_key: Some(PublicKey::from_string(recipient).map_err(|e| {
88                            MessageBoxError::Auth(format!("invalid recipient key: {e}"))
89                        })?),
90                    },
91                    privileged: false,
92                    privileged_reason: None,
93                    seek_permission: None,
94                },
95                self.originator(),
96            )
97            .await
98            .map_err(|e| MessageBoxError::Wallet(e.to_string()))?;
99
100        let request_proof = bytes_to_hex(&hmac_result.hmac);
101
102        let message = PaymentRequestMessage {
103            request_id: request_id.clone(),
104            sender_identity_key,
105            request_proof: request_proof.clone(),
106            amount: Some(amount),
107            description: Some(description.to_string()),
108            expires_at: Some(expires_at),
109            cancelled: None,
110        };
111
112        let body = serde_json::to_string(&message)?;
113
114        match self
115            .send_message(
116                recipient,
117                PAYMENT_REQUESTS_MESSAGEBOX,
118                &body,
119                false,
120                false,
121                None,
122                None,
123            )
124            .await
125        {
126            Ok(_) => {}
127            Err(MessageBoxError::Http(403, _)) => {
128                return Err(MessageBoxError::Validation(
129                    "Payment request blocked — you are not on the recipient's whitelist."
130                        .to_string(),
131                ));
132            }
133            Err(e) => return Err(e),
134        }
135
136        Ok(PaymentRequestResult {
137            request_id,
138            request_proof,
139        })
140    }
141
142    /// Cancel a previously sent payment request.
143    ///
144    /// Requires the original `request_id` and `request_proof` returned from
145    /// `request_payment()`. Sends a cancellation message to the recipient's
146    /// `payment_requests` inbox.
147    ///
148    /// Matches TS `PeerPayClient.cancelPaymentRequest()`.
149    pub async fn cancel_payment_request(
150        &self,
151        recipient: &str,
152        request_id: &str,
153        request_proof: &str,
154        host_override: Option<&str>,
155    ) -> Result<(), MessageBoxError> {
156        let sender_identity_key = self.get_identity_key().await?;
157
158        let message = PaymentRequestMessage {
159            request_id: request_id.to_string(),
160            sender_identity_key,
161            request_proof: request_proof.to_string(),
162            amount: None,
163            description: None,
164            expires_at: None,
165            cancelled: Some(true),
166        };
167
168        let body = serde_json::to_string(&message)?;
169        self.send_message(
170            recipient,
171            PAYMENT_REQUESTS_MESSAGEBOX,
172            &body,
173            false,
174            false,
175            None,
176            host_override,
177        )
178        .await?;
179
180        Ok(())
181    }
182
183    // -----------------------------------------------------------------------
184    // Receiving and processing payment requests
185    // -----------------------------------------------------------------------
186
187    /// List active incoming payment requests with automatic filtering.
188    ///
189    /// Performs multi-stage filtering matching TS `listIncomingPaymentRequests`:
190    /// 1. Parse and validate all messages
191    /// 2. Collect and verify cancellations (HMAC proof check)
192    /// 3. Filter: expired, cancelled, out-of-range, invalid proof
193    /// 4. Auto-acknowledge filtered messages
194    ///
195    /// Returns only valid, active, in-range requests.
196    pub async fn list_incoming_payment_requests(
197        &self,
198        host_override: Option<&str>,
199        limits: Option<PaymentRequestLimits>,
200    ) -> Result<Vec<IncomingPaymentRequest>, MessageBoxError> {
201        let limits = limits.unwrap_or_default();
202        let my_identity_key = self.get_identity_key().await?;
203
204        let messages = self
205            .list_messages(PAYMENT_REQUESTS_MESSAGEBOX, false, host_override)
206            .await?;
207
208        // Stage 1: Parse all messages
209        let mut parsed: Vec<(PeerMessage, PaymentRequestMessage)> = Vec::new();
210        let mut malformed_ids: Vec<String> = Vec::new();
211
212        for msg in &messages {
213            match serde_json::from_str::<PaymentRequestMessage>(&msg.body) {
214                Ok(body) if is_valid_payment_request(&body) => {
215                    parsed.push((msg.clone(), body));
216                }
217                _ => {
218                    malformed_ids.push(msg.message_id.clone());
219                }
220            }
221        }
222
223        // Stage 2: Collect verified cancellations
224        let mut cancelled_requests: std::collections::HashMap<String, String> =
225            std::collections::HashMap::new();
226        let mut cancellation_ids: Vec<String> = Vec::new();
227
228        for (msg, body) in &parsed {
229            if body.cancelled == Some(true) {
230                // Verify cancellation HMAC proof
231                let proof_data = format!("{}{}", body.request_id, my_identity_key);
232                let proof_bytes = match hex_to_bytes(&body.request_proof) {
233                    Ok(b) => b,
234                    Err(_) => {
235                        malformed_ids.push(msg.message_id.clone());
236                        continue;
237                    }
238                };
239
240                let verify_result = self
241                    .wallet()
242                    .verify_hmac(
243                        VerifyHmacArgs {
244                            data: proof_data.as_bytes().to_vec(),
245                            hmac: proof_bytes,
246                            protocol_id: payment_request_auth_protocol(),
247                            key_id: body.request_id.clone(),
248                            counterparty: Counterparty {
249                                counterparty_type: CounterpartyType::Other,
250                                public_key: PublicKey::from_string(&msg.sender).ok(),
251                            },
252                            privileged: false,
253                            privileged_reason: None,
254                            seek_permission: None,
255                        },
256                        self.originator(),
257                    )
258                    .await;
259
260                match verify_result {
261                    Ok(r) if r.valid => {
262                        cancelled_requests.insert(body.request_id.clone(), msg.sender.clone());
263                        cancellation_ids.push(msg.message_id.clone());
264                    }
265                    _ => {
266                        malformed_ids.push(msg.message_id.clone());
267                    }
268                }
269            }
270        }
271
272        // Stage 3: Filter active requests
273        let now_ms = std::time::SystemTime::now()
274            .duration_since(std::time::UNIX_EPOCH)
275            .unwrap_or_default()
276            .as_millis() as u64;
277
278        let mut result: Vec<IncomingPaymentRequest> = Vec::new();
279        let mut expired_ids: Vec<String> = Vec::new();
280        let mut cancelled_original_ids: Vec<String> = Vec::new();
281        let mut out_of_range_ids: Vec<String> = Vec::new();
282
283        for (msg, body) in &parsed {
284            // Skip cancellation messages themselves
285            if body.cancelled == Some(true) {
286                continue;
287            }
288
289            let amount = match body.amount {
290                Some(a) => a,
291                None => {
292                    malformed_ids.push(msg.message_id.clone());
293                    continue;
294                }
295            };
296            let description = match &body.description {
297                Some(d) => d.clone(),
298                None => {
299                    malformed_ids.push(msg.message_id.clone());
300                    continue;
301                }
302            };
303            let expires_at = match body.expires_at {
304                Some(e) => e,
305                None => {
306                    malformed_ids.push(msg.message_id.clone());
307                    continue;
308                }
309            };
310
311            // Check expiry
312            if expires_at < now_ms {
313                expired_ids.push(msg.message_id.clone());
314                continue;
315            }
316
317            // Check cancellation
318            if let Some(cancel_sender) = cancelled_requests.get(&body.request_id) {
319                if cancel_sender == &msg.sender {
320                    cancelled_original_ids.push(msg.message_id.clone());
321                    continue;
322                }
323            }
324
325            // Check range
326            if amount < limits.min_amount || amount > limits.max_amount {
327                out_of_range_ids.push(msg.message_id.clone());
328                continue;
329            }
330
331            // Verify HMAC proof
332            let proof_data = format!("{}{}", body.request_id, my_identity_key);
333            let proof_bytes = match hex_to_bytes(&body.request_proof) {
334                Ok(b) => b,
335                Err(_) => {
336                    malformed_ids.push(msg.message_id.clone());
337                    continue;
338                }
339            };
340
341            let verify_result = self
342                .wallet()
343                .verify_hmac(
344                    VerifyHmacArgs {
345                        data: proof_data.as_bytes().to_vec(),
346                        hmac: proof_bytes,
347                        protocol_id: payment_request_auth_protocol(),
348                        key_id: body.request_id.clone(),
349                        counterparty: Counterparty {
350                            counterparty_type: CounterpartyType::Other,
351                            public_key: PublicKey::from_string(&msg.sender).ok(),
352                        },
353                        privileged: false,
354                        privileged_reason: None,
355                        seek_permission: None,
356                    },
357                    self.originator(),
358                )
359                .await;
360
361            match verify_result {
362                Ok(r) if r.valid => {
363                    result.push(IncomingPaymentRequest {
364                        message_id: msg.message_id.clone(),
365                        sender: msg.sender.clone(),
366                        request_id: body.request_id.clone(),
367                        amount,
368                        description,
369                        expires_at,
370                    });
371                }
372                _ => {
373                    malformed_ids.push(msg.message_id.clone());
374                }
375            }
376        }
377
378        // Stage 4: Acknowledge filtered messages
379        let mut ack_ids: Vec<String> = Vec::new();
380        ack_ids.extend(expired_ids);
381        ack_ids.extend(cancelled_original_ids);
382        ack_ids.extend(cancellation_ids);
383        ack_ids.extend(out_of_range_ids);
384        ack_ids.extend(malformed_ids);
385
386        if !ack_ids.is_empty() {
387            // Best-effort acknowledge — don't fail the listing
388            let _ = self.acknowledge_message(ack_ids, host_override).await;
389        }
390
391        Ok(result)
392    }
393
394    /// Fulfill a payment request — send payment and notify the requester.
395    ///
396    /// 1. Sends the requested amount via PeerPay
397    /// 2. Sends a "paid" response to the requester's response inbox
398    /// 3. Acknowledges the original request
399    ///
400    /// Matches TS `PeerPayClient.fulfillPaymentRequest()`.
401    pub async fn fulfill_payment_request(
402        &self,
403        request: &IncomingPaymentRequest,
404        note: Option<&str>,
405        host_override: Option<&str>,
406    ) -> Result<(), MessageBoxError> {
407        // Step 1: Send payment
408        self.send_payment(&request.sender, request.amount).await?;
409
410        // Step 2: Send "paid" response
411        let mut response = PaymentRequestResponse {
412            request_id: request.request_id.clone(),
413            status: "paid".to_string(),
414            amount_paid: Some(request.amount),
415            note: None,
416        };
417        if let Some(n) = note {
418            response.note = Some(n.to_string());
419        }
420
421        let body = serde_json::to_string(&response)?;
422        self.send_message(
423            &request.sender,
424            PAYMENT_REQUEST_RESPONSES_MESSAGEBOX,
425            &body,
426            false,
427            false,
428            None,
429            host_override,
430        )
431        .await?;
432
433        // Step 3: Acknowledge original request
434        self.acknowledge_message(vec![request.message_id.clone()], host_override)
435            .await?;
436
437        Ok(())
438    }
439
440    /// Decline a payment request — notify the requester without sending payment.
441    ///
442    /// Matches TS `PeerPayClient.declinePaymentRequest()`.
443    pub async fn decline_payment_request(
444        &self,
445        request: &IncomingPaymentRequest,
446        note: Option<&str>,
447        host_override: Option<&str>,
448    ) -> Result<(), MessageBoxError> {
449        let mut response = PaymentRequestResponse {
450            request_id: request.request_id.clone(),
451            status: "declined".to_string(),
452            amount_paid: None,
453            note: None,
454        };
455        if let Some(n) = note {
456            response.note = Some(n.to_string());
457        }
458
459        let body = serde_json::to_string(&response)?;
460        self.send_message(
461            &request.sender,
462            PAYMENT_REQUEST_RESPONSES_MESSAGEBOX,
463            &body,
464            false,
465            false,
466            None,
467            host_override,
468        )
469        .await?;
470
471        // Acknowledge original request
472        self.acknowledge_message(vec![request.message_id.clone()], host_override)
473            .await?;
474
475        Ok(())
476    }
477
478    /// Listen for live payment requests via WebSocket.
479    ///
480    /// Wraps `listen_for_live_messages` on the `payment_requests` inbox.
481    /// Cancellation messages are silently skipped.
482    ///
483    /// Matches TS `PeerPayClient.listenForLivePaymentRequests()`.
484    pub async fn listen_for_live_payment_requests(
485        &self,
486        on_request: Arc<dyn Fn(IncomingPaymentRequest) + Send + Sync>,
487        override_host: Option<&str>,
488    ) -> Result<(), MessageBoxError> {
489        let wrapper: Arc<dyn Fn(PeerMessage) + Send + Sync> = Arc::new(move |msg: PeerMessage| {
490            if let Ok(body) = serde_json::from_str::<PaymentRequestMessage>(&msg.body) {
491                // Skip cancellations
492                if body.cancelled == Some(true) {
493                    return;
494                }
495                // Skip malformed (missing required fields)
496                if let (Some(amount), Some(description), Some(expires_at)) =
497                    (body.amount, body.description.clone(), body.expires_at)
498                {
499                    on_request(IncomingPaymentRequest {
500                        message_id: msg.message_id,
501                        sender: msg.sender,
502                        request_id: body.request_id,
503                        amount,
504                        description,
505                        expires_at,
506                    });
507                }
508            }
509        });
510
511        self.listen_for_live_messages(PAYMENT_REQUESTS_MESSAGEBOX, wrapper, override_host)
512            .await
513    }
514
515    /// List responses to payment requests we've sent.
516    ///
517    /// Returns all parsed responses from the `payment_request_responses` inbox.
518    /// Unparseable messages are silently skipped (safeParse behavior).
519    ///
520    /// Matches TS `PeerPayClient.listPaymentRequestResponses()`.
521    pub async fn list_payment_request_responses(
522        &self,
523        host_override: Option<&str>,
524    ) -> Result<Vec<PaymentRequestResponse>, MessageBoxError> {
525        let messages = self
526            .list_messages(PAYMENT_REQUEST_RESPONSES_MESSAGEBOX, false, host_override)
527            .await?;
528
529        let responses = messages
530            .into_iter()
531            .filter_map(|msg| serde_json::from_str::<PaymentRequestResponse>(&msg.body).ok())
532            .collect();
533
534        Ok(responses)
535    }
536
537    /// Listen for live payment request responses via WebSocket.
538    ///
539    /// Wraps `listen_for_live_messages` on the `payment_request_responses` inbox.
540    /// Unparseable messages are silently skipped (safeParse behavior).
541    ///
542    /// Matches TS `PeerPayClient.listenForLivePaymentRequestResponses()`.
543    pub async fn listen_for_live_payment_request_responses(
544        &self,
545        on_response: Arc<dyn Fn(PaymentRequestResponse) + Send + Sync>,
546        override_host: Option<&str>,
547    ) -> Result<(), MessageBoxError> {
548        let wrapper: Arc<dyn Fn(PeerMessage) + Send + Sync> = Arc::new(move |msg: PeerMessage| {
549            if let Ok(response) = serde_json::from_str::<PaymentRequestResponse>(&msg.body) {
550                on_response(response);
551            }
552        });
553
554        self.listen_for_live_messages(PAYMENT_REQUEST_RESPONSES_MESSAGEBOX, wrapper, override_host)
555            .await
556    }
557
558    // -----------------------------------------------------------------------
559    // Permission helpers
560    // -----------------------------------------------------------------------
561
562    /// Allow payment requests from a specific identity key.
563    ///
564    /// Sets recipient_fee to 0 (always allow) for the `payment_requests` inbox.
565    ///
566    /// Matches TS `PeerPayClient.allowPaymentRequestsFrom()`.
567    pub async fn allow_payment_requests_from(
568        &self,
569        identity_key: &str,
570        host_override: Option<&str>,
571    ) -> Result<(), MessageBoxError> {
572        self.set_message_box_permission(
573            crate::types::SetPermissionParams {
574                message_box: PAYMENT_REQUESTS_MESSAGEBOX.to_string(),
575                sender: Some(identity_key.to_string()),
576                recipient_fee: 0,
577            },
578            host_override,
579        )
580        .await
581    }
582
583    /// Block payment requests from a specific identity key.
584    ///
585    /// Sets recipient_fee to -1 (blocked) for the `payment_requests` inbox.
586    ///
587    /// Matches TS `PeerPayClient.blockPaymentRequestsFrom()`.
588    pub async fn block_payment_requests_from(
589        &self,
590        identity_key: &str,
591        host_override: Option<&str>,
592    ) -> Result<(), MessageBoxError> {
593        self.set_message_box_permission(
594            crate::types::SetPermissionParams {
595                message_box: PAYMENT_REQUESTS_MESSAGEBOX.to_string(),
596                sender: Some(identity_key.to_string()),
597                recipient_fee: -1,
598            },
599            host_override,
600        )
601        .await
602    }
603
604    /// List payment request permissions.
605    ///
606    /// Returns a list of `(identity_key, allowed)` pairs for the
607    /// `payment_requests` inbox.
608    ///
609    /// Matches TS `PeerPayClient.listPaymentRequestPermissions()`.
610    pub async fn list_payment_request_permissions(
611        &self,
612        host_override: Option<&str>,
613    ) -> Result<Vec<(String, bool)>, MessageBoxError> {
614        let permissions = self
615            .list_message_box_permissions(
616                Some(PAYMENT_REQUESTS_MESSAGEBOX),
617                None,
618                None,
619                host_override,
620            )
621            .await?;
622
623        let result = permissions
624            .into_iter()
625            .filter(|p| p.sender.is_some() && !p.sender.as_ref().unwrap().is_empty())
626            .map(|p| {
627                let allowed = p.status() != "blocked";
628                (p.sender.unwrap_or_default(), allowed)
629            })
630            .collect();
631
632        Ok(result)
633    }
634}
635
636/// Validate that a PaymentRequestMessage has the required fields.
637///
638/// Matches TS `isValidPaymentRequestMessage()`:
639/// - Must have `requestId`, `senderIdentityKey`, `requestProof` (non-empty strings)
640/// - Must be either a cancellation (`cancelled: true`) or have `amount`, `description`, `expiresAt`
641fn is_valid_payment_request(msg: &PaymentRequestMessage) -> bool {
642    if msg.request_id.is_empty()
643        || msg.sender_identity_key.is_empty()
644        || msg.request_proof.is_empty()
645    {
646        return false;
647    }
648
649    if msg.cancelled == Some(true) {
650        return true;
651    }
652
653    msg.amount.is_some() && msg.description.is_some() && msg.expires_at.is_some()
654}
655
656// ---------------------------------------------------------------------------
657// Tests
658// ---------------------------------------------------------------------------
659
660#[cfg(test)]
661mod tests {
662    use super::*;
663
664    #[test]
665    fn payment_request_message_serializes_camel_case() {
666        let msg = PaymentRequestMessage {
667            request_id: "req-123".to_string(),
668            sender_identity_key: "03abc".to_string(),
669            request_proof: "deadbeef".to_string(),
670            amount: Some(5000),
671            description: Some("test payment".to_string()),
672            expires_at: Some(1700000000000),
673            cancelled: None,
674        };
675        let json = serde_json::to_string(&msg).unwrap();
676        assert!(json.contains("\"requestId\""));
677        assert!(json.contains("\"senderIdentityKey\""));
678        assert!(json.contains("\"requestProof\""));
679        assert!(json.contains("\"expiresAt\""));
680        assert!(!json.contains("request_id"));
681        assert!(!json.contains("sender_identity_key"));
682        assert!(!json.contains("cancelled"));
683    }
684
685    #[test]
686    fn cancellation_message_serializes_correctly() {
687        let msg = PaymentRequestMessage {
688            request_id: "req-456".to_string(),
689            sender_identity_key: "03def".to_string(),
690            request_proof: "cafebabe".to_string(),
691            amount: None,
692            description: None,
693            expires_at: None,
694            cancelled: Some(true),
695        };
696        let json = serde_json::to_string(&msg).unwrap();
697        assert!(json.contains("\"cancelled\":true"));
698        assert!(!json.contains("amount"));
699        assert!(!json.contains("description"));
700        assert!(!json.contains("expiresAt"));
701    }
702
703    #[test]
704    fn payment_request_response_round_trip() {
705        let resp = PaymentRequestResponse {
706            request_id: "req-789".to_string(),
707            status: "paid".to_string(),
708            note: Some("done".to_string()),
709            amount_paid: Some(5000),
710        };
711        let json = serde_json::to_string(&resp).unwrap();
712        let back: PaymentRequestResponse = serde_json::from_str(&json).unwrap();
713        assert_eq!(back.request_id, "req-789");
714        assert_eq!(back.status, "paid");
715        assert_eq!(back.amount_paid, Some(5000));
716        assert_eq!(back.note, Some("done".to_string()));
717    }
718
719    #[test]
720    fn declined_response_omits_amount_paid() {
721        let resp = PaymentRequestResponse {
722            request_id: "req-abc".to_string(),
723            status: "declined".to_string(),
724            note: None,
725            amount_paid: None,
726        };
727        let json = serde_json::to_string(&resp).unwrap();
728        assert!(!json.contains("amountPaid"));
729        assert!(!json.contains("note"));
730    }
731
732    #[test]
733    fn is_valid_payment_request_validates_correctly() {
734        // Valid new request
735        let valid = PaymentRequestMessage {
736            request_id: "r1".to_string(),
737            sender_identity_key: "03abc".to_string(),
738            request_proof: "proof".to_string(),
739            amount: Some(1000),
740            description: Some("test".to_string()),
741            expires_at: Some(999999),
742            cancelled: None,
743        };
744        assert!(is_valid_payment_request(&valid));
745
746        // Valid cancellation
747        let cancel = PaymentRequestMessage {
748            request_id: "r2".to_string(),
749            sender_identity_key: "03def".to_string(),
750            request_proof: "proof".to_string(),
751            amount: None,
752            description: None,
753            expires_at: None,
754            cancelled: Some(true),
755        };
756        assert!(is_valid_payment_request(&cancel));
757
758        // Invalid: missing request_id
759        let bad = PaymentRequestMessage {
760            request_id: "".to_string(),
761            sender_identity_key: "03abc".to_string(),
762            request_proof: "proof".to_string(),
763            amount: Some(1000),
764            description: Some("test".to_string()),
765            expires_at: Some(999999),
766            cancelled: None,
767        };
768        assert!(!is_valid_payment_request(&bad));
769
770        // Invalid: not cancelled but missing amount
771        let bad2 = PaymentRequestMessage {
772            request_id: "r3".to_string(),
773            sender_identity_key: "03abc".to_string(),
774            request_proof: "proof".to_string(),
775            amount: None,
776            description: Some("test".to_string()),
777            expires_at: Some(999999),
778            cancelled: None,
779        };
780        assert!(!is_valid_payment_request(&bad2));
781    }
782
783    #[test]
784    fn payment_request_limits_default() {
785        let limits = PaymentRequestLimits::default();
786        assert_eq!(limits.min_amount, 1000);
787        assert_eq!(limits.max_amount, 10_000_000);
788    }
789}