Skip to main content

zinc_core/
lib.rs

1//! Zinc Core - Bitcoin + Ordinals wallet engine.
2//!
3//! `zinc-core` provides reusable wallet primitives for native Rust and WASM hosts:
4//! mnemonic handling, descriptor-backed account management, sync helpers, transaction
5//! signing, and Ordinal Shield PSBT analysis.
6//!
7//! Quick start:
8//! ```rust
9//! use zinc_core::{Network, WalletBuilder, ZincMnemonic};
10//!
11//! let mnemonic = ZincMnemonic::parse(
12//!     "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
13//! )
14//! .expect("valid mnemonic");
15//! let mut wallet = WalletBuilder::from_mnemonic(Network::Regtest, &mnemonic)
16//!     .build()
17//!     .expect("wallet");
18//! let _address = wallet.next_taproot_address().expect("address");
19//! ```
20//!
21//! Additional examples are available in `examples/`:
22//! `wallet_setup`, `sync_and_balance`, and `psbt_sign_audit`.
23
24use 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
32// Core modules
33pub mod builder;
34pub mod crypto;
35pub mod error;
36/// Transaction history models and wallet history helpers.
37pub mod history;
38pub mod keys;
39/// Fixed-price listing and sale PSBT validation helpers.
40pub mod listing;
41/// Nostr event models and signing/verification helpers for decentralized listings.
42pub mod listing_nostr;
43/// Native Nostr relay publish/discovery transport for listing events.
44#[cfg(not(target_arch = "wasm32"))]
45pub mod listing_relay;
46/// Offer envelope models and deterministic offer hashing/signature helpers.
47pub mod offer;
48/// Offer acceptance safety checks and signing plan derivation.
49pub mod offer_accept;
50/// Offer creation helpers for ord-compatible buyer offers.
51pub mod offer_create;
52/// Nostr event models and signing/verification helpers for decentralized offers.
53pub mod offer_nostr;
54/// Native Nostr relay publish/discovery transport for offer events.
55#[cfg(not(target_arch = "wasm32"))]
56pub mod offer_relay;
57/// Ordinals data models, HTTP client, and protection analysis.
58pub mod ordinals;
59/// Signed pairing + sign-intent protocol primitives.
60pub mod sign_intent;
61
62// Re-exports for convenience
63pub 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
118// Re-export bitcoin types we use
119pub use bdk_wallet::bitcoin::Network;
120use bdk_wallet::KeychainKind;
121
122// ============================================================================
123// Core Logic (Pure Rust)
124// ============================================================================
125
126#[doc(hidden)]
127/// Mnemonic material returned by wallet generation/decryption helpers.
128pub struct WalletResult {
129    /// Full normalized BIP-39 mnemonic phrase.
130    pub phrase: String,
131    /// Phrase split into individual words.
132    pub words: Vec<String>,
133}
134
135#[doc(hidden)]
136/// Generate a new mnemonic-backed wallet result for native Rust callers.
137pub 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)]
146/// Validate whether `phrase` is a syntactically valid BIP-39 mnemonic.
147pub fn validate_mnemonic_internal(phrase: &str) -> bool {
148    ZincMnemonic::parse(phrase).is_ok()
149}
150
151#[doc(hidden)]
152/// Derive the first external Taproot address from a mnemonic on `network`.
153pub 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)]
170/// Encrypt a mnemonic phrase with a password and return serialized JSON payload.
171pub 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)]
178/// Decrypt an encrypted wallet JSON payload and recover mnemonic details.
179pub 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)]
202/// Encrypt arbitrary UTF-8 secret material with a password and return serialized JSON payload.
203pub 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)]
209/// Decrypt an encrypted secret JSON payload and recover UTF-8 plaintext.
210pub 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
218// ============================================================================
219// WASM Bindings
220// ============================================================================
221
222use 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, // "standard" | "legacy"
237    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    // 1. Build descriptors for this specific index
262    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    // 2. Initialize a temporary wallet to derive addresses
307    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    // 3. Probing
327    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    // Check Vault Address (Postage + Inscriptions)
334    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    // Check Payment Address (Spendable BTC)
346    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    // Index 0 is always returned to ensure at least one account exists
360    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    // 1. Get Balance from Esplora
388    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    // 2. Get Inscriptions from Ord
416    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            // Handle different JSON structures: { "inscriptions": [...] } or direct array [...]
426            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                        // Extract ID from object (handle various field names)
443                        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/// Initialize WASM module (call once on load).
539#[wasm_bindgen(start)]
540pub fn init() {
541    zinc_log_trace!(target: LOG_TARGET_WASM, "init invoked");
542    INIT.call_once(|| {
543        // Better panic messages in the console
544        console_error_panic_hook::set_once();
545        zinc_log_info!(target: LOG_TARGET_WASM, "WASM module initialized");
546    });
547}
548
549/// Set runtime log level for zinc-core internals.
550#[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/// Enable or disable zinc-core logging at runtime.
577#[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/// Get current runtime log level.
588#[wasm_bindgen]
589pub fn get_log_level() -> String {
590    logging::get_log_level().as_str().to_string()
591}
592
593/// Generate a new wallet with a random mnemonic.
594#[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/// Validate a mnemonic phrase.
608#[wasm_bindgen]
609pub fn validate_mnemonic(phrase: &str) -> bool {
610    validate_mnemonic_internal(phrase)
611}
612
613/// Derive a Taproot address from a mnemonic.
614#[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/// Encrypt a mnemonic with a password.
628#[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/// Encrypt arbitrary secret material with a password.
634#[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)]
640/// WASM response payload for mnemonic decryption.
641pub struct DecryptResponse {
642    /// Whether decryption succeeded.
643    pub success: bool,
644    /// Decrypted BIP-39 phrase.
645    pub phrase: String,
646    /// Decrypted phrase split into words.
647    pub words: Vec<String>,
648}
649
650/// Decrypt an encrypted wallet blob.
651#[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/// Decrypt encrypted secret material and return plaintext UTF-8.
693#[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/// Validate and verify a signed pairing request payload.
699///
700/// Returns a compact JSON string:
701/// `{ "ok": true, "pairingId": "<hex>" }`
702#[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/// Validate and verify a signed pairing ack payload.
714///
715/// Returns:
716/// `{ "ok": true, "ackId": "<hex>" }`
717#[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/// Validate and verify a pairing ack transport envelope payload.
729///
730/// Returns:
731/// `{ "ok": true, "envelopeId": "<hex>" }`
732#[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/// Validate and verify a signed pairing-complete receipt payload.
744///
745/// Returns:
746/// `{ "ok": true, "receiptId": "<hex>" }`
747#[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/// Validate and verify a signed Nostr transport event payload.
762///
763/// Returns:
764/// `{ "ok": true, "eventId": "<hex>" }`
765#[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/// Validate and verify a signed sign-intent payload.
777///
778/// Returns:
779/// `{ "ok": true, "intentId": "<hex>" }`
780#[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/// Validate and verify a signed sign-intent receipt payload.
792///
793/// Returns:
794/// `{ "ok": true, "receiptId": "<hex>" }`
795#[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/// Verify a signed pairing request + signed pairing ack bundle at a given unix timestamp.
935///
936/// Returns:
937/// `{ "ok": true, "approval": { ...PairingLinkApprovalV1 } }`
938#[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
957// ============================================================================
958// Stateful Wallet Interface
959// ============================================================================
960
961use 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]
988/// WASM-safe stateful wallet handle wrapping the core `ZincWallet`.
989pub 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        // Parse 4-byte fingerprint from hex or ignore depending on what we configured
1055        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    /// Create a wallet from a plaintext mnemonic phrase.
1183    ///
1184    /// `network` accepts: `mainnet`, `bitcoin`, `testnet`, `signet`, `regtest`.
1185    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    /// Initialize wallet from encrypted wallet payload (preferred for security).
1223    #[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    /// Initialize wallet from a watch-only single-address profile.
1266    #[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        // Defensive check: ensure the Rc's strong count is sane.
1304        // A count of 0 would trigger UB in Rc::clone / try_borrow.
1305        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    /// Export current in-memory wallet changesets as serialized JSON.
1364    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    /// Change the address scheme (Unified <-> Dual) on the fly.
1386    /// This rebuilds the internal wallet using the stored phrase.
1387    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    /// Switch the active account index.
1409    /// This rebuilds the internal wallet logic for the new account.
1410    /// Note: Persistence is NOT carried over automatically for clear separation.
1411    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    /// Change the network on the fly.
1427    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    /// Switch logical account derivation mode.
1451    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    /// Get the current derivation mode label.
1472    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    /// Switch payment address type for dual scheme wallets.
1480    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    /// Get the current payment address type label.
1502    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    /// Enumerate account previews from index `0..count` for the active seed.
1512    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    /// High-performance parallel probing of hardware account ranges.
1526    /// Checks both Standard and Legacy paths for balances and inscriptions.
1527    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            // Parallelism configuration
1552            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                        // 1. Probe Standard Path (m/86'/0'/idx' and m/84'/0'/idx')
1570                        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                        // 2. Probe Legacy Path (m/86'/0'/0'/0/idx and m/84'/0'/0'/0/idx)
1584                        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    /// Return cached inscription list currently loaded in wallet state.
1617    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    /// Return cached read-only rune balances currently loaded in wallet state.
1629    #[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    /// Return total, spendable, display-spendable, and inscribed balances.
1642    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                // Use explicit serializer to ensure maps are converted to JS objects, not Map class
1670                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    /// Return recent wallet transactions ordered by pending-first then newest-first.
1682    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    /// Return first receive addresses/public keys for taproot/payment roles.
1697    ///
1698    /// In unified mode, `payment*` mirrors the same taproot branch.
1699    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                // We use inner.is_unified() so address behavior follows active inner wallet state.
1717                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                    // Backward-compatible aliases for older clients.
1737                    "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            // 1. Prepare Request (lock briefly)
1765            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            // 2. Fetch (NO LOCK HELD)
1810            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            // 3. Apply (lock briefly)
1896            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                // Scan each account's receive chain deeply enough to catch funds parked
2031                // on later derived addresses during recovery.
2032                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            }; // Always show at least 1 (also on timeout)
2085
2086            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    /// Replace wallet inscription cache from a JS value of `Inscription[]`.
2258    ///
2259    /// Security: this is treated as unverified metadata cache only. Call
2260    /// `syncOrdinals` before spend flows that require verified protection state.
2261    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            // 1. Collect info needed for sync (Borrow Read)
2299            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            // 2. Perform Network IO (NO Borrow Held)
2330            let client = crate::ordinals::OrdClient::new(ord_url.to_string());
2331
2332            // 2a. Check Lag
2333            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                // We need to set verified=false safely
2351                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            // 2b. Fetch rune balances and artifact metadata
2374            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            // 3. Apply Update (Borrow Mut)
2468            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; // FINISHED
2481                        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    // ========================================================================
2494    // Send Flow
2495    // ========================================================================
2496
2497    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    /// Create an unsigned PSBT for sending BTC from an object request.
2516    ///
2517    /// Request shape:
2518    /// - `recipient: string`
2519    /// - `amountSats: number`
2520    /// - `feeRateSatVb: number`
2521    #[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    /// Create a buyer-funded, buyer-signed listing purchase PSBT.
2533    #[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    /// Create an unsigned PSBT for sending BTC from positional args.
2557    ///
2558    /// Deprecated migration wrapper for consumers that haven't moved to
2559    /// `createPsbt(request)` yet.
2560    #[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    /// Sign a PSBT using the wallet's internal keys.
2579    /// Returns the signed PSBT as a base64-encoded string.
2580    #[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    /// Prepare a PSBT for external hardware signing.
2603    #[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    /// Verify a PSBT signed externally by a hardware wallet.
2632    #[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    /// Analyzes a PSBT for Ordinal Shield protection.
2672    /// Returns a JSON string containing the `AnalysisResult`.
2673    #[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    /// Audits a PSBT under the warn-only Ordinal Shield policy.
2685    /// Returns `Ok(())` when analysis succeeds, or an `Error` for malformed/unanalyzable payloads.
2686    #[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        // 1. Build known_inscriptions map
2714        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        // 2. Perform Audit
2726        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    /// Sign a message using the private key corresponding to the address.
2738    /// Returns signature string (base64).
2739    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    /// Build a signed pairing-ack JSON payload for a validated signed pairing request.
2752    ///
2753    /// Uses the active account's first taproot key (`m/86'/coin'/account'/0/0`) as signer.
2754    #[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    /// Return the current account pairing transport pubkey (x-only Schnorr).
2804    #[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    /// Build and sign a rejected sign-intent receipt using the account pairing key.
2822    #[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    /// Build and sign an approved sign-intent receipt using the account pairing key.
2859    #[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    /// Verify and extract seller signing scope for a signed `SignSellerInput` intent.
2897    #[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    /// Build a pairing ack envelope JSON payload from a signed pairing ack JSON string.
2912    #[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    /// Build and sign a generic Nostr transport event using the account pairing key.
2930    #[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    /// Decode/decrypt pairing transport event content using the account pairing key.
2965    #[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    /// Broadcast a signed PSBT to the network.
2995    /// Returns the transaction ID (txid) as a hex string.
2996    #[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            // Decode PSBT - no borrow needed
3008            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            // Extract the finalized transaction
3017            let tx = psbt
3018                .extract_tx()
3019                .map_err(|e| JsValue::from_str(&format!("Failed to extract tx: {e}")))?;
3020
3021            // Broadcast via Esplora (no RefCell borrow needed)
3022            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// Integration tests under src/tests/.
3037#[cfg(test)]
3038pub mod tests;