1use serde::{Deserialize, Serialize};
25#[cfg(any(target_arch = "wasm32", test))]
26use std::future::Future;
27use wasm_bindgen::prelude::*;
28
29#[macro_use]
30mod logging;
31
32pub mod builder;
34pub mod crypto;
35pub mod error;
36pub mod history;
38pub mod keys;
39pub mod listing;
41pub mod listing_nostr;
43#[cfg(not(target_arch = "wasm32"))]
45pub mod listing_relay;
46pub mod offer;
48pub mod offer_accept;
50pub mod offer_create;
52pub mod offer_nostr;
54#[cfg(not(target_arch = "wasm32"))]
56pub mod offer_relay;
57pub mod ordinals;
59pub mod sign_intent;
61
62pub use builder::{
64 Account, AddressScheme, CreatePsbtRequest, CreatePsbtTransportRequest, DerivationMode,
65 DiscoveryAccountPlan, DiscoveryContext, PaymentAddressType, ProfileMode, ScanPolicy, Seed64,
66 SignOptions, SyncRequestType, SyncSleeper, WalletBuilder, WalletKind, ZincBalance,
67 ZincPersistence, ZincSyncRequest, ZincWallet,
68};
69pub use error::{ZincError, ZincResult};
70pub use history::TxItem;
71pub use keys::{taproot_descriptors, DescriptorPair, ZincMnemonic};
72pub use listing::{
73 create_listing, create_listing_purchase, finalize_listing_purchase, finalize_listing_sale,
74 passthrough_script_pubkey, passthrough_tapscript, prepare_listing_sale_signature,
75 sign_listing_coordinator_psbt, sign_listing_sale_psbt, CreateListingPurchaseRequest,
76 CreateListingPurchaseResultV1, CreateListingRequest, CreateListingResultV1,
77 FinalizeListingPurchaseRequest, FinalizeListingPurchaseResultV1, FinalizedListingSaleResultV1,
78 ListingBuyerFundingInput, ListingEnvelopeV1, ListingSaleSigningPlanV1, LISTING_SALE_SIGHASH_U8,
79};
80pub use listing_nostr::{NostrListingEvent, LISTING_EVENT_KIND};
81#[cfg(not(target_arch = "wasm32"))]
82pub use listing_relay::{
83 ListingRelayPublishResult, ListingRelayQueryOptions, NostrListingRelayClient,
84};
85pub use offer::OfferEnvelopeV1;
86pub use offer_accept::{prepare_offer_acceptance, OfferAcceptancePlanV1};
87pub use offer_create::{CreateOfferRequest, OfferCreateResultV1};
88pub use offer_nostr::{NostrOfferEvent, OFFER_EVENT_KIND};
89#[cfg(not(target_arch = "wasm32"))]
90pub use offer_relay::{NostrRelayClient, RelayPublishResult, RelayQueryOptions};
91pub use ordinals::client::OrdClient;
92pub use ordinals::types::{Inscription, RuneBalance, Satpoint};
93pub use sign_intent::{
94 build_pairing_transport_event, build_signed_pairing_ack, build_signed_pairing_ack_with_granted,
95 build_signed_pairing_complete_receipt, build_signed_sign_intent_approved_receipt,
96 build_signed_sign_intent_rejection_receipt, decode_pairing_ack_envelope_event,
97 decode_pairing_ack_envelope_event_with_secret,
98 decode_pairing_transport_event_content_with_secret,
99 decode_signed_pairing_complete_receipt_event,
100 decode_signed_pairing_complete_receipt_event_with_secret, decode_signed_sign_intent_event,
101 decode_signed_sign_intent_event_with_secret, decode_signed_sign_intent_receipt_event,
102 decode_signed_sign_intent_receipt_event_with_secret, decrypt_pairing_transport_content,
103 encrypt_pairing_transport_content, generate_secret_key_hex, pairing_tag_hash_hex,
104 pairing_transport_tags, pubkey_hex_from_secret_key, verify_pairing_approval,
105 verify_sign_seller_input_scope, verify_sign_seller_input_scope_json, BuildBuyerOfferIntentV1,
106 CapabilityPolicyV1, NostrTransportEventV1, PairingAckDecisionV1, PairingAckEnvelopeV1,
107 PairingAckV1, PairingCompleteReceiptStatusV1, PairingCompleteReceiptV1, PairingLinkApprovalV1,
108 PairingRequestV1, SignIntentActionV1, SignIntentPayloadV1, SignIntentReceiptStatusV1,
109 SignIntentReceiptV1, SignIntentV1, SignSellerInputIntentV1, SignSellerInputScopeV1,
110 SignedPairingAckV1, SignedPairingCompleteReceiptV1, SignedPairingRequestV1,
111 SignedSignIntentReceiptV1, SignedSignIntentV1, NOSTR_PAIRING_ACK_TYPE_TAG_VALUE,
112 NOSTR_PAIRING_COMPLETE_RECEIPT_TYPE_TAG_VALUE, NOSTR_SIGN_INTENT_APP_TAG_VALUE,
113 NOSTR_SIGN_INTENT_RECEIPT_TYPE_TAG_VALUE, NOSTR_SIGN_INTENT_TYPE_TAG_VALUE, NOSTR_TAG_APP_KEY,
114 NOSTR_TAG_PAIRING_HASH_KEY, NOSTR_TAG_RECIPIENT_PUBKEY_KEY, NOSTR_TAG_TYPE_KEY,
115 PAIRING_TRANSPORT_EVENT_KIND,
116};
117
118pub use bdk_wallet::bitcoin::Network;
120use bdk_wallet::KeychainKind;
121
122#[doc(hidden)]
127pub struct WalletResult {
129 pub phrase: String,
131 pub words: Vec<String>,
133}
134
135#[doc(hidden)]
136pub fn generate_wallet_internal(word_count: u8) -> Result<WalletResult, ZincError> {
138 let mnemonic = ZincMnemonic::generate(word_count)?;
139 Ok(WalletResult {
140 phrase: mnemonic.phrase(),
141 words: mnemonic.words(),
142 })
143}
144
145#[doc(hidden)]
146pub fn validate_mnemonic_internal(phrase: &str) -> bool {
148 ZincMnemonic::parse(phrase).is_ok()
149}
150
151#[doc(hidden)]
152pub fn derive_address_internal(phrase: &str, network: Network) -> Result<String, ZincError> {
154 let mnemonic = ZincMnemonic::parse(phrase)?;
155 let descriptors = crate::keys::taproot_descriptors(&mnemonic, network)?;
156
157 let wallet = bdk_wallet::Wallet::create(
158 descriptors.external.to_string(),
159 descriptors.internal.to_string(),
160 )
161 .network(network)
162 .create_wallet_no_persist()
163 .map_err(|e| ZincError::BdkError(e.to_string()))?;
164
165 let address = wallet.peek_address(KeychainKind::External, 0);
166 Ok(address.address.to_string())
167}
168
169#[doc(hidden)]
170pub fn encrypt_wallet_internal(mnemonic: &str, password: &str) -> Result<String, ZincError> {
172 let m = ZincMnemonic::parse(mnemonic)?;
173 let encrypted = crypto::encrypt_seed(m.phrase().as_bytes(), password)?;
174 serde_json::to_string(&encrypted).map_err(|e| ZincError::SerializationError(e.to_string()))
175}
176
177#[doc(hidden)]
178pub fn decrypt_wallet_internal(
180 encrypted_json: &str,
181 password: &str,
182) -> Result<WalletResult, ZincError> {
183 let encrypted: crypto::EncryptedWallet = serde_json::from_str(encrypted_json)
184 .map_err(|e| ZincError::SerializationError(e.to_string()))?;
185
186 let plaintext = crypto::decrypt_seed(&encrypted, password)?;
187
188 let phrase = zeroize::Zeroizing::new(
189 String::from_utf8(plaintext.to_vec())
190 .map_err(|e| ZincError::SerializationError(format!("Invalid UTF-8: {e}")))?,
191 );
192
193 let mnemonic = ZincMnemonic::parse(&phrase)?;
194
195 Ok(WalletResult {
196 phrase: mnemonic.phrase(),
197 words: mnemonic.words(),
198 })
199}
200
201#[doc(hidden)]
202pub fn encrypt_secret_internal(secret: &str, password: &str) -> Result<String, ZincError> {
204 let encrypted = crypto::encrypt_seed(secret.as_bytes(), password)?;
205 serde_json::to_string(&encrypted).map_err(|e| ZincError::SerializationError(e.to_string()))
206}
207
208#[doc(hidden)]
209pub fn decrypt_secret_internal(encrypted_json: &str, password: &str) -> Result<String, ZincError> {
211 let encrypted: crypto::EncryptedWallet = serde_json::from_str(encrypted_json)
212 .map_err(|e| ZincError::SerializationError(e.to_string()))?;
213 let plaintext = crypto::decrypt_seed(&encrypted, password)?;
214 String::from_utf8(plaintext.to_vec())
215 .map_err(|e| ZincError::SerializationError(format!("Invalid UTF-8: {e}")))
216}
217
218use std::sync::Once;
223
224static INIT: Once = Once::new();
225const LOG_TARGET_WASM: &str = "zinc_core::wasm";
226
227#[derive(Debug, Clone, Serialize, Deserialize)]
228pub struct InscriptionPreview {
229 pub id: String,
230 pub content_type: Option<String>,
231}
232
233#[derive(Debug, Clone, Serialize, Deserialize)]
234pub struct AccountDiscoveryReport {
235 pub index: u32,
236 pub path_type: String, pub primary_address: String,
238 pub spendable_sats: u64,
239 pub postage_sats: u64,
240 pub inscription_count: u32,
241 pub inscriptions: Vec<InscriptionPreview>,
242 pub taproot_external: String,
243 pub taproot_internal: String,
244 pub payment_external: Option<String>,
245 pub payment_internal: Option<String>,
246}
247
248#[cfg(any(target_arch = "wasm32", test))]
249#[allow(dead_code)]
250async fn probe_single_account(
251 client: &reqwest::Client,
252 esplora_url: &str,
253 ord_url: &str,
254 network: Network,
255 fingerprint_hex: &str,
256 index: u32,
257 taproot_xpub: &str,
258 payment_xpub: Option<&String>,
259 path_type: &str,
260) -> Option<AccountDiscoveryReport> {
261 let (t_ext, t_int) = if path_type == "legacy" {
263 let path = format!(
264 "86'/{}'/0'",
265 if network == Network::Bitcoin { 0 } else { 1 }
266 );
267 (
268 format!("tr([{fingerprint_hex}/{path}]{taproot_xpub}/0/{index})"),
269 format!("tr([{fingerprint_hex}/{path}]{taproot_xpub}/1/{index})"),
270 )
271 } else {
272 let path = format!(
273 "86'/{}'/{index}'",
274 if network == Network::Bitcoin { 0 } else { 1 }
275 );
276 (
277 format!("tr([{fingerprint_hex}/{path}]{taproot_xpub}/0/*)"),
278 format!("tr([{fingerprint_hex}/{path}]{taproot_xpub}/1/*)"),
279 )
280 };
281
282 let (p_ext, p_int) = if let Some(xpub) = payment_xpub {
283 if path_type == "legacy" {
284 let path = format!(
285 "84'/{}'/0'",
286 if network == Network::Bitcoin { 0 } else { 1 }
287 );
288 (
289 Some(format!("wpkh([{fingerprint_hex}/{path}]{xpub}/0/{index})")),
290 Some(format!("wpkh([{fingerprint_hex}/{path}]{xpub}/1/{index})")),
291 )
292 } else {
293 let path = format!(
294 "84'/{}'/{index}'",
295 if network == Network::Bitcoin { 0 } else { 1 }
296 );
297 (
298 Some(format!("wpkh([{fingerprint_hex}/{path}]{xpub}/0/*)")),
299 Some(format!("wpkh([{fingerprint_hex}/{path}]{xpub}/1/*)")),
300 )
301 }
302 } else {
303 (None, None)
304 };
305
306 if path_type == "standard" && index > 0 {
308 return None;
309 }
310
311 let builder = WalletBuilder::new(network);
312 let wallet = match builder.build_hardware(
313 fingerprint_hex,
314 t_ext.clone(),
315 t_int.clone(),
316 p_ext.clone(),
317 p_int.clone(),
318 ) {
319 Ok(w) => w,
320 Err(_) => return None,
321 };
322
323 let vault_addr = wallet.peek_taproot_address(0).to_string();
324 let payment_addr = wallet.peek_payment_address(0).map(|a| a.to_string());
325
326 let mut spendable_sats = 0u64;
328 let mut postage_sats = 0u64;
329 let mut total_inscriptions = 0u32;
330 let mut inscriptions = Vec::new();
331 let mut has_activity = false;
332
333 if let Some((bal, ins_list, active)) =
335 fetch_addr_stats(client, esplora_url, ord_url, &vault_addr).await
336 {
337 postage_sats += bal;
338 total_inscriptions += ins_list.len() as u32;
339 inscriptions.extend(ins_list);
340 if active {
341 has_activity = true;
342 }
343 }
344
345 if let Some(p_addr) = payment_addr {
347 if let Some((bal, ins_list, active)) =
348 fetch_addr_stats(client, esplora_url, ord_url, &p_addr).await
349 {
350 spendable_sats += bal;
351 total_inscriptions += ins_list.len() as u32;
352 inscriptions.extend(ins_list);
353 if active {
354 has_activity = true;
355 }
356 }
357 }
358
359 if has_activity || index == 0 {
361 Some(AccountDiscoveryReport {
362 index,
363 path_type: path_type.to_string(),
364 primary_address: vault_addr,
365 spendable_sats,
366 postage_sats,
367 inscription_count: total_inscriptions,
368 inscriptions,
369 taproot_external: t_ext,
370 taproot_internal: t_int,
371 payment_external: p_ext,
372 payment_internal: p_int,
373 })
374 } else {
375 None
376 }
377}
378
379#[cfg(any(target_arch = "wasm32", test))]
380#[allow(dead_code)]
381async fn fetch_addr_stats(
382 client: &reqwest::Client,
383 esplora_url: &str,
384 ord_url: &str,
385 address: &str,
386) -> Option<(u64, Vec<InscriptionPreview>, bool)> {
387 let url = format!("{}/address/{}", esplora_url, address);
389 let mut balance = 0u64;
390 let mut has_history = false;
391
392 if let Ok(resp) = client.get(&url).send().await {
393 if let Ok(json) = resp.json::<serde_json::Value>().await {
394 let chain_stats = &json["chain_stats"];
395 let mempool_stats = &json["mempool_stats"];
396
397 let chain_funded = chain_stats["funded_txo_sum"].as_u64().unwrap_or(0);
398 let chain_spent = chain_stats["spent_txo_sum"].as_u64().unwrap_or(0);
399 let chain_sats = chain_funded.saturating_sub(chain_spent);
400
401 let mempool_funded = mempool_stats["funded_txo_sum"].as_u64().unwrap_or(0);
402 let mempool_spent = mempool_stats["spent_txo_sum"].as_u64().unwrap_or(0);
403 let mempool_sats = mempool_funded.saturating_sub(mempool_spent);
404
405 balance = chain_sats.saturating_add(mempool_sats);
406
407 let tx_count = chain_stats["tx_count"].as_u64().unwrap_or(0)
408 + mempool_stats["tx_count"].as_u64().unwrap_or(0);
409 if tx_count > 0 {
410 has_history = true;
411 }
412 }
413 }
414
415 let mut inscriptions = Vec::new();
417 let ord_addr_url = format!("{}/address/{}", ord_url, address);
418 if let Ok(resp) = client
419 .get(&ord_addr_url)
420 .header("Accept", "application/json")
421 .send()
422 .await
423 {
424 if let Ok(json) = resp.json::<serde_json::Value>().await {
425 let list_opt = if let Some(list) = json["inscriptions"].as_array() {
427 Some(list)
428 } else if let Some(list) = json.as_array() {
429 Some(list)
430 } else {
431 None
432 };
433
434 if let Some(list) = list_opt {
435 for item in list.iter().take(10) {
436 if let Some(id) = item.as_str() {
437 inscriptions.push(InscriptionPreview {
438 id: id.to_string(),
439 content_type: None,
440 });
441 } else if let Some(obj) = item.as_object() {
442 let id_opt = obj
444 .get("id")
445 .or(obj.get("inscription_id"))
446 .or(obj.get("inscriptionId"));
447 if let Some(id) = id_opt.and_then(|v| v.as_str()) {
448 let ct = obj
449 .get("content_type")
450 .or(obj.get("contentType"))
451 .and_then(|v| v.as_str())
452 .map(|s| s.to_string());
453 inscriptions.push(InscriptionPreview {
454 id: id.to_string(),
455 content_type: ct,
456 });
457 }
458 }
459 }
460 }
461 }
462 }
463
464 if has_history || balance > 0 || !inscriptions.is_empty() {
465 Some((balance, inscriptions, true))
466 } else {
467 Some((0, Vec::new(), false))
468 }
469}
470
471#[cfg(any(target_arch = "wasm32", test))]
472async fn first_active_receive_index_from_scan<F, Fut>(
473 address_scan_depth: u32,
474 mut has_activity_at: F,
475) -> Option<u32>
476where
477 F: FnMut(u32) -> Fut,
478 Fut: Future<Output = bool>,
479{
480 let depth = address_scan_depth.max(1);
481 const ADDRESS_SCAN_BATCH_SIZE: u32 = 20;
482
483 let mut batch_start = 0;
484 while batch_start < depth {
485 let batch_end = (batch_start + ADDRESS_SCAN_BATCH_SIZE).min(depth);
486 let mut checks = Vec::with_capacity((batch_end - batch_start) as usize);
487 for address_index in batch_start..batch_end {
488 checks.push(has_activity_at(address_index));
489 }
490
491 let results = futures_util::future::join_all(checks).await;
492 if let Some(offset) = results.iter().position(|is_active| *is_active) {
493 return Some(batch_start + offset as u32);
494 }
495
496 batch_start = batch_end;
497 }
498
499 None
500}
501
502#[cfg(any(target_arch = "wasm32", test))]
503async fn account_is_active_from_receive_scan<F, Fut>(
504 address_scan_depth: u32,
505 has_activity_at: F,
506) -> bool
507where
508 F: FnMut(u32) -> Fut,
509 Fut: Future<Output = bool>,
510{
511 first_active_receive_index_from_scan(address_scan_depth, has_activity_at)
512 .await
513 .is_some()
514}
515
516#[cfg(target_arch = "wasm32")]
517#[derive(Clone, Copy)]
518enum ImportDiscoveryBranch {
519 Taproot,
520 Payment,
521}
522
523#[cfg(target_arch = "wasm32")]
524#[derive(Serialize)]
525#[serde(rename_all = "camelCase")]
526struct ImportPathAccountHit {
527 index: u32,
528 first_active_address_index: u32,
529}
530
531#[cfg(target_arch = "wasm32")]
532#[derive(Serialize)]
533#[serde(rename_all = "camelCase")]
534struct ImportPathDiscoveryResponse {
535 active_accounts: Vec<ImportPathAccountHit>,
536}
537
538#[wasm_bindgen(start)]
540pub fn init() {
541 zinc_log_trace!(target: LOG_TARGET_WASM, "init invoked");
542 INIT.call_once(|| {
543 console_error_panic_hook::set_once();
545 zinc_log_info!(target: LOG_TARGET_WASM, "WASM module initialized");
546 });
547}
548
549#[wasm_bindgen]
551pub fn set_log_level(level: &str) -> Result<(), JsValue> {
552 let Some(parsed) = logging::parse_level(level) else {
553 zinc_log_warn!(
554 target: LOG_TARGET_WASM,
555 "rejected invalid log level request ({})",
556 logging::redacted_field("requested_level", level)
557 );
558 zinc_log_error!(
559 target: LOG_TARGET_WASM,
560 "invalid runtime log level request rejected"
561 );
562 return Err(JsValue::from_str(
563 "Invalid log level. Use one of: off, error, warn, info, debug, trace",
564 ));
565 };
566
567 logging::set_log_level(parsed);
568 zinc_log_info!(
569 target: LOG_TARGET_WASM,
570 "runtime log level updated to {}",
571 parsed.as_str()
572 );
573 Ok(())
574}
575
576#[wasm_bindgen]
578pub fn set_logging_enabled(enabled: bool) {
579 logging::set_logging_enabled(enabled);
580 zinc_log_info!(
581 target: LOG_TARGET_WASM,
582 "runtime logging {}",
583 if enabled { "enabled" } else { "disabled" }
584 );
585}
586
587#[wasm_bindgen]
589pub fn get_log_level() -> String {
590 logging::get_log_level().as_str().to_string()
591}
592
593#[wasm_bindgen]
595pub fn generate_wallet(word_count: u8) -> Result<JsValue, JsValue> {
596 let result =
597 generate_wallet_internal(word_count).map_err(|e| JsValue::from_str(&e.to_string()))?;
598
599 let js_result = serde_json::json!({
600 "words": result.words,
601 "phrase": result.phrase,
602 });
603
604 serde_wasm_bindgen::to_value(&js_result).map_err(|e| JsValue::from_str(&e.to_string()))
605}
606
607#[wasm_bindgen]
609pub fn validate_mnemonic(phrase: &str) -> bool {
610 validate_mnemonic_internal(phrase)
611}
612
613#[wasm_bindgen]
615pub fn derive_address(phrase: &str, network: &str) -> Result<String, JsValue> {
616 let network = match network {
617 "mainnet" | "bitcoin" => Network::Bitcoin,
618 "signet" => Network::Signet,
619 "testnet" => Network::Testnet,
620 "regtest" => Network::Regtest,
621 _ => return Err(JsValue::from_str("Invalid network")),
622 };
623
624 derive_address_internal(phrase, network).map_err(|e| JsValue::from_str(&e.to_string()))
625}
626
627#[wasm_bindgen]
629pub fn encrypt_wallet(mnemonic: &str, password: &str) -> Result<String, JsValue> {
630 encrypt_wallet_internal(mnemonic, password).map_err(|e| JsValue::from_str(&e.to_string()))
631}
632
633#[wasm_bindgen]
635pub fn encrypt_secret(secret: &str, password: &str) -> Result<String, JsValue> {
636 encrypt_secret_internal(secret, password).map_err(|e| JsValue::from_str(&e.to_string()))
637}
638
639#[derive(Serialize)]
640pub struct DecryptResponse {
642 pub success: bool,
644 pub phrase: String,
646 pub words: Vec<String>,
648}
649
650#[wasm_bindgen]
652pub fn decrypt_wallet(encrypted_json: &str, password: &str) -> Result<JsValue, JsValue> {
653 zinc_log_debug!(target: LOG_TARGET_WASM,
654 "decrypt_wallet called. Encrypted length: {}, Password length: {}",
655 encrypted_json.len(),
656 password.len()
657 );
658
659 let result = match decrypt_wallet_internal(encrypted_json, password) {
660 Ok(res) => {
661 zinc_log_debug!(target: LOG_TARGET_WASM,
662 "Internal decryption success. Phrase length: {}",
663 res.phrase.len()
664 );
665 res
666 }
667 Err(e) => {
668 zinc_log_debug!(target: LOG_TARGET_WASM, "Internal decryption failed: {:?}", e);
669 return Err(JsValue::from_str(&e.to_string()));
670 }
671 };
672
673 let response = DecryptResponse {
674 success: true,
675 phrase: result.phrase,
676 words: result.words,
677 };
678
679 zinc_log_debug!(target: LOG_TARGET_WASM, "Serializing response...");
680 match serde_wasm_bindgen::to_value(&response) {
681 Ok(val) => {
682 zinc_log_debug!(target: LOG_TARGET_WASM, "Serialization success.");
683 Ok(val)
684 }
685 Err(e) => {
686 zinc_log_debug!(target: LOG_TARGET_WASM, "Serialization failed: {:?}", e);
687 Err(JsValue::from_str(&e.to_string()))
688 }
689 }
690}
691
692#[wasm_bindgen]
694pub fn decrypt_secret(encrypted_json: &str, password: &str) -> Result<String, JsValue> {
695 decrypt_secret_internal(encrypted_json, password).map_err(|e| JsValue::from_str(&e.to_string()))
696}
697
698#[wasm_bindgen]
703pub fn validate_signed_pairing_request_json(payload_json: &str) -> Result<String, JsValue> {
704 let pairing_id = crate::sign_intent::validate_signed_pairing_request_json(payload_json)
705 .map_err(|e| JsValue::from_str(&e.to_string()))?;
706 Ok(serde_json::json!({
707 "ok": true,
708 "pairingId": pairing_id
709 })
710 .to_string())
711}
712
713#[wasm_bindgen]
718pub fn validate_signed_pairing_ack_json(payload_json: &str) -> Result<String, JsValue> {
719 let ack_id = crate::sign_intent::validate_signed_pairing_ack_json(payload_json)
720 .map_err(|e| JsValue::from_str(&e.to_string()))?;
721 Ok(serde_json::json!({
722 "ok": true,
723 "ackId": ack_id
724 })
725 .to_string())
726}
727
728#[wasm_bindgen]
733pub fn validate_pairing_ack_envelope_json(payload_json: &str) -> Result<String, JsValue> {
734 let envelope_id = crate::sign_intent::validate_pairing_ack_envelope_json(payload_json)
735 .map_err(|e| JsValue::from_str(&e.to_string()))?;
736 Ok(serde_json::json!({
737 "ok": true,
738 "envelopeId": envelope_id
739 })
740 .to_string())
741}
742
743#[wasm_bindgen]
748pub fn validate_signed_pairing_complete_receipt_json(
749 payload_json: &str,
750) -> Result<String, JsValue> {
751 let receipt_id =
752 crate::sign_intent::validate_signed_pairing_complete_receipt_json(payload_json)
753 .map_err(|e| JsValue::from_str(&e.to_string()))?;
754 Ok(serde_json::json!({
755 "ok": true,
756 "receiptId": receipt_id
757 })
758 .to_string())
759}
760
761#[wasm_bindgen]
766pub fn validate_nostr_transport_event_json(payload_json: &str) -> Result<String, JsValue> {
767 let event_id = crate::sign_intent::validate_nostr_transport_event_json(payload_json)
768 .map_err(|e| JsValue::from_str(&e.to_string()))?;
769 Ok(serde_json::json!({
770 "ok": true,
771 "eventId": event_id
772 })
773 .to_string())
774}
775
776#[wasm_bindgen]
781pub fn validate_signed_sign_intent_json(payload_json: &str) -> Result<String, JsValue> {
782 let intent_id = crate::sign_intent::validate_signed_sign_intent_json(payload_json)
783 .map_err(|e| JsValue::from_str(&e.to_string()))?;
784 Ok(serde_json::json!({
785 "ok": true,
786 "intentId": intent_id
787 })
788 .to_string())
789}
790
791#[wasm_bindgen]
796pub fn validate_signed_sign_intent_receipt_json(payload_json: &str) -> Result<String, JsValue> {
797 let receipt_id = crate::sign_intent::validate_signed_sign_intent_receipt_json(payload_json)
798 .map_err(|e| JsValue::from_str(&e.to_string()))?;
799 Ok(serde_json::json!({
800 "ok": true,
801 "receiptId": receipt_id
802 })
803 .to_string())
804}
805
806#[derive(Debug, Clone, Deserialize)]
807#[serde(rename_all = "camelCase")]
808pub struct CreateListingTransportRequest {
809 pub seller_pubkey_hex: String,
810 pub coordinator_pubkey_hex: String,
811 pub network: String,
812 pub inscription_id: String,
813 pub seller_outpoint: String,
814 pub seller_prevout_value_sats: u64,
815 pub seller_prevout_script_pubkey_hex: String,
816 pub seller_payout_script_pubkey_hex: String,
817 pub recovery_script_pubkey_hex: String,
818 pub ask_sats: u64,
819 pub fee_rate_sat_vb: u64,
820 pub created_at_unix: i64,
821 pub expires_at_unix: i64,
822 pub nonce: u64,
823}
824
825impl TryFrom<CreateListingTransportRequest> for listing::CreateListingRequest {
826 type Error = ZincError;
827
828 fn try_from(value: CreateListingTransportRequest) -> Result<Self, Self::Error> {
829 let seller_outpoint = value.seller_outpoint.parse().map_err(|e| {
830 ZincError::OfferError(format!(
831 "invalid seller_outpoint `{}`: {e}",
832 value.seller_outpoint
833 ))
834 })?;
835 let script_from_hex = |label: &str, hex_script: &str| {
836 let bytes = hex::decode(hex_script)
837 .map_err(|e| ZincError::OfferError(format!("invalid {label}: {e}")))?;
838 Ok(bitcoin::ScriptBuf::from_bytes(bytes))
839 };
840
841 Ok(Self {
842 seller_pubkey_hex: value.seller_pubkey_hex,
843 coordinator_pubkey_hex: value.coordinator_pubkey_hex,
844 network: value.network,
845 inscription_id: value.inscription_id,
846 seller_outpoint,
847 seller_prevout: bitcoin::TxOut {
848 value: bitcoin::Amount::from_sat(value.seller_prevout_value_sats),
849 script_pubkey: script_from_hex(
850 "seller_prevout_script_pubkey_hex",
851 &value.seller_prevout_script_pubkey_hex,
852 )?,
853 },
854 seller_payout_script_pubkey: script_from_hex(
855 "seller_payout_script_pubkey_hex",
856 &value.seller_payout_script_pubkey_hex,
857 )?,
858 recovery_script_pubkey: script_from_hex(
859 "recovery_script_pubkey_hex",
860 &value.recovery_script_pubkey_hex,
861 )?,
862 ask_sats: value.ask_sats,
863 fee_rate_sat_vb: value.fee_rate_sat_vb,
864 created_at_unix: value.created_at_unix,
865 expires_at_unix: value.expires_at_unix,
866 nonce: value.nonce,
867 })
868 }
869}
870
871#[derive(Debug, Clone, Deserialize)]
872#[serde(rename_all = "camelCase")]
873pub struct ListingEnvelopeTransportRequest {
874 pub listing: listing::ListingEnvelopeV1,
875 pub now_unix: i64,
876}
877
878#[wasm_bindgen(js_name = createListing)]
879pub fn create_listing_js(request: JsValue) -> Result<JsValue, JsValue> {
880 let transport: CreateListingTransportRequest = serde_wasm_bindgen::from_value(request)
881 .map_err(|e| JsValue::from_str(&format!("Invalid listing request: {e}")))?;
882 let request = listing::CreateListingRequest::try_from(transport)
883 .map_err(|e| JsValue::from_str(&e.to_string()))?;
884 let created =
885 listing::create_listing(&request).map_err(|e| JsValue::from_str(&e.to_string()))?;
886 serde_wasm_bindgen::to_value(&created)
887 .map_err(|e| JsValue::from_str(&format!("failed to serialize listing result: {e}")))
888}
889
890#[wasm_bindgen(js_name = prepareListingSaleSignature)]
891pub fn prepare_listing_sale_signature_js(request: JsValue) -> Result<JsValue, JsValue> {
892 let request: ListingEnvelopeTransportRequest = serde_wasm_bindgen::from_value(request)
893 .map_err(|e| JsValue::from_str(&format!("Invalid listing request: {e}")))?;
894 let plan = listing::prepare_listing_sale_signature(&request.listing, request.now_unix)
895 .map_err(|e| JsValue::from_str(&e.to_string()))?;
896 serde_wasm_bindgen::to_value(&plan)
897 .map_err(|e| JsValue::from_str(&format!("failed to serialize listing plan: {e}")))
898}
899
900#[wasm_bindgen(js_name = signListingSalePsbt)]
901pub fn sign_listing_sale_psbt_js(
902 listing: JsValue,
903 seller_secret_key_hex: &str,
904 now_unix: i64,
905) -> Result<String, JsValue> {
906 let listing: listing::ListingEnvelopeV1 = serde_wasm_bindgen::from_value(listing)
907 .map_err(|e| JsValue::from_str(&format!("Invalid listing: {e}")))?;
908 listing::sign_listing_sale_psbt(&listing, seller_secret_key_hex, now_unix)
909 .map_err(|e| JsValue::from_str(&e.to_string()))
910}
911
912#[wasm_bindgen(js_name = signListingCoordinatorPsbt)]
913pub fn sign_listing_coordinator_psbt_js(
914 listing: JsValue,
915 coordinator_secret_key_hex: &str,
916 now_unix: i64,
917) -> Result<String, JsValue> {
918 let listing: listing::ListingEnvelopeV1 = serde_wasm_bindgen::from_value(listing)
919 .map_err(|e| JsValue::from_str(&format!("Invalid listing: {e}")))?;
920 listing::sign_listing_coordinator_psbt(&listing, coordinator_secret_key_hex, now_unix)
921 .map_err(|e| JsValue::from_str(&e.to_string()))
922}
923
924#[wasm_bindgen(js_name = finalizeListingSale)]
925pub fn finalize_listing_sale_js(request: JsValue) -> Result<JsValue, JsValue> {
926 let request: ListingEnvelopeTransportRequest = serde_wasm_bindgen::from_value(request)
927 .map_err(|e| JsValue::from_str(&format!("Invalid listing request: {e}")))?;
928 let finalized = listing::finalize_listing_sale(&request.listing, request.now_unix)
929 .map_err(|e| JsValue::from_str(&e.to_string()))?;
930 serde_wasm_bindgen::to_value(&finalized)
931 .map_err(|e| JsValue::from_str(&format!("failed to serialize finalized sale: {e}")))
932}
933
934#[wasm_bindgen]
939pub fn verify_pairing_approval_json(
940 signed_request_json: &str,
941 signed_ack_json: &str,
942 now_unix: i64,
943) -> Result<String, JsValue> {
944 let approval = crate::sign_intent::verify_pairing_approval_json(
945 signed_request_json,
946 signed_ack_json,
947 now_unix,
948 )
949 .map_err(|e| JsValue::from_str(&e.to_string()))?;
950 Ok(serde_json::json!({
951 "ok": true,
952 "approval": approval
953 })
954 .to_string())
955}
956
957use std::cell::{Cell, RefCell};
962use std::rc::Rc;
963
964const VITALITY_MAGIC: u32 = 0x005a_11ad;
965#[cfg(target_arch = "wasm32")]
966const SYNC_STALE_ERROR: &str = "Wallet state changed during sync; stale result discarded";
967#[cfg(target_arch = "wasm32")]
968const ORD_SYNC_STALE_ERROR: &str =
969 "Wallet state changed during ordinals sync; stale result discarded";
970
971#[derive(Clone, Copy)]
972struct WalletState {
973 network: Network,
974 scheme: AddressScheme,
975 derivation_mode: DerivationMode,
976 payment_address_type: PaymentAddressType,
977 account_index: u32,
978}
979
980#[derive(Clone)]
981enum WalletMaterial {
982 MnemonicPhrase(String),
983 WatchAddress(String),
984 Hardware { _fingerprint: [u8; 4] },
985}
986
987#[wasm_bindgen]
988pub struct ZincWasmWallet {
990 inner: Rc<RefCell<ZincWallet>>,
991 material: WalletMaterial,
992 state: Cell<WalletState>,
993 vitality: u32,
994}
995
996#[wasm_bindgen]
997impl ZincWasmWallet {
998 fn parse_network_label(network: &str) -> Result<Network, JsValue> {
999 match network {
1000 "mainnet" | "bitcoin" => Ok(Network::Bitcoin),
1001 "signet" => Ok(Network::Signet),
1002 "testnet" => Ok(Network::Testnet),
1003 "regtest" => Ok(Network::Regtest),
1004 _ => Err(JsValue::from_str("Invalid network")),
1005 }
1006 }
1007
1008 fn build_seed_wallet(
1009 network: Network,
1010 phrase: &str,
1011 scheme: AddressScheme,
1012 derivation_mode: DerivationMode,
1013 payment_address_type: PaymentAddressType,
1014 account_index: u32,
1015 persistence_json: Option<&str>,
1016 ) -> Result<ZincWallet, JsValue> {
1017 let mnemonic =
1018 ZincMnemonic::parse(phrase).map_err(|e| JsValue::from_str(&e.to_string()))?;
1019 let mut builder = WalletBuilder::from_mnemonic(network, &mnemonic);
1020 builder = builder
1021 .with_scheme(scheme)
1022 .with_derivation_mode(derivation_mode)
1023 .with_payment_address_type(payment_address_type)
1024 .with_account_index(account_index);
1025
1026 if let Some(json) = persistence_json {
1027 builder = builder
1028 .with_persistence(json)
1029 .map_err(|e| JsValue::from_str(&e))?;
1030 }
1031
1032 builder.build().map_err(|e| JsValue::from_str(&e))
1033 }
1034
1035 #[wasm_bindgen]
1036 pub fn new_hardware(
1037 network: &str,
1038 fingerprint_hex: &str,
1039 taproot_external_desc: &str,
1040 taproot_internal_desc: &str,
1041 payment_external_desc: Option<String>,
1042 payment_internal_desc: Option<String>,
1043 account_index: u32,
1044 persistence_json: Option<String>,
1045 ) -> Result<ZincWasmWallet, JsValue> {
1046 let network_enum = match network {
1047 "mainnet" | "bitcoin" => Network::Bitcoin,
1048 "signet" => Network::Signet,
1049 "testnet" => Network::Testnet,
1050 "regtest" => Network::Regtest,
1051 _ => return Err(JsValue::from_str("Invalid network")),
1052 };
1053
1054 let mut fingerprint = [0u8; 4];
1056 if let Ok(fp_bytes) = hex::decode(fingerprint_hex) {
1057 if fp_bytes.len() == 4 {
1058 fingerprint.copy_from_slice(&fp_bytes);
1059 }
1060 }
1061
1062 let persistence = if let Some(json) = persistence_json {
1063 Some(
1064 serde_json::from_str::<ZincPersistence>(&json)
1065 .map_err(|e| JsValue::from_str(&e.to_string()))?,
1066 )
1067 } else {
1068 None
1069 };
1070
1071 let mut builder = WalletBuilder::new(network_enum).with_account_index(account_index);
1072
1073 if let Some(p) = persistence {
1074 builder = builder.persistence(p);
1075 }
1076
1077 let wallet = builder
1078 .build_hardware(
1079 fingerprint_hex,
1080 taproot_external_desc.to_string(),
1081 taproot_internal_desc.to_string(),
1082 payment_external_desc.clone(),
1083 payment_internal_desc.clone(),
1084 )
1085 .map_err(|e| JsValue::from_str(&e))?;
1086
1087 zinc_log_debug!(
1088 target: LOG_TARGET_WASM,
1089 "new_hardware - network: {:?}, fp: {:?}, tap_ext: {}, pay_ext: {:?}",
1090 network_enum,
1091 fingerprint,
1092 taproot_external_desc,
1093 payment_external_desc
1094 );
1095
1096 Ok(ZincWasmWallet {
1097 inner: std::rc::Rc::new(std::cell::RefCell::new(wallet)),
1098 material: WalletMaterial::Hardware {
1099 _fingerprint: fingerprint,
1100 },
1101 state: std::cell::Cell::new(WalletState {
1102 network: network_enum,
1103 scheme: AddressScheme::Dual,
1104 derivation_mode: DerivationMode::Account,
1105 payment_address_type: PaymentAddressType::NativeSegwit,
1106 account_index,
1107 }),
1108 vitality: VITALITY_MAGIC,
1109 })
1110 }
1111
1112 fn build_watch_wallet(
1113 network: Network,
1114 watch_address: &str,
1115 scheme: AddressScheme,
1116 derivation_mode: DerivationMode,
1117 payment_address_type: PaymentAddressType,
1118 account_index: u32,
1119 persistence_json: Option<&str>,
1120 ) -> Result<ZincWallet, JsValue> {
1121 let mut builder = WalletBuilder::from_watch_only(network)
1122 .with_watch_address(watch_address)
1123 .map_err(|e| JsValue::from_str(&e))?;
1124 builder = builder
1125 .with_scheme(scheme)
1126 .with_derivation_mode(derivation_mode)
1127 .with_payment_address_type(payment_address_type)
1128 .with_account_index(account_index);
1129
1130 if let Some(json) = persistence_json {
1131 builder = builder
1132 .with_persistence(json)
1133 .map_err(|e| JsValue::from_str(&e))?;
1134 }
1135
1136 builder.build().map_err(|e| JsValue::from_str(&e))
1137 }
1138
1139 fn build_wallet_for_state(
1140 material: &WalletMaterial,
1141 next_state: WalletState,
1142 ) -> Result<ZincWallet, JsValue> {
1143 match material {
1144 WalletMaterial::MnemonicPhrase(phrase) => Self::build_seed_wallet(
1145 next_state.network,
1146 phrase,
1147 next_state.scheme,
1148 next_state.derivation_mode,
1149 next_state.payment_address_type,
1150 next_state.account_index,
1151 None,
1152 ),
1153 WalletMaterial::WatchAddress(address) => Self::build_watch_wallet(
1154 next_state.network,
1155 address,
1156 next_state.scheme,
1157 next_state.derivation_mode,
1158 next_state.payment_address_type,
1159 next_state.account_index,
1160 None,
1161 ),
1162 WalletMaterial::Hardware { .. } => Err(JsValue::from_str(
1163 "Dynamic state updates are not yet supported for hardware wallets in this handle",
1164 )),
1165 }
1166 }
1167
1168 #[allow(dead_code)]
1169 fn seed_phrase(&self) -> Result<&str, JsValue> {
1170 match &self.material {
1171 WalletMaterial::MnemonicPhrase(phrase) => Ok(phrase.as_str()),
1172 WalletMaterial::WatchAddress(_) | WalletMaterial::Hardware { .. } => {
1173 Err(JsValue::from_str(
1174 "Operation is unavailable for watch-address and hardware profiles",
1175 ))
1176 }
1177 }
1178 }
1179
1180 #[wasm_bindgen(constructor)]
1181 #[allow(clippy::needless_pass_by_value)]
1182 pub fn new(
1186 network: &str,
1187 phrase: &str,
1188 scheme_str: Option<String>,
1189 persistence_json: Option<String>,
1190 account_index: Option<u32>,
1191 ) -> Result<ZincWasmWallet, JsValue> {
1192 let network_enum = Self::parse_network_label(network)?;
1193 let scheme = match scheme_str.as_deref() {
1194 Some("dual") => AddressScheme::Dual,
1195 _ => AddressScheme::Unified,
1196 };
1197 let active_index = account_index.unwrap_or(0);
1198 let wallet = Self::build_seed_wallet(
1199 network_enum,
1200 phrase,
1201 scheme,
1202 DerivationMode::Account,
1203 PaymentAddressType::NativeSegwit,
1204 active_index,
1205 persistence_json.as_deref(),
1206 )?;
1207
1208 Ok(ZincWasmWallet {
1209 inner: Rc::new(RefCell::new(wallet)),
1210 material: WalletMaterial::MnemonicPhrase(phrase.to_string()),
1211 state: Cell::new(WalletState {
1212 network: network_enum,
1213 scheme,
1214 derivation_mode: DerivationMode::Account,
1215 payment_address_type: PaymentAddressType::NativeSegwit,
1216 account_index: active_index,
1217 }),
1218 vitality: VITALITY_MAGIC,
1219 })
1220 }
1221
1222 #[wasm_bindgen]
1224 pub fn new_encrypted(
1225 network: &str,
1226 encrypted_json: &str,
1227 password: &str,
1228 scheme_str: Option<String>,
1229 persistence_json: Option<String>,
1230 account_index: Option<u32>,
1231 ) -> Result<ZincWasmWallet, JsValue> {
1232 let network_enum = Self::parse_network_label(network)?;
1233 let scheme = match scheme_str.as_deref() {
1234 Some("dual") => AddressScheme::Dual,
1235 _ => AddressScheme::Unified,
1236 };
1237 let active_index = account_index.unwrap_or(0);
1238
1239 let result = decrypt_wallet_internal(encrypted_json, password)
1240 .map_err(|e| JsValue::from_str(&e.to_string()))?;
1241 let wallet = Self::build_seed_wallet(
1242 network_enum,
1243 &result.phrase,
1244 scheme,
1245 DerivationMode::Account,
1246 PaymentAddressType::NativeSegwit,
1247 active_index,
1248 persistence_json.as_deref(),
1249 )?;
1250
1251 Ok(ZincWasmWallet {
1252 inner: Rc::new(RefCell::new(wallet)),
1253 material: WalletMaterial::MnemonicPhrase(result.phrase),
1254 state: Cell::new(WalletState {
1255 network: network_enum,
1256 scheme,
1257 derivation_mode: DerivationMode::Account,
1258 payment_address_type: PaymentAddressType::NativeSegwit,
1259 account_index: active_index,
1260 }),
1261 vitality: VITALITY_MAGIC,
1262 })
1263 }
1264
1265 #[wasm_bindgen]
1267 pub fn new_watch_address(
1268 network: &str,
1269 watch_address: &str,
1270 persistence_json: Option<String>,
1271 account_index: Option<u32>,
1272 ) -> Result<ZincWasmWallet, JsValue> {
1273 let network_enum = Self::parse_network_label(network)?;
1274 let active_index = account_index.unwrap_or(0);
1275 let wallet = Self::build_watch_wallet(
1276 network_enum,
1277 watch_address,
1278 AddressScheme::Unified,
1279 DerivationMode::Account,
1280 PaymentAddressType::NativeSegwit,
1281 active_index,
1282 persistence_json.as_deref(),
1283 )?;
1284
1285 Ok(ZincWasmWallet {
1286 inner: Rc::new(RefCell::new(wallet)),
1287 material: WalletMaterial::WatchAddress(watch_address.to_string()),
1288 state: Cell::new(WalletState {
1289 network: network_enum,
1290 scheme: AddressScheme::Unified,
1291 derivation_mode: DerivationMode::Account,
1292 payment_address_type: PaymentAddressType::NativeSegwit,
1293 account_index: active_index,
1294 }),
1295 vitality: VITALITY_MAGIC,
1296 })
1297 }
1298
1299 fn check_vitality(&self) -> Result<(), JsValue> {
1300 if self.vitality != VITALITY_MAGIC {
1301 return Err(JsValue::from_str("Wallet handle is stale or corrupted due to context destruction. Please reload the extension."));
1302 }
1303 let sc = Rc::strong_count(&self.inner);
1306 if sc == 0 {
1307 return Err(JsValue::from_str(
1308 "Internal error: Rc strong count is 0 (memory corruption). Please reload the extension."
1309 ));
1310 }
1311 Ok(())
1312 }
1313
1314 fn state_snapshot(&self) -> WalletState {
1315 self.state.get()
1316 }
1317
1318 fn replace_wallet(
1319 &self,
1320 mut next_wallet: ZincWallet,
1321 next_state: WalletState,
1322 busy_context: &str,
1323 ) -> Result<(), JsValue> {
1324 match self.inner.try_borrow_mut() {
1325 Ok(mut inner) => {
1326 next_wallet.account_generation = inner.account_generation().wrapping_add(1);
1327 *inner = next_wallet;
1328 self.state.set(next_state);
1329 Ok(())
1330 }
1331 Err(e) => Err(JsValue::from_str(&format!(
1332 "Wallet busy ({busy_context}): {e}"
1333 ))),
1334 }
1335 }
1336
1337 #[cfg(target_arch = "wasm32")]
1338 fn generation_mismatch_error(
1339 inner_rc: &Rc<RefCell<ZincWallet>>,
1340 expected_generation: u64,
1341 message: &str,
1342 ) -> Option<JsValue> {
1343 match inner_rc.try_borrow() {
1344 Ok(inner) if inner.account_generation() != expected_generation => {
1345 Some(JsValue::from_str(message))
1346 }
1347 _ => None,
1348 }
1349 }
1350
1351 #[cfg(target_arch = "wasm32")]
1352 fn clear_syncing_if_generation_matches(
1353 inner_rc: &Rc<RefCell<ZincWallet>>,
1354 expected_generation: u64,
1355 ) {
1356 if let Ok(mut inner) = inner_rc.try_borrow_mut() {
1357 if inner.account_generation() == expected_generation {
1358 inner.is_syncing = false;
1359 }
1360 }
1361 }
1362
1363 pub fn export_changeset(&self) -> Result<String, JsValue> {
1365 self.check_vitality()?;
1366 zinc_log_debug!(target: LOG_TARGET_WASM, "export_changeset called (wrapper)");
1367 let res = match self.inner.try_borrow() {
1368 Ok(inner) => inner
1369 .export_changeset()
1370 .map_err(|e| JsValue::from_str(&e))
1371 .and_then(|p| {
1372 serde_json::to_string(&p).map_err(|e| JsValue::from_str(&e.to_string()))
1373 }),
1374 Err(e) => {
1375 zinc_log_debug!(target: LOG_TARGET_WASM, "export_changeset failed to borrow: {:?}", e);
1376 Err(JsValue::from_str(&format!(
1377 "Wallet busy (export_changeset): {e}"
1378 )))
1379 }
1380 };
1381 zinc_log_debug!(target: LOG_TARGET_WASM, "export_changeset finished (wrapper)");
1382 res
1383 }
1384
1385 pub fn set_scheme(&self, scheme_str: &str) -> Result<(), JsValue> {
1388 self.check_vitality()?;
1389 let new_scheme = match scheme_str {
1390 "dual" => AddressScheme::Dual,
1391 "unified" => AddressScheme::Unified,
1392 _ => return Err(JsValue::from_str("Invalid scheme")),
1393 };
1394
1395 let state = self.state_snapshot();
1396 if state.scheme == new_scheme {
1397 return Ok(());
1398 }
1399
1400 let next_state = WalletState {
1401 scheme: new_scheme,
1402 ..state
1403 };
1404 let next_wallet = Self::build_wallet_for_state(&self.material, next_state)?;
1405 self.replace_wallet(next_wallet, next_state, "set_scheme")
1406 }
1407
1408 pub fn set_active_account(&self, account_index: u32) -> Result<(), JsValue> {
1412 self.check_vitality()?;
1413 let state = self.state_snapshot();
1414 if state.account_index == account_index {
1415 return Ok(());
1416 }
1417
1418 let next_state = WalletState {
1419 account_index,
1420 ..state
1421 };
1422 let next_wallet = Self::build_wallet_for_state(&self.material, next_state)?;
1423 self.replace_wallet(next_wallet, next_state, "set_active_account")
1424 }
1425
1426 pub fn set_network(&self, network_str: &str) -> Result<(), JsValue> {
1428 self.check_vitality()?;
1429 let new_network = match network_str {
1430 "mainnet" => Network::Bitcoin,
1431 "testnet" => Network::Testnet,
1432 "signet" => Network::Signet,
1433 "regtest" => Network::Regtest,
1434 _ => return Err(JsValue::from_str("Invalid network")),
1435 };
1436
1437 let state = self.state_snapshot();
1438 if state.network == new_network {
1439 return Ok(());
1440 }
1441
1442 let next_state = WalletState {
1443 network: new_network,
1444 ..state
1445 };
1446 let next_wallet = Self::build_wallet_for_state(&self.material, next_state)?;
1447 self.replace_wallet(next_wallet, next_state, "set_network")
1448 }
1449
1450 pub fn set_derivation_mode(&self, mode_str: &str) -> Result<(), JsValue> {
1452 self.check_vitality()?;
1453 let new_mode = match mode_str {
1454 "account" => DerivationMode::Account,
1455 "index" => DerivationMode::Index,
1456 _ => return Err(JsValue::from_str("Invalid derivation mode")),
1457 };
1458 let state = self.state_snapshot();
1459 if state.derivation_mode == new_mode {
1460 return Ok(());
1461 }
1462
1463 let next_state = WalletState {
1464 derivation_mode: new_mode,
1465 ..state
1466 };
1467 let next_wallet = Self::build_wallet_for_state(&self.material, next_state)?;
1468 self.replace_wallet(next_wallet, next_state, "set_derivation_mode")
1469 }
1470
1471 pub fn get_derivation_mode(&self) -> String {
1473 match self.state_snapshot().derivation_mode {
1474 DerivationMode::Account => "account".to_string(),
1475 DerivationMode::Index => "index".to_string(),
1476 }
1477 }
1478
1479 pub fn set_payment_address_type(&self, address_type_str: &str) -> Result<(), JsValue> {
1481 self.check_vitality()?;
1482 let new_type = match address_type_str {
1483 "native" => PaymentAddressType::NativeSegwit,
1484 "nested" => PaymentAddressType::NestedSegwit,
1485 "legacy" => PaymentAddressType::Legacy,
1486 _ => return Err(JsValue::from_str("Invalid payment address type")),
1487 };
1488 let state = self.state_snapshot();
1489 if state.payment_address_type == new_type {
1490 return Ok(());
1491 }
1492
1493 let next_state = WalletState {
1494 payment_address_type: new_type,
1495 ..state
1496 };
1497 let next_wallet = Self::build_wallet_for_state(&self.material, next_state)?;
1498 self.replace_wallet(next_wallet, next_state, "set_payment_address_type")
1499 }
1500
1501 pub fn get_payment_address_type(&self) -> String {
1503 match self.state_snapshot().payment_address_type {
1504 PaymentAddressType::NativeSegwit => "native".to_string(),
1505 PaymentAddressType::NestedSegwit => "nested".to_string(),
1506 PaymentAddressType::Legacy => "legacy".to_string(),
1507 }
1508 }
1509
1510 #[wasm_bindgen(js_name = get_accounts)]
1511 pub fn get_accounts(&self, count: u32) -> Result<JsValue, JsValue> {
1513 self.check_vitality()?;
1514
1515 let inner = self
1516 .inner
1517 .try_borrow()
1518 .map_err(|e| JsValue::from_str(&format!("Wallet busy (get_accounts): {}", e)))?;
1519
1520 let accounts = inner.get_accounts(count);
1521 Ok(serde_wasm_bindgen::to_value(&accounts)?)
1522 }
1523 #[cfg(target_arch = "wasm32")]
1524 #[wasm_bindgen(js_name = probeHardwareAccounts)]
1525 pub fn probe_hardware_accounts(
1528 network: String,
1529 fingerprint_hex: String,
1530 esplora_url: String,
1531 ord_url: String,
1532 standard_taproot_xpub: String,
1533 standard_payment_xpub: String,
1534 legacy_taproot_xpub: String,
1535 legacy_payment_xpub: String,
1536 start_index: u32,
1537 end_index: u32,
1538 ) -> Result<js_sys::Promise, JsValue> {
1539 let network_enum = match network.as_str() {
1540 "mainnet" | "bitcoin" => Network::Bitcoin,
1541 "signet" => Network::Signet,
1542 "testnet" => Network::Testnet,
1543 "regtest" => Network::Regtest,
1544 _ => return Err(JsValue::from_str("Invalid network")),
1545 };
1546
1547 Ok(wasm_bindgen_futures::future_to_promise(async move {
1548 let client = reqwest::Client::new();
1549 let mut reports = Vec::new();
1550
1551 const ACCOUNT_BATCH_SIZE: usize = 5;
1553
1554 for batch_start in (start_index..=end_index).step_by(ACCOUNT_BATCH_SIZE) {
1555 let batch_end = (batch_start + ACCOUNT_BATCH_SIZE as u32).min(end_index + 1);
1556 let mut batch_futures = Vec::new();
1557
1558 for idx in batch_start..batch_end {
1559 let client = client.clone();
1560 let esplora = esplora_url.clone();
1561 let ord = ord_url.clone();
1562 let s_t_xpub = standard_taproot_xpub.clone();
1563 let s_p_xpub = standard_payment_xpub.clone();
1564 let l_t_xpub = legacy_taproot_xpub.clone();
1565 let l_p_xpub = legacy_payment_xpub.clone();
1566 let fp = fingerprint_hex.clone();
1567
1568 batch_futures.push(async move {
1569 let standard_report = probe_single_account(
1571 &client,
1572 &esplora,
1573 &ord,
1574 network_enum,
1575 &fp,
1576 idx,
1577 &s_t_xpub,
1578 Some(&s_p_xpub),
1579 "standard",
1580 )
1581 .await;
1582
1583 let legacy_report = probe_single_account(
1585 &client,
1586 &esplora,
1587 &ord,
1588 network_enum,
1589 &fp,
1590 idx,
1591 &l_t_xpub,
1592 Some(&l_p_xpub),
1593 "legacy",
1594 )
1595 .await;
1596
1597 (standard_report, legacy_report)
1598 });
1599 }
1600
1601 let batch_results = futures_util::future::join_all(batch_futures).await;
1602 for (s, l) in batch_results {
1603 if let Some(r) = s {
1604 reports.push(r);
1605 }
1606 if let Some(r) = l {
1607 reports.push(r);
1608 }
1609 }
1610 }
1611
1612 Ok(serde_wasm_bindgen::to_value(&reports)?)
1613 }))
1614 }
1615
1616 pub fn get_inscriptions(&self) -> Result<JsValue, JsValue> {
1618 self.check_vitality()?;
1619 match self.inner.try_borrow() {
1620 Ok(inner) => serde_wasm_bindgen::to_value(&inner.inscriptions)
1621 .map_err(|e| JsValue::from_str(&format!("Failed to serialize inscriptions: {e}"))),
1622 Err(e) => Err(JsValue::from_str(&format!(
1623 "Wallet busy (get_inscriptions): {e}"
1624 ))),
1625 }
1626 }
1627
1628 #[wasm_bindgen(js_name = getRuneBalances)]
1630 pub fn get_rune_balances(&self) -> Result<JsValue, JsValue> {
1631 self.check_vitality()?;
1632 match self.inner.try_borrow() {
1633 Ok(inner) => serde_wasm_bindgen::to_value(inner.rune_balances())
1634 .map_err(|e| JsValue::from_str(&format!("Failed to serialize rune balances: {e}"))),
1635 Err(e) => Err(JsValue::from_str(&format!(
1636 "Wallet busy (get_rune_balances): {e}"
1637 ))),
1638 }
1639 }
1640
1641 pub fn get_balance(&self) -> Result<JsValue, JsValue> {
1643 self.check_vitality()?;
1644 match self.inner.try_borrow() {
1645 Ok(inner) => {
1646 let balance = inner.get_balance();
1647 let json = serde_json::json!({
1648 "total": {
1649 "confirmed": balance.total.confirmed.to_sat(),
1650 "trusted_pending": balance.total.trusted_pending.to_sat(),
1651 "untrusted_pending": balance.total.untrusted_pending.to_sat(),
1652 "immature": balance.total.immature.to_sat(),
1653 },
1654 "spendable": {
1655 "confirmed": balance.spendable.confirmed.to_sat(),
1656 "trusted_pending": balance.spendable.trusted_pending.to_sat(),
1657 "untrusted_pending": balance.spendable.untrusted_pending.to_sat(),
1658 "immature": balance.spendable.immature.to_sat(),
1659 },
1660 "display_spendable": {
1661 "confirmed": balance.display_spendable.confirmed.to_sat(),
1662 "trusted_pending": balance.display_spendable.trusted_pending.to_sat(),
1663 "untrusted_pending": balance.display_spendable.untrusted_pending.to_sat(),
1664 "immature": balance.display_spendable.immature.to_sat(),
1665 },
1666 "inscribed": balance.inscribed
1667 });
1668
1669 let serializer =
1671 serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true);
1672 json.serialize(&serializer)
1673 .map_err(|e| JsValue::from_str(&e.to_string()))
1674 }
1675 Err(e) => Err(JsValue::from_str(&format!(
1676 "Wallet busy (get_balance): {e}"
1677 ))),
1678 }
1679 }
1680
1681 pub fn get_transactions(&self, limit: usize) -> Result<JsValue, JsValue> {
1683 self.check_vitality()?;
1684 match self.inner.try_borrow() {
1685 Ok(inner) => {
1686 let txs = inner.get_transactions(limit);
1687 serde_wasm_bindgen::to_value(&txs)
1688 .map_err(|e| JsValue::from(format!("Failed to serialize transactions: {e}")))
1689 }
1690 Err(e) => Err(JsValue::from_str(&format!(
1691 "Wallet busy (get_transactions): {e}"
1692 ))),
1693 }
1694 }
1695
1696 pub fn get_addresses(&self) -> Result<JsValue, JsValue> {
1700 self.check_vitality()?;
1701 match self.inner.try_borrow() {
1702 Ok(inner) => {
1703 let account_idx = inner.account_index;
1704 let vault_addr = inner.peek_taproot_address(0);
1705 let vault_pubkey = inner
1706 .get_taproot_public_key(0)
1707 .unwrap_or_else(|_| String::new());
1708
1709 zinc_log_debug!(
1710 target: LOG_TARGET_WASM,
1711 "get_addresses - account: {}, taproot: {}",
1712 account_idx,
1713 vault_addr
1714 );
1715
1716 let (payment_addr, payment_pubkey) = if inner.is_unified() {
1718 (Some(vault_addr.to_string()), Some(vault_pubkey.clone()))
1719 } else {
1720 let addr = inner
1721 .peek_payment_address(0)
1722 .ok_or_else(|| JsValue::from_str("Payment wallet missing in dual mode"))?;
1723 let pubkey = inner
1724 .get_payment_public_key(0)
1725 .unwrap_or_else(|_| String::new());
1726 zinc_log_debug!(target: LOG_TARGET_WASM, "get_addresses - payment: {}", addr);
1727 (Some(addr.to_string()), Some(pubkey))
1728 };
1729
1730 let json = serde_json::json!({
1731 "account_index": account_idx,
1732 "taproot": vault_addr.to_string(),
1733 "taprootPublicKey": vault_pubkey,
1734 "payment": payment_addr,
1735 "paymentPublicKey": payment_pubkey,
1736 "vault": vault_addr.to_string(),
1738 "vaultPublicKey": vault_pubkey
1739 });
1740 serde_wasm_bindgen::to_value(&json).map_err(|e| JsValue::from(e.to_string()))
1741 }
1742 Err(e) => Err(JsValue::from_str(&format!(
1743 "Wallet busy (get_addresses): {e}"
1744 ))),
1745 }
1746 }
1747
1748 #[cfg(target_arch = "wasm32")]
1749 #[wasm_bindgen(js_name = sync)]
1750 pub fn sync(&self, esplora_url: String) -> Result<js_sys::Promise, JsValue> {
1751 self.check_vitality()?;
1752 use crate::builder::{SyncRequestType, SyncSleeper};
1753 use bdk_esplora::EsploraAsyncExt;
1754
1755 let inner_rc = self.inner.clone();
1756
1757 Ok(wasm_bindgen_futures::future_to_promise(async move {
1758 zinc_log_debug!(
1759 target: LOG_TARGET_WASM,
1760 "sync start ({})",
1761 logging::redacted_field("esplora_url", &esplora_url)
1762 );
1763
1764 let (sync_req, sync_generation) = {
1766 match inner_rc.try_borrow_mut() {
1767 Ok(mut inner) => {
1768 if inner.is_syncing {
1769 zinc_log_debug!(target: LOG_TARGET_WASM, "Sync already in progress, skipping.");
1770 return Err(JsValue::from_str("Wallet Busy: Sync already in progress"));
1771 }
1772 inner.is_syncing = true;
1773 zinc_log_debug!(target: LOG_TARGET_WASM, "borrow successful, preparing requests");
1774 (inner.prepare_requests(), inner.account_generation())
1775 }
1776 Err(e) => {
1777 zinc_log_debug!(target: LOG_TARGET_WASM, "sync: FAILED TO BORROW INNER: {:?}", e);
1778 return Err(JsValue::from_str(&format!(
1779 "Failed to borrow wallet inner state: {}",
1780 e
1781 )));
1782 }
1783 }
1784 };
1785
1786 let client = match esplora_client::Builder::new(&esplora_url)
1787 .build_async_with_sleeper::<SyncSleeper>()
1788 {
1789 Ok(c) => c,
1790 Err(e) => {
1791 zinc_log_error!(target: LOG_TARGET_WASM, "failed to create esplora client");
1792 zinc_log_debug!(
1793 target: LOG_TARGET_WASM,
1794 "failed to create esplora client: {:?}",
1795 e
1796 );
1797 ZincWasmWallet::clear_syncing_if_generation_matches(&inner_rc, sync_generation);
1798 if let Some(stale) = ZincWasmWallet::generation_mismatch_error(
1799 &inner_rc,
1800 sync_generation,
1801 SYNC_STALE_ERROR,
1802 ) {
1803 return Err(stale);
1804 }
1805 return Err(JsValue::from(format!("{:?}", e)));
1806 }
1807 };
1808
1809 let vault_update_res: Result<bdk_wallet::Update, JsValue> = match sync_req.taproot {
1811 SyncRequestType::Full(req) => {
1812 zinc_log_info!(target: LOG_TARGET_WASM, "starting taproot full scan");
1813 client
1814 .full_scan(req, 20, 1)
1815 .await
1816 .map(|u| u.into())
1817 .map_err(|e| {
1818 zinc_log_debug!(target: LOG_TARGET_WASM, "Vault full scan failed: {:?}", e);
1819 JsValue::from(e.to_string())
1820 })
1821 }
1822 SyncRequestType::Incremental(req) => {
1823 zinc_log_info!(target: LOG_TARGET_WASM, "starting taproot incremental sync");
1824 client.sync(req, 1).await.map(|u| u.into()).map_err(|e| {
1825 zinc_log_debug!(target: LOG_TARGET_WASM, "Vault sync failed: {:?}", e);
1826 JsValue::from(e.to_string())
1827 })
1828 }
1829 };
1830
1831 let vault_update = match vault_update_res {
1832 Ok(u) => u,
1833 Err(e) => {
1834 ZincWasmWallet::clear_syncing_if_generation_matches(&inner_rc, sync_generation);
1835 if let Some(stale) = ZincWasmWallet::generation_mismatch_error(
1836 &inner_rc,
1837 sync_generation,
1838 SYNC_STALE_ERROR,
1839 ) {
1840 return Err(stale);
1841 }
1842 return Err(e);
1843 }
1844 };
1845
1846 let payment_update: Option<bdk_wallet::Update> = if let Some(req_type) =
1847 sync_req.payment
1848 {
1849 let update_res: Result<bdk_wallet::Update, JsValue> = match req_type {
1850 SyncRequestType::Full(req) => {
1851 zinc_log_info!(target: LOG_TARGET_WASM, "starting payment full scan");
1852 client
1853 .full_scan(req, 20, 1)
1854 .await
1855 .map(|u| u.into())
1856 .map_err(|e| {
1857 zinc_log_debug!(target: LOG_TARGET_WASM, "Payment full scan failed: {:?}", e);
1858 JsValue::from(e.to_string())
1859 })
1860 }
1861 SyncRequestType::Incremental(req) => {
1862 zinc_log_info!(
1863 target: LOG_TARGET_WASM,
1864 "starting payment incremental sync"
1865 );
1866 client.sync(req, 1).await.map(|u| u.into()).map_err(|e| {
1867 zinc_log_debug!(target: LOG_TARGET_WASM, "Payment sync failed: {:?}", e);
1868 JsValue::from(e.to_string())
1869 })
1870 }
1871 };
1872
1873 match update_res {
1874 Ok(u) => Some(u),
1875 Err(e) => {
1876 ZincWasmWallet::clear_syncing_if_generation_matches(
1877 &inner_rc,
1878 sync_generation,
1879 );
1880 if let Some(stale) = ZincWasmWallet::generation_mismatch_error(
1881 &inner_rc,
1882 sync_generation,
1883 SYNC_STALE_ERROR,
1884 ) {
1885 return Err(stale);
1886 }
1887 return Err(e);
1888 }
1889 }
1890 } else {
1891 None
1892 };
1893 zinc_log_debug!(target: LOG_TARGET_WASM, "sync: chain client returned");
1894
1895 let events = {
1897 match inner_rc.try_borrow_mut() {
1898 Ok(mut inner) => {
1899 if inner.account_generation() != sync_generation {
1900 return Err(JsValue::from_str(SYNC_STALE_ERROR));
1901 }
1902 zinc_log_debug!(target: LOG_TARGET_WASM, "sync: applying updates");
1903 let res = inner
1904 .apply_sync(vault_update, payment_update)
1905 .map_err(|e| {
1906 inner.is_syncing = false;
1907 zinc_log_error!(target: LOG_TARGET_WASM, "failed to apply sync");
1908 zinc_log_debug!(
1909 target: LOG_TARGET_WASM,
1910 "failed to apply sync update: {}",
1911 e
1912 );
1913 JsValue::from(e)
1914 })?;
1915 inner.is_syncing = false;
1916 zinc_log_debug!(target: LOG_TARGET_WASM, "sync: updates applied");
1917 res
1918 }
1919 Err(e) => {
1920 zinc_log_debug!(target: LOG_TARGET_WASM, "FAILED TO BORROW MUT INNER: {:?}", e);
1921 return Err(JsValue::from_str(&format!(
1922 "Failed to borrow wallet inner state (mut): {}",
1923 e
1924 )));
1925 }
1926 }
1927 };
1928 zinc_log_debug!(target: LOG_TARGET_WASM, "sync: finished. events: {:?}", events);
1929
1930 serde_wasm_bindgen::to_value(&events).map_err(|e| JsValue::from(e.to_string()))
1931 }))
1932 }
1933
1934 #[cfg(target_arch = "wasm32")]
1935 #[wasm_bindgen(js_name = discoverAccounts)]
1936 pub fn discover_accounts(
1937 &self,
1938 esplora_url: String,
1939 account_gap_limit: u32,
1940 address_scan_depth: Option<u32>,
1941 timeout_ms: Option<u32>,
1942 ) -> Result<js_sys::Promise, JsValue> {
1943 self.check_vitality()?;
1944
1945 let mnemonic = crate::keys::ZincMnemonic::parse(self.seed_phrase()?)
1946 .map_err(|e| JsValue::from_str(&e.to_string()))?;
1947 let seed = crate::builder::Seed64::from_array(*mnemonic.to_seed(""));
1948 let state = self.state_snapshot();
1949 let network = state.network;
1950 let scheme = state.scheme;
1951 let derivation_mode = state.derivation_mode;
1952 let payment_address_type = state.payment_address_type;
1953 let account_gap_limit = account_gap_limit.max(1);
1954 let requested_address_scan_depth = address_scan_depth.unwrap_or(20).max(1);
1955 let address_scan_depth = requested_address_scan_depth;
1956 let timeout_ms = timeout_ms.unwrap_or(120_000).max(1);
1957
1958 Ok(wasm_bindgen_futures::future_to_promise(async move {
1959 zinc_log_debug!(
1960 target: LOG_TARGET_WASM,
1961 "discover_accounts start ({}, account_gap_limit={}, requested_scan_depth={}, effective_scan_depth={}, timeout_ms={})",
1962 logging::redacted_field("esplora_url", &esplora_url),
1963 account_gap_limit,
1964 requested_address_scan_depth,
1965 address_scan_depth,
1966 timeout_ms
1967 );
1968
1969 let client = reqwest::Client::new();
1970 let mut max_active_index: i32 = -1;
1971 let mut current_gap = 0;
1972 let mut account_index: u32 = 0;
1973 let start_ms = js_sys::Date::now();
1974 let deadline_ms = start_ms + f64::from(timeout_ms);
1975
1976 loop {
1977 if js_sys::Date::now() >= deadline_ms {
1978 zinc_log_warn!(
1979 target: LOG_TARGET_WASM,
1980 "discover_accounts reached timeout budget after {}ms (best_so_far_max_active={})",
1981 timeout_ms,
1982 max_active_index
1983 );
1984 break;
1985 }
1986
1987 if current_gap >= account_gap_limit {
1988 break;
1989 }
1990
1991 let mut builder = WalletBuilder::from_seed(network, seed.clone());
1992 builder = builder
1993 .with_scheme(scheme)
1994 .with_derivation_mode(derivation_mode)
1995 .with_payment_address_type(payment_address_type)
1996 .with_account_index(account_index);
1997
1998 let zwallet = builder.build().map_err(|e| JsValue::from_str(&e))?;
1999 let timed_out = std::cell::Cell::new(false);
2000 const ADDRESS_REQUEST_TIMEOUT_MS: u32 = 2_000;
2001
2002 let check_activity = |addr_str: String| {
2003 let client = client.clone();
2004 let url = format!("{}/address/{}", esplora_url, addr_str);
2005 async move {
2006 let request = async {
2007 if let Ok(resp) = client.get(&url).send().await {
2008 if let Ok(json) = resp.json::<serde_json::Value>().await {
2009 let chain_txs =
2010 json["chain_stats"]["tx_count"].as_u64().unwrap_or(0);
2011 let mempool_txs =
2012 json["mempool_stats"]["tx_count"].as_u64().unwrap_or(0);
2013 return chain_txs > 0 || mempool_txs > 0;
2014 }
2015 }
2016 false
2017 };
2018 let timeout =
2019 gloo_timers::future::TimeoutFuture::new(ADDRESS_REQUEST_TIMEOUT_MS);
2020 futures_util::pin_mut!(request);
2021 futures_util::pin_mut!(timeout);
2022
2023 match futures_util::future::select(request, timeout).await {
2024 futures_util::future::Either::Left((value, _)) => value,
2025 futures_util::future::Either::Right((_timed_out, _)) => false,
2026 }
2027 }
2028 };
2029
2030 let has_activity =
2033 account_is_active_from_receive_scan(address_scan_depth, |address_index| {
2034 let vault_addr = zwallet.peek_taproot_address(address_index).to_string();
2035
2036 let payment_addr = if scheme == AddressScheme::Dual {
2037 zwallet
2038 .peek_payment_address(address_index)
2039 .map(|addr| addr.to_string())
2040 } else {
2041 None
2042 };
2043
2044 async {
2045 if js_sys::Date::now() >= deadline_ms {
2046 timed_out.set(true);
2047 return false;
2048 }
2049 if check_activity(vault_addr).await {
2050 return true;
2051 }
2052 if let Some(payment_addr) = payment_addr {
2053 return check_activity(payment_addr).await;
2054 }
2055 false
2056 }
2057 })
2058 .await;
2059
2060 if timed_out.get() {
2061 zinc_log_warn!(
2062 target: LOG_TARGET_WASM,
2063 "discover_accounts stopped mid-account scan due to timeout budget (account_index={})",
2064 account_index
2065 );
2066 break;
2067 }
2068
2069 if has_activity {
2070 max_active_index = account_index as i32;
2071 current_gap = 0;
2072 } else {
2073 current_gap += 1;
2074 }
2075
2076 account_index += 1;
2077 }
2078
2079 let discovered_count = (max_active_index + 1) as u32;
2080 let final_count = if discovered_count > 0 {
2081 discovered_count
2082 } else {
2083 1
2084 }; zinc_log_debug!(target: LOG_TARGET_WASM,
2087 "discover_accounts finished. Found max active = {}, returning discovery count {}",
2088 max_active_index,
2089 final_count
2090 );
2091
2092 Ok(JsValue::from(final_count))
2093 }))
2094 }
2095
2096 #[cfg(target_arch = "wasm32")]
2097 #[wasm_bindgen(js_name = discoverImportPath)]
2098 pub fn discover_import_path(
2099 &self,
2100 branch_str: &str,
2101 esplora_url: String,
2102 account_gap_limit: u32,
2103 address_scan_depth: Option<u32>,
2104 timeout_ms: Option<u32>,
2105 ) -> Result<js_sys::Promise, JsValue> {
2106 self.check_vitality()?;
2107
2108 let branch = match branch_str {
2109 "taproot" => ImportDiscoveryBranch::Taproot,
2110 "payment" => ImportDiscoveryBranch::Payment,
2111 _ => return Err(JsValue::from_str("Invalid import discovery branch")),
2112 };
2113
2114 let mnemonic = crate::keys::ZincMnemonic::parse(self.seed_phrase()?)
2115 .map_err(|e| JsValue::from_str(&e.to_string()))?;
2116 let seed = crate::builder::Seed64::from_array(*mnemonic.to_seed(""));
2117 let state = self.state_snapshot();
2118 let network = state.network;
2119 let scheme = state.scheme;
2120 let derivation_mode = state.derivation_mode;
2121 let payment_address_type = state.payment_address_type;
2122 let account_gap_limit = account_gap_limit.max(1);
2123 let address_scan_depth = address_scan_depth.unwrap_or(20).max(1);
2124 let timeout_ms = timeout_ms.unwrap_or(45_000).max(1);
2125 let branch_label = branch_str.to_string();
2126
2127 Ok(wasm_bindgen_futures::future_to_promise(async move {
2128 zinc_log_debug!(
2129 target: LOG_TARGET_WASM,
2130 "discover_import_path start ({}, branch={}, account_gap_limit={}, address_scan_depth={}, timeout_ms={})",
2131 logging::redacted_field("esplora_url", &esplora_url),
2132 branch_label,
2133 account_gap_limit,
2134 address_scan_depth,
2135 timeout_ms
2136 );
2137
2138 let client = reqwest::Client::new();
2139 let mut active_accounts = Vec::new();
2140 let mut current_gap = 0;
2141 let mut account_index: u32 = 0;
2142 let start_ms = js_sys::Date::now();
2143 let deadline_ms = start_ms + f64::from(timeout_ms);
2144
2145 loop {
2146 if js_sys::Date::now() >= deadline_ms {
2147 zinc_log_warn!(
2148 target: LOG_TARGET_WASM,
2149 "discover_import_path reached timeout budget after {}ms (active_accounts={})",
2150 timeout_ms,
2151 active_accounts.len()
2152 );
2153 break;
2154 }
2155
2156 if current_gap >= account_gap_limit {
2157 break;
2158 }
2159
2160 let mut builder = WalletBuilder::from_seed(network, seed.clone());
2161 builder = builder
2162 .with_scheme(scheme)
2163 .with_derivation_mode(derivation_mode)
2164 .with_payment_address_type(payment_address_type)
2165 .with_account_index(account_index);
2166
2167 let zwallet = builder.build().map_err(|e| JsValue::from_str(&e))?;
2168 let timed_out = std::cell::Cell::new(false);
2169 const ADDRESS_REQUEST_TIMEOUT_MS: u32 = 2_000;
2170
2171 let check_activity = |addr_str: String| {
2172 let client = client.clone();
2173 let url = format!("{}/address/{}", esplora_url, addr_str);
2174 async move {
2175 let request = async {
2176 if let Ok(resp) = client.get(&url).send().await {
2177 if let Ok(json) = resp.json::<serde_json::Value>().await {
2178 let chain_txs =
2179 json["chain_stats"]["tx_count"].as_u64().unwrap_or(0);
2180 let mempool_txs =
2181 json["mempool_stats"]["tx_count"].as_u64().unwrap_or(0);
2182 return chain_txs > 0 || mempool_txs > 0;
2183 }
2184 }
2185 false
2186 };
2187 let timeout =
2188 gloo_timers::future::TimeoutFuture::new(ADDRESS_REQUEST_TIMEOUT_MS);
2189 futures_util::pin_mut!(request);
2190 futures_util::pin_mut!(timeout);
2191
2192 match futures_util::future::select(request, timeout).await {
2193 futures_util::future::Either::Left((value, _)) => value,
2194 futures_util::future::Either::Right((_timed_out, _)) => false,
2195 }
2196 }
2197 };
2198
2199 let first_active_address_index =
2200 first_active_receive_index_from_scan(address_scan_depth, |address_index| {
2201 let branch_address = match branch {
2202 ImportDiscoveryBranch::Taproot => {
2203 Some(zwallet.peek_taproot_address(address_index).to_string())
2204 }
2205 ImportDiscoveryBranch::Payment => {
2206 if scheme == AddressScheme::Dual {
2207 zwallet
2208 .peek_payment_address(address_index)
2209 .map(|addr| addr.to_string())
2210 } else {
2211 None
2212 }
2213 }
2214 };
2215
2216 async {
2217 if js_sys::Date::now() >= deadline_ms {
2218 timed_out.set(true);
2219 return false;
2220 }
2221 if let Some(address) = branch_address {
2222 return check_activity(address).await;
2223 }
2224 false
2225 }
2226 })
2227 .await;
2228
2229 if timed_out.get() {
2230 zinc_log_warn!(
2231 target: LOG_TARGET_WASM,
2232 "discover_import_path stopped mid-account scan due to timeout budget (account_index={})",
2233 account_index
2234 );
2235 break;
2236 }
2237
2238 if let Some(first_active_address_index) = first_active_address_index {
2239 active_accounts.push(ImportPathAccountHit {
2240 index: account_index,
2241 first_active_address_index,
2242 });
2243 current_gap = 0;
2244 } else {
2245 current_gap += 1;
2246 }
2247
2248 account_index += 1;
2249 }
2250
2251 serde_wasm_bindgen::to_value(&ImportPathDiscoveryResponse { active_accounts })
2252 .map_err(|e| JsValue::from(e.to_string()))
2253 }))
2254 }
2255
2256 #[wasm_bindgen(js_name = loadInscriptions)]
2257 pub fn load_inscriptions(&self, val: JsValue) -> Result<u32, JsValue> {
2262 self.check_vitality()?;
2263 zinc_log_debug!(target: LOG_TARGET_WASM, "load_inscriptions called with JsValue");
2264
2265 let inscriptions: Vec<crate::ordinals::types::Inscription> =
2266 serde_wasm_bindgen::from_value(val).map_err(|e| {
2267 JsValue::from_str(&format!("Failed to parse inscriptions from JsValue: {e}"))
2268 })?;
2269
2270 zinc_log_debug!(target: LOG_TARGET_WASM,
2271 "Parsed {} inscriptions from JsValue. Updating wallet state...",
2272 inscriptions.len()
2273 );
2274
2275 match self.inner.try_borrow_mut() {
2276 Ok(mut inner) => {
2277 let count = inner.apply_unverified_inscriptions_cache(inscriptions);
2278 zinc_log_debug!(target: LOG_TARGET_WASM, "Inscriptions applied. New count: {}", count);
2279 Ok(count as u32)
2280 }
2281 Err(e) => {
2282 zinc_log_debug!(target: LOG_TARGET_WASM, "load_inscriptions FAILED to borrow mutable: {}", e);
2283 Err(JsValue::from_str(&format!(
2284 "Wallet busy (load_inscriptions): {e}"
2285 )))
2286 }
2287 }
2288 }
2289
2290 #[cfg(target_arch = "wasm32")]
2291 #[wasm_bindgen(js_name = syncOrdinals)]
2292 pub fn sync_ordinals(&self, ord_url: String) -> Result<js_sys::Promise, JsValue> {
2293 self.check_vitality()?;
2294 let inner_rc = self.inner.clone();
2295
2296 Ok(wasm_bindgen_futures::future_to_promise(async move {
2297 zinc_log_debug!(target: LOG_TARGET_WASM, "sync_ordinals start");
2298 let (addresses, wallet_height, sync_generation) = {
2300 match inner_rc.try_borrow_mut() {
2301 Ok(mut inner) => {
2302 if inner.is_syncing {
2303 zinc_log_debug!(target: LOG_TARGET_WASM, "Ord sync skipped: Wallet is busy syncing.");
2304 return Err(JsValue::from_str(
2305 "Wallet Busy: Operation already in progress",
2306 ));
2307 }
2308 inner.is_syncing = true;
2309 zinc_log_debug!(target: LOG_TARGET_WASM, "sync_ordinals: collecting active addresses...");
2310 let addrs = inner.collect_active_addresses();
2311 zinc_log_debug!(target: LOG_TARGET_WASM, "sync_ordinals: collected {} addresses", addrs.len());
2312 for a in &addrs {
2313 zinc_log_debug!(
2314 target: LOG_TARGET_WASM,
2315 "sync_ordinals address queued: {}",
2316 a
2317 );
2318 }
2319 let height = inner.vault_wallet.local_chain().tip().height();
2320 (addrs, height, inner.account_generation())
2321 }
2322 Err(e) => {
2323 zinc_log_debug!(target: LOG_TARGET_WASM, "sync_ordinals: FAILED TO BORROW INNER: {:?}", e);
2324 return Err(JsValue::from_str(&format!("Failed to borrow: {}", e)));
2325 }
2326 }
2327 };
2328
2329 let client = crate::ordinals::OrdClient::new(ord_url.to_string());
2331
2332 let ord_height = match client.get_indexing_height().await {
2334 Ok(h) => h,
2335 Err(e) => {
2336 zinc_log_debug!(target: LOG_TARGET_WASM, "Failed to get ord height: {:?}", e);
2337 ZincWasmWallet::clear_syncing_if_generation_matches(&inner_rc, sync_generation);
2338 if let Some(stale) = ZincWasmWallet::generation_mismatch_error(
2339 &inner_rc,
2340 sync_generation,
2341 ORD_SYNC_STALE_ERROR,
2342 ) {
2343 return Err(stale);
2344 }
2345 return Err(JsValue::from_str(&e.to_string()));
2346 }
2347 };
2348
2349 if ord_height < wallet_height.saturating_sub(1) {
2350 zinc_log_debug!(target: LOG_TARGET_WASM, "sync_ordinals: Ord lagging, setting verified=false");
2352 match inner_rc.try_borrow_mut() {
2353 Ok(mut inner) => {
2354 if inner.account_generation() != sync_generation {
2355 return Err(JsValue::from_str(ORD_SYNC_STALE_ERROR));
2356 }
2357 inner.ordinals_verified = false;
2358 inner.is_syncing = false;
2359 }
2360 Err(e) => {
2361 zinc_log_debug!(target: LOG_TARGET_WASM,
2362 "sync_ordinals: Failed to borrow mut for lag update: {}",
2363 e
2364 );
2365 }
2366 }
2367 return Err(JsValue::from_str(&format!(
2368 "Ord Indexer is lagging! Ord: {}, Wallet: {}. Safety lock engaged.",
2369 ord_height, wallet_height
2370 )));
2371 }
2372
2373 let rune_balances = match client.get_rune_balances_for_addresses(&addresses).await {
2375 Ok(balances) => balances,
2376 Err(e) => {
2377 zinc_log_debug!(
2378 target: LOG_TARGET_WASM,
2379 "Failed to fetch rune balances: {:?}",
2380 e
2381 );
2382 ZincWasmWallet::clear_syncing_if_generation_matches(&inner_rc, sync_generation);
2383 if let Some(stale) = ZincWasmWallet::generation_mismatch_error(
2384 &inner_rc,
2385 sync_generation,
2386 ORD_SYNC_STALE_ERROR,
2387 ) {
2388 return Err(stale);
2389 }
2390 return Err(JsValue::from_str(&format!(
2391 "Failed to fetch rune balances: {}",
2392 e
2393 )));
2394 }
2395 };
2396
2397 zinc_log_debug!(target: LOG_TARGET_WASM, "sync_ordinals: fetching inscriptions");
2398 let mut all_inscriptions = Vec::new();
2399 let mut protected_outpoints = std::collections::HashSet::new();
2400 for addr_str in addresses {
2401 match client.get_inscriptions(&addr_str).await {
2402 Ok(list) => {
2403 zinc_log_debug!(target: LOG_TARGET_WASM,
2404 "sync_ordinals: found {} inscriptions for {}",
2405 list.len(),
2406 addr_str
2407 );
2408 all_inscriptions.extend(list);
2409 }
2410 Err(e) => {
2411 zinc_log_debug!(target: LOG_TARGET_WASM, "Failed to fetch inscriptions for {}: {}", addr_str, e);
2412 ZincWasmWallet::clear_syncing_if_generation_matches(
2413 &inner_rc,
2414 sync_generation,
2415 );
2416 if let Some(stale) = ZincWasmWallet::generation_mismatch_error(
2417 &inner_rc,
2418 sync_generation,
2419 ORD_SYNC_STALE_ERROR,
2420 ) {
2421 return Err(stale);
2422 }
2423 return Err(JsValue::from_str(&format!(
2424 "Failed to fetch for {}: {}",
2425 addr_str, e
2426 )));
2427 }
2428 }
2429
2430 match client.get_protected_outpoints(&addr_str).await {
2431 Ok(outpoints) => {
2432 zinc_log_debug!(target: LOG_TARGET_WASM,
2433 "sync_ordinals: found {} protected outputs for {}",
2434 outpoints.len(),
2435 addr_str
2436 );
2437 protected_outpoints.extend(outpoints);
2438 }
2439 Err(e) => {
2440 zinc_log_debug!(target: LOG_TARGET_WASM,
2441 "Failed to fetch protected outputs for {}: {}",
2442 addr_str,
2443 e
2444 );
2445 match inner_rc.try_borrow_mut() {
2446 Ok(mut inner) => {
2447 if inner.account_generation() != sync_generation {
2448 return Err(JsValue::from_str(ORD_SYNC_STALE_ERROR));
2449 }
2450 inner.ordinals_verified = false;
2451 inner.is_syncing = false;
2452 }
2453 Err(_) => {}
2454 }
2455 return Err(JsValue::from_str(&format!(
2456 "Failed to fetch protected outputs for {}: {}",
2457 addr_str, e
2458 )));
2459 }
2460 }
2461 }
2462 zinc_log_debug!(target: LOG_TARGET_WASM,
2463 "sync_ordinals: total inscriptions found: {}",
2464 all_inscriptions.len()
2465 );
2466
2467 zinc_log_debug!(target: LOG_TARGET_WASM, "sync_ordinals: applying update (borrow mut)");
2469 let count = {
2470 match inner_rc.try_borrow_mut() {
2471 Ok(mut inner) => {
2472 if inner.account_generation() != sync_generation {
2473 return Err(JsValue::from_str(ORD_SYNC_STALE_ERROR));
2474 }
2475 let c = inner.apply_verified_ordinals_update(
2476 all_inscriptions,
2477 protected_outpoints,
2478 rune_balances,
2479 );
2480 inner.is_syncing = false; c
2482 }
2483 Err(e) => {
2484 zinc_log_debug!(target: LOG_TARGET_WASM, "sync_ordinals: FAILED TO BORROW MUT: {:?}", e);
2485 return Err(JsValue::from_str(&format!("Failed to borrow mut: {}", e)));
2486 }
2487 }
2488 };
2489
2490 Ok(JsValue::from(count as u32))
2491 }))
2492 }
2493 fn create_psbt_with_transport(
2498 &self,
2499 transport: crate::builder::CreatePsbtTransportRequest,
2500 busy_label: &str,
2501 ) -> Result<String, JsValue> {
2502 let request = crate::builder::CreatePsbtRequest::try_from(transport)
2503 .map_err(|e| JsValue::from_str(&e.to_string()))?;
2504
2505 match self.inner.try_borrow_mut() {
2506 Ok(mut inner) => inner
2507 .create_psbt_base64(&request)
2508 .map_err(|e| JsValue::from_str(&e.to_string())),
2509 Err(e) => Err(JsValue::from_str(&format!(
2510 "Wallet busy ({busy_label}): {e}"
2511 ))),
2512 }
2513 }
2514
2515 #[wasm_bindgen(js_name = createPsbt)]
2522 pub fn create_psbt_request(&self, request: JsValue) -> Result<String, JsValue> {
2523 self.check_vitality()?;
2524
2525 let transport: crate::builder::CreatePsbtTransportRequest =
2526 serde_wasm_bindgen::from_value(request)
2527 .map_err(|e| JsValue::from_str(&format!("Invalid request: {e}")))?;
2528
2529 self.create_psbt_with_transport(transport, "createPsbt")
2530 }
2531
2532 #[wasm_bindgen(js_name = createListingPurchase)]
2534 pub fn create_listing_purchase_request(&self, request: JsValue) -> Result<JsValue, JsValue> {
2535 self.check_vitality()?;
2536
2537 let request: crate::listing::CreateListingPurchaseRequest =
2538 serde_wasm_bindgen::from_value(request)
2539 .map_err(|e| JsValue::from_str(&format!("Invalid request: {e}")))?;
2540
2541 match self.inner.try_borrow_mut() {
2542 Ok(mut inner) => {
2543 let result = inner
2544 .create_listing_purchase(&request)
2545 .map_err(|e| JsValue::from_str(&e.to_string()))?;
2546 serde_wasm_bindgen::to_value(&result).map_err(|e| {
2547 JsValue::from_str(&format!("failed to serialize listing purchase: {e}"))
2548 })
2549 }
2550 Err(e) => Err(JsValue::from_str(&format!(
2551 "Wallet busy (createListingPurchase): {e}"
2552 ))),
2553 }
2554 }
2555
2556 #[doc(hidden)]
2561 pub fn create_psbt(
2562 &self,
2563 recipient: &str,
2564 amount_sats: u64,
2565 fee_rate_sat_vb: u64,
2566 ) -> Result<String, JsValue> {
2567 self.check_vitality()?;
2568 self.create_psbt_with_transport(
2569 crate::builder::CreatePsbtTransportRequest {
2570 recipient: recipient.to_string(),
2571 amount_sats,
2572 fee_rate_sat_vb,
2573 },
2574 "create_psbt",
2575 )
2576 }
2577
2578 #[wasm_bindgen(js_name = signPsbt)]
2581 pub fn sign_psbt(&self, psbt_base64: &str, options: JsValue) -> Result<String, JsValue> {
2582 self.check_vitality()?;
2583
2584 let sign_opts: Option<crate::builder::SignOptions> =
2585 if options.is_null() || options.is_undefined() {
2586 None
2587 } else {
2588 match serde_wasm_bindgen::from_value(options) {
2589 Ok(opts) => Some(opts),
2590 Err(e) => return Err(JsValue::from_str(&format!("Invalid options: {e}"))),
2591 }
2592 };
2593
2594 match self.inner.try_borrow_mut() {
2595 Ok(mut inner) => inner
2596 .sign_psbt(psbt_base64, sign_opts)
2597 .map_err(JsValue::from),
2598 Err(e) => Err(JsValue::from_str(&format!("Wallet busy (sign_psbt): {e}"))),
2599 }
2600 }
2601
2602 #[wasm_bindgen(js_name = prepareExternalSignPsbt)]
2604 pub fn prepare_external_sign_psbt(
2605 &self,
2606 psbt_base64: &str,
2607 options: JsValue,
2608 ) -> Result<String, JsValue> {
2609 self.check_vitality()?;
2610
2611 let sign_opts: Option<crate::builder::SignOptions> =
2612 if options.is_null() || options.is_undefined() {
2613 None
2614 } else {
2615 match serde_wasm_bindgen::from_value(options) {
2616 Ok(opts) => Some(opts),
2617 Err(e) => return Err(JsValue::from_str(&format!("Invalid options: {e}"))),
2618 }
2619 };
2620
2621 match self.inner.try_borrow() {
2622 Ok(inner) => inner
2623 .prepare_external_sign_psbt(psbt_base64, sign_opts)
2624 .map_err(JsValue::from),
2625 Err(e) => Err(JsValue::from_str(&format!(
2626 "Wallet busy (prepare_external_sign_psbt): {e}"
2627 ))),
2628 }
2629 }
2630
2631 #[wasm_bindgen(js_name = verifyExternalSignedPsbt)]
2633 pub fn verify_external_signed_psbt(
2634 &self,
2635 original_psbt_base64: &str,
2636 signed_psbt_base64: &str,
2637 required_input_indices: JsValue,
2638 finalize: bool,
2639 ) -> Result<String, JsValue> {
2640 self.check_vitality()?;
2641
2642 let indices: Option<Vec<usize>> =
2643 if required_input_indices.is_null() || required_input_indices.is_undefined() {
2644 None
2645 } else {
2646 match serde_wasm_bindgen::from_value(required_input_indices) {
2647 Ok(val) => Some(val),
2648 Err(e) => {
2649 return Err(JsValue::from_str(&format!(
2650 "Invalid required_input_indices: {e}"
2651 )))
2652 }
2653 }
2654 };
2655
2656 match self.inner.try_borrow() {
2657 Ok(inner) => inner
2658 .verify_external_signed_psbt(
2659 original_psbt_base64,
2660 signed_psbt_base64,
2661 indices.as_deref(),
2662 finalize,
2663 )
2664 .map_err(JsValue::from),
2665 Err(e) => Err(JsValue::from_str(&format!(
2666 "Wallet busy (verify_external_signed_psbt): {e}"
2667 ))),
2668 }
2669 }
2670
2671 #[wasm_bindgen(js_name = analyzePsbt)]
2674 pub fn analyze_psbt(&self, psbt_base64: &str) -> Result<String, JsValue> {
2675 self.check_vitality()?;
2676 match self.inner.try_borrow() {
2677 Ok(inner) => inner.analyze_psbt(psbt_base64).map_err(JsValue::from),
2678 Err(e) => Err(JsValue::from_str(&format!(
2679 "Wallet busy (analyze_psbt): {e}"
2680 ))),
2681 }
2682 }
2683
2684 #[wasm_bindgen(js_name = auditPsbt)]
2687 pub fn audit_psbt(&self, psbt_base64: &str, options: JsValue) -> Result<(), JsValue> {
2688 self.check_vitality()?;
2689
2690 let sign_opts: Option<crate::builder::SignOptions> =
2691 if options.is_null() || options.is_undefined() {
2692 None
2693 } else {
2694 match serde_wasm_bindgen::from_value(options) {
2695 Ok(opts) => Some(opts),
2696 Err(e) => return Err(JsValue::from_str(&format!("Invalid options: {e}"))),
2697 }
2698 };
2699
2700 use base64::Engine;
2701 let psbt_bytes = base64::engine::general_purpose::STANDARD
2702 .decode(psbt_base64)
2703 .map_err(|e| JsValue::from_str(&format!("Invalid base64: {e}")))?;
2704
2705 let psbt = bitcoin::psbt::Psbt::deserialize(&psbt_bytes)
2706 .map_err(|e| JsValue::from_str(&format!("Invalid PSBT: {e}")))?;
2707
2708 let inner = self
2709 .inner
2710 .try_borrow()
2711 .map_err(|e| JsValue::from_str(&format!("Wallet busy (audit_psbt): {e}")))?;
2712
2713 let mut known_inscriptions: std::collections::HashMap<
2715 (bitcoin::Txid, u32),
2716 Vec<(String, u64)>,
2717 > = std::collections::HashMap::new();
2718 for ins in &inner.inscriptions {
2719 known_inscriptions
2720 .entry((ins.satpoint.outpoint.txid, ins.satpoint.outpoint.vout))
2721 .or_default()
2722 .push((ins.id.clone(), ins.satpoint.offset));
2723 }
2724
2725 let allowed_inputs = sign_opts.as_ref().and_then(|o| o.sign_inputs.as_deref());
2727
2728 crate::ordinals::shield::audit_psbt(
2729 &psbt,
2730 &known_inscriptions,
2731 allowed_inputs,
2732 inner.vault_wallet.network(),
2733 )
2734 .map_err(|e| JsValue::from_str(&e.to_string()))
2735 }
2736
2737 pub fn sign_message(&self, address: &str, message: &str) -> Result<String, JsValue> {
2740 self.check_vitality()?;
2741 match self.inner.try_borrow() {
2742 Ok(inner) => inner
2743 .sign_message(address, message)
2744 .map_err(|e| JsValue::from_str(&e)),
2745 Err(e) => Err(JsValue::from_str(&format!(
2746 "Wallet busy (sign_message): {e}"
2747 ))),
2748 }
2749 }
2750
2751 #[wasm_bindgen(js_name = build_signed_pairing_ack)]
2755 pub fn build_signed_pairing_ack(
2756 &self,
2757 signed_request_json: &str,
2758 now_unix: i64,
2759 ack_ttl_secs: u32,
2760 granted_capabilities_json: Option<String>,
2761 ) -> Result<String, JsValue> {
2762 self.check_vitality()?;
2763 match self.inner.try_borrow() {
2764 Ok(inner) => {
2765 let wallet_secret_key_hex = inner
2766 .get_pairing_secret_key_hex()
2767 .map_err(|e| JsValue::from_str(&e))?;
2768 let signed_request: crate::sign_intent::SignedPairingRequestV1 =
2769 serde_json::from_str(signed_request_json).map_err(|e| {
2770 JsValue::from_str(&format!("invalid signed pairing request json: {e}"))
2771 })?;
2772 let granted_capabilities = match granted_capabilities_json {
2773 Some(raw_json) => {
2774 let policy: crate::sign_intent::CapabilityPolicyV1 =
2775 serde_json::from_str(&raw_json).map_err(|e| {
2776 JsValue::from_str(&format!(
2777 "invalid granted capabilities json: {e}"
2778 ))
2779 })?;
2780 Some(policy)
2781 }
2782 None => None,
2783 };
2784
2785 let signed_ack = crate::sign_intent::build_signed_pairing_ack_with_granted(
2786 &signed_request,
2787 &wallet_secret_key_hex,
2788 now_unix,
2789 i64::from(ack_ttl_secs),
2790 granted_capabilities,
2791 )
2792 .map_err(|e| JsValue::from_str(&e.to_string()))?;
2793
2794 serde_json::to_string(&signed_ack)
2795 .map_err(|e| JsValue::from_str(&format!("failed to serialize signed ack: {e}")))
2796 }
2797 Err(e) => Err(JsValue::from_str(&format!(
2798 "Wallet busy (build_signed_pairing_ack): {e}"
2799 ))),
2800 }
2801 }
2802
2803 #[wasm_bindgen(js_name = get_pairing_pubkey_hex)]
2805 pub fn get_pairing_pubkey_hex(&self) -> Result<String, JsValue> {
2806 self.check_vitality()?;
2807 match self.inner.try_borrow() {
2808 Ok(inner) => {
2809 let secret_hex = inner
2810 .get_pairing_secret_key_hex()
2811 .map_err(|e| JsValue::from_str(&e))?;
2812 crate::sign_intent::pubkey_hex_from_secret_key(&secret_hex)
2813 .map_err(|e| JsValue::from_str(&e.to_string()))
2814 }
2815 Err(e) => Err(JsValue::from_str(&format!(
2816 "Wallet busy (get_pairing_pubkey_hex): {e}"
2817 ))),
2818 }
2819 }
2820
2821 #[wasm_bindgen(js_name = build_signed_sign_intent_rejection_receipt_json)]
2823 pub fn build_signed_sign_intent_rejection_receipt_json(
2824 &self,
2825 signed_intent_json: &str,
2826 created_at_unix: i64,
2827 rejection_reason: &str,
2828 ) -> Result<String, JsValue> {
2829 self.check_vitality()?;
2830 let signed_intent: crate::sign_intent::SignedSignIntentV1 =
2831 serde_json::from_str(signed_intent_json)
2832 .map_err(|e| JsValue::from_str(&format!("invalid signed sign intent json: {e}")))?;
2833 match self.inner.try_borrow() {
2834 Ok(inner) => {
2835 let secret_hex = inner
2836 .get_pairing_secret_key_hex()
2837 .map_err(|e| JsValue::from_str(&e))?;
2838 let signed_receipt =
2839 crate::sign_intent::build_signed_sign_intent_rejection_receipt(
2840 &signed_intent,
2841 &secret_hex,
2842 created_at_unix,
2843 rejection_reason,
2844 )
2845 .map_err(|e| JsValue::from_str(&e.to_string()))?;
2846 serde_json::to_string(&signed_receipt).map_err(|e| {
2847 JsValue::from_str(&format!(
2848 "failed to serialize signed sign intent receipt: {e}"
2849 ))
2850 })
2851 }
2852 Err(e) => Err(JsValue::from_str(&format!(
2853 "Wallet busy (build_signed_sign_intent_rejection_receipt_json): {e}"
2854 ))),
2855 }
2856 }
2857
2858 #[wasm_bindgen(js_name = build_signed_sign_intent_approved_receipt_json)]
2860 pub fn build_signed_sign_intent_approved_receipt_json(
2861 &self,
2862 signed_intent_json: &str,
2863 created_at_unix: i64,
2864 signed_psbt_base64: Option<String>,
2865 artifact_json: Option<String>,
2866 ) -> Result<String, JsValue> {
2867 self.check_vitality()?;
2868 let signed_intent: crate::sign_intent::SignedSignIntentV1 =
2869 serde_json::from_str(signed_intent_json)
2870 .map_err(|e| JsValue::from_str(&format!("invalid signed sign intent json: {e}")))?;
2871 match self.inner.try_borrow() {
2872 Ok(inner) => {
2873 let secret_hex = inner
2874 .get_pairing_secret_key_hex()
2875 .map_err(|e| JsValue::from_str(&e))?;
2876 let signed_receipt = crate::sign_intent::build_signed_sign_intent_approved_receipt(
2877 &signed_intent,
2878 &secret_hex,
2879 created_at_unix,
2880 signed_psbt_base64.as_deref(),
2881 artifact_json.as_deref(),
2882 )
2883 .map_err(|e| JsValue::from_str(&e.to_string()))?;
2884 serde_json::to_string(&signed_receipt).map_err(|e| {
2885 JsValue::from_str(&format!(
2886 "failed to serialize signed sign intent receipt: {e}"
2887 ))
2888 })
2889 }
2890 Err(e) => Err(JsValue::from_str(&format!(
2891 "Wallet busy (build_signed_sign_intent_approved_receipt_json): {e}"
2892 ))),
2893 }
2894 }
2895
2896 #[wasm_bindgen(js_name = verify_sign_seller_input_scope_json)]
2898 pub fn verify_sign_seller_input_scope_json(
2899 &self,
2900 signed_intent_json: &str,
2901 now_unix: i64,
2902 ) -> Result<String, JsValue> {
2903 self.check_vitality()?;
2904 let plan =
2905 crate::sign_intent::verify_sign_seller_input_scope_json(signed_intent_json, now_unix)
2906 .map_err(|e| JsValue::from_str(&e.to_string()))?;
2907 serde_json::to_string(&plan)
2908 .map_err(|e| JsValue::from_str(&format!("failed to serialize scope plan: {e}")))
2909 }
2910
2911 #[wasm_bindgen(js_name = build_pairing_ack_envelope_json)]
2913 pub fn build_pairing_ack_envelope_json(
2914 &self,
2915 signed_ack_json: &str,
2916 created_at_unix: i64,
2917 ) -> Result<String, JsValue> {
2918 self.check_vitality()?;
2919 let signed_ack: crate::sign_intent::SignedPairingAckV1 =
2920 serde_json::from_str(signed_ack_json)
2921 .map_err(|e| JsValue::from_str(&format!("invalid signed pairing ack json: {e}")))?;
2922 let envelope = crate::sign_intent::PairingAckEnvelopeV1::new(signed_ack, created_at_unix)
2923 .map_err(|e| JsValue::from_str(&e.to_string()))?;
2924 serde_json::to_string(&envelope).map_err(|e| {
2925 JsValue::from_str(&format!("failed to serialize pairing ack envelope: {e}"))
2926 })
2927 }
2928
2929 #[wasm_bindgen(js_name = build_pairing_transport_event_json)]
2931 pub fn build_pairing_transport_event_json(
2932 &self,
2933 content_json: &str,
2934 type_tag: &str,
2935 pairing_id: &str,
2936 recipient_pubkey_hex: &str,
2937 created_at_unix: u64,
2938 ) -> Result<String, JsValue> {
2939 self.check_vitality()?;
2940 match self.inner.try_borrow() {
2941 Ok(inner) => {
2942 let secret_hex = inner
2943 .get_pairing_secret_key_hex()
2944 .map_err(|e| JsValue::from_str(&e))?;
2945 let event = crate::sign_intent::build_pairing_transport_event(
2946 content_json,
2947 type_tag,
2948 pairing_id,
2949 recipient_pubkey_hex,
2950 created_at_unix,
2951 &secret_hex,
2952 )
2953 .map_err(|e| JsValue::from_str(&e.to_string()))?;
2954 serde_json::to_string(&event).map_err(|e| {
2955 JsValue::from_str(&format!("failed to serialize pairing transport event: {e}"))
2956 })
2957 }
2958 Err(e) => Err(JsValue::from_str(&format!(
2959 "Wallet busy (build_pairing_transport_event_json): {e}"
2960 ))),
2961 }
2962 }
2963
2964 #[wasm_bindgen(js_name = decode_pairing_transport_event_content_json)]
2966 pub fn decode_pairing_transport_event_content_json(
2967 &self,
2968 event_json: &str,
2969 ) -> Result<String, JsValue> {
2970 self.check_vitality()?;
2971 let event: crate::sign_intent::NostrTransportEventV1 = serde_json::from_str(event_json)
2972 .map_err(|e| JsValue::from_str(&format!("invalid transport event json: {e}")))?;
2973 event
2974 .verify()
2975 .map_err(|e| JsValue::from_str(&e.to_string()))?;
2976
2977 match self.inner.try_borrow() {
2978 Ok(inner) => {
2979 let secret_hex = inner
2980 .get_pairing_secret_key_hex()
2981 .map_err(|e| JsValue::from_str(&e))?;
2982 crate::sign_intent::decode_pairing_transport_event_content_with_secret(
2983 &event,
2984 &secret_hex,
2985 )
2986 .map_err(|e| JsValue::from_str(&e.to_string()))
2987 }
2988 Err(e) => Err(JsValue::from_str(&format!(
2989 "Wallet busy (decode_pairing_transport_event_content_json): {e}"
2990 ))),
2991 }
2992 }
2993
2994 #[cfg(target_arch = "wasm32")]
2997 #[wasm_bindgen(js_name = broadcast)]
2998 pub fn broadcast(
2999 &self,
3000 signed_psbt_base64: String,
3001 esplora_url: String,
3002 ) -> Result<js_sys::Promise, JsValue> {
3003 self.check_vitality()?;
3004 use crate::builder::SyncSleeper;
3005
3006 Ok(wasm_bindgen_futures::future_to_promise(async move {
3007 use base64::Engine;
3009 let psbt_bytes = base64::engine::general_purpose::STANDARD
3010 .decode(&signed_psbt_base64)
3011 .map_err(|e| JsValue::from_str(&format!("Invalid base64: {e}")))?;
3012
3013 let psbt = bitcoin::psbt::Psbt::deserialize(&psbt_bytes)
3014 .map_err(|e| JsValue::from_str(&format!("Invalid PSBT: {e}")))?;
3015
3016 let tx = psbt
3018 .extract_tx()
3019 .map_err(|e| JsValue::from_str(&format!("Failed to extract tx: {e}")))?;
3020
3021 let client = esplora_client::Builder::new(&esplora_url)
3023 .build_async_with_sleeper::<SyncSleeper>()
3024 .map_err(|e| JsValue::from_str(&format!("Failed to create client: {e:?}")))?;
3025
3026 client
3027 .broadcast(&tx)
3028 .await
3029 .map_err(|e| JsValue::from_str(&format!("Broadcast failed: {e}")))?;
3030
3031 Ok(JsValue::from(tx.compute_txid().to_string()))
3032 }))
3033 }
3034}
3035
3036#[cfg(test)]
3038pub mod tests;