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::{
25    LookupResolver, LookupResolverConfig, TopicBroadcaster, TopicBroadcasterConfig,
26};
27use bsv::services::overlay_tools::{LookupAnswer, LookupQuestion};
28use bsv::transaction::Transaction;
29use bsv::wallet::interfaces::{
30    CreateActionArgs, CreateActionInput, CreateActionOptions, CreateActionOutput,
31    SignActionArgs, SignActionSpend, WalletInterface,
32};
33use bsv::wallet::types::{
34    BooleanDefaultTrue, Counterparty, CounterpartyType, Protocol,
35};
36
37use crate::client::MessageBoxClient;
38use crate::error::MessageBoxError;
39use crate::types::{AdvertisementToken, ListDevicesResponse, RegisterDeviceRequest, RegisterDeviceResponse, RegisteredDevice};
40
41// ---------------------------------------------------------------------------
42// Standalone helpers
43// ---------------------------------------------------------------------------
44
45/// Build a correct data-push chunk for an arbitrary-length byte slice.
46///
47/// Uses the shortest possible push opcode per Bitcoin script encoding rules:
48/// - len < 0x4c: opcode IS the length (direct push)
49/// - len < 256: OP_PUSHDATA1 prefix
50/// - len < 65536: OP_PUSHDATA2 prefix
51/// - else: OP_PUSHDATA4 prefix
52fn make_data_push(data: &[u8]) -> ScriptChunk {
53    let len = data.len();
54    if len < 0x4c {
55        ScriptChunk::new_raw(len as u8, Some(data.to_vec()))
56    } else if len < 256 {
57        ScriptChunk::new_raw(Op::OpPushData1.to_byte(), Some(data.to_vec()))
58    } else if len < 65536 {
59        ScriptChunk::new_raw(Op::OpPushData2.to_byte(), Some(data.to_vec()))
60    } else {
61        ScriptChunk::new_raw(Op::OpPushData4.to_byte(), Some(data.to_vec()))
62    }
63}
64
65/// Build the unlocking script that spends a `messagebox advertisement` PushDrop
66/// output: a single data push of `<DER signature || sighash byte>`.
67///
68/// The digest convention is the load-bearing part. `create_signature` hashes its
69/// `data` with SHA-256 exactly ONCE and signs that (`ProtoWallet::create_signature_sync`),
70/// while a BSV sighash is `sha256d(preimage)` — so the caller supplies the FIRST
71/// hash and the wallet applies the second. Handing over the raw preimage signs
72/// `sha256(preimage)`, which no script engine will accept. Same convention as the
73/// SDK's own `PushDrop::unlock` and ts-sdk's `PushDrop.unlock`.
74async fn build_advertisement_unlock_script<W: WalletInterface + ?Sized>(
75    wallet: &W,
76    originator: Option<&str>,
77    partial_tx: &Transaction,
78    input_index: usize,
79    sighash_type: u32,
80    source_satoshis: u64,
81    lock_script: &LockingScript,
82) -> Result<Script, MessageBoxError> {
83    let preimage = partial_tx
84        .sighash_preimage(input_index, sighash_type, source_satoshis, lock_script)
85        .map_err(|e| MessageBoxError::Overlay(format!("sighash_preimage: {e}")))?;
86
87    let sig_result = wallet
88        .create_signature(
89            bsv::wallet::interfaces::CreateSignatureArgs {
90                protocol_id: Protocol {
91                    security_level: 1,
92                    protocol: "messagebox advertisement".to_string(),
93                },
94                key_id: "1".to_string(),
95                counterparty: Counterparty {
96                    counterparty_type: CounterpartyType::Anyone,
97                    public_key: None,
98                },
99                data: Some(sha256(&preimage).to_vec()),
100                hash_to_directly_sign: None,
101                privileged: false,
102                privileged_reason: None,
103                seek_permission: None,
104            },
105            originator,
106        )
107        .await
108        .map_err(|e| MessageBoxError::Wallet(e.to_string()))?;
109
110    // One data push of <sig_DER + sighash_byte>.
111    let mut sig_bytes = sig_result.signature;
112    sig_bytes.push(sighash_type as u8);
113    Ok(Script::from_chunks(vec![make_data_push(&sig_bytes)]))
114}
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
272        // fields[0] = raw identity key bytes (hex-decoded per Pitfall 3)
273        let id_key_bytes = hex::decode(&identity_key)
274            .map_err(|e| MessageBoxError::Overlay(format!("hex decode identity key: {e}")))?;
275        let host_bytes = host.as_bytes().to_vec();
276
277        // bsv-sdk 0.3: PushDrop is wallet-driven, so it derives the locking key and
278        // appends the signature field itself — with the SAME (protocol, keyID,
279        // counterparty, forSelf) triple used for the pubkey derivation above.
280        //
281        // This replaces a workaround that the old PrivateKey-based API forced:
282        // sign the fields by hand, construct PushDrop with a DUMMY PrivateKey(1)
283        // just to get the script shape, then splice chunk[0] to swap the dummy
284        // pubkey for the wallet-derived one. That was script surgery standing in
285        // for an API that couldn't express "lock to a key the wallet derives".
286        // Same bytes, none of the surgery.
287        let locking_script = PushDrop::new(self.wallet(), self.originator().map(String::from))
288            .lock(
289                vec![id_key_bytes, host_bytes],
290                Protocol {
291                    security_level: 1,
292                    protocol: "messagebox advertisement".to_string(),
293                },
294                "1",
295                Counterparty {
296                    counterparty_type: CounterpartyType::Anyone,
297                    public_key: None,
298                },
299                true, // for_self — matches the get_public_key derivation above
300                true, // include_signature — TS/Go default; appends sig as field[2]
301                LockPosition::Before,
302            )
303            .await
304            .map_err(|e| MessageBoxError::Overlay(format!("PushDrop lock: {e}")))?;
305
306        // Create the overlay advertisement transaction
307        let create_result = self
308            .wallet()
309            .create_action(
310                CreateActionArgs {
311                    description: "Anoint host for overlay routing".to_string(),
312                    input_beef: None,
313                    inputs: vec![],
314                    outputs: vec![CreateActionOutput {
315                        locking_script: Some(locking_script.to_binary()),
316                        satoshis: 1,
317                        output_description: "Overlay advertisement output".to_string(),
318                        basket: Some("overlay advertisements".to_string()),
319                        custom_instructions: None,
320                        tags: vec![],
321                    }],
322                    lock_time: None,
323                    version: None,
324                    labels: vec![],
325                    options: Some(CreateActionOptions {
326                        // randomize_outputs: false — output_index 0 is stable
327                        randomize_outputs: BooleanDefaultTrue(Some(false)),
328                        accept_delayed_broadcast: BooleanDefaultTrue(Some(false)),
329                        ..Default::default()
330                    }),
331                    reference: None,
332                },
333                self.originator(),
334            )
335            .await
336            .map_err(|e| MessageBoxError::Wallet(e.to_string()))?;
337
338        // create_action returns BEEF bytes. Parse to Transaction only for
339        // the txid — broadcast the original BEEF bytes directly to avoid
340        // losing the source transaction chain.
341        let beef_bytes = create_result
342            .tx
343            .ok_or_else(|| MessageBoxError::Overlay("create_action returned no tx".into()))?;
344        let beef_hex = hex::encode(&beef_bytes);
345        let tx = Transaction::from_beef(&beef_hex)
346            .map_err(|e| MessageBoxError::Overlay(format!("parse BEEF: {e}")))?;
347        let txid = tx
348            .id()
349            .map_err(|e| MessageBoxError::Overlay(format!("tx.id(): {e}")))?;
350
351        // Broadcast the original BEEF bytes via TopicBroadcaster.
352        // We use broadcast_beef() to pass pre-built BEEF directly,
353        // avoiding the Transaction → to_beef() round-trip which loses
354        // source transactions.
355        let broadcaster = TopicBroadcaster::new(
356            vec!["tm_messagebox".to_string()],
357            TopicBroadcasterConfig {
358                network: self.network.clone(),
359                ..Default::default()
360            },
361            LookupResolver::new(LookupResolverConfig {
362                network: self.network.clone(),
363                ..Default::default()
364            }),
365        )
366        .map_err(|e| MessageBoxError::Overlay(format!("build broadcaster: {e}")))?;
367
368        broadcaster
369            .broadcast_beef(beef_bytes)
370            .await
371            .map_err(|e| MessageBoxError::Overlay(format!("broadcast failed: {}", e.description)))?;
372
373        Ok(txid)
374    }
375
376    /// Register a device for FCM push notifications.
377    ///
378    /// POSTs `{"fcmToken": ..., "deviceId": ..., "platform": ...}` (camelCase) to
379    /// `{host}/registerDevice`. Returns `RegisterDeviceResponse { status, message, deviceId }`.
380    ///
381    /// TS parity: `registerDevice` returns the full response object including `deviceId`.
382    pub async fn register_device(
383        &self,
384        fcm_token: &str,
385        device_id: Option<&str>,
386        platform: Option<&str>,
387        override_host: Option<&str>,
388    ) -> Result<RegisterDeviceResponse, MessageBoxError> {
389        self.assert_initialized().await?;
390
391        let base = override_host.unwrap_or_else(|| self.host());
392        let request = RegisterDeviceRequest {
393            fcm_token: fcm_token.to_string(),
394            device_id: device_id.map(String::from),
395            platform: platform.map(String::from),
396        };
397
398        let body_bytes = serde_json::to_vec(&request)
399            .map_err(|e| MessageBoxError::Overlay(format!("serialize RegisterDeviceRequest: {e}")))?;
400
401        let url = format!("{base}/registerDevice");
402        let response = self.post_json(&url, body_bytes).await?;
403
404        let resp: RegisterDeviceResponse = serde_json::from_slice(&response.body)
405            .map_err(|e| MessageBoxError::Overlay(format!("deserialize RegisterDeviceResponse: {e}")))?;
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)
426            .map_err(|e| MessageBoxError::Overlay(format!("deserialize ListDevicesResponse: {e}")))?;
427
428        Ok(resp.devices)
429    }
430
431    /// Revoke an existing host advertisement by spending its UTXO.
432    ///
433    /// Two-step create+sign pattern:
434    /// 1. `create_action` with `input_beef` + input pointing to the advertisement UTXO.
435    ///    Returns a signable transaction with a `reference` for the sign step.
436    /// 2. Derive sighash preimage from the partial transaction.
437    /// 3. `create_signature` over `sha256(preimage)` with the advertisement protocol
438    ///    (the wallet applies the second hash) 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        // Steps 4+5: sign the sighash through the wallet and wrap the DER signature
494        // in the unlocking script. `build_advertisement_unlock_script` owns the
495        // digest convention (it pre-hashes the preimage — see its docs).
496        let unlock_script = build_advertisement_unlock_script(
497            self.wallet(),
498            self.originator(),
499            &partial_tx,
500            0,
501            sighash_type,
502            1,
503            &lock_script,
504        )
505        .await?;
506
507        // Step 6: sign_action finalizes the transaction with our unlock script
508        let sign_result = self
509            .wallet()
510            .sign_action(
511                SignActionArgs {
512                    reference: signable.reference,
513                    spends: HashMap::from([(
514                        0u32,
515                        SignActionSpend {
516                            unlocking_script: unlock_script.to_binary(),
517                            sequence_number: None,
518                        },
519                    )]),
520                    options: None,
521                },
522                self.originator(),
523            )
524            .await
525            .map_err(|e| MessageBoxError::Wallet(e.to_string()))?;
526
527        // Step 7: Broadcast signed BEEF directly (avoids from_beef → to_beef round-trip)
528        let signed_bytes = sign_result
529            .tx
530            .ok_or_else(|| MessageBoxError::Overlay("sign_action returned no tx".into()))?;
531
532        // Parse only for the txid — broadcast the original BEEF bytes.
533        let signed_tx = Transaction::from_beef(&hex::encode(&signed_bytes))
534            .map_err(|e| MessageBoxError::Overlay(format!("parse signed tx: {e}")))?;
535        let txid = signed_tx
536            .id()
537            .map_err(|e| MessageBoxError::Overlay(format!("signed_tx.id(): {e}")))?;
538
539        let broadcaster = TopicBroadcaster::new(
540            vec!["tm_messagebox".to_string()],
541            TopicBroadcasterConfig {
542                network: self.network.clone(),
543                ..Default::default()
544            },
545            LookupResolver::new(LookupResolverConfig {
546                network: self.network.clone(),
547                ..Default::default()
548            }),
549        )
550        .map_err(|e| MessageBoxError::Overlay(format!("build broadcaster: {e}")))?;
551
552        broadcaster
553            .broadcast_beef(signed_bytes)
554            .await
555            .map_err(|e| MessageBoxError::Overlay(format!("broadcast failed: {}", e.description)))?;
556
557        Ok(txid)
558    }
559}
560
561// ---------------------------------------------------------------------------
562// Tests
563// ---------------------------------------------------------------------------
564
565#[cfg(test)]
566mod tests {
567    use super::*;
568    use bsv::primitives::private_key::PrivateKey;
569    use bsv::services::overlay_tools::Network;
570    use bsv::wallet::error::WalletError;
571    use bsv::wallet::interfaces::*;
572    use bsv::wallet::proto_wallet::ProtoWallet;
573    use std::sync::Arc;
574
575    /// Test helper: thin Arc wrapper so ProtoWallet satisfies the Clone bound.
576    #[derive(Clone)]
577    struct ArcWallet(Arc<ProtoWallet>);
578
579    impl ArcWallet {
580        fn new() -> Self {
581            let key = PrivateKey::from_random().expect("random key");
582            ArcWallet(Arc::new(ProtoWallet::new(key)))
583        }
584
585        /// Fixed key, so a script-validation failure reproduces byte-for-byte.
586        fn deterministic() -> Self {
587            let key = PrivateKey::from_bytes(&[0x42u8; 32]).expect("fixed key");
588            ArcWallet(Arc::new(ProtoWallet::new(key)))
589        }
590    }
591
592    #[async_trait::async_trait]
593    impl WalletInterface for ArcWallet {
594        async fn create_action(&self, args: CreateActionArgs, orig: Option<&str>) -> Result<CreateActionResult, WalletError> { self.0.create_action(args, orig).await }
595        async fn sign_action(&self, args: SignActionArgs, orig: Option<&str>) -> Result<SignActionResult, WalletError> { self.0.sign_action(args, orig).await }
596        async fn abort_action(&self, args: AbortActionArgs, orig: Option<&str>) -> Result<AbortActionResult, WalletError> { self.0.abort_action(args, orig).await }
597        async fn list_actions(&self, args: ListActionsArgs, orig: Option<&str>) -> Result<ListActionsResult, WalletError> { self.0.list_actions(args, orig).await }
598        async fn internalize_action(&self, args: InternalizeActionArgs, orig: Option<&str>) -> Result<InternalizeActionResult, WalletError> { self.0.internalize_action(args, orig).await }
599        async fn list_outputs(&self, args: ListOutputsArgs, orig: Option<&str>) -> Result<ListOutputsResult, WalletError> { self.0.list_outputs(args, orig).await }
600        async fn relinquish_output(&self, args: RelinquishOutputArgs, orig: Option<&str>) -> Result<RelinquishOutputResult, WalletError> { self.0.relinquish_output(args, orig).await }
601        async fn get_public_key(&self, args: GetPublicKeyArgs, orig: Option<&str>) -> Result<GetPublicKeyResult, WalletError> { self.0.get_public_key(args, orig).await }
602        async fn reveal_counterparty_key_linkage(&self, args: RevealCounterpartyKeyLinkageArgs, orig: Option<&str>) -> Result<RevealCounterpartyKeyLinkageResult, WalletError> { self.0.reveal_counterparty_key_linkage(args, orig).await }
603        async fn reveal_specific_key_linkage(&self, args: RevealSpecificKeyLinkageArgs, orig: Option<&str>) -> Result<RevealSpecificKeyLinkageResult, WalletError> { self.0.reveal_specific_key_linkage(args, orig).await }
604        async fn encrypt(&self, args: EncryptArgs, orig: Option<&str>) -> Result<EncryptResult, WalletError> { self.0.encrypt(args, orig).await }
605        async fn decrypt(&self, args: DecryptArgs, orig: Option<&str>) -> Result<DecryptResult, WalletError> { self.0.decrypt(args, orig).await }
606        async fn create_hmac(&self, args: CreateHmacArgs, orig: Option<&str>) -> Result<CreateHmacResult, WalletError> { self.0.create_hmac(args, orig).await }
607        async fn verify_hmac(&self, args: VerifyHmacArgs, orig: Option<&str>) -> Result<VerifyHmacResult, WalletError> { self.0.verify_hmac(args, orig).await }
608        async fn create_signature(&self, args: CreateSignatureArgs, orig: Option<&str>) -> Result<CreateSignatureResult, WalletError> { self.0.create_signature(args, orig).await }
609        async fn verify_signature(&self, args: VerifySignatureArgs, orig: Option<&str>) -> Result<VerifySignatureResult, WalletError> { self.0.verify_signature(args, orig).await }
610        async fn acquire_certificate(&self, args: AcquireCertificateArgs, orig: Option<&str>) -> Result<Certificate, WalletError> { self.0.acquire_certificate(args, orig).await }
611        async fn list_certificates(&self, args: ListCertificatesArgs, orig: Option<&str>) -> Result<ListCertificatesResult, WalletError> { self.0.list_certificates(args, orig).await }
612        async fn prove_certificate(&self, args: ProveCertificateArgs, orig: Option<&str>) -> Result<ProveCertificateResult, WalletError> { self.0.prove_certificate(args, orig).await }
613        async fn relinquish_certificate(&self, args: RelinquishCertificateArgs, orig: Option<&str>) -> Result<RelinquishCertificateResult, WalletError> { self.0.relinquish_certificate(args, orig).await }
614        async fn discover_by_identity_key(&self, args: DiscoverByIdentityKeyArgs, orig: Option<&str>) -> Result<DiscoverCertificatesResult, WalletError> { self.0.discover_by_identity_key(args, orig).await }
615        async fn discover_by_attributes(&self, args: DiscoverByAttributesArgs, orig: Option<&str>) -> Result<DiscoverCertificatesResult, WalletError> { self.0.discover_by_attributes(args, orig).await }
616        async fn is_authenticated(&self, orig: Option<&str>) -> Result<AuthenticatedResult, WalletError> { self.0.is_authenticated(orig).await }
617        async fn wait_for_authentication(&self, orig: Option<&str>) -> Result<AuthenticatedResult, WalletError> { self.0.wait_for_authentication(orig).await }
618        async fn get_height(&self, orig: Option<&str>) -> Result<GetHeightResult, WalletError> { self.0.get_height(orig).await }
619        async fn get_header_for_height(&self, args: GetHeaderArgs, orig: Option<&str>) -> Result<GetHeaderResult, WalletError> { self.0.get_header_for_height(args, orig).await }
620        async fn get_network(&self, orig: Option<&str>) -> Result<GetNetworkResult, WalletError> { self.0.get_network(orig).await }
621        async fn get_version(&self, orig: Option<&str>) -> Result<GetVersionResult, WalletError> { self.0.get_version(orig).await }
622    }
623
624    fn make_client() -> MessageBoxClient<ArcWallet> {
625        MessageBoxClient::new(
626            "https://example.com".to_string(),
627            ArcWallet::new(),
628            None,
629            Network::Mainnet,
630        )
631    }
632
633    /// `resolve_host_for_recipient` falls back to `self.host` when overlay returns empty.
634    ///
635    /// With Network::Mainnet, `query_advertisements` will fail to reach SLAP trackers
636    /// and return an empty vec (TS parity: no error propagation). The fallback kicks in.
637    #[tokio::test]
638    async fn test_resolve_host_falls_back_to_default() {
639        let client = make_client();
640        // Overlay unreachable from unit test → empty vec → fall back to self.host
641        let host = client
642            .resolve_host_for_recipient("03deadbeef")
643            .await
644            .expect("should not error");
645        assert_eq!(host, "https://example.com", "must fall back to self.host");
646    }
647
648    /// revoke_host_advertisement builds an outpoint as "{txid}.{output_index}".
649    #[test]
650    fn test_revoke_host_args_correct() {
651        let txid = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab";
652        let output_index: u32 = 0;
653        let outpoint = format!("{txid}.{output_index}");
654        assert_eq!(
655            outpoint,
656            "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab.0"
657        );
658    }
659
660    /// The revocation unlocking script must actually SPEND the advertisement output.
661    ///
662    /// Runs the SDK script interpreter over (unlocking script, locking script) in the
663    /// real transaction context, so OP_CHECKSIG recomputes the BIP-143 sighash itself
664    /// and ECDSA-verifies our DER signature against `sha256d(preimage)` under the
665    /// PushDrop locking key. Nothing else in this crate ever executed the script, which
666    /// is exactly why signing the wrong digest (`sha256(preimage)`) survived — the
667    /// unlocking script was well-FORMED but not VALID. Handing `create_signature` the
668    /// raw preimage again turns this assertion red.
669    #[tokio::test]
670    async fn revocation_unlock_script_validates_against_the_advertisement_lock() {
671        use bsv::script::spend::{Spend, SpendParams};
672        use bsv::script::unlocking_script::UnlockingScript;
673        use bsv::transaction::{TransactionInput, TransactionOutput};
674
675        let wallet = ArcWallet::deterministic();
676        let sighash_type: u32 = 0x41; // SIGHASH_ALL | SIGHASH_FORKID
677
678        // The advertisement output, locked exactly as `anoint_host` locks it:
679        // same protocol/keyID/counterparty/for_self triple, so the key that
680        // `create_signature` derives is the counterpart of the locking key.
681        let locking_script = PushDrop::new(&wallet, None)
682            .lock(
683                vec![vec![0x02u8; 33], b"https://example.com".to_vec()],
684                Protocol {
685                    security_level: 1,
686                    protocol: "messagebox advertisement".to_string(),
687                },
688                "1",
689                Counterparty {
690                    counterparty_type: CounterpartyType::Anyone,
691                    public_key: None,
692                },
693                true, // for_self
694                true, // include_signature
695                LockPosition::Before,
696            )
697            .await
698            .expect("PushDrop lock");
699
700        let mut source = Transaction::new();
701        source.outputs.push(TransactionOutput {
702            satoshis: Some(1),
703            locking_script: locking_script.clone(),
704            change: false,
705        });
706        let source_txid = source.id().expect("source txid");
707
708        // The revocation shape `create_action` hands back: one input, no outputs.
709        let mut partial_tx = Transaction::new();
710        partial_tx.inputs.push(TransactionInput {
711            source_transaction: Some(Box::new(source)),
712            source_txid: Some(source_txid.clone()),
713            source_output_index: 0,
714            unlocking_script: None,
715            sequence: 0xFFFF_FFFF,
716        });
717
718        // The production path under test.
719        let unlock_script = build_advertisement_unlock_script(
720            &wallet,
721            None,
722            &partial_tx,
723            0,
724            sighash_type,
725            1,
726            &locking_script,
727        )
728        .await
729        .expect("build unlock script");
730
731        let mut spend = Spend::new(SpendParams {
732            locking_script: locking_script.clone(),
733            unlocking_script: UnlockingScript::from_binary(&unlock_script.to_binary()),
734            source_txid,
735            source_output_index: 0,
736            source_satoshis: 1,
737            transaction_version: partial_tx.version,
738            transaction_lock_time: partial_tx.lock_time,
739            transaction_sequence: partial_tx.inputs[0].sequence,
740            other_inputs: vec![],
741            other_outputs: partial_tx.outputs.clone(),
742            input_index: 0,
743        });
744
745        let valid = spend
746            .validate()
747            .unwrap_or_else(|e| panic!("script engine errored on the revocation spend: {e:?}"));
748        assert!(
749            valid,
750            "the revocation unlocking script must satisfy the advertisement locking \
751             script; a signature over sha256(preimage) instead of sha256d(preimage) \
752             fails here"
753        );
754    }
755
756    // -----------------------------------------------------------------------
757    // Task 3 — device registration tests
758    // -----------------------------------------------------------------------
759
760    /// `RegisterDeviceRequest` serializes to camelCase JSON with correct field names.
761    #[test]
762    fn test_register_device_request_serializes_camelcase() {
763        use crate::types::RegisterDeviceRequest;
764        let req = RegisterDeviceRequest {
765            fcm_token: "abc".to_string(),
766            device_id: Some("d1".to_string()),
767            platform: None,
768        };
769        let json = serde_json::to_string(&req).unwrap();
770        assert!(json.contains("\"fcmToken\":\"abc\""), "fcmToken must be camelCase: {json}");
771        assert!(json.contains("\"deviceId\":\"d1\""), "deviceId must be camelCase: {json}");
772        assert!(!json.contains("platform"), "platform absent when None: {json}");
773        assert!(!json.contains("fcm_token"), "no snake_case leakage: {json}");
774        assert!(!json.contains("device_id"), "no snake_case leakage: {json}");
775    }
776
777    /// `ListDevicesResponse` deserializes a full server response including all 8 fields.
778    #[test]
779    fn test_list_devices_response_deserializes() {
780        use crate::types::ListDevicesResponse;
781        let raw = r#"{
782            "status": "success",
783            "devices": [{
784                "id": 1,
785                "deviceId": "d1",
786                "fcmToken": "tok",
787                "platform": "ios",
788                "active": true,
789                "createdAt": "2026-01-01",
790                "updatedAt": "2026-01-01",
791                "lastUsed": "2026-01-01"
792            }]
793        }"#;
794        let resp: ListDevicesResponse = serde_json::from_str(raw).unwrap();
795        assert_eq!(resp.status, "success");
796        assert_eq!(resp.devices.len(), 1);
797        let dev = &resp.devices[0];
798        assert_eq!(dev.id, Some(1));
799        assert_eq!(dev.device_id.as_deref(), Some("d1"));
800        assert_eq!(dev.fcm_token, "tok");
801        assert_eq!(dev.platform.as_deref(), Some("ios"));
802        assert_eq!(dev.active, Some(true));
803        assert!(dev.created_at.is_some());
804        assert!(dev.updated_at.is_some());
805        assert!(dev.last_used.is_some());
806    }
807
808    /// `RegisterDeviceResponse` deserializes `{status, message, deviceId}` correctly.
809    #[test]
810    fn test_register_device_response_deserializes() {
811        use crate::types::RegisterDeviceResponse;
812        let raw = r#"{"status":"success","message":"registered","deviceId":42}"#;
813        let resp: RegisterDeviceResponse = serde_json::from_str(raw).unwrap();
814        assert_eq!(resp.status, "success");
815        assert_eq!(resp.message.as_deref(), Some("registered"));
816        assert_eq!(resp.device_id, Some(42));
817    }
818
819    /// `register_device` method exists on `MessageBoxClient` — compile check.
820    ///
821    /// Verifies the method signature accepts fcm_token, device_id, platform.
822    #[allow(dead_code)]
823    fn register_device_compiles(client: &MessageBoxClient<ArcWallet>) {
824        let _fut = client.register_device("tok123", Some("dev1"), Some("ios"), None);
825    }
826
827    /// `list_registered_devices` method exists on `MessageBoxClient` — compile check.
828    #[allow(dead_code)]
829    fn list_registered_devices_compiles(client: &MessageBoxClient<ArcWallet>) {
830        let _fut = client.list_registered_devices(None);
831    }
832}