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    /// When set, the next `prepare_requests` returns a Full (gap-limit) scan instead of an
410    /// incremental sync, then clears itself. Used for an explicit "rescan" to discover funds on
411    /// addresses past the currently-revealed frontier (e.g. a Sparrow user who keeps generating
412    /// new receive addresses). In-memory only — not persisted.
413    pub(crate) force_next_full: bool,
414}
415
416/// Describes how chain data should be fetched for a keychain set.
417pub enum SyncRequestType {
418    /// Full scan request.
419    Full(FullScanRequest<KeychainKind>),
420    /// Incremental sync request.
421    Incremental(SyncRequest<(KeychainKind, u32)>),
422}
423
424/// Bundled sync request for taproot and optional payment wallets.
425pub struct ZincSyncRequest {
426    /// Taproot sync request.
427    pub taproot: SyncRequestType,
428    /// Optional payment sync request.
429    pub payment: Option<SyncRequestType>,
430}
431
432/// User-facing balance view that separates total, spendable, and inscribed value.
433#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
434pub struct ZincBalance {
435    /// Raw combined wallet balance.
436    pub total: bdk_wallet::Balance,
437    /// Spendable balance after protection filtering.
438    pub spendable: bdk_wallet::Balance,
439    /// Display-focused spendable balance (payment wallet in dual mode).
440    pub display_spendable: bdk_wallet::Balance,
441    /// Estimated value currently marked as inscribed/protected.
442    pub inscribed: u64,
443}
444
445/// Minimum sats kept with an inscription when estimating salvageable cardinal value.
446const MIN_SALVAGE_PADDING_SATS: u64 = 546;
447const DEFAULT_DAPP_ADDRESS_SCAN_DEPTH: u32 = 20;
448const DAPP_SIGN_ADDRESS_SEARCH_DEPTH: u32 = 1000;
449
450/// A single wallet UTXO with ordinal-awareness, for coin-control UIs (camelCase over WASM).
451#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
452#[serde(rename_all = "camelCase")]
453pub struct UtxoItem {
454    /// Transaction id (hex).
455    pub txid: String,
456    /// Output index.
457    pub vout: u32,
458    /// Output value in sats.
459    pub value_sats: u64,
460    /// Derived address holding this UTXO, if it could be resolved.
461    pub address: Option<String>,
462    /// Keychain: `"external"` (receive) or `"internal"` (change).
463    pub keychain: String,
464    /// Owning wallet: `"taproot"` (vault) or `"payment"` (dual-scheme segwit branch).
465    pub wallet_role: String,
466    /// Whether the UTXO is confirmed.
467    pub confirmed: bool,
468    /// Confirmation count (0 when unconfirmed).
469    pub confirmations: u32,
470    /// True if the outpoint is protected (holds an inscription or rune).
471    pub is_inscribed: bool,
472    /// True if at least one known inscription sits in this UTXO.
473    pub has_inscription: bool,
474    /// Ids of inscriptions located in this UTXO.
475    pub inscription_ids: Vec<String>,
476    /// Byte offsets of those inscriptions within the UTXO.
477    pub inscription_offsets: Vec<u64>,
478    /// Conservative estimate of cardinal sats recoverable via salvage (0 unless inscription-bearing).
479    pub cardinal_salvageable_sats: u64,
480}
481
482/// Address-level dApp connect candidate with core-owned role eligibility.
483#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
484#[serde(rename_all = "camelCase")]
485pub struct DappAddressCandidate {
486    /// Derived address shown to the user.
487    pub address: String,
488    /// Public key for the derived address, when available.
489    pub public_key: Option<String>,
490    /// Provider address type (`p2tr`, `p2wpkh`, `p2sh`, or `p2pkh`).
491    pub address_type: String,
492    /// Owning wallet role (`taproot` or `payment`).
493    pub wallet_role: String,
494    /// Keychain for this selectable address (`external` receive or `internal` change).
495    pub keychain: String,
496    /// Absolute derivation index within the active logical account and keychain.
497    pub derivation_index: u32,
498    /// Clean, non-protected BTC currently held by this address.
499    pub clean_btc_sats: u64,
500    /// Confirmed subset of `clean_btc_sats`, useful for default selection.
501    pub clean_confirmed_btc_sats: u64,
502    /// Count of protected assets/UTXOs known on this address.
503    pub protected_asset_count: u32,
504    /// Known inscription ids on this address.
505    pub inscription_ids: Vec<String>,
506    /// True only when ordinals protection and metadata have both completed.
507    pub classification_complete: bool,
508    /// True when this address may be exposed as the payment/BTC dApp address.
509    pub eligible_payment: bool,
510    /// True when this address may be exposed as the ordinals/collectibles dApp address.
511    pub eligible_ordinals: bool,
512    /// User-facing reason this address cannot be selected as payment.
513    pub payment_disabled_reason: Option<String>,
514    /// User-facing reason this address cannot be selected as ordinals.
515    pub ordinals_disabled_reason: Option<String>,
516}
517
518#[derive(Debug, Clone, Copy)]
519struct OwnedAddressMatch {
520    purpose: u32,
521    keychain: KeychainKind,
522    absolute_index: u32,
523}
524
525/// Account summary returned by discovery and account listing APIs.
526#[derive(Debug, Clone, Serialize, Deserialize)]
527#[serde(rename_all = "camelCase")]
528pub struct Account {
529    /// Account index.
530    pub index: u32,
531    /// Human-readable account label.
532    pub label: String,
533    /// Taproot receive address.
534    #[serde(alias = "vaultAddress")]
535    pub taproot_address: String,
536    /// Taproot public key for account receive path.
537    #[serde(alias = "vaultPublicKey")]
538    pub taproot_public_key: String,
539    /// Payment receive address when in dual mode.
540    pub payment_address: Option<String>,
541    /// Payment public key when in dual mode.
542    pub payment_public_key: Option<String>,
543}
544
545/// Derived descriptor/public-key material for one account discovery candidate.
546#[derive(Debug, Clone)]
547pub struct DiscoveryAccountPlan {
548    /// Account index.
549    pub index: u32,
550    /// Taproot external descriptor template.
551    pub taproot_descriptor: String,
552    /// Taproot internal/change descriptor template.
553    pub taproot_change_descriptor: String,
554    /// Account taproot public key.
555    pub taproot_public_key: String,
556    /// Receive index used for taproot lookups in this logical account.
557    pub taproot_receive_index: u32,
558    /// Optional payment external descriptor template.
559    pub payment_descriptor: Option<String>,
560    /// Optional payment internal/change descriptor template.
561    pub payment_change_descriptor: Option<String>,
562    /// Optional payment public key.
563    pub payment_public_key: Option<String>,
564    /// Optional payment receive index used for lookups in this logical account.
565    pub payment_receive_index: Option<u32>,
566}
567
568/// Precomputed account discovery context that avoids exposing raw keys externally.
569#[derive(Debug, Clone)]
570pub struct DiscoveryContext {
571    /// Network for the descriptors in this context.
572    pub network: Network,
573    /// Address scheme for descriptors in this context.
574    pub scheme: AddressScheme,
575    /// Account derivation mode for descriptor/account mapping.
576    pub derivation_mode: DerivationMode,
577    /// Payment branch type for dual descriptors.
578    pub payment_address_type: PaymentAddressType,
579    /// Cryptographic identity used for derivation.
580    pub kind: WalletKind,
581    /// Account plans to evaluate.
582    pub accounts: Vec<DiscoveryAccountPlan>,
583    /// Guard flag used to prevent overlapping sync operations.
584    pub is_syncing: bool,
585    /// Monotonic generation used to invalidate stale async operations.
586    pub account_generation: u64,
587}
588
589fn payment_descriptor_for_xprv(
590    xprv: &bdk_wallet::bitcoin::bip32::Xpriv,
591    address_type: PaymentAddressType,
592    coin_type: u32,
593    account: u32,
594    chain: u32,
595) -> String {
596    let pay_purpose = address_type.purpose();
597
598    match address_type {
599        PaymentAddressType::NativeSegwit => {
600            format!("wpkh({xprv}/{pay_purpose}'/{coin_type}'/{account}'/{chain}/*)")
601        }
602        PaymentAddressType::NestedSegwit => {
603            format!("sh(wpkh({xprv}/{pay_purpose}'/{coin_type}'/{account}'/{chain}/*))")
604        }
605        PaymentAddressType::Legacy => {
606            format!("pkh({xprv}/{pay_purpose}'/{coin_type}'/{account}'/{chain}/*)")
607        }
608    }
609}
610
611fn payment_descriptor_for_xpub(
612    xpub: &bdk_wallet::bitcoin::bip32::Xpub,
613    address_type: PaymentAddressType,
614    chain: u32,
615) -> String {
616    match address_type {
617        PaymentAddressType::NativeSegwit => format!("wpkh({xpub}/{chain}/*)"),
618        PaymentAddressType::NestedSegwit => format!("sh(wpkh({xpub}/{chain}/*))"),
619        PaymentAddressType::Legacy => format!("pkh({xpub}/{chain}/*)"),
620    }
621}
622
623fn parse_extended_public_key(xpub: &str) -> Result<bdk_wallet::bitcoin::bip32::Xpub, String> {
624    use bdk_wallet::bitcoin::bip32::Xpub;
625
626    if let Ok(parsed) = Xpub::from_str(xpub) {
627        return Ok(parsed);
628    }
629
630    let mut data = bdk_wallet::bitcoin::base58::decode_check(xpub)
631        .map_err(|e| format!("Invalid extended public key: {e}"))?;
632    if data.len() != 78 {
633        return Err(format!(
634            "Invalid extended public key payload length: {} (expected 78)",
635            data.len()
636        ));
637    }
638
639    let version: [u8; 4] = [data[0], data[1], data[2], data[3]];
640    let normalized_version = match version {
641        // mainnet xpub/ypub/zpub/Ypub/Zpub variants
642        [0x04, 0x88, 0xB2, 0x1E]
643        | [0x04, 0x9D, 0x7C, 0xB2]
644        | [0x04, 0xB2, 0x47, 0x46]
645        | [0x02, 0x95, 0xB4, 0x3F]
646        | [0x02, 0xAA, 0x7E, 0xD3] => [0x04, 0x88, 0xB2, 0x1E],
647        // testnet/signet tpub/upub/vpub/Upub/Vpub variants
648        [0x04, 0x35, 0x87, 0xCF]
649        | [0x04, 0x4A, 0x52, 0x62]
650        | [0x04, 0x5F, 0x1C, 0xF6]
651        | [0x02, 0x42, 0x89, 0xEF]
652        | [0x02, 0x57, 0x54, 0x83] => [0x04, 0x35, 0x87, 0xCF],
653        _ => {
654            return Err(
655                "Unsupported extended public key prefix (expected xpub/ypub/zpub/tpub/upub/vpub)"
656                    .to_string(),
657            );
658        }
659    };
660
661    data[0..4].copy_from_slice(&normalized_version);
662    Xpub::decode(&data).map_err(|e| format!("Invalid extended public key: {e}"))
663}
664
665fn taproot_output_key_from_address(
666    address: &Address,
667) -> Result<bitcoin::secp256k1::XOnlyPublicKey, String> {
668    if address.address_type() != Some(AddressType::P2tr) {
669        return Err(
670            "Address watch mode currently supports taproot (bc1p/tb1p/bcrt1p) addresses only"
671                .to_string(),
672        );
673    }
674
675    let witness_program = address
676        .witness_program()
677        .ok_or_else(|| "Taproot address missing witness program".to_string())?;
678    let key_bytes = witness_program.program().as_bytes();
679    if key_bytes.len() != 32 {
680        return Err(format!(
681            "Invalid taproot witness program length: {}",
682            key_bytes.len()
683        ));
684    }
685
686    bitcoin::secp256k1::XOnlyPublicKey::from_slice(key_bytes)
687        .map_err(|e| format!("Invalid taproot output key: {e}"))
688}
689
690fn taproot_watch_descriptor(address: &Address) -> Result<String, String> {
691    let output_key = taproot_output_key_from_address(address)?;
692    Ok(format!("tr({output_key})"))
693}
694
695/// Builder for constructing a `ZincWallet` from identity, network, and options.
696#[derive(Clone)]
697pub struct WalletBuilder {
698    network: Network,
699    kind: Option<WalletKind>,
700    mode: ProfileMode,
701    scheme: AddressScheme,
702    derivation_mode: DerivationMode,
703    payment_address_type: PaymentAddressType,
704    persistence: Option<ZincPersistence>,
705    account_index: u32,
706    scan_policy: ScanPolicy,
707}
708
709impl ZincWallet {
710    fn watched_address(&self) -> Option<&Address> {
711        match &self.kind {
712            WalletKind::WatchAddress(address) => Some(address),
713            _ => None,
714        }
715    }
716
717    pub fn derive_public_key_internal(
718        &self,
719        purpose: u32,
720        _network: Network,
721        account: u32,
722        index: u32,
723    ) -> Result<String, String> {
724        self.derive_public_key_for_chain_internal(purpose, account, 0, index)
725    }
726
727    fn derive_public_key_for_chain_internal(
728        &self,
729        purpose: u32,
730        account: u32,
731        chain: u32,
732        index: u32,
733    ) -> Result<String, String> {
734        use bitcoin::secp256k1::Secp256k1;
735        let secp = Secp256k1::new();
736
737        match &self.kind {
738            WalletKind::Seed { master_xprv } => {
739                let network = self.vault_wallet.network();
740                let coin_type = if network == Network::Bitcoin { 0 } else { 1 };
741
742                let derivation_path = [
743                    // SECURITY: return an error instead of panicking if a derivation index
744                    // exceeds the BIP-32 hardened/normal bound (2^31). A dynamic/untrusted
745                    // index would otherwise abort the WASM runtime (DoS).
746                    bdk_wallet::bitcoin::bip32::ChildNumber::from_hardened_idx(purpose)
747                        .map_err(|e| format!("Invalid purpose index: {e}"))?,
748                    bdk_wallet::bitcoin::bip32::ChildNumber::from_hardened_idx(coin_type)
749                        .map_err(|e| format!("Invalid coin_type index: {e}"))?,
750                    bdk_wallet::bitcoin::bip32::ChildNumber::from_hardened_idx(account)
751                        .map_err(|e| format!("Invalid account index: {e}"))?,
752                    bdk_wallet::bitcoin::bip32::ChildNumber::from_normal_idx(chain)
753                        .map_err(|e| format!("Invalid chain index: {e}"))?,
754                    bdk_wallet::bitcoin::bip32::ChildNumber::from_normal_idx(index)
755                        .map_err(|e| format!("Invalid index: {e}"))?,
756                ];
757
758                let child_xprv = master_xprv
759                    .derive_priv(&secp, &derivation_path)
760                    .map_err(|e| format!("Key derivation failed: {e}"))?;
761
762                let public_key = child_xprv.private_key.public_key(&secp);
763
764                // Check purpose to decide format
765                if purpose == 86 {
766                    // Taproot (BIP-86) uses 32-byte x-only public keys
767                    let (x_only, _parity) = public_key.x_only_public_key();
768                    Ok(x_only.to_string())
769                } else {
770                    // SegWit (BIP-84) uses 33-byte compressed public keys
771                    Ok(public_key.to_string())
772                }
773            }
774            WalletKind::Hardware {
775                taproot_external,
776                payment_external,
777                ..
778            } => {
779                // For hardware wallets, we derive from the provided public descriptors.
780                // Desc format is usually like: tr([fp/path]xpub.../0/*)
781                let desc_str = if purpose == 86 {
782                    taproot_external
783                } else {
784                    payment_external.as_ref().ok_or_else(|| {
785                        "Payment descriptor missing for this hardware wallet".to_string()
786                    })?
787                };
788
789                // 1. Extract the Xpub string from the descriptor.
790                // Find either the character after ']' or the first '(' which starts the descriptor payload.
791                let xpub_start_part = if let Some(pos) = desc_str.find(']') {
792                    &desc_str[pos + 1..]
793                } else if let Some(pos) = desc_str.find('(') {
794                    &desc_str[pos + 1..]
795                } else {
796                    desc_str
797                };
798
799                // Find the first slash indicating the derivation path start from the xpub.
800                // If no slash, use the whole string (it might be a plain xpub or have trailing descriptor syntax).
801                let xpub_end_pos = xpub_start_part.find('/').unwrap_or(xpub_start_part.len());
802                let xpub_str = xpub_start_part[..xpub_end_pos].trim_end_matches(')');
803
804                // 2. Parse and derive.
805                use bitcoin::bip32::{ChildNumber, Xpub};
806                use std::str::FromStr;
807                let xpub = Xpub::from_str(xpub_str).map_err(|e| {
808                    format!(
809                        "Failed to parse xpub from descriptor (part: {}): {}",
810                        xpub_str, e
811                    )
812                })?;
813
814                // Derive /0/index (assuming external chain '0' matches our descriptors)
815                let derived_xpub = xpub
816                    .derive_pub(
817                        &secp,
818                        &[
819                            ChildNumber::from_normal_idx(0)
820                                .map_err(|e| format!("Invalid chain index: {e}"))?,
821                            // SECURITY: avoid panic on out-of-range derivation index.
822                            ChildNumber::from_normal_idx(index)
823                                .map_err(|e| format!("Invalid index: {e}"))?,
824                        ],
825                    )
826                    .map_err(|e| format!("Failed to derive public key from xpub: {}", e))?;
827
828                let public_key = derived_xpub.public_key;
829
830                if purpose == 86 {
831                    let (x_only, _parity) = public_key.x_only_public_key();
832                    Ok(x_only.to_string())
833                } else {
834                    Ok(public_key.to_string())
835                }
836            }
837            WalletKind::WatchAddress(address) => {
838                if purpose != 86 {
839                    return Err(ZincError::CapabilityMissing.to_string());
840                }
841                let output_key = taproot_output_key_from_address(address)
842                    .map_err(|_| ZincError::CapabilityMissing.to_string())?;
843                return Ok(output_key.to_string());
844            }
845        }
846    }
847
848    /// Return the cached inscriptions currently tracked by the wallet.
849    #[must_use]
850    pub fn inscriptions(&self) -> &[crate::ordinals::types::Inscription] {
851        &self.inscriptions
852    }
853
854    /// Return the cached rune balances currently tracked by the wallet.
855    #[must_use]
856    pub fn rune_balances(&self) -> &[crate::ordinals::types::RuneBalance] {
857        &self.rune_balances
858    }
859
860    /// Return the current account generation counter.
861    #[must_use]
862    pub fn account_generation(&self) -> u64 {
863        self.account_generation
864    }
865
866    /// Return the currently active account index.
867    #[must_use]
868    pub fn active_account_index(&self) -> u32 {
869        self.account_index
870    }
871
872    /// Return whether a sync operation is currently in progress.
873    #[must_use]
874    pub fn is_syncing(&self) -> bool {
875        self.is_syncing
876    }
877
878    /// Return whether ordinals protection data is verified.
879    #[must_use]
880    pub fn ordinals_verified(&self) -> bool {
881        self.ordinals_verified
882    }
883
884    /// Return whether ordinals metadata refresh completed successfully.
885    #[must_use]
886    pub fn ordinals_metadata_complete(&self) -> bool {
887        self.ordinals_metadata_complete
888    }
889
890    /// Return `true` when the wallet uses unified addressing.
891    pub fn is_unified(&self) -> bool {
892        self.scheme == AddressScheme::Unified
893    }
894
895    /// Return the active derivation mode.
896    #[must_use]
897    pub fn derivation_mode(&self) -> DerivationMode {
898        self.derivation_mode
899    }
900
901    /// Return the profile mode (`seed` vs `watch`).
902    #[must_use]
903    pub fn profile_mode(&self) -> ProfileMode {
904        self.mode
905    }
906
907    /// Return the active payment address type.
908    #[must_use]
909    pub fn payment_address_type(&self) -> PaymentAddressType {
910        self.payment_address_type
911    }
912
913    fn logical_account_path(&self, logical_account_index: u32) -> (u32, u32) {
914        match self.derivation_mode {
915            DerivationMode::Account => (logical_account_index, 0),
916            DerivationMode::Index => (0, logical_account_index),
917        }
918    }
919
920    fn active_receive_index(&self) -> u32 {
921        self.logical_account_path(self.account_index).1
922    }
923
924    fn active_derivation_account(&self) -> u32 {
925        self.logical_account_path(self.account_index).0
926    }
927
928    fn dual_payment_purpose(&self) -> u32 {
929        self.payment_address_type.purpose()
930    }
931
932    /// Return `true` when wallet state indicates a full scan is needed.
933    pub fn needs_full_scan(&self) -> bool {
934        // If we have no transactions and the tip is at genesis (or missing), we likely need a full scan
935        self.vault_wallet.local_chain().tip().height() == 0
936    }
937
938    /// Reveal and return the next taproot receive address.
939    pub fn next_taproot_address(&mut self) -> Result<Address, String> {
940        if let Some(address) = self.watched_address() {
941            return Ok(address.clone());
942        }
943
944        if self.derivation_mode == DerivationMode::Index {
945            return Ok(self.peek_taproot_address(0));
946        }
947        let info = self
948            .vault_wallet
949            .reveal_next_address(KeychainKind::External);
950        Ok(info.address)
951    }
952
953    /// Return the next UNUSED taproot receive address (Sparrow-style): the lowest revealed address
954    /// that has not received funds, revealing a fresh one only if every revealed address is used.
955    /// Idempotent until the returned address is used. May reveal (mutating state) — persist after.
956    pub fn next_unused_taproot_address(&mut self) -> Result<Address, String> {
957        if let Some(address) = self.watched_address() {
958            return Ok(address.clone());
959        }
960        if self.derivation_mode == DerivationMode::Index {
961            return Ok(self.peek_taproot_address(0));
962        }
963        Ok(self
964            .vault_wallet
965            .next_unused_address(KeychainKind::External)
966            .address)
967    }
968
969    /// Peek a taproot receive address at `index` without advancing state.
970    pub fn peek_taproot_address(&self, index: u32) -> Address {
971        if let Some(address) = self.watched_address() {
972            let _ = index;
973            return address.clone();
974        }
975
976        let resolved_index = self.active_receive_index().saturating_add(index);
977        self.vault_wallet
978            .peek_address(KeychainKind::External, resolved_index)
979            .address
980    }
981
982    /// Reveal and return the next payment address in dual mode.
983    ///
984    /// In unified mode this returns the next taproot address.
985    pub fn get_payment_address(&mut self) -> Result<bitcoin::Address, String> {
986        if self.scheme == AddressScheme::Dual {
987            if self.derivation_mode == DerivationMode::Index {
988                return self
989                    .peek_payment_address(0)
990                    .ok_or_else(|| "Payment wallet not initialized".to_string());
991            }
992            if let Some(wallet) = &mut self.payment_wallet {
993                Ok(wallet.reveal_next_address(KeychainKind::External).address)
994            } else {
995                Err("Payment wallet not initialized".to_string())
996            }
997        } else {
998            self.next_taproot_address()
999        }
1000    }
1001
1002    /// Peek a payment receive address at `index`.
1003    ///
1004    /// In unified mode this resolves to the taproot branch.
1005    pub fn peek_payment_address(&self, index: u32) -> Option<Address> {
1006        if self.scheme == AddressScheme::Dual {
1007            let resolved_index = self.active_receive_index().saturating_add(index);
1008            self.payment_wallet.as_ref().map(|w| {
1009                w.peek_address(KeychainKind::External, resolved_index)
1010                    .address
1011            })
1012        } else {
1013            Some(self.peek_taproot_address(index))
1014        }
1015    }
1016
1017    /// Export a persistence snapshot containing merged loaded+staged changesets.
1018    pub fn export_changeset(&self) -> Result<ZincPersistence, String> {
1019        // 1. Vault: Start with loaded changeset, merge staged changes
1020        let mut vault_changeset = self.loaded_vault_changeset.clone();
1021        if let Some(staged) = self.vault_wallet.staged() {
1022            vault_changeset.merge(staged.clone());
1023        }
1024
1025        // Ensure network and genesis are set (safety net for fresh wallets/empty state)
1026        let network = self.vault_wallet.network();
1027        vault_changeset.network = Some(network);
1028
1029        // Ensure descriptors are set
1030        vault_changeset.descriptor = Some(
1031            self.vault_wallet
1032                .public_descriptor(KeychainKind::External)
1033                .clone(),
1034        );
1035        vault_changeset.change_descriptor = Some(
1036            self.vault_wallet
1037                .public_descriptor(KeychainKind::Internal)
1038                .clone(),
1039        );
1040
1041        let genesis_hash = bitcoin::blockdata::constants::genesis_block(network)
1042            .header
1043            .block_hash();
1044        // Check if genesis is missing directly
1045        vault_changeset
1046            .local_chain
1047            .blocks
1048            .entry(0)
1049            .or_insert(Some(genesis_hash));
1050
1051        // 2. Payment: Start with loaded changeset, merge staged changes
1052        let mut payment_changeset = self.loaded_payment_changeset.clone();
1053
1054        if let Some(w) = &self.payment_wallet {
1055            // Ensure we have a base changeset to work with if we initialized one
1056            let mut pcs = payment_changeset.take().unwrap_or_default();
1057
1058            if let Some(staged) = w.staged() {
1059                pcs.merge(staged.clone());
1060            }
1061
1062            let net = w.network();
1063            pcs.network = Some(net);
1064
1065            // Ensure descriptors are set for payment wallet
1066            pcs.descriptor = Some(w.public_descriptor(KeychainKind::External).clone());
1067            pcs.change_descriptor = Some(w.public_descriptor(KeychainKind::Internal).clone());
1068
1069            let gen_hash = bitcoin::blockdata::constants::genesis_block(net)
1070                .header
1071                .block_hash();
1072            pcs.local_chain.blocks.entry(0).or_insert(Some(gen_hash));
1073            // Assign back to option
1074            payment_changeset = Some(pcs);
1075        } else {
1076            // If no payment wallet, ensure we don't persist stale data
1077            payment_changeset = None;
1078        }
1079
1080        Ok(ZincPersistence {
1081            taproot: Some(vault_changeset),
1082            payment: payment_changeset,
1083        })
1084    }
1085
1086    /// Check whether the configured Esplora endpoint is reachable.
1087    pub async fn check_connection(esplora_url: &str) -> bool {
1088        let client = esplora_client::Builder::new(esplora_url.trim_end_matches('/'))
1089            .build_async_with_sleeper::<SyncSleeper>();
1090
1091        match client {
1092            Ok(c) => c.get_height().await.is_ok(),
1093            Err(_) => false,
1094        }
1095    }
1096
1097    /// Build sync requests for taproot/payment wallets.
1098    pub fn prepare_requests(&self, force_full: bool) -> ZincSyncRequest {
1099        let now = wasm_now_secs();
1100        // Full (gap-limit) scan only when we must: the first-ever sync (no chain tip yet) or an
1101        // explicit rescan. Otherwise an incremental sync over already-revealed spks — far fewer
1102        // requests, so routine syncs and the post-unlock refresh land quickly. Newly-revealed
1103        // receive addresses are included automatically (they're part of the revealed set); funds on
1104        // addresses past the revealed frontier are picked up by a forced rescan.
1105        let full = force_full || self.needs_full_scan();
1106        let policy = self.scan_policy;
1107
1108        let build = |w: &Wallet| -> SyncRequestType {
1109            if full {
1110                SyncRequestType::Full(Self::flexible_full_scan_request(w, policy, now))
1111            } else {
1112                SyncRequestType::Incremental(Self::flexible_sync_request(w, now))
1113            }
1114        };
1115
1116        let vault = build(&self.vault_wallet);
1117        let payment = self.payment_wallet.as_ref().map(|w| build(w));
1118
1119        ZincSyncRequest {
1120            taproot: vault,
1121            payment,
1122        }
1123    }
1124
1125    /// Request that the next `prepare_requests` performs a full gap-limit scan (it then reverts to
1126    /// incremental). Use for an explicit "rescan" to discover funds beyond the revealed frontier.
1127    pub fn request_full_rescan(&mut self) {
1128        self.force_next_full = true;
1129    }
1130
1131    /// Apply taproot/payment updates and return merged event strings.
1132    pub fn apply_sync(
1133        &mut self,
1134        vault_update: impl Into<bdk_wallet::Update>,
1135        payment_update: Option<impl Into<bdk_wallet::Update>>,
1136    ) -> Result<Vec<String>, String> {
1137        let mut all_events = Vec::new();
1138
1139        // 1. Apply Vault Update
1140        let vault_events = self
1141            .vault_wallet
1142            .apply_update_events(vault_update)
1143            .map_err(|e| e.to_string())?;
1144
1145        for event in vault_events {
1146            all_events.push(format!("taproot:{event:?}"));
1147        }
1148
1149        // 2. Apply Payment Update
1150        if let (Some(w), Some(u)) = (&mut self.payment_wallet, payment_update) {
1151            let payment_events = w.apply_update_events(u).map_err(|e| e.to_string())?;
1152            for event in payment_events {
1153                all_events.push(format!("payment:{event:?}"));
1154            }
1155        }
1156
1157        Ok(all_events)
1158    }
1159
1160    /// Rebuild wallet state from current public descriptors, clearing cached sync state.
1161    pub fn reset_sync_state(&mut self) -> Result<(), String> {
1162        zinc_log_info!(
1163            target: LOG_TARGET_BUILDER,
1164            "resetting wallet sync state (chain mismatch recovery)"
1165        );
1166
1167        // 1. Reset Vault Wallet
1168        let vault_desc = self
1169            .vault_wallet
1170            .public_descriptor(KeychainKind::External)
1171            .to_string();
1172        let network = self.vault_wallet.network();
1173        self.vault_wallet = if matches!(&self.kind, WalletKind::WatchAddress(_)) {
1174            Wallet::create_single(vault_desc)
1175                .network(network)
1176                .create_wallet_no_persist()
1177                .map_err(|e| format!("Failed to reset taproot wallet: {e}"))?
1178        } else {
1179            let vault_change_desc = self
1180                .vault_wallet
1181                .public_descriptor(KeychainKind::Internal)
1182                .to_string();
1183            Wallet::create(vault_desc, vault_change_desc)
1184                .network(network)
1185                .create_wallet_no_persist()
1186                .map_err(|e| format!("Failed to reset taproot wallet: {e}"))?
1187        };
1188        self.loaded_vault_changeset = bdk_wallet::ChangeSet::default();
1189
1190        // 2. Reset Payment Wallet (if exists)
1191        if let Some(w) = &self.payment_wallet {
1192            let pay_desc = w.public_descriptor(KeychainKind::External).to_string();
1193            let pay_change_desc = w.public_descriptor(KeychainKind::Internal).to_string();
1194
1195            self.payment_wallet = Some(
1196                Wallet::create(pay_desc, pay_change_desc)
1197                    .network(network)
1198                    .create_wallet_no_persist()
1199                    .map_err(|e| format!("Failed to reset payment wallet: {e}"))?,
1200            );
1201            self.loaded_payment_changeset = Some(bdk_wallet::ChangeSet::default());
1202        }
1203
1204        // 3. Increment account generation to invalidate any in-flight syncs
1205        self.account_generation += 1;
1206        self.ordinals_verified = false;
1207        self.ordinals_metadata_complete = false;
1208
1209        Ok(())
1210    }
1211
1212    /// Run a full sync against Esplora for taproot and optional payment wallets.
1213    pub async fn sync(&mut self, esplora_url: &str) -> Result<Vec<String>, String> {
1214        let client = esplora_client::Builder::new(esplora_url.trim_end_matches('/'))
1215            .build_async_with_sleeper::<SyncSleeper>()
1216            .map_err(|e| format!("{e:?}"))?;
1217
1218        let now = wasm_now_secs();
1219        let vault_req = Self::flexible_full_scan_request(&self.vault_wallet, self.scan_policy, now);
1220        let payment_req = self
1221            .payment_wallet
1222            .as_ref()
1223            .map(|w| Self::flexible_full_scan_request(w, self.scan_policy, now));
1224
1225        let stop_gap = self.scan_policy.account_gap_limit as usize;
1226        let parallel_requests = 5;
1227
1228        // 1. Sync Vault
1229        let vault_update = client
1230            .full_scan(vault_req, stop_gap, parallel_requests)
1231            .await
1232            .map_err(|e| e.to_string())?;
1233
1234        // 2. Sync Payment (if exists)
1235        let payment_update = if let Some(req) = payment_req {
1236            Some(
1237                client
1238                    .full_scan(req, stop_gap, parallel_requests)
1239                    .await
1240                    .map_err(|e| e.to_string())?,
1241            )
1242        } else {
1243            None
1244        };
1245
1246        self.apply_sync(vault_update, payment_update)
1247    }
1248
1249    /// Collect the wallet's main receive addresses for ordinals sync.
1250    ///
1251    /// Strict scan policy intentionally limits this to index `0` receive paths:
1252    /// taproot external/0 and (when dual) payment external/0.
1253    /// Collect the wallet's main receive addresses for ordinals sync.
1254    ///
1255    /// The scan policy determines how many addresses are checked:
1256    /// - `address_scan_depth`: Ensuring at least N addresses are scanned.
1257    /// - Discovered active addresses from incremental sync.
1258    pub fn collect_active_addresses(&self) -> Vec<String> {
1259        if let Some(address) = self.watched_address() {
1260            return vec![address.to_string()];
1261        }
1262
1263        let mut addresses = Vec::new();
1264        let mut seen = std::collections::HashSet::new();
1265
1266        let mut collect_from_wallet = |wallet: &Wallet| {
1267            // 1. Always include up to address_scan_depth
1268            for i in 0..self.scan_policy.address_scan_depth {
1269                let addr = wallet
1270                    .peek_address(KeychainKind::External, i)
1271                    .address
1272                    .to_string();
1273                if seen.insert(addr.clone()) {
1274                    addresses.push(addr);
1275                }
1276            }
1277
1278            // 2. CRITICAL for ordinal safety: include every address that currently holds a UTXO
1279            // (these are USED addresses, which list_unused_addresses omits). Multi-address HD wallets
1280            // like Sparrow hold inscriptions across many used addresses; without this the ordinals scan
1281            // never sees them, inscribed_utxos stays empty for those outpoints, and a normal send would
1282            // happily select an inscription UTXO. Covers both external and internal keychains.
1283            for u in wallet.list_unspent() {
1284                let addr = wallet
1285                    .peek_address(u.keychain, u.derivation_index)
1286                    .address
1287                    .to_string();
1288                if seen.insert(addr.clone()) {
1289                    addresses.push(addr);
1290                }
1291            }
1292
1293            // 3. Include revealed-but-unused addresses too (gap addresses), for completeness.
1294            for info in wallet.list_unused_addresses(KeychainKind::External) {
1295                let addr = info.address.to_string();
1296                if seen.insert(addr.clone()) {
1297                    addresses.push(addr);
1298                }
1299            }
1300        };
1301
1302        collect_from_wallet(&self.vault_wallet);
1303        if let Some(w) = &self.payment_wallet {
1304            collect_from_wallet(w);
1305        }
1306
1307        addresses
1308    }
1309
1310    /// Addresses that currently hold a UTXO (across vault + payment keychains). Inscriptions and runes
1311    /// are UTXO-bound, so this is the complete, minimal set the ordinals sync needs — it skips the
1312    /// empty gap/unused addresses `collect_active_addresses` includes (each costs an ord round-trip
1313    /// but can never hold an asset). This is what makes "digital artifacts" populate quickly.
1314    pub fn collect_utxo_addresses(&self) -> Vec<String> {
1315        if let Some(address) = self.watched_address() {
1316            return vec![address.to_string()];
1317        }
1318
1319        let mut addresses = Vec::new();
1320        let mut seen = std::collections::HashSet::new();
1321
1322        let mut collect_from_wallet = |wallet: &Wallet| {
1323            for u in wallet.list_unspent() {
1324                let addr = wallet
1325                    .peek_address(u.keychain, u.derivation_index)
1326                    .address
1327                    .to_string();
1328                if seen.insert(addr.clone()) {
1329                    addresses.push(addr);
1330                }
1331            }
1332        };
1333
1334        collect_from_wallet(&self.vault_wallet);
1335        if let Some(w) = &self.payment_wallet {
1336            collect_from_wallet(w);
1337        }
1338
1339        addresses
1340    }
1341
1342    /// All wallet outpoints ("txid:vout") across vault + payment keychains, from the BDK UTXO set.
1343    /// These are the only outputs that can hold our inscriptions/runes, so the ordinals sync can
1344    /// bulk-query exactly these via `POST /outputs` — one request instead of per-address/per-output.
1345    pub fn collect_utxo_outpoints(&self) -> Vec<String> {
1346        let mut outpoints = Vec::new();
1347        let mut seen = std::collections::HashSet::new();
1348
1349        let mut collect_from_wallet = |wallet: &Wallet| {
1350            for u in wallet.list_unspent() {
1351                let op = u.outpoint.to_string();
1352                if seen.insert(op.clone()) {
1353                    outpoints.push(op);
1354                }
1355            }
1356        };
1357
1358        collect_from_wallet(&self.vault_wallet);
1359        if let Some(w) = &self.payment_wallet {
1360            collect_from_wallet(w);
1361        }
1362
1363        outpoints
1364    }
1365
1366    /// Update the wallet's internal inscription state.
1367    /// Call this AFTER fetching inscriptions successfully.
1368    pub fn apply_verified_ordinals_update(
1369        &mut self,
1370        inscriptions: Vec<crate::ordinals::types::Inscription>,
1371        protected_outpoints: std::collections::HashSet<bitcoin::OutPoint>,
1372        rune_balances: Vec<crate::ordinals::types::RuneBalance>,
1373    ) -> usize {
1374        zinc_log_info!(
1375            target: LOG_TARGET_BUILDER,
1376            "applying ordinals update: {} inscriptions received",
1377            inscriptions.len()
1378        );
1379        for inscription in &inscriptions {
1380            zinc_log_debug!(
1381                target: LOG_TARGET_BUILDER,
1382                "inscribed outpoint updated: {}",
1383                inscription.satpoint.outpoint
1384            );
1385        }
1386
1387        self.inscribed_utxos = protected_outpoints;
1388        self.inscriptions = inscriptions;
1389        self.rune_balances = rune_balances;
1390        self.ordinals_verified = true;
1391        self.ordinals_metadata_complete = true;
1392
1393        zinc_log_info!(
1394            target: LOG_TARGET_BUILDER,
1395            "total inscribed_utxos set size: {}",
1396            self.inscribed_utxos.len()
1397        );
1398        self.inscriptions.len()
1399    }
1400
1401    /// Apply cached inscription metadata from an untrusted caller boundary.
1402    ///
1403    /// This method updates metadata for UI rendering but intentionally does not
1404    /// mark the wallet's ordinals protection state as verified.
1405    pub fn apply_unverified_inscriptions_cache(
1406        &mut self,
1407        inscriptions: Vec<crate::ordinals::types::Inscription>,
1408    ) -> usize {
1409        zinc_log_info!(
1410            target: LOG_TARGET_BUILDER,
1411            "applying unverified inscription cache: {} inscriptions received",
1412            inscriptions.len()
1413        );
1414
1415        self.inscribed_utxos.clear();
1416        self.inscriptions = inscriptions;
1417        self.rune_balances.clear();
1418        self.ordinals_verified = false;
1419        self.ordinals_metadata_complete = true;
1420
1421        self.inscriptions.len()
1422    }
1423
1424    fn verify_ord_indexer_is_current(
1425        &mut self,
1426        ord_height: u32,
1427        wallet_height: u32,
1428    ) -> Result<(), String> {
1429        if ord_height < wallet_height.saturating_sub(1) {
1430            self.ordinals_verified = false;
1431            return Err(format!(
1432                "Ord Indexer is lagging! Ord: {ord_height}, Wallet: {wallet_height}. Safety lock engaged."
1433            ));
1434        }
1435        Ok(())
1436    }
1437
1438    /// Refresh only ordinals protection outpoints (no inscription metadata details).
1439    pub async fn sync_ordinals_protection(&mut self, ord_url: &str) -> Result<usize, String> {
1440        self.ordinals_verified = false;
1441        let addresses = self.collect_active_addresses();
1442        let client = crate::ordinals::OrdClient::new(ord_url.to_string());
1443
1444        // 0. Fetch Ord Indexer Tip to check for lag
1445        let ord_height = client
1446            .get_indexing_height()
1447            .await
1448            .map_err(|e| e.to_string())?;
1449
1450        // Get Wallet Tip (from Vault, which is always present)
1451        let wallet_height = self.vault_wallet.local_chain().tip().height();
1452
1453        self.verify_ord_indexer_is_current(ord_height, wallet_height)?;
1454
1455        let mut protected_outpoints = std::collections::HashSet::new();
1456        for addr_str in addresses {
1457            let snapshot = client
1458                .get_address_asset_snapshot(&addr_str)
1459                .await
1460                .map_err(|e| format!("Failed to fetch for {addr_str}: {e}"))?;
1461
1462            let protected = client
1463                .get_protected_outpoints_from_outputs(&snapshot.outputs)
1464                .await
1465                .map_err(|e| format!("Failed to fetch protected outputs for {addr_str}: {e}"))?;
1466            protected_outpoints.extend(protected);
1467        }
1468
1469        self.inscribed_utxos = protected_outpoints;
1470        self.ordinals_verified = true;
1471        Ok(self.inscribed_utxos.len())
1472    }
1473
1474    /// Refresh inscription metadata used by UI and PSBT analysis.
1475    pub async fn sync_ordinals_metadata(&mut self, ord_url: &str) -> Result<usize, String> {
1476        self.ordinals_metadata_complete = false;
1477        let addresses = self.collect_active_addresses();
1478        let client = crate::ordinals::OrdClient::new(ord_url.to_string());
1479
1480        let ord_height = client
1481            .get_indexing_height()
1482            .await
1483            .map_err(|e| e.to_string())?;
1484        let wallet_height = self.vault_wallet.local_chain().tip().height();
1485        self.verify_ord_indexer_is_current(ord_height, wallet_height)?;
1486        let rune_balances = client
1487            .get_rune_balances_for_addresses(&addresses)
1488            .await
1489            .map_err(|e| format!("Failed to fetch rune balances: {e}"))?;
1490
1491        let mut all_inscriptions = Vec::new();
1492        for addr_str in addresses {
1493            let snapshot = client
1494                .get_address_asset_snapshot(&addr_str)
1495                .await
1496                .map_err(|e| format!("Failed to fetch for {addr_str}: {e}"))?;
1497
1498            for inscription_id in snapshot.inscription_ids {
1499                let inscription = client
1500                    .get_inscription_details(&inscription_id)
1501                    .await
1502                    .map_err(|e| format!("Failed to fetch details for {inscription_id}: {e}"))?;
1503                all_inscriptions.push(inscription);
1504            }
1505        }
1506
1507        self.inscriptions = all_inscriptions;
1508        self.rune_balances = rune_balances;
1509        self.ordinals_metadata_complete = true;
1510        Ok(self.inscriptions.len())
1511    }
1512
1513    /// Sync Ordinals (Inscriptions) to build the Shield logic.
1514    /// This keeps the legacy behavior by running protection and metadata refresh.
1515    pub async fn sync_ordinals(&mut self, ord_url: &str) -> Result<usize, String> {
1516        self.sync_ordinals_protection(ord_url).await?;
1517        self.sync_ordinals_metadata(ord_url).await
1518    }
1519
1520    /// Return account summaries for the active wallet.
1521    pub fn get_accounts(&self, count: u32) -> Vec<Account> {
1522        match &self.kind {
1523            WalletKind::WatchAddress(address) => {
1524                let taproot_address = address.to_string();
1525                let taproot_public_key = self.get_taproot_public_key(0).unwrap_or_default();
1526                vec![Account {
1527                    index: self.account_index,
1528                    label: format!("Account {}", self.account_index + 1),
1529                    taproot_address: taproot_address.clone(),
1530                    taproot_public_key: taproot_public_key.clone(),
1531                    payment_address: Some(taproot_address),
1532                    payment_public_key: Some(taproot_public_key),
1533                }]
1534            }
1535            WalletKind::Hardware { .. } => {
1536                let taproot_address = self.peek_taproot_address(0).to_string();
1537                let taproot_public_key = self.get_taproot_public_key(0).unwrap_or_default();
1538                let (payment_address, payment_public_key) = if self.scheme == AddressScheme::Dual {
1539                    (
1540                        self.peek_payment_address(0).map(|a| a.to_string()),
1541                        self.get_payment_public_key(0).ok(),
1542                    )
1543                } else {
1544                    (
1545                        Some(taproot_address.clone()),
1546                        Some(taproot_public_key.clone()),
1547                    )
1548                };
1549                vec![Account {
1550                    index: self.account_index,
1551                    label: format!("Account {}", self.account_index + 1),
1552                    taproot_address,
1553                    taproot_public_key,
1554                    payment_address,
1555                    payment_public_key,
1556                }]
1557            }
1558            WalletKind::Seed { master_xprv } => {
1559                let mut accounts = Vec::new();
1560                for i in 0..count {
1561                    // Temporarily build a builder to derive addresses for other accounts
1562                    let builder = WalletBuilder::new(self.vault_wallet.network())
1563                        .kind(WalletKind::Seed {
1564                            master_xprv: *master_xprv,
1565                        })
1566                        .with_scheme(self.scheme)
1567                        .with_derivation_mode(self.derivation_mode)
1568                        .with_payment_address_type(self.payment_address_type)
1569                        .with_account_index(i);
1570
1571                    if let Ok(zwallet) = builder.build() {
1572                        let taproot_address = zwallet.peek_taproot_address(0).to_string();
1573                        let taproot_public_key =
1574                            zwallet.get_taproot_public_key(0).unwrap_or_default();
1575                        let (payment_address, payment_public_key) =
1576                            if self.scheme == AddressScheme::Dual {
1577                                (
1578                                    zwallet.peek_payment_address(0).map(|a| a.to_string()),
1579                                    zwallet.get_payment_public_key(0).ok(),
1580                                )
1581                            } else {
1582                                (
1583                                    Some(taproot_address.clone()),
1584                                    Some(taproot_public_key.clone()),
1585                                )
1586                            };
1587                        accounts.push(Account {
1588                            index: i,
1589                            label: format!("Account {}", i + 1),
1590                            taproot_address,
1591                            taproot_public_key,
1592                            payment_address,
1593                            payment_public_key,
1594                        });
1595                    }
1596                }
1597                accounts
1598            }
1599        }
1600    }
1601
1602    /// Return raw combined BDK balance across taproot and payment wallets.
1603    pub fn get_raw_balance(&self) -> bdk_wallet::Balance {
1604        let vault_bal = self.vault_wallet.balance();
1605        if let Some(payment_wallet) = &self.payment_wallet {
1606            let pay_bal = payment_wallet.balance();
1607            bdk_wallet::Balance {
1608                immature: vault_bal.immature + pay_bal.immature,
1609                trusted_pending: vault_bal.trusted_pending + pay_bal.trusted_pending,
1610                untrusted_pending: vault_bal.untrusted_pending + pay_bal.untrusted_pending,
1611                confirmed: vault_bal.confirmed + pay_bal.confirmed,
1612            }
1613        } else {
1614            vault_bal
1615        }
1616    }
1617
1618    /// Return an ordinals-aware balance view for display and spend checks.
1619    pub fn get_balance(&self) -> ZincBalance {
1620        let raw = self.get_raw_balance();
1621
1622        // Robust Approach:
1623        let calc_balance = |wallet: &Wallet| {
1624            let mut bal = bdk_wallet::Balance::default();
1625            for utxo in wallet.list_unspent() {
1626                if self.inscribed_utxos.contains(&utxo.outpoint) {
1627                    zinc_log_debug!(
1628                        target: LOG_TARGET_BUILDER,
1629                        "skipping inscribed UTXO while calculating balance: {:?}",
1630                        utxo.outpoint
1631                    );
1632                    continue;
1633                }
1634                match utxo.keychain {
1635                    KeychainKind::Internal | KeychainKind::External => {
1636                        // This UTXO is safe. Add to balance.
1637                        match utxo.chain_position {
1638                            bdk_chain::ChainPosition::Confirmed { .. } => {
1639                                bal.confirmed += utxo.txout.value;
1640                            }
1641                            bdk_chain::ChainPosition::Unconfirmed { .. } => {
1642                                bal.trusted_pending += utxo.txout.value;
1643                            }
1644                        }
1645                    }
1646                }
1647            }
1648            bal
1649        };
1650
1651        let mut safe_bal = calc_balance(&self.vault_wallet);
1652        if let Some(w) = &self.payment_wallet {
1653            let p_bal = calc_balance(w);
1654            safe_bal.confirmed += p_bal.confirmed;
1655            safe_bal.trusted_pending += p_bal.trusted_pending;
1656            safe_bal.untrusted_pending += p_bal.untrusted_pending;
1657            safe_bal.immature += p_bal.immature;
1658        }
1659
1660        let display_spendable = if let Some(payment_wallet) = &self.payment_wallet {
1661            calc_balance(payment_wallet)
1662        } else {
1663            safe_bal.clone()
1664        };
1665
1666        ZincBalance {
1667            total: raw.clone(),
1668            spendable: safe_bal.clone(),
1669            display_spendable,
1670            inscribed: raw
1671                .confirmed
1672                .to_sat()
1673                .saturating_sub(safe_bal.confirmed.to_sat())
1674                + raw
1675                    .trusted_pending
1676                    .to_sat()
1677                    .saturating_sub(safe_bal.trusted_pending.to_sat()), // Estimate
1678        }
1679    }
1680
1681    /// List every wallet UTXO (taproot keychain, plus payment in dual mode), annotated for
1682    /// coin-control UIs: address, confirmations, inscription ids/offsets, and an estimate of
1683    /// salvageable cardinal sats. Spans all derived addresses in each keychain.
1684    pub fn list_utxos(&self) -> Vec<UtxoItem> {
1685        let tip = self.vault_wallet.local_chain().tip().height();
1686        let mut out = Vec::new();
1687        self.collect_utxos_from(&self.vault_wallet, "taproot", tip, &mut out);
1688        if let Some(w) = &self.payment_wallet {
1689            self.collect_utxos_from(w, "payment", tip, &mut out);
1690        }
1691        out
1692    }
1693
1694    /// Return address-level candidates for dApp payment/ordinals role selection.
1695    ///
1696    /// External receive addresses are scanned for unused ordinals slots; internal/change
1697    /// addresses are included only when they already hold known UTXOs. Core owns eligibility
1698    /// so UI code does not infer safety from address strings or local UTXO summaries.
1699    pub fn dapp_address_candidates(&self, scan_depth: Option<u32>) -> Vec<DappAddressCandidate> {
1700        let scan_depth = scan_depth.unwrap_or(DEFAULT_DAPP_ADDRESS_SCAN_DEPTH).max(1);
1701        let mut out = Vec::new();
1702
1703        self.collect_dapp_address_candidates_from(
1704            &self.vault_wallet,
1705            "taproot",
1706            "p2tr",
1707            86,
1708            scan_depth,
1709            &mut out,
1710        );
1711
1712        if let Some(payment_wallet) = &self.payment_wallet {
1713            self.collect_dapp_address_candidates_from(
1714                payment_wallet,
1715                "payment",
1716                self.payment_provider_address_type(),
1717                self.dual_payment_purpose(),
1718                scan_depth,
1719                &mut out,
1720            );
1721        }
1722
1723        out
1724    }
1725
1726    fn collect_dapp_address_candidates_from(
1727        &self,
1728        wallet: &Wallet,
1729        wallet_role: &str,
1730        address_type: &str,
1731        purpose: u32,
1732        scan_depth: u32,
1733        out: &mut Vec<DappAddressCandidate>,
1734    ) {
1735        let active_receive_index = self.active_receive_index();
1736        let mut indexed_keychains: std::collections::BTreeSet<(KeychainKind, u32)> = (0
1737            ..scan_depth)
1738            .map(|offset| {
1739                (
1740                    KeychainKind::External,
1741                    active_receive_index.saturating_add(offset),
1742                )
1743            })
1744            .collect();
1745        for utxo in wallet.list_unspent() {
1746            indexed_keychains.insert((utxo.keychain, utxo.derivation_index));
1747        }
1748
1749        let classification_complete = self.ordinals_verified && self.ordinals_metadata_complete;
1750        let is_taproot = address_type == "p2tr";
1751        let account = self.active_derivation_account();
1752
1753        for (keychain, absolute_index) in indexed_keychains {
1754            let address = wallet
1755                .peek_address(keychain, absolute_index)
1756                .address
1757                .to_string();
1758            let chain = match keychain {
1759                KeychainKind::External => 0,
1760                KeychainKind::Internal => 1,
1761            };
1762            let keychain_label = match keychain {
1763                KeychainKind::External => "external",
1764                KeychainKind::Internal => "internal",
1765            };
1766            let public_key = self
1767                .derive_public_key_for_chain_internal(purpose, account, chain, absolute_index)
1768                .ok();
1769            let mut clean_btc_sats = 0u64;
1770            let mut clean_confirmed_btc_sats = 0u64;
1771            let mut protected_asset_count = 0u32;
1772            let mut inscription_ids = Vec::new();
1773
1774            for utxo in wallet.list_unspent() {
1775                if utxo.keychain != keychain || utxo.derivation_index != absolute_index {
1776                    continue;
1777                }
1778
1779                let mut utxo_inscription_ids = Vec::new();
1780                for inscription in &self.inscriptions {
1781                    if inscription.satpoint.outpoint == utxo.outpoint {
1782                        utxo_inscription_ids.push(inscription.id.clone());
1783                    }
1784                }
1785                let is_protected = self.inscribed_utxos.contains(&utxo.outpoint)
1786                    || !utxo_inscription_ids.is_empty();
1787
1788                if is_protected {
1789                    protected_asset_count = protected_asset_count
1790                        .saturating_add(utxo_inscription_ids.len().max(1) as u32);
1791                    inscription_ids.extend(utxo_inscription_ids);
1792                } else {
1793                    let value_sats = utxo.txout.value.to_sat();
1794                    clean_btc_sats = clean_btc_sats.saturating_add(value_sats);
1795                    if matches!(
1796                        utxo.chain_position,
1797                        bdk_chain::ChainPosition::Confirmed { .. }
1798                    ) {
1799                        clean_confirmed_btc_sats =
1800                            clean_confirmed_btc_sats.saturating_add(value_sats);
1801                    }
1802                }
1803            }
1804
1805            inscription_ids.sort();
1806            inscription_ids.dedup();
1807
1808            let has_clean_btc = clean_btc_sats > 0;
1809            let has_protected_assets = protected_asset_count > 0;
1810            let is_mixed = has_clean_btc && has_protected_assets;
1811            let eligible_payment =
1812                classification_complete && has_clean_btc && !has_protected_assets;
1813            let eligible_ordinals = classification_complete && is_taproot && !has_clean_btc;
1814
1815            let payment_disabled_reason = if eligible_payment {
1816                None
1817            } else if !classification_complete {
1818                Some("Sync ordinals before connecting".to_string())
1819            } else if is_mixed {
1820                Some("Holds BTC and artifacts; split in coin control first".to_string())
1821            } else if has_protected_assets {
1822                Some("Holds inscriptions; split in coin control first".to_string())
1823            } else {
1824                Some("No spendable BTC".to_string())
1825            };
1826
1827            let ordinals_disabled_reason = if eligible_ordinals {
1828                None
1829            } else if !classification_complete {
1830                Some("Sync ordinals before connecting".to_string())
1831            } else if !is_taproot {
1832                Some("Collectibles address must be Taproot".to_string())
1833            } else if is_mixed {
1834                Some("Holds BTC and artifacts; split in coin control first".to_string())
1835            } else if has_clean_btc {
1836                Some("Holds regular BTC; split in coin control first".to_string())
1837            } else {
1838                None
1839            };
1840
1841            out.push(DappAddressCandidate {
1842                address,
1843                public_key,
1844                address_type: address_type.to_string(),
1845                wallet_role: wallet_role.to_string(),
1846                keychain: keychain_label.to_string(),
1847                derivation_index: absolute_index,
1848                clean_btc_sats,
1849                clean_confirmed_btc_sats,
1850                protected_asset_count,
1851                inscription_ids,
1852                classification_complete,
1853                eligible_payment,
1854                eligible_ordinals,
1855                payment_disabled_reason,
1856                ordinals_disabled_reason,
1857            });
1858        }
1859    }
1860
1861    fn payment_provider_address_type(&self) -> &'static str {
1862        match self.payment_address_type {
1863            PaymentAddressType::NativeSegwit => "p2wpkh",
1864            PaymentAddressType::NestedSegwit => "p2sh",
1865            PaymentAddressType::Legacy => "p2pkh",
1866        }
1867    }
1868
1869    fn find_owned_address(&self, address: &str, scan_depth: u32) -> Option<OwnedAddressMatch> {
1870        self.find_owned_address_in_wallet(&self.vault_wallet, address, 86, scan_depth)
1871            .or_else(|| {
1872                self.payment_wallet.as_ref().and_then(|wallet| {
1873                    self.find_owned_address_in_wallet(
1874                        wallet,
1875                        address,
1876                        self.dual_payment_purpose(),
1877                        scan_depth,
1878                    )
1879                })
1880            })
1881    }
1882
1883    fn find_owned_address_in_wallet(
1884        &self,
1885        wallet: &Wallet,
1886        address: &str,
1887        purpose: u32,
1888        scan_depth: u32,
1889    ) -> Option<OwnedAddressMatch> {
1890        let active_receive_index = self.active_receive_index();
1891        let mut indexed_keychains: std::collections::BTreeSet<(KeychainKind, u32)> =
1892            std::collections::BTreeSet::new();
1893        for offset in 0..scan_depth.max(1) {
1894            indexed_keychains.insert((KeychainKind::External, offset));
1895            indexed_keychains.insert((KeychainKind::Internal, offset));
1896            indexed_keychains.insert((
1897                KeychainKind::External,
1898                active_receive_index.saturating_add(offset),
1899            ));
1900        }
1901        for utxo in wallet.list_unspent() {
1902            indexed_keychains.insert((utxo.keychain, utxo.derivation_index));
1903        }
1904
1905        indexed_keychains
1906            .into_iter()
1907            .find_map(|(keychain, absolute_index)| {
1908                let candidate = wallet
1909                    .peek_address(keychain, absolute_index)
1910                    .address
1911                    .to_string();
1912                if candidate == address {
1913                    Some(OwnedAddressMatch {
1914                        purpose,
1915                        keychain,
1916                        absolute_index,
1917                    })
1918                } else {
1919                    None
1920                }
1921            })
1922    }
1923
1924    /// Reveal selected dApp addresses so selections are included in persistence.
1925    pub fn confirm_dapp_address_selection(
1926        &mut self,
1927        payment_address: &str,
1928        ordinals_address: &str,
1929        scan_depth: Option<u32>,
1930    ) -> Result<(), String> {
1931        let scan_depth = scan_depth.unwrap_or(DEFAULT_DAPP_ADDRESS_SCAN_DEPTH).max(1);
1932        let payment_match = self
1933            .find_owned_address(payment_address, scan_depth)
1934            .ok_or_else(|| "Selected BTC address is not owned by this wallet".to_string())?;
1935        let ordinals_match = self
1936            .find_owned_address(ordinals_address, scan_depth)
1937            .ok_or_else(|| {
1938                "Selected collectibles address is not owned by this wallet".to_string()
1939            })?;
1940
1941        self.reveal_address_match(payment_match)?;
1942        self.reveal_address_match(ordinals_match)?;
1943        Ok(())
1944    }
1945
1946    fn reveal_address_match(&mut self, address_match: OwnedAddressMatch) -> Result<(), String> {
1947        let absolute_index = address_match.absolute_index;
1948
1949        if address_match.purpose == 86 {
1950            let _revealed: Vec<_> = self
1951                .vault_wallet
1952                .reveal_addresses_to(address_match.keychain, absolute_index)
1953                .collect();
1954            return Ok(());
1955        }
1956
1957        let payment_wallet = self
1958            .payment_wallet
1959            .as_mut()
1960            .ok_or_else(|| "Payment wallet not initialized".to_string())?;
1961        let _revealed: Vec<_> = payment_wallet
1962            .reveal_addresses_to(address_match.keychain, absolute_index)
1963            .collect();
1964        Ok(())
1965    }
1966
1967    fn collect_utxos_from(&self, wallet: &Wallet, role: &str, tip: u32, out: &mut Vec<UtxoItem>) {
1968        for u in wallet.list_unspent() {
1969            let (confirmed, confirmations) = match &u.chain_position {
1970                bdk_chain::ChainPosition::Confirmed { anchor, .. } => (
1971                    true,
1972                    tip.saturating_sub(anchor.block_id.height).saturating_add(1),
1973                ),
1974                bdk_chain::ChainPosition::Unconfirmed { .. } => (false, 0),
1975            };
1976
1977            let address = Some(
1978                wallet
1979                    .peek_address(u.keychain, u.derivation_index)
1980                    .address
1981                    .to_string(),
1982            );
1983
1984            let is_inscribed = self.inscribed_utxos.contains(&u.outpoint);
1985            let mut inscription_ids = Vec::new();
1986            let mut inscription_offsets = Vec::new();
1987            for ins in &self.inscriptions {
1988                if ins.satpoint.outpoint == u.outpoint {
1989                    inscription_ids.push(ins.id.clone());
1990                    inscription_offsets.push(ins.satpoint.offset);
1991                }
1992            }
1993            let has_inscription = !inscription_ids.is_empty();
1994            let value_sats = u.txout.value.to_sat();
1995
1996            // Conservative estimate: only inscription-bearing UTXOs expose salvageable cardinal sats,
1997            // keeping MIN_SALVAGE_PADDING_SATS per inscription. Runes-only protected UTXOs report 0
1998            // (handled by dedicated flows). The Ordinal Shield re-verifies any salvage before signing.
1999            let cardinal_salvageable_sats = if has_inscription {
2000                value_sats.saturating_sub(inscription_ids.len() as u64 * MIN_SALVAGE_PADDING_SATS)
2001            } else {
2002                0
2003            };
2004
2005            let keychain = match u.keychain {
2006                KeychainKind::Internal => "internal",
2007                KeychainKind::External => "external",
2008            };
2009
2010            out.push(UtxoItem {
2011                txid: u.outpoint.txid.to_string(),
2012                vout: u.outpoint.vout,
2013                value_sats,
2014                address,
2015                keychain: keychain.to_string(),
2016                wallet_role: role.to_string(),
2017                confirmed,
2018                confirmations,
2019                is_inscribed,
2020                has_inscription,
2021                inscription_ids,
2022                inscription_offsets,
2023                cardinal_salvageable_sats,
2024            });
2025        }
2026    }
2027
2028    /// Resolve one of the wallet's own UTXOs (across taproot + payment keychains) to its TxOut.
2029    fn resolve_owned_txout(&self, op: &bitcoin::OutPoint) -> Option<bitcoin::TxOut> {
2030        let find = |w: &Wallet| {
2031            w.list_unspent()
2032                .find(|u| u.outpoint == *op)
2033                .map(|u| u.txout.clone())
2034        };
2035        find(&self.vault_wallet).or_else(|| self.payment_wallet.as_ref().and_then(find))
2036    }
2037
2038    /// Build an unsigned "sat surgery" / salvage PSBT: spend the given inscription-bearing UTXOs and
2039    /// recover their cardinal (non-inscription) sats, keeping each inscription in its own
2040    /// `target_postage` output sent to `ordinals_address`; the recovered remainder (minus fee) goes to
2041    /// `destination`.
2042    ///
2043    /// Outputs are hand-ordered (postage outputs first, then recovery) so the inscription sats — assumed
2044    /// at offset 0 of each input — land in the padded outputs. This assumption is NOT trusted blindly:
2045    /// the caller MUST run the Ordinal Shield (`analyze_psbt`) on the result and refuse to sign if any
2046    /// inscription would move/burn (the shield uses real satpoint offsets).
2047    pub fn plan_salvage_tx(
2048        &self,
2049        outpoints: &[bitcoin::OutPoint],
2050        fee_rate: FeeRate,
2051        target_postage: u64,
2052        ordinals_address: &Address,
2053        destination: &Address,
2054    ) -> Result<Psbt, ZincError> {
2055        use bitcoin::{ScriptBuf, Sequence, TxIn, TxOut, Witness};
2056
2057        if !self.ordinals_verified {
2058            return Err(ZincError::WalletError(
2059                "Ordinals verification failed - safety lock engaged. Please retry sync."
2060                    .to_string(),
2061            ));
2062        }
2063        if outpoints.is_empty() {
2064            return Err(ZincError::WalletError(
2065                "No UTXOs selected for salvage".to_string(),
2066            ));
2067        }
2068
2069        // Dust floor: 546 sats clears every standard output type and matches our postage target.
2070        let dust = target_postage.max(546);
2071
2072        // Resolve each selected UTXO together with the offset of its (single) inscription.
2073        struct SalvageInput {
2074            op: bitcoin::OutPoint,
2075            txo: TxOut,
2076            offset: u64,
2077        }
2078        let mut metas: Vec<SalvageInput> = Vec::new();
2079        for op in outpoints {
2080            let txo = self
2081                .resolve_owned_txout(op)
2082                .ok_or_else(|| ZincError::WalletError(format!("UTXO not found in wallet: {op}")))?;
2083            let mut offsets: Vec<u64> = self
2084                .inscriptions
2085                .iter()
2086                .filter(|ins| ins.satpoint.outpoint == *op)
2087                .map(|ins| ins.satpoint.offset)
2088                .collect();
2089            offsets.sort_unstable();
2090            // Multiple inscriptions in one UTXO can't each be padded without modelling every sat
2091            // boundary; refuse here (the user can salvage it alone) rather than risk moving one.
2092            if offsets.len() > 1 {
2093                return Err(ZincError::WalletError(format!(
2094                    "UTXO {op} holds multiple inscriptions; salvage it on its own"
2095                )));
2096            }
2097            let offset = offsets.first().copied().unwrap_or(0);
2098            let value = txo.value.to_sat();
2099            if offset.saturating_add(target_postage) > value {
2100                return Err(ZincError::WalletError(format!(
2101                    "Inscription in {op} sits too close to the UTXO end to pad to {target_postage} sats"
2102                )));
2103            }
2104            metas.push(SalvageInput {
2105                op: *op,
2106                txo,
2107                offset,
2108            });
2109        }
2110
2111        // Place the input with the largest trailing cardinal LAST: only the final output's value is
2112        // reduced by the fee, and that reduction must never shift an earlier inscription's sat.
2113        metas.sort_by_key(|m| {
2114            m.txo
2115                .value
2116                .to_sat()
2117                .saturating_sub(m.offset + target_postage)
2118        });
2119
2120        let ord_spk = ordinals_address.script_pubkey();
2121        let dest_spk = destination.script_pubkey();
2122
2123        // Partition every input's sats, IN STREAM ORDER, into outputs:
2124        //   [lead cardinal -> dest] [postage (holds the inscription sat) -> ord] [trail cardinal -> dest]
2125        // Bitcoin assigns sats to outputs in input order, so this is the only layout that keeps each
2126        // inscription inside its own padded output. Sub-dust lead/trail is folded into that input's
2127        // postage (a little extra padding) — never dropped mid-stream, which would shift later sats.
2128        // The bool marks cardinal (dest) outputs; the fee comes out of the final one.
2129        let mut segments: Vec<(u64, ScriptBuf, bool)> = Vec::new();
2130        for m in &metas {
2131            let value = m.txo.value.to_sat();
2132            let offset = m.offset;
2133            let trail = value - offset - target_postage;
2134            let mut postage = target_postage;
2135            if offset >= dust {
2136                segments.push((offset, dest_spk.clone(), true));
2137            } else {
2138                postage += offset; // fold sub-dust lead: postage window now starts at sat 0
2139            }
2140            if trail >= dust {
2141                segments.push((postage, ord_spk.clone(), false));
2142                segments.push((trail, dest_spk.clone(), true));
2143            } else {
2144                segments.push((postage + trail, ord_spk.clone(), false)); // fold sub-dust trail
2145            }
2146        }
2147
2148        // Fee from a dummy tx with the real witnesses + the real output set.
2149        let mut dummy_inputs: Vec<TxIn> = Vec::with_capacity(metas.len());
2150        for m in &metas {
2151            let mut input = TxIn {
2152                previous_output: m.op,
2153                script_sig: ScriptBuf::new(),
2154                sequence: Sequence::ENABLE_RBF_NO_LOCKTIME,
2155                witness: Witness::new(),
2156            };
2157            if m.txo.script_pubkey.is_p2tr() {
2158                input.witness.push(vec![0u8; 64]);
2159            } else {
2160                input.witness.push(vec![0u8; 72]);
2161                input.witness.push(vec![0u8; 33]);
2162            }
2163            dummy_inputs.push(input);
2164        }
2165        let dummy_outputs: Vec<TxOut> = segments
2166            .iter()
2167            .map(|(v, spk, _)| TxOut {
2168                value: Amount::from_sat(*v),
2169                script_pubkey: spk.clone(),
2170            })
2171            .collect();
2172        let dummy_tx = Transaction {
2173            version: bitcoin::transaction::Version::TWO,
2174            lock_time: bitcoin::absolute::LockTime::ZERO,
2175            input: dummy_inputs,
2176            output: dummy_outputs,
2177        };
2178        let fee = (dummy_tx.vsize() as u64).saturating_mul(fee_rate.to_sat_per_vb_ceil());
2179
2180        // The final output must be cardinal, so the fee never touches a postage (inscription) output.
2181        let last_idx = segments.len() - 1;
2182        if !segments[last_idx].2 {
2183            return Err(ZincError::WalletError(
2184                "Selected inscriptions have no cardinal sats above the dust threshold to salvage"
2185                    .to_string(),
2186            ));
2187        }
2188        let last_after_fee = segments[last_idx].0.checked_sub(fee).ok_or_else(|| {
2189            ZincError::WalletError(format!(
2190                "Salvage is too small to cover the network fee ({fee} sats)"
2191            ))
2192        })?;
2193
2194        let mut outputs: Vec<TxOut> = Vec::with_capacity(segments.len());
2195        for (i, (v, spk, _)) in segments.iter().enumerate() {
2196            if i == last_idx {
2197                // Drop the final cardinal output if the fee leaves it below dust (absorbed into the
2198                // fee); dropping the LAST output never shifts an inscription.
2199                if last_after_fee >= dust {
2200                    outputs.push(TxOut {
2201                        value: Amount::from_sat(last_after_fee),
2202                        script_pubkey: spk.clone(),
2203                    });
2204                }
2205            } else {
2206                outputs.push(TxOut {
2207                    value: Amount::from_sat(*v),
2208                    script_pubkey: spk.clone(),
2209                });
2210            }
2211        }
2212        if outputs.is_empty() {
2213            return Err(ZincError::WalletError(
2214                "Salvage produced no spendable outputs".to_string(),
2215            ));
2216        }
2217
2218        let real_inputs: Vec<TxIn> = metas
2219            .iter()
2220            .map(|m| TxIn {
2221                previous_output: m.op,
2222                script_sig: ScriptBuf::new(),
2223                sequence: Sequence::ENABLE_RBF_NO_LOCKTIME,
2224                witness: Witness::new(),
2225            })
2226            .collect();
2227        let tx = Transaction {
2228            version: bitcoin::transaction::Version::TWO,
2229            lock_time: bitcoin::absolute::LockTime::ZERO,
2230            input: real_inputs,
2231            output: outputs,
2232        };
2233        let mut psbt = Psbt::from_unsigned_tx(tx)
2234            .map_err(|e| ZincError::WalletError(format!("Failed to build salvage PSBT: {e}")))?;
2235        for (i, m) in metas.iter().enumerate() {
2236            psbt.inputs[i].witness_utxo = Some(m.txo.clone());
2237        }
2238        Ok(psbt)
2239    }
2240
2241    /// Build an unsigned consolidation PSBT: sweep the given (cardinal-only) UTXOs into a single
2242    /// output at `destination`, minus fee. Refuses any inscription/rune-protected outpoint as a
2243    /// defense-in-depth check (the caller should only pass clean UTXOs).
2244    pub fn plan_consolidate_tx(
2245        &self,
2246        outpoints: &[bitcoin::OutPoint],
2247        fee_rate: FeeRate,
2248        destination: &Address,
2249    ) -> Result<Psbt, ZincError> {
2250        use bitcoin::{ScriptBuf, Sequence, TxIn, TxOut, Witness};
2251
2252        if outpoints.is_empty() {
2253            return Err(ZincError::WalletError(
2254                "No UTXOs selected for consolidation".to_string(),
2255            ));
2256        }
2257
2258        let mut resolved: Vec<(bitcoin::OutPoint, TxOut)> = Vec::new();
2259        for op in outpoints {
2260            if self.inscribed_utxos.contains(op) {
2261                return Err(ZincError::WalletError(format!(
2262                    "Refusing to consolidate a protected (inscription/rune) UTXO: {op}"
2263                )));
2264            }
2265            let txo = self
2266                .resolve_owned_txout(op)
2267                .ok_or_else(|| ZincError::WalletError(format!("UTXO not found in wallet: {op}")))?;
2268            resolved.push((*op, txo));
2269        }
2270
2271        let total_input: u64 = resolved.iter().map(|(_, t)| t.value.to_sat()).sum();
2272        let dest_spk = destination.script_pubkey();
2273
2274        let mut dummy_inputs = Vec::new();
2275        for (op, txo) in &resolved {
2276            let mut input = TxIn {
2277                previous_output: *op,
2278                script_sig: ScriptBuf::new(),
2279                sequence: Sequence::ENABLE_RBF_NO_LOCKTIME,
2280                witness: Witness::new(),
2281            };
2282            if txo.script_pubkey.is_p2tr() {
2283                input.witness.push(vec![0u8; 64]);
2284            } else {
2285                input.witness.push(vec![0u8; 72]);
2286                input.witness.push(vec![0u8; 33]);
2287            }
2288            dummy_inputs.push(input);
2289        }
2290        let dummy_tx = Transaction {
2291            version: bitcoin::transaction::Version::TWO,
2292            lock_time: bitcoin::absolute::LockTime::ZERO,
2293            input: dummy_inputs.clone(),
2294            output: vec![TxOut {
2295                value: Amount::from_sat(total_input),
2296                script_pubkey: dest_spk.clone(),
2297            }],
2298        };
2299        let fee = (dummy_tx.vsize() as u64).saturating_mul(fee_rate.to_sat_per_vb_ceil());
2300
2301        let consolidated = total_input
2302            .checked_sub(fee)
2303            .filter(|v| *v >= 546)
2304            .ok_or_else(|| {
2305                ZincError::WalletError(format!(
2306                    "Insufficient funds for consolidation: input {total_input}, fee {fee}"
2307                ))
2308            })?;
2309
2310        let mut inputs = dummy_inputs;
2311        for input in &mut inputs {
2312            input.witness = Witness::new();
2313        }
2314        let tx = Transaction {
2315            version: bitcoin::transaction::Version::TWO,
2316            lock_time: bitcoin::absolute::LockTime::ZERO,
2317            input: inputs,
2318            output: vec![TxOut {
2319                value: Amount::from_sat(consolidated),
2320                script_pubkey: dest_spk,
2321            }],
2322        };
2323
2324        let mut psbt = Psbt::from_unsigned_tx(tx).map_err(|e| {
2325            ZincError::WalletError(format!("Failed to build consolidation PSBT: {e}"))
2326        })?;
2327        for (i, (_, txo)) in resolved.iter().enumerate() {
2328            psbt.inputs[i].witness_utxo = Some(txo.clone());
2329        }
2330        Ok(psbt)
2331    }
2332
2333    /// Parse string inputs and return a base64 consolidation PSBT.
2334    pub fn plan_consolidate_base64(
2335        &self,
2336        outpoints: &[String],
2337        fee_rate_sat_vb: u64,
2338        destination: &str,
2339    ) -> Result<String, ZincError> {
2340        let network = self.vault_wallet.network();
2341        let fee_rate = FeeRate::from_sat_per_vb(fee_rate_sat_vb)
2342            .ok_or_else(|| ZincError::ConfigError("Invalid fee rate".to_string()))?;
2343        let dest_addr = Address::from_str(destination)
2344            .map_err(|e| ZincError::ConfigError(format!("Invalid destination address: {e}")))?
2345            .require_network(network)
2346            .map_err(|e| {
2347                ZincError::ConfigError(format!("Destination address network mismatch: {e}"))
2348            })?;
2349        let mut ops = Vec::with_capacity(outpoints.len());
2350        for s in outpoints {
2351            ops.push(
2352                bitcoin::OutPoint::from_str(s)
2353                    .map_err(|e| ZincError::ConfigError(format!("Invalid outpoint {s}: {e}")))?,
2354            );
2355        }
2356        let psbt = self.plan_consolidate_tx(&ops, fee_rate, &dest_addr)?;
2357        Ok(Self::encode_psbt_base64(&psbt))
2358    }
2359
2360    /// Build an unsigned "smart send" PSBT that pays `recipient` `amount_sats`, automatically salvaging
2361    /// any inscription-bearing UTXOs among `input_outpoints` (their cardinal sats help fund the send).
2362    ///
2363    /// Each inscribed input is padded inside its own postage output at the inscription's real sat
2364    /// offset; the recipient + change are paid from the contiguous cardinal at the tail (the
2365    /// largest inscribed input's trailing cardinal — placed last — plus the clean inputs), and every
2366    /// earlier inscribed input's cardinal returns to the wallet as its own change. The caller MUST
2367    /// still verify the result with `analyze_psbt` before signing.
2368    #[allow(clippy::too_many_arguments)]
2369    pub fn plan_send_with_salvage_tx(
2370        &self,
2371        input_outpoints: &[bitcoin::OutPoint],
2372        recipient: &Address,
2373        amount_sats: u64,
2374        fee_rate: FeeRate,
2375        target_postage: u64,
2376        ordinals_address: &Address,
2377        change_address: &Address,
2378    ) -> Result<Psbt, ZincError> {
2379        use bitcoin::{ScriptBuf, Sequence, TxIn, TxOut, Witness};
2380
2381        if !self.ordinals_verified {
2382            return Err(ZincError::WalletError(
2383                "Ordinals verification failed - safety lock engaged. Please retry sync."
2384                    .to_string(),
2385            ));
2386        }
2387        if input_outpoints.is_empty() {
2388            return Err(ZincError::WalletError(
2389                "No inputs provided for send".to_string(),
2390            ));
2391        }
2392        if amount_sats == 0 {
2393            return Err(ZincError::WalletError(
2394                "Send amount must be greater than zero".to_string(),
2395            ));
2396        }
2397
2398        // Resolve every input together with the offset of its inscription (clean inputs carry none).
2399        let dust = target_postage.max(546);
2400        struct SendInput {
2401            op: bitcoin::OutPoint,
2402            txo: TxOut,
2403            offset: u64,
2404        }
2405        let mut inscribed: Vec<SendInput> = Vec::new();
2406        let mut clean: Vec<SendInput> = Vec::new();
2407        for op in input_outpoints {
2408            let txo = self
2409                .resolve_owned_txout(op)
2410                .ok_or_else(|| ZincError::WalletError(format!("UTXO not found in wallet: {op}")))?;
2411            if self.inscribed_utxos.contains(op) {
2412                let mut offsets: Vec<u64> = self
2413                    .inscriptions
2414                    .iter()
2415                    .filter(|ins| ins.satpoint.outpoint == *op)
2416                    .map(|ins| ins.satpoint.offset)
2417                    .collect();
2418                offsets.sort_unstable();
2419                if offsets.len() > 1 {
2420                    return Err(ZincError::WalletError(format!(
2421                        "UTXO {op} holds multiple inscriptions; salvage it on its own first"
2422                    )));
2423                }
2424                let offset = offsets.first().copied().unwrap_or(0);
2425                if offset.saturating_add(target_postage) > txo.value.to_sat() {
2426                    return Err(ZincError::WalletError(format!(
2427                        "Inscription in {op} sits too close to the UTXO end to pad to {target_postage} sats"
2428                    )));
2429                }
2430                inscribed.push(SendInput {
2431                    op: *op,
2432                    txo,
2433                    offset,
2434                });
2435            } else {
2436                clean.push(SendInput {
2437                    op: *op,
2438                    txo,
2439                    offset: 0,
2440                });
2441            }
2442        }
2443
2444        // Largest trailing cardinal LAST among inscribed inputs, right before the clean inputs: only
2445        // that last inscribed input's trail + the clean inputs form the contiguous cardinal run the
2446        // recipient + change are paid from. Every earlier inscribed input's cardinal is stranded
2447        // between postage outputs and returns to the wallet as its own change.
2448        inscribed.sort_by_key(|m| {
2449            m.txo
2450                .value
2451                .to_sat()
2452                .saturating_sub(m.offset + target_postage)
2453        });
2454        let ordered: Vec<&SendInput> = inscribed.iter().chain(clean.iter()).collect();
2455
2456        let ord_spk = ordinals_address.script_pubkey();
2457        let recipient_spk = recipient.script_pubkey();
2458        let change_spk = change_address.script_pubkey();
2459
2460        let build_inputs = || {
2461            ordered
2462                .iter()
2463                .map(|m| {
2464                    let mut input = TxIn {
2465                        previous_output: m.op,
2466                        script_sig: ScriptBuf::new(),
2467                        sequence: Sequence::ENABLE_RBF_NO_LOCKTIME,
2468                        witness: Witness::new(),
2469                    };
2470                    if m.txo.script_pubkey.is_p2tr() {
2471                        input.witness.push(vec![0u8; 64]);
2472                    } else {
2473                        input.witness.push(vec![0u8; 72]);
2474                        input.witness.push(vec![0u8; 33]);
2475                    }
2476                    input
2477                })
2478                .collect::<Vec<_>>()
2479        };
2480
2481        // Leading outputs (everything before the recipient/change pool): each inscribed input's padded
2482        // postage in stream order, plus any stranded cardinal as self-change. The last inscribed input
2483        // emits only its postage — its trailing cardinal flows into the spend pool below. Sub-dust
2484        // lead/trail is folded into that input's postage rather than dropped (which would shift sats).
2485        let mut leading: Vec<TxOut> = Vec::new();
2486        let inscribed_count = inscribed.len();
2487        for (idx, m) in inscribed.iter().enumerate() {
2488            let value = m.txo.value.to_sat();
2489            let offset = m.offset;
2490            let trail = value - offset - target_postage;
2491            let mut postage = target_postage;
2492            if offset >= dust {
2493                leading.push(TxOut {
2494                    value: Amount::from_sat(offset),
2495                    script_pubkey: change_spk.clone(),
2496                });
2497            } else {
2498                postage += offset;
2499            }
2500            if idx + 1 == inscribed_count {
2501                leading.push(TxOut {
2502                    value: Amount::from_sat(postage),
2503                    script_pubkey: ord_spk.clone(),
2504                });
2505            } else if trail >= dust {
2506                leading.push(TxOut {
2507                    value: Amount::from_sat(postage),
2508                    script_pubkey: ord_spk.clone(),
2509                });
2510                leading.push(TxOut {
2511                    value: Amount::from_sat(trail),
2512                    script_pubkey: change_spk.clone(),
2513                });
2514            } else {
2515                leading.push(TxOut {
2516                    value: Amount::from_sat(postage + trail),
2517                    script_pubkey: ord_spk.clone(),
2518                });
2519            }
2520        }
2521
2522        // Contiguous spendable pool at the tail: last inscribed input's trail + all clean inputs.
2523        let last_trail = inscribed
2524            .last()
2525            .map(|m| m.txo.value.to_sat() - m.offset - target_postage)
2526            .unwrap_or(0);
2527        let clean_total: u64 = clean.iter().map(|m| m.txo.value.to_sat()).sum();
2528        let pool = last_trail + clean_total;
2529
2530        // Fee from a dummy tx with the leading outputs + recipient + a change placeholder (worst case).
2531        let mut dummy_out = leading.clone();
2532        dummy_out.push(TxOut {
2533            value: Amount::from_sat(amount_sats),
2534            script_pubkey: recipient_spk.clone(),
2535        });
2536        dummy_out.push(TxOut {
2537            value: Amount::from_sat(0),
2538            script_pubkey: change_spk.clone(),
2539        });
2540        let dummy_tx = Transaction {
2541            version: bitcoin::transaction::Version::TWO,
2542            lock_time: bitcoin::absolute::LockTime::ZERO,
2543            input: build_inputs(),
2544            output: dummy_out,
2545        };
2546        let fee = (dummy_tx.vsize() as u64).saturating_mul(fee_rate.to_sat_per_vb_ceil());
2547
2548        let change = pool.checked_sub(amount_sats + fee).ok_or_else(|| {
2549            ZincError::WalletError(format!(
2550                "Insufficient funds: the amount must be covered by the largest inscription's cardinal sats plus clean UTXOs (have {pool}, need {} = amount {amount_sats} + fee {fee})",
2551                amount_sats + fee
2552            ))
2553        })?;
2554
2555        let mut outputs = leading;
2556        outputs.push(TxOut {
2557            value: Amount::from_sat(amount_sats),
2558            script_pubkey: recipient_spk,
2559        });
2560        // Keep a change output only if it clears dust; otherwise the remainder is absorbed into the fee.
2561        if change >= dust {
2562            outputs.push(TxOut {
2563                value: Amount::from_sat(change),
2564                script_pubkey: change_spk,
2565            });
2566        }
2567
2568        let mut real_inputs = build_inputs();
2569        for input in &mut real_inputs {
2570            input.witness = Witness::new();
2571        }
2572        let tx = Transaction {
2573            version: bitcoin::transaction::Version::TWO,
2574            lock_time: bitcoin::absolute::LockTime::ZERO,
2575            input: real_inputs,
2576            output: outputs,
2577        };
2578        let mut psbt = Psbt::from_unsigned_tx(tx)
2579            .map_err(|e| ZincError::WalletError(format!("Failed to build send PSBT: {e}")))?;
2580        for (i, m) in ordered.iter().enumerate() {
2581            psbt.inputs[i].witness_utxo = Some(m.txo.clone());
2582        }
2583        Ok(psbt)
2584    }
2585
2586    /// Parse string inputs and return a base64 smart-send (send-with-salvage) PSBT.
2587    #[allow(clippy::too_many_arguments)]
2588    pub fn plan_send_with_salvage_base64(
2589        &self,
2590        input_outpoints: &[String],
2591        recipient: &str,
2592        amount_sats: u64,
2593        fee_rate_sat_vb: u64,
2594        target_postage: u64,
2595        ordinals_address: &str,
2596        change_address: &str,
2597    ) -> Result<String, ZincError> {
2598        let network = self.vault_wallet.network();
2599        let fee_rate = FeeRate::from_sat_per_vb(fee_rate_sat_vb)
2600            .ok_or_else(|| ZincError::ConfigError("Invalid fee rate".to_string()))?;
2601        let parse_addr = |s: &str, what: &str| -> Result<Address, ZincError> {
2602            Address::from_str(s)
2603                .map_err(|e| ZincError::ConfigError(format!("Invalid {what} address: {e}")))?
2604                .require_network(network)
2605                .map_err(|e| {
2606                    ZincError::ConfigError(format!("{what} address network mismatch: {e}"))
2607                })
2608        };
2609        let recipient_addr = parse_addr(recipient, "recipient")?;
2610        let ord_addr = parse_addr(ordinals_address, "ordinals")?;
2611        let change_addr = parse_addr(change_address, "change")?;
2612        let mut ops = Vec::with_capacity(input_outpoints.len());
2613        for s in input_outpoints {
2614            ops.push(
2615                bitcoin::OutPoint::from_str(s)
2616                    .map_err(|e| ZincError::ConfigError(format!("Invalid outpoint {s}: {e}")))?,
2617            );
2618        }
2619        let psbt = self.plan_send_with_salvage_tx(
2620            &ops,
2621            &recipient_addr,
2622            amount_sats,
2623            fee_rate,
2624            target_postage,
2625            &ord_addr,
2626            &change_addr,
2627        )?;
2628        Ok(Self::encode_psbt_base64(&psbt))
2629    }
2630
2631    /// Parse string inputs (outpoints `txid:vout`, addresses, fee rate) and return a base64 salvage PSBT.
2632    pub fn plan_salvage_base64(
2633        &self,
2634        outpoints: &[String],
2635        fee_rate_sat_vb: u64,
2636        target_postage: u64,
2637        ordinals_address: &str,
2638        destination: &str,
2639    ) -> Result<String, ZincError> {
2640        let network = self.vault_wallet.network();
2641        let fee_rate = FeeRate::from_sat_per_vb(fee_rate_sat_vb)
2642            .ok_or_else(|| ZincError::ConfigError("Invalid fee rate".to_string()))?;
2643        let ord_addr = Address::from_str(ordinals_address)
2644            .map_err(|e| ZincError::ConfigError(format!("Invalid ordinals address: {e}")))?
2645            .require_network(network)
2646            .map_err(|e| {
2647                ZincError::ConfigError(format!("Ordinals address network mismatch: {e}"))
2648            })?;
2649        let dest_addr = Address::from_str(destination)
2650            .map_err(|e| ZincError::ConfigError(format!("Invalid destination address: {e}")))?
2651            .require_network(network)
2652            .map_err(|e| {
2653                ZincError::ConfigError(format!("Destination address network mismatch: {e}"))
2654            })?;
2655        let mut ops = Vec::with_capacity(outpoints.len());
2656        for s in outpoints {
2657            ops.push(
2658                bitcoin::OutPoint::from_str(s)
2659                    .map_err(|e| ZincError::ConfigError(format!("Invalid outpoint {s}: {e}")))?,
2660            );
2661        }
2662        let psbt = self.plan_salvage_tx(&ops, fee_rate, target_postage, &ord_addr, &dest_addr)?;
2663        Ok(Self::encode_psbt_base64(&psbt))
2664    }
2665
2666    /// Create an unsigned PSBT for sending BTC.
2667    pub fn create_psbt_tx(&mut self, request: &CreatePsbtRequest) -> Result<Psbt, ZincError> {
2668        if !self.ordinals_verified {
2669            return Err(ZincError::WalletError(
2670                "Ordinals verification failed - safety lock engaged. Please retry sync."
2671                    .to_string(),
2672            ));
2673        }
2674
2675        let active_receive_index = self.active_receive_index();
2676        let wallet = if self.scheme == AddressScheme::Dual {
2677            self.payment_wallet.as_mut().ok_or_else(|| {
2678                ZincError::WalletError("Payment wallet not initialized".to_string())
2679            })?
2680        } else {
2681            &mut self.vault_wallet
2682        };
2683
2684        let recipient = request
2685            .recipient
2686            .clone()
2687            .require_network(wallet.network())
2688            .map_err(|e| ZincError::ConfigError(format!("Network mismatch: {e}")))?;
2689
2690        let change_script = wallet
2691            .peek_address(KeychainKind::External, active_receive_index)
2692            .script_pubkey();
2693
2694        let mut builder = wallet.build_tx();
2695        if !self.inscribed_utxos.is_empty() {
2696            builder.unspendable(self.inscribed_utxos.iter().copied().collect());
2697        }
2698
2699        builder
2700            .add_recipient(recipient.script_pubkey(), request.amount)
2701            .fee_rate(request.fee_rate)
2702            .drain_to(change_script);
2703
2704        builder
2705            .finish()
2706            .map_err(|e| ZincError::WalletError(format!("Failed to build tx: {e}")))
2707    }
2708
2709    /// Create an unsigned PSBT for sending BTC and encode it as base64.
2710    pub fn create_psbt_base64(&mut self, request: &CreatePsbtRequest) -> Result<String, ZincError> {
2711        let psbt = self.create_psbt_tx(request)?;
2712        Ok(Self::encode_psbt_base64(&psbt))
2713    }
2714
2715    /// Create an ord-compatible buyer offer PSBT and envelope.
2716    pub fn create_offer(
2717        &mut self,
2718        request: &crate::offer_create::CreateOfferRequest,
2719    ) -> Result<crate::offer_create::OfferCreateResultV1, ZincError> {
2720        crate::offer_create::create_offer(self, request)
2721    }
2722
2723    /// Create a buyer-funded listing purchase PSBT and sign buyer inputs.
2724    pub fn create_listing_purchase(
2725        &mut self,
2726        request: &crate::listing::CreateListingPurchaseRequest,
2727    ) -> Result<crate::listing::CreateListingPurchaseResultV1, ZincError> {
2728        crate::listing::create_listing_purchase(self, request)
2729    }
2730
2731    /// Create an unsigned PSBT for sending BTC from transport-friendly inputs.
2732    ///
2733    /// This method is a migration wrapper for app-boundary callers. New native
2734    /// Rust integrations should construct `CreatePsbtRequest` and call
2735    /// `create_psbt_tx` or `create_psbt_base64`.
2736    #[doc(hidden)]
2737    #[deprecated(note = "Use create_psbt_base64 with CreatePsbtRequest")]
2738    pub fn create_psbt(
2739        &mut self,
2740        recipient: &str,
2741        amount_sats: u64,
2742        fee_rate_sat_vb: u64,
2743    ) -> Result<String, String> {
2744        let request = CreatePsbtRequest::from_parts(recipient, amount_sats, fee_rate_sat_vb)
2745            .map_err(|e| e.to_string())?;
2746        self.create_psbt_base64(&request).map_err(|e| e.to_string())
2747    }
2748
2749    fn encode_psbt_base64(psbt: &Psbt) -> String {
2750        use base64::Engine;
2751        base64::engine::general_purpose::STANDARD.encode(psbt.serialize())
2752    }
2753
2754    /// Sign a PSBT using the wallet's internal keys.
2755    /// Returns the signed PSBT as base64.
2756    #[allow(deprecated)]
2757    pub fn sign_psbt(
2758        &mut self,
2759        psbt_base64: &str,
2760        options: Option<SignOptions>,
2761    ) -> Result<String, String> {
2762        use base64::Engine;
2763
2764        // Decode PSBT from base64
2765        let psbt_bytes = base64::engine::general_purpose::STANDARD
2766            .decode(psbt_base64)
2767            .map_err(|e| format!("Invalid base64: {e}"))?;
2768
2769        let mut psbt = Psbt::deserialize(&psbt_bytes).map_err(|e| format!("Invalid PSBT: {e}"))?;
2770
2771        // ENRICHMENT STEP: Fill in missing witness_utxo from our own wallet if possible
2772        // This solves "Plain PSBT" issues where dApps don't include UTXO info
2773        use std::collections::HashMap;
2774        let mut known_utxos = HashMap::new();
2775
2776        let collect_utxos = |w: &Wallet, map: &mut HashMap<bitcoin::OutPoint, bitcoin::TxOut>| {
2777            for utxo in w.list_unspent() {
2778                map.insert(utxo.outpoint, utxo.txout);
2779            }
2780        };
2781
2782        collect_utxos(&self.vault_wallet, &mut known_utxos);
2783        if let Some(w) = &self.payment_wallet {
2784            collect_utxos(w, &mut known_utxos);
2785        }
2786
2787        for (i, input) in psbt.inputs.iter_mut().enumerate() {
2788            if input.witness_utxo.is_none() && input.non_witness_utxo.is_none() {
2789                let outpoint = psbt.unsigned_tx.input[i].previous_output;
2790                if let Some(txout) = known_utxos.get(&outpoint) {
2791                    input.witness_utxo = Some(txout.clone());
2792                }
2793            }
2794        }
2795
2796        // Prepare BDK SignOptions and Apply Overrides (SIGHASH, etc.)
2797        // We do this BEFORE audit to ensuring we check the actual state being signed.
2798        let should_finalize = options.as_ref().is_some_and(|o| o.finalize);
2799        let bdk_options = bdk_wallet::SignOptions {
2800            // CRITICAL: Enable trust_witness_utxo for batch inscriptions where reveal
2801            // transactions spend outputs from not-yet-broadcast commit transactions.
2802            // The wallet can't verify these UTXOs from chain state, but we trust the dApp.
2803            trust_witness_utxo: true,
2804            // Finalize if explicitly requested (internal wallet use).
2805            // Default is false for dApp/marketplace compatibility.
2806            try_finalize: should_finalize,
2807            ..Default::default()
2808        };
2809        let mut inputs_to_sign: Option<Vec<usize>> = None;
2810
2811        if let Some(opts) = &options {
2812            if let Some(sighash_u8) = opts.sighash {
2813                // Use PsbtSighashType to match psbt.inputs type
2814                let target_sighash =
2815                    bitcoin::psbt::PsbtSighashType::from_u32(u32::from(sighash_u8));
2816                for input in &mut psbt.inputs {
2817                    input.sighash_type = Some(target_sighash);
2818                }
2819            }
2820            inputs_to_sign = opts.sign_inputs.clone();
2821        }
2822
2823        if let Some(indices) = inputs_to_sign.as_ref() {
2824            let mut seen = std::collections::HashSet::new();
2825            for index in indices {
2826                if *index >= psbt.inputs.len() {
2827                    return Err(format!(
2828                        "Security Violation: sign_inputs index {} is out of bounds for {} inputs",
2829                        index,
2830                        psbt.inputs.len()
2831                    ));
2832                }
2833                if !seen.insert(*index) {
2834                    return Err(format!(
2835                        "Security Violation: sign_inputs index {index} is duplicated"
2836                    ));
2837                }
2838                let input = &psbt.inputs[*index];
2839                if input.witness_utxo.is_none() && input.non_witness_utxo.is_none() {
2840                    return Err(format!(
2841                        "Security Violation: Requested input #{index} is missing UTXO metadata"
2842                    ));
2843                }
2844            }
2845        }
2846
2847        for (index, input) in psbt.inputs.iter().enumerate() {
2848            if let Some(sighash) = input.sighash_type {
2849                let value = sighash.to_u32();
2850                let base_type = value & 0x1f;
2851                let anyone_can_pay = (value & 0x80) != 0;
2852                let is_allowed_base = base_type == 0 || base_type == 1; // DEFAULT or ALL
2853
2854                if anyone_can_pay || !is_allowed_base {
2855                    return Err(format!(
2856                        "Security Violation: Sighash type is not allowed on input #{index} (value={value})"
2857                    ));
2858                }
2859            }
2860        }
2861
2862        // Ordinal Shield Audit: BEFORE signing!
2863        // We must build the known_inscriptions map to check for BURNS (sophisticated check)
2864        let mut known_inscriptions: HashMap<(bitcoin::Txid, u32), Vec<(String, u64)>> =
2865            HashMap::with_capacity(self.inscriptions.len());
2866        for ins in &self.inscriptions {
2867            known_inscriptions
2868                .entry((ins.satpoint.outpoint.txid, ins.satpoint.outpoint.vout))
2869                .or_default()
2870                .push((ins.id.clone(), ins.satpoint.offset));
2871        }
2872        // Normalize offsets
2873        for items in known_inscriptions.values_mut() {
2874            items.sort_by_key(|(_, offset)| *offset);
2875        }
2876
2877        if let Err(e) = crate::ordinals::shield::audit_psbt(
2878            &psbt,
2879            &known_inscriptions,
2880            inputs_to_sign.as_deref(),
2881            self.vault_wallet.network(),
2882        ) {
2883            return Err(format!("Security Violation: {e}"));
2884        }
2885
2886        // Keep a copy if we need to revert signatures for specific inputs
2887        let original_psbt = if inputs_to_sign.is_some() {
2888            Some(psbt.clone())
2889        } else {
2890            None
2891        };
2892
2893        // Try signing with both, just in case inputs are mixed
2894        // This is safe because BDK only signs inputs it controls
2895        self.vault_wallet
2896            .sign(&mut psbt, bdk_options.clone())
2897            .map_err(|e| format!("Vault signing failed: {e}"))?;
2898
2899        if let Some(payment_wallet) = &self.payment_wallet {
2900            payment_wallet
2901                .sign(&mut psbt, bdk_options)
2902                .map_err(|e| format!("Payment signing failed: {e}"))?;
2903        }
2904
2905        // CUSTOM SCRIPT-PATH SIGNING for Inscription Reveal Inputs
2906        // BDK's standard signer only signs inputs where the key's fingerprint matches the wallet.
2907        // For inscription reveals, the backend sets tap_key_origins with an empty fingerprint,
2908        // so BDK skips them. We manually sign these inputs if the key matches our ordinals key.
2909        self.sign_inscription_script_paths(&mut psbt, should_finalize, inputs_to_sign.as_deref())?;
2910
2911        // If specific inputs were requested, revert the others
2912        if let Some(indices) = inputs_to_sign.as_ref() {
2913            // Safe unwrap because we created it above if inputs_to_sign is Some
2914            let original = original_psbt
2915                .as_ref()
2916                .ok_or_else(|| "Security Violation: missing original PSBT snapshot".to_string())?;
2917            for (i, input) in psbt.inputs.iter_mut().enumerate() {
2918                if !indices.contains(&i) {
2919                    *input = original.inputs[i].clone();
2920                }
2921            }
2922        }
2923
2924        if let Some(indices) = inputs_to_sign.as_ref() {
2925            let original = original_psbt
2926                .as_ref()
2927                .ok_or_else(|| "Security Violation: missing original PSBT snapshot".to_string())?;
2928            for index in indices {
2929                let before = &original.inputs[*index];
2930                let after = &psbt.inputs[*index];
2931
2932                let signature_changed = before.tap_key_sig != after.tap_key_sig
2933                    || before.tap_script_sigs != after.tap_script_sigs
2934                    || before.partial_sigs != after.partial_sigs
2935                    || before.final_script_witness != after.final_script_witness;
2936
2937                if !signature_changed {
2938                    return Err(format!(
2939                        "Security Violation: Requested input #{index} was not signed by this wallet"
2940                    ));
2941                }
2942            }
2943        }
2944
2945        // Validation: Verify all requested inputs were signed
2946
2947        let signed_bytes = psbt.serialize();
2948        let signed_base64 = base64::engine::general_purpose::STANDARD.encode(&signed_bytes);
2949
2950        Ok(signed_base64)
2951    }
2952
2953    /// Prepare a PSBT for external signing (e.g. on a hardware wallet).
2954    ///
2955    /// Performs the pre-sign checks and enrichment that `sign_psbt` does
2956    /// (UTXO enrichment, sighash validation, bounds checks, Ordinal Shield),
2957    /// then returns the enriched PSBT as base64 for device signing.
2958    pub fn prepare_external_sign_psbt(
2959        &self,
2960        psbt_base64: &str,
2961        options: Option<SignOptions>,
2962    ) -> Result<String, String> {
2963        use base64::Engine;
2964
2965        let psbt_bytes = base64::engine::general_purpose::STANDARD
2966            .decode(psbt_base64)
2967            .map_err(|e| format!("Invalid base64: {e}"))?;
2968
2969        let mut psbt = Psbt::deserialize(&psbt_bytes).map_err(|e| format!("Invalid PSBT: {e}"))?;
2970
2971        use std::collections::HashMap;
2972        let mut known_utxos = HashMap::new();
2973
2974        let collect_utxos = |w: &Wallet, map: &mut HashMap<bitcoin::OutPoint, bitcoin::TxOut>| {
2975            for utxo in w.list_unspent() {
2976                map.insert(utxo.outpoint, utxo.txout);
2977            }
2978        };
2979
2980        collect_utxos(&self.vault_wallet, &mut known_utxos);
2981        if let Some(w) = &self.payment_wallet {
2982            collect_utxos(w, &mut known_utxos);
2983        }
2984
2985        for (i, input) in psbt.inputs.iter_mut().enumerate() {
2986            if input.witness_utxo.is_none() && input.non_witness_utxo.is_none() {
2987                let outpoint = psbt.unsigned_tx.input[i].previous_output;
2988                if let Some(txout) = known_utxos.get(&outpoint) {
2989                    input.witness_utxo = Some(txout.clone());
2990                }
2991            }
2992        }
2993
2994        #[allow(deprecated)]
2995        let _ = self
2996            .vault_wallet
2997            .sign(&mut psbt, bdk_wallet::SignOptions::default());
2998        if let Some(w) = &self.payment_wallet {
2999            #[allow(deprecated)]
3000            let _ = w.sign(&mut psbt, bdk_wallet::SignOptions::default());
3001        }
3002
3003        if let Some(opts) = &options {
3004            if let Some(sighash_u8) = opts.sighash {
3005                let target_sighash = bitcoin::psbt::PsbtSighashType::from_u32(sighash_u8 as u32);
3006                for input in psbt.inputs.iter_mut() {
3007                    input.sighash_type = Some(target_sighash);
3008                }
3009            }
3010        }
3011
3012        let inputs_to_sign = options.as_ref().and_then(|o| o.sign_inputs.clone());
3013        if let Some(indices) = inputs_to_sign.as_ref() {
3014            let mut seen = std::collections::HashSet::new();
3015            for index in indices {
3016                if *index >= psbt.inputs.len() {
3017                    return Err(format!(
3018                        "Security Violation: sign_inputs index {} is out of bounds for {} inputs",
3019                        index,
3020                        psbt.inputs.len()
3021                    ));
3022                }
3023                if !seen.insert(*index) {
3024                    return Err(format!(
3025                        "Security Violation: sign_inputs index {} is duplicated",
3026                        index
3027                    ));
3028                }
3029                let input = &psbt.inputs[*index];
3030                if input.witness_utxo.is_none() && input.non_witness_utxo.is_none() {
3031                    return Err(format!(
3032                        "Security Violation: Requested input #{} is missing UTXO metadata",
3033                        index
3034                    ));
3035                }
3036            }
3037        }
3038
3039        for (index, input) in psbt.inputs.iter().enumerate() {
3040            if let Some(sighash) = input.sighash_type {
3041                let value = sighash.to_u32();
3042                let base_type = value & 0x1f;
3043                let anyone_can_pay = (value & 0x80) != 0;
3044                let is_allowed_base = base_type == 0 || base_type == 1; // DEFAULT or ALL
3045
3046                if anyone_can_pay || !is_allowed_base {
3047                    return Err(format!(
3048                        "Security Violation: Sighash type is not allowed on input #{} (value={})",
3049                        index, value
3050                    ));
3051                }
3052            }
3053        }
3054
3055        let mut known_inscriptions: HashMap<(bitcoin::Txid, u32), Vec<(String, u64)>> =
3056            HashMap::with_capacity(self.inscriptions.len());
3057        for ins in &self.inscriptions {
3058            known_inscriptions
3059                .entry((ins.satpoint.outpoint.txid, ins.satpoint.outpoint.vout))
3060                .or_default()
3061                .push((ins.id.clone(), ins.satpoint.offset));
3062        }
3063        for items in known_inscriptions.values_mut() {
3064            items.sort_by_key(|(_, offset)| *offset);
3065        }
3066
3067        if let Err(e) = crate::ordinals::shield::audit_psbt(
3068            &psbt,
3069            &known_inscriptions,
3070            inputs_to_sign.as_deref(),
3071            self.vault_wallet.network(),
3072        ) {
3073            return Err(format!("Security Violation: {}", e));
3074        }
3075
3076        let prepared_bytes = psbt.serialize();
3077        Ok(base64::engine::general_purpose::STANDARD.encode(&prepared_bytes))
3078    }
3079
3080    /// Verify a PSBT that was signed externally (e.g. by a hardware wallet).
3081    ///
3082    /// Ensures unsigned transaction bytes are unchanged and only expected inputs
3083    /// gained signatures, then optionally finalizes and returns base64.
3084    pub fn verify_external_signed_psbt(
3085        &self,
3086        original_psbt_base64: &str,
3087        signed_psbt_base64: &str,
3088        required_input_indices: Option<&[usize]>,
3089        finalize: bool,
3090    ) -> Result<String, String> {
3091        use base64::Engine;
3092        use bitcoin::consensus::Encodable;
3093
3094        let decode = |b64: &str, label: &str| -> Result<Psbt, String> {
3095            let bytes = base64::engine::general_purpose::STANDARD
3096                .decode(b64)
3097                .map_err(|e| format!("Invalid base64 in {label}: {e}"))?;
3098            Psbt::deserialize(&bytes).map_err(|e| format!("Invalid PSBT in {label}: {e}"))
3099        };
3100
3101        let original = decode(original_psbt_base64, "original")?;
3102        let mut signed = decode(signed_psbt_base64, "signed")?;
3103
3104        let mut orig_tx_bytes = Vec::new();
3105        original
3106            .unsigned_tx
3107            .consensus_encode(&mut orig_tx_bytes)
3108            .map_err(|e| format!("Failed to encode original tx: {e}"))?;
3109
3110        let mut signed_tx_bytes = Vec::new();
3111        signed
3112            .unsigned_tx
3113            .consensus_encode(&mut signed_tx_bytes)
3114            .map_err(|e| format!("Failed to encode signed tx: {e}"))?;
3115
3116        if orig_tx_bytes != signed_tx_bytes {
3117            return Err(
3118                "Security Violation: Device returned a PSBT with a modified transaction. \
3119                 The unsigned_tx bytes do not match the original."
3120                    .to_string(),
3121            );
3122        }
3123
3124        let check_indices: Vec<usize> = required_input_indices
3125            .map(|v| v.to_vec())
3126            .unwrap_or_else(|| (0..signed.inputs.len()).collect());
3127
3128        for &idx in &check_indices {
3129            if idx >= signed.inputs.len() {
3130                return Err(format!(
3131                    "Security Violation: required input index {} is out of bounds",
3132                    idx
3133                ));
3134            }
3135
3136            let input = &signed.inputs[idx];
3137            let has_signature = input.tap_key_sig.is_some()
3138                || !input.tap_script_sigs.is_empty()
3139                || !input.partial_sigs.is_empty()
3140                || input.final_script_witness.is_some();
3141
3142            if !has_signature {
3143                return Err(format!(
3144                    "Security Violation: Required input #{} was not signed by the device",
3145                    idx
3146                ));
3147            }
3148        }
3149
3150        if required_input_indices.is_some() {
3151            let required_set: std::collections::HashSet<usize> =
3152                check_indices.iter().copied().collect();
3153
3154            for (i, (orig_input, signed_input)) in
3155                original.inputs.iter().zip(signed.inputs.iter()).enumerate()
3156            {
3157                if required_set.contains(&i) {
3158                    continue;
3159                }
3160
3161                let signatures_changed = orig_input.tap_key_sig != signed_input.tap_key_sig
3162                    || orig_input.tap_script_sigs != signed_input.tap_script_sigs
3163                    || orig_input.partial_sigs != signed_input.partial_sigs
3164                    || orig_input.final_script_witness != signed_input.final_script_witness;
3165
3166                if signatures_changed {
3167                    return Err(format!(
3168                        "Security Violation: Input #{} received an unauthorized signature \
3169                         (not in required_input_indices)",
3170                        i
3171                    ));
3172                }
3173            }
3174        }
3175
3176        if !finalize {
3177            // External multi-pass hardware signing can reuse the signed PSBT as input
3178            // for a subsequent pass (e.g. payment first, then taproot). Some device
3179            // SDKs infer "internal" inputs from derivation metadata and can be
3180            // confused by derivations added in prior passes. Clearing derivation
3181            // metadata here keeps the collected signatures while preventing
3182            // cross-pass account-type contamination.
3183            for input in signed.inputs.iter_mut() {
3184                input.bip32_derivation.clear();
3185                input.tap_key_origins.clear();
3186            }
3187        }
3188
3189        if finalize {
3190            for input in signed.inputs.iter_mut() {
3191                if let Some(sig) = input.tap_key_sig {
3192                    let mut witness = bitcoin::Witness::new();
3193                    witness.push(sig.to_vec());
3194                    input.final_script_witness = Some(witness);
3195                    input.tap_key_sig = None;
3196                    input.tap_internal_key = None;
3197                    input.tap_merkle_root = None;
3198                    input.tap_key_origins.clear();
3199                    input.witness_utxo = None;
3200                    input.sighash_type = None;
3201                } else if !input.partial_sigs.is_empty() {
3202                    if let Some((pubkey, sig)) = input.partial_sigs.iter().next() {
3203                        let mut witness = bitcoin::Witness::new();
3204                        witness.push(sig.to_vec());
3205                        witness.push(pubkey.to_bytes());
3206                        input.final_script_witness = Some(witness);
3207                        input.partial_sigs.clear();
3208                        input.bip32_derivation.clear();
3209                        input.witness_utxo = None;
3210                        input.sighash_type = None;
3211                    }
3212                }
3213            }
3214        }
3215
3216        let verified_bytes = signed.serialize();
3217        Ok(base64::engine::general_purpose::STANDARD.encode(&verified_bytes))
3218    }
3219
3220    /// Analyzes a PSBT for Ordinal Shield protection.
3221    /// Returns a JSON string containing the `AnalysisResult`.
3222    pub fn analyze_psbt(&self, psbt_base64: &str) -> Result<String, String> {
3223        // Use explicit path to avoid re-export issues if any
3224        use crate::ordinals::shield::analyze_psbt;
3225        use base64::Engine;
3226        use std::collections::HashMap;
3227
3228        // Decode PSBT
3229        let psbt_bytes = base64::engine::general_purpose::STANDARD
3230            .decode(psbt_base64)
3231            .map_err(|e| format!("Invalid base64: {e}"))?;
3232
3233        let mut psbt = match Psbt::deserialize(&psbt_bytes) {
3234            Ok(p) => p,
3235            Err(e) => {
3236                return Err(format!("Invalid PSBT: {e}"));
3237            }
3238        };
3239
3240        // ENRICHMENT STEP: Fill in missing witness_utxo from our own wallet if possible
3241        // This solves "Plain PSBT" issues where dApps don't include UTXO info
3242        let mut known_utxos = HashMap::new();
3243
3244        let collect_utxos = |w: &Wallet, map: &mut HashMap<bitcoin::OutPoint, bitcoin::TxOut>| {
3245            for utxo in w.list_unspent() {
3246                map.insert(utxo.outpoint, utxo.txout);
3247            }
3248        };
3249
3250        collect_utxos(&self.vault_wallet, &mut known_utxos);
3251        if let Some(w) = &self.payment_wallet {
3252            collect_utxos(w, &mut known_utxos);
3253        }
3254
3255        for (i, input) in psbt.inputs.iter_mut().enumerate() {
3256            if input.witness_utxo.is_none() && input.non_witness_utxo.is_none() {
3257                let outpoint = psbt.unsigned_tx.input[i].previous_output;
3258                if let Some(txout) = known_utxos.get(&outpoint) {
3259                    input.witness_utxo = Some(txout.clone());
3260                }
3261            }
3262        }
3263
3264        // Build Known Inscriptions Map from internal state
3265        // Map: (Txid, Vout) -> Vec<(InscriptionID, Offset)>
3266        let mut known_inscriptions: HashMap<(bitcoin::Txid, u32), Vec<(String, u64)>> =
3267            HashMap::with_capacity(self.inscriptions.len());
3268
3269        // We also need a way to map offsets back to Inscription IDs for the result?
3270        // The `analyze_psbt` function currently generates keys like "Inscription {N}".
3271        // Wait, I should probably pass the IDs or handle the mapping better.
3272        // My implementation in `shield.rs` generates keys.
3273        // Ideally, `analyze_psbt` should take `HashMap<(Txid, u32), Vec<(u64, String)>>` so it knows the IDs!
3274        // But for now, let's look at `shield.rs`. It iterates and pushes to `active_inscriptions`.
3275        // The `known_inscriptions` map is just `Vec<u66>`.
3276        // This is a limitation of my current `shield.rs` implementation.
3277        // I should update `shield.rs` to take IDs if I want the frontend to know *which* inscription is being burned.
3278        // BUT, `shield.rs` is already tested and working with the simplified map.
3279        // PROPOSAL: Since `shield.rs` generates opaque keys, I should stick to that for V1 reliability.
3280        // Actually, if I pass a map of `(Txid, Vout) -> Vec<u64>`, I lose the ID association.
3281        // BUT `self.inscriptions` has the ID.
3282
3283        // Optimization: Let's rely on the assumption that mapping order is consistent.
3284        // However, `shield.rs` uses `known_inscriptions.get(...)` which returns a Vec of offsets.
3285        // If I want the frontend to show specific inscription images, I need the IDs.
3286
3287        // Let's stick to the current implementation for now. The frontend can re-derive or we just warn "An inscription".
3288        // Actually, for TDD I implemented it simply.
3289        // Real user needs to know WHICH inscription.
3290
3291        for ins in &self.inscriptions {
3292            known_inscriptions
3293                .entry((ins.satpoint.outpoint.txid, ins.satpoint.outpoint.vout))
3294                .or_default()
3295                .push((ins.id.clone(), ins.satpoint.offset));
3296        }
3297
3298        // Sort offsets for deterministic behavior
3299        for items in known_inscriptions.values_mut() {
3300            items.sort_by_key(|(_, offset)| *offset);
3301        }
3302
3303        let result = match analyze_psbt(&psbt, &known_inscriptions, self.vault_wallet.network()) {
3304            Ok(r) => r,
3305            Err(e) => {
3306                return Err(e.to_string());
3307            }
3308        };
3309
3310        serde_json::to_string(&result).map_err(|e| e.to_string())
3311    }
3312
3313    /// Broadcast a signed PSBT to the network.
3314    /// Returns the transaction ID (txid) as a hex string.
3315    pub async fn broadcast(
3316        &mut self,
3317        signed_psbt_base64: &str,
3318        esplora_url: &str,
3319    ) -> Result<String, String> {
3320        use base64::Engine;
3321
3322        // Decode PSBT from base64
3323        let psbt_bytes = base64::engine::general_purpose::STANDARD
3324            .decode(signed_psbt_base64)
3325            .map_err(|e| format!("Invalid base64: {e}"))?;
3326
3327        let psbt = Psbt::deserialize(&psbt_bytes).map_err(|e| format!("Invalid PSBT: {e}"))?;
3328
3329        // Extract the finalized transaction
3330        let tx: Transaction = psbt
3331            .extract_tx()
3332            .map_err(|e| format!("Failed to extract tx: {e}"))?;
3333
3334        // Broadcast via Esplora
3335        let client = esplora_client::Builder::new(esplora_url.trim_end_matches('/'))
3336            .build_async_with_sleeper::<SyncSleeper>()
3337            .map_err(|e| format!("Failed to create client: {e:?}"))?;
3338
3339        let broadcast_res: Result<(), _> = client.broadcast(&tx).await;
3340
3341        broadcast_res.map_err(|e| format!("Broadcast failed: {e}"))?;
3342
3343        Ok(tx.compute_txid().to_string())
3344    }
3345
3346    /// Sign a message with the private key corresponding to the given address.
3347    /// Supports both Vault (Taproot) and Payment (`SegWit`) addresses.
3348    pub fn sign_message(&self, address: &str, message: &str) -> Result<String, String> {
3349        use base64::Engine;
3350        use bitcoin::hashes::Hash;
3351        use bitcoin::secp256k1::{Message, Secp256k1};
3352
3353        if let Some(watched) = self.watched_address() {
3354            if watched.to_string() == address {
3355                let _ = message;
3356                return Err(ZincError::CapabilityMissing.to_string());
3357            }
3358        }
3359
3360        let owned = self
3361            .find_owned_address(address, DAPP_SIGN_ADDRESS_SEARCH_DEPTH)
3362            .ok_or_else(|| "Address not found in wallet".to_string())?;
3363
3364        // 2. Derive Key
3365        let secp = Secp256k1::new();
3366        let chain = match owned.keychain {
3367            KeychainKind::External => 0,
3368            KeychainKind::Internal => 1,
3369        };
3370        let priv_key = self
3371            .derive_private_key_internal(
3372                owned.purpose,
3373                self.active_derivation_account(),
3374                chain,
3375                owned.absolute_index,
3376            )
3377            .map_err(|_| ZincError::CapabilityMissing.to_string())?;
3378
3379        // 3. Sign Message
3380        let signature_hash = bitcoin::sign_message::signed_msg_hash(message);
3381        let msg = Message::from_digest(signature_hash.to_byte_array());
3382
3383        let sig = secp.sign_ecdsa_recoverable(&msg, &priv_key);
3384        let (rec_id, sig_bytes_compact) = sig.serialize_compact();
3385
3386        let mut header = 27 + u8::try_from(rec_id.to_i32()).unwrap();
3387        header += 4; // Always compressed
3388
3389        let mut sig_bytes = Vec::with_capacity(65);
3390        sig_bytes.push(header);
3391        sig_bytes.extend_from_slice(&sig_bytes_compact);
3392
3393        Ok(base64::engine::general_purpose::STANDARD.encode(&sig_bytes))
3394    }
3395
3396    /// Sign a message as a BIP-322 simple signature and return the witness bytes as hex.
3397    pub fn sign_bip322_simple_hex(&self, address: &str, message: &str) -> Result<String, String> {
3398        use bitcoin::PrivateKey;
3399
3400        if let Some(watched) = self.watched_address() {
3401            if watched.to_string() == address {
3402                let _ = message;
3403                return Err(ZincError::CapabilityMissing.to_string());
3404            }
3405        }
3406
3407        let active_receive_index = self.active_receive_index();
3408        let vault_addr = self
3409            .vault_wallet
3410            .peek_address(KeychainKind::External, active_receive_index)
3411            .address
3412            .to_string();
3413
3414        let (is_vault, is_payment) = if address == vault_addr {
3415            (true, false)
3416        } else if let Some(w) = &self.payment_wallet {
3417            let pay_addr = w
3418                .peek_address(KeychainKind::External, active_receive_index)
3419                .address
3420                .to_string();
3421            (false, address == pay_addr)
3422        } else {
3423            (false, false)
3424        };
3425
3426        if !is_vault && !is_payment {
3427            return Err("Address not found in wallet".to_string());
3428        }
3429
3430        let (purpose, chain) = if is_vault {
3431            (86, 0)
3432        } else {
3433            (self.dual_payment_purpose(), 0)
3434        };
3435        let secret_key = self
3436            .derive_private_key(purpose, chain, 0)
3437            .map_err(|_| ZincError::CapabilityMissing.to_string())?;
3438        let network = self.vault_wallet.network();
3439        let private_key = PrivateKey::new(secret_key, network);
3440        let witness = bip322::sign_simple(
3441            &address
3442                .parse::<bitcoin::Address<bitcoin::address::NetworkUnchecked>>()
3443                .map_err(|e| format!("invalid address: {e}"))?
3444                .require_network(network)
3445                .map_err(|e| format!("address network mismatch: {e}"))?,
3446            message,
3447            private_key,
3448        )
3449        .map_err(|e| format!("failed to sign BIP-322 message: {e}"))?;
3450        let bytes = bitcoin::consensus::serialize(&witness);
3451        Ok(hex::encode(bytes))
3452    }
3453
3454    /// Derive the pairing signer secret key hex for this account.
3455    ///
3456    /// Uses the first taproot external key path: `m/86'/coin'/account'/0/0`.
3457    pub fn get_pairing_secret_key_hex(&self) -> Result<String, String> {
3458        let key = self.derive_private_key(86, 0, 0)?;
3459        Ok(hex::encode(key.secret_bytes()))
3460    }
3461    /// Derive the taproot public key for this account at `index`.
3462    pub fn get_taproot_public_key(&self, index: u32) -> Result<String, String> {
3463        self.derive_public_key(86, index)
3464    }
3465
3466    /// Derive the payment public key for this account at `index`.
3467    ///
3468    /// In unified mode this uses the same key family as taproot.
3469    pub fn get_payment_public_key(&self, index: u32) -> Result<String, String> {
3470        // Dual uses the selected payment family, unified mirrors taproot.
3471        let purpose = if self.scheme == AddressScheme::Dual {
3472            self.dual_payment_purpose()
3473        } else {
3474            86
3475        };
3476        self.derive_public_key(purpose, index)
3477    }
3478
3479    fn derive_public_key(&self, purpose: u32, index: u32) -> Result<String, String> {
3480        let account = self.active_derivation_account();
3481        let effective_index = self.active_receive_index().saturating_add(index);
3482        self.derive_public_key_internal(
3483            purpose,
3484            self.vault_wallet.network(),
3485            account,
3486            effective_index,
3487        )
3488    }
3489
3490    fn derive_private_key(
3491        &self,
3492        purpose: u32,
3493        chain: u32,
3494        index: u32,
3495    ) -> Result<bitcoin::secp256k1::SecretKey, String> {
3496        let account = self.active_derivation_account();
3497        let effective_index = self.active_receive_index().saturating_add(index);
3498        self.derive_private_key_internal(purpose, account, chain, effective_index)
3499    }
3500
3501    fn derive_private_key_internal(
3502        &self,
3503        purpose: u32,
3504        account: u32,
3505        chain: u32,
3506        index: u32,
3507    ) -> Result<bitcoin::secp256k1::SecretKey, String> {
3508        use bitcoin::secp256k1::Secp256k1;
3509        let secp = Secp256k1::new();
3510
3511        match &self.kind {
3512            WalletKind::Seed { master_xprv } => {
3513                let network = self.vault_wallet.network();
3514                let coin_type = u32::from(network != Network::Bitcoin);
3515
3516                let derivation_path = [
3517                    // SECURITY: return an error instead of panicking if a derivation index
3518                    // exceeds the BIP-32 hardened/normal bound (2^31). A dynamic/untrusted
3519                    // index would otherwise abort the WASM runtime (DoS).
3520                    bdk_wallet::bitcoin::bip32::ChildNumber::from_hardened_idx(purpose)
3521                        .map_err(|e| format!("Invalid purpose index: {e}"))?,
3522                    bdk_wallet::bitcoin::bip32::ChildNumber::from_hardened_idx(coin_type)
3523                        .map_err(|e| format!("Invalid coin_type index: {e}"))?,
3524                    bdk_wallet::bitcoin::bip32::ChildNumber::from_hardened_idx(account)
3525                        .map_err(|e| format!("Invalid account index: {e}"))?,
3526                    bdk_wallet::bitcoin::bip32::ChildNumber::from_normal_idx(chain)
3527                        .map_err(|e| format!("Invalid chain index: {e}"))?,
3528                    bdk_wallet::bitcoin::bip32::ChildNumber::from_normal_idx(index)
3529                        .map_err(|e| format!("Invalid index: {e}"))?,
3530                ];
3531
3532                let child_xprv = master_xprv
3533                    .derive_priv(&secp, &derivation_path)
3534                    .map_err(|e| format!("Key derivation failed: {e}"))?;
3535
3536                Ok(child_xprv.private_key)
3537            }
3538            _ => Err("Private key derivation not supported for this wallet kind".to_string()),
3539        }
3540    }
3541
3542    fn sign_inscription_script_paths(
3543        &self,
3544        psbt: &mut Psbt,
3545        finalize: bool,
3546        indices: Option<&[usize]>,
3547    ) -> Result<(), String> {
3548        use bitcoin::secp256k1::{Message, Secp256k1};
3549        use bitcoin::sighash::{Prevouts, SighashCache};
3550
3551        let secp = Secp256k1::new();
3552        let network = self.vault_wallet.network();
3553
3554        // 1. Collect all prevouts for sighash calculation
3555        let mut prevouts = Vec::with_capacity(psbt.unsigned_tx.input.len());
3556        for (i, input) in psbt.inputs.iter().enumerate() {
3557            let utxo = input
3558                .witness_utxo
3559                .as_ref()
3560                .or_else(|| {
3561                    input.non_witness_utxo.as_ref().and_then(|tx| {
3562                        tx.output
3563                            .get(psbt.unsigned_tx.input[i].previous_output.vout as usize)
3564                    })
3565                })
3566                .ok_or_else(|| format!("Missing witness_utxo for input #{i}"))?;
3567            prevouts.push(utxo.clone());
3568        }
3569        let prevouts_all = Prevouts::All(&prevouts);
3570
3571        // 2. Iterate inputs and sign matches
3572        for i in 0..psbt.inputs.len() {
3573            if let Some(allowed) = indices {
3574                if !allowed.contains(&i) {
3575                    continue;
3576                }
3577            }
3578
3579            let input = &mut psbt.inputs[i];
3580            if input.tap_key_sig.is_some() || !input.tap_script_sigs.is_empty() {
3581                continue; // Already signed
3582            }
3583
3584            // Check if this is an inscription reveal (tap_key_origins with empty fingerprint)
3585            let mut key_found = false;
3586            for (pubkey, (_, origin)) in &input.tap_key_origins {
3587                // Heuristic: Reveal inputs use the internal key directly in the script path
3588                // and often have an empty fingerprint [0,0,0,0] in the PSBT origin.
3589                if *origin.0.as_bytes() == [0, 0, 0, 0] {
3590                    // Try to derive the ordinals key (m/86'/coin'/account'/0/0)
3591                    let account = self.active_derivation_account();
3592                    let effective_index = self.active_receive_index();
3593                    if let Ok(derived_pubkey_hex) =
3594                        self.derive_public_key_internal(86, network, account, effective_index)
3595                    {
3596                        if pubkey.to_string() == derived_pubkey_hex {
3597                            // MATCH! Sign it.
3598                            let priv_key = self.derive_private_key(86, 0, 0)?;
3599
3600                            let mut cache = SighashCache::new(&psbt.unsigned_tx);
3601                            let sighash_type = input
3602                                .sighash_type
3603                                .unwrap_or(bitcoin::psbt::PsbtSighashType::from_u32(0)); // DEFAULT
3604
3605                            // For reveal, we sign the script path.
3606                            for (_control_block, (script, _)) in &input.tap_scripts {
3607                                let leaf_hash = bitcoin::taproot::TapLeafHash::from_script(
3608                                    script,
3609                                    bitcoin::taproot::LeafVersion::TapScript,
3610                                );
3611
3612                                // Convert PsbtSighashType to TapSighashType
3613                                let tap_sighash_type = match sighash_type.to_u32() {
3614                                    0 => bitcoin::sighash::TapSighashType::Default,
3615                                    1 => bitcoin::sighash::TapSighashType::All,
3616                                    2 => bitcoin::sighash::TapSighashType::None,
3617                                    3 => bitcoin::sighash::TapSighashType::Single,
3618                                    0x81 => bitcoin::sighash::TapSighashType::AllPlusAnyoneCanPay,
3619                                    0x82 => bitcoin::sighash::TapSighashType::NonePlusAnyoneCanPay,
3620                                    0x83 => {
3621                                        bitcoin::sighash::TapSighashType::SinglePlusAnyoneCanPay
3622                                    }
3623                                    _ => bitcoin::sighash::TapSighashType::Default,
3624                                };
3625
3626                                let sighash = cache
3627                                    .taproot_script_spend_signature_hash(
3628                                        i,
3629                                        &prevouts_all,
3630                                        leaf_hash,
3631                                        tap_sighash_type,
3632                                    )
3633                                    .map_err(|e| format!("Sighash calculation failed: {e}"))?;
3634
3635                                let msg = Message::from_digest(sighash.to_byte_array());
3636                                let sig = secp.sign_schnorr(&msg, &priv_key.keypair(&secp));
3637
3638                                let mut final_sig = sig.as_ref().to_vec();
3639                                if tap_sighash_type != bitcoin::sighash::TapSighashType::Default {
3640                                    final_sig.push(tap_sighash_type as u8);
3641                                }
3642
3643                                // SECURITY: avoid panic if the assembled taproot signature
3644                                // is malformed; surface an error instead of aborting.
3645                                let tap_sig =
3646                                    bitcoin::taproot::Signature::from_slice(&final_sig)
3647                                        .map_err(|e| format!("Invalid taproot signature: {e}"))?;
3648                                input.tap_script_sigs.insert((*pubkey, leaf_hash), tap_sig);
3649                                key_found = true;
3650                            }
3651                        }
3652                    }
3653                }
3654            }
3655
3656            if key_found && finalize {
3657                // Note: Full script-path finalization is complex (needs script + control block).
3658                // We leave it to the dApp or BDK if possible, or implement minimal reveal finalizer here.
3659            }
3660        }
3661
3662        Ok(())
3663    }
3664
3665    /// Return recently revealed addresses for the given keychain.
3666    pub fn get_revealed_addresses(&self, keychain: KeychainKind) -> Vec<String> {
3667        let wallet = match keychain {
3668            KeychainKind::External => &self.vault_wallet,
3669            KeychainKind::Internal => &self.vault_wallet,
3670        };
3671        wallet
3672            .list_unused_addresses(keychain)
3673            .into_iter()
3674            .map(|info| info.address.to_string())
3675            .collect()
3676    }
3677
3678    fn flexible_full_scan_request(
3679        wallet: &Wallet,
3680        _policy: ScanPolicy,
3681        now: u64,
3682    ) -> FullScanRequest<KeychainKind> {
3683        // Proper BIP-32 gap-limit scan. `start_full_scan_at` pre-loads each keychain's UNBOUNDED spk
3684        // iterator (via `spks_from_indexer`), so `client.full_scan(req, stop_gap, ..)` walks addresses
3685        // until `stop_gap` consecutive unused — essential for multi-address HD wallets like Sparrow
3686        // (a fresh address per receive). We use the explicit `start_time` variant to avoid the
3687        // std-time panic on wasm32-unknown-unknown.
3688        //
3689        // Do NOT call `spks_for_keychain` here: it REPLACES the unbounded iterator with a finite list,
3690        // which previously (with address_scan_depth=1) capped the scan to address index 0 — so only
3691        // funds on the first address were ever discovered.
3692        wallet.start_full_scan_at(now).build()
3693    }
3694
3695    /// Build an incremental sync request over the wallet's already-revealed spks. Much cheaper than a
3696    /// full scan (it doesn't walk the gap), so it's the default for routine syncs. Uses the explicit
3697    /// `start_time` variant to avoid the std-time panic on wasm32-unknown-unknown.
3698    fn flexible_sync_request(wallet: &Wallet, now: u64) -> SyncRequest<(KeychainKind, u32)> {
3699        wallet.start_sync_with_revealed_spks_at(now).build()
3700    }
3701}
3702
3703/// Builder for constructing a `ZincWallet` from identity, network, and options.
3704impl WalletBuilder {
3705    /// Create a new builder for the specified network.
3706    #[must_use]
3707    pub fn new(network: Network) -> Self {
3708        Self {
3709            network,
3710            kind: None,
3711            mode: ProfileMode::Seed,
3712            scheme: AddressScheme::Unified,
3713            derivation_mode: DerivationMode::Account,
3714            payment_address_type: PaymentAddressType::NativeSegwit,
3715            persistence: None,
3716            account_index: 0,
3717            scan_policy: ScanPolicy::default(),
3718        }
3719    }
3720
3721    /// Shortcut for creating a builder from a mnemonic phrase.
3722    pub fn from_mnemonic(network: Network, mnemonic: &ZincMnemonic) -> Self {
3723        use bdk_wallet::bitcoin::bip32::Xpriv;
3724        let seed = mnemonic.to_seed("");
3725        let master_xprv = Xpriv::new_master(network, seed.as_ref()).expect("valid seed");
3726        Self::new(network).kind(WalletKind::Seed { master_xprv })
3727    }
3728
3729    /// Shortcut for creating a builder from a seed (used by discovery).
3730    pub fn from_seed(network: Network, seed: Seed64) -> Self {
3731        use bdk_wallet::bitcoin::bip32::Xpriv;
3732        let master_xprv = Xpriv::new_master(network, seed.as_ref()).expect("valid seed");
3733        Self::new(network).kind(WalletKind::Seed { master_xprv })
3734    }
3735
3736    /// Shortcut for creating a builder for watch-only profiles.
3737    pub fn from_watch_only(network: Network) -> Self {
3738        Self::new(network).mode(ProfileMode::Watch)
3739    }
3740
3741    /// Set the watch address for single-address watch profiles.
3742    pub fn with_watch_address(mut self, address: &str) -> Result<Self, String> {
3743        let addr = address
3744            .parse::<Address<NetworkUnchecked>>()
3745            .map_err(|e| format!("Invalid address: {e}"))?
3746            .require_network(self.network)
3747            .map_err(|e| format!("Network mismatch: {e}"))?;
3748
3749        if addr.address_type() != Some(AddressType::P2tr) {
3750            return Err(
3751                "Address watch mode currently supports taproot (bc1p/tb1p/bcrt1p) addresses only"
3752                    .to_string(),
3753            );
3754        }
3755
3756        self.kind = Some(WalletKind::WatchAddress(addr));
3757        Ok(self)
3758    }
3759
3760    /// Set the account xpub for watch-only profiles.
3761    pub fn with_xpub(mut self, xpub: &str) -> Result<Self, String> {
3762        let parsed = parse_extended_public_key(xpub)?;
3763        let taproot_desc = format!("tr({parsed}/0/*)");
3764        let payment_desc = payment_descriptor_for_xpub(&parsed, self.payment_address_type, 0);
3765        self.kind = Some(WalletKind::Hardware {
3766            fingerprint: [0, 0, 0, 0],
3767            taproot_external: taproot_desc,
3768            payment_external: Some(payment_desc),
3769        });
3770        Ok(self)
3771    }
3772
3773    /// Set the explicit taproot account xpub for dual-account watch profiles.
3774    pub fn with_taproot_xpub(mut self, xpub: &str) -> Result<Self, String> {
3775        let parsed = parse_extended_public_key(xpub)?;
3776        let taproot_desc = format!("tr({parsed}/0/*)");
3777
3778        let mut kind = self.kind.take().unwrap_or(WalletKind::Hardware {
3779            fingerprint: [0, 0, 0, 0],
3780            taproot_external: String::new(),
3781            payment_external: None,
3782        });
3783
3784        if let WalletKind::Hardware {
3785            ref mut taproot_external,
3786            ..
3787        } = kind
3788        {
3789            *taproot_external = taproot_desc;
3790        }
3791
3792        self.kind = Some(kind);
3793        Ok(self)
3794    }
3795
3796    /// Set the explicit payment account xpub for dual-account watch profiles.
3797    pub fn with_payment_xpub(mut self, xpub: &str) -> Result<Self, String> {
3798        let parsed = parse_extended_public_key(xpub)?;
3799        let payment_desc = payment_descriptor_for_xpub(&parsed, self.payment_address_type, 0);
3800
3801        let mut kind = self.kind.take().unwrap_or(WalletKind::Hardware {
3802            fingerprint: [0, 0, 0, 0],
3803            taproot_external: String::new(),
3804            payment_external: None,
3805        });
3806
3807        if let WalletKind::Hardware {
3808            ref mut payment_external,
3809            ..
3810        } = kind
3811        {
3812            *payment_external = Some(payment_desc);
3813        }
3814
3815        self.kind = Some(kind);
3816        Ok(self)
3817    }
3818
3819    /// Set the wallet's cryptographic identity.
3820    #[must_use]
3821    pub fn kind(mut self, kind: WalletKind) -> Self {
3822        self.kind = Some(kind);
3823        self
3824    }
3825
3826    /// Set the operational mode (`seed` vs `watch`).
3827    #[must_use]
3828    pub fn mode(mut self, mode: ProfileMode) -> Self {
3829        self.mode = mode;
3830        self
3831    }
3832
3833    /// Set the address scheme (`unified` vs `dual`).
3834    #[must_use]
3835    pub fn with_scheme(mut self, scheme: AddressScheme) -> Self {
3836        self.scheme = scheme;
3837        self
3838    }
3839
3840    /// Set the account derivation mode (`account` vs `index`).
3841    #[must_use]
3842    pub fn with_derivation_mode(mut self, mode: DerivationMode) -> Self {
3843        self.derivation_mode = mode;
3844        self
3845    }
3846
3847    /// Set the payment address type for dual-scheme wallets.
3848    #[must_use]
3849    pub fn with_payment_address_type(mut self, address_type: PaymentAddressType) -> Self {
3850        self.payment_address_type = address_type;
3851        self
3852    }
3853
3854    /// Set the account index to use for derivation.
3855    #[must_use]
3856    pub fn with_account_index(mut self, index: u32) -> Self {
3857        self.account_index = index;
3858        self
3859    }
3860
3861    /// Set the scan policy for address discovery.
3862    #[must_use]
3863    pub fn scan_policy(mut self, policy: ScanPolicy) -> Self {
3864        self.scan_policy = policy;
3865        self
3866    }
3867
3868    /// Set the persistence snapshot to load.
3869    pub fn with_persistence(mut self, persistence_json: &str) -> Result<Self, String> {
3870        let persistence: ZincPersistence = serde_json::from_str(persistence_json)
3871            .map_err(|e| format!("Failed to parse persistence JSON: {e}"))?;
3872        self.persistence = Some(persistence);
3873        Ok(self)
3874    }
3875
3876    /// Set the persistence snapshot to load directly.
3877    #[must_use]
3878    pub fn persistence(mut self, persistence: ZincPersistence) -> Self {
3879        self.persistence = Some(persistence);
3880        self
3881    }
3882
3883    /// Build the `ZincWallet` instance.
3884    pub fn build(self) -> Result<ZincWallet, String> {
3885        let kind = self
3886            .kind
3887            .ok_or_else(|| "Wallet identity must be set".to_string())?;
3888
3889        let mut scheme = self.scheme;
3890        if matches!(kind, WalletKind::WatchAddress(_)) {
3891            if scheme == AddressScheme::Dual {
3892                return Err("Address watch profiles support unified scheme only".to_string());
3893            }
3894            scheme = AddressScheme::Unified;
3895        }
3896
3897        let derivation_account = match self.derivation_mode {
3898            DerivationMode::Account => self.account_index,
3899            DerivationMode::Index => 0,
3900        };
3901
3902        let (vault_ext, vault_int, payment_ext, payment_int) = kind.derive_descriptors(
3903            scheme,
3904            self.payment_address_type,
3905            self.network,
3906            derivation_account,
3907        );
3908
3909        // 1. Vault (Taproot) wallet
3910        let (vault_wallet, loaded_vault_changeset) = if let Some(p) = &self.persistence {
3911            if let Some(changeset) = &p.taproot {
3912                let mut loader =
3913                    Wallet::load().descriptor(KeychainKind::External, Some(vault_ext.clone()));
3914
3915                if !matches!(kind, WalletKind::WatchAddress(_)) {
3916                    loader = loader.descriptor(KeychainKind::Internal, Some(vault_int.clone()));
3917                }
3918
3919                let res = loader
3920                    .extract_keys()
3921                    .load_wallet_no_persist(changeset.clone());
3922
3923                match res {
3924                    Ok(Some(w)) => (w, changeset.clone()),
3925                    Ok(None) | Err(_) => {
3926                        let creator = if matches!(kind, WalletKind::WatchAddress(_)) {
3927                            Wallet::create_single(vault_ext)
3928                        } else {
3929                            Wallet::create(vault_ext, vault_int)
3930                        };
3931                        let w = creator
3932                            .network(self.network)
3933                            .create_wallet_no_persist()
3934                            .map_err(|e| format!("Failed to create taproot wallet: {e}"))?;
3935                        (w, bdk_wallet::ChangeSet::default())
3936                    }
3937                }
3938            } else {
3939                let creator = if matches!(kind, WalletKind::WatchAddress(_)) {
3940                    Wallet::create_single(vault_ext)
3941                } else {
3942                    Wallet::create(vault_ext, vault_int)
3943                };
3944                let w = creator
3945                    .network(self.network)
3946                    .create_wallet_no_persist()
3947                    .map_err(|e| format!("Failed to create taproot wallet: {e}"))?;
3948                (w, bdk_wallet::ChangeSet::default())
3949            }
3950        } else {
3951            let creator = if matches!(kind, WalletKind::WatchAddress(_)) {
3952                Wallet::create_single(vault_ext)
3953            } else {
3954                Wallet::create(vault_ext, vault_int)
3955            };
3956            let w = creator
3957                .network(self.network)
3958                .create_wallet_no_persist()
3959                .map_err(|e| format!("Failed to create taproot wallet: {e}"))?;
3960            (w, bdk_wallet::ChangeSet::default())
3961        };
3962
3963        // 2. Payment wallet (optional, for dual-scheme)
3964        let (payment_wallet, loaded_payment_changeset) =
3965            if let (Some(pay_ext), Some(pay_int)) = (payment_ext, payment_int) {
3966                let (wallet, changeset) = if let Some(p) = &self.persistence {
3967                    if let Some(changeset) = &p.payment {
3968                        let res = Wallet::load()
3969                            .descriptor(KeychainKind::External, Some(pay_ext.clone()))
3970                            .descriptor(KeychainKind::Internal, Some(pay_int.clone()))
3971                            .extract_keys()
3972                            .load_wallet_no_persist(changeset.clone());
3973
3974                        match res {
3975                            Ok(Some(w)) => (w, Some(changeset.clone())),
3976                            Ok(None) | Err(_) => {
3977                                let w = Wallet::create(pay_ext, pay_int)
3978                                    .network(self.network)
3979                                    .create_wallet_no_persist()
3980                                    .map_err(|e| format!("Failed to create payment wallet: {e}"))?;
3981                                (w, None)
3982                            }
3983                        }
3984                    } else {
3985                        let w = Wallet::create(pay_ext, pay_int)
3986                            .network(self.network)
3987                            .create_wallet_no_persist()
3988                            .map_err(|e| format!("Failed to create payment wallet: {e}"))?;
3989                        (w, None)
3990                    }
3991                } else {
3992                    let w = Wallet::create(pay_ext, pay_int)
3993                        .network(self.network)
3994                        .create_wallet_no_persist()
3995                        .map_err(|e| format!("Failed to create payment wallet: {e}"))?;
3996                    (w, None)
3997                };
3998                (Some(wallet), changeset)
3999            } else {
4000                (None, None)
4001            };
4002
4003        Ok(ZincWallet {
4004            vault_wallet,
4005            payment_wallet,
4006            scheme: self.scheme,
4007            derivation_mode: self.derivation_mode,
4008            payment_address_type: self.payment_address_type,
4009            loaded_vault_changeset,
4010            loaded_payment_changeset,
4011            account_index: self.account_index,
4012            mode: self.mode,
4013            scan_policy: self.scan_policy,
4014            inscribed_utxos: std::collections::HashSet::default(),
4015            inscriptions: Vec::new(),
4016            rune_balances: Vec::new(),
4017            ordinals_verified: false,
4018            ordinals_metadata_complete: false,
4019            kind,
4020            is_syncing: false,
4021            account_generation: 0,
4022            force_next_full: false,
4023        })
4024    }
4025
4026    /// Build a hardware wallet profile from public descriptors.
4027    pub fn build_hardware(
4028        self,
4029        fingerprint_hex: &str,
4030        taproot_external_desc: String,
4031        taproot_internal_desc: String,
4032        payment_external_desc: Option<String>,
4033        payment_internal_desc: Option<String>,
4034    ) -> Result<ZincWallet, String> {
4035        let fingerprint_vec =
4036            hex::decode(fingerprint_hex).map_err(|e| format!("Invalid fingerprint hex: {e}"))?;
4037        let fingerprint: [u8; 4] = fingerprint_vec
4038            .try_into()
4039            .map_err(|_| "Fingerprint must be 4 bytes".to_string())?;
4040
4041        let network = self.network;
4042        let account_index = self.account_index;
4043        let persistence = self.persistence;
4044
4045        let scheme = if payment_external_desc.is_some() {
4046            AddressScheme::Dual
4047        } else {
4048            AddressScheme::Unified
4049        };
4050
4051        // 1. Vault (Taproot) wallet from public descriptors
4052        let (vault_wallet, loaded_vault_changeset) = if let Some(p) = &persistence {
4053            if let Some(changeset) = &p.taproot {
4054                let res = Wallet::load()
4055                    .descriptor(KeychainKind::External, Some(taproot_external_desc.clone()))
4056                    .descriptor(KeychainKind::Internal, Some(taproot_internal_desc.clone()))
4057                    .extract_keys()
4058                    .load_wallet_no_persist(changeset.clone());
4059
4060                match res {
4061                    Ok(Some(w)) => (w, changeset.clone()),
4062                    Ok(None) | Err(_) => {
4063                        let w = Wallet::create(
4064                            taproot_external_desc.clone(),
4065                            taproot_internal_desc.clone(),
4066                        )
4067                        .network(network)
4068                        .create_wallet_no_persist()
4069                        .map_err(|e| {
4070                            format!("Failed to create taproot wallet from descriptor: {e}")
4071                        })?;
4072                        (w, bdk_wallet::ChangeSet::default())
4073                    }
4074                }
4075            } else {
4076                let w =
4077                    Wallet::create(taproot_external_desc.clone(), taproot_internal_desc.clone())
4078                        .network(network)
4079                        .create_wallet_no_persist()
4080                        .map_err(|e| {
4081                            format!("Failed to create taproot wallet from descriptor: {e}")
4082                        })?;
4083                (w, bdk_wallet::ChangeSet::default())
4084            }
4085        } else {
4086            let w = Wallet::create(taproot_external_desc.clone(), taproot_internal_desc.clone())
4087                .network(network)
4088                .create_wallet_no_persist()
4089                .map_err(|e| format!("Failed to create taproot wallet from descriptor: {e}"))?;
4090            (w, bdk_wallet::ChangeSet::default())
4091        };
4092
4093        // 2. Payment wallet (optional, for dual-scheme)
4094        let (payment_wallet, loaded_payment_changeset) = if let (Some(pay_ext), Some(pay_int)) =
4095            (&payment_external_desc, &payment_internal_desc)
4096        {
4097            let (wallet, changeset) = if let Some(p) = &persistence {
4098                if let Some(changeset) = &p.payment {
4099                    let res = Wallet::load()
4100                        .descriptor(KeychainKind::External, Some(pay_ext.clone()))
4101                        .descriptor(KeychainKind::Internal, Some(pay_int.clone()))
4102                        .extract_keys()
4103                        .load_wallet_no_persist(changeset.clone());
4104
4105                    match res {
4106                        Ok(Some(w)) => (w, Some(changeset.clone())),
4107                        Ok(None) | Err(_) => {
4108                            let w = Wallet::create(pay_ext.clone(), pay_int.clone())
4109                                .network(network)
4110                                .create_wallet_no_persist()
4111                                .map_err(|e| {
4112                                    format!("Failed to create payment wallet from descriptor: {e}")
4113                                })?;
4114                            (w, None)
4115                        }
4116                    }
4117                } else {
4118                    let w = Wallet::create(pay_ext.clone(), pay_int.clone())
4119                        .network(network)
4120                        .create_wallet_no_persist()
4121                        .map_err(|e| {
4122                            format!("Failed to create payment wallet from descriptor: {e}")
4123                        })?;
4124                    (w, None)
4125                }
4126            } else {
4127                let w = Wallet::create(pay_ext.clone(), pay_int.clone())
4128                    .network(network)
4129                    .create_wallet_no_persist()
4130                    .map_err(|e| format!("Failed to create payment wallet from descriptor: {e}"))?;
4131                (w, None)
4132            };
4133            (Some(wallet), changeset)
4134        } else {
4135            (None, None)
4136        };
4137
4138        Ok(ZincWallet {
4139            vault_wallet,
4140            payment_wallet,
4141            scheme,
4142            derivation_mode: self.derivation_mode,
4143            payment_address_type: self.payment_address_type,
4144            loaded_vault_changeset,
4145            loaded_payment_changeset,
4146            account_index,
4147            mode: ProfileMode::Watch,
4148            scan_policy: self.scan_policy,
4149            inscribed_utxos: std::collections::HashSet::default(),
4150            inscriptions: Vec::new(),
4151            rune_balances: Vec::new(),
4152            ordinals_verified: false,
4153            ordinals_metadata_complete: false,
4154            kind: WalletKind::Hardware {
4155                fingerprint,
4156                taproot_external: taproot_external_desc,
4157                payment_external: payment_external_desc,
4158            },
4159            is_syncing: false,
4160            account_generation: 0,
4161            force_next_full: false,
4162        })
4163    }
4164}
4165
4166/// Serializable persistence snapshot for taproot/payment wallet changesets.
4167#[derive(Debug, Clone, Default, Serialize, Deserialize)]
4168pub struct ZincPersistence {
4169    /// Merged changeset for the taproot wallet.
4170    pub taproot: Option<bdk_wallet::ChangeSet>,
4171    /// Merged changeset for the optional payment wallet.
4172    pub payment: Option<bdk_wallet::ChangeSet>,
4173}
4174
4175#[cfg(test)]
4176mod tests {
4177    use super::*;
4178    use bitcoin::Network;
4179
4180    #[test]
4181    fn test_builder_basic() {
4182        use bdk_wallet::bitcoin::bip32::Xpriv;
4183        let mnemonic = ZincMnemonic::generate(12).unwrap();
4184        let seed = mnemonic.to_seed("");
4185        let master_xprv = Xpriv::new_master(Network::Signet, seed.as_ref()).expect("valid seed");
4186
4187        let wallet = WalletBuilder::new(Network::Signet)
4188            .kind(WalletKind::Seed { master_xprv })
4189            .build()
4190            .unwrap();
4191
4192        assert_eq!(wallet.vault_wallet.network(), Network::Signet);
4193        assert!(wallet.is_unified());
4194    }
4195
4196    #[test]
4197    fn flexible_full_scan_request_uses_explicit_start_time() {
4198        use bdk_wallet::bitcoin::bip32::Xpriv;
4199        let mnemonic = ZincMnemonic::generate(12).unwrap();
4200        let seed = mnemonic.to_seed("");
4201        let master_xprv = Xpriv::new_master(Network::Signet, seed.as_ref()).expect("valid seed");
4202        let wallet = WalletBuilder::new(Network::Signet)
4203            .kind(WalletKind::Seed { master_xprv })
4204            .build()
4205            .unwrap();
4206
4207        let explicit_start = 1_777_777_777_u64;
4208        let req = ZincWallet::flexible_full_scan_request(
4209            &wallet.vault_wallet,
4210            ScanPolicy::default(),
4211            explicit_start,
4212        );
4213        assert_eq!(req.start_time(), explicit_start);
4214    }
4215}