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::primitives::hash::sha256;
19use bsv::script::locking_script::LockingScript;
20use bsv::script::op::Op;
21use bsv::script::script::Script;
22use bsv::script::script_chunk::ScriptChunk;
23use bsv::script::templates::push_drop::{decode as decode_push_drop, LockPosition, PushDrop};
24use bsv::services::overlay_tools::{LookupAnswer, LookupQuestion};
25use bsv::services::overlay_tools::{
26    LookupResolver, LookupResolverConfig, TopicBroadcaster, TopicBroadcasterConfig,
27};
28use bsv::transaction::Transaction;
29use bsv::wallet::interfaces::{
30    CreateActionArgs, CreateActionInput, CreateActionOptions, CreateActionOutput, SignActionArgs,
31    SignActionSpend, WalletInterface,
32};
33use bsv::wallet::types::{BooleanDefaultTrue, Counterparty, CounterpartyType, Protocol};
34
35use crate::client::MessageBoxClient;
36use crate::error::MessageBoxError;
37use crate::types::{
38    AdvertisementToken, ListDevicesResponse, RegisterDeviceRequest, RegisterDeviceResponse,
39    RegisteredDevice,
40};
41
42// ---------------------------------------------------------------------------
43// Standalone helpers
44// ---------------------------------------------------------------------------
45
46/// Build a correct data-push chunk for an arbitrary-length byte slice.
47///
48/// Uses the shortest possible push opcode per Bitcoin script encoding rules:
49/// - len < 0x4c: opcode IS the length (direct push)
50/// - len < 256: OP_PUSHDATA1 prefix
51/// - len < 65536: OP_PUSHDATA2 prefix
52/// - else: OP_PUSHDATA4 prefix
53fn make_data_push(data: &[u8]) -> ScriptChunk {
54    let len = data.len();
55    if len < 0x4c {
56        ScriptChunk::new_raw(len as u8, Some(data.to_vec()))
57    } else if len < 256 {
58        ScriptChunk::new_raw(Op::OpPushData1.to_byte(), Some(data.to_vec()))
59    } else if len < 65536 {
60        ScriptChunk::new_raw(Op::OpPushData2.to_byte(), Some(data.to_vec()))
61    } else {
62        ScriptChunk::new_raw(Op::OpPushData4.to_byte(), Some(data.to_vec()))
63    }
64}
65
66/// Build the unlocking script that spends a `messagebox advertisement` PushDrop
67/// output: a single data push of `<DER signature || sighash byte>`.
68///
69/// The digest convention is the load-bearing part. `create_signature` hashes its
70/// `data` with SHA-256 exactly ONCE and signs that (`ProtoWallet::create_signature_sync`),
71/// while a BSV sighash is `sha256d(preimage)` — so the caller supplies the FIRST
72/// hash and the wallet applies the second. Handing over the raw preimage signs
73/// `sha256(preimage)`, which no script engine will accept. Same convention as the
74/// SDK's own `PushDrop::unlock` and ts-sdk's `PushDrop.unlock`.
75async fn build_advertisement_unlock_script<W: WalletInterface + ?Sized>(
76    wallet: &W,
77    originator: Option<&str>,
78    partial_tx: &Transaction,
79    input_index: usize,
80    sighash_type: u32,
81    source_satoshis: u64,
82    lock_script: &LockingScript,
83) -> Result<Script, MessageBoxError> {
84    let preimage = partial_tx
85        .sighash_preimage(input_index, sighash_type, source_satoshis, lock_script)
86        .map_err(|e| MessageBoxError::Overlay(format!("sighash_preimage: {e}")))?;
87
88    let sig_result = wallet
89        .create_signature(
90            bsv::wallet::interfaces::CreateSignatureArgs {
91                protocol_id: Protocol {
92                    security_level: 1,
93                    protocol: "messagebox advertisement".to_string(),
94                },
95                key_id: "1".to_string(),
96                counterparty: Counterparty {
97                    counterparty_type: CounterpartyType::Anyone,
98                    public_key: None,
99                },
100                data: Some(sha256(&preimage).to_vec()),
101                hash_to_directly_sign: None,
102                privileged: false,
103                privileged_reason: None,
104                seek_permission: None,
105            },
106            originator,
107        )
108        .await
109        .map_err(|e| MessageBoxError::Wallet(e.to_string()))?;
110
111    // One data push of <sig_DER + sighash_byte>.
112    let mut sig_bytes = sig_result.signature;
113    sig_bytes.push(sighash_type as u8);
114    Ok(Script::from_chunks(vec![make_data_push(&sig_bytes)]))
115}
116
117// ---------------------------------------------------------------------------
118// MessageBoxClient impl — host resolution methods
119// ---------------------------------------------------------------------------
120
121impl<W: WalletInterface + Clone + 'static + Send + Sync> MessageBoxClient<W> {
122    /// Query the `ls_messagebox` overlay service for host advertisement tokens.
123    ///
124    /// Returns all matching `AdvertisementToken`s. Malformed outputs are silently
125    /// skipped. The ENTIRE method is wrapped in error recovery that returns an empty
126    /// Vec — matching the TypeScript `queryAdvertisements` which wraps everything in
127    /// try/catch and returns `[]` on any error, including overlay unreachability.
128    ///
129    /// TS parity:
130    /// ```typescript
131    /// } catch (err) { Logger.error('failed:', err); }
132    /// return hosts  // always returns, never throws
133    /// ```
134    pub async fn query_advertisements(
135        &self,
136        identity_key: Option<&str>,
137        host: Option<&str>,
138    ) -> Result<Vec<AdvertisementToken>, MessageBoxError> {
139        // CRITICAL TS PARITY: wrap everything; return empty vec on any failure
140        match self.query_advertisements_inner(identity_key, host).await {
141            Ok(tokens) => Ok(tokens),
142            Err(_) => Ok(vec![]),
143        }
144    }
145
146    /// Inner implementation — errors propagate; wrapped by `query_advertisements`.
147    async fn query_advertisements_inner(
148        &self,
149        identity_key: Option<&str>,
150        host: Option<&str>,
151    ) -> Result<Vec<AdvertisementToken>, MessageBoxError> {
152        let ik = match identity_key {
153            Some(k) => k.to_string(),
154            None => self.get_identity_key().await?,
155        };
156
157        let mut query_obj = serde_json::json!({ "identityKey": ik });
158        if let Some(h) = host {
159            let trimmed = h.trim();
160            if !trimmed.is_empty() {
161                query_obj["host"] = serde_json::Value::String(trimmed.to_string());
162            }
163        }
164
165        let question = LookupQuestion {
166            service: "ls_messagebox".to_string(),
167            query: query_obj,
168        };
169
170        // The SLAP trackers serve as universal overlay lookup hosts. Services
171        // like ls_messagebox may not have dedicated SLAP registrations, so we
172        // add the default SLAP tracker URLs as host_overrides for ls_messagebox.
173        // This lets the resolver query them directly without SLAP→host discovery.
174        let mut host_overrides = std::collections::HashMap::new();
175        let tracker_urls = self.network.default_slap_trackers();
176        host_overrides.insert("ls_messagebox".to_string(), tracker_urls);
177
178        let resolver = LookupResolver::new(LookupResolverConfig {
179            network: self.network.clone(),
180            host_overrides,
181            ..Default::default()
182        });
183
184        let answer = resolver
185            .query(&question, None)
186            .await
187            .map_err(|e| MessageBoxError::Overlay(e.to_string()))?;
188
189        let mut tokens = Vec::new();
190
191        if let LookupAnswer::OutputList { outputs } = answer {
192            for output in outputs {
193                // Convert BEEF bytes to hex string — Transaction::from_beef takes &str hex
194                let beef_hex = hex::encode(&output.beef);
195                let tx = match Transaction::from_beef(&beef_hex) {
196                    Ok(t) => t,
197                    Err(_) => continue,
198                };
199
200                let idx = output.output_index as usize;
201                if idx >= tx.outputs.len() {
202                    continue;
203                }
204
205                let script = &tx.outputs[idx].locking_script;
206                let pd = match decode_push_drop(script) {
207                    Ok(t) => t,
208                    Err(_) => continue,
209                };
210
211                if pd.fields.len() < 2 {
212                    continue;
213                }
214
215                let host_url = match String::from_utf8(pd.fields[1].clone()) {
216                    Ok(h) => h,
217                    Err(_) => continue,
218                };
219
220                // TS does NOT filter by protocol or hostname — all valid PushDrop
221                // hosts are returned. This allows local dev with http://localhost.
222
223                // tx.id() returns Result<String> with no argument (unlike TS)
224                let txid = match tx.id() {
225                    Ok(id) => id,
226                    Err(_) => continue,
227                };
228
229                tokens.push(AdvertisementToken {
230                    host: host_url,
231                    txid,
232                    output_index: output.output_index,
233                    locking_script: script.to_hex(),
234                    beef: output.beef,
235                });
236            }
237        }
238
239        Ok(tokens)
240    }
241
242    /// Resolve the MessageBox host for a given recipient identity key.
243    ///
244    /// Queries the overlay for the recipient's advertisements and returns the
245    /// first matching host. Falls back to `self.host` when:
246    /// - No advertisements exist for the recipient, or
247    /// - The overlay is unreachable (query_advertisements always returns Ok)
248    pub async fn resolve_host_for_recipient(
249        &self,
250        recipient: &str,
251    ) -> Result<String, MessageBoxError> {
252        let ads = self.query_advertisements(Some(recipient), None).await?;
253        if let Some(ad) = ads.into_iter().next() {
254            Ok(ad.host)
255        } else {
256            Ok(self.host().to_string())
257        }
258    }
259
260    /// Broadcast a host advertisement to the `tm_messagebox` overlay topic.
261    ///
262    /// Builds a PushDrop transaction with:
263    /// - fields[0] = identity key bytes (hex-decoded from identity key string)
264    /// - fields[1] = host URL bytes (UTF-8)
265    ///
266    /// Returns the txid of the broadcast transaction, matching TS `anointHost`
267    /// which returns `{ txid }`.
268    pub async fn anoint_host(&self, host: &str) -> Result<String, MessageBoxError> {
269        let identity_key = self.get_identity_key().await?;
270
271        // fields[0] = raw identity key bytes (hex-decoded per Pitfall 3)
272        let id_key_bytes = hex::decode(&identity_key)
273            .map_err(|e| MessageBoxError::Overlay(format!("hex decode identity key: {e}")))?;
274        let host_bytes = host.as_bytes().to_vec();
275
276        // bsv-sdk 0.3: PushDrop is wallet-driven, so it derives the locking key and
277        // appends the signature field itself — with the SAME (protocol, keyID,
278        // counterparty, forSelf) triple used for the pubkey derivation above.
279        //
280        // This replaces a workaround that the old PrivateKey-based API forced:
281        // sign the fields by hand, construct PushDrop with a DUMMY PrivateKey(1)
282        // just to get the script shape, then splice chunk[0] to swap the dummy
283        // pubkey for the wallet-derived one. That was script surgery standing in
284        // for an API that couldn't express "lock to a key the wallet derives".
285        // Same bytes, none of the surgery.
286        let locking_script = PushDrop::new(self.wallet(), self.originator().map(String::from))
287            .lock(
288                vec![id_key_bytes, host_bytes],
289                Protocol {
290                    security_level: 1,
291                    protocol: "messagebox advertisement".to_string(),
292                },
293                "1",
294                Counterparty {
295                    counterparty_type: CounterpartyType::Anyone,
296                    public_key: None,
297                },
298                true, // for_self — matches the get_public_key derivation above
299                true, // include_signature — TS/Go default; appends sig as field[2]
300                LockPosition::Before,
301            )
302            .await
303            .map_err(|e| MessageBoxError::Overlay(format!("PushDrop lock: {e}")))?;
304
305        // Create the overlay advertisement transaction
306        let create_result = self
307            .wallet()
308            .create_action(
309                CreateActionArgs {
310                    description: "Anoint host for overlay routing".to_string(),
311                    input_beef: None,
312                    inputs: None,
313                    outputs: Some(vec![CreateActionOutput {
314                        locking_script: Some(locking_script.to_binary()),
315                        satoshis: 1,
316                        output_description: "Overlay advertisement output".to_string(),
317                        basket: Some("overlay advertisements".to_string()),
318                        custom_instructions: None,
319                        tags: None,
320                    }]),
321                    lock_time: None,
322                    version: None,
323                    labels: None,
324                    options: Some(CreateActionOptions {
325                        // randomize_outputs: false — output_index 0 is stable
326                        randomize_outputs: BooleanDefaultTrue(Some(false)),
327                        accept_delayed_broadcast: BooleanDefaultTrue(Some(false)),
328                        ..Default::default()
329                    }),
330                    reference: None,
331                },
332                self.originator(),
333            )
334            .await
335            .map_err(|e| MessageBoxError::Wallet(e.to_string()))?;
336
337        // create_action returns BEEF bytes. Parse to Transaction only for
338        // the txid — broadcast the original BEEF bytes directly to avoid
339        // losing the source transaction chain.
340        let beef_bytes = create_result
341            .tx
342            .ok_or_else(|| MessageBoxError::Overlay("create_action returned no tx".into()))?;
343        let beef_hex = hex::encode(&beef_bytes);
344        let tx = Transaction::from_beef(&beef_hex)
345            .map_err(|e| MessageBoxError::Overlay(format!("parse BEEF: {e}")))?;
346        let txid = tx
347            .id()
348            .map_err(|e| MessageBoxError::Overlay(format!("tx.id(): {e}")))?;
349
350        // Broadcast the original BEEF bytes via TopicBroadcaster.
351        // We use broadcast_beef() to pass pre-built BEEF directly,
352        // avoiding the Transaction → to_beef() round-trip which loses
353        // source transactions.
354        let broadcaster = TopicBroadcaster::new(
355            vec!["tm_messagebox".to_string()],
356            TopicBroadcasterConfig {
357                network: self.network.clone(),
358                ..Default::default()
359            },
360            LookupResolver::new(LookupResolverConfig {
361                network: self.network.clone(),
362                ..Default::default()
363            }),
364        )
365        .map_err(|e| MessageBoxError::Overlay(format!("build broadcaster: {e}")))?;
366
367        broadcaster.broadcast_beef(beef_bytes).await.map_err(|e| {
368            MessageBoxError::Overlay(format!("broadcast failed: {}", e.description))
369        })?;
370
371        Ok(txid)
372    }
373
374    /// Register a device for FCM push notifications.
375    ///
376    /// POSTs `{"fcmToken": ..., "deviceId": ..., "platform": ...}` (camelCase) to
377    /// `{host}/registerDevice`. Returns `RegisterDeviceResponse { status, message, deviceId }`.
378    ///
379    /// TS parity: `registerDevice` returns the full response object including `deviceId`.
380    pub async fn register_device(
381        &self,
382        fcm_token: &str,
383        device_id: Option<&str>,
384        platform: Option<&str>,
385        override_host: Option<&str>,
386    ) -> Result<RegisterDeviceResponse, MessageBoxError> {
387        self.assert_initialized().await?;
388
389        let base = override_host.unwrap_or_else(|| self.host());
390        let request = RegisterDeviceRequest {
391            fcm_token: fcm_token.to_string(),
392            device_id: device_id.map(String::from),
393            platform: platform.map(String::from),
394        };
395
396        let body_bytes = serde_json::to_vec(&request).map_err(|e| {
397            MessageBoxError::Overlay(format!("serialize RegisterDeviceRequest: {e}"))
398        })?;
399
400        let url = format!("{base}/registerDevice");
401        let response = self.post_json(&url, body_bytes).await?;
402
403        let resp: RegisterDeviceResponse = serde_json::from_slice(&response.body).map_err(|e| {
404            MessageBoxError::Overlay(format!("deserialize RegisterDeviceResponse: {e}"))
405        })?;
406
407        Ok(resp)
408    }
409
410    /// List all registered devices for this identity.
411    ///
412    /// GETs `{host}/devices` and returns `Vec<RegisteredDevice>`.
413    /// All 8 server fields (id, deviceId, fcmToken, platform, active,
414    /// createdAt, updatedAt, lastUsed) are captured.
415    pub async fn list_registered_devices(
416        &self,
417        override_host: Option<&str>,
418    ) -> Result<Vec<RegisteredDevice>, MessageBoxError> {
419        self.assert_initialized().await?;
420
421        let base = override_host.unwrap_or_else(|| self.host());
422        let url = format!("{base}/devices");
423        let response = self.get_json(&url).await?;
424
425        let resp: ListDevicesResponse = serde_json::from_slice(&response.body).map_err(|e| {
426            MessageBoxError::Overlay(format!("deserialize ListDevicesResponse: {e}"))
427        })?;
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` over `sha256(preimage)` with the advertisement protocol
439    ///    (the wallet applies the second hash) to produce a DER signature.
440    /// 4. `sign_action` with the DER+sighash-type unlock script.
441    /// 5. Broadcast the signed transaction via TopicBroadcaster.
442    ///
443    /// Returns the txid of the spending transaction.
444    pub async fn revoke_host_advertisement(
445        &self,
446        token: &AdvertisementToken,
447    ) -> Result<String, MessageBoxError> {
448        // Step 1: create a signable (unsigned) transaction spending the advertisement UTXO.
449        // unlocking_script_length: 73 matches TS (1 push byte + 72 DER sig bytes)
450        let create_result = self
451            .wallet()
452            .create_action(
453                CreateActionArgs {
454                    description: "Revoke MessageBox host advertisement".to_string(),
455                    input_beef: Some(token.beef.clone()),
456                    inputs: Some(vec![CreateActionInput {
457                        outpoint: format!("{}.{}", token.txid, token.output_index),
458                        input_description: "Revoking host advertisement token".to_string(),
459                        unlocking_script: None,
460                        unlocking_script_length: Some(73),
461                        sequence_number: None,
462                    }]),
463                    outputs: None,
464                    lock_time: None,
465                    version: None,
466                    labels: None,
467                    options: Some(CreateActionOptions {
468                        accept_delayed_broadcast: BooleanDefaultTrue(Some(false)),
469                        ..Default::default()
470                    }),
471                    reference: None,
472                },
473                self.originator(),
474            )
475            .await
476            .map_err(|e| MessageBoxError::Wallet(e.to_string()))?;
477
478        // Step 2: Extract the signable transaction and its reference
479        let signable = create_result.signable_transaction.ok_or_else(|| {
480            MessageBoxError::Overlay("create_action returned no signable_transaction".into())
481        })?;
482
483        // Step 3: Build the partial transaction so we can compute the sighash preimage
484        let partial_tx = Transaction::from_beef(&hex::encode(&signable.tx))
485            .map_err(|e| MessageBoxError::Overlay(format!("parse signable tx: {e}")))?;
486
487        // Recover the locking script from the token for the preimage
488        let lock_script = LockingScript::from_hex(&token.locking_script)
489            .map_err(|e| MessageBoxError::Overlay(format!("parse locking script hex: {e}")))?;
490
491        // SIGHASH_ALL | SIGHASH_FORKID = 0x41
492        let sighash_type: u32 = 0x41;
493
494        // Steps 4+5: sign the sighash through the wallet and wrap the DER signature
495        // in the unlocking script. `build_advertisement_unlock_script` owns the
496        // digest convention (it pre-hashes the preimage — see its docs).
497        let unlock_script = build_advertisement_unlock_script(
498            self.wallet(),
499            self.originator(),
500            &partial_tx,
501            0,
502            sighash_type,
503            1,
504            &lock_script,
505        )
506        .await?;
507
508        // Step 6: sign_action finalizes the transaction with our unlock script
509        let sign_result = self
510            .wallet()
511            .sign_action(
512                SignActionArgs {
513                    reference: signable.reference,
514                    spends: HashMap::from([(
515                        0u32,
516                        SignActionSpend {
517                            unlocking_script: unlock_script.to_binary(),
518                            sequence_number: None,
519                        },
520                    )]),
521                    options: None,
522                },
523                self.originator(),
524            )
525            .await
526            .map_err(|e| MessageBoxError::Wallet(e.to_string()))?;
527
528        // Step 7: Broadcast signed BEEF directly (avoids from_beef → to_beef round-trip)
529        let signed_bytes = sign_result
530            .tx
531            .ok_or_else(|| MessageBoxError::Overlay("sign_action returned no tx".into()))?;
532
533        // Parse only for the txid — broadcast the original BEEF bytes.
534        let signed_tx = Transaction::from_beef(&hex::encode(&signed_bytes))
535            .map_err(|e| MessageBoxError::Overlay(format!("parse signed tx: {e}")))?;
536        let txid = signed_tx
537            .id()
538            .map_err(|e| MessageBoxError::Overlay(format!("signed_tx.id(): {e}")))?;
539
540        let broadcaster = TopicBroadcaster::new(
541            vec!["tm_messagebox".to_string()],
542            TopicBroadcasterConfig {
543                network: self.network.clone(),
544                ..Default::default()
545            },
546            LookupResolver::new(LookupResolverConfig {
547                network: self.network.clone(),
548                ..Default::default()
549            }),
550        )
551        .map_err(|e| MessageBoxError::Overlay(format!("build broadcaster: {e}")))?;
552
553        broadcaster
554            .broadcast_beef(signed_bytes)
555            .await
556            .map_err(|e| {
557                MessageBoxError::Overlay(format!("broadcast failed: {}", e.description))
558            })?;
559
560        Ok(txid)
561    }
562}
563
564// ---------------------------------------------------------------------------
565// Tests
566// ---------------------------------------------------------------------------
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571    use bsv::primitives::private_key::PrivateKey;
572    use bsv::services::overlay_tools::Network;
573    use bsv::wallet::error::WalletError;
574    use bsv::wallet::interfaces::*;
575    use bsv::wallet::proto_wallet::ProtoWallet;
576    use std::sync::Arc;
577
578    /// Test helper: thin Arc wrapper so ProtoWallet satisfies the Clone bound.
579    #[derive(Clone)]
580    struct ArcWallet(Arc<ProtoWallet>);
581
582    impl ArcWallet {
583        fn new() -> Self {
584            let key = PrivateKey::from_random().expect("random key");
585            ArcWallet(Arc::new(ProtoWallet::new(key)))
586        }
587
588        /// Fixed key, so a script-validation failure reproduces byte-for-byte.
589        fn deterministic() -> Self {
590            let key = PrivateKey::from_bytes(&[0x42u8; 32]).expect("fixed key");
591            ArcWallet(Arc::new(ProtoWallet::new(key)))
592        }
593    }
594
595    #[async_trait::async_trait]
596    impl WalletInterface for ArcWallet {
597        async fn create_action(
598            &self,
599            args: CreateActionArgs,
600            orig: Option<&str>,
601        ) -> Result<CreateActionResult, WalletError> {
602            self.0.create_action(args, orig).await
603        }
604        async fn sign_action(
605            &self,
606            args: SignActionArgs,
607            orig: Option<&str>,
608        ) -> Result<SignActionResult, WalletError> {
609            self.0.sign_action(args, orig).await
610        }
611        async fn abort_action(
612            &self,
613            args: AbortActionArgs,
614            orig: Option<&str>,
615        ) -> Result<AbortActionResult, WalletError> {
616            self.0.abort_action(args, orig).await
617        }
618        async fn list_actions(
619            &self,
620            args: ListActionsArgs,
621            orig: Option<&str>,
622        ) -> Result<ListActionsResult, WalletError> {
623            self.0.list_actions(args, orig).await
624        }
625        async fn internalize_action(
626            &self,
627            args: InternalizeActionArgs,
628            orig: Option<&str>,
629        ) -> Result<InternalizeActionResult, WalletError> {
630            self.0.internalize_action(args, orig).await
631        }
632        async fn list_outputs(
633            &self,
634            args: ListOutputsArgs,
635            orig: Option<&str>,
636        ) -> Result<ListOutputsResult, WalletError> {
637            self.0.list_outputs(args, orig).await
638        }
639        async fn relinquish_output(
640            &self,
641            args: RelinquishOutputArgs,
642            orig: Option<&str>,
643        ) -> Result<RelinquishOutputResult, WalletError> {
644            self.0.relinquish_output(args, orig).await
645        }
646        async fn get_public_key(
647            &self,
648            args: GetPublicKeyArgs,
649            orig: Option<&str>,
650        ) -> Result<GetPublicKeyResult, WalletError> {
651            self.0.get_public_key(args, orig).await
652        }
653        async fn reveal_counterparty_key_linkage(
654            &self,
655            args: RevealCounterpartyKeyLinkageArgs,
656            orig: Option<&str>,
657        ) -> Result<RevealCounterpartyKeyLinkageResult, WalletError> {
658            self.0.reveal_counterparty_key_linkage(args, orig).await
659        }
660        async fn reveal_specific_key_linkage(
661            &self,
662            args: RevealSpecificKeyLinkageArgs,
663            orig: Option<&str>,
664        ) -> Result<RevealSpecificKeyLinkageResult, WalletError> {
665            self.0.reveal_specific_key_linkage(args, orig).await
666        }
667        async fn encrypt(
668            &self,
669            args: EncryptArgs,
670            orig: Option<&str>,
671        ) -> Result<EncryptResult, WalletError> {
672            self.0.encrypt(args, orig).await
673        }
674        async fn decrypt(
675            &self,
676            args: DecryptArgs,
677            orig: Option<&str>,
678        ) -> Result<DecryptResult, WalletError> {
679            self.0.decrypt(args, orig).await
680        }
681        async fn create_hmac(
682            &self,
683            args: CreateHmacArgs,
684            orig: Option<&str>,
685        ) -> Result<CreateHmacResult, WalletError> {
686            self.0.create_hmac(args, orig).await
687        }
688        async fn verify_hmac(
689            &self,
690            args: VerifyHmacArgs,
691            orig: Option<&str>,
692        ) -> Result<VerifyHmacResult, WalletError> {
693            self.0.verify_hmac(args, orig).await
694        }
695        async fn create_signature(
696            &self,
697            args: CreateSignatureArgs,
698            orig: Option<&str>,
699        ) -> Result<CreateSignatureResult, WalletError> {
700            self.0.create_signature(args, orig).await
701        }
702        async fn verify_signature(
703            &self,
704            args: VerifySignatureArgs,
705            orig: Option<&str>,
706        ) -> Result<VerifySignatureResult, WalletError> {
707            self.0.verify_signature(args, orig).await
708        }
709        async fn acquire_certificate(
710            &self,
711            args: AcquireCertificateArgs,
712            orig: Option<&str>,
713        ) -> Result<Certificate, WalletError> {
714            self.0.acquire_certificate(args, orig).await
715        }
716        async fn list_certificates(
717            &self,
718            args: ListCertificatesArgs,
719            orig: Option<&str>,
720        ) -> Result<ListCertificatesResult, WalletError> {
721            self.0.list_certificates(args, orig).await
722        }
723        async fn prove_certificate(
724            &self,
725            args: ProveCertificateArgs,
726            orig: Option<&str>,
727        ) -> Result<ProveCertificateResult, WalletError> {
728            self.0.prove_certificate(args, orig).await
729        }
730        async fn relinquish_certificate(
731            &self,
732            args: RelinquishCertificateArgs,
733            orig: Option<&str>,
734        ) -> Result<RelinquishCertificateResult, WalletError> {
735            self.0.relinquish_certificate(args, orig).await
736        }
737        async fn discover_by_identity_key(
738            &self,
739            args: DiscoverByIdentityKeyArgs,
740            orig: Option<&str>,
741        ) -> Result<DiscoverCertificatesResult, WalletError> {
742            self.0.discover_by_identity_key(args, orig).await
743        }
744        async fn discover_by_attributes(
745            &self,
746            args: DiscoverByAttributesArgs,
747            orig: Option<&str>,
748        ) -> Result<DiscoverCertificatesResult, WalletError> {
749            self.0.discover_by_attributes(args, orig).await
750        }
751        async fn is_authenticated(
752            &self,
753            orig: Option<&str>,
754        ) -> Result<AuthenticatedResult, WalletError> {
755            self.0.is_authenticated(orig).await
756        }
757        async fn wait_for_authentication(
758            &self,
759            orig: Option<&str>,
760        ) -> Result<AuthenticatedResult, WalletError> {
761            self.0.wait_for_authentication(orig).await
762        }
763        async fn get_height(&self, orig: Option<&str>) -> Result<GetHeightResult, WalletError> {
764            self.0.get_height(orig).await
765        }
766        async fn get_header_for_height(
767            &self,
768            args: GetHeaderArgs,
769            orig: Option<&str>,
770        ) -> Result<GetHeaderResult, WalletError> {
771            self.0.get_header_for_height(args, orig).await
772        }
773        async fn get_network(&self, orig: Option<&str>) -> Result<GetNetworkResult, WalletError> {
774            self.0.get_network(orig).await
775        }
776        async fn get_version(&self, orig: Option<&str>) -> Result<GetVersionResult, WalletError> {
777            self.0.get_version(orig).await
778        }
779    }
780
781    fn make_client() -> MessageBoxClient<ArcWallet> {
782        MessageBoxClient::new(
783            "https://example.com".to_string(),
784            ArcWallet::new(),
785            None,
786            Network::Mainnet,
787        )
788    }
789
790    /// `resolve_host_for_recipient` falls back to `self.host` when overlay returns empty.
791    ///
792    /// With Network::Mainnet, `query_advertisements` will fail to reach SLAP trackers
793    /// and return an empty vec (TS parity: no error propagation). The fallback kicks in.
794    #[tokio::test]
795    async fn test_resolve_host_falls_back_to_default() {
796        let client = make_client();
797        // Overlay unreachable from unit test → empty vec → fall back to self.host
798        let host = client
799            .resolve_host_for_recipient("03deadbeef")
800            .await
801            .expect("should not error");
802        assert_eq!(host, "https://example.com", "must fall back to self.host");
803    }
804
805    /// revoke_host_advertisement builds an outpoint as "{txid}.{output_index}".
806    #[test]
807    fn test_revoke_host_args_correct() {
808        let txid = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab";
809        let output_index: u32 = 0;
810        let outpoint = format!("{txid}.{output_index}");
811        assert_eq!(
812            outpoint,
813            "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab.0"
814        );
815    }
816
817    /// The revocation unlocking script must actually SPEND the advertisement output.
818    ///
819    /// Runs the SDK script interpreter over (unlocking script, locking script) in the
820    /// real transaction context, so OP_CHECKSIG recomputes the BIP-143 sighash itself
821    /// and ECDSA-verifies our DER signature against `sha256d(preimage)` under the
822    /// PushDrop locking key. Nothing else in this crate ever executed the script, which
823    /// is exactly why signing the wrong digest (`sha256(preimage)`) survived — the
824    /// unlocking script was well-FORMED but not VALID. Handing `create_signature` the
825    /// raw preimage again turns this assertion red.
826    #[tokio::test]
827    async fn revocation_unlock_script_validates_against_the_advertisement_lock() {
828        use bsv::script::spend::{Spend, SpendParams};
829        use bsv::script::unlocking_script::UnlockingScript;
830        use bsv::transaction::{TransactionInput, TransactionOutput};
831
832        let wallet = ArcWallet::deterministic();
833        let sighash_type: u32 = 0x41; // SIGHASH_ALL | SIGHASH_FORKID
834
835        // The advertisement output, locked exactly as `anoint_host` locks it:
836        // same protocol/keyID/counterparty/for_self triple, so the key that
837        // `create_signature` derives is the counterpart of the locking key.
838        let locking_script = PushDrop::new(&wallet, None)
839            .lock(
840                vec![vec![0x02u8; 33], b"https://example.com".to_vec()],
841                Protocol {
842                    security_level: 1,
843                    protocol: "messagebox advertisement".to_string(),
844                },
845                "1",
846                Counterparty {
847                    counterparty_type: CounterpartyType::Anyone,
848                    public_key: None,
849                },
850                true, // for_self
851                true, // include_signature
852                LockPosition::Before,
853            )
854            .await
855            .expect("PushDrop lock");
856
857        let mut source = Transaction::new();
858        source.outputs.push(TransactionOutput {
859            satoshis: Some(1),
860            locking_script: locking_script.clone(),
861            change: false,
862        });
863        let source_txid = source.id().expect("source txid");
864
865        // The revocation shape `create_action` hands back: one input, no outputs.
866        let mut partial_tx = Transaction::new();
867        partial_tx.inputs.push(TransactionInput {
868            source_transaction: Some(Box::new(source)),
869            source_txid: Some(source_txid.clone()),
870            source_output_index: 0,
871            unlocking_script: None,
872            sequence: 0xFFFF_FFFF,
873        });
874
875        // The production path under test.
876        let unlock_script = build_advertisement_unlock_script(
877            &wallet,
878            None,
879            &partial_tx,
880            0,
881            sighash_type,
882            1,
883            &locking_script,
884        )
885        .await
886        .expect("build unlock script");
887
888        let mut spend = Spend::new(SpendParams {
889            locking_script: locking_script.clone(),
890            unlocking_script: UnlockingScript::from_binary(&unlock_script.to_binary()),
891            source_txid,
892            source_output_index: 0,
893            source_satoshis: 1,
894            transaction_version: partial_tx.version,
895            transaction_lock_time: partial_tx.lock_time,
896            transaction_sequence: partial_tx.inputs[0].sequence,
897            other_inputs: vec![],
898            other_outputs: partial_tx.outputs.clone(),
899            input_index: 0,
900        });
901
902        let valid = spend
903            .validate()
904            .unwrap_or_else(|e| panic!("script engine errored on the revocation spend: {e:?}"));
905        assert!(
906            valid,
907            "the revocation unlocking script must satisfy the advertisement locking \
908             script; a signature over sha256(preimage) instead of sha256d(preimage) \
909             fails here"
910        );
911    }
912
913    // -----------------------------------------------------------------------
914    // Task 3 — device registration tests
915    // -----------------------------------------------------------------------
916
917    /// `RegisterDeviceRequest` serializes to camelCase JSON with correct field names.
918    #[test]
919    fn test_register_device_request_serializes_camelcase() {
920        use crate::types::RegisterDeviceRequest;
921        let req = RegisterDeviceRequest {
922            fcm_token: "abc".to_string(),
923            device_id: Some("d1".to_string()),
924            platform: None,
925        };
926        let json = serde_json::to_string(&req).unwrap();
927        assert!(
928            json.contains("\"fcmToken\":\"abc\""),
929            "fcmToken must be camelCase: {json}"
930        );
931        assert!(
932            json.contains("\"deviceId\":\"d1\""),
933            "deviceId must be camelCase: {json}"
934        );
935        assert!(
936            !json.contains("platform"),
937            "platform absent when None: {json}"
938        );
939        assert!(!json.contains("fcm_token"), "no snake_case leakage: {json}");
940        assert!(!json.contains("device_id"), "no snake_case leakage: {json}");
941    }
942
943    /// `ListDevicesResponse` deserializes a full server response including all 8 fields.
944    #[test]
945    fn test_list_devices_response_deserializes() {
946        use crate::types::ListDevicesResponse;
947        let raw = r#"{
948            "status": "success",
949            "devices": [{
950                "id": 1,
951                "deviceId": "d1",
952                "fcmToken": "tok",
953                "platform": "ios",
954                "active": true,
955                "createdAt": "2026-01-01",
956                "updatedAt": "2026-01-01",
957                "lastUsed": "2026-01-01"
958            }]
959        }"#;
960        let resp: ListDevicesResponse = serde_json::from_str(raw).unwrap();
961        assert_eq!(resp.status, "success");
962        assert_eq!(resp.devices.len(), 1);
963        let dev = &resp.devices[0];
964        assert_eq!(dev.id, Some(1));
965        assert_eq!(dev.device_id.as_deref(), Some("d1"));
966        assert_eq!(dev.fcm_token, "tok");
967        assert_eq!(dev.platform.as_deref(), Some("ios"));
968        assert_eq!(dev.active, Some(true));
969        assert!(dev.created_at.is_some());
970        assert!(dev.updated_at.is_some());
971        assert!(dev.last_used.is_some());
972    }
973
974    /// `RegisterDeviceResponse` deserializes `{status, message, deviceId}` correctly.
975    #[test]
976    fn test_register_device_response_deserializes() {
977        use crate::types::RegisterDeviceResponse;
978        let raw = r#"{"status":"success","message":"registered","deviceId":42}"#;
979        let resp: RegisterDeviceResponse = serde_json::from_str(raw).unwrap();
980        assert_eq!(resp.status, "success");
981        assert_eq!(resp.message.as_deref(), Some("registered"));
982        assert_eq!(resp.device_id, Some(42));
983    }
984
985    /// `register_device` method exists on `MessageBoxClient` — compile check.
986    ///
987    /// Verifies the method signature accepts fcm_token, device_id, platform.
988    #[allow(dead_code)]
989    fn register_device_compiles(client: &MessageBoxClient<ArcWallet>) {
990        let _fut = client.register_device("tok123", Some("dev1"), Some("ios"), None);
991    }
992
993    /// `list_registered_devices` method exists on `MessageBoxClient` — compile check.
994    #[allow(dead_code)]
995    fn list_registered_devices_compiles(client: &MessageBoxClient<ArcWallet>) {
996        let _fut = client.list_registered_devices(None);
997    }
998}