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