Skip to main content

bsv_messagebox_client/
types.rs

1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4
5// ---------------------------------------------------------------------------
6// Phase 6 — parity types: sendList, multi-quote, payment metadata
7// ---------------------------------------------------------------------------
8
9/// Parameters for sending a message to multiple recipients in one call.
10#[derive(Serialize, Deserialize, Clone, Debug)]
11#[serde(rename_all = "camelCase")]
12pub struct SendListParams {
13    pub recipients: Vec<String>,
14    pub message_box: String,
15    pub body: String,
16    #[serde(skip_serializing_if = "Option::is_none")]
17    pub skip_encryption: Option<bool>,
18}
19
20/// A single successfully-delivered recipient entry in a sendList result.
21#[derive(Serialize, Deserialize, Clone, Debug)]
22#[serde(rename_all = "camelCase")]
23pub struct SentRecipient {
24    pub recipient: String,
25    pub message_id: String,
26}
27
28/// A failed recipient entry in a sendList result.
29#[derive(Serialize, Deserialize, Clone, Debug)]
30#[serde(rename_all = "camelCase")]
31pub struct FailedRecipient {
32    pub recipient: String,
33    pub error: String,
34}
35
36/// Aggregate fee totals returned in a sendList or multi-quote response.
37#[derive(Serialize, Deserialize, Clone, Debug)]
38#[serde(rename_all = "camelCase")]
39pub struct SendListTotals {
40    pub delivery_fees: i64,
41    pub recipient_fees: i64,
42    pub total_for_payable_recipients: i64,
43}
44
45/// Result of a sendList operation.
46#[derive(Serialize, Deserialize, Clone, Debug)]
47#[serde(rename_all = "camelCase")]
48pub struct SendListResult {
49    pub status: String,
50    pub description: String,
51    pub sent: Vec<SentRecipient>,
52    pub blocked: Vec<String>,
53    pub failed: Vec<FailedRecipient>,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub totals: Option<SendListTotals>,
56}
57
58/// Quote for a single recipient in a multi-recipient quote request.
59#[derive(Serialize, Deserialize, Clone, Debug)]
60#[serde(rename_all = "camelCase")]
61pub struct RecipientQuote {
62    pub recipient: String,
63    pub message_box: String,
64    pub delivery_fee: i64,
65    pub recipient_fee: i64,
66    pub status: String,
67}
68
69/// Aggregated delivery quotes for multiple recipients.
70#[derive(Serialize, Deserialize, Clone, Debug)]
71#[serde(rename_all = "camelCase")]
72pub struct MessageBoxMultiQuote {
73    pub quotes_by_recipient: Vec<RecipientQuote>,
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub totals: Option<SendListTotals>,
76    pub blocked_recipients: Vec<String>,
77    pub delivery_agent_identity_key_by_host: HashMap<String, String>,
78}
79
80/// Remittance output describing how a payment was routed to a recipient.
81///
82/// Carries the derivation keys needed to prove the counterparty can redeem.
83#[derive(Serialize, Deserialize, Clone, Debug)]
84#[serde(rename_all = "camelCase")]
85pub struct PaymentRemittanceInfo {
86    pub derivation_prefix: String,
87    pub derivation_suffix: String,
88    pub sender_identity_key: String,
89}
90
91/// Basket insertion remittance — used when the output targets a basket.
92#[derive(Serialize, Deserialize, Clone, Debug)]
93#[serde(rename_all = "camelCase")]
94pub struct InsertionRemittanceInfo {
95    pub basket: String,
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub custom_instructions: Option<String>,
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub tags: Option<Vec<String>>,
100}
101
102/// A single output within a Payment struct.
103#[derive(Serialize, Deserialize, Clone, Debug)]
104#[serde(rename_all = "camelCase")]
105pub struct PaymentOutput {
106    pub output_index: u32,
107    pub protocol: String,
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub payment_remittance: Option<PaymentRemittanceInfo>,
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub insertion_remittance: Option<InsertionRemittanceInfo>,
112}
113
114/// A payment token for attaching BSV remittance to a sendList call.
115///
116/// Distinct from `PaymentToken` (PeerPay p2p) — this type represents an
117/// on-chain payment submitted alongside a multi-recipient send.
118#[derive(Serialize, Deserialize, Clone, Debug)]
119#[serde(rename_all = "camelCase")]
120pub struct Payment {
121    pub tx: Vec<u8>,
122    pub outputs: Vec<PaymentOutput>,
123    pub description: String,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub labels: Option<Vec<String>>,
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub seek_permission: Option<bool>,
128}
129
130// ---------------------------------------------------------------------------
131// Phase 5 — overlay advertisement and device registration types
132// ---------------------------------------------------------------------------
133
134/// An advertisement token recovered from an overlay lookup.
135///
136/// Represents a single UTXO that encodes a MessageBox host advertisement via
137/// PushDrop: fields[0] = identity key bytes, fields[1] = host URL bytes.
138#[derive(Clone, Debug)]
139pub struct AdvertisementToken {
140    /// The host URL encoded in the advertisement.
141    pub host: String,
142    /// Transaction ID of the advertising UTXO.
143    pub txid: String,
144    /// Output index within the advertising transaction.
145    pub output_index: u32,
146    /// Hex-encoded locking script of the advertising output.
147    pub locking_script: String,
148    /// Raw BEEF bytes for the advertising transaction (needed for revocation).
149    pub beef: Vec<u8>,
150}
151
152/// Request body for registering an FCM device token.
153///
154/// Serializes to camelCase JSON for POST to `{host}/registerDevice`.
155/// Optional fields are omitted when None per TS wire format.
156#[derive(Serialize, Clone, Debug)]
157#[serde(rename_all = "camelCase")]
158pub struct RegisterDeviceRequest {
159    pub fcm_token: String,
160    #[serde(skip_serializing_if = "Option::is_none")]
161    pub device_id: Option<String>,
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub platform: Option<String>,
164}
165
166/// A registered device record as returned by the server's `/devices` endpoint.
167///
168/// All fields are `Option` because the server may omit any of them.
169/// Includes ALL known server fields to prevent deserialization failures
170/// against go-messagebox-server.
171#[derive(Deserialize, Debug, Clone)]
172#[serde(rename_all = "camelCase")]
173pub struct RegisteredDevice {
174    pub id: Option<i64>,
175    pub device_id: Option<String>,
176    pub platform: Option<String>,
177    pub fcm_token: String,
178    pub active: Option<bool>,
179    pub created_at: Option<String>,
180    pub updated_at: Option<String>,
181    pub last_used: Option<String>,
182}
183
184/// Response from the `/registerDevice` endpoint.
185///
186/// TS returns `{ status, message, deviceId }`.
187#[derive(Deserialize, Clone, Debug)]
188#[serde(rename_all = "camelCase")]
189pub struct RegisterDeviceResponse {
190    pub status: String,
191    pub message: Option<String>,
192    pub device_id: Option<i64>,
193}
194
195/// Response from the `/devices` (list registered devices) endpoint.
196#[derive(Deserialize, Clone, Debug)]
197#[serde(rename_all = "camelCase")]
198pub struct ListDevicesResponse {
199    pub status: String,
200    pub devices: Vec<RegisteredDevice>,
201}
202
203// ---------------------------------------------------------------------------
204// Phase 2 — permission / quote types
205// ---------------------------------------------------------------------------
206
207/// Parameters for setting a permission rule on a message box.
208///
209/// Serializes to camelCase for POST body to `/permissions/set`.
210/// `sender` is omitted when `None` — the server treats absence as "any sender".
211#[derive(Serialize, Deserialize, Clone, Debug)]
212#[serde(rename_all = "camelCase")]
213pub struct SetPermissionParams {
214    pub message_box: String,
215    #[serde(skip_serializing_if = "Option::is_none")]
216    pub sender: Option<String>,
217    /// -1 = block, 0 = always allow, >0 = satoshi fee required
218    pub recipient_fee: i64,
219}
220
221/// A permission record as returned by `/permissions/get` (camelCase) or
222/// `/permissions/list` (snake_case).
223///
224/// Uses per-field `#[serde(alias)]` instead of `rename_all` so the same
225/// struct deserializes from BOTH server endpoint formats.
226#[derive(Serialize, Deserialize, Clone, Debug)]
227pub struct MessageBoxPermission {
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub sender: Option<String>,
230    #[serde(alias = "messageBox")]
231    pub message_box: String,
232    #[serde(alias = "recipientFee")]
233    pub recipient_fee: i64,
234    #[serde(alias = "createdAt")]
235    pub created_at: String,
236    #[serde(alias = "updatedAt")]
237    pub updated_at: String,
238}
239
240impl MessageBoxPermission {
241    /// Compute the permission status from the `recipient_fee` value.
242    ///
243    /// Mirrors the TypeScript SDK's `getStatusFromFee()`:
244    /// -1 → "blocked", 0 → "always_allow", >0 → "payment_required"
245    pub fn status(&self) -> &str {
246        match self.recipient_fee {
247            f if f < 0 => "blocked",
248            0 => "always_allow",
249            _ => "payment_required",
250        }
251    }
252}
253
254/// Quote for delivering a message to a recipient.
255///
256/// NOT deserialized directly from JSON — constructed manually after parsing
257/// the wrapped `{"quote": {"recipientFee": N, "deliveryFee": N}}` response
258/// body and extracting the `x-bsv-auth-identity-key` response header.
259#[derive(Clone, Debug)]
260pub struct MessageBoxQuote {
261    pub delivery_fee: i64,
262    pub recipient_fee: i64,
263    /// Populated from the `x-bsv-auth-identity-key` response header, not the body.
264    pub delivery_agent_identity_key: String,
265}
266
267/// Parameters for sending a message to a recipient's inbox.
268#[derive(Serialize, Deserialize, Clone, Debug)]
269#[serde(rename_all = "camelCase")]
270pub struct SendMessageParams {
271    pub recipient: String,
272    pub message_box: String,
273    pub body: String,
274    pub message_id: String,
275}
276
277/// Payment structure included in a sendMessage request when check_permissions requires a fee.
278///
279/// Serializes the transaction bytes and outputs to wire format matching TS Payment type.
280#[derive(Serialize, Deserialize, Clone, Debug)]
281#[serde(rename_all = "camelCase")]
282pub struct MessagePayment {
283    /// Raw transaction bytes.
284    pub tx: Vec<u8>,
285    /// Outputs from the payment transaction.
286    pub outputs: Vec<MessagePaymentOutput>,
287}
288
289/// One output entry in a MessagePayment.
290#[derive(Serialize, Deserialize, Clone, Debug)]
291#[serde(rename_all = "camelCase")]
292pub struct MessagePaymentOutput {
293    pub output_index: u32,
294    pub derivation_prefix: Vec<u8>,
295    pub derivation_suffix: Vec<u8>,
296    pub sender_identity_key: String,
297}
298
299/// Wire format wrapper — serializes as `{"message": <params>}` for the `/sendMessage` endpoint.
300#[derive(Serialize, Deserialize, Clone, Debug)]
301pub struct SendMessageRequest {
302    pub message: SendMessageParams,
303    #[serde(skip_serializing_if = "Option::is_none")]
304    pub payment: Option<MessagePayment>,
305}
306
307
308/// Parameters for listing messages from a specific inbox.
309#[derive(Serialize, Deserialize, Clone, Debug)]
310#[serde(rename_all = "camelCase")]
311pub struct ListMessagesParams {
312    pub message_box: String,
313}
314
315/// Parameters for acknowledging (marking as read) a set of messages.
316#[derive(Serialize, Deserialize, Clone, Debug)]
317#[serde(rename_all = "camelCase")]
318pub struct AcknowledgeMessageParams {
319    pub message_ids: Vec<String>,
320}
321
322/// A message as returned by the server's list endpoint.
323///
324/// Uses explicit `serde(rename)` on camelCase fields because the server mixes
325/// naming conventions: `messageId`, `sender` are camelCase but `created_at` /
326/// `updated_at` are snake_case. Do NOT use `deny_unknown_fields` — the server
327/// may add new fields at any time.
328#[derive(Serialize, Deserialize, Clone, Debug)]
329pub struct ServerPeerMessage {
330    #[serde(rename = "messageId")]
331    pub message_id: String,
332    pub body: String,
333    pub sender: String,
334    #[serde(alias = "createdAt")]
335    pub created_at: String,
336    #[serde(alias = "updatedAt")]
337    pub updated_at: String,
338    #[serde(skip_serializing_if = "Option::is_none")]
339    pub acknowledged: Option<bool>,
340    /// `true` iff `body` was produced by a genuine authenticated (AEAD) decrypt
341    /// of an encrypted envelope against `sender`'s key — set by
342    /// [`crate::client::MessageBoxClient::list_messages_lite`] via the typed
343    /// decrypt path. ADDITIVE + `#[serde(default)]`: never sent on the wire by the
344    /// server (it is `false` after deserialization), so this does not break wire
345    /// compatibility or any existing consumer (e.g. peerpay). Consumers requiring
346    /// sender provenance (the MPC transport) reject bodies with this `false`.
347    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
348    pub authenticated_decrypt: bool,
349}
350
351/// A live-delivered peer message paired with its authenticated-decrypt provenance.
352///
353/// Client-owned sibling of `bsv::remittance::types::PeerMessage` (which is an
354/// upstream SDK type we cannot extend). Delivered by
355/// [`crate::client::MessageBoxClient::listen_for_live_messages_typed`] so that
356/// provenance-requiring consumers (the MPC transport) can fail-closed on bodies
357/// that did not AEAD-decrypt against `sender`. The legacy
358/// [`crate::client::MessageBoxClient::listen_for_live_messages`] continues to
359/// deliver a bare `PeerMessage` (this flag dropped) for existing consumers.
360#[derive(Clone, Debug)]
361pub struct AuthenticatedPeerMessage {
362    pub message_id: String,
363    pub sender: String,
364    pub recipient: String,
365    pub message_box: String,
366    pub body: String,
367    /// `true` iff `body` came from a genuine authenticated (AEAD) decrypt of an
368    /// encrypted envelope against `sender`'s key.
369    pub authenticated_decrypt: bool,
370}
371
372/// Response from the `/listMessages` endpoint.
373#[derive(Serialize, Deserialize, Clone, Debug)]
374#[serde(rename_all = "camelCase")]
375pub struct ListMessagesResponse {
376    pub status: String,
377    pub messages: Vec<ServerPeerMessage>,
378}
379
380/// Response from the `/sendMessage` endpoint.
381#[derive(Serialize, Deserialize, Clone, Debug)]
382#[serde(rename_all = "camelCase")]
383pub struct SendMessageResponse {
384    pub status: String,
385    /// The server-assigned message ID (may be absent on some server versions).
386    #[serde(skip_serializing_if = "Option::is_none")]
387    pub message_id: Option<String>,
388}
389
390// ---------------------------------------------------------------------------
391// Phase 4 — WebSocket live messaging wire types
392// ---------------------------------------------------------------------------
393
394/// Payload for the sendMessage WebSocket event.
395///
396/// Serialized as JSON and passed to socket.emit("sendMessage", ...).
397/// Wire format: {"roomId": "recipient-inbox", "message": {"messageId": "...", ...}}
398#[derive(Serialize, Deserialize, Clone, Debug)]
399#[serde(rename_all = "camelCase")]
400pub struct WsSendMessageData {
401    pub room_id: String,
402    pub message: WsSendMessagePayload,
403}
404
405/// The message object inside a sendMessage event.
406#[derive(Serialize, Deserialize, Clone, Debug)]
407#[serde(rename_all = "camelCase")]
408pub struct WsSendMessagePayload {
409    pub message_id: String,
410    pub recipient: String,
411    pub body: String,
412}
413
414// ---------------------------------------------------------------------------
415// Phase 7 — Payment Request types (PeerPay Request System)
416// ---------------------------------------------------------------------------
417
418/// Message box names for the payment request system.
419pub const PAYMENT_REQUESTS_MESSAGEBOX: &str = "payment_requests";
420pub const PAYMENT_REQUEST_RESPONSES_MESSAGEBOX: &str = "payment_request_responses";
421
422/// Default limits for filtering incoming payment requests.
423pub const DEFAULT_PAYMENT_REQUEST_MIN_AMOUNT: u64 = 1000;
424pub const DEFAULT_PAYMENT_REQUEST_MAX_AMOUNT: u64 = 10_000_000;
425
426/// A payment request message sent to a recipient's `payment_requests` inbox.
427///
428/// Discriminated union: if `cancelled` is `Some(true)`, this is a cancellation
429/// message and `amount`/`description`/`expires_at` will be absent.
430#[derive(Serialize, Deserialize, Clone, Debug)]
431#[serde(rename_all = "camelCase")]
432pub struct PaymentRequestMessage {
433    pub request_id: String,
434    pub sender_identity_key: String,
435    pub request_proof: String,
436    #[serde(skip_serializing_if = "Option::is_none")]
437    pub amount: Option<u64>,
438    #[serde(skip_serializing_if = "Option::is_none")]
439    pub description: Option<String>,
440    #[serde(skip_serializing_if = "Option::is_none")]
441    pub expires_at: Option<u64>,
442    #[serde(skip_serializing_if = "Option::is_none")]
443    pub cancelled: Option<bool>,
444}
445
446/// Response to a payment request, sent to the requester's
447/// `payment_request_responses` inbox.
448#[derive(Serialize, Deserialize, Clone, Debug)]
449#[serde(rename_all = "camelCase")]
450pub struct PaymentRequestResponse {
451    pub request_id: String,
452    pub status: String, // "paid" | "declined"
453    #[serde(skip_serializing_if = "Option::is_none")]
454    pub note: Option<String>,
455    #[serde(skip_serializing_if = "Option::is_none")]
456    pub amount_paid: Option<u64>,
457}
458
459/// A validated incoming payment request (after filtering and HMAC verification).
460#[derive(Clone, Debug)]
461pub struct IncomingPaymentRequest {
462    pub message_id: String,
463    pub sender: String,
464    pub request_id: String,
465    pub amount: u64,
466    pub description: String,
467    pub expires_at: u64,
468}
469
470/// Optional limits for filtering incoming payment requests.
471#[derive(Clone, Debug)]
472pub struct PaymentRequestLimits {
473    pub min_amount: u64,
474    pub max_amount: u64,
475}
476
477impl Default for PaymentRequestLimits {
478    fn default() -> Self {
479        Self {
480            min_amount: DEFAULT_PAYMENT_REQUEST_MIN_AMOUNT,
481            max_amount: DEFAULT_PAYMENT_REQUEST_MAX_AMOUNT,
482        }
483    }
484}
485
486/// Result returned from `request_payment`.
487#[derive(Clone, Debug)]
488pub struct PaymentRequestResult {
489    pub request_id: String,
490    pub request_proof: String,
491}
492
493// ---------------------------------------------------------------------------
494// Phase 3 — PeerPay payment types
495// ---------------------------------------------------------------------------
496
497/// Custom instructions embedded in a PeerPay transaction output.
498///
499/// Serializes to camelCase JSON for the TS wire format.
500/// `payee` is omitted when None (TS omits it when there's no explicit payee override).
501#[derive(Serialize, Deserialize, Clone, Debug)]
502#[serde(rename_all = "camelCase")]
503pub struct PaymentCustomInstructions {
504    pub derivation_prefix: String,
505    pub derivation_suffix: String,
506    #[serde(skip_serializing_if = "Option::is_none")]
507    pub payee: Option<String>,
508}
509
510/// A PeerPay payment token sent to a recipient's payment_inbox.
511///
512/// Serializes to camelCase JSON matching the TS PaymentToken wire format:
513/// - `transaction` is a number array (Vec<u8>) on the wire
514/// - `outputIndex` is omitted at creation time (None); defaulted to 0 at accept time
515#[derive(Serialize, Deserialize, Clone, Debug)]
516#[serde(rename_all = "camelCase")]
517pub struct PaymentToken {
518    pub custom_instructions: PaymentCustomInstructions,
519    /// Raw transaction bytes.
520    pub transaction: Vec<u8>,
521    pub amount: u64,
522    /// Only present after being set by the sender; defaults to 0 at accept time.
523    #[serde(skip_serializing_if = "Option::is_none")]
524    pub output_index: Option<u32>,
525}
526
527/// A parsed incoming payment from the payment_inbox.
528///
529/// Holds the decoded token plus routing metadata needed for accept/reject.
530/// NOT serialized — constructed internally from a ServerPeerMessage.
531#[derive(Clone, Debug)]
532pub struct IncomingPayment {
533    pub token: PaymentToken,
534    pub sender: String,
535    pub message_id: String,
536}
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541
542    // -----------------------------------------------------------------------
543    // Phase 2 — permission type tests
544    // -----------------------------------------------------------------------
545
546    #[test]
547    fn set_permission_params_serializes_camel_case() {
548        let p = SetPermissionParams {
549            message_box: "payment_inbox".to_string(),
550            sender: None,
551            recipient_fee: 100,
552        };
553        let json = serde_json::to_string(&p).unwrap();
554        assert!(json.contains("\"messageBox\""), "messageBox field name");
555        assert!(json.contains("\"recipientFee\""), "recipientFee field name");
556        assert!(!json.contains("message_box"), "no snake_case leakage");
557        assert!(!json.contains("recipient_fee"), "no snake_case leakage");
558        assert!(!json.contains("sender"), "sender absent when None");
559    }
560
561    #[test]
562    fn set_permission_params_includes_sender_when_some() {
563        let p = SetPermissionParams {
564            message_box: "inbox".to_string(),
565            sender: Some("03abc".to_string()),
566            recipient_fee: 0,
567        };
568        let json = serde_json::to_string(&p).unwrap();
569        assert!(json.contains("\"sender\""), "sender present when Some");
570        assert!(json.contains("\"03abc\""), "sender value correct");
571    }
572
573    #[test]
574    fn message_box_permission_deserializes_camel_case() {
575        let raw = r#"{
576            "messageBox": "payment_inbox",
577            "recipientFee": 50,
578            "createdAt": "2024-01-01T00:00:00Z",
579            "updatedAt": "2024-01-02T00:00:00Z"
580        }"#;
581        let perm: MessageBoxPermission = serde_json::from_str(raw).unwrap();
582        assert_eq!(perm.message_box, "payment_inbox");
583        assert_eq!(perm.recipient_fee, 50);
584        assert_eq!(perm.created_at, "2024-01-01T00:00:00Z");
585        assert_eq!(perm.updated_at, "2024-01-02T00:00:00Z");
586    }
587
588    #[test]
589    fn message_box_permission_deserializes_snake_case() {
590        // The /permissions/list endpoint returns snake_case field names.
591        let raw = r#"{
592            "message_box": "payment_inbox",
593            "recipient_fee": 75,
594            "created_at": "2024-01-01T00:00:00Z",
595            "updated_at": "2024-01-02T00:00:00Z"
596        }"#;
597        let perm: MessageBoxPermission = serde_json::from_str(raw).unwrap();
598        assert_eq!(perm.message_box, "payment_inbox");
599        assert_eq!(perm.recipient_fee, 75);
600        assert_eq!(perm.created_at, "2024-01-01T00:00:00Z");
601        assert_eq!(perm.updated_at, "2024-01-02T00:00:00Z");
602    }
603
604    #[test]
605    fn message_box_permission_status_computes_correctly() {
606        let make = |fee: i64| MessageBoxPermission {
607            sender: None,
608            message_box: "inbox".to_string(),
609            recipient_fee: fee,
610            created_at: "2024-01-01".to_string(),
611            updated_at: "2024-01-01".to_string(),
612        };
613        assert_eq!(make(-1).status(), "blocked");
614        assert_eq!(make(0).status(), "always_allow");
615        assert_eq!(make(100).status(), "payment_required");
616        assert_eq!(make(1).status(), "payment_required");
617    }
618
619    #[test]
620    fn message_box_quote_can_be_constructed() {
621        // MessageBoxQuote is manually constructed — not deserialized from JSON.
622        let quote = MessageBoxQuote {
623            delivery_fee: 10,
624            recipient_fee: 50,
625            delivery_agent_identity_key: "03deadbeef".to_string(),
626        };
627        assert_eq!(quote.delivery_fee, 10);
628        assert_eq!(quote.recipient_fee, 50);
629        assert_eq!(quote.delivery_agent_identity_key, "03deadbeef");
630    }
631
632    // -----------------------------------------------------------------------
633    // Phase 3 — payment type tests
634    // -----------------------------------------------------------------------
635
636    #[test]
637    fn payment_custom_instructions_round_trip() {
638        let ci = PaymentCustomInstructions {
639            derivation_prefix: "pfx123".to_string(),
640            derivation_suffix: "sfx456".to_string(),
641            payee: Some("03abc".to_string()),
642        };
643        let json = serde_json::to_string(&ci).unwrap();
644        let back: PaymentCustomInstructions = serde_json::from_str(&json).unwrap();
645        assert_eq!(back.derivation_prefix, "pfx123");
646        assert_eq!(back.derivation_suffix, "sfx456");
647        assert_eq!(back.payee, Some("03abc".to_string()));
648    }
649
650    #[test]
651    fn payment_token_serializes_camel_case() {
652        let token = PaymentToken {
653            custom_instructions: PaymentCustomInstructions {
654                derivation_prefix: "pfx".to_string(),
655                derivation_suffix: "sfx".to_string(),
656                payee: Some("03recipient".to_string()),
657            },
658            transaction: vec![1, 2, 3],
659            amount: 1000,
660            output_index: Some(0),
661        };
662        let json = serde_json::to_string(&token).unwrap();
663        // Verify camelCase field names
664        assert!(json.contains("\"customInstructions\""), "customInstructions field name");
665        assert!(json.contains("\"derivationPrefix\""), "derivationPrefix field name");
666        assert!(json.contains("\"derivationSuffix\""), "derivationSuffix field name");
667        assert!(json.contains("\"payee\""), "payee present when Some");
668        assert!(json.contains("\"outputIndex\""), "outputIndex present when Some");
669        // No snake_case leakage
670        assert!(!json.contains("custom_instructions"), "no snake_case leakage");
671        assert!(!json.contains("derivation_prefix"), "no snake_case leakage");
672        assert!(!json.contains("output_index"), "no snake_case leakage");
673    }
674
675    #[test]
676    fn payment_token_no_output_index_by_default() {
677        let token = PaymentToken {
678            custom_instructions: PaymentCustomInstructions {
679                derivation_prefix: "pfx".to_string(),
680                derivation_suffix: "sfx".to_string(),
681                payee: None,
682            },
683            transaction: vec![0xab, 0xcd],
684            amount: 500,
685            output_index: None,
686        };
687        let json = serde_json::to_string(&token).unwrap();
688        // outputIndex must be absent when None
689        assert!(!json.contains("outputIndex"), "outputIndex absent when None");
690        // payee must be absent when None
691        assert!(!json.contains("payee"), "payee absent when None");
692    }
693
694    #[test]
695    fn incoming_payment_can_be_constructed() {
696        let token = PaymentToken {
697            custom_instructions: PaymentCustomInstructions {
698                derivation_prefix: "p".to_string(),
699                derivation_suffix: "s".to_string(),
700                payee: None,
701            },
702            transaction: vec![0x01],
703            amount: 2000,
704            output_index: None,
705        };
706        let incoming = IncomingPayment {
707            token: token.clone(),
708            sender: "03sender".to_string(),
709            message_id: "msg001".to_string(),
710        };
711        assert_eq!(incoming.sender, "03sender");
712        assert_eq!(incoming.message_id, "msg001");
713        assert_eq!(incoming.token.amount, 2000);
714    }
715
716    // -----------------------------------------------------------------------
717    // Phase 5 — overlay/device registration type tests
718    // -----------------------------------------------------------------------
719
720    #[test]
721    fn register_device_request_camel_case() {
722        let req = RegisterDeviceRequest {
723            fcm_token: "tok123".to_string(),
724            device_id: Some("dev-abc".to_string()),
725            platform: Some("android".to_string()),
726        };
727        let json = serde_json::to_string(&req).unwrap();
728        assert!(json.contains("\"fcmToken\""), "fcmToken field name");
729        assert!(json.contains("\"deviceId\""), "deviceId field name");
730        assert!(json.contains("\"platform\""), "platform field name");
731        assert!(!json.contains("fcm_token"), "no snake_case leakage");
732        assert!(!json.contains("device_id"), "no snake_case leakage");
733    }
734
735    #[test]
736    fn register_device_request_omits_optional_fields_when_none() {
737        let req = RegisterDeviceRequest {
738            fcm_token: "tok456".to_string(),
739            device_id: None,
740            platform: None,
741        };
742        let json = serde_json::to_string(&req).unwrap();
743        assert!(json.contains("\"fcmToken\""), "fcmToken present");
744        assert!(!json.contains("deviceId"), "deviceId absent when None");
745        assert!(!json.contains("platform"), "platform absent when None");
746    }
747
748    #[test]
749    fn registered_device_deserializes_camel_case() {
750        let raw = r#"{
751            "id": 42,
752            "deviceId": "dev-123",
753            "platform": "ios",
754            "fcmToken": "fcm-abc",
755            "active": true,
756            "createdAt": "2024-01-01T00:00:00Z",
757            "updatedAt": "2024-01-02T00:00:00Z",
758            "lastUsed": "2024-01-03T00:00:00Z"
759        }"#;
760        let dev: RegisteredDevice = serde_json::from_str(raw).unwrap();
761        assert_eq!(dev.id, Some(42));
762        assert_eq!(dev.device_id.as_deref(), Some("dev-123"));
763        assert_eq!(dev.platform.as_deref(), Some("ios"));
764        assert_eq!(dev.fcm_token, "fcm-abc");
765        assert_eq!(dev.active, Some(true));
766        assert_eq!(dev.created_at.as_deref(), Some("2024-01-01T00:00:00Z"));
767        assert_eq!(dev.last_used.as_deref(), Some("2024-01-03T00:00:00Z"));
768    }
769
770    // -----------------------------------------------------------------------
771    // Phase 1 — existing tests
772    // -----------------------------------------------------------------------
773
774    #[test]
775    fn send_message_params_serializes_camel_case() {
776        let p = SendMessageParams {
777            recipient: "03abc".to_string(),
778            message_box: "inbox".to_string(),
779            body: "hello".to_string(),
780            message_id: "deadbeef".to_string(),
781        };
782        let json = serde_json::to_string(&p).unwrap();
783        assert!(json.contains("\"messageBox\""), "messageBox field name");
784        assert!(json.contains("\"messageId\""), "messageId field name");
785        assert!(!json.contains("message_box"), "no snake_case leakage");
786        assert!(!json.contains("message_id"), "no snake_case leakage");
787    }
788
789    #[test]
790    fn acknowledge_params_serializes_camel_case() {
791        let p = AcknowledgeMessageParams {
792            message_ids: vec!["id1".to_string(), "id2".to_string()],
793        };
794        let json = serde_json::to_string(&p).unwrap();
795        assert!(json.contains("\"messageIds\""), "messageIds field name");
796        assert!(!json.contains("message_ids"), "no snake_case leakage");
797    }
798
799    #[test]
800    fn server_peer_message_tolerates_unknown_fields() {
801        // The server may add fields not in our struct — must NOT reject them.
802        let raw = r#"{
803            "messageId": "abc123",
804            "body": "hello",
805            "sender": "03xyz",
806            "created_at": "2024-01-01T00:00:00Z",
807            "updated_at": "2024-01-01T00:00:00Z",
808            "acknowledged": false,
809            "unknownField": "should be ignored",
810            "anotherExtra": 42
811        }"#;
812        let msg: ServerPeerMessage = serde_json::from_str(raw).unwrap();
813        assert_eq!(msg.message_id, "abc123");
814        assert_eq!(msg.body, "hello");
815        assert_eq!(msg.acknowledged, Some(false));
816    }
817}