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