Skip to main content

bsv_messagebox_client/
http_ops.rs

1use std::collections::{HashMap, HashSet};
2
3use bsv::primitives::public_key::PublicKey;
4use bsv::remittance::types::PeerMessage;
5use bsv::wallet::interfaces::{InternalizeActionArgs, InternalizeOutput, Payment, WalletInterface};
6use bsv::wallet::types::BooleanDefaultTrue;
7use futures_util::future::join_all;
8
9use crate::client::MessageBoxClient;
10use crate::client::{check_status_error, is_duplicate_message_rejection};
11use crate::encryption;
12use crate::error::MessageBoxError;
13use crate::types::{
14    AcknowledgeMessageParams, FailedRecipient, ListMessagesParams, ListMessagesResponse,
15    MessagePayment, MessagePaymentOutput, SendListParams, SendListResult, SendMessageParams,
16    SendMessageRequest, SendMessageResponse, SentRecipient, ServerPeerMessage,
17};
18
19/// Deduplicate messages from multiple hosts by `message_id`, preserving order.
20///
21/// First occurrence wins — matches TS `Promise.allSettled` + Map-based dedup semantics.
22/// Server returns messages newest-first; this preserves that ordering by using a
23/// HashSet for seen-tracking and a Vec for ordered output (TS parity: sorted newest-first).
24pub(crate) fn dedup_messages(results: Vec<Vec<PeerMessage>>) -> Vec<PeerMessage> {
25    let mut seen = HashSet::new();
26    let mut out = Vec::new();
27    for host_messages in results {
28        for msg in host_messages {
29            if seen.insert(msg.message_id.clone()) {
30                out.push(msg);
31            }
32        }
33    }
34    out
35}
36
37/// Intermediate type for server's wrapped message body format.
38/// The server MAY wrap message body as { "message": ..., "payment": ... }
39/// where payment contains delivery fee data for internalization.
40#[derive(serde::Deserialize)]
41pub(crate) struct WrappedMessageBody {
42    pub message: Option<serde_json::Value>,
43    pub payment: Option<ServerPayment>,
44}
45
46#[derive(serde::Deserialize)]
47pub(crate) struct ServerPayment {
48    pub tx: Option<Vec<u8>>,
49    pub outputs: Option<Vec<ServerPaymentOutput>>,
50    pub description: Option<String>,
51}
52
53/// One output entry from the server's delivery-fee payment.
54/// All fields are optional — internalization is best-effort and errors are ignored.
55#[derive(serde::Deserialize)]
56#[serde(rename_all = "camelCase")]
57pub(crate) struct ServerPaymentOutput {
58    pub output_index: Option<u32>,
59    /// Protocol type — TS filters to `"wallet payment"` only.
60    pub protocol: Option<String>,
61    /// Derivation prefix as byte array.
62    pub derivation_prefix: Option<Vec<u8>>,
63    /// Derivation suffix as byte array.
64    pub derivation_suffix: Option<Vec<u8>>,
65    /// Sender identity key as DER hex.
66    pub sender_identity_key: Option<String>,
67}
68
69impl<W: WalletInterface + Clone + 'static + Send + Sync> MessageBoxClient<W> {
70    /// Send a message to a recipient's inbox.
71    ///
72    /// CRITICAL TS PARITY: resolves the recipient's MessageBox host via overlay
73    /// (`resolveHostForRecipient`) before sending — matching TS line 952:
74    /// `const finalHost = overrideHost ?? await this.resolveHostForRecipient(message.recipient)`
75    ///
76    /// When `override_host` is Some, it is used directly without overlay resolution.
77    ///
78    /// 1. Asserts the client is initialized.
79    /// 2. Resolves recipient's host via overlay (falls back to self.host if unreachable).
80    /// 3. Delegates to `send_message_to_host` with the resolved host.
81    #[allow(clippy::too_many_arguments)]
82    pub async fn send_message(
83        &self,
84        recipient: &str,
85        message_box: &str,
86        body: &str,
87        skip_encryption: bool,
88        check_permissions: bool,
89        message_id: Option<&str>,
90        override_host: Option<&str>,
91    ) -> Result<String, MessageBoxError> {
92        self.assert_initialized().await?;
93        let host = match override_host {
94            Some(h) => h.to_string(),
95            None => self.resolve_host_for_recipient(recipient).await?,
96        };
97        self.send_message_to_host(
98            &host,
99            recipient,
100            message_box,
101            body,
102            skip_encryption,
103            check_permissions,
104            message_id,
105            None,
106        )
107        .await
108    }
109
110    /// Send a message to a recipient's inbox at an explicit host.
111    ///
112    /// Lower-level helper used by `send_message` (after host resolution) and by
113    /// `RemittanceAdapter` when `host_override` is provided.
114    ///
115    /// Parameters:
116    /// - `skip_encryption`: when true, sends body as-is without BRC-78 encryption.
117    /// - `check_permissions`: when true, fetches a fee quote and creates a payment if needed.
118    /// - `message_id`: when Some, uses caller-supplied ID instead of HMAC-derived ID.
119    /// - `payment`: pre-created payment (used by batch sends to avoid re-creating the tx).
120    ///
121    /// Returns the HMAC-derived message ID (or server ID if present).
122    #[allow(clippy::too_many_arguments)]
123    pub(crate) async fn send_message_to_host(
124        &self,
125        host: &str,
126        recipient: &str,
127        message_box: &str,
128        body: &str,
129        skip_encryption: bool,
130        check_permissions: bool,
131        message_id: Option<&str>,
132        payment: Option<MessagePayment>,
133    ) -> Result<String, MessageBoxError> {
134        // Encrypt body (or use as-is when skipEncryption is true).
135        let wire_body = if skip_encryption {
136            body.to_string()
137        } else {
138            encryption::encrypt_body(self.wallet(), body, recipient, self.originator()).await?
139        };
140
141        // Resolve or generate message ID.
142        // NOTE: generate_message_id internally calls serde_json::to_string(body) to replicate
143        // TS JSON.stringify(message.body) behavior for exact parity (line 917 in MessageBoxClient.ts)
144        let resolved_message_id = if let Some(id) = message_id {
145            id.to_string()
146        } else {
147            encryption::generate_message_id(self.wallet(), body, recipient, self.originator())
148                .await?
149        };
150
151        // When check_permissions is true and no payment was supplied, obtain a fee quote
152        // and create a message payment if any fees are required.
153        let payment = if check_permissions && payment.is_none() {
154            let quote = self
155                .get_message_box_quote(recipient, message_box, None)
156                .await?;
157            if quote.delivery_fee > 0 || quote.recipient_fee > 0 {
158                let p = self.create_message_payment(recipient, &quote, None).await?;
159                Some(p)
160            } else {
161                None
162            }
163        } else {
164            payment
165        };
166
167        // Build request wire format: {"message": {...}, "payment": ...}
168        let request = SendMessageRequest {
169            message: SendMessageParams {
170                recipient: recipient.to_string(),
171                message_box: message_box.to_string(),
172                body: wire_body,
173                message_id: resolved_message_id.clone(),
174            },
175            payment,
176        };
177
178        let body_bytes = serde_json::to_vec(&request)?;
179        let url = format!("{host}/sendMessage");
180        // Use the raw POST so we can inspect the body even on a non-2xx status.
181        // The relay rejects a duplicate `messageId` with HTTP 400 + a structured
182        // body; `post_json` would early-return `Err(Http(400))` and discard that
183        // body, hiding the duplicate signal from us.
184        let response = self.post_json_raw(&url, body_bytes).await?;
185
186        // IDEMPOTENT-DELIVERY SEMANTICS: a duplicate-message rejection means the
187        // relay ALREADY has this exact `messageId` stored — i.e. the message was
188        // already delivered. Because `generate_message_id` is a deterministic
189        // HMAC over (body, recipient, originator), two concurrent sends of the
190        // same logical message collide on the same id: one insert wins, the
191        // other gets `ERR_DUPLICATE_MESSAGE`. The logical send DID succeed, so we
192        // return Ok with the (already-known) message id instead of an error. This
193        // prevents spurious failures + retries under concurrent presign sends.
194        //
195        // We match the relay's PRECISE duplicate signal (`code ==
196        // "ERR_DUPLICATE_MESSAGE"`), never a blanket "swallow all 400s" — genuine
197        // auth / validation rejections continue to surface as errors below.
198        if is_duplicate_message_rejection(&response.body) {
199            return Ok(resolved_message_id);
200        }
201
202        // Non-2xx that ISN'T a duplicate → genuine HTTP failure. Preserve the
203        // status-code error `post_json` would have produced.
204        if response.status < 200 || response.status >= 300 {
205            return Err(MessageBoxError::Http(response.status, url));
206        }
207
208        // 2xx but possibly a logical `{"status":"error",...}` payload.
209        check_status_error(&response.body)?;
210
211        // PARITY: TS returns server messageId when present, falls back to HMAC ID
212        if let Ok(resp) = serde_json::from_slice::<SendMessageResponse>(&response.body) {
213            if let Some(server_id) = resp.message_id {
214                return Ok(server_id);
215            }
216        }
217        Ok(resolved_message_id)
218    }
219
220    /// Create a message delivery payment for a single recipient.
221    ///
222    /// Called by `send_message_to_host` when `check_permissions` is true and fees are required.
223    ///
224    /// TS PARITY (critical — must match exactly for cross-client interop):
225    /// - Protocol: `[2, "3241645161d8"]` (same as PeerPay, NOT `[1, "messagebox"]`)
226    /// - Nonces: `Random(32)` + base64 encode (NOT wallet create_nonce)
227    /// - Delivery fee senderIdentityKey: current user's identity key (NOT the agent's key)
228    /// - Recipient fee: derived via `ProtoWallet('anyone')`, senderIdentityKey = anyone wallet's key
229    async fn create_message_payment(
230        &self,
231        recipient: &str,
232        quote: &crate::types::MessageBoxQuote,
233        description: Option<&str>,
234    ) -> Result<MessagePayment, MessageBoxError> {
235        use base64::Engine;
236        use bsv::primitives::public_key::PublicKey;
237        use bsv::primitives::utils::from_hex;
238        use bsv::script::templates::{ScriptTemplateLock, P2PKH};
239        use bsv::wallet::interfaces::{
240            CreateActionArgs, CreateActionOptions, CreateActionOutput, GetPublicKeyArgs,
241        };
242        use bsv::wallet::proto_wallet::ProtoWallet;
243        use bsv::wallet::types::{BooleanDefaultTrue, Counterparty, CounterpartyType, Protocol};
244
245        let desc = description.unwrap_or("MessageBox delivery fee");
246        let sender_identity_key = self.get_identity_key().await?;
247
248        let mut output_index: u32 = 0;
249        let mut outputs = Vec::new();
250        let mut payment_outputs = Vec::new();
251
252        // --- Delivery fee output (if > 0) ---
253        if quote.delivery_fee > 0 {
254            // TS: Random(32) + Utils.toBase64() for nonces
255            let prefix_bytes: Vec<u8> = (0..32).map(|_| rand::random::<u8>()).collect();
256            let suffix_bytes: Vec<u8> = (0..32).map(|_| rand::random::<u8>()).collect();
257            let prefix = base64::engine::general_purpose::STANDARD.encode(&prefix_bytes);
258            let suffix = base64::engine::general_purpose::STANDARD.encode(&suffix_bytes);
259
260            let agent_pk = PublicKey::from_string(&quote.delivery_agent_identity_key)
261                .map_err(|e| MessageBoxError::Wallet(format!("agent key: {e}")))?;
262
263            // TS: protocolID [2, '3241645161d8'], counterparty = deliveryAgentIdentityKey
264            let delivery_key = self
265                .wallet()
266                .get_public_key(
267                    GetPublicKeyArgs {
268                        identity_key: false,
269                        protocol_id: Some(Protocol {
270                            security_level: 2,
271                            protocol: "3241645161d8".to_string(),
272                        }),
273                        key_id: Some(format!("{prefix} {suffix}")),
274                        counterparty: Some(Counterparty {
275                            counterparty_type: CounterpartyType::Other,
276                            public_key: Some(agent_pk),
277                        }),
278                        privileged: false,
279                        privileged_reason: None,
280                        for_self: None,
281                        seek_permission: None,
282                    },
283                    self.originator(),
284                )
285                .await
286                .map_err(|e| MessageBoxError::Wallet(e.to_string()))?;
287
288            let hash_vec = delivery_key.public_key.to_hash();
289            let mut hash = [0u8; 20];
290            hash.copy_from_slice(&hash_vec);
291            let lock = P2PKH::from_public_key_hash(hash)
292                .lock()
293                .map_err(|e| MessageBoxError::Wallet(format!("P2PKH lock: {e}")))?;
294            let lock_bytes = from_hex(&lock.to_hex())
295                .map_err(|e| MessageBoxError::Wallet(format!("hex decode: {e}")))?;
296
297            outputs.push(CreateActionOutput {
298                locking_script: Some(lock_bytes),
299                satoshis: quote.delivery_fee as u64,
300                output_description: "MessageBox server delivery fee".to_string(),
301                basket: None,
302                custom_instructions: None,
303                tags: None,
304            });
305
306            // TS: senderIdentityKey = current user's identity key (NOT agent key)
307            payment_outputs.push(MessagePaymentOutput {
308                output_index,
309                derivation_prefix: prefix.as_bytes().to_vec(),
310                derivation_suffix: suffix.as_bytes().to_vec(),
311                sender_identity_key: sender_identity_key.clone(),
312            });
313            output_index += 1;
314        }
315
316        // --- Recipient fee output (if > 0) ---
317        if quote.recipient_fee > 0 {
318            let prefix_bytes: Vec<u8> = (0..32).map(|_| rand::random::<u8>()).collect();
319            let suffix_bytes: Vec<u8> = (0..32).map(|_| rand::random::<u8>()).collect();
320            let prefix = base64::engine::general_purpose::STANDARD.encode(&prefix_bytes);
321            let suffix = base64::engine::general_purpose::STANDARD.encode(&suffix_bytes);
322
323            // TS: uses ProtoWallet('anyone') for the recipient fee key derivation.
324            // In Rust SDK, CounterpartyType::Anyone is the equivalent — it uses PrivateKey(1)
325            // as the "anyone" wallet's root key, matching the TS SDK's CachedKeyDeriver('anyone').
326            let anyone_wallet = ProtoWallet::anyone();
327
328            let recipient_pk = PublicKey::from_string(recipient)
329                .map_err(|e| MessageBoxError::Wallet(format!("recipient key: {e}")))?;
330
331            // TS: protocolID [2, '3241645161d8'], counterparty = recipient, via anyoneWallet
332            let recv_key = anyone_wallet
333                .get_public_key(
334                    GetPublicKeyArgs {
335                        identity_key: false,
336                        protocol_id: Some(Protocol {
337                            security_level: 2,
338                            protocol: "3241645161d8".to_string(),
339                        }),
340                        key_id: Some(format!("{prefix} {suffix}")),
341                        counterparty: Some(Counterparty {
342                            counterparty_type: CounterpartyType::Other,
343                            public_key: Some(recipient_pk),
344                        }),
345                        privileged: false,
346                        privileged_reason: None,
347                        for_self: None,
348                        seek_permission: None,
349                    },
350                    None,
351                )
352                .await
353                .map_err(|e| MessageBoxError::Wallet(e.to_string()))?;
354
355            let hash_vec2 = recv_key.public_key.to_hash();
356            let mut hash2 = [0u8; 20];
357            hash2.copy_from_slice(&hash_vec2);
358            let lock_recv = P2PKH::from_public_key_hash(hash2)
359                .lock()
360                .map_err(|e| MessageBoxError::Wallet(format!("P2PKH lock: {e}")))?;
361            let lock_recv_bytes = from_hex(&lock_recv.to_hex())
362                .map_err(|e| MessageBoxError::Wallet(format!("hex decode: {e}")))?;
363
364            outputs.push(CreateActionOutput {
365                locking_script: Some(lock_recv_bytes),
366                satoshis: quote.recipient_fee as u64,
367                output_description: "Recipient message fee".to_string(),
368                basket: None,
369                custom_instructions: None,
370                tags: None,
371            });
372
373            // TS: senderIdentityKey = anyoneWallet's identity key (PrivateKey(1).toPublicKey())
374            let anyone_id = anyone_wallet
375                .get_public_key(
376                    GetPublicKeyArgs {
377                        identity_key: true,
378                        protocol_id: None,
379                        key_id: None,
380                        counterparty: None,
381                        privileged: false,
382                        privileged_reason: None,
383                        for_self: None,
384                        seek_permission: None,
385                    },
386                    None,
387                )
388                .await
389                .map_err(|e| MessageBoxError::Wallet(e.to_string()))?;
390
391            payment_outputs.push(MessagePaymentOutput {
392                output_index,
393                derivation_prefix: prefix.as_bytes().to_vec(),
394                derivation_suffix: suffix.as_bytes().to_vec(),
395                sender_identity_key: anyone_id.public_key.to_der_hex(),
396            });
397        }
398
399        let create_result = self
400            .wallet()
401            .create_action(
402                CreateActionArgs {
403                    description: desc.to_string(),
404                    input_beef: None,
405                    inputs: None,
406                    outputs: Some(outputs),
407                    lock_time: None,
408                    version: None,
409                    labels: Some(vec!["messagebox".to_string()]),
410                    options: Some(CreateActionOptions {
411                        randomize_outputs: BooleanDefaultTrue(Some(false)),
412                        ..Default::default()
413                    }),
414                    reference: None,
415                },
416                self.originator(),
417            )
418            .await
419            .map_err(|e| MessageBoxError::Wallet(e.to_string()))?;
420
421        let tx = create_result
422            .tx
423            .ok_or_else(|| MessageBoxError::Wallet("create_action returned no tx".to_string()))?;
424
425        Ok(MessagePayment {
426            tx,
427            outputs: payment_outputs,
428        })
429    }
430
431    /// Send a message to a list of recipients in a single batch operation.
432    ///
433    /// Matches the TS `sendMesagetoRecepients` behavior (note the TS typo — Rust uses corrected name):
434    /// 1. Gets multi-recipient quote; blocked recipients are separated out.
435    /// 2. Creates a single batch payment transaction covering all payable recipients.
436    /// 3. Loops individual `send_message_to_host` calls sharing the batch payment.
437    pub async fn send_message_to_recipients(
438        &self,
439        params: &SendListParams,
440        override_host: Option<&str>,
441    ) -> Result<SendListResult, MessageBoxError> {
442        self.assert_initialized().await?;
443
444        let skip_enc = params.skip_encryption.unwrap_or(false);
445
446        let recipient_refs: Vec<&str> = params.recipients.iter().map(|s| s.as_str()).collect();
447        let multi_quote = self
448            .get_message_box_quote_multi(&recipient_refs, &params.message_box, override_host)
449            .await?;
450
451        // Separate blocked from sendable based on recipient_fee == -1 (blocked status).
452        let blocked: Vec<String> = multi_quote.blocked_recipients.clone();
453        let sendable: Vec<&crate::types::RecipientQuote> = multi_quote
454            .quotes_by_recipient
455            .iter()
456            .filter(|rq| rq.status != "blocked")
457            .collect();
458
459        // Resolve host per-recipient (or use override).
460        let mut recipient_hosts: HashMap<String, String> = HashMap::new();
461        for rq in &sendable {
462            let host = if let Some(h) = override_host {
463                h.to_string()
464            } else {
465                self.resolve_host_for_recipient(&rq.recipient)
466                    .await
467                    .unwrap_or_else(|_| self.host().to_string())
468            };
469            recipient_hosts.insert(rq.recipient.clone(), host);
470        }
471
472        // Create a single batch payment if any fees exist.
473        let needs_payment = sendable
474            .iter()
475            .any(|rq| rq.delivery_fee > 0 || rq.recipient_fee > 0);
476
477        let batch_payment = if needs_payment {
478            // Build the (recipient, host) tuples for batch payment creation.
479            let pairs_for_payment: Vec<(String, i64, i64, String)> = sendable
480                .iter()
481                .map(|rq| {
482                    let host = recipient_hosts
483                        .get(&rq.recipient)
484                        .cloned()
485                        .unwrap_or_else(|| self.host().to_string());
486                    let agent_key = multi_quote
487                        .delivery_agent_identity_key_by_host
488                        .get(&host)
489                        .cloned()
490                        .unwrap_or_default();
491                    (
492                        rq.recipient.clone(),
493                        rq.delivery_fee,
494                        rq.recipient_fee,
495                        agent_key,
496                    )
497                })
498                .collect();
499
500            match self
501                .create_message_payment_batch_from_tuples(&pairs_for_payment, None)
502                .await
503            {
504                Ok(p) => Some(p),
505                Err(e) => {
506                    // If batch payment creation fails, all sendable recipients fail.
507                    let failed_entries: Vec<FailedRecipient> = sendable
508                        .iter()
509                        .map(|rq| FailedRecipient {
510                            recipient: rq.recipient.clone(),
511                            error: e.to_string(),
512                        })
513                        .collect();
514                    return Ok(SendListResult {
515                        status: "error".to_string(),
516                        description: "Batch payment creation failed".to_string(),
517                        sent: vec![],
518                        blocked,
519                        failed: failed_entries,
520                        totals: None,
521                    });
522                }
523            }
524        } else {
525            None
526        };
527
528        // Send to each recipient individually.
529        let mut sent: Vec<SentRecipient> = Vec::new();
530        let mut failed: Vec<FailedRecipient> = Vec::new();
531
532        for rq in &sendable {
533            let host = recipient_hosts
534                .get(&rq.recipient)
535                .cloned()
536                .unwrap_or_else(|| self.host().to_string());
537
538            match self
539                .send_message_to_host(
540                    &host,
541                    &rq.recipient,
542                    &params.message_box,
543                    &params.body,
544                    skip_enc,
545                    false, // payment already prepared
546                    None,
547                    batch_payment.clone(),
548                )
549                .await
550            {
551                Ok(msg_id) => sent.push(SentRecipient {
552                    recipient: rq.recipient.clone(),
553                    message_id: msg_id,
554                }),
555                Err(e) => failed.push(FailedRecipient {
556                    recipient: rq.recipient.clone(),
557                    error: e.to_string(),
558                }),
559            }
560        }
561
562        Ok(SendListResult {
563            status: "success".to_string(),
564            description: format!("Sent to {} recipients", sent.len()),
565            sent,
566            blocked,
567            failed,
568            totals: multi_quote.totals,
569        })
570    }
571
572    /// Internal helper: create a batch payment from pre-resolved (recipient, delivery_fee, recipient_fee, agent_key) tuples.
573    ///
574    /// TS PARITY (must match `createMessagePaymentBatch` exactly):
575    /// - Protocol: `[2, "3241645161d8"]` for ALL key derivations
576    /// - Nonces: `Random(32)` + base64 encode
577    /// - Delivery fee senderIdentityKey: current user's identity key
578    /// - Recipient fee: derived via `ProtoWallet::anyone()`, senderIdentityKey = anyone wallet's key
579    async fn create_message_payment_batch_from_tuples(
580        &self,
581        tuples: &[(String, i64, i64, String)],
582        description: Option<&str>,
583    ) -> Result<MessagePayment, MessageBoxError> {
584        use base64::Engine;
585        use bsv::primitives::public_key::PublicKey;
586        use bsv::primitives::utils::from_hex;
587        use bsv::script::templates::{ScriptTemplateLock, P2PKH};
588        use bsv::wallet::interfaces::{
589            CreateActionArgs, CreateActionOptions, CreateActionOutput, GetPublicKeyArgs,
590        };
591        use bsv::wallet::proto_wallet::ProtoWallet;
592        use bsv::wallet::types::{BooleanDefaultTrue, Counterparty, CounterpartyType, Protocol};
593
594        let desc = description.unwrap_or("MessageBox batch delivery fee");
595        let sender_identity_key = self.get_identity_key().await?;
596        let anyone_wallet = ProtoWallet::anyone();
597        let anyone_id = anyone_wallet
598            .get_public_key(
599                GetPublicKeyArgs {
600                    identity_key: true,
601                    protocol_id: None,
602                    key_id: None,
603                    counterparty: None,
604                    privileged: false,
605                    privileged_reason: None,
606                    for_self: None,
607                    seek_permission: None,
608                },
609                None,
610            )
611            .await
612            .map_err(|e| MessageBoxError::Wallet(e.to_string()))?;
613        let anyone_id_hex = anyone_id.public_key.to_der_hex();
614
615        let mut outputs: Vec<CreateActionOutput> = Vec::new();
616        let mut payment_outputs: Vec<MessagePaymentOutput> = Vec::new();
617
618        for (recipient, delivery_fee, recipient_fee, agent_key) in tuples {
619            // --- Delivery fee output ---
620            if *delivery_fee > 0 && !agent_key.is_empty() {
621                let prefix_bytes: Vec<u8> = (0..32).map(|_| rand::random::<u8>()).collect();
622                let suffix_bytes: Vec<u8> = (0..32).map(|_| rand::random::<u8>()).collect();
623                let prefix = base64::engine::general_purpose::STANDARD.encode(&prefix_bytes);
624                let suffix = base64::engine::general_purpose::STANDARD.encode(&suffix_bytes);
625
626                let agent_pk = PublicKey::from_string(agent_key)
627                    .map_err(|e| MessageBoxError::Wallet(format!("agent key: {e}")))?;
628
629                let key = self
630                    .wallet()
631                    .get_public_key(
632                        GetPublicKeyArgs {
633                            identity_key: false,
634                            protocol_id: Some(Protocol {
635                                security_level: 2,
636                                protocol: "3241645161d8".to_string(),
637                            }),
638                            key_id: Some(format!("{prefix} {suffix}")),
639                            counterparty: Some(Counterparty {
640                                counterparty_type: CounterpartyType::Other,
641                                public_key: Some(agent_pk),
642                            }),
643                            privileged: false,
644                            privileged_reason: None,
645                            for_self: None,
646                            seek_permission: None,
647                        },
648                        self.originator(),
649                    )
650                    .await
651                    .map_err(|e| MessageBoxError::Wallet(e.to_string()))?;
652
653                let hash_vec = key.public_key.to_hash();
654                let mut hash = [0u8; 20];
655                hash.copy_from_slice(&hash_vec);
656                let lock = P2PKH::from_public_key_hash(hash)
657                    .lock()
658                    .map_err(|e| MessageBoxError::Wallet(format!("P2PKH lock: {e}")))?;
659                let lock_bytes = from_hex(&lock.to_hex())
660                    .map_err(|e| MessageBoxError::Wallet(format!("hex decode: {e}")))?;
661
662                let output_index = outputs.len() as u32;
663                outputs.push(CreateActionOutput {
664                    locking_script: Some(lock_bytes),
665                    satoshis: *delivery_fee as u64,
666                    output_description: format!("Delivery fee for {}", recipient),
667                    basket: None,
668                    custom_instructions: None,
669                    tags: None,
670                });
671                payment_outputs.push(MessagePaymentOutput {
672                    output_index,
673                    derivation_prefix: prefix.as_bytes().to_vec(),
674                    derivation_suffix: suffix.as_bytes().to_vec(),
675                    sender_identity_key: sender_identity_key.clone(),
676                });
677            }
678
679            // --- Recipient fee output ---
680            if *recipient_fee > 0 {
681                let prefix_bytes: Vec<u8> = (0..32).map(|_| rand::random::<u8>()).collect();
682                let suffix_bytes: Vec<u8> = (0..32).map(|_| rand::random::<u8>()).collect();
683                let prefix = base64::engine::general_purpose::STANDARD.encode(&prefix_bytes);
684                let suffix = base64::engine::general_purpose::STANDARD.encode(&suffix_bytes);
685
686                let recipient_pk = PublicKey::from_string(recipient)
687                    .map_err(|e| MessageBoxError::Wallet(format!("recipient key: {e}")))?;
688
689                // TS: uses anyoneWallet for recipient fee key derivation
690                let key = anyone_wallet
691                    .get_public_key(
692                        GetPublicKeyArgs {
693                            identity_key: false,
694                            protocol_id: Some(Protocol {
695                                security_level: 2,
696                                protocol: "3241645161d8".to_string(),
697                            }),
698                            key_id: Some(format!("{prefix} {suffix}")),
699                            counterparty: Some(Counterparty {
700                                counterparty_type: CounterpartyType::Other,
701                                public_key: Some(recipient_pk),
702                            }),
703                            privileged: false,
704                            privileged_reason: None,
705                            for_self: None,
706                            seek_permission: None,
707                        },
708                        None,
709                    )
710                    .await
711                    .map_err(|e| MessageBoxError::Wallet(e.to_string()))?;
712
713                let hash_vec = key.public_key.to_hash();
714                let mut hash = [0u8; 20];
715                hash.copy_from_slice(&hash_vec);
716                let lock = P2PKH::from_public_key_hash(hash)
717                    .lock()
718                    .map_err(|e| MessageBoxError::Wallet(format!("P2PKH lock: {e}")))?;
719                let lock_bytes = from_hex(&lock.to_hex())
720                    .map_err(|e| MessageBoxError::Wallet(format!("hex decode: {e}")))?;
721
722                let output_index = outputs.len() as u32;
723                outputs.push(CreateActionOutput {
724                    locking_script: Some(lock_bytes),
725                    satoshis: *recipient_fee as u64,
726                    output_description: format!("Recipient fee for {}", recipient),
727                    basket: None,
728                    custom_instructions: None,
729                    tags: None,
730                });
731                payment_outputs.push(MessagePaymentOutput {
732                    output_index,
733                    derivation_prefix: prefix.as_bytes().to_vec(),
734                    derivation_suffix: suffix.as_bytes().to_vec(),
735                    sender_identity_key: anyone_id_hex.clone(),
736                });
737            }
738        }
739
740        if outputs.is_empty() {
741            return Ok(MessagePayment {
742                tx: vec![],
743                outputs: vec![],
744            });
745        }
746
747        let create_result = self
748            .wallet()
749            .create_action(
750                CreateActionArgs {
751                    description: desc.to_string(),
752                    input_beef: None,
753                    inputs: None,
754                    outputs: Some(outputs),
755                    lock_time: None,
756                    version: None,
757                    labels: Some(vec!["messagebox".to_string()]),
758                    options: Some(CreateActionOptions {
759                        randomize_outputs: BooleanDefaultTrue(Some(false)),
760                        ..Default::default()
761                    }),
762                    reference: None,
763                },
764                self.originator(),
765            )
766            .await
767            .map_err(|e| MessageBoxError::Wallet(e.to_string()))?;
768
769        let tx = create_result
770            .tx
771            .ok_or_else(|| MessageBoxError::Wallet("create_action returned no tx".to_string()))?;
772
773        Ok(MessagePayment {
774            tx,
775            outputs: payment_outputs,
776        })
777    }
778
779    /// Retrieve messages from an inbox without payment internalization.
780    ///
781    /// Calls `/listMessages` and auto-decrypts each message body.
782    ///
783    /// PARITY: passes `originator: None` to `try_decrypt_message`, matching
784    /// the TS `listMessagesLite` which omits originator (Pitfall 4).
785    pub async fn list_messages_lite(
786        &self,
787        message_box: &str,
788        override_host: Option<&str>,
789    ) -> Result<Vec<ServerPeerMessage>, MessageBoxError> {
790        self.assert_initialized().await?;
791
792        let host = override_host.unwrap_or_else(|| self.host());
793        let params = ListMessagesParams {
794            message_box: message_box.to_string(),
795        };
796        let body_bytes = serde_json::to_vec(&params)?;
797        let url = format!("{host}/listMessages");
798        let response = self.post_json(&url, body_bytes).await?;
799        check_status_error(&response.body)?;
800
801        let mut list_response: ListMessagesResponse = serde_json::from_slice(&response.body)?;
802
803        // Decrypt each message body in-place.
804        // PARITY: originator is None here — matches TS listMessagesLite which omits originator
805        for msg in &mut list_response.messages {
806            // Typed decrypt: surface whether the body genuinely AEAD-decrypted so
807            // provenance-requiring consumers (the MPC transport) can fail-closed.
808            // String result is identical to the legacy try_decrypt_message.
809            let outcome = encryption::try_decrypt_message_typed(
810                self.wallet(),
811                &msg.body,
812                &msg.sender,
813                None, // PARITY: matches TS listMessagesLite which omits originator
814            )
815            .await;
816            msg.authenticated_decrypt = outcome.is_authenticated();
817            msg.body = outcome.into_body();
818        }
819
820        Ok(list_response.messages)
821    }
822
823    /// Retrieve messages from an inbox with optional server payment internalization.
824    ///
825    /// Unlike `list_messages_lite`, this method:
826    /// - Returns `Vec<PeerMessage>` (not `Vec<ServerPeerMessage>`) with `recipient`
827    ///   populated from `get_identity_key()` and `message_box` from the parameter.
828    /// - Parses the server's `{ message, payment }` wrapper body format.
829    /// - When `accept_payments` is true, internalizes the server delivery-fee payment
830    ///   via `wallet.internalize_action`. Errors are logged/ignored (TS parity).
831    ///
832    /// Multi-host: queries all hosts advertised by this identity concurrently and
833    /// deduplicates results by `message_id`. Matches TS `Promise.allSettled` semantics:
834    /// if at least one host succeeds, partial results are returned.
835    ///
836    /// NOTE: This handles the server delivery-fee payment wrapper, NOT PeerPay
837    /// PaymentTokens (peer-to-peer). PeerPay tokens are handled by `list_incoming_payments`.
838    pub async fn list_messages(
839        &self,
840        message_box: &str,
841        accept_payments: bool,
842        override_host: Option<&str>,
843    ) -> Result<Vec<PeerMessage>, MessageBoxError> {
844        self.assert_initialized().await?;
845
846        // When override_host is provided, skip multi-host overlay and use that single host.
847        if let Some(host) = override_host {
848            return self
849                .list_messages_from_host(host, message_box, accept_payments)
850                .await;
851        }
852
853        // Discover all known hosts for this identity.
854        let identity_key = self.get_identity_key().await?;
855        let ads = self
856            .query_advertisements(Some(&identity_key), None)
857            .await
858            .unwrap_or_default();
859
860        // Build the set of unique host URLs: ads + self.host (always included).
861        let mut host_set: HashSet<String> = ads.into_iter().map(|ad| ad.host).collect();
862        host_set.insert(self.host().to_string());
863
864        if host_set.len() == 1 {
865            // Single-host path — no need for dedup.
866            return self
867                .list_messages_from_host(self.host(), message_box, accept_payments)
868                .await;
869        }
870
871        // Multi-host path: query all concurrently (TS Promise.allSettled semantics).
872        let futures: Vec<_> = host_set
873            .iter()
874            .map(|h| self.list_messages_from_host(h, message_box, accept_payments))
875            .collect();
876
877        let outcomes = join_all(futures).await;
878        let successful: Vec<Vec<PeerMessage>> =
879            outcomes.into_iter().filter_map(|r| r.ok()).collect();
880
881        if successful.is_empty() {
882            return Err(MessageBoxError::Http(
883                0,
884                format!("list_messages: all {} hosts failed", host_set.len()),
885            ));
886        }
887
888        Ok(dedup_messages(successful))
889    }
890
891    /// Retrieve messages from a single explicit host.
892    ///
893    /// Core implementation extracted so `list_messages` can call it per-host
894    /// for multi-host deduplication without repeating internalization logic.
895    async fn list_messages_from_host(
896        &self,
897        host: &str,
898        message_box: &str,
899        accept_payments: bool,
900    ) -> Result<Vec<PeerMessage>, MessageBoxError> {
901        // Cache identity key once — used as recipient in every PeerMessage.
902        let identity_key = self.get_identity_key().await?;
903
904        let params = ListMessagesParams {
905            message_box: message_box.to_string(),
906        };
907        let body_bytes = serde_json::to_vec(&params)?;
908        let url = format!("{host}/listMessages");
909        let response = self.post_json(&url, body_bytes).await?;
910        check_status_error(&response.body)?;
911
912        let list_response: ListMessagesResponse = serde_json::from_slice(&response.body)?;
913
914        let mut result = Vec::with_capacity(list_response.messages.len());
915        for msg in list_response.messages {
916            // Try to parse the body as a server-wrapped { message, payment } envelope.
917            let plain_body: String =
918                if let Ok(wrapped) = serde_json::from_str::<WrappedMessageBody>(&msg.body) {
919                    // Attempt to internalize the server delivery-fee payment when accept_payments=true.
920                    if accept_payments {
921                        if let Some(payment) = &wrapped.payment {
922                            if let Some(tx_bytes) = &payment.tx {
923                                let description = payment
924                                    .description
925                                    .clone()
926                                    .unwrap_or_else(|| "Server delivery fee".to_string());
927
928                                // Build output list from server payment data.
929                                // Errors are intentionally ignored — matches TS try/catch behavior.
930                                let outputs: Vec<InternalizeOutput> = payment
931                                    .outputs
932                                    .as_deref()
933                                    .unwrap_or(&[])
934                                    .iter()
935                                    .filter_map(|o| {
936                                        // TS: only internalizes outputs where protocol === 'wallet payment'
937                                        if o.protocol.as_deref() != Some("wallet payment")
938                                            && o.protocol.is_some()
939                                        {
940                                            return None;
941                                        }
942                                        // Try to parse sender key — skip output if invalid.
943                                        let sender_pk = o
944                                            .sender_identity_key
945                                            .as_deref()
946                                            .and_then(|k| PublicKey::from_string(k).ok())?;
947                                        Some(InternalizeOutput::WalletPayment {
948                                            output_index: o.output_index.unwrap_or(0),
949                                            payment: Payment {
950                                                derivation_prefix: o
951                                                    .derivation_prefix
952                                                    .clone()
953                                                    .unwrap_or_default(),
954                                                derivation_suffix: o
955                                                    .derivation_suffix
956                                                    .clone()
957                                                    .unwrap_or_default(),
958                                                sender_identity_key: sender_pk,
959                                            },
960                                        })
961                                    })
962                                    .collect();
963
964                                let args = InternalizeActionArgs {
965                                    tx: tx_bytes.clone(),
966                                    description,
967                                    labels: Some(vec!["server-delivery-fee".to_string()]),
968                                    seek_permission: BooleanDefaultTrue(Some(false)),
969                                    outputs: outputs,
970                                };
971                                // Defensive: ignore internalization errors, continue processing.
972                                let _ = self
973                                    .wallet()
974                                    .internalize_action(args, self.originator())
975                                    .await;
976                            }
977                        }
978                    }
979
980                    // Extract the message sub-field from the wrapper regardless of accept_payments.
981                    match wrapped.message {
982                        Some(serde_json::Value::String(s)) => s,
983                        Some(v) => v.to_string(),
984                        None => msg.body.clone(),
985                    }
986                } else {
987                    // Not a wrapped body — pass through as plain text.
988                    msg.body.clone()
989                };
990
991            // Decrypt the extracted body.
992            let decrypted = encryption::try_decrypt_message(
993                self.wallet(),
994                &plain_body,
995                &msg.sender,
996                self.originator(),
997            )
998            .await;
999
1000            result.push(PeerMessage {
1001                message_id: msg.message_id,
1002                sender: msg.sender,
1003                recipient: identity_key.clone(),
1004                message_box: message_box.to_string(),
1005                body: decrypted,
1006            });
1007        }
1008
1009        Ok(result)
1010    }
1011
1012    /// Mark messages as acknowledged (read) by their IDs.
1013    ///
1014    /// TS PARITY: When `override_host` is None, fans out to ALL advertised hosts in parallel
1015    /// (same `join_all` pattern as `list_messages`). Returns Ok if ANY host succeeds.
1016    /// When `override_host` is Some, acks on that single host only.
1017    pub async fn acknowledge_message(
1018        &self,
1019        message_ids: Vec<String>,
1020        override_host: Option<&str>,
1021    ) -> Result<(), MessageBoxError> {
1022        self.assert_initialized().await?;
1023
1024        if let Some(host) = override_host {
1025            return self.acknowledge_message_on_host(host, &message_ids).await;
1026        }
1027
1028        // Multi-host fan-out: ack on all known hosts concurrently.
1029        let identity_key = self.get_identity_key().await?;
1030        let ads = self
1031            .query_advertisements(Some(&identity_key), None)
1032            .await
1033            .unwrap_or_default();
1034
1035        let mut host_set: HashSet<String> = ads.into_iter().map(|ad| ad.host).collect();
1036        host_set.insert(self.host().to_string());
1037
1038        if host_set.len() == 1 {
1039            return self
1040                .acknowledge_message_on_host(self.host(), &message_ids)
1041                .await;
1042        }
1043
1044        // Fan out in parallel — return Ok if at least one succeeds.
1045        let futures: Vec<_> = host_set
1046            .iter()
1047            .map(|h| self.acknowledge_message_on_host(h, &message_ids))
1048            .collect();
1049
1050        let outcomes = join_all(futures).await;
1051        let any_ok = outcomes.iter().any(|r| r.is_ok());
1052
1053        if any_ok {
1054            Ok(())
1055        } else {
1056            Err(MessageBoxError::Http(
1057                0,
1058                format!("acknowledge_message: all {} hosts failed", host_set.len()),
1059            ))
1060        }
1061    }
1062
1063    /// Acknowledge messages on a single explicit host.
1064    async fn acknowledge_message_on_host(
1065        &self,
1066        host: &str,
1067        message_ids: &[String],
1068    ) -> Result<(), MessageBoxError> {
1069        let params = AcknowledgeMessageParams {
1070            message_ids: message_ids.to_vec(),
1071        };
1072        let body_bytes = serde_json::to_vec(&params)?;
1073        let url = format!("{host}/acknowledgeMessage");
1074        let response = self.post_json(&url, body_bytes).await?;
1075        check_status_error(&response.body)?;
1076        Ok(())
1077    }
1078}
1079
1080// ---------------------------------------------------------------------------
1081// Tests
1082// ---------------------------------------------------------------------------
1083
1084#[cfg(test)]
1085mod tests {
1086    use crate::encryption::generate_message_id;
1087    use crate::types::{
1088        AcknowledgeMessageParams, ListMessagesResponse, SendMessageParams, SendMessageRequest,
1089    };
1090    use bsv::primitives::private_key::PrivateKey;
1091    use bsv::wallet::error::WalletError;
1092    use bsv::wallet::interfaces::*;
1093    use bsv::wallet::proto_wallet::ProtoWallet;
1094    use std::sync::Arc;
1095
1096    // Reuse the same ArcWallet helper as client::tests
1097    #[derive(Clone)]
1098    struct ArcWallet(Arc<ProtoWallet>);
1099
1100    impl ArcWallet {
1101        fn new() -> Self {
1102            let key = PrivateKey::from_random().expect("random key");
1103            ArcWallet(Arc::new(ProtoWallet::new(key)))
1104        }
1105    }
1106
1107    #[async_trait::async_trait]
1108    impl WalletInterface for ArcWallet {
1109        async fn create_action(
1110            &self,
1111            args: CreateActionArgs,
1112            orig: Option<&str>,
1113        ) -> Result<CreateActionResult, WalletError> {
1114            self.0.create_action(args, orig).await
1115        }
1116        async fn sign_action(
1117            &self,
1118            args: SignActionArgs,
1119            orig: Option<&str>,
1120        ) -> Result<SignActionResult, WalletError> {
1121            self.0.sign_action(args, orig).await
1122        }
1123        async fn abort_action(
1124            &self,
1125            args: AbortActionArgs,
1126            orig: Option<&str>,
1127        ) -> Result<AbortActionResult, WalletError> {
1128            self.0.abort_action(args, orig).await
1129        }
1130        async fn list_actions(
1131            &self,
1132            args: ListActionsArgs,
1133            orig: Option<&str>,
1134        ) -> Result<ListActionsResult, WalletError> {
1135            self.0.list_actions(args, orig).await
1136        }
1137        async fn internalize_action(
1138            &self,
1139            args: InternalizeActionArgs,
1140            orig: Option<&str>,
1141        ) -> Result<InternalizeActionResult, WalletError> {
1142            self.0.internalize_action(args, orig).await
1143        }
1144        async fn list_outputs(
1145            &self,
1146            args: ListOutputsArgs,
1147            orig: Option<&str>,
1148        ) -> Result<ListOutputsResult, WalletError> {
1149            self.0.list_outputs(args, orig).await
1150        }
1151        async fn relinquish_output(
1152            &self,
1153            args: RelinquishOutputArgs,
1154            orig: Option<&str>,
1155        ) -> Result<RelinquishOutputResult, WalletError> {
1156            self.0.relinquish_output(args, orig).await
1157        }
1158        async fn get_public_key(
1159            &self,
1160            args: GetPublicKeyArgs,
1161            orig: Option<&str>,
1162        ) -> Result<GetPublicKeyResult, WalletError> {
1163            self.0.get_public_key(args, orig).await
1164        }
1165        async fn reveal_counterparty_key_linkage(
1166            &self,
1167            args: RevealCounterpartyKeyLinkageArgs,
1168            orig: Option<&str>,
1169        ) -> Result<RevealCounterpartyKeyLinkageResult, WalletError> {
1170            self.0.reveal_counterparty_key_linkage(args, orig).await
1171        }
1172        async fn reveal_specific_key_linkage(
1173            &self,
1174            args: RevealSpecificKeyLinkageArgs,
1175            orig: Option<&str>,
1176        ) -> Result<RevealSpecificKeyLinkageResult, WalletError> {
1177            self.0.reveal_specific_key_linkage(args, orig).await
1178        }
1179        async fn encrypt(
1180            &self,
1181            args: EncryptArgs,
1182            orig: Option<&str>,
1183        ) -> Result<EncryptResult, WalletError> {
1184            self.0.encrypt(args, orig).await
1185        }
1186        async fn decrypt(
1187            &self,
1188            args: DecryptArgs,
1189            orig: Option<&str>,
1190        ) -> Result<DecryptResult, WalletError> {
1191            self.0.decrypt(args, orig).await
1192        }
1193        async fn create_hmac(
1194            &self,
1195            args: CreateHmacArgs,
1196            orig: Option<&str>,
1197        ) -> Result<CreateHmacResult, WalletError> {
1198            self.0.create_hmac(args, orig).await
1199        }
1200        async fn verify_hmac(
1201            &self,
1202            args: VerifyHmacArgs,
1203            orig: Option<&str>,
1204        ) -> Result<VerifyHmacResult, WalletError> {
1205            self.0.verify_hmac(args, orig).await
1206        }
1207        async fn create_signature(
1208            &self,
1209            args: CreateSignatureArgs,
1210            orig: Option<&str>,
1211        ) -> Result<CreateSignatureResult, WalletError> {
1212            self.0.create_signature(args, orig).await
1213        }
1214        async fn verify_signature(
1215            &self,
1216            args: VerifySignatureArgs,
1217            orig: Option<&str>,
1218        ) -> Result<VerifySignatureResult, WalletError> {
1219            self.0.verify_signature(args, orig).await
1220        }
1221        async fn acquire_certificate(
1222            &self,
1223            args: AcquireCertificateArgs,
1224            orig: Option<&str>,
1225        ) -> Result<Certificate, WalletError> {
1226            self.0.acquire_certificate(args, orig).await
1227        }
1228        async fn list_certificates(
1229            &self,
1230            args: ListCertificatesArgs,
1231            orig: Option<&str>,
1232        ) -> Result<ListCertificatesResult, WalletError> {
1233            self.0.list_certificates(args, orig).await
1234        }
1235        async fn prove_certificate(
1236            &self,
1237            args: ProveCertificateArgs,
1238            orig: Option<&str>,
1239        ) -> Result<ProveCertificateResult, WalletError> {
1240            self.0.prove_certificate(args, orig).await
1241        }
1242        async fn relinquish_certificate(
1243            &self,
1244            args: RelinquishCertificateArgs,
1245            orig: Option<&str>,
1246        ) -> Result<RelinquishCertificateResult, WalletError> {
1247            self.0.relinquish_certificate(args, orig).await
1248        }
1249        async fn discover_by_identity_key(
1250            &self,
1251            args: DiscoverByIdentityKeyArgs,
1252            orig: Option<&str>,
1253        ) -> Result<DiscoverCertificatesResult, WalletError> {
1254            self.0.discover_by_identity_key(args, orig).await
1255        }
1256        async fn discover_by_attributes(
1257            &self,
1258            args: DiscoverByAttributesArgs,
1259            orig: Option<&str>,
1260        ) -> Result<DiscoverCertificatesResult, WalletError> {
1261            self.0.discover_by_attributes(args, orig).await
1262        }
1263        async fn is_authenticated(
1264            &self,
1265            orig: Option<&str>,
1266        ) -> Result<AuthenticatedResult, WalletError> {
1267            self.0.is_authenticated(orig).await
1268        }
1269        async fn wait_for_authentication(
1270            &self,
1271            orig: Option<&str>,
1272        ) -> Result<AuthenticatedResult, WalletError> {
1273            self.0.wait_for_authentication(orig).await
1274        }
1275        async fn get_height(&self, orig: Option<&str>) -> Result<GetHeightResult, WalletError> {
1276            self.0.get_height(orig).await
1277        }
1278        async fn get_header_for_height(
1279            &self,
1280            args: GetHeaderArgs,
1281            orig: Option<&str>,
1282        ) -> Result<GetHeaderResult, WalletError> {
1283            self.0.get_header_for_height(args, orig).await
1284        }
1285        async fn get_network(&self, orig: Option<&str>) -> Result<GetNetworkResult, WalletError> {
1286            self.0.get_network(orig).await
1287        }
1288        async fn get_version(&self, orig: Option<&str>) -> Result<GetVersionResult, WalletError> {
1289            self.0.get_version(orig).await
1290        }
1291    }
1292
1293    // -----------------------------------------------------------------------
1294    // Wire format tests (no HTTP needed)
1295    // -----------------------------------------------------------------------
1296
1297    /// Verify the sendMessage wire format serializes correctly.
1298    #[test]
1299    fn test_send_message_request_format() {
1300        let req = SendMessageRequest {
1301            message: SendMessageParams {
1302                recipient: "03abc123".to_string(),
1303                message_box: "payment_inbox".to_string(),
1304                body: r#"{"encryptedMessage":"abc=="}"#.to_string(),
1305                message_id: "deadbeef01234567".to_string(),
1306            },
1307            payment: None,
1308        };
1309        let json = serde_json::to_string(&req).unwrap();
1310        // Must be wrapped as {"message": {...}}
1311        assert!(
1312            json.starts_with(r#"{"message":"#),
1313            "must have message wrapper"
1314        );
1315        assert!(json.contains("\"recipient\""), "camelCase recipient");
1316        assert!(json.contains("\"messageBox\""), "camelCase messageBox");
1317        assert!(json.contains("\"messageId\""), "camelCase messageId");
1318        assert!(
1319            json.contains("\"payment_inbox\""),
1320            "messageBox value preserved"
1321        );
1322        assert!(!json.contains("message_box"), "no snake_case leakage");
1323        assert!(!json.contains("message_id"), "no snake_case leakage");
1324    }
1325
1326    /// Verify acknowledge request wire format.
1327    #[test]
1328    fn test_acknowledge_request_format() {
1329        let params = AcknowledgeMessageParams {
1330            message_ids: vec!["id1".to_string(), "id2".to_string()],
1331        };
1332        let json = serde_json::to_string(&params).unwrap();
1333        assert_eq!(json, r#"{"messageIds":["id1","id2"]}"#);
1334    }
1335
1336    /// Verify listMessages response can be parsed from a sample JSON payload.
1337    #[test]
1338    fn test_list_messages_response_parsing() {
1339        let raw = r#"{
1340            "status": "success",
1341            "messages": [
1342                {
1343                    "messageId": "abc123",
1344                    "body": "hello world",
1345                    "sender": "03xyz",
1346                    "created_at": "2024-01-01T00:00:00Z",
1347                    "updated_at": "2024-01-01T00:01:00Z"
1348                }
1349            ]
1350        }"#;
1351        let resp: ListMessagesResponse = serde_json::from_str(raw).unwrap();
1352        assert_eq!(resp.status, "success");
1353        assert_eq!(resp.messages.len(), 1);
1354        assert_eq!(resp.messages[0].message_id, "abc123");
1355        assert_eq!(resp.messages[0].body, "hello world");
1356        assert_eq!(resp.messages[0].sender, "03xyz");
1357    }
1358
1359    // -----------------------------------------------------------------------
1360    // HMAC message ID tests
1361    // -----------------------------------------------------------------------
1362
1363    /// HMAC message ID must be exactly 64 lowercase hex characters.
1364    #[tokio::test]
1365    async fn test_message_id_is_64_hex_chars() {
1366        let wallet = ArcWallet::new();
1367        // Use a placeholder recipient pubkey — need a valid compressed pubkey
1368        let other = ArcWallet::new();
1369        let other_pk = other
1370            .get_public_key(
1371                GetPublicKeyArgs {
1372                    identity_key: true,
1373                    protocol_id: None,
1374                    key_id: None,
1375                    counterparty: None,
1376                    privileged: false,
1377                    privileged_reason: None,
1378                    for_self: None,
1379                    seek_permission: None,
1380                },
1381                None,
1382            )
1383            .await
1384            .expect("get_public_key")
1385            .public_key
1386            .to_der_hex();
1387
1388        let id = generate_message_id(&wallet, "test body", &other_pk, None)
1389            .await
1390            .expect("generate_message_id");
1391
1392        assert_eq!(id.len(), 64, "HMAC hex must be 64 chars (32 bytes)");
1393        assert!(
1394            id.chars().all(|c| c.is_ascii_hexdigit()),
1395            "all characters must be hex"
1396        );
1397        assert!(
1398            id.chars().all(|c| !c.is_uppercase()),
1399            "hex must be lowercase"
1400        );
1401    }
1402
1403    // -----------------------------------------------------------------------
1404    // list_messages body parsing tests (no HTTP needed)
1405    // -----------------------------------------------------------------------
1406
1407    /// Verify wrapped {message, payment} body is unwrapped to the message sub-field.
1408    #[test]
1409    fn list_messages_parses_wrapped_body() {
1410        use super::WrappedMessageBody;
1411        let raw = r#"{"message": "hello world", "payment": {"tx": [1,2,3]}}"#;
1412        let wrapped: WrappedMessageBody = serde_json::from_str(raw).unwrap();
1413        assert!(
1414            wrapped.message.is_some(),
1415            "message sub-field must be present"
1416        );
1417        assert!(
1418            wrapped.payment.is_some(),
1419            "payment sub-field must be present"
1420        );
1421        // The message value is a JSON string
1422        let msg_val = wrapped.message.unwrap();
1423        assert_eq!(msg_val.as_str().unwrap(), "hello world");
1424    }
1425
1426    /// Non-wrapped body must fail to parse as WrappedMessageBody gracefully.
1427    #[test]
1428    fn list_messages_plain_body_passthrough() {
1429        use super::WrappedMessageBody;
1430        // A plain string "hello" is NOT valid JSON for WrappedMessageBody
1431        let plain = "plain body text";
1432        let result = serde_json::from_str::<WrappedMessageBody>(plain);
1433        assert!(result.is_err(), "plain text must not parse as wrapped body");
1434    }
1435
1436    /// Wrapped body with payment: null must not crash.
1437    #[test]
1438    fn list_messages_missing_payment_no_crash() {
1439        use super::WrappedMessageBody;
1440        let raw = r#"{"message": "the content", "payment": null}"#;
1441        let wrapped: WrappedMessageBody = serde_json::from_str(raw).unwrap();
1442        assert!(wrapped.message.is_some(), "message present");
1443        assert!(wrapped.payment.is_none(), "payment is none when null");
1444    }
1445
1446    /// `dedup_messages` deduplicates by message_id — first occurrence wins.
1447    #[test]
1448    fn test_list_messages_dedup_by_id() {
1449        use super::dedup_messages;
1450        use bsv::remittance::types::PeerMessage;
1451
1452        let msg_a = PeerMessage {
1453            message_id: "id-1".to_string(),
1454            sender: "03sender".to_string(),
1455            recipient: "03me".to_string(),
1456            message_box: "inbox".to_string(),
1457            body: "first".to_string(),
1458        };
1459        let msg_a_dup = PeerMessage {
1460            message_id: "id-1".to_string(), // same id — should be deduplicated
1461            sender: "03sender".to_string(),
1462            recipient: "03me".to_string(),
1463            message_box: "inbox".to_string(),
1464            body: "duplicate".to_string(), // different body — first-seen wins
1465        };
1466        let msg_b = PeerMessage {
1467            message_id: "id-2".to_string(),
1468            sender: "03sender".to_string(),
1469            recipient: "03me".to_string(),
1470            message_box: "inbox".to_string(),
1471            body: "second".to_string(),
1472        };
1473
1474        // Two hosts: host1 has [msg_a, msg_b], host2 has [msg_a_dup]
1475        let results = vec![vec![msg_a.clone(), msg_b.clone()], vec![msg_a_dup]];
1476        let deduped = dedup_messages(results);
1477
1478        assert_eq!(deduped.len(), 2, "must deduplicate to 2 unique messages");
1479        // First-seen wins: id-1 body must be "first", not "duplicate"
1480        let first = deduped.iter().find(|m| m.message_id == "id-1").unwrap();
1481        assert_eq!(first.body, "first", "first-seen must win on deduplication");
1482    }
1483
1484    /// HMAC message ID must be deterministic — same inputs produce same output.
1485    #[tokio::test]
1486    async fn test_message_id_deterministic() {
1487        let wallet = ArcWallet::new();
1488        let other = ArcWallet::new();
1489        let other_pk = other
1490            .get_public_key(
1491                GetPublicKeyArgs {
1492                    identity_key: true,
1493                    protocol_id: None,
1494                    key_id: None,
1495                    counterparty: None,
1496                    privileged: false,
1497                    privileged_reason: None,
1498                    for_self: None,
1499                    seek_permission: None,
1500                },
1501                None,
1502            )
1503            .await
1504            .expect("get_public_key")
1505            .public_key
1506            .to_der_hex();
1507
1508        let id1 = generate_message_id(&wallet, "same body", &other_pk, None)
1509            .await
1510            .expect("first call");
1511        let id2 = generate_message_id(&wallet, "same body", &other_pk, None)
1512            .await
1513            .expect("second call");
1514
1515        assert_eq!(id1, id2, "same inputs must produce the same HMAC");
1516    }
1517}