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::{AddressType, NetworkUnchecked};
12use bitcoin::hashes::Hash;
13use bitcoin::psbt::Psbt;
14use bitcoin::{Address, Amount, FeeRate, Network, Transaction};
15// use bitcoin::PsbtSighashType; // Failed
16use crate::error::ZincError;
17use crate::keys::ZincMnemonic;
18use serde::{Deserialize, Serialize};
19use std::str::FromStr;
20
21const LOG_TARGET_BUILDER: &str = "zinc_core::builder";
22
23/// Platform-safe current-time-in-seconds for BDK sync request start times.
24///
25/// `FullScanRequest::builder()` (the parameterless variant) calls
26/// `std::time::UNIX_EPOCH.elapsed()` internally, which panics on
27/// `wasm32-unknown-unknown` with "time not implemented on this platform".
28/// This helper provides the same value via `js_sys::Date::now()` on WASM
29/// and the standard library on native targets.
30fn wasm_now_secs() -> u64 {
31    #[cfg(target_arch = "wasm32")]
32    {
33        (js_sys::Date::now() / 1000.0) as u64
34    }
35    #[cfg(not(target_arch = "wasm32"))]
36    {
37        std::time::UNIX_EPOCH
38            .elapsed()
39            .unwrap_or_default()
40            .as_secs()
41    }
42}
43
44/// Optional controls for PSBT signing behavior.
45#[derive(Debug, Clone, Deserialize, Default)]
46#[serde(rename_all = "camelCase")]
47pub struct SignOptions {
48    /// Restrict signing to specific input indices.
49    pub sign_inputs: Option<Vec<usize>>,
50    /// Override the PSBT sighash type as raw `u8`.
51    pub sighash: Option<u8>,
52    /// If true, finalize the PSBT after signing (for internal wallet use).
53    /// Defaults to false for dApp/marketplace compatibility.
54    #[serde(default)]
55    pub finalize: bool,
56}
57
58use zeroize::{Zeroize, ZeroizeOnDrop};
59
60/// Strongly-typed 64-byte seed material used by canonical constructors.
61#[derive(Debug, Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)]
62pub struct Seed64([u8; 64]);
63
64impl Seed64 {
65    /// Create a seed wrapper from a 64-byte array.
66    #[must_use]
67    pub const fn from_array(bytes: [u8; 64]) -> Self {
68        Self(bytes)
69    }
70}
71
72impl AsRef<[u8]> for Seed64 {
73    fn as_ref(&self) -> &[u8] {
74        &self.0
75    }
76}
77
78impl TryFrom<&[u8]> for Seed64 {
79    type Error = ZincError;
80
81    fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
82        let array: [u8; 64] = value.try_into().map_err(|_| {
83            ZincError::ConfigError(format!(
84                "Invalid seed length: {}. Expected 64 bytes.",
85                value.len()
86            ))
87        })?;
88        Ok(Self(array))
89    }
90}
91
92/// Typed request for PSBT creation in native Rust flows.
93#[derive(Debug, Clone)]
94pub struct CreatePsbtRequest {
95    /// Recipient address (network checked at build time).
96    pub recipient: Address<NetworkUnchecked>,
97    /// Amount in satoshis.
98    pub amount: Amount,
99    /// Fee rate in sat/vB.
100    pub fee_rate: FeeRate,
101}
102
103impl CreatePsbtRequest {
104    /// Build a typed request from transport-friendly inputs.
105    pub fn from_parts(
106        recipient: &str,
107        amount_sats: u64,
108        fee_rate_sat_vb: u64,
109    ) -> Result<Self, ZincError> {
110        let recipient = recipient
111            .parse::<Address<NetworkUnchecked>>()
112            .map_err(|e| ZincError::ConfigError(format!("Invalid address: {e}")))?;
113        let fee_rate = FeeRate::from_sat_per_vb(fee_rate_sat_vb)
114            .ok_or_else(|| ZincError::ConfigError("Invalid fee rate".to_string()))?;
115
116        Ok(Self {
117            recipient,
118            amount: Amount::from_sat(amount_sats),
119            fee_rate,
120        })
121    }
122}
123
124/// Transport-friendly PSBT creation request used by WASM/RPC boundaries.
125#[derive(Debug, Clone, Deserialize, Serialize)]
126#[serde(rename_all = "camelCase")]
127pub struct CreatePsbtTransportRequest {
128    /// Recipient address string.
129    pub recipient: String,
130    /// Amount in satoshis.
131    pub amount_sats: u64,
132    /// Fee rate in sat/vB.
133    pub fee_rate_sat_vb: u64,
134}
135
136impl TryFrom<CreatePsbtTransportRequest> for CreatePsbtRequest {
137    type Error = ZincError;
138
139    fn try_from(value: CreatePsbtTransportRequest) -> Result<Self, Self::Error> {
140        Self::from_parts(&value.recipient, value.amount_sats, value.fee_rate_sat_vb)
141    }
142}
143
144/// Address derivation mode for a wallet account.
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub enum AddressScheme {
147    /// Single-wallet mode (taproot only).
148    Unified,
149    /// Two-wallet mode (taproot + payment).
150    Dual,
151}
152
153/// Payment address branch type used in dual-scheme mode.
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
155#[serde(rename_all = "lowercase")]
156#[derive(Default)]
157pub enum PaymentAddressType {
158    /// BIP84 native segwit (bech32 `bc1q...` / `tb1q...`).
159    #[default]
160    NativeSegwit,
161    /// BIP49 nested segwit (P2SH `3...` / `2...`).
162    NestedSegwit,
163    /// BIP44 legacy P2PKH (`1...` / `m|n...`).
164    Legacy,
165}
166
167impl PaymentAddressType {
168    #[must_use]
169    pub fn purpose(self) -> u32 {
170        match self {
171            Self::NativeSegwit => 84,
172            Self::NestedSegwit => 49,
173            Self::Legacy => 44,
174        }
175    }
176}
177
178/// Logical account mapping mode for descriptor derivation.
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
180#[serde(rename_all = "lowercase")]
181#[derive(Default)]
182pub enum DerivationMode {
183    /// Traditional account derivation: account=N, address index=0.
184    #[default]
185    Account,
186    /// Index-style derivation: account=0, address index=N.
187    Index,
188}
189
190/// Operational mode for the profile.
191#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
192#[serde(rename_all = "lowercase")]
193pub enum ProfileMode {
194    /// Full signing capabilities with stored seed.
195    Seed,
196    /// Watch-only mode (public descriptors only).
197    Watch,
198}
199
200/// Controls for address discovery and sync behavior.
201#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
202#[serde(rename_all = "camelCase")]
203pub struct ScanPolicy {
204    /// Number of consecutive unused addresses to skip before stopping scan.
205    pub account_gap_limit: u32,
206    /// Constant number of addresses to scan regardless of activity.
207    pub address_scan_depth: u32,
208}
209
210impl Default for ScanPolicy {
211    fn default() -> Self {
212        Self {
213            account_gap_limit: 20,
214            address_scan_depth: 1,
215        }
216    }
217}
218
219#[cfg(target_arch = "wasm32")]
220#[derive(Debug, Clone, Copy, Default)]
221pub struct WasmSleeper;
222
223#[cfg(target_arch = "wasm32")]
224pub struct WasmSleep(gloo_timers::future::TimeoutFuture);
225
226#[cfg(target_arch = "wasm32")]
227impl std::future::Future for WasmSleep {
228    type Output = ();
229    fn poll(
230        mut self: std::pin::Pin<&mut Self>,
231        cx: &mut std::task::Context<'_>,
232    ) -> std::task::Poll<Self::Output> {
233        std::pin::Pin::new(&mut self.0).poll(cx)
234    }
235}
236
237#[cfg(target_arch = "wasm32")]
238// SAFETY: WASM is single-threaded, so we can safely implement Send
239#[allow(unsafe_code)]
240unsafe impl Send for WasmSleep {}
241
242#[cfg(target_arch = "wasm32")]
243impl esplora_client::Sleeper for WasmSleeper {
244    type Sleep = WasmSleep;
245    fn sleep(dur: std::time::Duration) -> Self::Sleep {
246        WasmSleep(gloo_timers::future::TimeoutFuture::new(
247            dur.as_millis() as u32
248        ))
249    }
250}
251
252#[cfg(target_arch = "wasm32")]
253pub type SyncSleeper = WasmSleeper;
254
255/// Native async sleeper used by Esplora clients on non-WASM targets.
256#[cfg(not(target_arch = "wasm32"))]
257#[derive(Debug, Clone, Copy, Default)]
258pub struct TokioSleeper;
259
260#[cfg(not(target_arch = "wasm32"))]
261impl esplora_client::Sleeper for TokioSleeper {
262    type Sleep = tokio::time::Sleep;
263    fn sleep(dur: std::time::Duration) -> Self::Sleep {
264        tokio::time::sleep(dur)
265    }
266}
267
268/// Platform-specific async sleeper used for sync calls.
269#[cfg(not(target_arch = "wasm32"))]
270pub type SyncSleeper = TokioSleeper;
271
272/// Return the current UNIX epoch seconds for the active target.
273pub fn now_unix() -> u64 {
274    #[cfg(target_arch = "wasm32")]
275    {
276        (js_sys::Date::now() / 1000.0) as u64
277    }
278
279    #[cfg(not(target_arch = "wasm32"))]
280    {
281        std::time::SystemTime::now()
282            .duration_since(std::time::UNIX_EPOCH)
283            .unwrap_or_default()
284            .as_secs()
285    }
286}
287
288/// Represents the cryptographic identity of the wallet.
289#[derive(Debug, Clone)]
290pub enum WalletKind {
291    /// Full signing capability with master private key.
292    Seed {
293        /// Master extended private key derived from the seed.
294        master_xprv: bdk_wallet::bitcoin::bip32::Xpriv,
295    },
296    /// Hardware/watch-only wallet constructed from public descriptors.
297    Hardware {
298        /// The 4-byte master fingerprint of the hardware device.
299        fingerprint: [u8; 4],
300        /// External taproot descriptor for public key derivation.
301        taproot_external: String,
302        /// Optional external payment descriptor for public key derivation.
303        payment_external: Option<String>,
304    },
305    /// Read-only capability bound to a single tracked address.
306    WatchAddress(Address),
307}
308
309impl WalletKind {
310    /// Returns true if this identity is read-only.
311    #[must_use]
312    pub fn is_watch(&self) -> bool {
313        !matches!(self, Self::Seed { .. })
314    }
315
316    /// Derive external and internal descriptor strings for the vault and optional payment keychains.
317    /// Returns (vault_external, vault_internal, payment_external, payment_internal).
318    pub fn derive_descriptors(
319        &self,
320        scheme: AddressScheme,
321        payment_type: PaymentAddressType,
322        network: Network,
323        account: u32,
324    ) -> (String, String, Option<String>, Option<String>) {
325        let coin_type = u32::from(network != Network::Bitcoin);
326
327        match self {
328            Self::Seed {
329                master_xprv: master,
330            } => {
331                let vault_ext = format!("tr({master}/86'/{coin_type}'/{account}'/0/*)");
332                let vault_int = format!("tr({master}/86'/{coin_type}'/{account}'/1/*)");
333
334                if scheme == AddressScheme::Dual {
335                    let pay_ext =
336                        payment_descriptor_for_xprv(master, payment_type, coin_type, account, 0);
337                    let pay_int =
338                        payment_descriptor_for_xprv(master, payment_type, coin_type, account, 1);
339                    (vault_ext, vault_int, Some(pay_ext), Some(pay_int))
340                } else {
341                    (vault_ext, vault_int, None, None)
342                }
343            }
344            Self::Hardware {
345                taproot_external,
346                payment_external,
347                ..
348            } => {
349                // For hardware wallets, we assume the provided descriptors are already at the account level.
350                (
351                    taproot_external.clone(),
352                    taproot_external.replace("/0/*", "/1/*"),
353                    payment_external.clone(),
354                    payment_external.as_ref().map(|e| e.replace("/0/*", "/1/*")),
355                )
356            }
357            Self::WatchAddress(address) => {
358                let descriptor = taproot_watch_descriptor(address)
359                    .expect("watch-address identity must hold a validated taproot address");
360                (descriptor.clone(), descriptor, None, None)
361            }
362        }
363    }
364}
365
366/// Stateful wallet runtime that owns account wallets and safety state.
367pub struct ZincWallet {
368    // We use Option for payment_wallet to support Unified mode (where only taproot exists)
369    // In Unified mode, payment calls are routed to taproot.
370    /// Taproot wallet (always present).
371    pub(crate) vault_wallet: Wallet,
372    /// Optional payment wallet used in dual-account scheme.
373    pub(crate) payment_wallet: Option<Wallet>,
374    /// Current address scheme in use.
375    pub(crate) scheme: AddressScheme,
376    /// Account derivation mode.
377    pub(crate) derivation_mode: DerivationMode,
378    /// Payment branch address type (used in dual scheme).
379    pub(crate) payment_address_type: PaymentAddressType,
380    // Store original loaded changesets to merge with staged changes for full persistence
381    /// Loaded taproot changeset baseline used for persistence merges.
382    pub(crate) loaded_vault_changeset: bdk_wallet::ChangeSet,
383    /// Loaded payment changeset baseline used for persistence merges.
384    pub(crate) loaded_payment_changeset: Option<bdk_wallet::ChangeSet>,
385    /// Active account index.
386    pub(crate) account_index: u32,
387    /// Whether the wallet has signing capabilities.
388    pub(crate) mode: ProfileMode,
389    /// Active scan policy for address discovery.
390    pub(crate) scan_policy: ScanPolicy,
391    // Ordinal Shield State (In-Memory Only)
392    /// Outpoints currently marked as inscribed/protected.
393    pub(crate) inscribed_utxos: std::collections::HashSet<bitcoin::OutPoint>,
394    /// Cached inscription metadata known to the wallet.
395    pub(crate) inscriptions: Vec<crate::ordinals::types::Inscription>,
396    /// Cached read-only rune balances known to the wallet.
397    pub(crate) rune_balances: Vec<crate::ordinals::types::RuneBalance>,
398    /// Whether ordinals protection state is currently verified.
399    pub(crate) ordinals_verified: bool,
400    /// Whether inscription metadata refresh has completed.
401    pub(crate) ordinals_metadata_complete: bool,
402    /// Cryptographic identity of this wallet (Seed or Hardware).
403    pub(crate) kind: WalletKind,
404    /// Guard flag used to prevent overlapping sync operations.
405    #[allow(dead_code)]
406    pub(crate) is_syncing: bool,
407    /// Monotonic generation used to invalidate stale async operations.
408    pub(crate) account_generation: u64,
409}
410
411/// Describes how chain data should be fetched for a keychain set.
412pub enum SyncRequestType {
413    /// Full scan request.
414    Full(FullScanRequest<KeychainKind>),
415    /// Incremental sync request.
416    Incremental(SyncRequest<(KeychainKind, u32)>),
417}
418
419/// Bundled sync request for taproot and optional payment wallets.
420pub struct ZincSyncRequest {
421    /// Taproot sync request.
422    pub taproot: SyncRequestType,
423    /// Optional payment sync request.
424    pub payment: Option<SyncRequestType>,
425}
426
427/// User-facing balance view that separates total, spendable, and inscribed value.
428#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
429pub struct ZincBalance {
430    /// Raw combined wallet balance.
431    pub total: bdk_wallet::Balance,
432    /// Spendable balance after protection filtering.
433    pub spendable: bdk_wallet::Balance,
434    /// Display-focused spendable balance (payment wallet in dual mode).
435    pub display_spendable: bdk_wallet::Balance,
436    /// Estimated value currently marked as inscribed/protected.
437    pub inscribed: u64,
438}
439
440/// Account summary returned by discovery and account listing APIs.
441#[derive(Debug, Clone, Serialize, Deserialize)]
442#[serde(rename_all = "camelCase")]
443pub struct Account {
444    /// Account index.
445    pub index: u32,
446    /// Human-readable account label.
447    pub label: String,
448    /// Taproot receive address.
449    #[serde(alias = "vaultAddress")]
450    pub taproot_address: String,
451    /// Taproot public key for account receive path.
452    #[serde(alias = "vaultPublicKey")]
453    pub taproot_public_key: String,
454    /// Payment receive address when in dual mode.
455    pub payment_address: Option<String>,
456    /// Payment public key when in dual mode.
457    pub payment_public_key: Option<String>,
458}
459
460/// Derived descriptor/public-key material for one account discovery candidate.
461#[derive(Debug, Clone)]
462pub struct DiscoveryAccountPlan {
463    /// Account index.
464    pub index: u32,
465    /// Taproot external descriptor template.
466    pub taproot_descriptor: String,
467    /// Taproot internal/change descriptor template.
468    pub taproot_change_descriptor: String,
469    /// Account taproot public key.
470    pub taproot_public_key: String,
471    /// Receive index used for taproot lookups in this logical account.
472    pub taproot_receive_index: u32,
473    /// Optional payment external descriptor template.
474    pub payment_descriptor: Option<String>,
475    /// Optional payment internal/change descriptor template.
476    pub payment_change_descriptor: Option<String>,
477    /// Optional payment public key.
478    pub payment_public_key: Option<String>,
479    /// Optional payment receive index used for lookups in this logical account.
480    pub payment_receive_index: Option<u32>,
481}
482
483/// Precomputed account discovery context that avoids exposing raw keys externally.
484#[derive(Debug, Clone)]
485pub struct DiscoveryContext {
486    /// Network for the descriptors in this context.
487    pub network: Network,
488    /// Address scheme for descriptors in this context.
489    pub scheme: AddressScheme,
490    /// Account derivation mode for descriptor/account mapping.
491    pub derivation_mode: DerivationMode,
492    /// Payment branch type for dual descriptors.
493    pub payment_address_type: PaymentAddressType,
494    /// Cryptographic identity used for derivation.
495    pub kind: WalletKind,
496    /// Account plans to evaluate.
497    pub accounts: Vec<DiscoveryAccountPlan>,
498    /// Guard flag used to prevent overlapping sync operations.
499    pub is_syncing: bool,
500    /// Monotonic generation used to invalidate stale async operations.
501    pub account_generation: u64,
502}
503
504fn payment_descriptor_for_xprv(
505    xprv: &bdk_wallet::bitcoin::bip32::Xpriv,
506    address_type: PaymentAddressType,
507    coin_type: u32,
508    account: u32,
509    chain: u32,
510) -> String {
511    let pay_purpose = address_type.purpose();
512
513    match address_type {
514        PaymentAddressType::NativeSegwit => {
515            format!("wpkh({xprv}/{pay_purpose}'/{coin_type}'/{account}'/{chain}/*)")
516        }
517        PaymentAddressType::NestedSegwit => {
518            format!("sh(wpkh({xprv}/{pay_purpose}'/{coin_type}'/{account}'/{chain}/*))")
519        }
520        PaymentAddressType::Legacy => {
521            format!("pkh({xprv}/{pay_purpose}'/{coin_type}'/{account}'/{chain}/*)")
522        }
523    }
524}
525
526fn payment_descriptor_for_xpub(
527    xpub: &bdk_wallet::bitcoin::bip32::Xpub,
528    address_type: PaymentAddressType,
529    chain: u32,
530) -> String {
531    match address_type {
532        PaymentAddressType::NativeSegwit => format!("wpkh({xpub}/{chain}/*)"),
533        PaymentAddressType::NestedSegwit => format!("sh(wpkh({xpub}/{chain}/*))"),
534        PaymentAddressType::Legacy => format!("pkh({xpub}/{chain}/*)"),
535    }
536}
537
538fn parse_extended_public_key(xpub: &str) -> Result<bdk_wallet::bitcoin::bip32::Xpub, String> {
539    use bdk_wallet::bitcoin::bip32::Xpub;
540
541    if let Ok(parsed) = Xpub::from_str(xpub) {
542        return Ok(parsed);
543    }
544
545    let mut data = bdk_wallet::bitcoin::base58::decode_check(xpub)
546        .map_err(|e| format!("Invalid extended public key: {e}"))?;
547    if data.len() != 78 {
548        return Err(format!(
549            "Invalid extended public key payload length: {} (expected 78)",
550            data.len()
551        ));
552    }
553
554    let version: [u8; 4] = [data[0], data[1], data[2], data[3]];
555    let normalized_version = match version {
556        // mainnet xpub/ypub/zpub/Ypub/Zpub variants
557        [0x04, 0x88, 0xB2, 0x1E]
558        | [0x04, 0x9D, 0x7C, 0xB2]
559        | [0x04, 0xB2, 0x47, 0x46]
560        | [0x02, 0x95, 0xB4, 0x3F]
561        | [0x02, 0xAA, 0x7E, 0xD3] => [0x04, 0x88, 0xB2, 0x1E],
562        // testnet/signet tpub/upub/vpub/Upub/Vpub variants
563        [0x04, 0x35, 0x87, 0xCF]
564        | [0x04, 0x4A, 0x52, 0x62]
565        | [0x04, 0x5F, 0x1C, 0xF6]
566        | [0x02, 0x42, 0x89, 0xEF]
567        | [0x02, 0x57, 0x54, 0x83] => [0x04, 0x35, 0x87, 0xCF],
568        _ => {
569            return Err(
570                "Unsupported extended public key prefix (expected xpub/ypub/zpub/tpub/upub/vpub)"
571                    .to_string(),
572            );
573        }
574    };
575
576    data[0..4].copy_from_slice(&normalized_version);
577    Xpub::decode(&data).map_err(|e| format!("Invalid extended public key: {e}"))
578}
579
580fn taproot_output_key_from_address(
581    address: &Address,
582) -> Result<bitcoin::secp256k1::XOnlyPublicKey, String> {
583    if address.address_type() != Some(AddressType::P2tr) {
584        return Err(
585            "Address watch mode currently supports taproot (bc1p/tb1p/bcrt1p) addresses only"
586                .to_string(),
587        );
588    }
589
590    let witness_program = address
591        .witness_program()
592        .ok_or_else(|| "Taproot address missing witness program".to_string())?;
593    let key_bytes = witness_program.program().as_bytes();
594    if key_bytes.len() != 32 {
595        return Err(format!(
596            "Invalid taproot witness program length: {}",
597            key_bytes.len()
598        ));
599    }
600
601    bitcoin::secp256k1::XOnlyPublicKey::from_slice(key_bytes)
602        .map_err(|e| format!("Invalid taproot output key: {e}"))
603}
604
605fn taproot_watch_descriptor(address: &Address) -> Result<String, String> {
606    let output_key = taproot_output_key_from_address(address)?;
607    Ok(format!("tr({output_key})"))
608}
609
610/// Builder for constructing a `ZincWallet` from identity, network, and options.
611#[derive(Clone)]
612pub struct WalletBuilder {
613    network: Network,
614    kind: Option<WalletKind>,
615    mode: ProfileMode,
616    scheme: AddressScheme,
617    derivation_mode: DerivationMode,
618    payment_address_type: PaymentAddressType,
619    persistence: Option<ZincPersistence>,
620    account_index: u32,
621    scan_policy: ScanPolicy,
622}
623
624impl ZincWallet {
625    fn watched_address(&self) -> Option<&Address> {
626        match &self.kind {
627            WalletKind::WatchAddress(address) => Some(address),
628            _ => None,
629        }
630    }
631
632    pub fn derive_public_key_internal(
633        &self,
634        purpose: u32,
635        _network: Network,
636        account: u32,
637        index: u32,
638    ) -> Result<String, String> {
639        use bitcoin::secp256k1::Secp256k1;
640        let secp = Secp256k1::new();
641
642        match &self.kind {
643            WalletKind::Seed { master_xprv } => {
644                let network = self.vault_wallet.network();
645                let coin_type = if network == Network::Bitcoin { 0 } else { 1 };
646                let chain = 0; // External
647
648                let derivation_path = [
649                    bdk_wallet::bitcoin::bip32::ChildNumber::from_hardened_idx(purpose).unwrap(),
650                    bdk_wallet::bitcoin::bip32::ChildNumber::from_hardened_idx(coin_type).unwrap(),
651                    bdk_wallet::bitcoin::bip32::ChildNumber::from_hardened_idx(account).unwrap(),
652                    bdk_wallet::bitcoin::bip32::ChildNumber::from_normal_idx(chain).unwrap(),
653                    bdk_wallet::bitcoin::bip32::ChildNumber::from_normal_idx(index).unwrap(),
654                ];
655
656                let child_xprv = master_xprv
657                    .derive_priv(&secp, &derivation_path)
658                    .map_err(|e| format!("Key derivation failed: {e}"))?;
659
660                let public_key = child_xprv.private_key.public_key(&secp);
661
662                // Check purpose to decide format
663                if purpose == 86 {
664                    // Taproot (BIP-86) uses 32-byte x-only public keys
665                    let (x_only, _parity) = public_key.x_only_public_key();
666                    Ok(x_only.to_string())
667                } else {
668                    // SegWit (BIP-84) uses 33-byte compressed public keys
669                    Ok(public_key.to_string())
670                }
671            }
672            WalletKind::Hardware {
673                taproot_external,
674                payment_external,
675                ..
676            } => {
677                // For hardware wallets, we derive from the provided public descriptors.
678                // Desc format is usually like: tr([fp/path]xpub.../0/*)
679                let desc_str = if purpose == 86 {
680                    taproot_external
681                } else {
682                    payment_external.as_ref().ok_or_else(|| {
683                        "Payment descriptor missing for this hardware wallet".to_string()
684                    })?
685                };
686
687                // 1. Extract the Xpub string from the descriptor.
688                // Find either the character after ']' or the first '(' which starts the descriptor payload.
689                let xpub_start_part = if let Some(pos) = desc_str.find(']') {
690                    &desc_str[pos + 1..]
691                } else if let Some(pos) = desc_str.find('(') {
692                    &desc_str[pos + 1..]
693                } else {
694                    desc_str
695                };
696
697                // Find the first slash indicating the derivation path start from the xpub.
698                // If no slash, use the whole string (it might be a plain xpub or have trailing descriptor syntax).
699                let xpub_end_pos = xpub_start_part.find('/').unwrap_or(xpub_start_part.len());
700                let xpub_str = xpub_start_part[..xpub_end_pos].trim_end_matches(')');
701
702                // 2. Parse and derive.
703                use bitcoin::bip32::{ChildNumber, Xpub};
704                use std::str::FromStr;
705                let xpub = Xpub::from_str(xpub_str).map_err(|e| {
706                    format!(
707                        "Failed to parse xpub from descriptor (part: {}): {}",
708                        xpub_str, e
709                    )
710                })?;
711
712                // Derive /0/index (assuming external chain '0' matches our descriptors)
713                let derived_xpub = xpub
714                    .derive_pub(
715                        &secp,
716                        &[
717                            ChildNumber::from_normal_idx(0).unwrap(),
718                            ChildNumber::from_normal_idx(index).unwrap(),
719                        ],
720                    )
721                    .map_err(|e| format!("Failed to derive public key from xpub: {}", e))?;
722
723                let public_key = derived_xpub.public_key;
724
725                if purpose == 86 {
726                    let (x_only, _parity) = public_key.x_only_public_key();
727                    Ok(x_only.to_string())
728                } else {
729                    Ok(public_key.to_string())
730                }
731            }
732            WalletKind::WatchAddress(address) => {
733                if purpose != 86 {
734                    return Err(ZincError::CapabilityMissing.to_string());
735                }
736                let output_key = taproot_output_key_from_address(address)
737                    .map_err(|_| ZincError::CapabilityMissing.to_string())?;
738                return Ok(output_key.to_string());
739            }
740        }
741    }
742
743    /// Return the cached inscriptions currently tracked by the wallet.
744    #[must_use]
745    pub fn inscriptions(&self) -> &[crate::ordinals::types::Inscription] {
746        &self.inscriptions
747    }
748
749    /// Return the cached rune balances currently tracked by the wallet.
750    #[must_use]
751    pub fn rune_balances(&self) -> &[crate::ordinals::types::RuneBalance] {
752        &self.rune_balances
753    }
754
755    /// Return the current account generation counter.
756    #[must_use]
757    pub fn account_generation(&self) -> u64 {
758        self.account_generation
759    }
760
761    /// Return the currently active account index.
762    #[must_use]
763    pub fn active_account_index(&self) -> u32 {
764        self.account_index
765    }
766
767    /// Return whether a sync operation is currently in progress.
768    #[must_use]
769    pub fn is_syncing(&self) -> bool {
770        self.is_syncing
771    }
772
773    /// Return whether ordinals protection data is verified.
774    #[must_use]
775    pub fn ordinals_verified(&self) -> bool {
776        self.ordinals_verified
777    }
778
779    /// Return whether ordinals metadata refresh completed successfully.
780    #[must_use]
781    pub fn ordinals_metadata_complete(&self) -> bool {
782        self.ordinals_metadata_complete
783    }
784
785    /// Return `true` when the wallet uses unified addressing.
786    pub fn is_unified(&self) -> bool {
787        self.scheme == AddressScheme::Unified
788    }
789
790    /// Return the active derivation mode.
791    #[must_use]
792    pub fn derivation_mode(&self) -> DerivationMode {
793        self.derivation_mode
794    }
795
796    /// Return the profile mode (`seed` vs `watch`).
797    #[must_use]
798    pub fn profile_mode(&self) -> ProfileMode {
799        self.mode
800    }
801
802    /// Return the active payment address type.
803    #[must_use]
804    pub fn payment_address_type(&self) -> PaymentAddressType {
805        self.payment_address_type
806    }
807
808    fn logical_account_path(&self, logical_account_index: u32) -> (u32, u32) {
809        match self.derivation_mode {
810            DerivationMode::Account => (logical_account_index, 0),
811            DerivationMode::Index => (0, logical_account_index),
812        }
813    }
814
815    fn active_receive_index(&self) -> u32 {
816        self.logical_account_path(self.account_index).1
817    }
818
819    fn active_derivation_account(&self) -> u32 {
820        self.logical_account_path(self.account_index).0
821    }
822
823    fn dual_payment_purpose(&self) -> u32 {
824        self.payment_address_type.purpose()
825    }
826
827    /// Return `true` when wallet state indicates a full scan is needed.
828    pub fn needs_full_scan(&self) -> bool {
829        // If we have no transactions and the tip is at genesis (or missing), we likely need a full scan
830        self.vault_wallet.local_chain().tip().height() == 0
831    }
832
833    /// Reveal and return the next taproot receive address.
834    pub fn next_taproot_address(&mut self) -> Result<Address, String> {
835        if let Some(address) = self.watched_address() {
836            return Ok(address.clone());
837        }
838
839        if self.derivation_mode == DerivationMode::Index {
840            return Ok(self.peek_taproot_address(0));
841        }
842        let info = self
843            .vault_wallet
844            .reveal_next_address(KeychainKind::External);
845        Ok(info.address)
846    }
847
848    /// Peek a taproot receive address at `index` without advancing state.
849    pub fn peek_taproot_address(&self, index: u32) -> Address {
850        if let Some(address) = self.watched_address() {
851            let _ = index;
852            return address.clone();
853        }
854
855        let resolved_index = self.active_receive_index().saturating_add(index);
856        self.vault_wallet
857            .peek_address(KeychainKind::External, resolved_index)
858            .address
859    }
860
861    /// Reveal and return the next payment address in dual mode.
862    ///
863    /// In unified mode this returns the next taproot address.
864    pub fn get_payment_address(&mut self) -> Result<bitcoin::Address, String> {
865        if self.scheme == AddressScheme::Dual {
866            if self.derivation_mode == DerivationMode::Index {
867                return self
868                    .peek_payment_address(0)
869                    .ok_or_else(|| "Payment wallet not initialized".to_string());
870            }
871            if let Some(wallet) = &mut self.payment_wallet {
872                Ok(wallet.reveal_next_address(KeychainKind::External).address)
873            } else {
874                Err("Payment wallet not initialized".to_string())
875            }
876        } else {
877            self.next_taproot_address()
878        }
879    }
880
881    /// Peek a payment receive address at `index`.
882    ///
883    /// In unified mode this resolves to the taproot branch.
884    pub fn peek_payment_address(&self, index: u32) -> Option<Address> {
885        if self.scheme == AddressScheme::Dual {
886            let resolved_index = self.active_receive_index().saturating_add(index);
887            self.payment_wallet.as_ref().map(|w| {
888                w.peek_address(KeychainKind::External, resolved_index)
889                    .address
890            })
891        } else {
892            Some(self.peek_taproot_address(index))
893        }
894    }
895
896    /// Export a persistence snapshot containing merged loaded+staged changesets.
897    pub fn export_changeset(&self) -> Result<ZincPersistence, String> {
898        // 1. Vault: Start with loaded changeset, merge staged changes
899        let mut vault_changeset = self.loaded_vault_changeset.clone();
900        if let Some(staged) = self.vault_wallet.staged() {
901            vault_changeset.merge(staged.clone());
902        }
903
904        // Ensure network and genesis are set (safety net for fresh wallets/empty state)
905        let network = self.vault_wallet.network();
906        vault_changeset.network = Some(network);
907
908        // Ensure descriptors are set
909        vault_changeset.descriptor = Some(
910            self.vault_wallet
911                .public_descriptor(KeychainKind::External)
912                .clone(),
913        );
914        vault_changeset.change_descriptor = Some(
915            self.vault_wallet
916                .public_descriptor(KeychainKind::Internal)
917                .clone(),
918        );
919
920        let genesis_hash = bitcoin::blockdata::constants::genesis_block(network)
921            .header
922            .block_hash();
923        // Check if genesis is missing directly
924        vault_changeset
925            .local_chain
926            .blocks
927            .entry(0)
928            .or_insert(Some(genesis_hash));
929
930        // 2. Payment: Start with loaded changeset, merge staged changes
931        let mut payment_changeset = self.loaded_payment_changeset.clone();
932
933        if let Some(w) = &self.payment_wallet {
934            // Ensure we have a base changeset to work with if we initialized one
935            let mut pcs = payment_changeset.take().unwrap_or_default();
936
937            if let Some(staged) = w.staged() {
938                pcs.merge(staged.clone());
939            }
940
941            let net = w.network();
942            pcs.network = Some(net);
943
944            // Ensure descriptors are set for payment wallet
945            pcs.descriptor = Some(w.public_descriptor(KeychainKind::External).clone());
946            pcs.change_descriptor = Some(w.public_descriptor(KeychainKind::Internal).clone());
947
948            let gen_hash = bitcoin::blockdata::constants::genesis_block(net)
949                .header
950                .block_hash();
951            pcs.local_chain.blocks.entry(0).or_insert(Some(gen_hash));
952            // Assign back to option
953            payment_changeset = Some(pcs);
954        } else {
955            // If no payment wallet, ensure we don't persist stale data
956            payment_changeset = None;
957        }
958
959        Ok(ZincPersistence {
960            taproot: Some(vault_changeset),
961            payment: payment_changeset,
962        })
963    }
964
965    /// Check whether the configured Esplora endpoint is reachable.
966    pub async fn check_connection(esplora_url: &str) -> bool {
967        let client = esplora_client::Builder::new(esplora_url.trim_end_matches('/'))
968            .build_async_with_sleeper::<SyncSleeper>();
969
970        match client {
971            Ok(c) => c.get_height().await.is_ok(),
972            Err(_) => false,
973        }
974    }
975
976    /// Build sync requests for taproot/payment wallets.
977    pub fn prepare_requests(&self) -> ZincSyncRequest {
978        let now = wasm_now_secs();
979        let vault = SyncRequestType::Full(Self::flexible_full_scan_request(
980            &self.vault_wallet,
981            self.scan_policy,
982            now,
983        ));
984
985        let payment = self.payment_wallet.as_ref().map(|w| {
986            SyncRequestType::Full(Self::flexible_full_scan_request(w, self.scan_policy, now))
987        });
988
989        ZincSyncRequest {
990            taproot: vault,
991            payment,
992        }
993    }
994
995    /// Apply taproot/payment updates and return merged event strings.
996    pub fn apply_sync(
997        &mut self,
998        vault_update: impl Into<bdk_wallet::Update>,
999        payment_update: Option<impl Into<bdk_wallet::Update>>,
1000    ) -> Result<Vec<String>, String> {
1001        let mut all_events = Vec::new();
1002
1003        // 1. Apply Vault Update
1004        let vault_events = self
1005            .vault_wallet
1006            .apply_update_events(vault_update)
1007            .map_err(|e| e.to_string())?;
1008
1009        for event in vault_events {
1010            all_events.push(format!("taproot:{event:?}"));
1011        }
1012
1013        // 2. Apply Payment Update
1014        if let (Some(w), Some(u)) = (&mut self.payment_wallet, payment_update) {
1015            let payment_events = w.apply_update_events(u).map_err(|e| e.to_string())?;
1016            for event in payment_events {
1017                all_events.push(format!("payment:{event:?}"));
1018            }
1019        }
1020
1021        Ok(all_events)
1022    }
1023
1024    /// Rebuild wallet state from current public descriptors, clearing cached sync state.
1025    pub fn reset_sync_state(&mut self) -> Result<(), String> {
1026        zinc_log_info!(
1027            target: LOG_TARGET_BUILDER,
1028            "resetting wallet sync state (chain mismatch recovery)"
1029        );
1030
1031        // 1. Reset Vault Wallet
1032        let vault_desc = self
1033            .vault_wallet
1034            .public_descriptor(KeychainKind::External)
1035            .to_string();
1036        let network = self.vault_wallet.network();
1037        self.vault_wallet = if matches!(&self.kind, WalletKind::WatchAddress(_)) {
1038            Wallet::create_single(vault_desc)
1039                .network(network)
1040                .create_wallet_no_persist()
1041                .map_err(|e| format!("Failed to reset taproot wallet: {e}"))?
1042        } else {
1043            let vault_change_desc = self
1044                .vault_wallet
1045                .public_descriptor(KeychainKind::Internal)
1046                .to_string();
1047            Wallet::create(vault_desc, vault_change_desc)
1048                .network(network)
1049                .create_wallet_no_persist()
1050                .map_err(|e| format!("Failed to reset taproot wallet: {e}"))?
1051        };
1052        self.loaded_vault_changeset = bdk_wallet::ChangeSet::default();
1053
1054        // 2. Reset Payment Wallet (if exists)
1055        if let Some(w) = &self.payment_wallet {
1056            let pay_desc = w.public_descriptor(KeychainKind::External).to_string();
1057            let pay_change_desc = w.public_descriptor(KeychainKind::Internal).to_string();
1058
1059            self.payment_wallet = Some(
1060                Wallet::create(pay_desc, pay_change_desc)
1061                    .network(network)
1062                    .create_wallet_no_persist()
1063                    .map_err(|e| format!("Failed to reset payment wallet: {e}"))?,
1064            );
1065            self.loaded_payment_changeset = Some(bdk_wallet::ChangeSet::default());
1066        }
1067
1068        // 3. Increment account generation to invalidate any in-flight syncs
1069        self.account_generation += 1;
1070        self.ordinals_verified = false;
1071        self.ordinals_metadata_complete = false;
1072
1073        Ok(())
1074    }
1075
1076    /// Run a full sync against Esplora for taproot and optional payment wallets.
1077    pub async fn sync(&mut self, esplora_url: &str) -> Result<Vec<String>, String> {
1078        let client = esplora_client::Builder::new(esplora_url.trim_end_matches('/'))
1079            .build_async_with_sleeper::<SyncSleeper>()
1080            .map_err(|e| format!("{e:?}"))?;
1081
1082        let now = wasm_now_secs();
1083        let vault_req = Self::flexible_full_scan_request(&self.vault_wallet, self.scan_policy, now);
1084        let payment_req = self
1085            .payment_wallet
1086            .as_ref()
1087            .map(|w| Self::flexible_full_scan_request(w, self.scan_policy, now));
1088
1089        let stop_gap = self.scan_policy.account_gap_limit as usize;
1090        let parallel_requests = 5;
1091
1092        // 1. Sync Vault
1093        let vault_update = client
1094            .full_scan(vault_req, stop_gap, parallel_requests)
1095            .await
1096            .map_err(|e| e.to_string())?;
1097
1098        // 2. Sync Payment (if exists)
1099        let payment_update = if let Some(req) = payment_req {
1100            Some(
1101                client
1102                    .full_scan(req, stop_gap, parallel_requests)
1103                    .await
1104                    .map_err(|e| e.to_string())?,
1105            )
1106        } else {
1107            None
1108        };
1109
1110        self.apply_sync(vault_update, payment_update)
1111    }
1112
1113    /// Collect the wallet's main receive addresses for ordinals sync.
1114    ///
1115    /// Strict scan policy intentionally limits this to index `0` receive paths:
1116    /// taproot external/0 and (when dual) payment external/0.
1117    /// Collect the wallet's main receive addresses for ordinals sync.
1118    ///
1119    /// The scan policy determines how many addresses are checked:
1120    /// - `address_scan_depth`: Ensuring at least N addresses are scanned.
1121    /// - Discovered active addresses from incremental sync.
1122    pub fn collect_active_addresses(&self) -> Vec<String> {
1123        if let Some(address) = self.watched_address() {
1124            return vec![address.to_string()];
1125        }
1126
1127        let mut addresses = Vec::new();
1128        let mut seen = std::collections::HashSet::new();
1129
1130        let mut collect_from_wallet = |wallet: &Wallet| {
1131            // 1. Always include up to address_scan_depth
1132            for i in 0..self.scan_policy.address_scan_depth {
1133                let addr = wallet
1134                    .peek_address(KeychainKind::External, i)
1135                    .address
1136                    .to_string();
1137                if seen.insert(addr.clone()) {
1138                    addresses.push(addr);
1139                }
1140            }
1141
1142            // 2. Include any addresses that have been discovered via sync/reveal
1143            // (e.g. if the user manually revealed address 5, or sync found funds there)
1144            for info in wallet.list_unused_addresses(KeychainKind::External) {
1145                let addr = info.address.to_string();
1146                if seen.insert(addr.clone()) {
1147                    addresses.push(addr);
1148                }
1149            }
1150        };
1151
1152        collect_from_wallet(&self.vault_wallet);
1153        if let Some(w) = &self.payment_wallet {
1154            collect_from_wallet(w);
1155        }
1156
1157        addresses
1158    }
1159
1160    /// Update the wallet's internal inscription state.
1161    /// Call this AFTER fetching inscriptions successfully.
1162    pub fn apply_verified_ordinals_update(
1163        &mut self,
1164        inscriptions: Vec<crate::ordinals::types::Inscription>,
1165        protected_outpoints: std::collections::HashSet<bitcoin::OutPoint>,
1166        rune_balances: Vec<crate::ordinals::types::RuneBalance>,
1167    ) -> usize {
1168        zinc_log_info!(
1169            target: LOG_TARGET_BUILDER,
1170            "applying ordinals update: {} inscriptions received",
1171            inscriptions.len()
1172        );
1173        for inscription in &inscriptions {
1174            zinc_log_debug!(
1175                target: LOG_TARGET_BUILDER,
1176                "inscribed outpoint updated: {}",
1177                inscription.satpoint.outpoint
1178            );
1179        }
1180
1181        self.inscribed_utxos = protected_outpoints;
1182        self.inscriptions = inscriptions;
1183        self.rune_balances = rune_balances;
1184        self.ordinals_verified = true;
1185        self.ordinals_metadata_complete = true;
1186
1187        zinc_log_info!(
1188            target: LOG_TARGET_BUILDER,
1189            "total inscribed_utxos set size: {}",
1190            self.inscribed_utxos.len()
1191        );
1192        self.inscriptions.len()
1193    }
1194
1195    /// Apply cached inscription metadata from an untrusted caller boundary.
1196    ///
1197    /// This method updates metadata for UI rendering but intentionally does not
1198    /// mark the wallet's ordinals protection state as verified.
1199    pub fn apply_unverified_inscriptions_cache(
1200        &mut self,
1201        inscriptions: Vec<crate::ordinals::types::Inscription>,
1202    ) -> usize {
1203        zinc_log_info!(
1204            target: LOG_TARGET_BUILDER,
1205            "applying unverified inscription cache: {} inscriptions received",
1206            inscriptions.len()
1207        );
1208
1209        self.inscribed_utxos.clear();
1210        self.inscriptions = inscriptions;
1211        self.rune_balances.clear();
1212        self.ordinals_verified = false;
1213        self.ordinals_metadata_complete = true;
1214
1215        self.inscriptions.len()
1216    }
1217
1218    fn verify_ord_indexer_is_current(
1219        &mut self,
1220        ord_height: u32,
1221        wallet_height: u32,
1222    ) -> Result<(), String> {
1223        if ord_height < wallet_height.saturating_sub(1) {
1224            self.ordinals_verified = false;
1225            return Err(format!(
1226                "Ord Indexer is lagging! Ord: {ord_height}, Wallet: {wallet_height}. Safety lock engaged."
1227            ));
1228        }
1229        Ok(())
1230    }
1231
1232    /// Refresh only ordinals protection outpoints (no inscription metadata details).
1233    pub async fn sync_ordinals_protection(&mut self, ord_url: &str) -> Result<usize, String> {
1234        self.ordinals_verified = false;
1235        let addresses = self.collect_active_addresses();
1236        let client = crate::ordinals::OrdClient::new(ord_url.to_string());
1237
1238        // 0. Fetch Ord Indexer Tip to check for lag
1239        let ord_height = client
1240            .get_indexing_height()
1241            .await
1242            .map_err(|e| e.to_string())?;
1243
1244        // Get Wallet Tip (from Vault, which is always present)
1245        let wallet_height = self.vault_wallet.local_chain().tip().height();
1246
1247        self.verify_ord_indexer_is_current(ord_height, wallet_height)?;
1248
1249        let mut protected_outpoints = std::collections::HashSet::new();
1250        for addr_str in addresses {
1251            let snapshot = client
1252                .get_address_asset_snapshot(&addr_str)
1253                .await
1254                .map_err(|e| format!("Failed to fetch for {addr_str}: {e}"))?;
1255
1256            let protected = client
1257                .get_protected_outpoints_from_outputs(&snapshot.outputs)
1258                .await
1259                .map_err(|e| format!("Failed to fetch protected outputs for {addr_str}: {e}"))?;
1260            protected_outpoints.extend(protected);
1261        }
1262
1263        self.inscribed_utxos = protected_outpoints;
1264        self.ordinals_verified = true;
1265        Ok(self.inscribed_utxos.len())
1266    }
1267
1268    /// Refresh inscription metadata used by UI and PSBT analysis.
1269    pub async fn sync_ordinals_metadata(&mut self, ord_url: &str) -> Result<usize, String> {
1270        self.ordinals_metadata_complete = false;
1271        let addresses = self.collect_active_addresses();
1272        let client = crate::ordinals::OrdClient::new(ord_url.to_string());
1273
1274        let ord_height = client
1275            .get_indexing_height()
1276            .await
1277            .map_err(|e| e.to_string())?;
1278        let wallet_height = self.vault_wallet.local_chain().tip().height();
1279        self.verify_ord_indexer_is_current(ord_height, wallet_height)?;
1280        let rune_balances = client
1281            .get_rune_balances_for_addresses(&addresses)
1282            .await
1283            .map_err(|e| format!("Failed to fetch rune balances: {e}"))?;
1284
1285        let mut all_inscriptions = Vec::new();
1286        for addr_str in addresses {
1287            let snapshot = client
1288                .get_address_asset_snapshot(&addr_str)
1289                .await
1290                .map_err(|e| format!("Failed to fetch for {addr_str}: {e}"))?;
1291
1292            for inscription_id in snapshot.inscription_ids {
1293                let inscription = client
1294                    .get_inscription_details(&inscription_id)
1295                    .await
1296                    .map_err(|e| format!("Failed to fetch details for {inscription_id}: {e}"))?;
1297                all_inscriptions.push(inscription);
1298            }
1299        }
1300
1301        self.inscriptions = all_inscriptions;
1302        self.rune_balances = rune_balances;
1303        self.ordinals_metadata_complete = true;
1304        Ok(self.inscriptions.len())
1305    }
1306
1307    /// Sync Ordinals (Inscriptions) to build the Shield logic.
1308    /// This keeps the legacy behavior by running protection and metadata refresh.
1309    pub async fn sync_ordinals(&mut self, ord_url: &str) -> Result<usize, String> {
1310        self.sync_ordinals_protection(ord_url).await?;
1311        self.sync_ordinals_metadata(ord_url).await
1312    }
1313
1314    /// Return account summaries for the active wallet.
1315    pub fn get_accounts(&self, count: u32) -> Vec<Account> {
1316        match &self.kind {
1317            WalletKind::WatchAddress(address) => {
1318                let taproot_address = address.to_string();
1319                let taproot_public_key = self.get_taproot_public_key(0).unwrap_or_default();
1320                vec![Account {
1321                    index: self.account_index,
1322                    label: format!("Account {}", self.account_index + 1),
1323                    taproot_address: taproot_address.clone(),
1324                    taproot_public_key: taproot_public_key.clone(),
1325                    payment_address: Some(taproot_address),
1326                    payment_public_key: Some(taproot_public_key),
1327                }]
1328            }
1329            WalletKind::Hardware { .. } => {
1330                let taproot_address = self.peek_taproot_address(0).to_string();
1331                let taproot_public_key = self.get_taproot_public_key(0).unwrap_or_default();
1332                let (payment_address, payment_public_key) = if self.scheme == AddressScheme::Dual {
1333                    (
1334                        self.peek_payment_address(0).map(|a| a.to_string()),
1335                        self.get_payment_public_key(0).ok(),
1336                    )
1337                } else {
1338                    (
1339                        Some(taproot_address.clone()),
1340                        Some(taproot_public_key.clone()),
1341                    )
1342                };
1343                vec![Account {
1344                    index: self.account_index,
1345                    label: format!("Account {}", self.account_index + 1),
1346                    taproot_address,
1347                    taproot_public_key,
1348                    payment_address,
1349                    payment_public_key,
1350                }]
1351            }
1352            WalletKind::Seed { master_xprv } => {
1353                let mut accounts = Vec::new();
1354                for i in 0..count {
1355                    // Temporarily build a builder to derive addresses for other accounts
1356                    let builder = WalletBuilder::new(self.vault_wallet.network())
1357                        .kind(WalletKind::Seed {
1358                            master_xprv: *master_xprv,
1359                        })
1360                        .with_scheme(self.scheme)
1361                        .with_derivation_mode(self.derivation_mode)
1362                        .with_payment_address_type(self.payment_address_type)
1363                        .with_account_index(i);
1364
1365                    if let Ok(zwallet) = builder.build() {
1366                        let taproot_address = zwallet.peek_taproot_address(0).to_string();
1367                        let taproot_public_key =
1368                            zwallet.get_taproot_public_key(0).unwrap_or_default();
1369                        let (payment_address, payment_public_key) =
1370                            if self.scheme == AddressScheme::Dual {
1371                                (
1372                                    zwallet.peek_payment_address(0).map(|a| a.to_string()),
1373                                    zwallet.get_payment_public_key(0).ok(),
1374                                )
1375                            } else {
1376                                (
1377                                    Some(taproot_address.clone()),
1378                                    Some(taproot_public_key.clone()),
1379                                )
1380                            };
1381                        accounts.push(Account {
1382                            index: i,
1383                            label: format!("Account {}", i + 1),
1384                            taproot_address,
1385                            taproot_public_key,
1386                            payment_address,
1387                            payment_public_key,
1388                        });
1389                    }
1390                }
1391                accounts
1392            }
1393        }
1394    }
1395
1396    /// Return raw combined BDK balance across taproot and payment wallets.
1397    pub fn get_raw_balance(&self) -> bdk_wallet::Balance {
1398        let vault_bal = self.vault_wallet.balance();
1399        if let Some(payment_wallet) = &self.payment_wallet {
1400            let pay_bal = payment_wallet.balance();
1401            bdk_wallet::Balance {
1402                immature: vault_bal.immature + pay_bal.immature,
1403                trusted_pending: vault_bal.trusted_pending + pay_bal.trusted_pending,
1404                untrusted_pending: vault_bal.untrusted_pending + pay_bal.untrusted_pending,
1405                confirmed: vault_bal.confirmed + pay_bal.confirmed,
1406            }
1407        } else {
1408            vault_bal
1409        }
1410    }
1411
1412    /// Return an ordinals-aware balance view for display and spend checks.
1413    pub fn get_balance(&self) -> ZincBalance {
1414        let raw = self.get_raw_balance();
1415
1416        // Robust Approach:
1417        let calc_balance = |wallet: &Wallet| {
1418            let mut bal = bdk_wallet::Balance::default();
1419            for utxo in wallet.list_unspent() {
1420                if self.inscribed_utxos.contains(&utxo.outpoint) {
1421                    zinc_log_debug!(
1422                        target: LOG_TARGET_BUILDER,
1423                        "skipping inscribed UTXO while calculating balance: {:?}",
1424                        utxo.outpoint
1425                    );
1426                    continue;
1427                }
1428                match utxo.keychain {
1429                    KeychainKind::Internal | KeychainKind::External => {
1430                        // This UTXO is safe. Add to balance.
1431                        match utxo.chain_position {
1432                            bdk_chain::ChainPosition::Confirmed { .. } => {
1433                                bal.confirmed += utxo.txout.value;
1434                            }
1435                            bdk_chain::ChainPosition::Unconfirmed { .. } => {
1436                                bal.trusted_pending += utxo.txout.value;
1437                            }
1438                        }
1439                    }
1440                }
1441            }
1442            bal
1443        };
1444
1445        let mut safe_bal = calc_balance(&self.vault_wallet);
1446        if let Some(w) = &self.payment_wallet {
1447            let p_bal = calc_balance(w);
1448            safe_bal.confirmed += p_bal.confirmed;
1449            safe_bal.trusted_pending += p_bal.trusted_pending;
1450            safe_bal.untrusted_pending += p_bal.untrusted_pending;
1451            safe_bal.immature += p_bal.immature;
1452        }
1453
1454        let display_spendable = if let Some(payment_wallet) = &self.payment_wallet {
1455            calc_balance(payment_wallet)
1456        } else {
1457            safe_bal.clone()
1458        };
1459
1460        ZincBalance {
1461            total: raw.clone(),
1462            spendable: safe_bal.clone(),
1463            display_spendable,
1464            inscribed: raw
1465                .confirmed
1466                .to_sat()
1467                .saturating_sub(safe_bal.confirmed.to_sat())
1468                + raw
1469                    .trusted_pending
1470                    .to_sat()
1471                    .saturating_sub(safe_bal.trusted_pending.to_sat()), // Estimate
1472        }
1473    }
1474
1475    /// Create an unsigned PSBT for sending BTC.
1476    pub fn create_psbt_tx(&mut self, request: &CreatePsbtRequest) -> Result<Psbt, ZincError> {
1477        if !self.ordinals_verified {
1478            return Err(ZincError::WalletError(
1479                "Ordinals verification failed - safety lock engaged. Please retry sync."
1480                    .to_string(),
1481            ));
1482        }
1483
1484        let active_receive_index = self.active_receive_index();
1485        let wallet = if self.scheme == AddressScheme::Dual {
1486            self.payment_wallet.as_mut().ok_or_else(|| {
1487                ZincError::WalletError("Payment wallet not initialized".to_string())
1488            })?
1489        } else {
1490            &mut self.vault_wallet
1491        };
1492
1493        let recipient = request
1494            .recipient
1495            .clone()
1496            .require_network(wallet.network())
1497            .map_err(|e| ZincError::ConfigError(format!("Network mismatch: {e}")))?;
1498
1499        let change_script = wallet
1500            .peek_address(KeychainKind::External, active_receive_index)
1501            .script_pubkey();
1502
1503        let mut builder = wallet.build_tx();
1504        if !self.inscribed_utxos.is_empty() {
1505            builder.unspendable(self.inscribed_utxos.iter().copied().collect());
1506        }
1507
1508        builder
1509            .add_recipient(recipient.script_pubkey(), request.amount)
1510            .fee_rate(request.fee_rate)
1511            .drain_to(change_script);
1512
1513        builder
1514            .finish()
1515            .map_err(|e| ZincError::WalletError(format!("Failed to build tx: {e}")))
1516    }
1517
1518    /// Create an unsigned PSBT for sending BTC and encode it as base64.
1519    pub fn create_psbt_base64(&mut self, request: &CreatePsbtRequest) -> Result<String, ZincError> {
1520        let psbt = self.create_psbt_tx(request)?;
1521        Ok(Self::encode_psbt_base64(&psbt))
1522    }
1523
1524    /// Create an ord-compatible buyer offer PSBT and envelope.
1525    pub fn create_offer(
1526        &mut self,
1527        request: &crate::offer_create::CreateOfferRequest,
1528    ) -> Result<crate::offer_create::OfferCreateResultV1, ZincError> {
1529        crate::offer_create::create_offer(self, request)
1530    }
1531
1532    /// Create a buyer-funded listing purchase PSBT and sign buyer inputs.
1533    pub fn create_listing_purchase(
1534        &mut self,
1535        request: &crate::listing::CreateListingPurchaseRequest,
1536    ) -> Result<crate::listing::CreateListingPurchaseResultV1, ZincError> {
1537        crate::listing::create_listing_purchase(self, request)
1538    }
1539
1540    /// Create an unsigned PSBT for sending BTC from transport-friendly inputs.
1541    ///
1542    /// This method is a migration wrapper for app-boundary callers. New native
1543    /// Rust integrations should construct `CreatePsbtRequest` and call
1544    /// `create_psbt_tx` or `create_psbt_base64`.
1545    #[doc(hidden)]
1546    #[deprecated(note = "Use create_psbt_base64 with CreatePsbtRequest")]
1547    pub fn create_psbt(
1548        &mut self,
1549        recipient: &str,
1550        amount_sats: u64,
1551        fee_rate_sat_vb: u64,
1552    ) -> Result<String, String> {
1553        let request = CreatePsbtRequest::from_parts(recipient, amount_sats, fee_rate_sat_vb)
1554            .map_err(|e| e.to_string())?;
1555        self.create_psbt_base64(&request).map_err(|e| e.to_string())
1556    }
1557
1558    fn encode_psbt_base64(psbt: &Psbt) -> String {
1559        use base64::Engine;
1560        base64::engine::general_purpose::STANDARD.encode(psbt.serialize())
1561    }
1562
1563    /// Sign a PSBT using the wallet's internal keys.
1564    /// Returns the signed PSBT as base64.
1565    #[allow(deprecated)]
1566    pub fn sign_psbt(
1567        &mut self,
1568        psbt_base64: &str,
1569        options: Option<SignOptions>,
1570    ) -> Result<String, String> {
1571        use base64::Engine;
1572
1573        // Decode PSBT from base64
1574        let psbt_bytes = base64::engine::general_purpose::STANDARD
1575            .decode(psbt_base64)
1576            .map_err(|e| format!("Invalid base64: {e}"))?;
1577
1578        let mut psbt = Psbt::deserialize(&psbt_bytes).map_err(|e| format!("Invalid PSBT: {e}"))?;
1579
1580        // ENRICHMENT STEP: Fill in missing witness_utxo from our own wallet if possible
1581        // This solves "Plain PSBT" issues where dApps don't include UTXO info
1582        use std::collections::HashMap;
1583        let mut known_utxos = HashMap::new();
1584
1585        let collect_utxos = |w: &Wallet, map: &mut HashMap<bitcoin::OutPoint, bitcoin::TxOut>| {
1586            for utxo in w.list_unspent() {
1587                map.insert(utxo.outpoint, utxo.txout);
1588            }
1589        };
1590
1591        collect_utxos(&self.vault_wallet, &mut known_utxos);
1592        if let Some(w) = &self.payment_wallet {
1593            collect_utxos(w, &mut known_utxos);
1594        }
1595
1596        for (i, input) in psbt.inputs.iter_mut().enumerate() {
1597            if input.witness_utxo.is_none() && input.non_witness_utxo.is_none() {
1598                let outpoint = psbt.unsigned_tx.input[i].previous_output;
1599                if let Some(txout) = known_utxos.get(&outpoint) {
1600                    input.witness_utxo = Some(txout.clone());
1601                }
1602            }
1603        }
1604
1605        // Prepare BDK SignOptions and Apply Overrides (SIGHASH, etc.)
1606        // We do this BEFORE audit to ensuring we check the actual state being signed.
1607        let should_finalize = options.as_ref().is_some_and(|o| o.finalize);
1608        let bdk_options = bdk_wallet::SignOptions {
1609            // CRITICAL: Enable trust_witness_utxo for batch inscriptions where reveal
1610            // transactions spend outputs from not-yet-broadcast commit transactions.
1611            // The wallet can't verify these UTXOs from chain state, but we trust the dApp.
1612            trust_witness_utxo: true,
1613            // Finalize if explicitly requested (internal wallet use).
1614            // Default is false for dApp/marketplace compatibility.
1615            try_finalize: should_finalize,
1616            ..Default::default()
1617        };
1618        let mut inputs_to_sign: Option<Vec<usize>> = None;
1619
1620        if let Some(opts) = &options {
1621            if let Some(sighash_u8) = opts.sighash {
1622                // Use PsbtSighashType to match psbt.inputs type
1623                let target_sighash =
1624                    bitcoin::psbt::PsbtSighashType::from_u32(u32::from(sighash_u8));
1625                for input in &mut psbt.inputs {
1626                    input.sighash_type = Some(target_sighash);
1627                }
1628            }
1629            inputs_to_sign = opts.sign_inputs.clone();
1630        }
1631
1632        if let Some(indices) = inputs_to_sign.as_ref() {
1633            let mut seen = std::collections::HashSet::new();
1634            for index in indices {
1635                if *index >= psbt.inputs.len() {
1636                    return Err(format!(
1637                        "Security Violation: sign_inputs index {} is out of bounds for {} inputs",
1638                        index,
1639                        psbt.inputs.len()
1640                    ));
1641                }
1642                if !seen.insert(*index) {
1643                    return Err(format!(
1644                        "Security Violation: sign_inputs index {index} is duplicated"
1645                    ));
1646                }
1647                let input = &psbt.inputs[*index];
1648                if input.witness_utxo.is_none() && input.non_witness_utxo.is_none() {
1649                    return Err(format!(
1650                        "Security Violation: Requested input #{index} is missing UTXO metadata"
1651                    ));
1652                }
1653            }
1654        }
1655
1656        for (index, input) in psbt.inputs.iter().enumerate() {
1657            if let Some(sighash) = input.sighash_type {
1658                let value = sighash.to_u32();
1659                let base_type = value & 0x1f;
1660                let anyone_can_pay = (value & 0x80) != 0;
1661                let is_allowed_base = base_type == 0 || base_type == 1; // DEFAULT or ALL
1662
1663                if anyone_can_pay || !is_allowed_base {
1664                    return Err(format!(
1665                        "Security Violation: Sighash type is not allowed on input #{index} (value={value})"
1666                    ));
1667                }
1668            }
1669        }
1670
1671        // Ordinal Shield Audit: BEFORE signing!
1672        // We must build the known_inscriptions map to check for BURNS (sophisticated check)
1673        let mut known_inscriptions: HashMap<(bitcoin::Txid, u32), Vec<(String, u64)>> =
1674            HashMap::new();
1675        for ins in &self.inscriptions {
1676            known_inscriptions
1677                .entry((ins.satpoint.outpoint.txid, ins.satpoint.outpoint.vout))
1678                .or_default()
1679                .push((ins.id.clone(), ins.satpoint.offset));
1680        }
1681        // Normalize offsets
1682        for items in known_inscriptions.values_mut() {
1683            items.sort_by_key(|(_, offset)| *offset);
1684        }
1685
1686        if let Err(e) = crate::ordinals::shield::audit_psbt(
1687            &psbt,
1688            &known_inscriptions,
1689            inputs_to_sign.as_deref(),
1690            self.vault_wallet.network(),
1691        ) {
1692            return Err(format!("Security Violation: {e}"));
1693        }
1694
1695        // Keep a copy if we need to revert signatures for specific inputs
1696        let original_psbt = if inputs_to_sign.is_some() {
1697            Some(psbt.clone())
1698        } else {
1699            None
1700        };
1701
1702        // Try signing with both, just in case inputs are mixed
1703        // This is safe because BDK only signs inputs it controls
1704        self.vault_wallet
1705            .sign(&mut psbt, bdk_options.clone())
1706            .map_err(|e| format!("Vault signing failed: {e}"))?;
1707
1708        if let Some(payment_wallet) = &self.payment_wallet {
1709            payment_wallet
1710                .sign(&mut psbt, bdk_options)
1711                .map_err(|e| format!("Payment signing failed: {e}"))?;
1712        }
1713
1714        // CUSTOM SCRIPT-PATH SIGNING for Inscription Reveal Inputs
1715        // BDK's standard signer only signs inputs where the key's fingerprint matches the wallet.
1716        // For inscription reveals, the backend sets tap_key_origins with an empty fingerprint,
1717        // so BDK skips them. We manually sign these inputs if the key matches our ordinals key.
1718        self.sign_inscription_script_paths(&mut psbt, should_finalize, inputs_to_sign.as_deref())?;
1719
1720        // If specific inputs were requested, revert the others
1721        if let Some(indices) = inputs_to_sign.as_ref() {
1722            // Safe unwrap because we created it above if inputs_to_sign is Some
1723            let original = original_psbt
1724                .as_ref()
1725                .ok_or_else(|| "Security Violation: missing original PSBT snapshot".to_string())?;
1726            for (i, input) in psbt.inputs.iter_mut().enumerate() {
1727                if !indices.contains(&i) {
1728                    *input = original.inputs[i].clone();
1729                }
1730            }
1731        }
1732
1733        if let Some(indices) = inputs_to_sign.as_ref() {
1734            let original = original_psbt
1735                .as_ref()
1736                .ok_or_else(|| "Security Violation: missing original PSBT snapshot".to_string())?;
1737            for index in indices {
1738                let before = &original.inputs[*index];
1739                let after = &psbt.inputs[*index];
1740
1741                let signature_changed = before.tap_key_sig != after.tap_key_sig
1742                    || before.tap_script_sigs != after.tap_script_sigs
1743                    || before.partial_sigs != after.partial_sigs
1744                    || before.final_script_witness != after.final_script_witness;
1745
1746                if !signature_changed {
1747                    return Err(format!(
1748                        "Security Violation: Requested input #{index} was not signed by this wallet"
1749                    ));
1750                }
1751            }
1752        }
1753
1754        // Validation: Verify all requested inputs were signed
1755
1756        let signed_bytes = psbt.serialize();
1757        let signed_base64 = base64::engine::general_purpose::STANDARD.encode(&signed_bytes);
1758
1759        Ok(signed_base64)
1760    }
1761
1762    /// Prepare a PSBT for external signing (e.g. on a hardware wallet).
1763    ///
1764    /// Performs the pre-sign checks and enrichment that `sign_psbt` does
1765    /// (UTXO enrichment, sighash validation, bounds checks, Ordinal Shield),
1766    /// then returns the enriched PSBT as base64 for device signing.
1767    pub fn prepare_external_sign_psbt(
1768        &self,
1769        psbt_base64: &str,
1770        options: Option<SignOptions>,
1771    ) -> Result<String, String> {
1772        use base64::Engine;
1773
1774        let psbt_bytes = base64::engine::general_purpose::STANDARD
1775            .decode(psbt_base64)
1776            .map_err(|e| format!("Invalid base64: {e}"))?;
1777
1778        let mut psbt = Psbt::deserialize(&psbt_bytes).map_err(|e| format!("Invalid PSBT: {e}"))?;
1779
1780        use std::collections::HashMap;
1781        let mut known_utxos = HashMap::new();
1782
1783        let collect_utxos = |w: &Wallet, map: &mut HashMap<bitcoin::OutPoint, bitcoin::TxOut>| {
1784            for utxo in w.list_unspent() {
1785                map.insert(utxo.outpoint, utxo.txout);
1786            }
1787        };
1788
1789        collect_utxos(&self.vault_wallet, &mut known_utxos);
1790        if let Some(w) = &self.payment_wallet {
1791            collect_utxos(w, &mut known_utxos);
1792        }
1793
1794        for (i, input) in psbt.inputs.iter_mut().enumerate() {
1795            if input.witness_utxo.is_none() && input.non_witness_utxo.is_none() {
1796                let outpoint = psbt.unsigned_tx.input[i].previous_output;
1797                if let Some(txout) = known_utxos.get(&outpoint) {
1798                    input.witness_utxo = Some(txout.clone());
1799                }
1800            }
1801        }
1802
1803        #[allow(deprecated)]
1804        let _ = self
1805            .vault_wallet
1806            .sign(&mut psbt, bdk_wallet::SignOptions::default());
1807        if let Some(w) = &self.payment_wallet {
1808            #[allow(deprecated)]
1809            let _ = w.sign(&mut psbt, bdk_wallet::SignOptions::default());
1810        }
1811
1812        if let Some(opts) = &options {
1813            if let Some(sighash_u8) = opts.sighash {
1814                let target_sighash = bitcoin::psbt::PsbtSighashType::from_u32(sighash_u8 as u32);
1815                for input in psbt.inputs.iter_mut() {
1816                    input.sighash_type = Some(target_sighash);
1817                }
1818            }
1819        }
1820
1821        let inputs_to_sign = options.as_ref().and_then(|o| o.sign_inputs.clone());
1822        if let Some(indices) = inputs_to_sign.as_ref() {
1823            let mut seen = std::collections::HashSet::new();
1824            for index in indices {
1825                if *index >= psbt.inputs.len() {
1826                    return Err(format!(
1827                        "Security Violation: sign_inputs index {} is out of bounds for {} inputs",
1828                        index,
1829                        psbt.inputs.len()
1830                    ));
1831                }
1832                if !seen.insert(*index) {
1833                    return Err(format!(
1834                        "Security Violation: sign_inputs index {} is duplicated",
1835                        index
1836                    ));
1837                }
1838                let input = &psbt.inputs[*index];
1839                if input.witness_utxo.is_none() && input.non_witness_utxo.is_none() {
1840                    return Err(format!(
1841                        "Security Violation: Requested input #{} is missing UTXO metadata",
1842                        index
1843                    ));
1844                }
1845            }
1846        }
1847
1848        for (index, input) in psbt.inputs.iter().enumerate() {
1849            if let Some(sighash) = input.sighash_type {
1850                let value = sighash.to_u32();
1851                let base_type = value & 0x1f;
1852                let anyone_can_pay = (value & 0x80) != 0;
1853                let is_allowed_base = base_type == 0 || base_type == 1; // DEFAULT or ALL
1854
1855                if anyone_can_pay || !is_allowed_base {
1856                    return Err(format!(
1857                        "Security Violation: Sighash type is not allowed on input #{} (value={})",
1858                        index, value
1859                    ));
1860                }
1861            }
1862        }
1863
1864        let mut known_inscriptions: HashMap<(bitcoin::Txid, u32), Vec<(String, u64)>> =
1865            HashMap::new();
1866        for ins in &self.inscriptions {
1867            known_inscriptions
1868                .entry((ins.satpoint.outpoint.txid, ins.satpoint.outpoint.vout))
1869                .or_default()
1870                .push((ins.id.clone(), ins.satpoint.offset));
1871        }
1872        for items in known_inscriptions.values_mut() {
1873            items.sort_by_key(|(_, offset)| *offset);
1874        }
1875
1876        if let Err(e) = crate::ordinals::shield::audit_psbt(
1877            &psbt,
1878            &known_inscriptions,
1879            inputs_to_sign.as_deref(),
1880            self.vault_wallet.network(),
1881        ) {
1882            return Err(format!("Security Violation: {}", e));
1883        }
1884
1885        let prepared_bytes = psbt.serialize();
1886        Ok(base64::engine::general_purpose::STANDARD.encode(&prepared_bytes))
1887    }
1888
1889    /// Verify a PSBT that was signed externally (e.g. by a hardware wallet).
1890    ///
1891    /// Ensures unsigned transaction bytes are unchanged and only expected inputs
1892    /// gained signatures, then optionally finalizes and returns base64.
1893    pub fn verify_external_signed_psbt(
1894        &self,
1895        original_psbt_base64: &str,
1896        signed_psbt_base64: &str,
1897        required_input_indices: Option<&[usize]>,
1898        finalize: bool,
1899    ) -> Result<String, String> {
1900        use base64::Engine;
1901        use bitcoin::consensus::Encodable;
1902
1903        let decode = |b64: &str, label: &str| -> Result<Psbt, String> {
1904            let bytes = base64::engine::general_purpose::STANDARD
1905                .decode(b64)
1906                .map_err(|e| format!("Invalid base64 in {label}: {e}"))?;
1907            Psbt::deserialize(&bytes).map_err(|e| format!("Invalid PSBT in {label}: {e}"))
1908        };
1909
1910        let original = decode(original_psbt_base64, "original")?;
1911        let mut signed = decode(signed_psbt_base64, "signed")?;
1912
1913        let mut orig_tx_bytes = Vec::new();
1914        original
1915            .unsigned_tx
1916            .consensus_encode(&mut orig_tx_bytes)
1917            .map_err(|e| format!("Failed to encode original tx: {e}"))?;
1918
1919        let mut signed_tx_bytes = Vec::new();
1920        signed
1921            .unsigned_tx
1922            .consensus_encode(&mut signed_tx_bytes)
1923            .map_err(|e| format!("Failed to encode signed tx: {e}"))?;
1924
1925        if orig_tx_bytes != signed_tx_bytes {
1926            return Err(
1927                "Security Violation: Device returned a PSBT with a modified transaction. \
1928                 The unsigned_tx bytes do not match the original."
1929                    .to_string(),
1930            );
1931        }
1932
1933        let check_indices: Vec<usize> = required_input_indices
1934            .map(|v| v.to_vec())
1935            .unwrap_or_else(|| (0..signed.inputs.len()).collect());
1936
1937        for &idx in &check_indices {
1938            if idx >= signed.inputs.len() {
1939                return Err(format!(
1940                    "Security Violation: required input index {} is out of bounds",
1941                    idx
1942                ));
1943            }
1944
1945            let input = &signed.inputs[idx];
1946            let has_signature = input.tap_key_sig.is_some()
1947                || !input.tap_script_sigs.is_empty()
1948                || !input.partial_sigs.is_empty()
1949                || input.final_script_witness.is_some();
1950
1951            if !has_signature {
1952                return Err(format!(
1953                    "Security Violation: Required input #{} was not signed by the device",
1954                    idx
1955                ));
1956            }
1957        }
1958
1959        if required_input_indices.is_some() {
1960            let required_set: std::collections::HashSet<usize> =
1961                check_indices.iter().copied().collect();
1962
1963            for (i, (orig_input, signed_input)) in
1964                original.inputs.iter().zip(signed.inputs.iter()).enumerate()
1965            {
1966                if required_set.contains(&i) {
1967                    continue;
1968                }
1969
1970                let signatures_changed = orig_input.tap_key_sig != signed_input.tap_key_sig
1971                    || orig_input.tap_script_sigs != signed_input.tap_script_sigs
1972                    || orig_input.partial_sigs != signed_input.partial_sigs
1973                    || orig_input.final_script_witness != signed_input.final_script_witness;
1974
1975                if signatures_changed {
1976                    return Err(format!(
1977                        "Security Violation: Input #{} received an unauthorized signature \
1978                         (not in required_input_indices)",
1979                        i
1980                    ));
1981                }
1982            }
1983        }
1984
1985        if !finalize {
1986            // External multi-pass hardware signing can reuse the signed PSBT as input
1987            // for a subsequent pass (e.g. payment first, then taproot). Some device
1988            // SDKs infer "internal" inputs from derivation metadata and can be
1989            // confused by derivations added in prior passes. Clearing derivation
1990            // metadata here keeps the collected signatures while preventing
1991            // cross-pass account-type contamination.
1992            for input in signed.inputs.iter_mut() {
1993                input.bip32_derivation.clear();
1994                input.tap_key_origins.clear();
1995            }
1996        }
1997
1998        if finalize {
1999            for input in signed.inputs.iter_mut() {
2000                if let Some(sig) = input.tap_key_sig {
2001                    let mut witness = bitcoin::Witness::new();
2002                    witness.push(sig.to_vec());
2003                    input.final_script_witness = Some(witness);
2004                    input.tap_key_sig = None;
2005                    input.tap_internal_key = None;
2006                    input.tap_merkle_root = None;
2007                    input.tap_key_origins.clear();
2008                    input.witness_utxo = None;
2009                    input.sighash_type = None;
2010                } else if !input.partial_sigs.is_empty() {
2011                    if let Some((pubkey, sig)) = input.partial_sigs.iter().next() {
2012                        let mut witness = bitcoin::Witness::new();
2013                        witness.push(sig.to_vec());
2014                        witness.push(pubkey.to_bytes());
2015                        input.final_script_witness = Some(witness);
2016                        input.partial_sigs.clear();
2017                        input.bip32_derivation.clear();
2018                        input.witness_utxo = None;
2019                        input.sighash_type = None;
2020                    }
2021                }
2022            }
2023        }
2024
2025        let verified_bytes = signed.serialize();
2026        Ok(base64::engine::general_purpose::STANDARD.encode(&verified_bytes))
2027    }
2028
2029    /// Analyzes a PSBT for Ordinal Shield protection.
2030    /// Returns a JSON string containing the `AnalysisResult`.
2031    pub fn analyze_psbt(&self, psbt_base64: &str) -> Result<String, String> {
2032        // Use explicit path to avoid re-export issues if any
2033        use crate::ordinals::shield::analyze_psbt;
2034        use base64::Engine;
2035        use std::collections::HashMap;
2036
2037        // Decode PSBT
2038        let psbt_bytes = base64::engine::general_purpose::STANDARD
2039            .decode(psbt_base64)
2040            .map_err(|e| format!("Invalid base64: {e}"))?;
2041
2042        let mut psbt = match Psbt::deserialize(&psbt_bytes) {
2043            Ok(p) => p,
2044            Err(e) => {
2045                return Err(format!("Invalid PSBT: {e}"));
2046            }
2047        };
2048
2049        // ENRICHMENT STEP: Fill in missing witness_utxo from our own wallet if possible
2050        // This solves "Plain PSBT" issues where dApps don't include UTXO info
2051        let mut known_utxos = HashMap::new();
2052
2053        let collect_utxos = |w: &Wallet, map: &mut HashMap<bitcoin::OutPoint, bitcoin::TxOut>| {
2054            for utxo in w.list_unspent() {
2055                map.insert(utxo.outpoint, utxo.txout);
2056            }
2057        };
2058
2059        collect_utxos(&self.vault_wallet, &mut known_utxos);
2060        if let Some(w) = &self.payment_wallet {
2061            collect_utxos(w, &mut known_utxos);
2062        }
2063
2064        for (i, input) in psbt.inputs.iter_mut().enumerate() {
2065            if input.witness_utxo.is_none() && input.non_witness_utxo.is_none() {
2066                let outpoint = psbt.unsigned_tx.input[i].previous_output;
2067                if let Some(txout) = known_utxos.get(&outpoint) {
2068                    input.witness_utxo = Some(txout.clone());
2069                }
2070            }
2071        }
2072
2073        // Build Known Inscriptions Map from internal state
2074        // Map: (Txid, Vout) -> Vec<(InscriptionID, Offset)>
2075        let mut known_inscriptions: HashMap<(bitcoin::Txid, u32), Vec<(String, u64)>> =
2076            HashMap::new();
2077
2078        // We also need a way to map offsets back to Inscription IDs for the result?
2079        // The `analyze_psbt` function currently generates keys like "Inscription {N}".
2080        // Wait, I should probably pass the IDs or handle the mapping better.
2081        // My implementation in `shield.rs` generates keys.
2082        // Ideally, `analyze_psbt` should take `HashMap<(Txid, u32), Vec<(u64, String)>>` so it knows the IDs!
2083        // But for now, let's look at `shield.rs`. It iterates and pushes to `active_inscriptions`.
2084        // The `known_inscriptions` map is just `Vec<u66>`.
2085        // This is a limitation of my current `shield.rs` implementation.
2086        // I should update `shield.rs` to take IDs if I want the frontend to know *which* inscription is being burned.
2087        // BUT, `shield.rs` is already tested and working with the simplified map.
2088        // PROPOSAL: Since `shield.rs` generates opaque keys, I should stick to that for V1 reliability.
2089        // Actually, if I pass a map of `(Txid, Vout) -> Vec<u64>`, I lose the ID association.
2090        // BUT `self.inscriptions` has the ID.
2091
2092        // Optimization: Let's rely on the assumption that mapping order is consistent.
2093        // However, `shield.rs` uses `known_inscriptions.get(...)` which returns a Vec of offsets.
2094        // If I want the frontend to show specific inscription images, I need the IDs.
2095
2096        // Let's stick to the current implementation for now. The frontend can re-derive or we just warn "An inscription".
2097        // Actually, for TDD I implemented it simply.
2098        // Real user needs to know WHICH inscription.
2099
2100        for ins in &self.inscriptions {
2101            known_inscriptions
2102                .entry((ins.satpoint.outpoint.txid, ins.satpoint.outpoint.vout))
2103                .or_default()
2104                .push((ins.id.clone(), ins.satpoint.offset));
2105        }
2106
2107        // Sort offsets for deterministic behavior
2108        for items in known_inscriptions.values_mut() {
2109            items.sort_by_key(|(_, offset)| *offset);
2110        }
2111
2112        let result = match analyze_psbt(&psbt, &known_inscriptions, self.vault_wallet.network()) {
2113            Ok(r) => r,
2114            Err(e) => {
2115                return Err(e.to_string());
2116            }
2117        };
2118
2119        serde_json::to_string(&result).map_err(|e| e.to_string())
2120    }
2121
2122    /// Broadcast a signed PSBT to the network.
2123    /// Returns the transaction ID (txid) as a hex string.
2124    pub async fn broadcast(
2125        &mut self,
2126        signed_psbt_base64: &str,
2127        esplora_url: &str,
2128    ) -> Result<String, String> {
2129        use base64::Engine;
2130
2131        // Decode PSBT from base64
2132        let psbt_bytes = base64::engine::general_purpose::STANDARD
2133            .decode(signed_psbt_base64)
2134            .map_err(|e| format!("Invalid base64: {e}"))?;
2135
2136        let psbt = Psbt::deserialize(&psbt_bytes).map_err(|e| format!("Invalid PSBT: {e}"))?;
2137
2138        // Extract the finalized transaction
2139        let tx: Transaction = psbt
2140            .extract_tx()
2141            .map_err(|e| format!("Failed to extract tx: {e}"))?;
2142
2143        // Broadcast via Esplora
2144        let client = esplora_client::Builder::new(esplora_url.trim_end_matches('/'))
2145            .build_async_with_sleeper::<SyncSleeper>()
2146            .map_err(|e| format!("Failed to create client: {e:?}"))?;
2147
2148        let broadcast_res: Result<(), _> = client.broadcast(&tx).await;
2149
2150        broadcast_res.map_err(|e| format!("Broadcast failed: {e}"))?;
2151
2152        Ok(tx.compute_txid().to_string())
2153    }
2154
2155    /// Sign a message with the private key corresponding to the given address.
2156    /// Supports both Vault (Taproot) and Payment (`SegWit`) addresses.
2157    pub fn sign_message(&self, address: &str, message: &str) -> Result<String, String> {
2158        use base64::Engine;
2159        use bitcoin::hashes::Hash;
2160        use bitcoin::secp256k1::{Message, Secp256k1};
2161
2162        if let Some(watched) = self.watched_address() {
2163            if watched.to_string() == address {
2164                let _ = message;
2165                return Err(ZincError::CapabilityMissing.to_string());
2166            }
2167        }
2168
2169        // 1. Identify which wallet/keychain owns this address
2170        let active_receive_index = self.active_receive_index();
2171        let vault_addr = self
2172            .vault_wallet
2173            .peek_address(KeychainKind::External, active_receive_index)
2174            .address
2175            .to_string();
2176
2177        let (is_vault, is_payment) = if address == vault_addr {
2178            (true, false)
2179        } else if let Some(w) = &self.payment_wallet {
2180            let pay_addr = w
2181                .peek_address(KeychainKind::External, active_receive_index)
2182                .address
2183                .to_string();
2184            (false, address == pay_addr)
2185        } else {
2186            (false, false)
2187        };
2188
2189        if !is_vault && !is_payment {
2190            return Err("Address not found in wallet".to_string());
2191        }
2192
2193        // 2. Derive Key
2194        let secp = Secp256k1::new();
2195        // Derivation path components
2196        let (purpose, chain) = if is_vault {
2197            (86, 0)
2198        } else {
2199            (self.dual_payment_purpose(), 0)
2200        };
2201        let priv_key = self
2202            .derive_private_key(purpose, chain, 0)
2203            .map_err(|_| ZincError::CapabilityMissing.to_string())?;
2204
2205        // 3. Sign Message
2206        let signature_hash = bitcoin::sign_message::signed_msg_hash(message);
2207        let msg = Message::from_digest(signature_hash.to_byte_array());
2208
2209        let sig = secp.sign_ecdsa_recoverable(&msg, &priv_key);
2210        let (rec_id, sig_bytes_compact) = sig.serialize_compact();
2211
2212        let mut header = 27 + u8::try_from(rec_id.to_i32()).unwrap();
2213        header += 4; // Always compressed
2214
2215        let mut sig_bytes = Vec::with_capacity(65);
2216        sig_bytes.push(header);
2217        sig_bytes.extend_from_slice(&sig_bytes_compact);
2218
2219        Ok(base64::engine::general_purpose::STANDARD.encode(&sig_bytes))
2220    }
2221
2222    /// Sign a message as a BIP-322 simple signature and return the witness bytes as hex.
2223    pub fn sign_bip322_simple_hex(&self, address: &str, message: &str) -> Result<String, String> {
2224        use bitcoin::PrivateKey;
2225
2226        if let Some(watched) = self.watched_address() {
2227            if watched.to_string() == address {
2228                let _ = message;
2229                return Err(ZincError::CapabilityMissing.to_string());
2230            }
2231        }
2232
2233        let active_receive_index = self.active_receive_index();
2234        let vault_addr = self
2235            .vault_wallet
2236            .peek_address(KeychainKind::External, active_receive_index)
2237            .address
2238            .to_string();
2239
2240        let (is_vault, is_payment) = if address == vault_addr {
2241            (true, false)
2242        } else if let Some(w) = &self.payment_wallet {
2243            let pay_addr = w
2244                .peek_address(KeychainKind::External, active_receive_index)
2245                .address
2246                .to_string();
2247            (false, address == pay_addr)
2248        } else {
2249            (false, false)
2250        };
2251
2252        if !is_vault && !is_payment {
2253            return Err("Address not found in wallet".to_string());
2254        }
2255
2256        let (purpose, chain) = if is_vault {
2257            (86, 0)
2258        } else {
2259            (self.dual_payment_purpose(), 0)
2260        };
2261        let secret_key = self
2262            .derive_private_key(purpose, chain, 0)
2263            .map_err(|_| ZincError::CapabilityMissing.to_string())?;
2264        let network = self.vault_wallet.network();
2265        let private_key = PrivateKey::new(secret_key, network);
2266        let witness = bip322::sign_simple(
2267            &address
2268                .parse::<bitcoin::Address<bitcoin::address::NetworkUnchecked>>()
2269                .map_err(|e| format!("invalid address: {e}"))?
2270                .require_network(network)
2271                .map_err(|e| format!("address network mismatch: {e}"))?,
2272            message,
2273            private_key,
2274        )
2275        .map_err(|e| format!("failed to sign BIP-322 message: {e}"))?;
2276        let bytes = bitcoin::consensus::serialize(&witness);
2277        Ok(hex::encode(bytes))
2278    }
2279
2280    /// Derive the pairing signer secret key hex for this account.
2281    ///
2282    /// Uses the first taproot external key path: `m/86'/coin'/account'/0/0`.
2283    pub fn get_pairing_secret_key_hex(&self) -> Result<String, String> {
2284        let key = self.derive_private_key(86, 0, 0)?;
2285        Ok(bytes_to_lower_hex(&key.secret_bytes()))
2286    }
2287    /// Derive the taproot public key for this account at `index`.
2288    pub fn get_taproot_public_key(&self, index: u32) -> Result<String, String> {
2289        self.derive_public_key(86, index)
2290    }
2291
2292    /// Derive the payment public key for this account at `index`.
2293    ///
2294    /// In unified mode this uses the same key family as taproot.
2295    pub fn get_payment_public_key(&self, index: u32) -> Result<String, String> {
2296        // Dual uses the selected payment family, unified mirrors taproot.
2297        let purpose = if self.scheme == AddressScheme::Dual {
2298            self.dual_payment_purpose()
2299        } else {
2300            86
2301        };
2302        self.derive_public_key(purpose, index)
2303    }
2304
2305    fn derive_public_key(&self, purpose: u32, index: u32) -> Result<String, String> {
2306        let account = self.active_derivation_account();
2307        let effective_index = self.active_receive_index().saturating_add(index);
2308        self.derive_public_key_internal(
2309            purpose,
2310            self.vault_wallet.network(),
2311            account,
2312            effective_index,
2313        )
2314    }
2315
2316    fn derive_private_key(
2317        &self,
2318        purpose: u32,
2319        chain: u32,
2320        index: u32,
2321    ) -> Result<bitcoin::secp256k1::SecretKey, String> {
2322        let account = self.active_derivation_account();
2323        let effective_index = self.active_receive_index().saturating_add(index);
2324        self.derive_private_key_internal(purpose, account, chain, effective_index)
2325    }
2326
2327    fn derive_private_key_internal(
2328        &self,
2329        purpose: u32,
2330        account: u32,
2331        chain: u32,
2332        index: u32,
2333    ) -> Result<bitcoin::secp256k1::SecretKey, String> {
2334        use bitcoin::secp256k1::Secp256k1;
2335        let secp = Secp256k1::new();
2336
2337        match &self.kind {
2338            WalletKind::Seed { master_xprv } => {
2339                let network = self.vault_wallet.network();
2340                let coin_type = u32::from(network != Network::Bitcoin);
2341
2342                let derivation_path = [
2343                    bdk_wallet::bitcoin::bip32::ChildNumber::from_hardened_idx(purpose).unwrap(),
2344                    bdk_wallet::bitcoin::bip32::ChildNumber::from_hardened_idx(coin_type).unwrap(),
2345                    bdk_wallet::bitcoin::bip32::ChildNumber::from_hardened_idx(account).unwrap(),
2346                    bdk_wallet::bitcoin::bip32::ChildNumber::from_normal_idx(chain).unwrap(),
2347                    bdk_wallet::bitcoin::bip32::ChildNumber::from_normal_idx(index).unwrap(),
2348                ];
2349
2350                let child_xprv = master_xprv
2351                    .derive_priv(&secp, &derivation_path)
2352                    .map_err(|e| format!("Key derivation failed: {e}"))?;
2353
2354                Ok(child_xprv.private_key)
2355            }
2356            _ => Err("Private key derivation not supported for this wallet kind".to_string()),
2357        }
2358    }
2359
2360    fn sign_inscription_script_paths(
2361        &self,
2362        psbt: &mut Psbt,
2363        finalize: bool,
2364        indices: Option<&[usize]>,
2365    ) -> Result<(), String> {
2366        use bitcoin::secp256k1::{Message, Secp256k1};
2367        use bitcoin::sighash::{Prevouts, SighashCache};
2368
2369        let secp = Secp256k1::new();
2370        let network = self.vault_wallet.network();
2371
2372        // 1. Collect all prevouts for sighash calculation
2373        let mut prevouts = Vec::with_capacity(psbt.unsigned_tx.input.len());
2374        for (i, input) in psbt.inputs.iter().enumerate() {
2375            let utxo = input
2376                .witness_utxo
2377                .as_ref()
2378                .or_else(|| {
2379                    input.non_witness_utxo.as_ref().and_then(|tx| {
2380                        tx.output
2381                            .get(psbt.unsigned_tx.input[i].previous_output.vout as usize)
2382                    })
2383                })
2384                .ok_or_else(|| format!("Missing witness_utxo for input #{i}"))?;
2385            prevouts.push(utxo.clone());
2386        }
2387        let prevouts_all = Prevouts::All(&prevouts);
2388
2389        // 2. Iterate inputs and sign matches
2390        for i in 0..psbt.inputs.len() {
2391            if let Some(allowed) = indices {
2392                if !allowed.contains(&i) {
2393                    continue;
2394                }
2395            }
2396
2397            let input = &mut psbt.inputs[i];
2398            if input.tap_key_sig.is_some() || !input.tap_script_sigs.is_empty() {
2399                continue; // Already signed
2400            }
2401
2402            // Check if this is an inscription reveal (tap_key_origins with empty fingerprint)
2403            let mut key_found = false;
2404            for (pubkey, (_, origin)) in &input.tap_key_origins {
2405                // Heuristic: Reveal inputs use the internal key directly in the script path
2406                // and often have an empty fingerprint [0,0,0,0] in the PSBT origin.
2407                if *origin.0.as_bytes() == [0, 0, 0, 0] {
2408                    // Try to derive the ordinals key (m/86'/coin'/account'/0/0)
2409                    let account = self.active_derivation_account();
2410                    let effective_index = self.active_receive_index();
2411                    if let Ok(derived_pubkey_hex) =
2412                        self.derive_public_key_internal(86, network, account, effective_index)
2413                    {
2414                        if pubkey.to_string() == derived_pubkey_hex {
2415                            // MATCH! Sign it.
2416                            let priv_key = self.derive_private_key(86, 0, 0)?;
2417
2418                            let mut cache = SighashCache::new(&psbt.unsigned_tx);
2419                            let sighash_type = input
2420                                .sighash_type
2421                                .unwrap_or(bitcoin::psbt::PsbtSighashType::from_u32(0)); // DEFAULT
2422
2423                            // For reveal, we sign the script path.
2424                            for (_control_block, (script, _)) in &input.tap_scripts {
2425                                let leaf_hash = bitcoin::taproot::TapLeafHash::from_script(
2426                                    script,
2427                                    bitcoin::taproot::LeafVersion::TapScript,
2428                                );
2429
2430                                // Convert PsbtSighashType to TapSighashType
2431                                let tap_sighash_type = match sighash_type.to_u32() {
2432                                    0 => bitcoin::sighash::TapSighashType::Default,
2433                                    1 => bitcoin::sighash::TapSighashType::All,
2434                                    2 => bitcoin::sighash::TapSighashType::None,
2435                                    3 => bitcoin::sighash::TapSighashType::Single,
2436                                    0x81 => bitcoin::sighash::TapSighashType::AllPlusAnyoneCanPay,
2437                                    0x82 => bitcoin::sighash::TapSighashType::NonePlusAnyoneCanPay,
2438                                    0x83 => {
2439                                        bitcoin::sighash::TapSighashType::SinglePlusAnyoneCanPay
2440                                    }
2441                                    _ => bitcoin::sighash::TapSighashType::Default,
2442                                };
2443
2444                                let sighash = cache
2445                                    .taproot_script_spend_signature_hash(
2446                                        i,
2447                                        &prevouts_all,
2448                                        leaf_hash,
2449                                        tap_sighash_type,
2450                                    )
2451                                    .map_err(|e| format!("Sighash calculation failed: {e}"))?;
2452
2453                                let msg = Message::from_digest(sighash.to_byte_array());
2454                                let sig = secp.sign_schnorr(&msg, &priv_key.keypair(&secp));
2455
2456                                let mut final_sig = sig.as_ref().to_vec();
2457                                if tap_sighash_type != bitcoin::sighash::TapSighashType::Default {
2458                                    final_sig.push(tap_sighash_type as u8);
2459                                }
2460
2461                                input.tap_script_sigs.insert(
2462                                    (*pubkey, leaf_hash),
2463                                    bitcoin::taproot::Signature::from_slice(&final_sig).unwrap(),
2464                                );
2465                                key_found = true;
2466                            }
2467                        }
2468                    }
2469                }
2470            }
2471
2472            if key_found && finalize {
2473                // Note: Full script-path finalization is complex (needs script + control block).
2474                // We leave it to the dApp or BDK if possible, or implement minimal reveal finalizer here.
2475            }
2476        }
2477
2478        Ok(())
2479    }
2480
2481    /// Return recently revealed addresses for the given keychain.
2482    pub fn get_revealed_addresses(&self, keychain: KeychainKind) -> Vec<String> {
2483        let wallet = match keychain {
2484            KeychainKind::External => &self.vault_wallet,
2485            KeychainKind::Internal => &self.vault_wallet,
2486        };
2487        wallet
2488            .list_unused_addresses(keychain)
2489            .into_iter()
2490            .map(|info| info.address.to_string())
2491            .collect()
2492    }
2493
2494    fn flexible_full_scan_request(
2495        wallet: &Wallet,
2496        policy: ScanPolicy,
2497        now: u64,
2498    ) -> FullScanRequest<KeychainKind> {
2499        // IMPORTANT: use the explicit `start_time` variant to avoid
2500        // std time calls that panic on wasm32-unknown-unknown.
2501        let mut builder = wallet.start_full_scan_at(now);
2502
2503        // 1. Set explicit scan depth (always scan at least N)
2504        if policy.address_scan_depth > 0 {
2505            for keychain in [KeychainKind::External, KeychainKind::Internal] {
2506                // Eagerly collect to satisfy 'static bound on iterator in BDK builder
2507                let spks: Vec<(u32, bitcoin::ScriptBuf)> = (0..policy.address_scan_depth)
2508                    .map(|i| (i, wallet.peek_address(keychain, i).script_pubkey()))
2509                    .collect();
2510                builder = builder.spks_for_keychain(keychain, spks);
2511            }
2512        }
2513
2514        builder.build()
2515    }
2516}
2517
2518/// Builder for constructing a `ZincWallet` from identity, network, and options.
2519impl WalletBuilder {
2520    /// Create a new builder for the specified network.
2521    #[must_use]
2522    pub fn new(network: Network) -> Self {
2523        Self {
2524            network,
2525            kind: None,
2526            mode: ProfileMode::Seed,
2527            scheme: AddressScheme::Unified,
2528            derivation_mode: DerivationMode::Account,
2529            payment_address_type: PaymentAddressType::NativeSegwit,
2530            persistence: None,
2531            account_index: 0,
2532            scan_policy: ScanPolicy::default(),
2533        }
2534    }
2535
2536    /// Shortcut for creating a builder from a mnemonic phrase.
2537    pub fn from_mnemonic(network: Network, mnemonic: &ZincMnemonic) -> Self {
2538        use bdk_wallet::bitcoin::bip32::Xpriv;
2539        let seed = mnemonic.to_seed("");
2540        let master_xprv = Xpriv::new_master(network, seed.as_ref()).expect("valid seed");
2541        Self::new(network).kind(WalletKind::Seed { master_xprv })
2542    }
2543
2544    /// Shortcut for creating a builder from a seed (used by discovery).
2545    pub fn from_seed(network: Network, seed: Seed64) -> Self {
2546        use bdk_wallet::bitcoin::bip32::Xpriv;
2547        let master_xprv = Xpriv::new_master(network, seed.as_ref()).expect("valid seed");
2548        Self::new(network).kind(WalletKind::Seed { master_xprv })
2549    }
2550
2551    /// Shortcut for creating a builder for watch-only profiles.
2552    pub fn from_watch_only(network: Network) -> Self {
2553        Self::new(network).mode(ProfileMode::Watch)
2554    }
2555
2556    /// Set the watch address for single-address watch profiles.
2557    pub fn with_watch_address(mut self, address: &str) -> Result<Self, String> {
2558        let addr = address
2559            .parse::<Address<NetworkUnchecked>>()
2560            .map_err(|e| format!("Invalid address: {e}"))?
2561            .require_network(self.network)
2562            .map_err(|e| format!("Network mismatch: {e}"))?;
2563
2564        if addr.address_type() != Some(AddressType::P2tr) {
2565            return Err(
2566                "Address watch mode currently supports taproot (bc1p/tb1p/bcrt1p) addresses only"
2567                    .to_string(),
2568            );
2569        }
2570
2571        self.kind = Some(WalletKind::WatchAddress(addr));
2572        Ok(self)
2573    }
2574
2575    /// Set the account xpub for watch-only profiles.
2576    pub fn with_xpub(mut self, xpub: &str) -> Result<Self, String> {
2577        let parsed = parse_extended_public_key(xpub)?;
2578        let taproot_desc = format!("tr({parsed}/0/*)");
2579        let payment_desc = payment_descriptor_for_xpub(&parsed, self.payment_address_type, 0);
2580        self.kind = Some(WalletKind::Hardware {
2581            fingerprint: [0, 0, 0, 0],
2582            taproot_external: taproot_desc,
2583            payment_external: Some(payment_desc),
2584        });
2585        Ok(self)
2586    }
2587
2588    /// Set the explicit taproot account xpub for dual-account watch profiles.
2589    pub fn with_taproot_xpub(mut self, xpub: &str) -> Result<Self, String> {
2590        let parsed = parse_extended_public_key(xpub)?;
2591        let taproot_desc = format!("tr({parsed}/0/*)");
2592
2593        let mut kind = self.kind.take().unwrap_or(WalletKind::Hardware {
2594            fingerprint: [0, 0, 0, 0],
2595            taproot_external: String::new(),
2596            payment_external: None,
2597        });
2598
2599        if let WalletKind::Hardware {
2600            ref mut taproot_external,
2601            ..
2602        } = kind
2603        {
2604            *taproot_external = taproot_desc;
2605        }
2606
2607        self.kind = Some(kind);
2608        Ok(self)
2609    }
2610
2611    /// Set the explicit payment account xpub for dual-account watch profiles.
2612    pub fn with_payment_xpub(mut self, xpub: &str) -> Result<Self, String> {
2613        let parsed = parse_extended_public_key(xpub)?;
2614        let payment_desc = payment_descriptor_for_xpub(&parsed, self.payment_address_type, 0);
2615
2616        let mut kind = self.kind.take().unwrap_or(WalletKind::Hardware {
2617            fingerprint: [0, 0, 0, 0],
2618            taproot_external: String::new(),
2619            payment_external: None,
2620        });
2621
2622        if let WalletKind::Hardware {
2623            ref mut payment_external,
2624            ..
2625        } = kind
2626        {
2627            *payment_external = Some(payment_desc);
2628        }
2629
2630        self.kind = Some(kind);
2631        Ok(self)
2632    }
2633
2634    /// Set the wallet's cryptographic identity.
2635    #[must_use]
2636    pub fn kind(mut self, kind: WalletKind) -> Self {
2637        self.kind = Some(kind);
2638        self
2639    }
2640
2641    /// Set the operational mode (`seed` vs `watch`).
2642    #[must_use]
2643    pub fn mode(mut self, mode: ProfileMode) -> Self {
2644        self.mode = mode;
2645        self
2646    }
2647
2648    /// Set the address scheme (`unified` vs `dual`).
2649    #[must_use]
2650    pub fn with_scheme(mut self, scheme: AddressScheme) -> Self {
2651        self.scheme = scheme;
2652        self
2653    }
2654
2655    /// Set the account derivation mode (`account` vs `index`).
2656    #[must_use]
2657    pub fn with_derivation_mode(mut self, mode: DerivationMode) -> Self {
2658        self.derivation_mode = mode;
2659        self
2660    }
2661
2662    /// Set the payment address type for dual-scheme wallets.
2663    #[must_use]
2664    pub fn with_payment_address_type(mut self, address_type: PaymentAddressType) -> Self {
2665        self.payment_address_type = address_type;
2666        self
2667    }
2668
2669    /// Set the account index to use for derivation.
2670    #[must_use]
2671    pub fn with_account_index(mut self, index: u32) -> Self {
2672        self.account_index = index;
2673        self
2674    }
2675
2676    /// Set the scan policy for address discovery.
2677    #[must_use]
2678    pub fn scan_policy(mut self, policy: ScanPolicy) -> Self {
2679        self.scan_policy = policy;
2680        self
2681    }
2682
2683    /// Set the persistence snapshot to load.
2684    pub fn with_persistence(mut self, persistence_json: &str) -> Result<Self, String> {
2685        let persistence: ZincPersistence = serde_json::from_str(persistence_json)
2686            .map_err(|e| format!("Failed to parse persistence JSON: {e}"))?;
2687        self.persistence = Some(persistence);
2688        Ok(self)
2689    }
2690
2691    /// Set the persistence snapshot to load directly.
2692    #[must_use]
2693    pub fn persistence(mut self, persistence: ZincPersistence) -> Self {
2694        self.persistence = Some(persistence);
2695        self
2696    }
2697
2698    /// Build the `ZincWallet` instance.
2699    pub fn build(self) -> Result<ZincWallet, String> {
2700        let kind = self
2701            .kind
2702            .ok_or_else(|| "Wallet identity must be set".to_string())?;
2703
2704        let mut scheme = self.scheme;
2705        if matches!(kind, WalletKind::WatchAddress(_)) {
2706            if scheme == AddressScheme::Dual {
2707                return Err("Address watch profiles support unified scheme only".to_string());
2708            }
2709            scheme = AddressScheme::Unified;
2710        }
2711
2712        let derivation_account = match self.derivation_mode {
2713            DerivationMode::Account => self.account_index,
2714            DerivationMode::Index => 0,
2715        };
2716
2717        let (vault_ext, vault_int, payment_ext, payment_int) = kind.derive_descriptors(
2718            scheme,
2719            self.payment_address_type,
2720            self.network,
2721            derivation_account,
2722        );
2723
2724        // 1. Vault (Taproot) wallet
2725        let (vault_wallet, loaded_vault_changeset) = if let Some(p) = &self.persistence {
2726            if let Some(changeset) = &p.taproot {
2727                let mut loader =
2728                    Wallet::load().descriptor(KeychainKind::External, Some(vault_ext.clone()));
2729
2730                if !matches!(kind, WalletKind::WatchAddress(_)) {
2731                    loader = loader.descriptor(KeychainKind::Internal, Some(vault_int.clone()));
2732                }
2733
2734                let res = loader
2735                    .extract_keys()
2736                    .load_wallet_no_persist(changeset.clone());
2737
2738                match res {
2739                    Ok(Some(w)) => (w, changeset.clone()),
2740                    Ok(None) | Err(_) => {
2741                        let creator = if matches!(kind, WalletKind::WatchAddress(_)) {
2742                            Wallet::create_single(vault_ext)
2743                        } else {
2744                            Wallet::create(vault_ext, vault_int)
2745                        };
2746                        let w = creator
2747                            .network(self.network)
2748                            .create_wallet_no_persist()
2749                            .map_err(|e| format!("Failed to create taproot wallet: {e}"))?;
2750                        (w, bdk_wallet::ChangeSet::default())
2751                    }
2752                }
2753            } else {
2754                let creator = if matches!(kind, WalletKind::WatchAddress(_)) {
2755                    Wallet::create_single(vault_ext)
2756                } else {
2757                    Wallet::create(vault_ext, vault_int)
2758                };
2759                let w = creator
2760                    .network(self.network)
2761                    .create_wallet_no_persist()
2762                    .map_err(|e| format!("Failed to create taproot wallet: {e}"))?;
2763                (w, bdk_wallet::ChangeSet::default())
2764            }
2765        } else {
2766            let creator = if matches!(kind, WalletKind::WatchAddress(_)) {
2767                Wallet::create_single(vault_ext)
2768            } else {
2769                Wallet::create(vault_ext, vault_int)
2770            };
2771            let w = creator
2772                .network(self.network)
2773                .create_wallet_no_persist()
2774                .map_err(|e| format!("Failed to create taproot wallet: {e}"))?;
2775            (w, bdk_wallet::ChangeSet::default())
2776        };
2777
2778        // 2. Payment wallet (optional, for dual-scheme)
2779        let (payment_wallet, loaded_payment_changeset) =
2780            if let (Some(pay_ext), Some(pay_int)) = (payment_ext, payment_int) {
2781                let (wallet, changeset) = if let Some(p) = &self.persistence {
2782                    if let Some(changeset) = &p.payment {
2783                        let res = Wallet::load()
2784                            .descriptor(KeychainKind::External, Some(pay_ext.clone()))
2785                            .descriptor(KeychainKind::Internal, Some(pay_int.clone()))
2786                            .extract_keys()
2787                            .load_wallet_no_persist(changeset.clone());
2788
2789                        match res {
2790                            Ok(Some(w)) => (w, Some(changeset.clone())),
2791                            Ok(None) | Err(_) => {
2792                                let w = Wallet::create(pay_ext, pay_int)
2793                                    .network(self.network)
2794                                    .create_wallet_no_persist()
2795                                    .map_err(|e| format!("Failed to create payment wallet: {e}"))?;
2796                                (w, None)
2797                            }
2798                        }
2799                    } else {
2800                        let w = Wallet::create(pay_ext, pay_int)
2801                            .network(self.network)
2802                            .create_wallet_no_persist()
2803                            .map_err(|e| format!("Failed to create payment wallet: {e}"))?;
2804                        (w, None)
2805                    }
2806                } else {
2807                    let w = Wallet::create(pay_ext, pay_int)
2808                        .network(self.network)
2809                        .create_wallet_no_persist()
2810                        .map_err(|e| format!("Failed to create payment wallet: {e}"))?;
2811                    (w, None)
2812                };
2813                (Some(wallet), changeset)
2814            } else {
2815                (None, None)
2816            };
2817
2818        Ok(ZincWallet {
2819            vault_wallet,
2820            payment_wallet,
2821            scheme: self.scheme,
2822            derivation_mode: self.derivation_mode,
2823            payment_address_type: self.payment_address_type,
2824            loaded_vault_changeset,
2825            loaded_payment_changeset,
2826            account_index: self.account_index,
2827            mode: self.mode,
2828            scan_policy: self.scan_policy,
2829            inscribed_utxos: std::collections::HashSet::default(),
2830            inscriptions: Vec::new(),
2831            rune_balances: Vec::new(),
2832            ordinals_verified: false,
2833            ordinals_metadata_complete: false,
2834            kind,
2835            is_syncing: false,
2836            account_generation: 0,
2837        })
2838    }
2839
2840    /// Build a hardware wallet profile from public descriptors.
2841    pub fn build_hardware(
2842        self,
2843        fingerprint_hex: &str,
2844        taproot_external_desc: String,
2845        taproot_internal_desc: String,
2846        payment_external_desc: Option<String>,
2847        payment_internal_desc: Option<String>,
2848    ) -> Result<ZincWallet, String> {
2849        let fingerprint_vec =
2850            hex::decode(fingerprint_hex).map_err(|e| format!("Invalid fingerprint hex: {e}"))?;
2851        let fingerprint: [u8; 4] = fingerprint_vec
2852            .try_into()
2853            .map_err(|_| "Fingerprint must be 4 bytes".to_string())?;
2854
2855        let network = self.network;
2856        let account_index = self.account_index;
2857        let persistence = self.persistence;
2858
2859        let scheme = if payment_external_desc.is_some() {
2860            AddressScheme::Dual
2861        } else {
2862            AddressScheme::Unified
2863        };
2864
2865        // 1. Vault (Taproot) wallet from public descriptors
2866        let (vault_wallet, loaded_vault_changeset) = if let Some(p) = &persistence {
2867            if let Some(changeset) = &p.taproot {
2868                let res = Wallet::load()
2869                    .descriptor(KeychainKind::External, Some(taproot_external_desc.clone()))
2870                    .descriptor(KeychainKind::Internal, Some(taproot_internal_desc.clone()))
2871                    .extract_keys()
2872                    .load_wallet_no_persist(changeset.clone());
2873
2874                match res {
2875                    Ok(Some(w)) => (w, changeset.clone()),
2876                    Ok(None) | Err(_) => {
2877                        let w = Wallet::create(
2878                            taproot_external_desc.clone(),
2879                            taproot_internal_desc.clone(),
2880                        )
2881                        .network(network)
2882                        .create_wallet_no_persist()
2883                        .map_err(|e| {
2884                            format!("Failed to create taproot wallet from descriptor: {e}")
2885                        })?;
2886                        (w, bdk_wallet::ChangeSet::default())
2887                    }
2888                }
2889            } else {
2890                let w =
2891                    Wallet::create(taproot_external_desc.clone(), taproot_internal_desc.clone())
2892                        .network(network)
2893                        .create_wallet_no_persist()
2894                        .map_err(|e| {
2895                            format!("Failed to create taproot wallet from descriptor: {e}")
2896                        })?;
2897                (w, bdk_wallet::ChangeSet::default())
2898            }
2899        } else {
2900            let w = Wallet::create(taproot_external_desc.clone(), taproot_internal_desc.clone())
2901                .network(network)
2902                .create_wallet_no_persist()
2903                .map_err(|e| format!("Failed to create taproot wallet from descriptor: {e}"))?;
2904            (w, bdk_wallet::ChangeSet::default())
2905        };
2906
2907        // 2. Payment wallet (optional, for dual-scheme)
2908        let (payment_wallet, loaded_payment_changeset) = if let (Some(pay_ext), Some(pay_int)) =
2909            (&payment_external_desc, &payment_internal_desc)
2910        {
2911            let (wallet, changeset) = if let Some(p) = &persistence {
2912                if let Some(changeset) = &p.payment {
2913                    let res = Wallet::load()
2914                        .descriptor(KeychainKind::External, Some(pay_ext.clone()))
2915                        .descriptor(KeychainKind::Internal, Some(pay_int.clone()))
2916                        .extract_keys()
2917                        .load_wallet_no_persist(changeset.clone());
2918
2919                    match res {
2920                        Ok(Some(w)) => (w, Some(changeset.clone())),
2921                        Ok(None) | Err(_) => {
2922                            let w = Wallet::create(pay_ext.clone(), pay_int.clone())
2923                                .network(network)
2924                                .create_wallet_no_persist()
2925                                .map_err(|e| {
2926                                    format!("Failed to create payment wallet from descriptor: {e}")
2927                                })?;
2928                            (w, None)
2929                        }
2930                    }
2931                } else {
2932                    let w = Wallet::create(pay_ext.clone(), pay_int.clone())
2933                        .network(network)
2934                        .create_wallet_no_persist()
2935                        .map_err(|e| {
2936                            format!("Failed to create payment wallet from descriptor: {e}")
2937                        })?;
2938                    (w, None)
2939                }
2940            } else {
2941                let w = Wallet::create(pay_ext.clone(), pay_int.clone())
2942                    .network(network)
2943                    .create_wallet_no_persist()
2944                    .map_err(|e| format!("Failed to create payment wallet from descriptor: {e}"))?;
2945                (w, None)
2946            };
2947            (Some(wallet), changeset)
2948        } else {
2949            (None, None)
2950        };
2951
2952        Ok(ZincWallet {
2953            vault_wallet,
2954            payment_wallet,
2955            scheme,
2956            derivation_mode: self.derivation_mode,
2957            payment_address_type: self.payment_address_type,
2958            loaded_vault_changeset,
2959            loaded_payment_changeset,
2960            account_index,
2961            mode: ProfileMode::Watch,
2962            scan_policy: self.scan_policy,
2963            inscribed_utxos: std::collections::HashSet::default(),
2964            inscriptions: Vec::new(),
2965            rune_balances: Vec::new(),
2966            ordinals_verified: false,
2967            ordinals_metadata_complete: false,
2968            kind: WalletKind::Hardware {
2969                fingerprint,
2970                taproot_external: taproot_external_desc,
2971                payment_external: payment_external_desc,
2972            },
2973            is_syncing: false,
2974            account_generation: 0,
2975        })
2976    }
2977}
2978
2979/// Serializable persistence snapshot for taproot/payment wallet changesets.
2980#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2981pub struct ZincPersistence {
2982    /// Merged changeset for the taproot wallet.
2983    pub taproot: Option<bdk_wallet::ChangeSet>,
2984    /// Merged changeset for the optional payment wallet.
2985    pub payment: Option<bdk_wallet::ChangeSet>,
2986}
2987
2988fn bytes_to_lower_hex(bytes: &[u8]) -> String {
2989    let mut s = String::with_capacity(bytes.len() * 2);
2990    for &b in bytes {
2991        use std::fmt::Write;
2992        write!(&mut s, "{:02x}", b).unwrap();
2993    }
2994    s
2995}
2996
2997#[cfg(test)]
2998mod tests {
2999    use super::*;
3000    use bitcoin::Network;
3001
3002    #[test]
3003    fn test_builder_basic() {
3004        use bdk_wallet::bitcoin::bip32::Xpriv;
3005        let mnemonic = ZincMnemonic::generate(12).unwrap();
3006        let seed = mnemonic.to_seed("");
3007        let master_xprv = Xpriv::new_master(Network::Signet, seed.as_ref()).expect("valid seed");
3008
3009        let wallet = WalletBuilder::new(Network::Signet)
3010            .kind(WalletKind::Seed { master_xprv })
3011            .build()
3012            .unwrap();
3013
3014        assert_eq!(wallet.vault_wallet.network(), Network::Signet);
3015        assert!(wallet.is_unified());
3016    }
3017
3018    #[test]
3019    fn flexible_full_scan_request_uses_explicit_start_time() {
3020        use bdk_wallet::bitcoin::bip32::Xpriv;
3021        let mnemonic = ZincMnemonic::generate(12).unwrap();
3022        let seed = mnemonic.to_seed("");
3023        let master_xprv = Xpriv::new_master(Network::Signet, seed.as_ref()).expect("valid seed");
3024        let wallet = WalletBuilder::new(Network::Signet)
3025            .kind(WalletKind::Seed { master_xprv })
3026            .build()
3027            .unwrap();
3028
3029        let explicit_start = 1_777_777_777_u64;
3030        let req = ZincWallet::flexible_full_scan_request(
3031            &wallet.vault_wallet,
3032            ScanPolicy::default(),
3033            explicit_start,
3034        );
3035        assert_eq!(req.start_time(), explicit_start);
3036    }
3037}