Skip to main content

bsv_messagebox_client/
host_resolution.rs

1//! Overlay host resolution, advertisement, and revocation.
2//!
3//! Implements the four overlay methods that mirror the TypeScript MessageBoxClient:
4//! - `query_advertisements` — lookup PushDrop-encoded host advertisements on the overlay
5//! - `resolve_host_for_recipient` — derive the recipient's MessageBox host URL
6//! - `anoint_host` — broadcast a host advertisement via SHIP/PushDrop
7//! - `revoke_host_advertisement` — spend an existing advertisement UTXO
8//!
9//! The PushDrop locking script is built manually from chunks because the Rust SDK's
10//! `PushDrop::new` requires a raw `PrivateKey`, which `WalletInterface` does not expose.
11//! We derive the public key via `wallet.get_public_key` with `forSelf: true` instead.
12
13use std::collections::HashMap;
14
15use bsv::script::locking_script::LockingScript;
16use bsv::script::op::Op;
17use bsv::script::script::Script;
18use bsv::script::script_chunk::ScriptChunk;
19use bsv::script::templates::PushDrop;
20use bsv::services::overlay_tools::{
21    LookupResolver, LookupResolverConfig, TopicBroadcaster, TopicBroadcasterConfig,
22};
23use bsv::services::overlay_tools::{LookupAnswer, LookupQuestion};
24use bsv::transaction::Transaction;
25use bsv::wallet::interfaces::{
26    CreateActionArgs, CreateActionInput, CreateActionOptions, CreateActionOutput, GetPublicKeyArgs,
27    SignActionArgs, SignActionSpend, WalletInterface,
28};
29use bsv::wallet::types::{
30    BooleanDefaultTrue, Counterparty, CounterpartyType, Protocol,
31};
32
33use crate::client::MessageBoxClient;
34use crate::error::MessageBoxError;
35use crate::types::{AdvertisementToken, ListDevicesResponse, RegisterDeviceRequest, RegisterDeviceResponse, RegisteredDevice};
36
37// ---------------------------------------------------------------------------
38// Standalone helpers
39// ---------------------------------------------------------------------------
40
41/// Build a correct data-push chunk for an arbitrary-length byte slice.
42///
43/// Uses the shortest possible push opcode per Bitcoin script encoding rules:
44/// - len < 0x4c: opcode IS the length (direct push)
45/// - len < 256: OP_PUSHDATA1 prefix
46/// - len < 65536: OP_PUSHDATA2 prefix
47/// - else: OP_PUSHDATA4 prefix
48fn make_data_push(data: &[u8]) -> ScriptChunk {
49    let len = data.len();
50    if len < 0x4c {
51        ScriptChunk::new_raw(len as u8, Some(data.to_vec()))
52    } else if len < 256 {
53        ScriptChunk::new_raw(Op::OpPushData1.to_byte(), Some(data.to_vec()))
54    } else if len < 65536 {
55        ScriptChunk::new_raw(Op::OpPushData2.to_byte(), Some(data.to_vec()))
56    } else {
57        ScriptChunk::new_raw(Op::OpPushData4.to_byte(), Some(data.to_vec()))
58    }
59}
60
61
62// ---------------------------------------------------------------------------
63// MessageBoxClient impl — host resolution methods
64// ---------------------------------------------------------------------------
65
66impl<W: WalletInterface + Clone + 'static + Send + Sync> MessageBoxClient<W> {
67    /// Query the `ls_messagebox` overlay service for host advertisement tokens.
68    ///
69    /// Returns all matching `AdvertisementToken`s. Malformed outputs are silently
70    /// skipped. The ENTIRE method is wrapped in error recovery that returns an empty
71    /// Vec — matching the TypeScript `queryAdvertisements` which wraps everything in
72    /// try/catch and returns `[]` on any error, including overlay unreachability.
73    ///
74    /// TS parity:
75    /// ```typescript
76    /// } catch (err) { Logger.error('failed:', err); }
77    /// return hosts  // always returns, never throws
78    /// ```
79    pub async fn query_advertisements(
80        &self,
81        identity_key: Option<&str>,
82        host: Option<&str>,
83    ) -> Result<Vec<AdvertisementToken>, MessageBoxError> {
84        // CRITICAL TS PARITY: wrap everything; return empty vec on any failure
85        match self.query_advertisements_inner(identity_key, host).await {
86            Ok(tokens) => Ok(tokens),
87            Err(_) => Ok(vec![]),
88        }
89    }
90
91    /// Inner implementation — errors propagate; wrapped by `query_advertisements`.
92    async fn query_advertisements_inner(
93        &self,
94        identity_key: Option<&str>,
95        host: Option<&str>,
96    ) -> Result<Vec<AdvertisementToken>, MessageBoxError> {
97        let ik = match identity_key {
98            Some(k) => k.to_string(),
99            None => self.get_identity_key().await?,
100        };
101
102        let mut query_obj = serde_json::json!({ "identityKey": ik });
103        if let Some(h) = host {
104            let trimmed = h.trim();
105            if !trimmed.is_empty() {
106                query_obj["host"] = serde_json::Value::String(trimmed.to_string());
107            }
108        }
109
110        let question = LookupQuestion {
111            service: "ls_messagebox".to_string(),
112            query: query_obj,
113        };
114
115        // The SLAP trackers serve as universal overlay lookup hosts. Services
116        // like ls_messagebox may not have dedicated SLAP registrations, so we
117        // add the default SLAP tracker URLs as host_overrides for ls_messagebox.
118        // This lets the resolver query them directly without SLAP→host discovery.
119        let mut host_overrides = std::collections::HashMap::new();
120        let tracker_urls = self.network.default_slap_trackers();
121        host_overrides.insert("ls_messagebox".to_string(), tracker_urls);
122
123        let resolver = LookupResolver::new(LookupResolverConfig {
124            network: self.network.clone(),
125            host_overrides,
126            ..Default::default()
127        });
128
129        let answer = resolver
130            .query(&question, None)
131            .await
132            .map_err(|e| MessageBoxError::Overlay(e.to_string()))?;
133
134        let mut tokens = Vec::new();
135
136        if let LookupAnswer::OutputList { outputs } = answer {
137            for output in outputs {
138                // Convert BEEF bytes to hex string — Transaction::from_beef takes &str hex
139                let beef_hex = hex::encode(&output.beef);
140                let tx = match Transaction::from_beef(&beef_hex) {
141                    Ok(t) => t,
142                    Err(_) => continue,
143                };
144
145                let idx = output.output_index as usize;
146                if idx >= tx.outputs.len() {
147                    continue;
148                }
149
150                let script = &tx.outputs[idx].locking_script;
151                let pd = match PushDrop::decode(script) {
152                    Ok(t) => t,
153                    Err(_) => continue,
154                };
155
156                if pd.fields.len() < 2 {
157                    continue;
158                }
159
160                let host_url = match String::from_utf8(pd.fields[1].clone()) {
161                    Ok(h) => h,
162                    Err(_) => continue,
163                };
164
165                // TS does NOT filter by protocol or hostname — all valid PushDrop
166                // hosts are returned. This allows local dev with http://localhost.
167
168                // tx.id() returns Result<String> with no argument (unlike TS)
169                let txid = match tx.id() {
170                    Ok(id) => id,
171                    Err(_) => continue,
172                };
173
174                tokens.push(AdvertisementToken {
175                    host: host_url,
176                    txid,
177                    output_index: output.output_index,
178                    locking_script: script.to_hex(),
179                    beef: output.beef,
180                });
181            }
182        }
183
184        Ok(tokens)
185    }
186
187    /// Resolve the MessageBox host for a given recipient identity key.
188    ///
189    /// Queries the overlay for the recipient's advertisements and returns the
190    /// first matching host. Falls back to `self.host` when:
191    /// - No advertisements exist for the recipient, or
192    /// - The overlay is unreachable (query_advertisements always returns Ok)
193    pub async fn resolve_host_for_recipient(
194        &self,
195        recipient: &str,
196    ) -> Result<String, MessageBoxError> {
197        let ads = self.query_advertisements(Some(recipient), None).await?;
198        if let Some(ad) = ads.into_iter().next() {
199            Ok(ad.host)
200        } else {
201            Ok(self.host().to_string())
202        }
203    }
204
205    /// Broadcast a host advertisement to the `tm_messagebox` overlay topic.
206    ///
207    /// Builds a PushDrop transaction with:
208    /// - fields[0] = identity key bytes (hex-decoded from identity key string)
209    /// - fields[1] = host URL bytes (UTF-8)
210    ///
211    /// Returns the txid of the broadcast transaction, matching TS `anointHost`
212    /// which returns `{ txid }`.
213    pub async fn anoint_host(&self, host: &str) -> Result<String, MessageBoxError> {
214        let identity_key = self.get_identity_key().await?;
215
216        // Derive the public key for the PushDrop locking script.
217        // Uses protocol [1, "messagebox advertisement"], keyId "1", counterparty Anyone, forSelf true.
218        // This is the key the wallet will use when signing inputs that spend this output.
219        let pk_result = self
220            .wallet()
221            .get_public_key(
222                GetPublicKeyArgs {
223                    identity_key: false,
224                    protocol_id: Some(Protocol {
225                        security_level: 1,
226                        protocol: "messagebox advertisement".to_string(),
227                    }),
228                    key_id: Some("1".to_string()),
229                    counterparty: Some(Counterparty {
230                        counterparty_type: CounterpartyType::Anyone,
231                        public_key: None,
232                    }),
233                    privileged: false,
234                    privileged_reason: None,
235                    for_self: Some(true),
236                    seek_permission: None,
237                },
238                self.originator(),
239            )
240            .await
241            .map_err(|e| MessageBoxError::Wallet(e.to_string()))?;
242
243        let pubkey_bytes = pk_result.public_key.to_der();
244
245        // fields[0] = raw identity key bytes (hex-decoded per Pitfall 3)
246        let id_key_bytes = hex::decode(&identity_key)
247            .map_err(|e| MessageBoxError::Overlay(format!("hex decode identity key: {e}")))?;
248        let host_bytes = host.as_bytes().to_vec();
249
250        // Sign the concatenated field data (matches TS PushDrop.lock with includeSignature=true)
251        let data_to_sign: Vec<u8> = [id_key_bytes.as_slice(), host_bytes.as_slice()].concat();
252        let sig_result = self
253            .wallet()
254            .create_signature(
255                bsv::wallet::interfaces::CreateSignatureArgs {
256                    data: Some(data_to_sign),
257                    hash_to_directly_sign: None,
258                    protocol_id: Protocol {
259                        security_level: 1,
260                        protocol: "messagebox advertisement".to_string(),
261                    },
262                    key_id: "1".to_string(),
263                    counterparty: Counterparty {
264                        counterparty_type: CounterpartyType::Anyone,
265                        public_key: None,
266                    },
267                    privileged: false,
268                    privileged_reason: None,
269                    seek_permission: None,
270                },
271                self.originator(),
272            )
273            .await
274            .map_err(|e| MessageBoxError::Overlay(format!("sign fields: {e}")))?;
275
276        // Build PushDrop locking script using SDK's PushDrop template.
277        // We use a dummy PrivateKey(1) for PushDrop::new since we only need
278        // the script structure — the actual locking key is the wallet-derived
279        // pubkey which we embed by replacing the dummy pubkey in the output.
280        // The signature field from wallet.create_signature() is included as
281        // the last data field, matching the TS SDK's includeSignature=true.
282        let fields = vec![id_key_bytes, host_bytes, sig_result.signature];
283
284        // Build the locking script with "before" position using the derived pubkey.
285        // PushDrop template needs a PrivateKey, but we can build the chunks directly
286        // since we have the public key from wallet.get_public_key().
287        use bsv::script::templates::ScriptTemplateLock;
288        let locking_script = {
289            // Use PrivateKey(1) as dummy — we'll replace the pubkey
290            let mut dummy_buf = [0u8; 32];
291            dummy_buf[31] = 1;
292            let dummy_key = bsv::primitives::private_key::PrivateKey::from_bytes(&dummy_buf)
293                .map_err(|e| MessageBoxError::Overlay(format!("dummy key: {e}")))?;
294            let pd = PushDrop::new(fields, dummy_key);
295            let script = pd.lock()
296                .map_err(|e| MessageBoxError::Overlay(format!("PushDrop lock: {e}")))?;
297
298            // Replace the dummy pubkey (chunk 0) with the wallet-derived pubkey
299            let mut chunks = script.chunks().to_vec();
300            chunks[0] = ScriptChunk::new_raw(
301                pubkey_bytes.len() as u8,
302                Some(pubkey_bytes),
303            );
304            LockingScript::from_script(Script::from_chunks(chunks))
305        };
306
307        // Create the overlay advertisement transaction
308        let create_result = self
309            .wallet()
310            .create_action(
311                CreateActionArgs {
312                    description: "Anoint host for overlay routing".to_string(),
313                    input_beef: None,
314                    inputs: vec![],
315                    outputs: vec![CreateActionOutput {
316                        locking_script: Some(locking_script.to_binary()),
317                        satoshis: 1,
318                        output_description: "Overlay advertisement output".to_string(),
319                        basket: Some("overlay advertisements".to_string()),
320                        custom_instructions: None,
321                        tags: vec![],
322                    }],
323                    lock_time: None,
324                    version: None,
325                    labels: vec![],
326                    options: Some(CreateActionOptions {
327                        // randomize_outputs: false — output_index 0 is stable
328                        randomize_outputs: BooleanDefaultTrue(Some(false)),
329                        accept_delayed_broadcast: BooleanDefaultTrue(Some(false)),
330                        ..Default::default()
331                    }),
332                    reference: None,
333                },
334                self.originator(),
335            )
336            .await
337            .map_err(|e| MessageBoxError::Wallet(e.to_string()))?;
338
339        // create_action returns BEEF bytes. Parse to Transaction only for
340        // the txid — broadcast the original BEEF bytes directly to avoid
341        // losing the source transaction chain.
342        let beef_bytes = create_result
343            .tx
344            .ok_or_else(|| MessageBoxError::Overlay("create_action returned no tx".into()))?;
345        let beef_hex = hex::encode(&beef_bytes);
346        let tx = Transaction::from_beef(&beef_hex)
347            .map_err(|e| MessageBoxError::Overlay(format!("parse BEEF: {e}")))?;
348        let txid = tx
349            .id()
350            .map_err(|e| MessageBoxError::Overlay(format!("tx.id(): {e}")))?;
351
352        // Broadcast the original BEEF bytes via TopicBroadcaster.
353        // We use broadcast_beef() to pass pre-built BEEF directly,
354        // avoiding the Transaction → to_beef() round-trip which loses
355        // source transactions.
356        let broadcaster = TopicBroadcaster::new(
357            vec!["tm_messagebox".to_string()],
358            TopicBroadcasterConfig {
359                network: self.network.clone(),
360                ..Default::default()
361            },
362            LookupResolver::new(LookupResolverConfig {
363                network: self.network.clone(),
364                ..Default::default()
365            }),
366        )
367        .map_err(|e| MessageBoxError::Overlay(format!("build broadcaster: {e}")))?;
368
369        broadcaster
370            .broadcast_beef(beef_bytes)
371            .await
372            .map_err(|e| MessageBoxError::Overlay(format!("broadcast failed: {}", e.description)))?;
373
374        Ok(txid)
375    }
376
377    /// Register a device for FCM push notifications.
378    ///
379    /// POSTs `{"fcmToken": ..., "deviceId": ..., "platform": ...}` (camelCase) to
380    /// `{host}/registerDevice`. Returns `RegisterDeviceResponse { status, message, deviceId }`.
381    ///
382    /// TS parity: `registerDevice` returns the full response object including `deviceId`.
383    pub async fn register_device(
384        &self,
385        fcm_token: &str,
386        device_id: Option<&str>,
387        platform: Option<&str>,
388        override_host: Option<&str>,
389    ) -> Result<RegisterDeviceResponse, MessageBoxError> {
390        self.assert_initialized().await?;
391
392        let base = override_host.unwrap_or_else(|| self.host());
393        let request = RegisterDeviceRequest {
394            fcm_token: fcm_token.to_string(),
395            device_id: device_id.map(String::from),
396            platform: platform.map(String::from),
397        };
398
399        let body_bytes = serde_json::to_vec(&request)
400            .map_err(|e| MessageBoxError::Overlay(format!("serialize RegisterDeviceRequest: {e}")))?;
401
402        let url = format!("{base}/registerDevice");
403        let response = self.post_json(&url, body_bytes).await?;
404
405        let resp: RegisterDeviceResponse = serde_json::from_slice(&response.body)
406            .map_err(|e| MessageBoxError::Overlay(format!("deserialize RegisterDeviceResponse: {e}")))?;
407
408        Ok(resp)
409    }
410
411    /// List all registered devices for this identity.
412    ///
413    /// GETs `{host}/devices` and returns `Vec<RegisteredDevice>`.
414    /// All 8 server fields (id, deviceId, fcmToken, platform, active,
415    /// createdAt, updatedAt, lastUsed) are captured.
416    pub async fn list_registered_devices(
417        &self,
418        override_host: Option<&str>,
419    ) -> Result<Vec<RegisteredDevice>, MessageBoxError> {
420        self.assert_initialized().await?;
421
422        let base = override_host.unwrap_or_else(|| self.host());
423        let url = format!("{base}/devices");
424        let response = self.get_json(&url).await?;
425
426        let resp: ListDevicesResponse = serde_json::from_slice(&response.body)
427            .map_err(|e| MessageBoxError::Overlay(format!("deserialize ListDevicesResponse: {e}")))?;
428
429        Ok(resp.devices)
430    }
431
432    /// Revoke an existing host advertisement by spending its UTXO.
433    ///
434    /// Two-step create+sign pattern:
435    /// 1. `create_action` with `input_beef` + input pointing to the advertisement UTXO.
436    ///    Returns a signable transaction with a `reference` for the sign step.
437    /// 2. Derive sighash preimage from the partial transaction.
438    /// 3. `create_signature` with the advertisement protocol to produce a DER signature.
439    /// 4. `sign_action` with the DER+sighash-type unlock script.
440    /// 5. Broadcast the signed transaction via TopicBroadcaster.
441    ///
442    /// Returns the txid of the spending transaction.
443    pub async fn revoke_host_advertisement(
444        &self,
445        token: &AdvertisementToken,
446    ) -> Result<String, MessageBoxError> {
447        // Step 1: create a signable (unsigned) transaction spending the advertisement UTXO.
448        // unlocking_script_length: 73 matches TS (1 push byte + 72 DER sig bytes)
449        let create_result = self
450            .wallet()
451            .create_action(
452                CreateActionArgs {
453                    description: "Revoke MessageBox host advertisement".to_string(),
454                    input_beef: Some(token.beef.clone()),
455                    inputs: vec![CreateActionInput {
456                        outpoint: format!("{}.{}", token.txid, token.output_index),
457                        input_description: "Revoking host advertisement token".to_string(),
458                        unlocking_script: None,
459                        unlocking_script_length: Some(73),
460                        sequence_number: None,
461                    }],
462                    outputs: vec![],
463                    lock_time: None,
464                    version: None,
465                    labels: vec![],
466                    options: Some(CreateActionOptions {
467                        accept_delayed_broadcast: BooleanDefaultTrue(Some(false)),
468                        ..Default::default()
469                    }),
470                    reference: None,
471                },
472                self.originator(),
473            )
474            .await
475            .map_err(|e| MessageBoxError::Wallet(e.to_string()))?;
476
477        // Step 2: Extract the signable transaction and its reference
478        let signable = create_result.signable_transaction.ok_or_else(|| {
479            MessageBoxError::Overlay("create_action returned no signable_transaction".into())
480        })?;
481
482        // Step 3: Build the partial transaction so we can compute the sighash preimage
483        let partial_tx = Transaction::from_beef(&hex::encode(&signable.tx))
484            .map_err(|e| MessageBoxError::Overlay(format!("parse signable tx: {e}")))?;
485
486        // Recover the locking script from the token for the preimage
487        let lock_script = LockingScript::from_hex(&token.locking_script)
488            .map_err(|e| MessageBoxError::Overlay(format!("parse locking script hex: {e}")))?;
489
490        // SIGHASH_ALL | SIGHASH_FORKID = 0x41
491        let sighash_type: u32 = 0x41;
492
493        let preimage = partial_tx
494            .sighash_preimage(0, sighash_type, 1, &lock_script)
495            .map_err(|e| MessageBoxError::Overlay(format!("sighash_preimage: {e}")))?;
496
497        // Step 4: Sign via wallet using the advertisement protocol
498        // create_signature takes `data` = the preimage bytes (wallet hashes internally)
499        let sig_result = self
500            .wallet()
501            .create_signature(
502                bsv::wallet::interfaces::CreateSignatureArgs {
503                    protocol_id: Protocol {
504                        security_level: 1,
505                        protocol: "messagebox advertisement".to_string(),
506                    },
507                    key_id: "1".to_string(),
508                    counterparty: Counterparty {
509                        counterparty_type: CounterpartyType::Anyone,
510                        public_key: None,
511                    },
512                    data: Some(preimage),
513                    hash_to_directly_sign: None,
514                    privileged: false,
515                    privileged_reason: None,
516                    seek_permission: None,
517                },
518                self.originator(),
519            )
520            .await
521            .map_err(|e| MessageBoxError::Wallet(e.to_string()))?;
522
523        // Step 5: Build the unlock script: one data push of <sig_DER + sighash_byte>
524        let mut sig_bytes = sig_result.signature;
525        sig_bytes.push(sighash_type as u8);
526        let unlock_chunks = vec![make_data_push(&sig_bytes)];
527        let unlock_script = Script::from_chunks(unlock_chunks);
528
529        // Step 6: sign_action finalizes the transaction with our unlock script
530        let sign_result = self
531            .wallet()
532            .sign_action(
533                SignActionArgs {
534                    reference: signable.reference,
535                    spends: HashMap::from([(
536                        0u32,
537                        SignActionSpend {
538                            unlocking_script: unlock_script.to_binary(),
539                            sequence_number: None,
540                        },
541                    )]),
542                    options: None,
543                },
544                self.originator(),
545            )
546            .await
547            .map_err(|e| MessageBoxError::Wallet(e.to_string()))?;
548
549        // Step 7: Broadcast signed BEEF directly (avoids from_beef → to_beef round-trip)
550        let signed_bytes = sign_result
551            .tx
552            .ok_or_else(|| MessageBoxError::Overlay("sign_action returned no tx".into()))?;
553
554        // Parse only for the txid — broadcast the original BEEF bytes.
555        let signed_tx = Transaction::from_beef(&hex::encode(&signed_bytes))
556            .map_err(|e| MessageBoxError::Overlay(format!("parse signed tx: {e}")))?;
557        let txid = signed_tx
558            .id()
559            .map_err(|e| MessageBoxError::Overlay(format!("signed_tx.id(): {e}")))?;
560
561        let broadcaster = TopicBroadcaster::new(
562            vec!["tm_messagebox".to_string()],
563            TopicBroadcasterConfig {
564                network: self.network.clone(),
565                ..Default::default()
566            },
567            LookupResolver::new(LookupResolverConfig {
568                network: self.network.clone(),
569                ..Default::default()
570            }),
571        )
572        .map_err(|e| MessageBoxError::Overlay(format!("build broadcaster: {e}")))?;
573
574        broadcaster
575            .broadcast_beef(signed_bytes)
576            .await
577            .map_err(|e| MessageBoxError::Overlay(format!("broadcast failed: {}", e.description)))?;
578
579        Ok(txid)
580    }
581}
582
583// ---------------------------------------------------------------------------
584// Tests
585// ---------------------------------------------------------------------------
586
587#[cfg(test)]
588mod tests {
589    use super::*;
590    use bsv::primitives::private_key::PrivateKey;
591    use bsv::services::overlay_tools::Network;
592    use bsv::wallet::error::WalletError;
593    use bsv::wallet::interfaces::*;
594    use bsv::wallet::proto_wallet::ProtoWallet;
595    use std::sync::Arc;
596
597    /// Test helper: thin Arc wrapper so ProtoWallet satisfies the Clone bound.
598    #[derive(Clone)]
599    struct ArcWallet(Arc<ProtoWallet>);
600
601    impl ArcWallet {
602        fn new() -> Self {
603            let key = PrivateKey::from_random().expect("random key");
604            ArcWallet(Arc::new(ProtoWallet::new(key)))
605        }
606    }
607
608    #[async_trait::async_trait]
609    impl WalletInterface for ArcWallet {
610        async fn create_action(&self, args: CreateActionArgs, orig: Option<&str>) -> Result<CreateActionResult, WalletError> { self.0.create_action(args, orig).await }
611        async fn sign_action(&self, args: SignActionArgs, orig: Option<&str>) -> Result<SignActionResult, WalletError> { self.0.sign_action(args, orig).await }
612        async fn abort_action(&self, args: AbortActionArgs, orig: Option<&str>) -> Result<AbortActionResult, WalletError> { self.0.abort_action(args, orig).await }
613        async fn list_actions(&self, args: ListActionsArgs, orig: Option<&str>) -> Result<ListActionsResult, WalletError> { self.0.list_actions(args, orig).await }
614        async fn internalize_action(&self, args: InternalizeActionArgs, orig: Option<&str>) -> Result<InternalizeActionResult, WalletError> { self.0.internalize_action(args, orig).await }
615        async fn list_outputs(&self, args: ListOutputsArgs, orig: Option<&str>) -> Result<ListOutputsResult, WalletError> { self.0.list_outputs(args, orig).await }
616        async fn relinquish_output(&self, args: RelinquishOutputArgs, orig: Option<&str>) -> Result<RelinquishOutputResult, WalletError> { self.0.relinquish_output(args, orig).await }
617        async fn get_public_key(&self, args: GetPublicKeyArgs, orig: Option<&str>) -> Result<GetPublicKeyResult, WalletError> { self.0.get_public_key(args, orig).await }
618        async fn reveal_counterparty_key_linkage(&self, args: RevealCounterpartyKeyLinkageArgs, orig: Option<&str>) -> Result<RevealCounterpartyKeyLinkageResult, WalletError> { self.0.reveal_counterparty_key_linkage(args, orig).await }
619        async fn reveal_specific_key_linkage(&self, args: RevealSpecificKeyLinkageArgs, orig: Option<&str>) -> Result<RevealSpecificKeyLinkageResult, WalletError> { self.0.reveal_specific_key_linkage(args, orig).await }
620        async fn encrypt(&self, args: EncryptArgs, orig: Option<&str>) -> Result<EncryptResult, WalletError> { self.0.encrypt(args, orig).await }
621        async fn decrypt(&self, args: DecryptArgs, orig: Option<&str>) -> Result<DecryptResult, WalletError> { self.0.decrypt(args, orig).await }
622        async fn create_hmac(&self, args: CreateHmacArgs, orig: Option<&str>) -> Result<CreateHmacResult, WalletError> { self.0.create_hmac(args, orig).await }
623        async fn verify_hmac(&self, args: VerifyHmacArgs, orig: Option<&str>) -> Result<VerifyHmacResult, WalletError> { self.0.verify_hmac(args, orig).await }
624        async fn create_signature(&self, args: CreateSignatureArgs, orig: Option<&str>) -> Result<CreateSignatureResult, WalletError> { self.0.create_signature(args, orig).await }
625        async fn verify_signature(&self, args: VerifySignatureArgs, orig: Option<&str>) -> Result<VerifySignatureResult, WalletError> { self.0.verify_signature(args, orig).await }
626        async fn acquire_certificate(&self, args: AcquireCertificateArgs, orig: Option<&str>) -> Result<Certificate, WalletError> { self.0.acquire_certificate(args, orig).await }
627        async fn list_certificates(&self, args: ListCertificatesArgs, orig: Option<&str>) -> Result<ListCertificatesResult, WalletError> { self.0.list_certificates(args, orig).await }
628        async fn prove_certificate(&self, args: ProveCertificateArgs, orig: Option<&str>) -> Result<ProveCertificateResult, WalletError> { self.0.prove_certificate(args, orig).await }
629        async fn relinquish_certificate(&self, args: RelinquishCertificateArgs, orig: Option<&str>) -> Result<RelinquishCertificateResult, WalletError> { self.0.relinquish_certificate(args, orig).await }
630        async fn discover_by_identity_key(&self, args: DiscoverByIdentityKeyArgs, orig: Option<&str>) -> Result<DiscoverCertificatesResult, WalletError> { self.0.discover_by_identity_key(args, orig).await }
631        async fn discover_by_attributes(&self, args: DiscoverByAttributesArgs, orig: Option<&str>) -> Result<DiscoverCertificatesResult, WalletError> { self.0.discover_by_attributes(args, orig).await }
632        async fn is_authenticated(&self, orig: Option<&str>) -> Result<AuthenticatedResult, WalletError> { self.0.is_authenticated(orig).await }
633        async fn wait_for_authentication(&self, orig: Option<&str>) -> Result<AuthenticatedResult, WalletError> { self.0.wait_for_authentication(orig).await }
634        async fn get_height(&self, orig: Option<&str>) -> Result<GetHeightResult, WalletError> { self.0.get_height(orig).await }
635        async fn get_header_for_height(&self, args: GetHeaderArgs, orig: Option<&str>) -> Result<GetHeaderResult, WalletError> { self.0.get_header_for_height(args, orig).await }
636        async fn get_network(&self, orig: Option<&str>) -> Result<GetNetworkResult, WalletError> { self.0.get_network(orig).await }
637        async fn get_version(&self, orig: Option<&str>) -> Result<GetVersionResult, WalletError> { self.0.get_version(orig).await }
638    }
639
640    fn make_client() -> MessageBoxClient<ArcWallet> {
641        MessageBoxClient::new(
642            "https://example.com".to_string(),
643            ArcWallet::new(),
644            None,
645            Network::Mainnet,
646        )
647    }
648
649    /// `resolve_host_for_recipient` falls back to `self.host` when overlay returns empty.
650    ///
651    /// With Network::Mainnet, `query_advertisements` will fail to reach SLAP trackers
652    /// and return an empty vec (TS parity: no error propagation). The fallback kicks in.
653    #[tokio::test]
654    async fn test_resolve_host_falls_back_to_default() {
655        let client = make_client();
656        // Overlay unreachable from unit test → empty vec → fall back to self.host
657        let host = client
658            .resolve_host_for_recipient("03deadbeef")
659            .await
660            .expect("should not error");
661        assert_eq!(host, "https://example.com", "must fall back to self.host");
662    }
663
664    /// revoke_host_advertisement builds an outpoint as "{txid}.{output_index}".
665    #[test]
666    fn test_revoke_host_args_correct() {
667        let txid = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab";
668        let output_index: u32 = 0;
669        let outpoint = format!("{txid}.{output_index}");
670        assert_eq!(
671            outpoint,
672            "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab.0"
673        );
674    }
675
676    // -----------------------------------------------------------------------
677    // Task 3 — device registration tests
678    // -----------------------------------------------------------------------
679
680    /// `RegisterDeviceRequest` serializes to camelCase JSON with correct field names.
681    #[test]
682    fn test_register_device_request_serializes_camelcase() {
683        use crate::types::RegisterDeviceRequest;
684        let req = RegisterDeviceRequest {
685            fcm_token: "abc".to_string(),
686            device_id: Some("d1".to_string()),
687            platform: None,
688        };
689        let json = serde_json::to_string(&req).unwrap();
690        assert!(json.contains("\"fcmToken\":\"abc\""), "fcmToken must be camelCase: {json}");
691        assert!(json.contains("\"deviceId\":\"d1\""), "deviceId must be camelCase: {json}");
692        assert!(!json.contains("platform"), "platform absent when None: {json}");
693        assert!(!json.contains("fcm_token"), "no snake_case leakage: {json}");
694        assert!(!json.contains("device_id"), "no snake_case leakage: {json}");
695    }
696
697    /// `ListDevicesResponse` deserializes a full server response including all 8 fields.
698    #[test]
699    fn test_list_devices_response_deserializes() {
700        use crate::types::ListDevicesResponse;
701        let raw = r#"{
702            "status": "success",
703            "devices": [{
704                "id": 1,
705                "deviceId": "d1",
706                "fcmToken": "tok",
707                "platform": "ios",
708                "active": true,
709                "createdAt": "2026-01-01",
710                "updatedAt": "2026-01-01",
711                "lastUsed": "2026-01-01"
712            }]
713        }"#;
714        let resp: ListDevicesResponse = serde_json::from_str(raw).unwrap();
715        assert_eq!(resp.status, "success");
716        assert_eq!(resp.devices.len(), 1);
717        let dev = &resp.devices[0];
718        assert_eq!(dev.id, Some(1));
719        assert_eq!(dev.device_id.as_deref(), Some("d1"));
720        assert_eq!(dev.fcm_token, "tok");
721        assert_eq!(dev.platform.as_deref(), Some("ios"));
722        assert_eq!(dev.active, Some(true));
723        assert!(dev.created_at.is_some());
724        assert!(dev.updated_at.is_some());
725        assert!(dev.last_used.is_some());
726    }
727
728    /// `RegisterDeviceResponse` deserializes `{status, message, deviceId}` correctly.
729    #[test]
730    fn test_register_device_response_deserializes() {
731        use crate::types::RegisterDeviceResponse;
732        let raw = r#"{"status":"success","message":"registered","deviceId":42}"#;
733        let resp: RegisterDeviceResponse = serde_json::from_str(raw).unwrap();
734        assert_eq!(resp.status, "success");
735        assert_eq!(resp.message.as_deref(), Some("registered"));
736        assert_eq!(resp.device_id, Some(42));
737    }
738
739    /// `register_device` method exists on `MessageBoxClient` — compile check.
740    ///
741    /// Verifies the method signature accepts fcm_token, device_id, platform.
742    #[allow(dead_code)]
743    fn register_device_compiles(client: &MessageBoxClient<ArcWallet>) {
744        let _fut = client.register_device("tok123", Some("dev1"), Some("ios"), None);
745    }
746
747    /// `list_registered_devices` method exists on `MessageBoxClient` — compile check.
748    #[allow(dead_code)]
749    fn list_registered_devices_compiles(client: &MessageBoxClient<ArcWallet>) {
750        let _fut = client.list_registered_devices(None);
751    }
752}