1use 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};
15use 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
23fn 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#[derive(Debug, Clone, Deserialize, Default)]
46#[serde(rename_all = "camelCase")]
47pub struct SignOptions {
48 pub sign_inputs: Option<Vec<usize>>,
50 pub sighash: Option<u8>,
52 #[serde(default)]
55 pub finalize: bool,
56}
57
58use zeroize::{Zeroize, ZeroizeOnDrop};
59
60#[derive(Debug, Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)]
62pub struct Seed64([u8; 64]);
63
64impl Seed64 {
65 #[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#[derive(Debug, Clone)]
94pub struct CreatePsbtRequest {
95 pub recipient: Address<NetworkUnchecked>,
97 pub amount: Amount,
99 pub fee_rate: FeeRate,
101}
102
103impl CreatePsbtRequest {
104 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#[derive(Debug, Clone, Deserialize, Serialize)]
126#[serde(rename_all = "camelCase")]
127pub struct CreatePsbtTransportRequest {
128 pub recipient: String,
130 pub amount_sats: u64,
132 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub enum AddressScheme {
147 Unified,
149 Dual,
151}
152
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
155#[serde(rename_all = "lowercase")]
156#[derive(Default)]
157pub enum PaymentAddressType {
158 #[default]
160 NativeSegwit,
161 NestedSegwit,
163 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
180#[serde(rename_all = "lowercase")]
181#[derive(Default)]
182pub enum DerivationMode {
183 #[default]
185 Account,
186 Index,
188}
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
192#[serde(rename_all = "lowercase")]
193pub enum ProfileMode {
194 Seed,
196 Watch,
198}
199
200#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
202#[serde(rename_all = "camelCase")]
203pub struct ScanPolicy {
204 pub account_gap_limit: u32,
206 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#[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#[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#[cfg(not(target_arch = "wasm32"))]
270pub type SyncSleeper = TokioSleeper;
271
272pub 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#[derive(Debug, Clone)]
290pub enum WalletKind {
291 Seed {
293 master_xprv: bdk_wallet::bitcoin::bip32::Xpriv,
295 },
296 Hardware {
298 fingerprint: [u8; 4],
300 taproot_external: String,
302 payment_external: Option<String>,
304 },
305 WatchAddress(Address),
307}
308
309impl WalletKind {
310 #[must_use]
312 pub fn is_watch(&self) -> bool {
313 !matches!(self, Self::Seed { .. })
314 }
315
316 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 (
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
366pub struct ZincWallet {
368 pub(crate) vault_wallet: Wallet,
372 pub(crate) payment_wallet: Option<Wallet>,
374 pub(crate) scheme: AddressScheme,
376 pub(crate) derivation_mode: DerivationMode,
378 pub(crate) payment_address_type: PaymentAddressType,
380 pub(crate) loaded_vault_changeset: bdk_wallet::ChangeSet,
383 pub(crate) loaded_payment_changeset: Option<bdk_wallet::ChangeSet>,
385 pub(crate) account_index: u32,
387 pub(crate) mode: ProfileMode,
389 pub(crate) scan_policy: ScanPolicy,
391 pub(crate) inscribed_utxos: std::collections::HashSet<bitcoin::OutPoint>,
394 pub(crate) inscriptions: Vec<crate::ordinals::types::Inscription>,
396 pub(crate) rune_balances: Vec<crate::ordinals::types::RuneBalance>,
398 pub(crate) ordinals_verified: bool,
400 pub(crate) ordinals_metadata_complete: bool,
402 pub(crate) kind: WalletKind,
404 #[allow(dead_code)]
406 pub(crate) is_syncing: bool,
407 pub(crate) account_generation: u64,
409 pub(crate) force_next_full: bool,
414}
415
416pub enum SyncRequestType {
418 Full(FullScanRequest<KeychainKind>),
420 Incremental(SyncRequest<(KeychainKind, u32)>),
422}
423
424pub struct ZincSyncRequest {
426 pub taproot: SyncRequestType,
428 pub payment: Option<SyncRequestType>,
430}
431
432#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
434pub struct ZincBalance {
435 pub total: bdk_wallet::Balance,
437 pub spendable: bdk_wallet::Balance,
439 pub display_spendable: bdk_wallet::Balance,
441 pub inscribed: u64,
443}
444
445const MIN_SALVAGE_PADDING_SATS: u64 = 546;
447const DEFAULT_DAPP_ADDRESS_SCAN_DEPTH: u32 = 20;
448const DAPP_SIGN_ADDRESS_SEARCH_DEPTH: u32 = 1000;
449
450#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
452#[serde(rename_all = "camelCase")]
453pub struct UtxoItem {
454 pub txid: String,
456 pub vout: u32,
458 pub value_sats: u64,
460 pub address: Option<String>,
462 pub keychain: String,
464 pub wallet_role: String,
466 pub confirmed: bool,
468 pub confirmations: u32,
470 pub is_inscribed: bool,
472 pub has_inscription: bool,
474 pub inscription_ids: Vec<String>,
476 pub inscription_offsets: Vec<u64>,
478 pub cardinal_salvageable_sats: u64,
480}
481
482#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
484#[serde(rename_all = "camelCase")]
485pub struct DappAddressCandidate {
486 pub address: String,
488 pub public_key: Option<String>,
490 pub address_type: String,
492 pub wallet_role: String,
494 pub keychain: String,
496 pub derivation_index: u32,
498 pub clean_btc_sats: u64,
500 pub clean_confirmed_btc_sats: u64,
502 pub protected_asset_count: u32,
504 pub inscription_ids: Vec<String>,
506 pub classification_complete: bool,
508 pub eligible_payment: bool,
510 pub eligible_ordinals: bool,
512 pub payment_disabled_reason: Option<String>,
514 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#[derive(Debug, Clone, Serialize, Deserialize)]
527#[serde(rename_all = "camelCase")]
528pub struct Account {
529 pub index: u32,
531 pub label: String,
533 #[serde(alias = "vaultAddress")]
535 pub taproot_address: String,
536 #[serde(alias = "vaultPublicKey")]
538 pub taproot_public_key: String,
539 pub payment_address: Option<String>,
541 pub payment_public_key: Option<String>,
543}
544
545#[derive(Debug, Clone)]
547pub struct DiscoveryAccountPlan {
548 pub index: u32,
550 pub taproot_descriptor: String,
552 pub taproot_change_descriptor: String,
554 pub taproot_public_key: String,
556 pub taproot_receive_index: u32,
558 pub payment_descriptor: Option<String>,
560 pub payment_change_descriptor: Option<String>,
562 pub payment_public_key: Option<String>,
564 pub payment_receive_index: Option<u32>,
566}
567
568#[derive(Debug, Clone)]
570pub struct DiscoveryContext {
571 pub network: Network,
573 pub scheme: AddressScheme,
575 pub derivation_mode: DerivationMode,
577 pub payment_address_type: PaymentAddressType,
579 pub kind: WalletKind,
581 pub accounts: Vec<DiscoveryAccountPlan>,
583 pub is_syncing: bool,
585 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 [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 [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#[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 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 if purpose == 86 {
766 let (x_only, _parity) = public_key.x_only_public_key();
768 Ok(x_only.to_string())
769 } else {
770 Ok(public_key.to_string())
772 }
773 }
774 WalletKind::Hardware {
775 taproot_external,
776 payment_external,
777 ..
778 } => {
779 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 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 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 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 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 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 #[must_use]
850 pub fn inscriptions(&self) -> &[crate::ordinals::types::Inscription] {
851 &self.inscriptions
852 }
853
854 #[must_use]
856 pub fn rune_balances(&self) -> &[crate::ordinals::types::RuneBalance] {
857 &self.rune_balances
858 }
859
860 #[must_use]
862 pub fn account_generation(&self) -> u64 {
863 self.account_generation
864 }
865
866 #[must_use]
868 pub fn active_account_index(&self) -> u32 {
869 self.account_index
870 }
871
872 #[must_use]
874 pub fn is_syncing(&self) -> bool {
875 self.is_syncing
876 }
877
878 #[must_use]
880 pub fn ordinals_verified(&self) -> bool {
881 self.ordinals_verified
882 }
883
884 #[must_use]
886 pub fn ordinals_metadata_complete(&self) -> bool {
887 self.ordinals_metadata_complete
888 }
889
890 pub fn is_unified(&self) -> bool {
892 self.scheme == AddressScheme::Unified
893 }
894
895 #[must_use]
897 pub fn derivation_mode(&self) -> DerivationMode {
898 self.derivation_mode
899 }
900
901 #[must_use]
903 pub fn profile_mode(&self) -> ProfileMode {
904 self.mode
905 }
906
907 #[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 pub fn needs_full_scan(&self) -> bool {
934 self.vault_wallet.local_chain().tip().height() == 0
936 }
937
938 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 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 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 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 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 pub fn export_changeset(&self) -> Result<ZincPersistence, String> {
1019 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 let network = self.vault_wallet.network();
1027 vault_changeset.network = Some(network);
1028
1029 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 vault_changeset
1046 .local_chain
1047 .blocks
1048 .entry(0)
1049 .or_insert(Some(genesis_hash));
1050
1051 let mut payment_changeset = self.loaded_payment_changeset.clone();
1053
1054 if let Some(w) = &self.payment_wallet {
1055 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 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 payment_changeset = Some(pcs);
1075 } else {
1076 payment_changeset = None;
1078 }
1079
1080 Ok(ZincPersistence {
1081 taproot: Some(vault_changeset),
1082 payment: payment_changeset,
1083 })
1084 }
1085
1086 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 pub fn prepare_requests(&self, force_full: bool) -> ZincSyncRequest {
1099 let now = wasm_now_secs();
1100 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 pub fn request_full_rescan(&mut self) {
1128 self.force_next_full = true;
1129 }
1130
1131 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 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 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 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 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 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 self.account_generation += 1;
1206 self.ordinals_verified = false;
1207 self.ordinals_metadata_complete = false;
1208
1209 Ok(())
1210 }
1211
1212 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 let vault_update = client
1230 .full_scan(vault_req, stop_gap, parallel_requests)
1231 .await
1232 .map_err(|e| e.to_string())?;
1233
1234 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 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 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 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 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 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 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 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 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 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 let ord_height = client
1446 .get_indexing_height()
1447 .await
1448 .map_err(|e| e.to_string())?;
1449
1450 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 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 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 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 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 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 pub fn get_balance(&self) -> ZincBalance {
1620 let raw = self.get_raw_balance();
1621
1622 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 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()), }
1679 }
1680
1681 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 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 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 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 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 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 let dust = target_postage.max(546);
2071
2072 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 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 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 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; }
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)); }
2146 }
2147
2148 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 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 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 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 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 #[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 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 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 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 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 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 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 #[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 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 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 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 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 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 #[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 #[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 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 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 let should_finalize = options.as_ref().is_some_and(|o| o.finalize);
2799 let bdk_options = bdk_wallet::SignOptions {
2800 trust_witness_utxo: true,
2804 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 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; 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 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 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 let original_psbt = if inputs_to_sign.is_some() {
2888 Some(psbt.clone())
2889 } else {
2890 None
2891 };
2892
2893 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 self.sign_inscription_script_paths(&mut psbt, should_finalize, inputs_to_sign.as_deref())?;
2910
2911 if let Some(indices) = inputs_to_sign.as_ref() {
2913 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 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 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; 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 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 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 pub fn analyze_psbt(&self, psbt_base64: &str) -> Result<String, String> {
3223 use crate::ordinals::shield::analyze_psbt;
3225 use base64::Engine;
3226 use std::collections::HashMap;
3227
3228 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 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 let mut known_inscriptions: HashMap<(bitcoin::Txid, u32), Vec<(String, u64)>> =
3267 HashMap::with_capacity(self.inscriptions.len());
3268
3269 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 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 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 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 let tx: Transaction = psbt
3331 .extract_tx()
3332 .map_err(|e| format!("Failed to extract tx: {e}"))?;
3333
3334 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 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 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 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; 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 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 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 pub fn get_taproot_public_key(&self, index: u32) -> Result<String, String> {
3463 self.derive_public_key(86, index)
3464 }
3465
3466 pub fn get_payment_public_key(&self, index: u32) -> Result<String, String> {
3470 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 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 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 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; }
3583
3584 let mut key_found = false;
3586 for (pubkey, (_, origin)) in &input.tap_key_origins {
3587 if *origin.0.as_bytes() == [0, 0, 0, 0] {
3590 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 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)); 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 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 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 }
3660 }
3661
3662 Ok(())
3663 }
3664
3665 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 wallet.start_full_scan_at(now).build()
3693 }
3694
3695 fn flexible_sync_request(wallet: &Wallet, now: u64) -> SyncRequest<(KeychainKind, u32)> {
3699 wallet.start_sync_with_revealed_spks_at(now).build()
3700 }
3701}
3702
3703impl WalletBuilder {
3705 #[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 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 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 pub fn from_watch_only(network: Network) -> Self {
3738 Self::new(network).mode(ProfileMode::Watch)
3739 }
3740
3741 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 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 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 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 #[must_use]
3821 pub fn kind(mut self, kind: WalletKind) -> Self {
3822 self.kind = Some(kind);
3823 self
3824 }
3825
3826 #[must_use]
3828 pub fn mode(mut self, mode: ProfileMode) -> Self {
3829 self.mode = mode;
3830 self
3831 }
3832
3833 #[must_use]
3835 pub fn with_scheme(mut self, scheme: AddressScheme) -> Self {
3836 self.scheme = scheme;
3837 self
3838 }
3839
3840 #[must_use]
3842 pub fn with_derivation_mode(mut self, mode: DerivationMode) -> Self {
3843 self.derivation_mode = mode;
3844 self
3845 }
3846
3847 #[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 #[must_use]
3856 pub fn with_account_index(mut self, index: u32) -> Self {
3857 self.account_index = index;
3858 self
3859 }
3860
3861 #[must_use]
3863 pub fn scan_policy(mut self, policy: ScanPolicy) -> Self {
3864 self.scan_policy = policy;
3865 self
3866 }
3867
3868 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 #[must_use]
3878 pub fn persistence(mut self, persistence: ZincPersistence) -> Self {
3879 self.persistence = Some(persistence);
3880 self
3881 }
3882
3883 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 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 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 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 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 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
4168pub struct ZincPersistence {
4169 pub taproot: Option<bdk_wallet::ChangeSet>,
4171 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}