Skip to main content

zinc_core/
builder.rs

1//! Core wallet construction and stateful operations.
2//!
3//! This module contains the `WalletBuilder` entrypoint plus the primary
4//! `ZincWallet` runtime used by both native Rust and WASM bindings.
5
6use bdk_chain::spk_client::{FullScanRequest, SyncRequest};
7use bdk_chain::Merge;
8use bdk_esplora::EsploraAsyncExt;
9
10use bdk_wallet::{KeychainKind, Wallet};
11use bitcoin::address::NetworkUnchecked;
12use bitcoin::psbt::Psbt;
13use bitcoin::{Address, Amount, FeeRate, Network, Transaction};
14// use bitcoin::PsbtSighashType; // Failed
15use serde::{Deserialize, Serialize};
16
17use crate::error::ZincError;
18use crate::keys::ZincMnemonic;
19
20const LOG_TARGET_BUILDER: &str = "zinc_core::builder";
21
22/// Optional controls for PSBT signing behavior.
23#[derive(Debug, Clone, Deserialize, Default)]
24#[serde(rename_all = "camelCase")]
25pub struct SignOptions {
26    /// Restrict signing to specific input indices.
27    pub sign_inputs: Option<Vec<usize>>,
28    /// Override the PSBT sighash type as raw `u8`.
29    pub sighash: Option<u8>,
30    /// If true, finalize the PSBT after signing (for internal wallet use).
31    /// Defaults to false for dApp/marketplace compatibility.
32    #[serde(default)]
33    pub finalize: bool,
34}
35
36/// Strongly-typed 64-byte seed material used by canonical constructors.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub struct Seed64([u8; 64]);
39
40impl Seed64 {
41    /// Create a seed wrapper from a 64-byte array.
42    #[must_use]
43    pub const fn from_array(bytes: [u8; 64]) -> Self {
44        Self(bytes)
45    }
46}
47
48impl AsRef<[u8]> for Seed64 {
49    fn as_ref(&self) -> &[u8] {
50        &self.0
51    }
52}
53
54impl TryFrom<&[u8]> for Seed64 {
55    type Error = ZincError;
56
57    fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
58        let array: [u8; 64] = value.try_into().map_err(|_| {
59            ZincError::ConfigError(format!(
60                "Invalid seed length: {}. Expected 64 bytes.",
61                value.len()
62            ))
63        })?;
64        Ok(Self(array))
65    }
66}
67
68/// Typed request for PSBT creation in native Rust flows.
69#[derive(Debug, Clone)]
70pub struct CreatePsbtRequest {
71    /// Recipient address (network checked at build time).
72    pub recipient: Address<NetworkUnchecked>,
73    /// Amount in satoshis.
74    pub amount: Amount,
75    /// Fee rate in sat/vB.
76    pub fee_rate: FeeRate,
77}
78
79impl CreatePsbtRequest {
80    /// Build a typed request from transport-friendly inputs.
81    pub fn from_parts(
82        recipient: &str,
83        amount_sats: u64,
84        fee_rate_sat_vb: u64,
85    ) -> Result<Self, ZincError> {
86        let recipient = recipient
87            .parse::<Address<NetworkUnchecked>>()
88            .map_err(|e| ZincError::ConfigError(format!("Invalid address: {e}")))?;
89        let fee_rate = FeeRate::from_sat_per_vb(fee_rate_sat_vb)
90            .ok_or_else(|| ZincError::ConfigError("Invalid fee rate".to_string()))?;
91
92        Ok(Self {
93            recipient,
94            amount: Amount::from_sat(amount_sats),
95            fee_rate,
96        })
97    }
98}
99
100/// Transport-friendly PSBT creation request used by WASM/RPC boundaries.
101#[derive(Debug, Clone, Deserialize, Serialize)]
102#[serde(rename_all = "camelCase")]
103pub struct CreatePsbtTransportRequest {
104    /// Recipient address string.
105    pub recipient: String,
106    /// Amount in satoshis.
107    pub amount_sats: u64,
108    /// Fee rate in sat/vB.
109    pub fee_rate_sat_vb: u64,
110}
111
112impl TryFrom<CreatePsbtTransportRequest> for CreatePsbtRequest {
113    type Error = ZincError;
114
115    fn try_from(value: CreatePsbtTransportRequest) -> Result<Self, Self::Error> {
116        Self::from_parts(&value.recipient, value.amount_sats, value.fee_rate_sat_vb)
117    }
118}
119
120/// Address derivation mode for a wallet account.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum AddressScheme {
123    /// Single-wallet mode (taproot only).
124    Unified,
125    /// Two-wallet mode (taproot + payment).
126    Dual,
127}
128
129#[cfg(target_arch = "wasm32")]
130#[derive(Debug, Clone, Copy, Default)]
131pub struct WasmSleeper;
132
133#[cfg(target_arch = "wasm32")]
134pub struct WasmSleep(gloo_timers::future::TimeoutFuture);
135
136#[cfg(target_arch = "wasm32")]
137impl std::future::Future for WasmSleep {
138    type Output = ();
139    fn poll(
140        mut self: std::pin::Pin<&mut Self>,
141        cx: &mut std::task::Context<'_>,
142    ) -> std::task::Poll<Self::Output> {
143        std::pin::Pin::new(&mut self.0).poll(cx)
144    }
145}
146
147#[cfg(target_arch = "wasm32")]
148// SAFETY: WASM is single-threaded, so we can safely implement Send
149#[allow(unsafe_code)]
150unsafe impl Send for WasmSleep {}
151
152#[cfg(target_arch = "wasm32")]
153impl esplora_client::Sleeper for WasmSleeper {
154    type Sleep = WasmSleep;
155    fn sleep(dur: std::time::Duration) -> Self::Sleep {
156        WasmSleep(gloo_timers::future::TimeoutFuture::new(
157            dur.as_millis() as u32
158        ))
159    }
160}
161
162#[cfg(target_arch = "wasm32")]
163pub type SyncSleeper = WasmSleeper;
164
165/// Native async sleeper used by Esplora clients on non-WASM targets.
166#[cfg(not(target_arch = "wasm32"))]
167#[derive(Debug, Clone, Copy, Default)]
168pub struct TokioSleeper;
169
170#[cfg(not(target_arch = "wasm32"))]
171impl esplora_client::Sleeper for TokioSleeper {
172    type Sleep = tokio::time::Sleep;
173    fn sleep(dur: std::time::Duration) -> Self::Sleep {
174        tokio::time::sleep(dur)
175    }
176}
177
178/// Platform-specific async sleeper used for sync calls.
179#[cfg(not(target_arch = "wasm32"))]
180pub type SyncSleeper = TokioSleeper;
181
182/// Return the current UNIX epoch seconds for the active target.
183pub fn now_unix() -> u64 {
184    #[cfg(target_arch = "wasm32")]
185    {
186        (js_sys::Date::now() / 1000.0) as u64
187    }
188
189    #[cfg(not(target_arch = "wasm32"))]
190    {
191        std::time::SystemTime::now()
192            .duration_since(std::time::UNIX_EPOCH)
193            .unwrap_or_default()
194            .as_secs()
195    }
196}
197
198/// Builder for constructing a `ZincWallet` from seed, network, and options.
199pub struct WalletBuilder {
200    network: Network,
201    seed: Vec<u8>,
202    scheme: AddressScheme,
203    persistence: Option<ZincPersistence>,
204    account_index: u32,
205}
206
207/// Stateful wallet runtime that owns account wallets and safety state.
208pub struct ZincWallet {
209    // We use Option for payment_wallet to support Unified mode (where only taproot exists)
210    // In Unified mode, payment calls are routed to taproot.
211    /// Taproot wallet (always present).
212    pub(crate) vault_wallet: Wallet,
213    /// Optional payment wallet used in dual-account scheme.
214    pub(crate) payment_wallet: Option<Wallet>,
215    /// Current address scheme in use.
216    pub(crate) scheme: AddressScheme,
217    // Store original loaded changesets to merge with staged changes for full persistence
218    /// Loaded taproot changeset baseline used for persistence merges.
219    pub(crate) loaded_vault_changeset: bdk_wallet::ChangeSet,
220    /// Loaded payment changeset baseline used for persistence merges.
221    pub(crate) loaded_payment_changeset: Option<bdk_wallet::ChangeSet>,
222    /// Active account index.
223    pub(crate) account_index: u32,
224    // Ordinal Shield State (In-Memory Only)
225    /// Outpoints currently marked as inscribed/protected.
226    pub(crate) inscribed_utxos: std::collections::HashSet<bitcoin::OutPoint>,
227    /// Cached inscription metadata known to the wallet.
228    pub(crate) inscriptions: Vec<crate::ordinals::types::Inscription>,
229    /// Whether ordinals protection state is currently verified.
230    pub(crate) ordinals_verified: bool,
231    /// Whether inscription metadata refresh has completed.
232    pub(crate) ordinals_metadata_complete: bool,
233    master_xprv: bdk_wallet::bitcoin::bip32::Xpriv,
234    /// Guard flag used to prevent overlapping sync operations.
235    #[allow(dead_code)]
236    pub(crate) is_syncing: bool,
237    /// Monotonic generation used to invalidate stale async operations.
238    pub(crate) account_generation: u64,
239}
240
241/// Describes how chain data should be fetched for a keychain set.
242pub enum SyncRequestType {
243    /// Full scan request.
244    Full(FullScanRequest<KeychainKind>),
245    /// Incremental sync request.
246    Incremental(SyncRequest<(KeychainKind, u32)>),
247}
248
249/// Bundled sync request for taproot and optional payment wallets.
250pub struct ZincSyncRequest {
251    /// Taproot sync request.
252    pub taproot: SyncRequestType,
253    /// Optional payment sync request.
254    pub payment: Option<SyncRequestType>,
255}
256
257/// User-facing balance view that separates total, spendable, and inscribed value.
258#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
259pub struct ZincBalance {
260    /// Raw combined wallet balance.
261    pub total: bdk_wallet::Balance,
262    /// Spendable balance after protection filtering.
263    pub spendable: bdk_wallet::Balance,
264    /// Display-focused spendable balance (payment wallet in dual mode).
265    pub display_spendable: bdk_wallet::Balance,
266    /// Estimated value currently marked as inscribed/protected.
267    pub inscribed: u64,
268}
269
270/// Account summary returned by discovery and account listing APIs.
271#[derive(Debug, Clone, Serialize, Deserialize)]
272#[serde(rename_all = "camelCase")]
273pub struct Account {
274    /// Account index.
275    pub index: u32,
276    /// Human-readable account label.
277    pub label: String,
278    /// Taproot receive address.
279    #[serde(alias = "vaultAddress")]
280    pub taproot_address: String,
281    /// Taproot public key for account receive path.
282    #[serde(alias = "vaultPublicKey")]
283    pub taproot_public_key: String,
284    /// Payment receive address when in dual mode.
285    pub payment_address: Option<String>,
286    /// Payment public key when in dual mode.
287    pub payment_public_key: Option<String>,
288}
289
290/// Derived descriptor/public-key material for one account discovery candidate.
291#[derive(Debug, Clone)]
292pub struct DiscoveryAccountPlan {
293    /// Account index.
294    pub index: u32,
295    /// Taproot external descriptor template.
296    pub taproot_descriptor: String,
297    /// Taproot internal/change descriptor template.
298    pub taproot_change_descriptor: String,
299    /// Account taproot public key.
300    pub taproot_public_key: String,
301    /// Optional payment external descriptor template.
302    pub payment_descriptor: Option<String>,
303    /// Optional payment internal/change descriptor template.
304    pub payment_change_descriptor: Option<String>,
305    /// Optional payment public key.
306    pub payment_public_key: Option<String>,
307}
308
309/// Precomputed account discovery context that avoids exposing raw xprv externally.
310#[derive(Debug, Clone)]
311pub struct DiscoveryContext {
312    /// Network for the descriptors in this context.
313    pub network: Network,
314    /// Address scheme for descriptors in this context.
315    pub scheme: AddressScheme,
316    /// Account plans to evaluate.
317    pub accounts: Vec<DiscoveryAccountPlan>,
318}
319
320impl ZincWallet {
321    /// Return the cached inscriptions currently tracked by the wallet.
322    #[must_use]
323    pub fn inscriptions(&self) -> &[crate::ordinals::types::Inscription] {
324        &self.inscriptions
325    }
326
327    /// Return the current account generation counter.
328    #[must_use]
329    pub fn account_generation(&self) -> u64 {
330        self.account_generation
331    }
332
333    /// Return the currently active account index.
334    #[must_use]
335    pub fn active_account_index(&self) -> u32 {
336        self.account_index
337    }
338
339    /// Return whether a sync operation is currently in progress.
340    #[must_use]
341    pub fn is_syncing(&self) -> bool {
342        self.is_syncing
343    }
344
345    /// Return whether ordinals protection data is verified.
346    #[must_use]
347    pub fn ordinals_verified(&self) -> bool {
348        self.ordinals_verified
349    }
350
351    /// Return whether ordinals metadata refresh completed successfully.
352    #[must_use]
353    pub fn ordinals_metadata_complete(&self) -> bool {
354        self.ordinals_metadata_complete
355    }
356
357    /// Return `true` when the wallet uses unified addressing.
358    pub fn is_unified(&self) -> bool {
359        self.scheme == AddressScheme::Unified
360    }
361
362    /// Return `true` when wallet state indicates a full scan is needed.
363    pub fn needs_full_scan(&self) -> bool {
364        // If we have no transactions and the tip is at genesis (or missing), we likely need a full scan
365        self.vault_wallet.local_chain().tip().height() == 0
366    }
367
368    /// Reveal and return the next taproot receive address.
369    pub fn next_taproot_address(&mut self) -> Result<Address, String> {
370        let info = self
371            .vault_wallet
372            .reveal_next_address(KeychainKind::External);
373        Ok(info.address)
374    }
375
376    /// Peek a taproot receive address at `index` without advancing state.
377    pub fn peek_taproot_address(&self, index: u32) -> Address {
378        self.vault_wallet
379            .peek_address(KeychainKind::External, index)
380            .address
381    }
382
383    /// Reveal and return the next payment address in dual mode.
384    ///
385    /// In unified mode this returns the next taproot address.
386    pub fn get_payment_address(&mut self) -> Result<bitcoin::Address, String> {
387        if self.scheme == AddressScheme::Dual {
388            if let Some(wallet) = &mut self.payment_wallet {
389                Ok(wallet.reveal_next_address(KeychainKind::External).address)
390            } else {
391                Err("Payment wallet not initialized".to_string())
392            }
393        } else {
394            self.next_taproot_address()
395        }
396    }
397
398    /// Peek a payment receive address at `index`.
399    ///
400    /// In unified mode this resolves to the taproot branch.
401    pub fn peek_payment_address(&self, index: u32) -> Option<Address> {
402        if self.scheme == AddressScheme::Dual {
403            self.payment_wallet
404                .as_ref()
405                .map(|w| w.peek_address(KeychainKind::External, index).address)
406        } else {
407            Some(self.peek_taproot_address(index))
408        }
409    }
410
411    /// Export a persistence snapshot containing merged loaded+staged changesets.
412    pub fn export_changeset(&self) -> Result<ZincPersistence, String> {
413        // 1. Vault: Start with loaded changeset, merge staged changes
414        let mut vault_changeset = self.loaded_vault_changeset.clone();
415        if let Some(staged) = self.vault_wallet.staged() {
416            vault_changeset.merge(staged.clone());
417        }
418
419        // Ensure network and genesis are set (safety net for fresh wallets/empty state)
420        let network = self.vault_wallet.network();
421        vault_changeset.network = Some(network);
422
423        // Ensure descriptors are set
424        vault_changeset.descriptor = Some(
425            self.vault_wallet
426                .public_descriptor(KeychainKind::External)
427                .clone(),
428        );
429        vault_changeset.change_descriptor = Some(
430            self.vault_wallet
431                .public_descriptor(KeychainKind::Internal)
432                .clone(),
433        );
434
435        let genesis_hash = bitcoin::blockdata::constants::genesis_block(network)
436            .header
437            .block_hash();
438        // Check if genesis is missing directly
439        vault_changeset
440            .local_chain
441            .blocks
442            .entry(0)
443            .or_insert(Some(genesis_hash));
444
445        // 2. Payment: Start with loaded changeset, merge staged changes
446        let mut payment_changeset = self.loaded_payment_changeset.clone();
447
448        if let Some(w) = &self.payment_wallet {
449            // Ensure we have a base changeset to work with if we initialized one
450            let mut pcs = payment_changeset.take().unwrap_or_default();
451
452            if let Some(staged) = w.staged() {
453                pcs.merge(staged.clone());
454            }
455
456            let net = w.network();
457            pcs.network = Some(net);
458
459            // Ensure descriptors are set for payment wallet
460            pcs.descriptor = Some(w.public_descriptor(KeychainKind::External).clone());
461            pcs.change_descriptor = Some(w.public_descriptor(KeychainKind::Internal).clone());
462
463            let gen_hash = bitcoin::blockdata::constants::genesis_block(net)
464                .header
465                .block_hash();
466            pcs.local_chain.blocks.entry(0).or_insert(Some(gen_hash));
467            // Assign back to option
468            payment_changeset = Some(pcs);
469        } else {
470            // If no payment wallet, ensure we don't persist stale data
471            payment_changeset = None;
472        }
473
474        Ok(ZincPersistence {
475            taproot: Some(vault_changeset),
476            payment: payment_changeset,
477        })
478    }
479
480    /// Check whether the configured Esplora endpoint is reachable.
481    pub async fn check_connection(esplora_url: &str) -> bool {
482        let client =
483            esplora_client::Builder::new(esplora_url).build_async_with_sleeper::<SyncSleeper>();
484
485        match client {
486            Ok(c) => c.get_height().await.is_ok(),
487            Err(_) => false,
488        }
489    }
490
491    /// Build sync requests for taproot/payment wallets.
492    pub fn prepare_requests(&self) -> ZincSyncRequest {
493        let now = now_unix();
494        let use_full = self.needs_full_scan();
495
496        let vault = if use_full {
497            SyncRequestType::Full(self.vault_wallet.start_full_scan_at(now).build())
498        } else {
499            // TODO: BDK's start_sync_with_revealed_spks() triggers a panic on WASM (likely std::time usage).
500            // For now, we force a full scan from the current time. This is safe but less efficient than incremental.
501            // Future optimization: Fix incremental sync for WASM.
502            SyncRequestType::Full(self.vault_wallet.start_full_scan_at(now).build())
503        };
504
505        let payment = self.payment_wallet.as_ref().map(|w| {
506            // Same workaround for payment wallet
507            SyncRequestType::Full(w.start_full_scan_at(now).build())
508        });
509
510        ZincSyncRequest {
511            taproot: vault,
512            payment,
513        }
514    }
515
516    /// Apply taproot/payment updates and return merged event strings.
517    pub fn apply_sync(
518        &mut self,
519        vault_update: impl Into<bdk_wallet::Update>,
520        payment_update: Option<impl Into<bdk_wallet::Update>>,
521    ) -> Result<Vec<String>, String> {
522        let mut all_events = Vec::new();
523
524        // 1. Apply Vault Update
525        let vault_events = self
526            .vault_wallet
527            .apply_update_events(vault_update)
528            .map_err(|e| e.to_string())?;
529
530        for event in vault_events {
531            all_events.push(format!("taproot:{:?}", event));
532        }
533
534        // 2. Apply Payment Update
535        if let (Some(w), Some(u)) = (&mut self.payment_wallet, payment_update) {
536            let payment_events = w.apply_update_events(u).map_err(|e| e.to_string())?;
537            for event in payment_events {
538                all_events.push(format!("payment:{:?}", event));
539            }
540        }
541
542        Ok(all_events)
543    }
544
545    /// Rebuild wallet state from current public descriptors, clearing cached sync state.
546    pub fn reset_sync_state(&mut self) -> Result<(), String> {
547        zinc_log_info!(
548            target: LOG_TARGET_BUILDER,
549            "resetting wallet sync state (chain mismatch recovery)"
550        );
551
552        // 1. Reset Vault Wallet
553        let vault_desc = self
554            .vault_wallet
555            .public_descriptor(KeychainKind::External)
556            .to_string();
557        let vault_change_desc = self
558            .vault_wallet
559            .public_descriptor(KeychainKind::Internal)
560            .to_string();
561        let network = self.vault_wallet.network();
562
563        self.vault_wallet = Wallet::create(vault_desc, vault_change_desc)
564            .network(network)
565            .create_wallet_no_persist()
566            .map_err(|e| format!("Failed to reset taproot wallet: {}", e))?;
567        self.loaded_vault_changeset = bdk_wallet::ChangeSet::default();
568
569        // 2. Reset Payment Wallet (if exists)
570        if let Some(w) = &self.payment_wallet {
571            let pay_desc = w.public_descriptor(KeychainKind::External).to_string();
572            let pay_change_desc = w.public_descriptor(KeychainKind::Internal).to_string();
573
574            self.payment_wallet = Some(
575                Wallet::create(pay_desc, pay_change_desc)
576                    .network(network)
577                    .create_wallet_no_persist()
578                    .map_err(|e| format!("Failed to reset payment wallet: {}", e))?,
579            );
580            self.loaded_payment_changeset = Some(bdk_wallet::ChangeSet::default());
581        }
582
583        // 3. Increment account generation to invalidate any in-flight syncs
584        self.account_generation += 1;
585        self.ordinals_verified = false;
586        self.ordinals_metadata_complete = false;
587
588        Ok(())
589    }
590
591    /// Run a full sync against Esplora for taproot and optional payment wallets.
592    pub async fn sync(&mut self, esplora_url: &str) -> Result<Vec<String>, String> {
593        let client = esplora_client::Builder::new(esplora_url)
594            .build_async_with_sleeper::<SyncSleeper>()
595            .map_err(|e| format!("{:?}", e))?;
596
597        let now = now_unix();
598        let vault_req = self.vault_wallet.start_full_scan_at(now).build();
599        let payment_req = self
600            .payment_wallet
601            .as_ref()
602            .map(|w| w.start_full_scan_at(now).build());
603
604        // 1. Sync Vault
605        let vault_update = client
606            .full_scan(vault_req, 20, 1)
607            .await
608            .map_err(|e| e.to_string())?;
609
610        // 2. Sync Payment (if exists)
611        let payment_update = if let Some(req) = payment_req {
612            Some(
613                client
614                    .full_scan(req, 20, 1)
615                    .await
616                    .map_err(|e| e.to_string())?,
617            )
618        } else {
619            None
620        };
621
622        self.apply_sync(vault_update, payment_update)
623    }
624
625    /// Collect all addresses (Vault + Payment) that currently hold UTXOs.
626    /// Used for syncing Ordinals.
627    pub fn collect_active_addresses(&self) -> Vec<String> {
628        let mut addresses = std::collections::HashSet::new();
629
630        // Helper to collect addresses from a wallet
631        let collect_addresses = |wallet: &Wallet| {
632            let mut addrs = std::collections::HashSet::new();
633            for utxo in wallet.list_unspent() {
634                addrs.insert(utxo.txout.script_pubkey);
635            }
636            addrs
637        };
638
639        // Collect from Vault
640        let vault_scripts = collect_addresses(&self.vault_wallet);
641        for script in vault_scripts {
642            if let Ok(addr) = Address::from_script(&script, self.vault_wallet.network()) {
643                addresses.insert(addr);
644            }
645        }
646
647        // Collect from Payment
648        if let Some(w) = &self.payment_wallet {
649            let payment_scripts = collect_addresses(w);
650            for script in payment_scripts {
651                if let Ok(addr) = Address::from_script(&script, w.network()) {
652                    addresses.insert(addr);
653                }
654            }
655        }
656
657        addresses.into_iter().map(|a| a.to_string()).collect()
658    }
659
660    /// Update the wallet's internal inscription state.
661    /// Call this AFTER fetching inscriptions successfully.
662    pub fn apply_verified_ordinals_update(
663        &mut self,
664        inscriptions: Vec<crate::ordinals::types::Inscription>,
665        protected_outpoints: std::collections::HashSet<bitcoin::OutPoint>,
666    ) -> usize {
667        zinc_log_info!(
668            target: LOG_TARGET_BUILDER,
669            "applying ordinals update: {} inscriptions received",
670            inscriptions.len()
671        );
672        for inscription in &inscriptions {
673            zinc_log_debug!(
674                target: LOG_TARGET_BUILDER,
675                "inscribed outpoint updated: {}",
676                inscription.satpoint.outpoint
677            );
678        }
679
680        self.inscribed_utxos = protected_outpoints;
681        self.inscriptions = inscriptions;
682        self.ordinals_verified = true;
683        self.ordinals_metadata_complete = true;
684
685        zinc_log_info!(
686            target: LOG_TARGET_BUILDER,
687            "total inscribed_utxos set size: {}",
688            self.inscribed_utxos.len()
689        );
690        self.inscriptions.len()
691    }
692
693    /// Apply cached inscription metadata from an untrusted caller boundary.
694    ///
695    /// This method updates metadata for UI rendering but intentionally does not
696    /// mark the wallet's ordinals protection state as verified.
697    pub fn apply_unverified_inscriptions_cache(
698        &mut self,
699        inscriptions: Vec<crate::ordinals::types::Inscription>,
700    ) -> usize {
701        zinc_log_info!(
702            target: LOG_TARGET_BUILDER,
703            "applying unverified inscription cache: {} inscriptions received",
704            inscriptions.len()
705        );
706
707        self.inscribed_utxos.clear();
708        self.inscriptions = inscriptions;
709        self.ordinals_verified = false;
710        self.ordinals_metadata_complete = true;
711
712        self.inscriptions.len()
713    }
714
715    fn verify_ord_indexer_is_current(
716        &mut self,
717        ord_height: u32,
718        wallet_height: u32,
719    ) -> Result<(), String> {
720        if ord_height < wallet_height.saturating_sub(1) {
721            self.ordinals_verified = false;
722            return Err(format!(
723                "Ord Indexer is lagging! Ord: {}, Wallet: {}. Safety lock engaged.",
724                ord_height, wallet_height
725            ));
726        }
727        Ok(())
728    }
729
730    /// Refresh only ordinals protection outpoints (no inscription metadata details).
731    pub async fn sync_ordinals_protection(&mut self, ord_url: &str) -> Result<usize, String> {
732        self.ordinals_verified = false;
733        let addresses = self.collect_active_addresses();
734        let client = crate::ordinals::OrdClient::new(ord_url.to_string());
735
736        // 0. Fetch Ord Indexer Tip to check for lag
737        let ord_height = client
738            .get_indexing_height()
739            .await
740            .map_err(|e| e.to_string())?;
741
742        // Get Wallet Tip (from Vault, which is always present)
743        let wallet_height = self.vault_wallet.local_chain().tip().height();
744
745        self.verify_ord_indexer_is_current(ord_height, wallet_height)?;
746
747        let mut protected_outpoints = std::collections::HashSet::new();
748        for addr_str in addresses {
749            let snapshot = client
750                .get_address_asset_snapshot(&addr_str)
751                .await
752                .map_err(|e| format!("Failed to fetch for {}: {}", addr_str, e))?;
753
754            let protected = client
755                .get_protected_outpoints_from_outputs(&snapshot.outputs)
756                .await
757                .map_err(|e| {
758                    format!("Failed to fetch protected outputs for {}: {}", addr_str, e)
759                })?;
760            protected_outpoints.extend(protected);
761        }
762
763        self.inscribed_utxos = protected_outpoints;
764        self.ordinals_verified = true;
765        Ok(self.inscribed_utxos.len())
766    }
767
768    /// Refresh inscription metadata used by UI and PSBT analysis.
769    pub async fn sync_ordinals_metadata(&mut self, ord_url: &str) -> Result<usize, String> {
770        self.ordinals_metadata_complete = false;
771        let addresses = self.collect_active_addresses();
772        let client = crate::ordinals::OrdClient::new(ord_url.to_string());
773
774        let ord_height = client
775            .get_indexing_height()
776            .await
777            .map_err(|e| e.to_string())?;
778        let wallet_height = self.vault_wallet.local_chain().tip().height();
779        self.verify_ord_indexer_is_current(ord_height, wallet_height)?;
780
781        let mut all_inscriptions = Vec::new();
782        for addr_str in addresses {
783            let snapshot = client
784                .get_address_asset_snapshot(&addr_str)
785                .await
786                .map_err(|e| format!("Failed to fetch for {}: {}", addr_str, e))?;
787
788            for inscription_id in snapshot.inscription_ids {
789                let inscription = client
790                    .get_inscription_details(&inscription_id)
791                    .await
792                    .map_err(|e| {
793                        format!("Failed to fetch details for {}: {}", inscription_id, e)
794                    })?;
795                all_inscriptions.push(inscription);
796            }
797        }
798
799        self.inscriptions = all_inscriptions;
800        self.ordinals_metadata_complete = true;
801        Ok(self.inscriptions.len())
802    }
803
804    /// Sync Ordinals (Inscriptions) to build the Shield logic.
805    /// This keeps the legacy behavior by running protection and metadata refresh.
806    pub async fn sync_ordinals(&mut self, ord_url: &str) -> Result<usize, String> {
807        self.sync_ordinals_protection(ord_url).await?;
808        self.sync_ordinals_metadata(ord_url).await
809    }
810
811    /// Return raw combined BDK balance across taproot and payment wallets.
812    pub fn get_raw_balance(&self) -> bdk_wallet::Balance {
813        let vault_bal = self.vault_wallet.balance();
814        if let Some(payment_wallet) = &self.payment_wallet {
815            let pay_bal = payment_wallet.balance();
816            bdk_wallet::Balance {
817                immature: vault_bal.immature + pay_bal.immature,
818                trusted_pending: vault_bal.trusted_pending + pay_bal.trusted_pending,
819                untrusted_pending: vault_bal.untrusted_pending + pay_bal.untrusted_pending,
820                confirmed: vault_bal.confirmed + pay_bal.confirmed,
821            }
822        } else {
823            vault_bal
824        }
825    }
826
827    /// Return an ordinals-aware balance view for display and spend checks.
828    pub fn get_balance(&self) -> ZincBalance {
829        let raw = self.get_raw_balance();
830
831        // Robust Approach:
832        let calc_balance = |wallet: &Wallet| {
833            let mut bal = bdk_wallet::Balance::default();
834            for utxo in wallet.list_unspent() {
835                if self.inscribed_utxos.contains(&utxo.outpoint) {
836                    zinc_log_debug!(
837                        target: LOG_TARGET_BUILDER,
838                        "skipping inscribed UTXO while calculating balance: {:?}",
839                        utxo.outpoint
840                    );
841                    continue;
842                }
843                match utxo.keychain {
844                    KeychainKind::Internal | KeychainKind::External => {
845                        // This UTXO is safe. Add to balance.
846                        match utxo.chain_position {
847                            bdk_chain::ChainPosition::Confirmed { .. } => {
848                                bal.confirmed += utxo.txout.value;
849                            }
850                            bdk_chain::ChainPosition::Unconfirmed { .. } => {
851                                bal.trusted_pending += utxo.txout.value;
852                            }
853                        }
854                    }
855                }
856            }
857            bal
858        };
859
860        let mut safe_bal = calc_balance(&self.vault_wallet);
861        if let Some(w) = &self.payment_wallet {
862            let p_bal = calc_balance(w);
863            safe_bal.confirmed += p_bal.confirmed;
864            safe_bal.trusted_pending += p_bal.trusted_pending;
865            safe_bal.untrusted_pending += p_bal.untrusted_pending;
866            safe_bal.immature += p_bal.immature;
867        }
868
869        let display_spendable = if let Some(payment_wallet) = &self.payment_wallet {
870            calc_balance(payment_wallet)
871        } else {
872            safe_bal.clone()
873        };
874
875        ZincBalance {
876            total: raw.clone(),
877            spendable: safe_bal.clone(),
878            display_spendable,
879            inscribed: raw
880                .confirmed
881                .to_sat()
882                .saturating_sub(safe_bal.confirmed.to_sat())
883                + raw
884                    .trusted_pending
885                    .to_sat()
886                    .saturating_sub(safe_bal.trusted_pending.to_sat()), // Estimate
887        }
888    }
889
890    /// Create an unsigned PSBT for sending BTC.
891    pub fn create_psbt_tx(&mut self, request: &CreatePsbtRequest) -> Result<Psbt, ZincError> {
892        if !self.ordinals_verified {
893            return Err(ZincError::WalletError(
894                "Ordinals verification failed - safety lock engaged. Please retry sync."
895                    .to_string(),
896            ));
897        }
898
899        let wallet = if self.scheme == AddressScheme::Dual {
900            self.payment_wallet.as_mut().ok_or_else(|| {
901                ZincError::WalletError("Payment wallet not initialized".to_string())
902            })?
903        } else {
904            &mut self.vault_wallet
905        };
906
907        let recipient = request
908            .recipient
909            .clone()
910            .require_network(wallet.network())
911            .map_err(|e| ZincError::ConfigError(format!("Network mismatch: {e}")))?;
912
913        let change_script = wallet
914            .peek_address(KeychainKind::External, 0)
915            .script_pubkey();
916
917        let mut builder = wallet.build_tx();
918        if !self.inscribed_utxos.is_empty() {
919            builder.unspendable(self.inscribed_utxos.iter().cloned().collect());
920        }
921
922        builder
923            .add_recipient(recipient.script_pubkey(), request.amount)
924            .fee_rate(request.fee_rate)
925            .drain_to(change_script);
926
927        builder
928            .finish()
929            .map_err(|e| ZincError::WalletError(format!("Failed to build tx: {e}")))
930    }
931
932    /// Create an unsigned PSBT for sending BTC and encode it as base64.
933    pub fn create_psbt_base64(&mut self, request: &CreatePsbtRequest) -> Result<String, ZincError> {
934        let psbt = self.create_psbt_tx(request)?;
935        Ok(Self::encode_psbt_base64(&psbt))
936    }
937
938    /// Create an ord-compatible buyer offer PSBT and envelope.
939    pub fn create_offer(
940        &mut self,
941        request: &crate::offer_create::CreateOfferRequest,
942    ) -> Result<crate::offer_create::OfferCreateResultV1, ZincError> {
943        crate::offer_create::create_offer(self, request)
944    }
945
946    /// Create an unsigned PSBT for sending BTC from transport-friendly inputs.
947    ///
948    /// This method is a migration wrapper for app-boundary callers. New native
949    /// Rust integrations should construct `CreatePsbtRequest` and call
950    /// `create_psbt_tx` or `create_psbt_base64`.
951    #[doc(hidden)]
952    #[deprecated(note = "Use create_psbt_base64 with CreatePsbtRequest")]
953    pub fn create_psbt(
954        &mut self,
955        recipient: &str,
956        amount_sats: u64,
957        fee_rate_sat_vb: u64,
958    ) -> Result<String, String> {
959        let request = CreatePsbtRequest::from_parts(recipient, amount_sats, fee_rate_sat_vb)
960            .map_err(|e| e.to_string())?;
961        self.create_psbt_base64(&request).map_err(|e| e.to_string())
962    }
963
964    fn encode_psbt_base64(psbt: &Psbt) -> String {
965        use base64::Engine;
966        base64::engine::general_purpose::STANDARD.encode(psbt.serialize())
967    }
968
969    /// Sign a PSBT using the wallet's internal keys.
970    /// Returns the signed PSBT as base64.
971    #[allow(deprecated)]
972    pub fn sign_psbt(
973        &mut self,
974        psbt_base64: &str,
975        options: Option<SignOptions>,
976    ) -> Result<String, String> {
977        use base64::Engine;
978
979        // Decode PSBT from base64
980        let psbt_bytes = base64::engine::general_purpose::STANDARD
981            .decode(psbt_base64)
982            .map_err(|e| format!("Invalid base64: {e}"))?;
983
984        let mut psbt = Psbt::deserialize(&psbt_bytes).map_err(|e| format!("Invalid PSBT: {e}"))?;
985
986        // ENRICHMENT STEP: Fill in missing witness_utxo from our own wallet if possible
987        // This solves "Plain PSBT" issues where dApps don't include UTXO info
988        use std::collections::HashMap;
989        let mut known_utxos = HashMap::new();
990
991        let collect_utxos = |w: &Wallet, map: &mut HashMap<bitcoin::OutPoint, bitcoin::TxOut>| {
992            for utxo in w.list_unspent() {
993                map.insert(utxo.outpoint, utxo.txout);
994            }
995        };
996
997        collect_utxos(&self.vault_wallet, &mut known_utxos);
998        if let Some(w) = &self.payment_wallet {
999            collect_utxos(w, &mut known_utxos);
1000        }
1001
1002        for (i, input) in psbt.inputs.iter_mut().enumerate() {
1003            if input.witness_utxo.is_none() && input.non_witness_utxo.is_none() {
1004                let outpoint = psbt.unsigned_tx.input[i].previous_output;
1005                if let Some(txout) = known_utxos.get(&outpoint) {
1006                    input.witness_utxo = Some(txout.clone());
1007                }
1008            }
1009        }
1010
1011        // Prepare BDK SignOptions and Apply Overrides (SIGHASH, etc.)
1012        // We do this BEFORE audit to ensuring we check the actual state being signed.
1013        let should_finalize = options.as_ref().map(|o| o.finalize).unwrap_or(false);
1014        let bdk_options = bdk_wallet::SignOptions {
1015            // CRITICAL: Enable trust_witness_utxo for batch inscriptions where reveal
1016            // transactions spend outputs from not-yet-broadcast commit transactions.
1017            // The wallet can't verify these UTXOs from chain state, but we trust the dApp.
1018            trust_witness_utxo: true,
1019            // Finalize if explicitly requested (internal wallet use).
1020            // Default is false for dApp/marketplace compatibility.
1021            try_finalize: should_finalize,
1022            ..Default::default()
1023        };
1024        let mut inputs_to_sign: Option<Vec<usize>> = None;
1025
1026        if let Some(opts) = &options {
1027            if let Some(sighash_u8) = opts.sighash {
1028                // Use PsbtSighashType to match psbt.inputs type
1029                let target_sighash = bitcoin::psbt::PsbtSighashType::from_u32(sighash_u8 as u32);
1030                for input in psbt.inputs.iter_mut() {
1031                    input.sighash_type = Some(target_sighash);
1032                }
1033            }
1034            inputs_to_sign = opts.sign_inputs.clone();
1035        }
1036
1037        if let Some(indices) = inputs_to_sign.as_ref() {
1038            let mut seen = std::collections::HashSet::new();
1039            for index in indices {
1040                if *index >= psbt.inputs.len() {
1041                    return Err(format!(
1042                        "Security Violation: sign_inputs index {} is out of bounds for {} inputs",
1043                        index,
1044                        psbt.inputs.len()
1045                    ));
1046                }
1047                if !seen.insert(*index) {
1048                    return Err(format!(
1049                        "Security Violation: sign_inputs index {} is duplicated",
1050                        index
1051                    ));
1052                }
1053                let input = &psbt.inputs[*index];
1054                if input.witness_utxo.is_none() && input.non_witness_utxo.is_none() {
1055                    return Err(format!(
1056                        "Security Violation: Requested input #{} is missing UTXO metadata",
1057                        index
1058                    ));
1059                }
1060            }
1061        }
1062
1063        for (index, input) in psbt.inputs.iter().enumerate() {
1064            if let Some(sighash) = input.sighash_type {
1065                let value = sighash.to_u32();
1066                let base_type = value & 0x1f;
1067                let anyone_can_pay = (value & 0x80) != 0;
1068                let is_allowed_base = base_type == 0 || base_type == 1; // DEFAULT or ALL
1069
1070                if anyone_can_pay || !is_allowed_base {
1071                    return Err(format!(
1072                        "Security Violation: Sighash type is not allowed on input #{} (value={})",
1073                        index, value
1074                    ));
1075                }
1076            }
1077        }
1078
1079        // Ordinal Shield Audit: BEFORE signing!
1080        // We must build the known_inscriptions map to check for BURNS (sophisticated check)
1081        let mut known_inscriptions: HashMap<(bitcoin::Txid, u32), Vec<(String, u64)>> =
1082            HashMap::new();
1083        for ins in &self.inscriptions {
1084            known_inscriptions
1085                .entry((ins.satpoint.outpoint.txid, ins.satpoint.outpoint.vout))
1086                .or_default()
1087                .push((ins.id.clone(), ins.satpoint.offset));
1088        }
1089        // Normalize offsets
1090        for items in known_inscriptions.values_mut() {
1091            items.sort_by_key(|(_, offset)| *offset);
1092        }
1093
1094        if let Err(e) = crate::ordinals::shield::audit_psbt(
1095            &psbt,
1096            &known_inscriptions,
1097            inputs_to_sign.as_deref(),
1098            self.vault_wallet.network(),
1099        ) {
1100            return Err(format!("Security Violation: {}", e));
1101        }
1102
1103        // Keep a copy if we need to revert signatures for specific inputs
1104        let original_psbt = if inputs_to_sign.is_some() {
1105            Some(psbt.clone())
1106        } else {
1107            None
1108        };
1109
1110        // Try signing with both, just in case inputs are mixed
1111        // This is safe because BDK only signs inputs it controls
1112        self.vault_wallet
1113            .sign(&mut psbt, bdk_options.clone())
1114            .map_err(|e| format!("Vault signing failed: {e}"))?;
1115
1116        if let Some(payment_wallet) = &self.payment_wallet {
1117            payment_wallet
1118                .sign(&mut psbt, bdk_options)
1119                .map_err(|e| format!("Payment signing failed: {e}"))?;
1120        }
1121
1122        // CUSTOM SCRIPT-PATH SIGNING for Inscription Reveal Inputs
1123        // BDK's standard signer only signs inputs where the key's fingerprint matches the wallet.
1124        // For inscription reveals, the backend sets tap_key_origins with an empty fingerprint,
1125        // so BDK skips them. We manually sign these inputs if the key matches our ordinals key.
1126        self.sign_inscription_script_paths(&mut psbt, should_finalize, inputs_to_sign.as_deref())?;
1127
1128        // If specific inputs were requested, revert the others
1129        if let Some(indices) = inputs_to_sign.as_ref() {
1130            // Safe unwrap because we created it above if inputs_to_sign is Some
1131            let original = original_psbt
1132                .as_ref()
1133                .ok_or_else(|| "Security Violation: missing original PSBT snapshot".to_string())?;
1134            for (i, input) in psbt.inputs.iter_mut().enumerate() {
1135                if !indices.contains(&i) {
1136                    *input = original.inputs[i].clone();
1137                }
1138            }
1139        }
1140
1141        if let Some(indices) = inputs_to_sign.as_ref() {
1142            let original = original_psbt
1143                .as_ref()
1144                .ok_or_else(|| "Security Violation: missing original PSBT snapshot".to_string())?;
1145            for index in indices {
1146                let before = &original.inputs[*index];
1147                let after = &psbt.inputs[*index];
1148
1149                let signature_changed = before.tap_key_sig != after.tap_key_sig
1150                    || before.tap_script_sigs != after.tap_script_sigs
1151                    || before.partial_sigs != after.partial_sigs
1152                    || before.final_script_witness != after.final_script_witness;
1153
1154                if !signature_changed {
1155                    return Err(format!(
1156                        "Security Violation: Requested input #{} was not signed by this wallet",
1157                        index
1158                    ));
1159                }
1160            }
1161        }
1162
1163        // POST-SIGNING: Log the signature state for debugging
1164        // With try_finalize: false, BDK places signatures in:
1165        // - tap_key_sig for Taproot key-path spends
1166        // - tap_script_sig for Taproot script-path spends (not used for wallet inputs)
1167        // - partial_sigs for SegWit P2WPKH spends
1168        for (_i, input) in psbt.inputs.iter().enumerate() {
1169            if input.tap_key_sig.is_some() {
1170            } else if !input.tap_script_sigs.is_empty() {
1171            } else if !input.partial_sigs.is_empty() {
1172            } else if input.final_script_witness.is_some() {
1173                // This shouldn't happen with try_finalize: false, but log it
1174            } else {
1175                // Input not signed by this wallet - could be unsigned or signed by another party
1176            }
1177        }
1178
1179        let signed_bytes = psbt.serialize();
1180        let signed_base64 = base64::engine::general_purpose::STANDARD.encode(&signed_bytes);
1181
1182        Ok(signed_base64)
1183    }
1184
1185    /// Analyzes a PSBT for Ordinal Shield protection.
1186    /// Returns a JSON string containing the AnalysisResult.
1187    pub fn analyze_psbt(&self, psbt_base64: &str) -> Result<String, String> {
1188        // Use explicit path to avoid re-export issues if any
1189        use crate::ordinals::shield::analyze_psbt;
1190        use base64::Engine;
1191        use std::collections::HashMap;
1192
1193        // Decode PSBT
1194        let psbt_bytes = base64::engine::general_purpose::STANDARD
1195            .decode(psbt_base64)
1196            .map_err(|e| format!("Invalid base64: {e}"))?;
1197
1198        let mut psbt = match Psbt::deserialize(&psbt_bytes) {
1199            Ok(p) => p,
1200            Err(e) => {
1201                return Err(format!("Invalid PSBT: {e}"));
1202            }
1203        };
1204
1205        // ENRICHMENT STEP: Fill in missing witness_utxo from our own wallet if possible
1206        // This solves "Plain PSBT" issues where dApps don't include UTXO info
1207        let mut known_utxos = HashMap::new();
1208
1209        let collect_utxos = |w: &Wallet, map: &mut HashMap<bitcoin::OutPoint, bitcoin::TxOut>| {
1210            for utxo in w.list_unspent() {
1211                map.insert(utxo.outpoint, utxo.txout);
1212            }
1213        };
1214
1215        collect_utxos(&self.vault_wallet, &mut known_utxos);
1216        if let Some(w) = &self.payment_wallet {
1217            collect_utxos(w, &mut known_utxos);
1218        }
1219
1220        let mut enriched_count = 0;
1221        for (i, input) in psbt.inputs.iter_mut().enumerate() {
1222            if input.witness_utxo.is_none() && input.non_witness_utxo.is_none() {
1223                let outpoint = psbt.unsigned_tx.input[i].previous_output;
1224                if let Some(txout) = known_utxos.get(&outpoint) {
1225                    input.witness_utxo = Some(txout.clone());
1226                    enriched_count += 1;
1227                }
1228            }
1229        }
1230
1231        if enriched_count > 0 {}
1232
1233        // Build Known Inscriptions Map from internal state
1234        // Map: (Txid, Vout) -> Vec<(InscriptionID, Offset)>
1235        let mut known_inscriptions: HashMap<(bitcoin::Txid, u32), Vec<(String, u64)>> =
1236            HashMap::new();
1237
1238        // We also need a way to map offsets back to Inscription IDs for the result?
1239        // The `analyze_psbt` function currently generates keys like "Inscription {N}".
1240        // Wait, I should probably pass the IDs or handle the mapping better.
1241        // My implementation in `shield.rs` generates keys.
1242        // Ideally, `analyze_psbt` should take `HashMap<(Txid, u32), Vec<(u64, String)>>` so it knows the IDs!
1243        // But for now, let's look at `shield.rs`. It iterates and pushes to `active_inscriptions`.
1244        // The `known_inscriptions` map is just `Vec<u66>`.
1245        // This is a limitation of my current `shield.rs` implementation.
1246        // I should update `shield.rs` to take IDs if I want the frontend to know *which* inscription is being burned.
1247        // BUT, `shield.rs` is already tested and working with the simplified map.
1248        // PROPOSAL: Since `shield.rs` generates opaque keys, I should stick to that for V1 reliability.
1249        // Actually, if I pass a map of `(Txid, Vout) -> Vec<u64>`, I lose the ID association.
1250        // BUT `self.inscriptions` has the ID.
1251
1252        // Optimization: Let's rely on the assumption that mapping order is consistent.
1253        // However, `shield.rs` uses `known_inscriptions.get(...)` which returns a Vec of offsets.
1254        // If I want the frontend to show specific inscription images, I need the IDs.
1255
1256        // Let's stick to the current implementation for now. The frontend can re-derive or we just warn "An inscription".
1257        // Actually, for TDD I implemented it simply.
1258        // Real user needs to know WHICH inscription.
1259
1260        for ins in &self.inscriptions {
1261            known_inscriptions
1262                .entry((ins.satpoint.outpoint.txid, ins.satpoint.outpoint.vout))
1263                .or_default()
1264                .push((ins.id.clone(), ins.satpoint.offset));
1265        }
1266
1267        // Sort offsets for deterministic behavior
1268        for items in known_inscriptions.values_mut() {
1269            items.sort_by_key(|(_, offset)| *offset);
1270        }
1271
1272        let result = match analyze_psbt(&psbt, &known_inscriptions, self.vault_wallet.network()) {
1273            Ok(r) => r,
1274            Err(e) => {
1275                return Err(e.to_string());
1276            }
1277        };
1278
1279        serde_json::to_string(&result).map_err(|e| e.to_string())
1280    }
1281
1282    /// Broadcast a signed PSBT to the network.
1283    /// Returns the transaction ID (txid) as a hex string.
1284    pub async fn broadcast(
1285        &mut self,
1286        signed_psbt_base64: &str,
1287        esplora_url: &str,
1288    ) -> Result<String, String> {
1289        use base64::Engine;
1290
1291        // Decode PSBT from base64
1292        let psbt_bytes = base64::engine::general_purpose::STANDARD
1293            .decode(signed_psbt_base64)
1294            .map_err(|e| format!("Invalid base64: {e}"))?;
1295
1296        let psbt = Psbt::deserialize(&psbt_bytes).map_err(|e| format!("Invalid PSBT: {e}"))?;
1297
1298        // Extract the finalized transaction
1299        let tx: Transaction = psbt
1300            .extract_tx()
1301            .map_err(|e| format!("Failed to extract tx: {e}"))?;
1302
1303        // Broadcast via Esplora
1304        let client = esplora_client::Builder::new(esplora_url)
1305            .build_async_with_sleeper::<SyncSleeper>()
1306            .map_err(|e| format!("Failed to create client: {e:?}"))?;
1307
1308        let broadcast_res: Result<(), _> = client.broadcast(&tx).await;
1309
1310        broadcast_res.map_err(|e| format!("Broadcast failed: {e}"))?;
1311
1312        Ok(tx.compute_txid().to_string())
1313    }
1314
1315    /// Sign a message with the private key corresponding to the given address.
1316    /// Supports both Vault (Taproot) and Payment (SegWit) addresses.
1317    pub fn sign_message(&self, address: &str, message: &str) -> Result<String, String> {
1318        use base64::Engine;
1319        use bitcoin::hashes::Hash;
1320        use bitcoin::secp256k1::{Message, Secp256k1};
1321
1322        // 1. Identify which wallet/keychain owns this address
1323        let index = 0;
1324        let vault_addr = self
1325            .vault_wallet
1326            .peek_address(KeychainKind::External, index)
1327            .address
1328            .to_string();
1329
1330        let (is_vault, is_payment) = if address == vault_addr {
1331            (true, false)
1332        } else if let Some(w) = &self.payment_wallet {
1333            let pay_addr = w
1334                .peek_address(KeychainKind::External, index)
1335                .address
1336                .to_string();
1337            (false, address == pay_addr)
1338        } else {
1339            (false, false)
1340        };
1341
1342        if !is_vault && !is_payment {
1343            return Err("Address not found in wallet".to_string());
1344        }
1345
1346        // 2. Derive Key
1347        let secp = Secp256k1::new();
1348        let coin_type = if self.vault_wallet.network() == Network::Bitcoin {
1349            0
1350        } else {
1351            1
1352        };
1353        let account = self.account_index;
1354
1355        // Derivation path components
1356        let (purpose, chain) = if is_vault { (86, 0) } else { (84, 0) };
1357
1358        let derivation_path = [
1359            bdk_wallet::bitcoin::bip32::ChildNumber::from_hardened_idx(purpose).unwrap(),
1360            bdk_wallet::bitcoin::bip32::ChildNumber::from_hardened_idx(coin_type).unwrap(),
1361            bdk_wallet::bitcoin::bip32::ChildNumber::from_hardened_idx(account).unwrap(),
1362            bdk_wallet::bitcoin::bip32::ChildNumber::from_normal_idx(chain).unwrap(),
1363            bdk_wallet::bitcoin::bip32::ChildNumber::from_normal_idx(index).unwrap(),
1364        ];
1365
1366        let child_xprv = self
1367            .master_xprv
1368            .derive_priv(&secp, &derivation_path)
1369            .map_err(|e| format!("Key derivation failed: {e}"))?;
1370
1371        let priv_key = child_xprv.private_key;
1372
1373        // 3. Sign Message
1374        let signature_hash = bitcoin::sign_message::signed_msg_hash(message);
1375        let msg = Message::from_digest(signature_hash.to_byte_array());
1376
1377        let sig = secp.sign_ecdsa_recoverable(&msg, &priv_key);
1378        let (rec_id, sig_bytes_compact) = sig.serialize_compact();
1379
1380        let mut header = 27 + u8::try_from(rec_id.to_i32()).unwrap();
1381        header += 4; // Always compressed
1382
1383        let mut sig_bytes = Vec::with_capacity(65);
1384        sig_bytes.push(header);
1385        sig_bytes.extend_from_slice(&sig_bytes_compact);
1386
1387        Ok(base64::engine::general_purpose::STANDARD.encode(&sig_bytes))
1388    }
1389    /// Derive the taproot public key for this account at `index`.
1390    pub fn get_taproot_public_key(&self, index: u32) -> Result<String, String> {
1391        self.derive_public_key(86, index)
1392    }
1393
1394    /// Derive the payment public key for this account at `index`.
1395    ///
1396    /// In unified mode this uses the same key family as taproot.
1397    pub fn get_payment_public_key(&self, index: u32) -> Result<String, String> {
1398        // Dual uses 84 (SegWit), Unified uses 86 (same as taproot)
1399        let purpose = if self.scheme == AddressScheme::Dual {
1400            84
1401        } else {
1402            86
1403        };
1404        self.derive_public_key(purpose, index)
1405    }
1406
1407    fn derive_public_key(&self, purpose: u32, index: u32) -> Result<String, String> {
1408        self.derive_public_key_internal(purpose, self.account_index, index)
1409    }
1410
1411    /// Sign inscription reveal script-path inputs that BDK's standard signer missed.
1412    ///
1413    /// BDK checks tap_key_origins fingerprint to match wallet keys. Since the inscription
1414    /// backend uses empty fingerprints, BDK skips these inputs. This method manually
1415    /// signs if the public key in tap_key_origins matches our ordinals key.
1416    fn sign_inscription_script_paths(
1417        &self,
1418        psbt: &mut Psbt,
1419        should_finalize: bool,
1420        allowed_inputs: Option<&[usize]>,
1421    ) -> Result<(), String> {
1422        use bitcoin::secp256k1::{Keypair, Message, Secp256k1};
1423        use bitcoin::sighash::{Prevouts, SighashCache, TapSighashType};
1424        use bitcoin::taproot::TapLeafHash;
1425
1426        let secp = Secp256k1::new();
1427
1428        // Derive the ordinals key (m/86'/coin'/account'/0/0)
1429        let coin_type = if self.vault_wallet.network() == Network::Bitcoin {
1430            0
1431        } else {
1432            1
1433        };
1434        let derivation_path = [
1435            bdk_wallet::bitcoin::bip32::ChildNumber::from_hardened_idx(86).unwrap(),
1436            bdk_wallet::bitcoin::bip32::ChildNumber::from_hardened_idx(coin_type).unwrap(),
1437            bdk_wallet::bitcoin::bip32::ChildNumber::from_hardened_idx(self.account_index).unwrap(),
1438            bdk_wallet::bitcoin::bip32::ChildNumber::from_normal_idx(0).unwrap(), // External chain
1439            bdk_wallet::bitcoin::bip32::ChildNumber::from_normal_idx(0).unwrap(), // First key
1440        ];
1441
1442        let ordinals_xprv = self
1443            .master_xprv
1444            .derive_priv(&secp, &derivation_path)
1445            .map_err(|e| format!("Failed to derive ordinals key: {e}"))?;
1446
1447        let ordinals_keypair = Keypair::from_secret_key(&secp, &ordinals_xprv.private_key);
1448        let (ordinals_xonly, _) = ordinals_keypair.x_only_public_key();
1449
1450        // Collect all prevouts for sighash computation
1451        let prevouts: Vec<bitcoin::TxOut> = psbt
1452            .inputs
1453            .iter()
1454            .map(|inp| {
1455                inp.witness_utxo.clone().unwrap_or_else(|| {
1456                    // Fallback - this shouldn't happen if PSBT is properly formed
1457                    bitcoin::TxOut {
1458                        value: bitcoin::Amount::ZERO,
1459                        script_pubkey: bitcoin::ScriptBuf::new(),
1460                    }
1461                })
1462            })
1463            .collect();
1464
1465        for (i, input) in psbt.inputs.iter_mut().enumerate() {
1466            if let Some(indices) = allowed_inputs {
1467                if !indices.contains(&i) {
1468                    continue;
1469                }
1470            }
1471
1472            // Skip if already signed or no script-path data
1473            if !input.tap_script_sigs.is_empty()
1474                || input.tap_key_sig.is_some()
1475                || input.final_script_witness.is_some()
1476            {
1477                continue;
1478            }
1479
1480            // Check if has tap_scripts (inscription reveal inputs have these)
1481            if input.tap_scripts.is_empty() {
1482                continue;
1483            }
1484
1485            // Accept either explicit tap_key_origins ownership or matching tap_internal_key.
1486            // Some inscription builders omit/reshape tap_key_origins for script-path reveals.
1487            let has_our_key_origin = input.tap_key_origins.keys().any(|k| *k == ordinals_xonly);
1488            let has_matching_internal_key = input
1489                .tap_internal_key
1490                .map(|key| key == ordinals_xonly)
1491                .unwrap_or(false);
1492            if !has_our_key_origin && !has_matching_internal_key {
1493                continue;
1494            }
1495
1496            // Get the script and leaf version from tap_scripts
1497            let (control_block, (script, leaf_version)) = input
1498                .tap_scripts
1499                .iter()
1500                .next()
1501                .ok_or_else(|| format!("Input {} has empty tap_scripts", i))?;
1502
1503            let leaf_hash = TapLeafHash::from_script(script, *leaf_version);
1504
1505            // Compute the script-path sighash
1506            let mut sighash_cache = SighashCache::new(&psbt.unsigned_tx);
1507            let sighash = sighash_cache
1508                .taproot_script_spend_signature_hash(
1509                    i,
1510                    &Prevouts::All(&prevouts),
1511                    leaf_hash,
1512                    TapSighashType::Default,
1513                )
1514                .map_err(|e| format!("Failed to compute script sighash for input {}: {e}", i))?;
1515
1516            // Sign it
1517            let msg = Message::from_digest_slice(sighash.as_ref())
1518                .map_err(|e| format!("Invalid sighash message: {e}"))?;
1519            let signature = secp.sign_schnorr(&msg, &ordinals_keypair);
1520
1521            // Create the TapScriptSig (signature + sighash type)
1522            let tap_sig = bitcoin::taproot::Signature {
1523                signature,
1524                sighash_type: TapSighashType::Default,
1525            };
1526
1527            // Add to tap_script_sigs with (public_key, leaf_hash) as key
1528            let tap_sig_serialized = tap_sig.serialize();
1529            input
1530                .tap_script_sigs
1531                .insert((ordinals_xonly, leaf_hash), tap_sig);
1532
1533            if should_finalize {
1534                let mut witness = bitcoin::Witness::new();
1535                witness.push(tap_sig_serialized);
1536                witness.push(script.as_bytes());
1537                witness.push(control_block.serialize());
1538                input.final_script_witness = Some(witness);
1539            }
1540        }
1541
1542        Ok(())
1543    }
1544
1545    /// Build account summaries for indices `[0, count)`.
1546    pub fn get_accounts(&self, count: u32) -> Vec<Account> {
1547        let mut accounts = Vec::new();
1548        let network = self.vault_wallet.network();
1549        let coin_type = i32::from(network != Network::Bitcoin);
1550
1551        for i in 0..count {
1552            // Derive Vault Address
1553            let vault_desc = format!("tr({}/86'/{coin_type}'/{i}'/0/*)", self.master_xprv);
1554            let vault_change_desc = format!("tr({}/86'/{coin_type}'/{i}'/1/*)", self.master_xprv);
1555
1556            // Temporary wallet for peeking
1557            if let Ok(vw) = Wallet::create(vault_desc, vault_change_desc)
1558                .network(network)
1559                .create_wallet_no_persist()
1560            {
1561                let taproot_address = vw
1562                    .peek_address(KeychainKind::External, 0)
1563                    .address
1564                    .to_string();
1565                let taproot_public_key = self
1566                    .derive_public_key_internal(86, i, 0)
1567                    .unwrap_or_default();
1568
1569                let (payment_address, payment_public_key) = if self.scheme == AddressScheme::Dual {
1570                    let pay_desc = format!("wpkh({}/84'/{coin_type}'/{i}'/0/*)", self.master_xprv);
1571                    let pay_change_desc =
1572                        format!("wpkh({}/84'/{coin_type}'/{i}'/1/*)", self.master_xprv);
1573
1574                    if let Ok(pw) = Wallet::create(pay_desc, pay_change_desc)
1575                        .network(network)
1576                        .create_wallet_no_persist()
1577                    {
1578                        (
1579                            Some(
1580                                pw.peek_address(KeychainKind::External, 0)
1581                                    .address
1582                                    .to_string(),
1583                            ),
1584                            Some(
1585                                self.derive_public_key_internal(84, i, 0)
1586                                    .unwrap_or_default(),
1587                            ),
1588                        )
1589                    } else {
1590                        (None, None)
1591                    }
1592                } else {
1593                    (
1594                        Some(taproot_address.clone()),
1595                        Some(taproot_public_key.clone()),
1596                    )
1597                };
1598
1599                accounts.push(Account {
1600                    index: i,
1601                    label: format!("Account {}", i + 1),
1602                    taproot_address,
1603                    taproot_public_key,
1604                    payment_address,
1605                    payment_public_key,
1606                });
1607            }
1608        }
1609        accounts
1610    }
1611
1612    fn child_hardened(index: u32) -> Result<bdk_wallet::bitcoin::bip32::ChildNumber, String> {
1613        bdk_wallet::bitcoin::bip32::ChildNumber::from_hardened_idx(index)
1614            .map_err(|e| format!("Invalid hardened child index {index}: {e}"))
1615    }
1616
1617    fn child_normal(index: u32) -> Result<bdk_wallet::bitcoin::bip32::ChildNumber, String> {
1618        bdk_wallet::bitcoin::bip32::ChildNumber::from_normal_idx(index)
1619            .map_err(|e| format!("Invalid normal child index {index}: {e}"))
1620    }
1621
1622    fn account_discovery_plan_from_xprv(
1623        master_xprv: bdk_wallet::bitcoin::bip32::Xpriv,
1624        network: Network,
1625        scheme: AddressScheme,
1626        account_index: u32,
1627    ) -> Result<DiscoveryAccountPlan, String> {
1628        use bitcoin::secp256k1::Secp256k1;
1629
1630        let secp = Secp256k1::new();
1631        let coin_type = if network == Network::Bitcoin { 0 } else { 1 };
1632
1633        let vault_path = [
1634            Self::child_hardened(86)?,
1635            Self::child_hardened(coin_type)?,
1636            Self::child_hardened(account_index)?,
1637        ];
1638        let vault_account_xprv = master_xprv.derive_priv(&secp, &vault_path).map_err(|e| {
1639            format!("Failed to derive taproot account xprv for account {account_index}: {e}")
1640        })?;
1641        let vault_account_xpub =
1642            bdk_wallet::bitcoin::bip32::Xpub::from_priv(&secp, &vault_account_xprv);
1643        let taproot_descriptor = format!("tr({}/0/*)", vault_account_xpub);
1644        let taproot_change_descriptor = format!("tr({}/1/*)", vault_account_xpub);
1645
1646        let vault_pub_path = [Self::child_normal(0)?, Self::child_normal(0)?];
1647        let vault_pubkey = vault_account_xpub
1648            .derive_pub(&secp, &vault_pub_path)
1649            .map_err(|e| {
1650                format!("Failed to derive taproot public key for account {account_index}: {e}")
1651            })?
1652            .public_key;
1653        let taproot_public_key = vault_pubkey.x_only_public_key().0.to_string();
1654
1655        let (payment_descriptor, payment_change_descriptor, payment_public_key) = if scheme
1656            == AddressScheme::Dual
1657        {
1658            let payment_path = [
1659                Self::child_hardened(84)?,
1660                Self::child_hardened(coin_type)?,
1661                Self::child_hardened(account_index)?,
1662            ];
1663            let payment_account_xprv =
1664                master_xprv.derive_priv(&secp, &payment_path).map_err(|e| {
1665                    format!(
1666                        "Failed to derive payment account xprv for account {account_index}: {e}"
1667                    )
1668                })?;
1669            let payment_account_xpub =
1670                bdk_wallet::bitcoin::bip32::Xpub::from_priv(&secp, &payment_account_xprv);
1671            let payment_pubkey = payment_account_xpub
1672                .derive_pub(&secp, &vault_pub_path)
1673                .map_err(|e| {
1674                    format!("Failed to derive payment public key for account {account_index}: {e}")
1675                })?
1676                .public_key
1677                .to_string();
1678
1679            (
1680                Some(format!("wpkh({}/0/*)", payment_account_xpub)),
1681                Some(format!("wpkh({}/1/*)", payment_account_xpub)),
1682                Some(payment_pubkey),
1683            )
1684        } else {
1685            (None, None, None)
1686        };
1687
1688        Ok(DiscoveryAccountPlan {
1689            index: account_index,
1690            taproot_descriptor,
1691            taproot_change_descriptor,
1692            taproot_public_key,
1693            payment_descriptor,
1694            payment_change_descriptor,
1695            payment_public_key,
1696        })
1697    }
1698
1699    fn build_discovery_context_from_xprv(
1700        master_xprv: bdk_wallet::bitcoin::bip32::Xpriv,
1701        network: Network,
1702        scheme: AddressScheme,
1703        start: u32,
1704        count: u32,
1705    ) -> Result<DiscoveryContext, String> {
1706        let mut accounts = Vec::new();
1707        let end = start.saturating_add(count);
1708
1709        for account_index in start..end {
1710            accounts.push(Self::account_discovery_plan_from_xprv(
1711                master_xprv,
1712                network,
1713                scheme,
1714                account_index,
1715            )?);
1716        }
1717
1718        Ok(DiscoveryContext {
1719            network,
1720            scheme,
1721            accounts,
1722        })
1723    }
1724
1725    /// Build discovery context for accounts in `[start, start + count)`.
1726    pub fn build_discovery_context(
1727        &self,
1728        start: u32,
1729        count: u32,
1730    ) -> Result<DiscoveryContext, String> {
1731        Self::build_discovery_context_from_xprv(
1732            self.master_xprv,
1733            self.vault_wallet.network(),
1734            self.scheme,
1735            start,
1736            count,
1737        )
1738    }
1739
1740    /// Discover active accounts from index `0` up to `count`.
1741    pub async fn discover_active_accounts(
1742        &self,
1743        esplora_url: &str,
1744        count: u32,
1745        gap: u32,
1746    ) -> Result<Vec<Account>, String> {
1747        self.discover_active_accounts_range(esplora_url, 0, count, gap)
1748            .await
1749    }
1750
1751    /// Discover active accounts in `[start, start + count)`.
1752    pub async fn discover_active_accounts_range(
1753        &self,
1754        esplora_url: &str,
1755        start: u32,
1756        count: u32,
1757        gap: u32,
1758    ) -> Result<Vec<Account>, String> {
1759        let context = self.build_discovery_context(start, count)?;
1760        Self::discover_accounts_with_context(context, esplora_url, gap).await
1761    }
1762
1763    /// Discover active accounts using a pre-built discovery context.
1764    pub async fn discover_accounts_with_context(
1765        context: DiscoveryContext,
1766        esplora_url: &str,
1767        gap: u32,
1768    ) -> Result<Vec<Account>, String> {
1769        let client = esplora_client::Builder::new(esplora_url)
1770            .build_async_with_sleeper::<SyncSleeper>()
1771            .map_err(|e| format!("{e:?}"))?;
1772
1773        let mut active_accounts = Vec::new();
1774
1775        for plan in context.accounts {
1776            let vault_wallet = Wallet::create(
1777                plan.taproot_descriptor.clone(),
1778                plan.taproot_change_descriptor.clone(),
1779            )
1780            .network(context.network)
1781            .create_wallet_no_persist()
1782            .map_err(|e| e.to_string())?;
1783
1784            let mut has_activity = false;
1785
1786            for i in 0..gap {
1787                let ext = vault_wallet.peek_address(KeychainKind::External, i).address;
1788                let stats = client
1789                    .get_address_stats(&ext)
1790                    .await
1791                    .map_err(|e| e.to_string())?;
1792                if stats.chain_stats.tx_count > 0 || stats.mempool_stats.tx_count > 0 {
1793                    has_activity = true;
1794                    break;
1795                }
1796
1797                let change = vault_wallet.peek_address(KeychainKind::Internal, i).address;
1798                let stats = client
1799                    .get_address_stats(&change)
1800                    .await
1801                    .map_err(|e| e.to_string())?;
1802                if stats.chain_stats.tx_count > 0 || stats.mempool_stats.tx_count > 0 {
1803                    has_activity = true;
1804                    break;
1805                }
1806            }
1807
1808            let mut payment_wallet: Option<Wallet> = None;
1809            if let (Some(pay_desc), Some(pay_change_desc)) = (
1810                plan.payment_descriptor.as_ref(),
1811                plan.payment_change_descriptor.as_ref(),
1812            ) {
1813                if let Ok(created_wallet) =
1814                    Wallet::create(pay_desc.clone(), pay_change_desc.clone())
1815                        .network(context.network)
1816                        .create_wallet_no_persist()
1817                {
1818                    if !has_activity {
1819                        for i in 0..gap {
1820                            let ext = created_wallet
1821                                .peek_address(KeychainKind::External, i)
1822                                .address;
1823                            let stats = client
1824                                .get_address_stats(&ext)
1825                                .await
1826                                .map_err(|e| e.to_string())?;
1827                            if stats.chain_stats.tx_count > 0 || stats.mempool_stats.tx_count > 0 {
1828                                has_activity = true;
1829                                break;
1830                            }
1831
1832                            let change = created_wallet
1833                                .peek_address(KeychainKind::Internal, i)
1834                                .address;
1835                            let stats = client
1836                                .get_address_stats(&change)
1837                                .await
1838                                .map_err(|e| e.to_string())?;
1839                            if stats.chain_stats.tx_count > 0 || stats.mempool_stats.tx_count > 0 {
1840                                has_activity = true;
1841                                break;
1842                            }
1843                        }
1844                    }
1845                    payment_wallet = Some(created_wallet);
1846                }
1847            }
1848
1849            if has_activity {
1850                let taproot_address = vault_wallet
1851                    .peek_address(KeychainKind::External, 0)
1852                    .address
1853                    .to_string();
1854                let taproot_public_key = plan.taproot_public_key.clone();
1855                let payment_address = if context.scheme == AddressScheme::Dual {
1856                    payment_wallet.as_ref().map(|wallet| {
1857                        wallet
1858                            .peek_address(KeychainKind::External, 0)
1859                            .address
1860                            .to_string()
1861                    })
1862                } else {
1863                    Some(taproot_address.clone())
1864                };
1865                let payment_public_key = if context.scheme == AddressScheme::Dual {
1866                    plan.payment_public_key.clone()
1867                } else {
1868                    Some(taproot_public_key.clone())
1869                };
1870
1871                active_accounts.push(Account {
1872                    index: plan.index,
1873                    label: format!("Account {}", plan.index + 1),
1874                    taproot_address,
1875                    taproot_public_key,
1876                    payment_address,
1877                    payment_public_key,
1878                });
1879            }
1880        }
1881
1882        Ok(active_accounts)
1883    }
1884
1885    /// Switch the active account and reset account-scoped runtime state.
1886    pub fn set_active_account(&mut self, index: u32) -> Result<(), String> {
1887        if self.account_index == index {
1888            return Ok(());
1889        }
1890
1891        let network = self.vault_wallet.network();
1892        let coin_type = i32::from(network != Network::Bitcoin);
1893
1894        // Rebuild Vault Wallet
1895        let vault_desc = format!("tr({}/86'/{coin_type}'/{index}'/0/*)", self.master_xprv);
1896        let vault_change_desc = format!("tr({}/86'/{coin_type}'/{index}'/1/*)", self.master_xprv);
1897
1898        let next_vault_wallet = Wallet::create(vault_desc, vault_change_desc)
1899            .network(network)
1900            .create_wallet_no_persist()
1901            .map_err(|e| e.to_string())?;
1902
1903        // Rebuild Payment Wallet if in Dual mode
1904        let next_payment_wallet = if self.scheme == AddressScheme::Dual {
1905            let pay_desc = format!("wpkh({}/84'/{coin_type}'/{index}'/0/*)", self.master_xprv);
1906            let pay_change_desc =
1907                format!("wpkh({}/84'/{coin_type}'/{index}'/1/*)", self.master_xprv);
1908
1909            Some(
1910                Wallet::create(pay_desc, pay_change_desc)
1911                    .network(network)
1912                    .create_wallet_no_persist()
1913                    .map_err(|e| e.to_string())?,
1914            )
1915        } else {
1916            None
1917        };
1918
1919        self.account_index = index;
1920        self.vault_wallet = next_vault_wallet;
1921        self.payment_wallet = next_payment_wallet;
1922        self.loaded_vault_changeset = bdk_wallet::ChangeSet::default();
1923        self.loaded_payment_changeset = None;
1924        self.inscribed_utxos.clear();
1925        self.inscriptions.clear();
1926        self.ordinals_verified = false;
1927        self.ordinals_metadata_complete = false;
1928        self.is_syncing = false;
1929        self.account_generation = self.account_generation.wrapping_add(1);
1930
1931        Ok(())
1932    }
1933
1934    /// Switch between unified and dual address schemes.
1935    pub fn set_address_scheme(&mut self, scheme: AddressScheme) -> Result<(), String> {
1936        if self.scheme == scheme {
1937            return Ok(());
1938        }
1939
1940        self.scheme = scheme;
1941        let network = self.vault_wallet.network();
1942        let index = self.account_index;
1943        let coin_type = i32::from(network != Network::Bitcoin);
1944
1945        if scheme == AddressScheme::Dual {
1946            let pay_desc = format!("wpkh({}/84'/{coin_type}'/{index}'/0/*)", self.master_xprv);
1947            let pay_change_desc =
1948                format!("wpkh({}/84'/{coin_type}'/{index}'/1/*)", self.master_xprv);
1949
1950            self.payment_wallet = Some(
1951                Wallet::create(pay_desc, pay_change_desc)
1952                    .network(network)
1953                    .create_wallet_no_persist()
1954                    .map_err(|e| e.to_string())?,
1955            );
1956        } else {
1957            self.payment_wallet = None;
1958        }
1959
1960        Ok(())
1961    }
1962
1963    fn derive_public_key_internal(
1964        &self,
1965        purpose: u32,
1966        account: u32,
1967        index: u32,
1968    ) -> Result<String, String> {
1969        use bitcoin::secp256k1::Secp256k1;
1970        let secp = Secp256k1::new();
1971
1972        let coin_type = if self.vault_wallet.network() == Network::Bitcoin {
1973            0
1974        } else {
1975            1
1976        };
1977        let chain = 0;
1978
1979        let derivation_path = [
1980            bdk_wallet::bitcoin::bip32::ChildNumber::from_hardened_idx(purpose).unwrap(),
1981            bdk_wallet::bitcoin::bip32::ChildNumber::from_hardened_idx(coin_type).unwrap(),
1982            bdk_wallet::bitcoin::bip32::ChildNumber::from_hardened_idx(account).unwrap(),
1983            bdk_wallet::bitcoin::bip32::ChildNumber::from_normal_idx(chain).unwrap(),
1984            bdk_wallet::bitcoin::bip32::ChildNumber::from_normal_idx(index).unwrap(),
1985        ];
1986
1987        let child_xprv = self
1988            .master_xprv
1989            .derive_priv(&secp, &derivation_path)
1990            .map_err(|e| format!("Key derivation failed: {e}"))?;
1991
1992        let public_key = child_xprv.private_key.public_key(&secp);
1993
1994        // Check purpose to decide format
1995        if purpose == 86 {
1996            // Taproot (BIP-86) uses 32-byte x-only public keys
1997            let (x_only, _parity) = public_key.x_only_public_key();
1998            Ok(x_only.to_string())
1999        } else {
2000            // SegWit (BIP-84) uses 33-byte compressed public keys
2001            Ok(public_key.to_string())
2002        }
2003    }
2004}
2005
2006/// Serializable persistence snapshot for taproot/payment wallet changesets.
2007#[derive(serde::Serialize, serde::Deserialize, Clone)]
2008pub struct ZincPersistence {
2009    /// Optional taproot changeset.
2010    #[serde(default, alias = "vault")]
2011    pub taproot: Option<bdk_wallet::ChangeSet>,
2012    /// Optional payment changeset.
2013    pub payment: Option<bdk_wallet::ChangeSet>,
2014}
2015
2016impl WalletBuilder {
2017    /// Create a new builder from network and a strongly typed 64-byte seed.
2018    pub fn from_seed(network: Network, seed: Seed64) -> Self {
2019        Self {
2020            network,
2021            seed: seed.as_ref().to_vec(),
2022            scheme: AddressScheme::Unified,
2023            persistence: None,
2024            account_index: 0,
2025        }
2026    }
2027
2028    /// Create a new builder from network and mnemonic material.
2029    pub fn from_mnemonic(network: Network, mnemonic: &ZincMnemonic) -> Self {
2030        let seed = mnemonic.to_seed("");
2031        Self::from_seed(network, Seed64::from_array(*seed))
2032    }
2033
2034    /// Create a new builder from `network` and seed bytes.
2035    #[doc(hidden)]
2036    #[deprecated(note = "Use from_seed or from_mnemonic")]
2037    pub fn new(network: Network, seed: &[u8]) -> Self {
2038        Self {
2039            network,
2040            seed: seed.to_vec(),
2041            scheme: AddressScheme::Unified,
2042            persistence: None,
2043            account_index: 0,
2044        }
2045    }
2046
2047    #[must_use]
2048    /// Set wallet address scheme (`Unified` or `Dual`).
2049    pub fn with_scheme(mut self, scheme: AddressScheme) -> Self {
2050        self.scheme = scheme;
2051        self
2052    }
2053
2054    #[must_use]
2055    /// Set active account index used for descriptor derivation.
2056    pub fn with_account_index(mut self, account_index: u32) -> Self {
2057        self.account_index = account_index;
2058        self
2059    }
2060
2061    #[must_use]
2062    /// Attach typed persistence state to hydrate wallet state.
2063    pub fn with_persistence_state(mut self, persistence: ZincPersistence) -> Self {
2064        self.persistence = Some(persistence);
2065        self
2066    }
2067
2068    /// Attach serialized persistence JSON to hydrate wallet state.
2069    pub fn with_persistence(mut self, json: &str) -> Result<Self, String> {
2070        let parsed = serde_json::from_str::<ZincPersistence>(json)
2071            .map_err(|e| format!("Persistence deserialization failed: {e}"))?;
2072        self.persistence = Some(parsed);
2073        Ok(self)
2074    }
2075
2076    /// Build a fully initialized `ZincWallet`.
2077    pub fn build(self) -> Result<ZincWallet, String> {
2078        let xprv = bdk_wallet::bitcoin::bip32::Xpriv::new_master(self.network, &self.seed)
2079            .map_err(|e| e.to_string())?;
2080
2081        let coin_type = i32::from(self.network != Network::Bitcoin);
2082        let account = self.account_index;
2083
2084        // 1. Vault Wallet (Always BIP-86 Taproot)
2085        // Manual descriptor construction for dynamic account index support
2086        // Template: tr(xprv/86'/coin'/account'/0/*)
2087        let vault_desc_str = format!("tr({}/86'/{coin_type}'/{account}'/0/*)", xprv);
2088        let vault_change_desc_str = format!("tr({}/86'/{coin_type}'/{account}'/1/*)", xprv);
2089
2090        let (vault_wallet, loaded_vault_changeset) = if let Some(p) = &self.persistence {
2091            let (wallet, changeset) = if let Some(changeset) = &p.taproot {
2092                // Attempt to load with persistence
2093                let res = Wallet::load()
2094                    .descriptor(KeychainKind::External, Some(vault_desc_str.clone()))
2095                    .descriptor(KeychainKind::Internal, Some(vault_change_desc_str.clone()))
2096                    .extract_keys()
2097                    .load_wallet_no_persist(changeset.clone());
2098
2099                match res {
2100                    Ok(Some(w)) => (w, changeset.clone()),
2101                    Ok(None) => {
2102                        let w = Wallet::create(vault_desc_str, vault_change_desc_str)
2103                            .network(self.network)
2104                            .create_wallet_no_persist()
2105                            .map_err(|e| e.to_string())?;
2106                        (w, bdk_wallet::ChangeSet::default())
2107                    }
2108                    Err(_e) => {
2109                        let w = Wallet::create(vault_desc_str, vault_change_desc_str)
2110                            .network(self.network)
2111                            .create_wallet_no_persist()
2112                            .map_err(|e| e.to_string())?;
2113                        (w, bdk_wallet::ChangeSet::default())
2114                    }
2115                }
2116            } else {
2117                let w = Wallet::create(vault_desc_str, vault_change_desc_str)
2118                    .network(self.network)
2119                    .create_wallet_no_persist()
2120                    .map_err(|e| e.to_string())?;
2121                (w, bdk_wallet::ChangeSet::default())
2122            };
2123
2124            (wallet, changeset)
2125        } else {
2126            let wallet = Wallet::create(vault_desc_str, vault_change_desc_str)
2127                .network(self.network)
2128                .create_wallet_no_persist()
2129                .map_err(|e| e.to_string())?;
2130            (wallet, bdk_wallet::ChangeSet::default())
2131        };
2132
2133        // 2. Payment Wallet (Only for Dual Scheme: BIP-84 SegWit)
2134        let (payment_wallet, loaded_payment_changeset) = if self.scheme == AddressScheme::Dual {
2135            // Manual descriptor construction for dynamic account index support
2136            // Template: wpkh(xprv/84'/coin'/account'/0/*)
2137            let payment_desc_str = format!("wpkh({}/84'/{coin_type}'/{account}'/0/*)", xprv);
2138            let payment_change_desc_str = format!("wpkh({}/84'/{coin_type}'/{account}'/1/*)", xprv);
2139
2140            let (wallet, changeset) = if let Some(p) = &self.persistence {
2141                if let Some(changeset) = &p.payment {
2142                    let res = Wallet::load()
2143                        .descriptor(KeychainKind::External, Some(payment_desc_str.clone()))
2144                        .descriptor(
2145                            KeychainKind::Internal,
2146                            Some(payment_change_desc_str.clone()),
2147                        )
2148                        .extract_keys()
2149                        .load_wallet_no_persist(changeset.clone());
2150
2151                    match res {
2152                        Ok(Some(w)) => (w, Some(changeset.clone())),
2153                        Ok(None) => {
2154                            let w = Wallet::create(
2155                                payment_desc_str.clone(),
2156                                payment_change_desc_str.clone(),
2157                            )
2158                            .network(self.network)
2159                            .create_wallet_no_persist()
2160                            .map_err(|e| e.to_string())?;
2161                            (w, None)
2162                        }
2163                        Err(_e) => {
2164                            let w = Wallet::create(
2165                                payment_desc_str.clone(),
2166                                payment_change_desc_str.clone(),
2167                            )
2168                            .network(self.network)
2169                            .create_wallet_no_persist()
2170                            .map_err(|e| e.to_string())?;
2171                            (w, None)
2172                        }
2173                    }
2174                } else {
2175                    let wallet =
2176                        Wallet::create(payment_desc_str.clone(), payment_change_desc_str.clone())
2177                            .network(self.network)
2178                            .create_wallet_no_persist()
2179                            .map_err(|e| e.to_string())?;
2180                    (wallet, None)
2181                }
2182            } else {
2183                let wallet = Wallet::create(payment_desc_str, payment_change_desc_str)
2184                    .network(self.network)
2185                    .create_wallet_no_persist()
2186                    .map_err(|e| e.to_string())?;
2187                (wallet, None)
2188            };
2189
2190            (Some(wallet), changeset)
2191        } else {
2192            (None, None)
2193        };
2194
2195        Ok(ZincWallet {
2196            vault_wallet,
2197            payment_wallet,
2198            scheme: self.scheme,
2199            loaded_vault_changeset,
2200            loaded_payment_changeset,
2201            account_index: self.account_index,
2202            inscribed_utxos: std::collections::HashSet::default(), // Initialize empty
2203            inscriptions: Vec::new(),
2204            ordinals_verified: false,
2205            ordinals_metadata_complete: false,
2206            master_xprv: xprv,
2207            is_syncing: false,
2208            account_generation: 0,
2209        })
2210    }
2211}
2212
2213#[cfg(test)]
2214mod tests {
2215    use super::*;
2216    use crate::keys::ZincMnemonic;
2217
2218    #[test]
2219    fn test_builder_ignores_mismatched_persistence() {
2220        // 1. Setup: Generate a mnemonic for consistent testing
2221        let mnemonic = ZincMnemonic::generate(12).unwrap();
2222        let seed = mnemonic.to_seed("");
2223        let network = Network::Regtest; // Use Regtest for generating addresses
2224
2225        // 2. Create "Account 1" persistence
2226        // We simulate a scenario where the user was previously on Account 1
2227        let mut builder_acc1 = WalletBuilder::from_seed(network, Seed64::from_array(*seed));
2228        builder_acc1 = builder_acc1.with_account_index(1);
2229        let mut wallet_acc1 = builder_acc1.build().unwrap();
2230
2231        let acc1_address = wallet_acc1.next_taproot_address().unwrap().to_string();
2232        let persistence_json = wallet_acc1.export_changeset().unwrap();
2233        let persistence_str = serde_json::to_string(&persistence_json).unwrap();
2234
2235        // 3. Attempt to build "Account 0" using "Account 1" persistence
2236        // This simulates the bug: cached state from wrong account ID used for initialization
2237        let mut builder_acc0 = WalletBuilder::from_seed(network, Seed64::from_array(*seed));
2238        builder_acc0 = builder_acc0
2239            .with_account_index(0)
2240            .with_persistence(&persistence_str) // Inject mismatching persistence
2241            .unwrap();
2242
2243        let mut wallet_acc0 = builder_acc0.build().unwrap();
2244        let acc0_address = wallet_acc0.next_taproot_address().unwrap().to_string();
2245
2246        // 4. Verify:
2247        // - The resulting wallet should have Account 0's address (persistence ignored)
2248        // - It should NOT have Account 1's address
2249        assert_ne!(
2250            acc0_address, acc1_address,
2251            "Account 0 should have different address than Account 1"
2252        );
2253
2254        // Let's create a pristine Account 0 to verify the address matches exactly
2255        let mut pristine_acc0 = WalletBuilder::from_seed(network, Seed64::from_array(*seed))
2256            .with_account_index(0)
2257            .build()
2258            .unwrap();
2259        let expected_acc0_addr = pristine_acc0.next_taproot_address().unwrap().to_string();
2260
2261        assert_eq!(
2262            acc0_address, expected_acc0_addr,
2263            "Wallet should match clean Account 0 address, ignoring mismatched persistence"
2264        );
2265    }
2266
2267    #[test]
2268    fn test_persistence_cycle_mismatch() {
2269        // This tests the "tpub vs xprv" descriptor string mismatch issue.
2270        // BDK persists as 'tpub' (checksummed), but we calculate 'xprv' (no checksum) on load.
2271        // The builder MUST be smart enough to load it anyway.
2272
2273        let mnemonic = ZincMnemonic::generate(12).unwrap();
2274        let seed = mnemonic.to_seed("");
2275        let network = Network::Regtest;
2276
2277        // 1. Create original wallet
2278        let builder = WalletBuilder::from_seed(network, Seed64::from_array(*seed));
2279        let wallet = builder.build().unwrap();
2280
2281        // Simulating some state to persist (would be empty changeset but structurally valid)
2282        let persistence_struct = wallet.export_changeset().unwrap();
2283        let persistence_str = serde_json::to_string(&persistence_struct).unwrap();
2284
2285        // 2. Create NEW wallet with SAME seed + persistence
2286        let mut builder_rehydrated = WalletBuilder::from_seed(network, Seed64::from_array(*seed));
2287        builder_rehydrated = builder_rehydrated
2288            .with_persistence(&persistence_str)
2289            .unwrap();
2290
2291        let res = builder_rehydrated.build();
2292        assert!(
2293            res.is_ok(),
2294            "Should build successfully with matching persistence"
2295        );
2296
2297        let wallet_rehydrated = res.unwrap();
2298
2299        // If hydration worked, the loaded changeset should be present (Some)
2300        // Note: Our builder returns a wallet struct. We can check internal state if exposed,
2301        // or rely on the fact that build() succeeded and didn't panic/fail.
2302        // The critical check is that `builder.rs` logic didn't reject the persistence
2303        // because of the string difference.
2304
2305        assert!(
2306            wallet_rehydrated
2307                .loaded_vault_changeset
2308                .descriptor
2309                .is_some(),
2310            "Vault changeset descriptor should be loaded"
2311        );
2312    }
2313
2314    #[test]
2315    fn test_set_active_account_resets_account_scoped_state() {
2316        let mnemonic = ZincMnemonic::generate(12).unwrap();
2317        let seed = mnemonic.to_seed("");
2318        let network = Network::Regtest;
2319
2320        let mut wallet = WalletBuilder::from_seed(network, Seed64::from_array(*seed))
2321            .build()
2322            .unwrap();
2323        wallet.loaded_vault_changeset.network = Some(network);
2324        wallet.loaded_payment_changeset = Some(bdk_wallet::ChangeSet::default());
2325        wallet.inscribed_utxos.insert(bitcoin::OutPoint::null());
2326        wallet
2327            .inscriptions
2328            .push(crate::ordinals::types::Inscription {
2329                id: "testi0".to_string(),
2330                number: 1,
2331                satpoint: Default::default(),
2332                content_type: Some("image/png".to_string()),
2333                value: Some(1),
2334                content_length: None,
2335                timestamp: None,
2336            });
2337        wallet.ordinals_verified = true;
2338        let original_generation = wallet.account_generation;
2339
2340        wallet.set_active_account(1).unwrap();
2341
2342        assert_eq!(wallet.account_index, 1);
2343        assert!(wallet.loaded_vault_changeset.network.is_none());
2344        assert!(wallet.loaded_payment_changeset.is_none());
2345        assert!(wallet.inscribed_utxos.is_empty());
2346        assert!(wallet.inscriptions.is_empty());
2347        assert!(!wallet.ordinals_verified);
2348        assert_eq!(wallet.account_generation, original_generation + 1);
2349    }
2350
2351    #[test]
2352    fn test_unverified_inscription_cache_does_not_mark_verified() {
2353        let mnemonic = ZincMnemonic::generate(12).unwrap();
2354        let seed = mnemonic.to_seed("");
2355        let network = Network::Regtest;
2356
2357        let mut wallet = WalletBuilder::from_seed(network, Seed64::from_array(*seed))
2358            .build()
2359            .unwrap();
2360
2361        let mut protected = std::collections::HashSet::new();
2362        protected.insert(bitcoin::OutPoint::null());
2363        wallet.apply_verified_ordinals_update(Vec::new(), protected);
2364        assert!(wallet.ordinals_verified);
2365        assert!(!wallet.inscribed_utxos.is_empty());
2366
2367        let count =
2368            wallet.apply_unverified_inscriptions_cache(vec![crate::ordinals::types::Inscription {
2369                id: "testi0".to_string(),
2370                number: 1,
2371                satpoint: Default::default(),
2372                content_type: Some("image/png".to_string()),
2373                value: Some(1),
2374                content_length: None,
2375                timestamp: None,
2376            }]);
2377
2378        assert_eq!(count, 1);
2379        assert_eq!(wallet.inscriptions.len(), 1);
2380        assert!(wallet.inscribed_utxos.is_empty());
2381        assert!(!wallet.ordinals_verified);
2382        assert!(wallet.ordinals_metadata_complete);
2383    }
2384}