Skip to main content

bsv_messagebox_client/
http_ops.rs

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