1use 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
41fn 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
65async 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 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
117impl<W: WalletInterface + Clone + 'static + Send + Sync> MessageBoxClient<W> {
122 pub async fn query_advertisements(
135 &self,
136 identity_key: Option<&str>,
137 host: Option<&str>,
138 ) -> Result<Vec<AdvertisementToken>, MessageBoxError> {
139 match self.query_advertisements_inner(identity_key, host).await {
141 Ok(tokens) => Ok(tokens),
142 Err(_) => Ok(vec![]),
143 }
144 }
145
146 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 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 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 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 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 pub async fn anoint_host(&self, host: &str) -> Result<String, MessageBoxError> {
269 let identity_key = self.get_identity_key().await?;
270
271
272 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 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, true, LockPosition::Before,
302 )
303 .await
304 .map_err(|e| MessageBoxError::Overlay(format!("PushDrop lock: {e}")))?;
305
306 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: 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 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 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 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 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 pub async fn revoke_host_advertisement(
444 &self,
445 token: &AdvertisementToken,
446 ) -> Result<String, MessageBoxError> {
447 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 let signable = create_result.signable_transaction.ok_or_else(|| {
479 MessageBoxError::Overlay("create_action returned no signable_transaction".into())
480 })?;
481
482 let partial_tx = Transaction::from_beef(&hex::encode(&signable.tx))
484 .map_err(|e| MessageBoxError::Overlay(format!("parse signable tx: {e}")))?;
485
486 let lock_script = LockingScript::from_hex(&token.locking_script)
488 .map_err(|e| MessageBoxError::Overlay(format!("parse locking script hex: {e}")))?;
489
490 let sighash_type: u32 = 0x41;
492
493 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 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 let signed_bytes = sign_result
529 .tx
530 .ok_or_else(|| MessageBoxError::Overlay("sign_action returned no tx".into()))?;
531
532 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#[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 #[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 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 #[tokio::test]
638 async fn test_resolve_host_falls_back_to_default() {
639 let client = make_client();
640 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 #[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 #[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; 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, true, 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 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 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 #[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 #[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 #[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 #[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 #[allow(dead_code)]
829 fn list_registered_devices_compiles(client: &MessageBoxClient<ArcWallet>) {
830 let _fut = client.list_registered_devices(None);
831 }
832}