use std::collections::HashMap;
use bsv::script::locking_script::LockingScript;
use bsv::script::op::Op;
use bsv::script::script::Script;
use bsv::script::script_chunk::ScriptChunk;
use bsv::script::templates::PushDrop;
use bsv::services::overlay_tools::{
LookupResolver, LookupResolverConfig, TopicBroadcaster, TopicBroadcasterConfig,
};
use bsv::services::overlay_tools::{LookupAnswer, LookupQuestion};
use bsv::transaction::Transaction;
use bsv::wallet::interfaces::{
CreateActionArgs, CreateActionInput, CreateActionOptions, CreateActionOutput, GetPublicKeyArgs,
SignActionArgs, SignActionSpend, WalletInterface,
};
use bsv::wallet::types::{
BooleanDefaultTrue, Counterparty, CounterpartyType, Protocol,
};
use crate::client::MessageBoxClient;
use crate::error::MessageBoxError;
use crate::types::{AdvertisementToken, ListDevicesResponse, RegisterDeviceRequest, RegisterDeviceResponse, RegisteredDevice};
fn make_data_push(data: &[u8]) -> ScriptChunk {
let len = data.len();
if len < 0x4c {
ScriptChunk::new_raw(len as u8, Some(data.to_vec()))
} else if len < 256 {
ScriptChunk::new_raw(Op::OpPushData1.to_byte(), Some(data.to_vec()))
} else if len < 65536 {
ScriptChunk::new_raw(Op::OpPushData2.to_byte(), Some(data.to_vec()))
} else {
ScriptChunk::new_raw(Op::OpPushData4.to_byte(), Some(data.to_vec()))
}
}
impl<W: WalletInterface + Clone + 'static + Send + Sync> MessageBoxClient<W> {
pub async fn query_advertisements(
&self,
identity_key: Option<&str>,
host: Option<&str>,
) -> Result<Vec<AdvertisementToken>, MessageBoxError> {
match self.query_advertisements_inner(identity_key, host).await {
Ok(tokens) => Ok(tokens),
Err(_) => Ok(vec![]),
}
}
async fn query_advertisements_inner(
&self,
identity_key: Option<&str>,
host: Option<&str>,
) -> Result<Vec<AdvertisementToken>, MessageBoxError> {
let ik = match identity_key {
Some(k) => k.to_string(),
None => self.get_identity_key().await?,
};
let mut query_obj = serde_json::json!({ "identityKey": ik });
if let Some(h) = host {
let trimmed = h.trim();
if !trimmed.is_empty() {
query_obj["host"] = serde_json::Value::String(trimmed.to_string());
}
}
let question = LookupQuestion {
service: "ls_messagebox".to_string(),
query: query_obj,
};
let mut host_overrides = std::collections::HashMap::new();
let tracker_urls = self.network.default_slap_trackers();
host_overrides.insert("ls_messagebox".to_string(), tracker_urls);
let resolver = LookupResolver::new(LookupResolverConfig {
network: self.network.clone(),
host_overrides,
..Default::default()
});
let answer = resolver
.query(&question, None)
.await
.map_err(|e| MessageBoxError::Overlay(e.to_string()))?;
let mut tokens = Vec::new();
if let LookupAnswer::OutputList { outputs } = answer {
for output in outputs {
let beef_hex = hex::encode(&output.beef);
let tx = match Transaction::from_beef(&beef_hex) {
Ok(t) => t,
Err(_) => continue,
};
let idx = output.output_index as usize;
if idx >= tx.outputs.len() {
continue;
}
let script = &tx.outputs[idx].locking_script;
let pd = match PushDrop::decode(script) {
Ok(t) => t,
Err(_) => continue,
};
if pd.fields.len() < 2 {
continue;
}
let host_url = match String::from_utf8(pd.fields[1].clone()) {
Ok(h) => h,
Err(_) => continue,
};
let txid = match tx.id() {
Ok(id) => id,
Err(_) => continue,
};
tokens.push(AdvertisementToken {
host: host_url,
txid,
output_index: output.output_index,
locking_script: script.to_hex(),
beef: output.beef,
});
}
}
Ok(tokens)
}
pub async fn resolve_host_for_recipient(
&self,
recipient: &str,
) -> Result<String, MessageBoxError> {
let ads = self.query_advertisements(Some(recipient), None).await?;
if let Some(ad) = ads.into_iter().next() {
Ok(ad.host)
} else {
Ok(self.host().to_string())
}
}
pub async fn anoint_host(&self, host: &str) -> Result<String, MessageBoxError> {
let identity_key = self.get_identity_key().await?;
let pk_result = self
.wallet()
.get_public_key(
GetPublicKeyArgs {
identity_key: false,
protocol_id: Some(Protocol {
security_level: 1,
protocol: "messagebox advertisement".to_string(),
}),
key_id: Some("1".to_string()),
counterparty: Some(Counterparty {
counterparty_type: CounterpartyType::Anyone,
public_key: None,
}),
privileged: false,
privileged_reason: None,
for_self: Some(true),
seek_permission: None,
},
self.originator(),
)
.await
.map_err(|e| MessageBoxError::Wallet(e.to_string()))?;
let pubkey_bytes = pk_result.public_key.to_der();
let id_key_bytes = hex::decode(&identity_key)
.map_err(|e| MessageBoxError::Overlay(format!("hex decode identity key: {e}")))?;
let host_bytes = host.as_bytes().to_vec();
let data_to_sign: Vec<u8> = [id_key_bytes.as_slice(), host_bytes.as_slice()].concat();
let sig_result = self
.wallet()
.create_signature(
bsv::wallet::interfaces::CreateSignatureArgs {
data: Some(data_to_sign),
hash_to_directly_sign: None,
protocol_id: Protocol {
security_level: 1,
protocol: "messagebox advertisement".to_string(),
},
key_id: "1".to_string(),
counterparty: Counterparty {
counterparty_type: CounterpartyType::Anyone,
public_key: None,
},
privileged: false,
privileged_reason: None,
seek_permission: None,
},
self.originator(),
)
.await
.map_err(|e| MessageBoxError::Overlay(format!("sign fields: {e}")))?;
let fields = vec![id_key_bytes, host_bytes, sig_result.signature];
use bsv::script::templates::ScriptTemplateLock;
let locking_script = {
let mut dummy_buf = [0u8; 32];
dummy_buf[31] = 1;
let dummy_key = bsv::primitives::private_key::PrivateKey::from_bytes(&dummy_buf)
.map_err(|e| MessageBoxError::Overlay(format!("dummy key: {e}")))?;
let pd = PushDrop::new(fields, dummy_key);
let script = pd.lock()
.map_err(|e| MessageBoxError::Overlay(format!("PushDrop lock: {e}")))?;
let mut chunks = script.chunks().to_vec();
chunks[0] = ScriptChunk::new_raw(
pubkey_bytes.len() as u8,
Some(pubkey_bytes),
);
LockingScript::from_script(Script::from_chunks(chunks))
};
let create_result = self
.wallet()
.create_action(
CreateActionArgs {
description: "Anoint host for overlay routing".to_string(),
input_beef: None,
inputs: vec![],
outputs: vec![CreateActionOutput {
locking_script: Some(locking_script.to_binary()),
satoshis: 1,
output_description: "Overlay advertisement output".to_string(),
basket: Some("overlay advertisements".to_string()),
custom_instructions: None,
tags: vec![],
}],
lock_time: None,
version: None,
labels: vec![],
options: Some(CreateActionOptions {
randomize_outputs: BooleanDefaultTrue(Some(false)),
accept_delayed_broadcast: BooleanDefaultTrue(Some(false)),
..Default::default()
}),
reference: None,
},
self.originator(),
)
.await
.map_err(|e| MessageBoxError::Wallet(e.to_string()))?;
let beef_bytes = create_result
.tx
.ok_or_else(|| MessageBoxError::Overlay("create_action returned no tx".into()))?;
let beef_hex = hex::encode(&beef_bytes);
let tx = Transaction::from_beef(&beef_hex)
.map_err(|e| MessageBoxError::Overlay(format!("parse BEEF: {e}")))?;
let txid = tx
.id()
.map_err(|e| MessageBoxError::Overlay(format!("tx.id(): {e}")))?;
let broadcaster = TopicBroadcaster::new(
vec!["tm_messagebox".to_string()],
TopicBroadcasterConfig {
network: self.network.clone(),
..Default::default()
},
LookupResolver::new(LookupResolverConfig {
network: self.network.clone(),
..Default::default()
}),
)
.map_err(|e| MessageBoxError::Overlay(format!("build broadcaster: {e}")))?;
broadcaster
.broadcast_beef(beef_bytes)
.await
.map_err(|e| MessageBoxError::Overlay(format!("broadcast failed: {}", e.description)))?;
Ok(txid)
}
pub async fn register_device(
&self,
fcm_token: &str,
device_id: Option<&str>,
platform: Option<&str>,
override_host: Option<&str>,
) -> Result<RegisterDeviceResponse, MessageBoxError> {
self.assert_initialized().await?;
let base = override_host.unwrap_or_else(|| self.host());
let request = RegisterDeviceRequest {
fcm_token: fcm_token.to_string(),
device_id: device_id.map(String::from),
platform: platform.map(String::from),
};
let body_bytes = serde_json::to_vec(&request)
.map_err(|e| MessageBoxError::Overlay(format!("serialize RegisterDeviceRequest: {e}")))?;
let url = format!("{base}/registerDevice");
let response = self.post_json(&url, body_bytes).await?;
let resp: RegisterDeviceResponse = serde_json::from_slice(&response.body)
.map_err(|e| MessageBoxError::Overlay(format!("deserialize RegisterDeviceResponse: {e}")))?;
Ok(resp)
}
pub async fn list_registered_devices(
&self,
override_host: Option<&str>,
) -> Result<Vec<RegisteredDevice>, MessageBoxError> {
self.assert_initialized().await?;
let base = override_host.unwrap_or_else(|| self.host());
let url = format!("{base}/devices");
let response = self.get_json(&url).await?;
let resp: ListDevicesResponse = serde_json::from_slice(&response.body)
.map_err(|e| MessageBoxError::Overlay(format!("deserialize ListDevicesResponse: {e}")))?;
Ok(resp.devices)
}
pub async fn revoke_host_advertisement(
&self,
token: &AdvertisementToken,
) -> Result<String, MessageBoxError> {
let create_result = self
.wallet()
.create_action(
CreateActionArgs {
description: "Revoke MessageBox host advertisement".to_string(),
input_beef: Some(token.beef.clone()),
inputs: vec![CreateActionInput {
outpoint: format!("{}.{}", token.txid, token.output_index),
input_description: "Revoking host advertisement token".to_string(),
unlocking_script: None,
unlocking_script_length: Some(73),
sequence_number: None,
}],
outputs: vec![],
lock_time: None,
version: None,
labels: vec![],
options: Some(CreateActionOptions {
accept_delayed_broadcast: BooleanDefaultTrue(Some(false)),
..Default::default()
}),
reference: None,
},
self.originator(),
)
.await
.map_err(|e| MessageBoxError::Wallet(e.to_string()))?;
let signable = create_result.signable_transaction.ok_or_else(|| {
MessageBoxError::Overlay("create_action returned no signable_transaction".into())
})?;
let partial_tx = Transaction::from_beef(&hex::encode(&signable.tx))
.map_err(|e| MessageBoxError::Overlay(format!("parse signable tx: {e}")))?;
let lock_script = LockingScript::from_hex(&token.locking_script)
.map_err(|e| MessageBoxError::Overlay(format!("parse locking script hex: {e}")))?;
let sighash_type: u32 = 0x41;
let preimage = partial_tx
.sighash_preimage(0, sighash_type, 1, &lock_script)
.map_err(|e| MessageBoxError::Overlay(format!("sighash_preimage: {e}")))?;
let sig_result = self
.wallet()
.create_signature(
bsv::wallet::interfaces::CreateSignatureArgs {
protocol_id: Protocol {
security_level: 1,
protocol: "messagebox advertisement".to_string(),
},
key_id: "1".to_string(),
counterparty: Counterparty {
counterparty_type: CounterpartyType::Anyone,
public_key: None,
},
data: Some(preimage),
hash_to_directly_sign: None,
privileged: false,
privileged_reason: None,
seek_permission: None,
},
self.originator(),
)
.await
.map_err(|e| MessageBoxError::Wallet(e.to_string()))?;
let mut sig_bytes = sig_result.signature;
sig_bytes.push(sighash_type as u8);
let unlock_chunks = vec![make_data_push(&sig_bytes)];
let unlock_script = Script::from_chunks(unlock_chunks);
let sign_result = self
.wallet()
.sign_action(
SignActionArgs {
reference: signable.reference,
spends: HashMap::from([(
0u32,
SignActionSpend {
unlocking_script: unlock_script.to_binary(),
sequence_number: None,
},
)]),
options: None,
},
self.originator(),
)
.await
.map_err(|e| MessageBoxError::Wallet(e.to_string()))?;
let signed_bytes = sign_result
.tx
.ok_or_else(|| MessageBoxError::Overlay("sign_action returned no tx".into()))?;
let signed_tx = Transaction::from_beef(&hex::encode(&signed_bytes))
.map_err(|e| MessageBoxError::Overlay(format!("parse signed tx: {e}")))?;
let txid = signed_tx
.id()
.map_err(|e| MessageBoxError::Overlay(format!("signed_tx.id(): {e}")))?;
let broadcaster = TopicBroadcaster::new(
vec!["tm_messagebox".to_string()],
TopicBroadcasterConfig {
network: self.network.clone(),
..Default::default()
},
LookupResolver::new(LookupResolverConfig {
network: self.network.clone(),
..Default::default()
}),
)
.map_err(|e| MessageBoxError::Overlay(format!("build broadcaster: {e}")))?;
broadcaster
.broadcast_beef(signed_bytes)
.await
.map_err(|e| MessageBoxError::Overlay(format!("broadcast failed: {}", e.description)))?;
Ok(txid)
}
}
#[cfg(test)]
mod tests {
use super::*;
use bsv::primitives::private_key::PrivateKey;
use bsv::services::overlay_tools::Network;
use bsv::wallet::error::WalletError;
use bsv::wallet::interfaces::*;
use bsv::wallet::proto_wallet::ProtoWallet;
use std::sync::Arc;
#[derive(Clone)]
struct ArcWallet(Arc<ProtoWallet>);
impl ArcWallet {
fn new() -> Self {
let key = PrivateKey::from_random().expect("random key");
ArcWallet(Arc::new(ProtoWallet::new(key)))
}
}
#[async_trait::async_trait]
impl WalletInterface for ArcWallet {
async fn create_action(&self, args: CreateActionArgs, orig: Option<&str>) -> Result<CreateActionResult, WalletError> { self.0.create_action(args, orig).await }
async fn sign_action(&self, args: SignActionArgs, orig: Option<&str>) -> Result<SignActionResult, WalletError> { self.0.sign_action(args, orig).await }
async fn abort_action(&self, args: AbortActionArgs, orig: Option<&str>) -> Result<AbortActionResult, WalletError> { self.0.abort_action(args, orig).await }
async fn list_actions(&self, args: ListActionsArgs, orig: Option<&str>) -> Result<ListActionsResult, WalletError> { self.0.list_actions(args, orig).await }
async fn internalize_action(&self, args: InternalizeActionArgs, orig: Option<&str>) -> Result<InternalizeActionResult, WalletError> { self.0.internalize_action(args, orig).await }
async fn list_outputs(&self, args: ListOutputsArgs, orig: Option<&str>) -> Result<ListOutputsResult, WalletError> { self.0.list_outputs(args, orig).await }
async fn relinquish_output(&self, args: RelinquishOutputArgs, orig: Option<&str>) -> Result<RelinquishOutputResult, WalletError> { self.0.relinquish_output(args, orig).await }
async fn get_public_key(&self, args: GetPublicKeyArgs, orig: Option<&str>) -> Result<GetPublicKeyResult, WalletError> { self.0.get_public_key(args, orig).await }
async fn reveal_counterparty_key_linkage(&self, args: RevealCounterpartyKeyLinkageArgs, orig: Option<&str>) -> Result<RevealCounterpartyKeyLinkageResult, WalletError> { self.0.reveal_counterparty_key_linkage(args, orig).await }
async fn reveal_specific_key_linkage(&self, args: RevealSpecificKeyLinkageArgs, orig: Option<&str>) -> Result<RevealSpecificKeyLinkageResult, WalletError> { self.0.reveal_specific_key_linkage(args, orig).await }
async fn encrypt(&self, args: EncryptArgs, orig: Option<&str>) -> Result<EncryptResult, WalletError> { self.0.encrypt(args, orig).await }
async fn decrypt(&self, args: DecryptArgs, orig: Option<&str>) -> Result<DecryptResult, WalletError> { self.0.decrypt(args, orig).await }
async fn create_hmac(&self, args: CreateHmacArgs, orig: Option<&str>) -> Result<CreateHmacResult, WalletError> { self.0.create_hmac(args, orig).await }
async fn verify_hmac(&self, args: VerifyHmacArgs, orig: Option<&str>) -> Result<VerifyHmacResult, WalletError> { self.0.verify_hmac(args, orig).await }
async fn create_signature(&self, args: CreateSignatureArgs, orig: Option<&str>) -> Result<CreateSignatureResult, WalletError> { self.0.create_signature(args, orig).await }
async fn verify_signature(&self, args: VerifySignatureArgs, orig: Option<&str>) -> Result<VerifySignatureResult, WalletError> { self.0.verify_signature(args, orig).await }
async fn acquire_certificate(&self, args: AcquireCertificateArgs, orig: Option<&str>) -> Result<Certificate, WalletError> { self.0.acquire_certificate(args, orig).await }
async fn list_certificates(&self, args: ListCertificatesArgs, orig: Option<&str>) -> Result<ListCertificatesResult, WalletError> { self.0.list_certificates(args, orig).await }
async fn prove_certificate(&self, args: ProveCertificateArgs, orig: Option<&str>) -> Result<ProveCertificateResult, WalletError> { self.0.prove_certificate(args, orig).await }
async fn relinquish_certificate(&self, args: RelinquishCertificateArgs, orig: Option<&str>) -> Result<RelinquishCertificateResult, WalletError> { self.0.relinquish_certificate(args, orig).await }
async fn discover_by_identity_key(&self, args: DiscoverByIdentityKeyArgs, orig: Option<&str>) -> Result<DiscoverCertificatesResult, WalletError> { self.0.discover_by_identity_key(args, orig).await }
async fn discover_by_attributes(&self, args: DiscoverByAttributesArgs, orig: Option<&str>) -> Result<DiscoverCertificatesResult, WalletError> { self.0.discover_by_attributes(args, orig).await }
async fn is_authenticated(&self, orig: Option<&str>) -> Result<AuthenticatedResult, WalletError> { self.0.is_authenticated(orig).await }
async fn wait_for_authentication(&self, orig: Option<&str>) -> Result<AuthenticatedResult, WalletError> { self.0.wait_for_authentication(orig).await }
async fn get_height(&self, orig: Option<&str>) -> Result<GetHeightResult, WalletError> { self.0.get_height(orig).await }
async fn get_header_for_height(&self, args: GetHeaderArgs, orig: Option<&str>) -> Result<GetHeaderResult, WalletError> { self.0.get_header_for_height(args, orig).await }
async fn get_network(&self, orig: Option<&str>) -> Result<GetNetworkResult, WalletError> { self.0.get_network(orig).await }
async fn get_version(&self, orig: Option<&str>) -> Result<GetVersionResult, WalletError> { self.0.get_version(orig).await }
}
fn make_client() -> MessageBoxClient<ArcWallet> {
MessageBoxClient::new(
"https://example.com".to_string(),
ArcWallet::new(),
None,
Network::Mainnet,
)
}
#[tokio::test]
async fn test_resolve_host_falls_back_to_default() {
let client = make_client();
let host = client
.resolve_host_for_recipient("03deadbeef")
.await
.expect("should not error");
assert_eq!(host, "https://example.com", "must fall back to self.host");
}
#[test]
fn test_revoke_host_args_correct() {
let txid = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab";
let output_index: u32 = 0;
let outpoint = format!("{txid}.{output_index}");
assert_eq!(
outpoint,
"abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab.0"
);
}
#[test]
fn test_register_device_request_serializes_camelcase() {
use crate::types::RegisterDeviceRequest;
let req = RegisterDeviceRequest {
fcm_token: "abc".to_string(),
device_id: Some("d1".to_string()),
platform: None,
};
let json = serde_json::to_string(&req).unwrap();
assert!(json.contains("\"fcmToken\":\"abc\""), "fcmToken must be camelCase: {json}");
assert!(json.contains("\"deviceId\":\"d1\""), "deviceId must be camelCase: {json}");
assert!(!json.contains("platform"), "platform absent when None: {json}");
assert!(!json.contains("fcm_token"), "no snake_case leakage: {json}");
assert!(!json.contains("device_id"), "no snake_case leakage: {json}");
}
#[test]
fn test_list_devices_response_deserializes() {
use crate::types::ListDevicesResponse;
let raw = r#"{
"status": "success",
"devices": [{
"id": 1,
"deviceId": "d1",
"fcmToken": "tok",
"platform": "ios",
"active": true,
"createdAt": "2026-01-01",
"updatedAt": "2026-01-01",
"lastUsed": "2026-01-01"
}]
}"#;
let resp: ListDevicesResponse = serde_json::from_str(raw).unwrap();
assert_eq!(resp.status, "success");
assert_eq!(resp.devices.len(), 1);
let dev = &resp.devices[0];
assert_eq!(dev.id, Some(1));
assert_eq!(dev.device_id.as_deref(), Some("d1"));
assert_eq!(dev.fcm_token, "tok");
assert_eq!(dev.platform.as_deref(), Some("ios"));
assert_eq!(dev.active, Some(true));
assert!(dev.created_at.is_some());
assert!(dev.updated_at.is_some());
assert!(dev.last_used.is_some());
}
#[test]
fn test_register_device_response_deserializes() {
use crate::types::RegisterDeviceResponse;
let raw = r#"{"status":"success","message":"registered","deviceId":42}"#;
let resp: RegisterDeviceResponse = serde_json::from_str(raw).unwrap();
assert_eq!(resp.status, "success");
assert_eq!(resp.message.as_deref(), Some("registered"));
assert_eq!(resp.device_id, Some(42));
}
#[allow(dead_code)]
fn register_device_compiles(client: &MessageBoxClient<ArcWallet>) {
let _fut = client.register_device("tok123", Some("dev1"), Some("ios"), None);
}
#[allow(dead_code)]
fn list_registered_devices_compiles(client: &MessageBoxClient<ArcWallet>) {
let _fut = client.list_registered_devices(None);
}
}