1use bdk_chain::spk_client::{FullScanRequest, SyncRequest};
7use bdk_chain::Merge;
8use bdk_esplora::EsploraAsyncExt;
9
10use bdk_wallet::{KeychainKind, Wallet};
11use bitcoin::address::NetworkUnchecked;
12use bitcoin::psbt::Psbt;
13use bitcoin::{Address, Amount, FeeRate, Network, Transaction};
14use serde::{Deserialize, Serialize};
16
17use crate::error::ZincError;
18use crate::keys::ZincMnemonic;
19
20const LOG_TARGET_BUILDER: &str = "zinc_core::builder";
21
22#[derive(Debug, Clone, Deserialize, Default)]
24#[serde(rename_all = "camelCase")]
25pub struct SignOptions {
26 pub sign_inputs: Option<Vec<usize>>,
28 pub sighash: Option<u8>,
30 #[serde(default)]
33 pub finalize: bool,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub struct Seed64([u8; 64]);
39
40impl Seed64 {
41 #[must_use]
43 pub const fn from_array(bytes: [u8; 64]) -> Self {
44 Self(bytes)
45 }
46}
47
48impl AsRef<[u8]> for Seed64 {
49 fn as_ref(&self) -> &[u8] {
50 &self.0
51 }
52}
53
54impl TryFrom<&[u8]> for Seed64 {
55 type Error = ZincError;
56
57 fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
58 let array: [u8; 64] = value.try_into().map_err(|_| {
59 ZincError::ConfigError(format!(
60 "Invalid seed length: {}. Expected 64 bytes.",
61 value.len()
62 ))
63 })?;
64 Ok(Self(array))
65 }
66}
67
68#[derive(Debug, Clone)]
70pub struct CreatePsbtRequest {
71 pub recipient: Address<NetworkUnchecked>,
73 pub amount: Amount,
75 pub fee_rate: FeeRate,
77}
78
79impl CreatePsbtRequest {
80 pub fn from_parts(
82 recipient: &str,
83 amount_sats: u64,
84 fee_rate_sat_vb: u64,
85 ) -> Result<Self, ZincError> {
86 let recipient = recipient
87 .parse::<Address<NetworkUnchecked>>()
88 .map_err(|e| ZincError::ConfigError(format!("Invalid address: {e}")))?;
89 let fee_rate = FeeRate::from_sat_per_vb(fee_rate_sat_vb)
90 .ok_or_else(|| ZincError::ConfigError("Invalid fee rate".to_string()))?;
91
92 Ok(Self {
93 recipient,
94 amount: Amount::from_sat(amount_sats),
95 fee_rate,
96 })
97 }
98}
99
100#[derive(Debug, Clone, Deserialize, Serialize)]
102#[serde(rename_all = "camelCase")]
103pub struct CreatePsbtTransportRequest {
104 pub recipient: String,
106 pub amount_sats: u64,
108 pub fee_rate_sat_vb: u64,
110}
111
112impl TryFrom<CreatePsbtTransportRequest> for CreatePsbtRequest {
113 type Error = ZincError;
114
115 fn try_from(value: CreatePsbtTransportRequest) -> Result<Self, Self::Error> {
116 Self::from_parts(&value.recipient, value.amount_sats, value.fee_rate_sat_vb)
117 }
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum AddressScheme {
123 Unified,
125 Dual,
127}
128
129#[cfg(target_arch = "wasm32")]
130#[derive(Debug, Clone, Copy, Default)]
131pub struct WasmSleeper;
132
133#[cfg(target_arch = "wasm32")]
134pub struct WasmSleep(gloo_timers::future::TimeoutFuture);
135
136#[cfg(target_arch = "wasm32")]
137impl std::future::Future for WasmSleep {
138 type Output = ();
139 fn poll(
140 mut self: std::pin::Pin<&mut Self>,
141 cx: &mut std::task::Context<'_>,
142 ) -> std::task::Poll<Self::Output> {
143 std::pin::Pin::new(&mut self.0).poll(cx)
144 }
145}
146
147#[cfg(target_arch = "wasm32")]
148#[allow(unsafe_code)]
150unsafe impl Send for WasmSleep {}
151
152#[cfg(target_arch = "wasm32")]
153impl esplora_client::Sleeper for WasmSleeper {
154 type Sleep = WasmSleep;
155 fn sleep(dur: std::time::Duration) -> Self::Sleep {
156 WasmSleep(gloo_timers::future::TimeoutFuture::new(
157 dur.as_millis() as u32
158 ))
159 }
160}
161
162#[cfg(target_arch = "wasm32")]
163pub type SyncSleeper = WasmSleeper;
164
165#[cfg(not(target_arch = "wasm32"))]
167#[derive(Debug, Clone, Copy, Default)]
168pub struct TokioSleeper;
169
170#[cfg(not(target_arch = "wasm32"))]
171impl esplora_client::Sleeper for TokioSleeper {
172 type Sleep = tokio::time::Sleep;
173 fn sleep(dur: std::time::Duration) -> Self::Sleep {
174 tokio::time::sleep(dur)
175 }
176}
177
178#[cfg(not(target_arch = "wasm32"))]
180pub type SyncSleeper = TokioSleeper;
181
182pub fn now_unix() -> u64 {
184 #[cfg(target_arch = "wasm32")]
185 {
186 (js_sys::Date::now() / 1000.0) as u64
187 }
188
189 #[cfg(not(target_arch = "wasm32"))]
190 {
191 std::time::SystemTime::now()
192 .duration_since(std::time::UNIX_EPOCH)
193 .unwrap_or_default()
194 .as_secs()
195 }
196}
197
198pub struct WalletBuilder {
200 network: Network,
201 seed: Vec<u8>,
202 scheme: AddressScheme,
203 persistence: Option<ZincPersistence>,
204 account_index: u32,
205}
206
207pub struct ZincWallet {
209 pub(crate) vault_wallet: Wallet,
213 pub(crate) payment_wallet: Option<Wallet>,
215 pub(crate) scheme: AddressScheme,
217 pub(crate) loaded_vault_changeset: bdk_wallet::ChangeSet,
220 pub(crate) loaded_payment_changeset: Option<bdk_wallet::ChangeSet>,
222 pub(crate) account_index: u32,
224 pub(crate) inscribed_utxos: std::collections::HashSet<bitcoin::OutPoint>,
227 pub(crate) inscriptions: Vec<crate::ordinals::types::Inscription>,
229 pub(crate) ordinals_verified: bool,
231 pub(crate) ordinals_metadata_complete: bool,
233 master_xprv: bdk_wallet::bitcoin::bip32::Xpriv,
234 #[allow(dead_code)]
236 pub(crate) is_syncing: bool,
237 pub(crate) account_generation: u64,
239}
240
241pub enum SyncRequestType {
243 Full(FullScanRequest<KeychainKind>),
245 Incremental(SyncRequest<(KeychainKind, u32)>),
247}
248
249pub struct ZincSyncRequest {
251 pub taproot: SyncRequestType,
253 pub payment: Option<SyncRequestType>,
255}
256
257#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
259pub struct ZincBalance {
260 pub total: bdk_wallet::Balance,
262 pub spendable: bdk_wallet::Balance,
264 pub display_spendable: bdk_wallet::Balance,
266 pub inscribed: u64,
268}
269
270#[derive(Debug, Clone, Serialize, Deserialize)]
272#[serde(rename_all = "camelCase")]
273pub struct Account {
274 pub index: u32,
276 pub label: String,
278 #[serde(alias = "vaultAddress")]
280 pub taproot_address: String,
281 #[serde(alias = "vaultPublicKey")]
283 pub taproot_public_key: String,
284 pub payment_address: Option<String>,
286 pub payment_public_key: Option<String>,
288}
289
290#[derive(Debug, Clone)]
292pub struct DiscoveryAccountPlan {
293 pub index: u32,
295 pub taproot_descriptor: String,
297 pub taproot_change_descriptor: String,
299 pub taproot_public_key: String,
301 pub payment_descriptor: Option<String>,
303 pub payment_change_descriptor: Option<String>,
305 pub payment_public_key: Option<String>,
307}
308
309#[derive(Debug, Clone)]
311pub struct DiscoveryContext {
312 pub network: Network,
314 pub scheme: AddressScheme,
316 pub accounts: Vec<DiscoveryAccountPlan>,
318}
319
320impl ZincWallet {
321 #[must_use]
323 pub fn inscriptions(&self) -> &[crate::ordinals::types::Inscription] {
324 &self.inscriptions
325 }
326
327 #[must_use]
329 pub fn account_generation(&self) -> u64 {
330 self.account_generation
331 }
332
333 #[must_use]
335 pub fn active_account_index(&self) -> u32 {
336 self.account_index
337 }
338
339 #[must_use]
341 pub fn is_syncing(&self) -> bool {
342 self.is_syncing
343 }
344
345 #[must_use]
347 pub fn ordinals_verified(&self) -> bool {
348 self.ordinals_verified
349 }
350
351 #[must_use]
353 pub fn ordinals_metadata_complete(&self) -> bool {
354 self.ordinals_metadata_complete
355 }
356
357 pub fn is_unified(&self) -> bool {
359 self.scheme == AddressScheme::Unified
360 }
361
362 pub fn needs_full_scan(&self) -> bool {
364 self.vault_wallet.local_chain().tip().height() == 0
366 }
367
368 pub fn next_taproot_address(&mut self) -> Result<Address, String> {
370 let info = self
371 .vault_wallet
372 .reveal_next_address(KeychainKind::External);
373 Ok(info.address)
374 }
375
376 pub fn peek_taproot_address(&self, index: u32) -> Address {
378 self.vault_wallet
379 .peek_address(KeychainKind::External, index)
380 .address
381 }
382
383 pub fn get_payment_address(&mut self) -> Result<bitcoin::Address, String> {
387 if self.scheme == AddressScheme::Dual {
388 if let Some(wallet) = &mut self.payment_wallet {
389 Ok(wallet.reveal_next_address(KeychainKind::External).address)
390 } else {
391 Err("Payment wallet not initialized".to_string())
392 }
393 } else {
394 self.next_taproot_address()
395 }
396 }
397
398 pub fn peek_payment_address(&self, index: u32) -> Option<Address> {
402 if self.scheme == AddressScheme::Dual {
403 self.payment_wallet
404 .as_ref()
405 .map(|w| w.peek_address(KeychainKind::External, index).address)
406 } else {
407 Some(self.peek_taproot_address(index))
408 }
409 }
410
411 pub fn export_changeset(&self) -> Result<ZincPersistence, String> {
413 let mut vault_changeset = self.loaded_vault_changeset.clone();
415 if let Some(staged) = self.vault_wallet.staged() {
416 vault_changeset.merge(staged.clone());
417 }
418
419 let network = self.vault_wallet.network();
421 vault_changeset.network = Some(network);
422
423 vault_changeset.descriptor = Some(
425 self.vault_wallet
426 .public_descriptor(KeychainKind::External)
427 .clone(),
428 );
429 vault_changeset.change_descriptor = Some(
430 self.vault_wallet
431 .public_descriptor(KeychainKind::Internal)
432 .clone(),
433 );
434
435 let genesis_hash = bitcoin::blockdata::constants::genesis_block(network)
436 .header
437 .block_hash();
438 vault_changeset
440 .local_chain
441 .blocks
442 .entry(0)
443 .or_insert(Some(genesis_hash));
444
445 let mut payment_changeset = self.loaded_payment_changeset.clone();
447
448 if let Some(w) = &self.payment_wallet {
449 let mut pcs = payment_changeset.take().unwrap_or_default();
451
452 if let Some(staged) = w.staged() {
453 pcs.merge(staged.clone());
454 }
455
456 let net = w.network();
457 pcs.network = Some(net);
458
459 pcs.descriptor = Some(w.public_descriptor(KeychainKind::External).clone());
461 pcs.change_descriptor = Some(w.public_descriptor(KeychainKind::Internal).clone());
462
463 let gen_hash = bitcoin::blockdata::constants::genesis_block(net)
464 .header
465 .block_hash();
466 pcs.local_chain.blocks.entry(0).or_insert(Some(gen_hash));
467 payment_changeset = Some(pcs);
469 } else {
470 payment_changeset = None;
472 }
473
474 Ok(ZincPersistence {
475 taproot: Some(vault_changeset),
476 payment: payment_changeset,
477 })
478 }
479
480 pub async fn check_connection(esplora_url: &str) -> bool {
482 let client =
483 esplora_client::Builder::new(esplora_url).build_async_with_sleeper::<SyncSleeper>();
484
485 match client {
486 Ok(c) => c.get_height().await.is_ok(),
487 Err(_) => false,
488 }
489 }
490
491 pub fn prepare_requests(&self) -> ZincSyncRequest {
493 let now = now_unix();
494 let use_full = self.needs_full_scan();
495
496 let vault = if use_full {
497 SyncRequestType::Full(self.vault_wallet.start_full_scan_at(now).build())
498 } else {
499 SyncRequestType::Full(self.vault_wallet.start_full_scan_at(now).build())
503 };
504
505 let payment = self.payment_wallet.as_ref().map(|w| {
506 SyncRequestType::Full(w.start_full_scan_at(now).build())
508 });
509
510 ZincSyncRequest {
511 taproot: vault,
512 payment,
513 }
514 }
515
516 pub fn apply_sync(
518 &mut self,
519 vault_update: impl Into<bdk_wallet::Update>,
520 payment_update: Option<impl Into<bdk_wallet::Update>>,
521 ) -> Result<Vec<String>, String> {
522 let mut all_events = Vec::new();
523
524 let vault_events = self
526 .vault_wallet
527 .apply_update_events(vault_update)
528 .map_err(|e| e.to_string())?;
529
530 for event in vault_events {
531 all_events.push(format!("taproot:{:?}", event));
532 }
533
534 if let (Some(w), Some(u)) = (&mut self.payment_wallet, payment_update) {
536 let payment_events = w.apply_update_events(u).map_err(|e| e.to_string())?;
537 for event in payment_events {
538 all_events.push(format!("payment:{:?}", event));
539 }
540 }
541
542 Ok(all_events)
543 }
544
545 pub fn reset_sync_state(&mut self) -> Result<(), String> {
547 zinc_log_info!(
548 target: LOG_TARGET_BUILDER,
549 "resetting wallet sync state (chain mismatch recovery)"
550 );
551
552 let vault_desc = self
554 .vault_wallet
555 .public_descriptor(KeychainKind::External)
556 .to_string();
557 let vault_change_desc = self
558 .vault_wallet
559 .public_descriptor(KeychainKind::Internal)
560 .to_string();
561 let network = self.vault_wallet.network();
562
563 self.vault_wallet = Wallet::create(vault_desc, vault_change_desc)
564 .network(network)
565 .create_wallet_no_persist()
566 .map_err(|e| format!("Failed to reset taproot wallet: {}", e))?;
567 self.loaded_vault_changeset = bdk_wallet::ChangeSet::default();
568
569 if let Some(w) = &self.payment_wallet {
571 let pay_desc = w.public_descriptor(KeychainKind::External).to_string();
572 let pay_change_desc = w.public_descriptor(KeychainKind::Internal).to_string();
573
574 self.payment_wallet = Some(
575 Wallet::create(pay_desc, pay_change_desc)
576 .network(network)
577 .create_wallet_no_persist()
578 .map_err(|e| format!("Failed to reset payment wallet: {}", e))?,
579 );
580 self.loaded_payment_changeset = Some(bdk_wallet::ChangeSet::default());
581 }
582
583 self.account_generation += 1;
585 self.ordinals_verified = false;
586 self.ordinals_metadata_complete = false;
587
588 Ok(())
589 }
590
591 pub async fn sync(&mut self, esplora_url: &str) -> Result<Vec<String>, String> {
593 let client = esplora_client::Builder::new(esplora_url)
594 .build_async_with_sleeper::<SyncSleeper>()
595 .map_err(|e| format!("{:?}", e))?;
596
597 let now = now_unix();
598 let vault_req = self.vault_wallet.start_full_scan_at(now).build();
599 let payment_req = self
600 .payment_wallet
601 .as_ref()
602 .map(|w| w.start_full_scan_at(now).build());
603
604 let vault_update = client
606 .full_scan(vault_req, 20, 1)
607 .await
608 .map_err(|e| e.to_string())?;
609
610 let payment_update = if let Some(req) = payment_req {
612 Some(
613 client
614 .full_scan(req, 20, 1)
615 .await
616 .map_err(|e| e.to_string())?,
617 )
618 } else {
619 None
620 };
621
622 self.apply_sync(vault_update, payment_update)
623 }
624
625 pub fn collect_active_addresses(&self) -> Vec<String> {
628 let mut addresses = std::collections::HashSet::new();
629
630 let collect_addresses = |wallet: &Wallet| {
632 let mut addrs = std::collections::HashSet::new();
633 for utxo in wallet.list_unspent() {
634 addrs.insert(utxo.txout.script_pubkey);
635 }
636 addrs
637 };
638
639 let vault_scripts = collect_addresses(&self.vault_wallet);
641 for script in vault_scripts {
642 if let Ok(addr) = Address::from_script(&script, self.vault_wallet.network()) {
643 addresses.insert(addr);
644 }
645 }
646
647 if let Some(w) = &self.payment_wallet {
649 let payment_scripts = collect_addresses(w);
650 for script in payment_scripts {
651 if let Ok(addr) = Address::from_script(&script, w.network()) {
652 addresses.insert(addr);
653 }
654 }
655 }
656
657 addresses.into_iter().map(|a| a.to_string()).collect()
658 }
659
660 pub fn apply_verified_ordinals_update(
663 &mut self,
664 inscriptions: Vec<crate::ordinals::types::Inscription>,
665 protected_outpoints: std::collections::HashSet<bitcoin::OutPoint>,
666 ) -> usize {
667 zinc_log_info!(
668 target: LOG_TARGET_BUILDER,
669 "applying ordinals update: {} inscriptions received",
670 inscriptions.len()
671 );
672 for inscription in &inscriptions {
673 zinc_log_debug!(
674 target: LOG_TARGET_BUILDER,
675 "inscribed outpoint updated: {}",
676 inscription.satpoint.outpoint
677 );
678 }
679
680 self.inscribed_utxos = protected_outpoints;
681 self.inscriptions = inscriptions;
682 self.ordinals_verified = true;
683 self.ordinals_metadata_complete = true;
684
685 zinc_log_info!(
686 target: LOG_TARGET_BUILDER,
687 "total inscribed_utxos set size: {}",
688 self.inscribed_utxos.len()
689 );
690 self.inscriptions.len()
691 }
692
693 pub fn apply_unverified_inscriptions_cache(
698 &mut self,
699 inscriptions: Vec<crate::ordinals::types::Inscription>,
700 ) -> usize {
701 zinc_log_info!(
702 target: LOG_TARGET_BUILDER,
703 "applying unverified inscription cache: {} inscriptions received",
704 inscriptions.len()
705 );
706
707 self.inscribed_utxos.clear();
708 self.inscriptions = inscriptions;
709 self.ordinals_verified = false;
710 self.ordinals_metadata_complete = true;
711
712 self.inscriptions.len()
713 }
714
715 fn verify_ord_indexer_is_current(
716 &mut self,
717 ord_height: u32,
718 wallet_height: u32,
719 ) -> Result<(), String> {
720 if ord_height < wallet_height.saturating_sub(1) {
721 self.ordinals_verified = false;
722 return Err(format!(
723 "Ord Indexer is lagging! Ord: {}, Wallet: {}. Safety lock engaged.",
724 ord_height, wallet_height
725 ));
726 }
727 Ok(())
728 }
729
730 pub async fn sync_ordinals_protection(&mut self, ord_url: &str) -> Result<usize, String> {
732 self.ordinals_verified = false;
733 let addresses = self.collect_active_addresses();
734 let client = crate::ordinals::OrdClient::new(ord_url.to_string());
735
736 let ord_height = client
738 .get_indexing_height()
739 .await
740 .map_err(|e| e.to_string())?;
741
742 let wallet_height = self.vault_wallet.local_chain().tip().height();
744
745 self.verify_ord_indexer_is_current(ord_height, wallet_height)?;
746
747 let mut protected_outpoints = std::collections::HashSet::new();
748 for addr_str in addresses {
749 let snapshot = client
750 .get_address_asset_snapshot(&addr_str)
751 .await
752 .map_err(|e| format!("Failed to fetch for {}: {}", addr_str, e))?;
753
754 let protected = client
755 .get_protected_outpoints_from_outputs(&snapshot.outputs)
756 .await
757 .map_err(|e| {
758 format!("Failed to fetch protected outputs for {}: {}", addr_str, e)
759 })?;
760 protected_outpoints.extend(protected);
761 }
762
763 self.inscribed_utxos = protected_outpoints;
764 self.ordinals_verified = true;
765 Ok(self.inscribed_utxos.len())
766 }
767
768 pub async fn sync_ordinals_metadata(&mut self, ord_url: &str) -> Result<usize, String> {
770 self.ordinals_metadata_complete = false;
771 let addresses = self.collect_active_addresses();
772 let client = crate::ordinals::OrdClient::new(ord_url.to_string());
773
774 let ord_height = client
775 .get_indexing_height()
776 .await
777 .map_err(|e| e.to_string())?;
778 let wallet_height = self.vault_wallet.local_chain().tip().height();
779 self.verify_ord_indexer_is_current(ord_height, wallet_height)?;
780
781 let mut all_inscriptions = Vec::new();
782 for addr_str in addresses {
783 let snapshot = client
784 .get_address_asset_snapshot(&addr_str)
785 .await
786 .map_err(|e| format!("Failed to fetch for {}: {}", addr_str, e))?;
787
788 for inscription_id in snapshot.inscription_ids {
789 let inscription = client
790 .get_inscription_details(&inscription_id)
791 .await
792 .map_err(|e| {
793 format!("Failed to fetch details for {}: {}", inscription_id, e)
794 })?;
795 all_inscriptions.push(inscription);
796 }
797 }
798
799 self.inscriptions = all_inscriptions;
800 self.ordinals_metadata_complete = true;
801 Ok(self.inscriptions.len())
802 }
803
804 pub async fn sync_ordinals(&mut self, ord_url: &str) -> Result<usize, String> {
807 self.sync_ordinals_protection(ord_url).await?;
808 self.sync_ordinals_metadata(ord_url).await
809 }
810
811 pub fn get_raw_balance(&self) -> bdk_wallet::Balance {
813 let vault_bal = self.vault_wallet.balance();
814 if let Some(payment_wallet) = &self.payment_wallet {
815 let pay_bal = payment_wallet.balance();
816 bdk_wallet::Balance {
817 immature: vault_bal.immature + pay_bal.immature,
818 trusted_pending: vault_bal.trusted_pending + pay_bal.trusted_pending,
819 untrusted_pending: vault_bal.untrusted_pending + pay_bal.untrusted_pending,
820 confirmed: vault_bal.confirmed + pay_bal.confirmed,
821 }
822 } else {
823 vault_bal
824 }
825 }
826
827 pub fn get_balance(&self) -> ZincBalance {
829 let raw = self.get_raw_balance();
830
831 let calc_balance = |wallet: &Wallet| {
833 let mut bal = bdk_wallet::Balance::default();
834 for utxo in wallet.list_unspent() {
835 if self.inscribed_utxos.contains(&utxo.outpoint) {
836 zinc_log_debug!(
837 target: LOG_TARGET_BUILDER,
838 "skipping inscribed UTXO while calculating balance: {:?}",
839 utxo.outpoint
840 );
841 continue;
842 }
843 match utxo.keychain {
844 KeychainKind::Internal | KeychainKind::External => {
845 match utxo.chain_position {
847 bdk_chain::ChainPosition::Confirmed { .. } => {
848 bal.confirmed += utxo.txout.value;
849 }
850 bdk_chain::ChainPosition::Unconfirmed { .. } => {
851 bal.trusted_pending += utxo.txout.value;
852 }
853 }
854 }
855 }
856 }
857 bal
858 };
859
860 let mut safe_bal = calc_balance(&self.vault_wallet);
861 if let Some(w) = &self.payment_wallet {
862 let p_bal = calc_balance(w);
863 safe_bal.confirmed += p_bal.confirmed;
864 safe_bal.trusted_pending += p_bal.trusted_pending;
865 safe_bal.untrusted_pending += p_bal.untrusted_pending;
866 safe_bal.immature += p_bal.immature;
867 }
868
869 let display_spendable = if let Some(payment_wallet) = &self.payment_wallet {
870 calc_balance(payment_wallet)
871 } else {
872 safe_bal.clone()
873 };
874
875 ZincBalance {
876 total: raw.clone(),
877 spendable: safe_bal.clone(),
878 display_spendable,
879 inscribed: raw
880 .confirmed
881 .to_sat()
882 .saturating_sub(safe_bal.confirmed.to_sat())
883 + raw
884 .trusted_pending
885 .to_sat()
886 .saturating_sub(safe_bal.trusted_pending.to_sat()), }
888 }
889
890 pub fn create_psbt_tx(&mut self, request: &CreatePsbtRequest) -> Result<Psbt, ZincError> {
892 if !self.ordinals_verified {
893 return Err(ZincError::WalletError(
894 "Ordinals verification failed - safety lock engaged. Please retry sync."
895 .to_string(),
896 ));
897 }
898
899 let wallet = if self.scheme == AddressScheme::Dual {
900 self.payment_wallet.as_mut().ok_or_else(|| {
901 ZincError::WalletError("Payment wallet not initialized".to_string())
902 })?
903 } else {
904 &mut self.vault_wallet
905 };
906
907 let recipient = request
908 .recipient
909 .clone()
910 .require_network(wallet.network())
911 .map_err(|e| ZincError::ConfigError(format!("Network mismatch: {e}")))?;
912
913 let change_script = wallet
914 .peek_address(KeychainKind::External, 0)
915 .script_pubkey();
916
917 let mut builder = wallet.build_tx();
918 if !self.inscribed_utxos.is_empty() {
919 builder.unspendable(self.inscribed_utxos.iter().cloned().collect());
920 }
921
922 builder
923 .add_recipient(recipient.script_pubkey(), request.amount)
924 .fee_rate(request.fee_rate)
925 .drain_to(change_script);
926
927 builder
928 .finish()
929 .map_err(|e| ZincError::WalletError(format!("Failed to build tx: {e}")))
930 }
931
932 pub fn create_psbt_base64(&mut self, request: &CreatePsbtRequest) -> Result<String, ZincError> {
934 let psbt = self.create_psbt_tx(request)?;
935 Ok(Self::encode_psbt_base64(&psbt))
936 }
937
938 pub fn create_offer(
940 &mut self,
941 request: &crate::offer_create::CreateOfferRequest,
942 ) -> Result<crate::offer_create::OfferCreateResultV1, ZincError> {
943 crate::offer_create::create_offer(self, request)
944 }
945
946 #[doc(hidden)]
952 #[deprecated(note = "Use create_psbt_base64 with CreatePsbtRequest")]
953 pub fn create_psbt(
954 &mut self,
955 recipient: &str,
956 amount_sats: u64,
957 fee_rate_sat_vb: u64,
958 ) -> Result<String, String> {
959 let request = CreatePsbtRequest::from_parts(recipient, amount_sats, fee_rate_sat_vb)
960 .map_err(|e| e.to_string())?;
961 self.create_psbt_base64(&request).map_err(|e| e.to_string())
962 }
963
964 fn encode_psbt_base64(psbt: &Psbt) -> String {
965 use base64::Engine;
966 base64::engine::general_purpose::STANDARD.encode(psbt.serialize())
967 }
968
969 #[allow(deprecated)]
972 pub fn sign_psbt(
973 &mut self,
974 psbt_base64: &str,
975 options: Option<SignOptions>,
976 ) -> Result<String, String> {
977 use base64::Engine;
978
979 let psbt_bytes = base64::engine::general_purpose::STANDARD
981 .decode(psbt_base64)
982 .map_err(|e| format!("Invalid base64: {e}"))?;
983
984 let mut psbt = Psbt::deserialize(&psbt_bytes).map_err(|e| format!("Invalid PSBT: {e}"))?;
985
986 use std::collections::HashMap;
989 let mut known_utxos = HashMap::new();
990
991 let collect_utxos = |w: &Wallet, map: &mut HashMap<bitcoin::OutPoint, bitcoin::TxOut>| {
992 for utxo in w.list_unspent() {
993 map.insert(utxo.outpoint, utxo.txout);
994 }
995 };
996
997 collect_utxos(&self.vault_wallet, &mut known_utxos);
998 if let Some(w) = &self.payment_wallet {
999 collect_utxos(w, &mut known_utxos);
1000 }
1001
1002 for (i, input) in psbt.inputs.iter_mut().enumerate() {
1003 if input.witness_utxo.is_none() && input.non_witness_utxo.is_none() {
1004 let outpoint = psbt.unsigned_tx.input[i].previous_output;
1005 if let Some(txout) = known_utxos.get(&outpoint) {
1006 input.witness_utxo = Some(txout.clone());
1007 }
1008 }
1009 }
1010
1011 let should_finalize = options.as_ref().map(|o| o.finalize).unwrap_or(false);
1014 let bdk_options = bdk_wallet::SignOptions {
1015 trust_witness_utxo: true,
1019 try_finalize: should_finalize,
1022 ..Default::default()
1023 };
1024 let mut inputs_to_sign: Option<Vec<usize>> = None;
1025
1026 if let Some(opts) = &options {
1027 if let Some(sighash_u8) = opts.sighash {
1028 let target_sighash = bitcoin::psbt::PsbtSighashType::from_u32(sighash_u8 as u32);
1030 for input in psbt.inputs.iter_mut() {
1031 input.sighash_type = Some(target_sighash);
1032 }
1033 }
1034 inputs_to_sign = opts.sign_inputs.clone();
1035 }
1036
1037 if let Some(indices) = inputs_to_sign.as_ref() {
1038 let mut seen = std::collections::HashSet::new();
1039 for index in indices {
1040 if *index >= psbt.inputs.len() {
1041 return Err(format!(
1042 "Security Violation: sign_inputs index {} is out of bounds for {} inputs",
1043 index,
1044 psbt.inputs.len()
1045 ));
1046 }
1047 if !seen.insert(*index) {
1048 return Err(format!(
1049 "Security Violation: sign_inputs index {} is duplicated",
1050 index
1051 ));
1052 }
1053 let input = &psbt.inputs[*index];
1054 if input.witness_utxo.is_none() && input.non_witness_utxo.is_none() {
1055 return Err(format!(
1056 "Security Violation: Requested input #{} is missing UTXO metadata",
1057 index
1058 ));
1059 }
1060 }
1061 }
1062
1063 for (index, input) in psbt.inputs.iter().enumerate() {
1064 if let Some(sighash) = input.sighash_type {
1065 let value = sighash.to_u32();
1066 let base_type = value & 0x1f;
1067 let anyone_can_pay = (value & 0x80) != 0;
1068 let is_allowed_base = base_type == 0 || base_type == 1; if anyone_can_pay || !is_allowed_base {
1071 return Err(format!(
1072 "Security Violation: Sighash type is not allowed on input #{} (value={})",
1073 index, value
1074 ));
1075 }
1076 }
1077 }
1078
1079 let mut known_inscriptions: HashMap<(bitcoin::Txid, u32), Vec<(String, u64)>> =
1082 HashMap::new();
1083 for ins in &self.inscriptions {
1084 known_inscriptions
1085 .entry((ins.satpoint.outpoint.txid, ins.satpoint.outpoint.vout))
1086 .or_default()
1087 .push((ins.id.clone(), ins.satpoint.offset));
1088 }
1089 for items in known_inscriptions.values_mut() {
1091 items.sort_by_key(|(_, offset)| *offset);
1092 }
1093
1094 if let Err(e) = crate::ordinals::shield::audit_psbt(
1095 &psbt,
1096 &known_inscriptions,
1097 inputs_to_sign.as_deref(),
1098 self.vault_wallet.network(),
1099 ) {
1100 return Err(format!("Security Violation: {}", e));
1101 }
1102
1103 let original_psbt = if inputs_to_sign.is_some() {
1105 Some(psbt.clone())
1106 } else {
1107 None
1108 };
1109
1110 self.vault_wallet
1113 .sign(&mut psbt, bdk_options.clone())
1114 .map_err(|e| format!("Vault signing failed: {e}"))?;
1115
1116 if let Some(payment_wallet) = &self.payment_wallet {
1117 payment_wallet
1118 .sign(&mut psbt, bdk_options)
1119 .map_err(|e| format!("Payment signing failed: {e}"))?;
1120 }
1121
1122 self.sign_inscription_script_paths(&mut psbt, should_finalize, inputs_to_sign.as_deref())?;
1127
1128 if let Some(indices) = inputs_to_sign.as_ref() {
1130 let original = original_psbt
1132 .as_ref()
1133 .ok_or_else(|| "Security Violation: missing original PSBT snapshot".to_string())?;
1134 for (i, input) in psbt.inputs.iter_mut().enumerate() {
1135 if !indices.contains(&i) {
1136 *input = original.inputs[i].clone();
1137 }
1138 }
1139 }
1140
1141 if let Some(indices) = inputs_to_sign.as_ref() {
1142 let original = original_psbt
1143 .as_ref()
1144 .ok_or_else(|| "Security Violation: missing original PSBT snapshot".to_string())?;
1145 for index in indices {
1146 let before = &original.inputs[*index];
1147 let after = &psbt.inputs[*index];
1148
1149 let signature_changed = before.tap_key_sig != after.tap_key_sig
1150 || before.tap_script_sigs != after.tap_script_sigs
1151 || before.partial_sigs != after.partial_sigs
1152 || before.final_script_witness != after.final_script_witness;
1153
1154 if !signature_changed {
1155 return Err(format!(
1156 "Security Violation: Requested input #{} was not signed by this wallet",
1157 index
1158 ));
1159 }
1160 }
1161 }
1162
1163 for (_i, input) in psbt.inputs.iter().enumerate() {
1169 if input.tap_key_sig.is_some() {
1170 } else if !input.tap_script_sigs.is_empty() {
1171 } else if !input.partial_sigs.is_empty() {
1172 } else if input.final_script_witness.is_some() {
1173 } else {
1175 }
1177 }
1178
1179 let signed_bytes = psbt.serialize();
1180 let signed_base64 = base64::engine::general_purpose::STANDARD.encode(&signed_bytes);
1181
1182 Ok(signed_base64)
1183 }
1184
1185 pub fn analyze_psbt(&self, psbt_base64: &str) -> Result<String, String> {
1188 use crate::ordinals::shield::analyze_psbt;
1190 use base64::Engine;
1191 use std::collections::HashMap;
1192
1193 let psbt_bytes = base64::engine::general_purpose::STANDARD
1195 .decode(psbt_base64)
1196 .map_err(|e| format!("Invalid base64: {e}"))?;
1197
1198 let mut psbt = match Psbt::deserialize(&psbt_bytes) {
1199 Ok(p) => p,
1200 Err(e) => {
1201 return Err(format!("Invalid PSBT: {e}"));
1202 }
1203 };
1204
1205 let mut known_utxos = HashMap::new();
1208
1209 let collect_utxos = |w: &Wallet, map: &mut HashMap<bitcoin::OutPoint, bitcoin::TxOut>| {
1210 for utxo in w.list_unspent() {
1211 map.insert(utxo.outpoint, utxo.txout);
1212 }
1213 };
1214
1215 collect_utxos(&self.vault_wallet, &mut known_utxos);
1216 if let Some(w) = &self.payment_wallet {
1217 collect_utxos(w, &mut known_utxos);
1218 }
1219
1220 let mut enriched_count = 0;
1221 for (i, input) in psbt.inputs.iter_mut().enumerate() {
1222 if input.witness_utxo.is_none() && input.non_witness_utxo.is_none() {
1223 let outpoint = psbt.unsigned_tx.input[i].previous_output;
1224 if let Some(txout) = known_utxos.get(&outpoint) {
1225 input.witness_utxo = Some(txout.clone());
1226 enriched_count += 1;
1227 }
1228 }
1229 }
1230
1231 if enriched_count > 0 {}
1232
1233 let mut known_inscriptions: HashMap<(bitcoin::Txid, u32), Vec<(String, u64)>> =
1236 HashMap::new();
1237
1238 for ins in &self.inscriptions {
1261 known_inscriptions
1262 .entry((ins.satpoint.outpoint.txid, ins.satpoint.outpoint.vout))
1263 .or_default()
1264 .push((ins.id.clone(), ins.satpoint.offset));
1265 }
1266
1267 for items in known_inscriptions.values_mut() {
1269 items.sort_by_key(|(_, offset)| *offset);
1270 }
1271
1272 let result = match analyze_psbt(&psbt, &known_inscriptions, self.vault_wallet.network()) {
1273 Ok(r) => r,
1274 Err(e) => {
1275 return Err(e.to_string());
1276 }
1277 };
1278
1279 serde_json::to_string(&result).map_err(|e| e.to_string())
1280 }
1281
1282 pub async fn broadcast(
1285 &mut self,
1286 signed_psbt_base64: &str,
1287 esplora_url: &str,
1288 ) -> Result<String, String> {
1289 use base64::Engine;
1290
1291 let psbt_bytes = base64::engine::general_purpose::STANDARD
1293 .decode(signed_psbt_base64)
1294 .map_err(|e| format!("Invalid base64: {e}"))?;
1295
1296 let psbt = Psbt::deserialize(&psbt_bytes).map_err(|e| format!("Invalid PSBT: {e}"))?;
1297
1298 let tx: Transaction = psbt
1300 .extract_tx()
1301 .map_err(|e| format!("Failed to extract tx: {e}"))?;
1302
1303 let client = esplora_client::Builder::new(esplora_url)
1305 .build_async_with_sleeper::<SyncSleeper>()
1306 .map_err(|e| format!("Failed to create client: {e:?}"))?;
1307
1308 let broadcast_res: Result<(), _> = client.broadcast(&tx).await;
1309
1310 broadcast_res.map_err(|e| format!("Broadcast failed: {e}"))?;
1311
1312 Ok(tx.compute_txid().to_string())
1313 }
1314
1315 pub fn sign_message(&self, address: &str, message: &str) -> Result<String, String> {
1318 use base64::Engine;
1319 use bitcoin::hashes::Hash;
1320 use bitcoin::secp256k1::{Message, Secp256k1};
1321
1322 let index = 0;
1324 let vault_addr = self
1325 .vault_wallet
1326 .peek_address(KeychainKind::External, index)
1327 .address
1328 .to_string();
1329
1330 let (is_vault, is_payment) = if address == vault_addr {
1331 (true, false)
1332 } else if let Some(w) = &self.payment_wallet {
1333 let pay_addr = w
1334 .peek_address(KeychainKind::External, index)
1335 .address
1336 .to_string();
1337 (false, address == pay_addr)
1338 } else {
1339 (false, false)
1340 };
1341
1342 if !is_vault && !is_payment {
1343 return Err("Address not found in wallet".to_string());
1344 }
1345
1346 let secp = Secp256k1::new();
1348 let coin_type = if self.vault_wallet.network() == Network::Bitcoin {
1349 0
1350 } else {
1351 1
1352 };
1353 let account = self.account_index;
1354
1355 let (purpose, chain) = if is_vault { (86, 0) } else { (84, 0) };
1357
1358 let derivation_path = [
1359 bdk_wallet::bitcoin::bip32::ChildNumber::from_hardened_idx(purpose).unwrap(),
1360 bdk_wallet::bitcoin::bip32::ChildNumber::from_hardened_idx(coin_type).unwrap(),
1361 bdk_wallet::bitcoin::bip32::ChildNumber::from_hardened_idx(account).unwrap(),
1362 bdk_wallet::bitcoin::bip32::ChildNumber::from_normal_idx(chain).unwrap(),
1363 bdk_wallet::bitcoin::bip32::ChildNumber::from_normal_idx(index).unwrap(),
1364 ];
1365
1366 let child_xprv = self
1367 .master_xprv
1368 .derive_priv(&secp, &derivation_path)
1369 .map_err(|e| format!("Key derivation failed: {e}"))?;
1370
1371 let priv_key = child_xprv.private_key;
1372
1373 let signature_hash = bitcoin::sign_message::signed_msg_hash(message);
1375 let msg = Message::from_digest(signature_hash.to_byte_array());
1376
1377 let sig = secp.sign_ecdsa_recoverable(&msg, &priv_key);
1378 let (rec_id, sig_bytes_compact) = sig.serialize_compact();
1379
1380 let mut header = 27 + u8::try_from(rec_id.to_i32()).unwrap();
1381 header += 4; let mut sig_bytes = Vec::with_capacity(65);
1384 sig_bytes.push(header);
1385 sig_bytes.extend_from_slice(&sig_bytes_compact);
1386
1387 Ok(base64::engine::general_purpose::STANDARD.encode(&sig_bytes))
1388 }
1389 pub fn get_taproot_public_key(&self, index: u32) -> Result<String, String> {
1391 self.derive_public_key(86, index)
1392 }
1393
1394 pub fn get_payment_public_key(&self, index: u32) -> Result<String, String> {
1398 let purpose = if self.scheme == AddressScheme::Dual {
1400 84
1401 } else {
1402 86
1403 };
1404 self.derive_public_key(purpose, index)
1405 }
1406
1407 fn derive_public_key(&self, purpose: u32, index: u32) -> Result<String, String> {
1408 self.derive_public_key_internal(purpose, self.account_index, index)
1409 }
1410
1411 fn sign_inscription_script_paths(
1417 &self,
1418 psbt: &mut Psbt,
1419 should_finalize: bool,
1420 allowed_inputs: Option<&[usize]>,
1421 ) -> Result<(), String> {
1422 use bitcoin::secp256k1::{Keypair, Message, Secp256k1};
1423 use bitcoin::sighash::{Prevouts, SighashCache, TapSighashType};
1424 use bitcoin::taproot::TapLeafHash;
1425
1426 let secp = Secp256k1::new();
1427
1428 let coin_type = if self.vault_wallet.network() == Network::Bitcoin {
1430 0
1431 } else {
1432 1
1433 };
1434 let derivation_path = [
1435 bdk_wallet::bitcoin::bip32::ChildNumber::from_hardened_idx(86).unwrap(),
1436 bdk_wallet::bitcoin::bip32::ChildNumber::from_hardened_idx(coin_type).unwrap(),
1437 bdk_wallet::bitcoin::bip32::ChildNumber::from_hardened_idx(self.account_index).unwrap(),
1438 bdk_wallet::bitcoin::bip32::ChildNumber::from_normal_idx(0).unwrap(), bdk_wallet::bitcoin::bip32::ChildNumber::from_normal_idx(0).unwrap(), ];
1441
1442 let ordinals_xprv = self
1443 .master_xprv
1444 .derive_priv(&secp, &derivation_path)
1445 .map_err(|e| format!("Failed to derive ordinals key: {e}"))?;
1446
1447 let ordinals_keypair = Keypair::from_secret_key(&secp, &ordinals_xprv.private_key);
1448 let (ordinals_xonly, _) = ordinals_keypair.x_only_public_key();
1449
1450 let prevouts: Vec<bitcoin::TxOut> = psbt
1452 .inputs
1453 .iter()
1454 .map(|inp| {
1455 inp.witness_utxo.clone().unwrap_or_else(|| {
1456 bitcoin::TxOut {
1458 value: bitcoin::Amount::ZERO,
1459 script_pubkey: bitcoin::ScriptBuf::new(),
1460 }
1461 })
1462 })
1463 .collect();
1464
1465 for (i, input) in psbt.inputs.iter_mut().enumerate() {
1466 if let Some(indices) = allowed_inputs {
1467 if !indices.contains(&i) {
1468 continue;
1469 }
1470 }
1471
1472 if !input.tap_script_sigs.is_empty()
1474 || input.tap_key_sig.is_some()
1475 || input.final_script_witness.is_some()
1476 {
1477 continue;
1478 }
1479
1480 if input.tap_scripts.is_empty() {
1482 continue;
1483 }
1484
1485 let has_our_key_origin = input.tap_key_origins.keys().any(|k| *k == ordinals_xonly);
1488 let has_matching_internal_key = input
1489 .tap_internal_key
1490 .map(|key| key == ordinals_xonly)
1491 .unwrap_or(false);
1492 if !has_our_key_origin && !has_matching_internal_key {
1493 continue;
1494 }
1495
1496 let (control_block, (script, leaf_version)) = input
1498 .tap_scripts
1499 .iter()
1500 .next()
1501 .ok_or_else(|| format!("Input {} has empty tap_scripts", i))?;
1502
1503 let leaf_hash = TapLeafHash::from_script(script, *leaf_version);
1504
1505 let mut sighash_cache = SighashCache::new(&psbt.unsigned_tx);
1507 let sighash = sighash_cache
1508 .taproot_script_spend_signature_hash(
1509 i,
1510 &Prevouts::All(&prevouts),
1511 leaf_hash,
1512 TapSighashType::Default,
1513 )
1514 .map_err(|e| format!("Failed to compute script sighash for input {}: {e}", i))?;
1515
1516 let msg = Message::from_digest_slice(sighash.as_ref())
1518 .map_err(|e| format!("Invalid sighash message: {e}"))?;
1519 let signature = secp.sign_schnorr(&msg, &ordinals_keypair);
1520
1521 let tap_sig = bitcoin::taproot::Signature {
1523 signature,
1524 sighash_type: TapSighashType::Default,
1525 };
1526
1527 let tap_sig_serialized = tap_sig.serialize();
1529 input
1530 .tap_script_sigs
1531 .insert((ordinals_xonly, leaf_hash), tap_sig);
1532
1533 if should_finalize {
1534 let mut witness = bitcoin::Witness::new();
1535 witness.push(tap_sig_serialized);
1536 witness.push(script.as_bytes());
1537 witness.push(control_block.serialize());
1538 input.final_script_witness = Some(witness);
1539 }
1540 }
1541
1542 Ok(())
1543 }
1544
1545 pub fn get_accounts(&self, count: u32) -> Vec<Account> {
1547 let mut accounts = Vec::new();
1548 let network = self.vault_wallet.network();
1549 let coin_type = i32::from(network != Network::Bitcoin);
1550
1551 for i in 0..count {
1552 let vault_desc = format!("tr({}/86'/{coin_type}'/{i}'/0/*)", self.master_xprv);
1554 let vault_change_desc = format!("tr({}/86'/{coin_type}'/{i}'/1/*)", self.master_xprv);
1555
1556 if let Ok(vw) = Wallet::create(vault_desc, vault_change_desc)
1558 .network(network)
1559 .create_wallet_no_persist()
1560 {
1561 let taproot_address = vw
1562 .peek_address(KeychainKind::External, 0)
1563 .address
1564 .to_string();
1565 let taproot_public_key = self
1566 .derive_public_key_internal(86, i, 0)
1567 .unwrap_or_default();
1568
1569 let (payment_address, payment_public_key) = if self.scheme == AddressScheme::Dual {
1570 let pay_desc = format!("wpkh({}/84'/{coin_type}'/{i}'/0/*)", self.master_xprv);
1571 let pay_change_desc =
1572 format!("wpkh({}/84'/{coin_type}'/{i}'/1/*)", self.master_xprv);
1573
1574 if let Ok(pw) = Wallet::create(pay_desc, pay_change_desc)
1575 .network(network)
1576 .create_wallet_no_persist()
1577 {
1578 (
1579 Some(
1580 pw.peek_address(KeychainKind::External, 0)
1581 .address
1582 .to_string(),
1583 ),
1584 Some(
1585 self.derive_public_key_internal(84, i, 0)
1586 .unwrap_or_default(),
1587 ),
1588 )
1589 } else {
1590 (None, None)
1591 }
1592 } else {
1593 (
1594 Some(taproot_address.clone()),
1595 Some(taproot_public_key.clone()),
1596 )
1597 };
1598
1599 accounts.push(Account {
1600 index: i,
1601 label: format!("Account {}", i + 1),
1602 taproot_address,
1603 taproot_public_key,
1604 payment_address,
1605 payment_public_key,
1606 });
1607 }
1608 }
1609 accounts
1610 }
1611
1612 fn child_hardened(index: u32) -> Result<bdk_wallet::bitcoin::bip32::ChildNumber, String> {
1613 bdk_wallet::bitcoin::bip32::ChildNumber::from_hardened_idx(index)
1614 .map_err(|e| format!("Invalid hardened child index {index}: {e}"))
1615 }
1616
1617 fn child_normal(index: u32) -> Result<bdk_wallet::bitcoin::bip32::ChildNumber, String> {
1618 bdk_wallet::bitcoin::bip32::ChildNumber::from_normal_idx(index)
1619 .map_err(|e| format!("Invalid normal child index {index}: {e}"))
1620 }
1621
1622 fn account_discovery_plan_from_xprv(
1623 master_xprv: bdk_wallet::bitcoin::bip32::Xpriv,
1624 network: Network,
1625 scheme: AddressScheme,
1626 account_index: u32,
1627 ) -> Result<DiscoveryAccountPlan, String> {
1628 use bitcoin::secp256k1::Secp256k1;
1629
1630 let secp = Secp256k1::new();
1631 let coin_type = if network == Network::Bitcoin { 0 } else { 1 };
1632
1633 let vault_path = [
1634 Self::child_hardened(86)?,
1635 Self::child_hardened(coin_type)?,
1636 Self::child_hardened(account_index)?,
1637 ];
1638 let vault_account_xprv = master_xprv.derive_priv(&secp, &vault_path).map_err(|e| {
1639 format!("Failed to derive taproot account xprv for account {account_index}: {e}")
1640 })?;
1641 let vault_account_xpub =
1642 bdk_wallet::bitcoin::bip32::Xpub::from_priv(&secp, &vault_account_xprv);
1643 let taproot_descriptor = format!("tr({}/0/*)", vault_account_xpub);
1644 let taproot_change_descriptor = format!("tr({}/1/*)", vault_account_xpub);
1645
1646 let vault_pub_path = [Self::child_normal(0)?, Self::child_normal(0)?];
1647 let vault_pubkey = vault_account_xpub
1648 .derive_pub(&secp, &vault_pub_path)
1649 .map_err(|e| {
1650 format!("Failed to derive taproot public key for account {account_index}: {e}")
1651 })?
1652 .public_key;
1653 let taproot_public_key = vault_pubkey.x_only_public_key().0.to_string();
1654
1655 let (payment_descriptor, payment_change_descriptor, payment_public_key) = if scheme
1656 == AddressScheme::Dual
1657 {
1658 let payment_path = [
1659 Self::child_hardened(84)?,
1660 Self::child_hardened(coin_type)?,
1661 Self::child_hardened(account_index)?,
1662 ];
1663 let payment_account_xprv =
1664 master_xprv.derive_priv(&secp, &payment_path).map_err(|e| {
1665 format!(
1666 "Failed to derive payment account xprv for account {account_index}: {e}"
1667 )
1668 })?;
1669 let payment_account_xpub =
1670 bdk_wallet::bitcoin::bip32::Xpub::from_priv(&secp, &payment_account_xprv);
1671 let payment_pubkey = payment_account_xpub
1672 .derive_pub(&secp, &vault_pub_path)
1673 .map_err(|e| {
1674 format!("Failed to derive payment public key for account {account_index}: {e}")
1675 })?
1676 .public_key
1677 .to_string();
1678
1679 (
1680 Some(format!("wpkh({}/0/*)", payment_account_xpub)),
1681 Some(format!("wpkh({}/1/*)", payment_account_xpub)),
1682 Some(payment_pubkey),
1683 )
1684 } else {
1685 (None, None, None)
1686 };
1687
1688 Ok(DiscoveryAccountPlan {
1689 index: account_index,
1690 taproot_descriptor,
1691 taproot_change_descriptor,
1692 taproot_public_key,
1693 payment_descriptor,
1694 payment_change_descriptor,
1695 payment_public_key,
1696 })
1697 }
1698
1699 fn build_discovery_context_from_xprv(
1700 master_xprv: bdk_wallet::bitcoin::bip32::Xpriv,
1701 network: Network,
1702 scheme: AddressScheme,
1703 start: u32,
1704 count: u32,
1705 ) -> Result<DiscoveryContext, String> {
1706 let mut accounts = Vec::new();
1707 let end = start.saturating_add(count);
1708
1709 for account_index in start..end {
1710 accounts.push(Self::account_discovery_plan_from_xprv(
1711 master_xprv,
1712 network,
1713 scheme,
1714 account_index,
1715 )?);
1716 }
1717
1718 Ok(DiscoveryContext {
1719 network,
1720 scheme,
1721 accounts,
1722 })
1723 }
1724
1725 pub fn build_discovery_context(
1727 &self,
1728 start: u32,
1729 count: u32,
1730 ) -> Result<DiscoveryContext, String> {
1731 Self::build_discovery_context_from_xprv(
1732 self.master_xprv,
1733 self.vault_wallet.network(),
1734 self.scheme,
1735 start,
1736 count,
1737 )
1738 }
1739
1740 pub async fn discover_active_accounts(
1742 &self,
1743 esplora_url: &str,
1744 count: u32,
1745 gap: u32,
1746 ) -> Result<Vec<Account>, String> {
1747 self.discover_active_accounts_range(esplora_url, 0, count, gap)
1748 .await
1749 }
1750
1751 pub async fn discover_active_accounts_range(
1753 &self,
1754 esplora_url: &str,
1755 start: u32,
1756 count: u32,
1757 gap: u32,
1758 ) -> Result<Vec<Account>, String> {
1759 let context = self.build_discovery_context(start, count)?;
1760 Self::discover_accounts_with_context(context, esplora_url, gap).await
1761 }
1762
1763 pub async fn discover_accounts_with_context(
1765 context: DiscoveryContext,
1766 esplora_url: &str,
1767 gap: u32,
1768 ) -> Result<Vec<Account>, String> {
1769 let client = esplora_client::Builder::new(esplora_url)
1770 .build_async_with_sleeper::<SyncSleeper>()
1771 .map_err(|e| format!("{e:?}"))?;
1772
1773 let mut active_accounts = Vec::new();
1774
1775 for plan in context.accounts {
1776 let vault_wallet = Wallet::create(
1777 plan.taproot_descriptor.clone(),
1778 plan.taproot_change_descriptor.clone(),
1779 )
1780 .network(context.network)
1781 .create_wallet_no_persist()
1782 .map_err(|e| e.to_string())?;
1783
1784 let mut has_activity = false;
1785
1786 for i in 0..gap {
1787 let ext = vault_wallet.peek_address(KeychainKind::External, i).address;
1788 let stats = client
1789 .get_address_stats(&ext)
1790 .await
1791 .map_err(|e| e.to_string())?;
1792 if stats.chain_stats.tx_count > 0 || stats.mempool_stats.tx_count > 0 {
1793 has_activity = true;
1794 break;
1795 }
1796
1797 let change = vault_wallet.peek_address(KeychainKind::Internal, i).address;
1798 let stats = client
1799 .get_address_stats(&change)
1800 .await
1801 .map_err(|e| e.to_string())?;
1802 if stats.chain_stats.tx_count > 0 || stats.mempool_stats.tx_count > 0 {
1803 has_activity = true;
1804 break;
1805 }
1806 }
1807
1808 let mut payment_wallet: Option<Wallet> = None;
1809 if let (Some(pay_desc), Some(pay_change_desc)) = (
1810 plan.payment_descriptor.as_ref(),
1811 plan.payment_change_descriptor.as_ref(),
1812 ) {
1813 if let Ok(created_wallet) =
1814 Wallet::create(pay_desc.clone(), pay_change_desc.clone())
1815 .network(context.network)
1816 .create_wallet_no_persist()
1817 {
1818 if !has_activity {
1819 for i in 0..gap {
1820 let ext = created_wallet
1821 .peek_address(KeychainKind::External, i)
1822 .address;
1823 let stats = client
1824 .get_address_stats(&ext)
1825 .await
1826 .map_err(|e| e.to_string())?;
1827 if stats.chain_stats.tx_count > 0 || stats.mempool_stats.tx_count > 0 {
1828 has_activity = true;
1829 break;
1830 }
1831
1832 let change = created_wallet
1833 .peek_address(KeychainKind::Internal, i)
1834 .address;
1835 let stats = client
1836 .get_address_stats(&change)
1837 .await
1838 .map_err(|e| e.to_string())?;
1839 if stats.chain_stats.tx_count > 0 || stats.mempool_stats.tx_count > 0 {
1840 has_activity = true;
1841 break;
1842 }
1843 }
1844 }
1845 payment_wallet = Some(created_wallet);
1846 }
1847 }
1848
1849 if has_activity {
1850 let taproot_address = vault_wallet
1851 .peek_address(KeychainKind::External, 0)
1852 .address
1853 .to_string();
1854 let taproot_public_key = plan.taproot_public_key.clone();
1855 let payment_address = if context.scheme == AddressScheme::Dual {
1856 payment_wallet.as_ref().map(|wallet| {
1857 wallet
1858 .peek_address(KeychainKind::External, 0)
1859 .address
1860 .to_string()
1861 })
1862 } else {
1863 Some(taproot_address.clone())
1864 };
1865 let payment_public_key = if context.scheme == AddressScheme::Dual {
1866 plan.payment_public_key.clone()
1867 } else {
1868 Some(taproot_public_key.clone())
1869 };
1870
1871 active_accounts.push(Account {
1872 index: plan.index,
1873 label: format!("Account {}", plan.index + 1),
1874 taproot_address,
1875 taproot_public_key,
1876 payment_address,
1877 payment_public_key,
1878 });
1879 }
1880 }
1881
1882 Ok(active_accounts)
1883 }
1884
1885 pub fn set_active_account(&mut self, index: u32) -> Result<(), String> {
1887 if self.account_index == index {
1888 return Ok(());
1889 }
1890
1891 let network = self.vault_wallet.network();
1892 let coin_type = i32::from(network != Network::Bitcoin);
1893
1894 let vault_desc = format!("tr({}/86'/{coin_type}'/{index}'/0/*)", self.master_xprv);
1896 let vault_change_desc = format!("tr({}/86'/{coin_type}'/{index}'/1/*)", self.master_xprv);
1897
1898 let next_vault_wallet = Wallet::create(vault_desc, vault_change_desc)
1899 .network(network)
1900 .create_wallet_no_persist()
1901 .map_err(|e| e.to_string())?;
1902
1903 let next_payment_wallet = if self.scheme == AddressScheme::Dual {
1905 let pay_desc = format!("wpkh({}/84'/{coin_type}'/{index}'/0/*)", self.master_xprv);
1906 let pay_change_desc =
1907 format!("wpkh({}/84'/{coin_type}'/{index}'/1/*)", self.master_xprv);
1908
1909 Some(
1910 Wallet::create(pay_desc, pay_change_desc)
1911 .network(network)
1912 .create_wallet_no_persist()
1913 .map_err(|e| e.to_string())?,
1914 )
1915 } else {
1916 None
1917 };
1918
1919 self.account_index = index;
1920 self.vault_wallet = next_vault_wallet;
1921 self.payment_wallet = next_payment_wallet;
1922 self.loaded_vault_changeset = bdk_wallet::ChangeSet::default();
1923 self.loaded_payment_changeset = None;
1924 self.inscribed_utxos.clear();
1925 self.inscriptions.clear();
1926 self.ordinals_verified = false;
1927 self.ordinals_metadata_complete = false;
1928 self.is_syncing = false;
1929 self.account_generation = self.account_generation.wrapping_add(1);
1930
1931 Ok(())
1932 }
1933
1934 pub fn set_address_scheme(&mut self, scheme: AddressScheme) -> Result<(), String> {
1936 if self.scheme == scheme {
1937 return Ok(());
1938 }
1939
1940 self.scheme = scheme;
1941 let network = self.vault_wallet.network();
1942 let index = self.account_index;
1943 let coin_type = i32::from(network != Network::Bitcoin);
1944
1945 if scheme == AddressScheme::Dual {
1946 let pay_desc = format!("wpkh({}/84'/{coin_type}'/{index}'/0/*)", self.master_xprv);
1947 let pay_change_desc =
1948 format!("wpkh({}/84'/{coin_type}'/{index}'/1/*)", self.master_xprv);
1949
1950 self.payment_wallet = Some(
1951 Wallet::create(pay_desc, pay_change_desc)
1952 .network(network)
1953 .create_wallet_no_persist()
1954 .map_err(|e| e.to_string())?,
1955 );
1956 } else {
1957 self.payment_wallet = None;
1958 }
1959
1960 Ok(())
1961 }
1962
1963 fn derive_public_key_internal(
1964 &self,
1965 purpose: u32,
1966 account: u32,
1967 index: u32,
1968 ) -> Result<String, String> {
1969 use bitcoin::secp256k1::Secp256k1;
1970 let secp = Secp256k1::new();
1971
1972 let coin_type = if self.vault_wallet.network() == Network::Bitcoin {
1973 0
1974 } else {
1975 1
1976 };
1977 let chain = 0;
1978
1979 let derivation_path = [
1980 bdk_wallet::bitcoin::bip32::ChildNumber::from_hardened_idx(purpose).unwrap(),
1981 bdk_wallet::bitcoin::bip32::ChildNumber::from_hardened_idx(coin_type).unwrap(),
1982 bdk_wallet::bitcoin::bip32::ChildNumber::from_hardened_idx(account).unwrap(),
1983 bdk_wallet::bitcoin::bip32::ChildNumber::from_normal_idx(chain).unwrap(),
1984 bdk_wallet::bitcoin::bip32::ChildNumber::from_normal_idx(index).unwrap(),
1985 ];
1986
1987 let child_xprv = self
1988 .master_xprv
1989 .derive_priv(&secp, &derivation_path)
1990 .map_err(|e| format!("Key derivation failed: {e}"))?;
1991
1992 let public_key = child_xprv.private_key.public_key(&secp);
1993
1994 if purpose == 86 {
1996 let (x_only, _parity) = public_key.x_only_public_key();
1998 Ok(x_only.to_string())
1999 } else {
2000 Ok(public_key.to_string())
2002 }
2003 }
2004}
2005
2006#[derive(serde::Serialize, serde::Deserialize, Clone)]
2008pub struct ZincPersistence {
2009 #[serde(default, alias = "vault")]
2011 pub taproot: Option<bdk_wallet::ChangeSet>,
2012 pub payment: Option<bdk_wallet::ChangeSet>,
2014}
2015
2016impl WalletBuilder {
2017 pub fn from_seed(network: Network, seed: Seed64) -> Self {
2019 Self {
2020 network,
2021 seed: seed.as_ref().to_vec(),
2022 scheme: AddressScheme::Unified,
2023 persistence: None,
2024 account_index: 0,
2025 }
2026 }
2027
2028 pub fn from_mnemonic(network: Network, mnemonic: &ZincMnemonic) -> Self {
2030 let seed = mnemonic.to_seed("");
2031 Self::from_seed(network, Seed64::from_array(*seed))
2032 }
2033
2034 #[doc(hidden)]
2036 #[deprecated(note = "Use from_seed or from_mnemonic")]
2037 pub fn new(network: Network, seed: &[u8]) -> Self {
2038 Self {
2039 network,
2040 seed: seed.to_vec(),
2041 scheme: AddressScheme::Unified,
2042 persistence: None,
2043 account_index: 0,
2044 }
2045 }
2046
2047 #[must_use]
2048 pub fn with_scheme(mut self, scheme: AddressScheme) -> Self {
2050 self.scheme = scheme;
2051 self
2052 }
2053
2054 #[must_use]
2055 pub fn with_account_index(mut self, account_index: u32) -> Self {
2057 self.account_index = account_index;
2058 self
2059 }
2060
2061 #[must_use]
2062 pub fn with_persistence_state(mut self, persistence: ZincPersistence) -> Self {
2064 self.persistence = Some(persistence);
2065 self
2066 }
2067
2068 pub fn with_persistence(mut self, json: &str) -> Result<Self, String> {
2070 let parsed = serde_json::from_str::<ZincPersistence>(json)
2071 .map_err(|e| format!("Persistence deserialization failed: {e}"))?;
2072 self.persistence = Some(parsed);
2073 Ok(self)
2074 }
2075
2076 pub fn build(self) -> Result<ZincWallet, String> {
2078 let xprv = bdk_wallet::bitcoin::bip32::Xpriv::new_master(self.network, &self.seed)
2079 .map_err(|e| e.to_string())?;
2080
2081 let coin_type = i32::from(self.network != Network::Bitcoin);
2082 let account = self.account_index;
2083
2084 let vault_desc_str = format!("tr({}/86'/{coin_type}'/{account}'/0/*)", xprv);
2088 let vault_change_desc_str = format!("tr({}/86'/{coin_type}'/{account}'/1/*)", xprv);
2089
2090 let (vault_wallet, loaded_vault_changeset) = if let Some(p) = &self.persistence {
2091 let (wallet, changeset) = if let Some(changeset) = &p.taproot {
2092 let res = Wallet::load()
2094 .descriptor(KeychainKind::External, Some(vault_desc_str.clone()))
2095 .descriptor(KeychainKind::Internal, Some(vault_change_desc_str.clone()))
2096 .extract_keys()
2097 .load_wallet_no_persist(changeset.clone());
2098
2099 match res {
2100 Ok(Some(w)) => (w, changeset.clone()),
2101 Ok(None) => {
2102 let w = Wallet::create(vault_desc_str, vault_change_desc_str)
2103 .network(self.network)
2104 .create_wallet_no_persist()
2105 .map_err(|e| e.to_string())?;
2106 (w, bdk_wallet::ChangeSet::default())
2107 }
2108 Err(_e) => {
2109 let w = Wallet::create(vault_desc_str, vault_change_desc_str)
2110 .network(self.network)
2111 .create_wallet_no_persist()
2112 .map_err(|e| e.to_string())?;
2113 (w, bdk_wallet::ChangeSet::default())
2114 }
2115 }
2116 } else {
2117 let w = Wallet::create(vault_desc_str, vault_change_desc_str)
2118 .network(self.network)
2119 .create_wallet_no_persist()
2120 .map_err(|e| e.to_string())?;
2121 (w, bdk_wallet::ChangeSet::default())
2122 };
2123
2124 (wallet, changeset)
2125 } else {
2126 let wallet = Wallet::create(vault_desc_str, vault_change_desc_str)
2127 .network(self.network)
2128 .create_wallet_no_persist()
2129 .map_err(|e| e.to_string())?;
2130 (wallet, bdk_wallet::ChangeSet::default())
2131 };
2132
2133 let (payment_wallet, loaded_payment_changeset) = if self.scheme == AddressScheme::Dual {
2135 let payment_desc_str = format!("wpkh({}/84'/{coin_type}'/{account}'/0/*)", xprv);
2138 let payment_change_desc_str = format!("wpkh({}/84'/{coin_type}'/{account}'/1/*)", xprv);
2139
2140 let (wallet, changeset) = if let Some(p) = &self.persistence {
2141 if let Some(changeset) = &p.payment {
2142 let res = Wallet::load()
2143 .descriptor(KeychainKind::External, Some(payment_desc_str.clone()))
2144 .descriptor(
2145 KeychainKind::Internal,
2146 Some(payment_change_desc_str.clone()),
2147 )
2148 .extract_keys()
2149 .load_wallet_no_persist(changeset.clone());
2150
2151 match res {
2152 Ok(Some(w)) => (w, Some(changeset.clone())),
2153 Ok(None) => {
2154 let w = Wallet::create(
2155 payment_desc_str.clone(),
2156 payment_change_desc_str.clone(),
2157 )
2158 .network(self.network)
2159 .create_wallet_no_persist()
2160 .map_err(|e| e.to_string())?;
2161 (w, None)
2162 }
2163 Err(_e) => {
2164 let w = Wallet::create(
2165 payment_desc_str.clone(),
2166 payment_change_desc_str.clone(),
2167 )
2168 .network(self.network)
2169 .create_wallet_no_persist()
2170 .map_err(|e| e.to_string())?;
2171 (w, None)
2172 }
2173 }
2174 } else {
2175 let wallet =
2176 Wallet::create(payment_desc_str.clone(), payment_change_desc_str.clone())
2177 .network(self.network)
2178 .create_wallet_no_persist()
2179 .map_err(|e| e.to_string())?;
2180 (wallet, None)
2181 }
2182 } else {
2183 let wallet = Wallet::create(payment_desc_str, payment_change_desc_str)
2184 .network(self.network)
2185 .create_wallet_no_persist()
2186 .map_err(|e| e.to_string())?;
2187 (wallet, None)
2188 };
2189
2190 (Some(wallet), changeset)
2191 } else {
2192 (None, None)
2193 };
2194
2195 Ok(ZincWallet {
2196 vault_wallet,
2197 payment_wallet,
2198 scheme: self.scheme,
2199 loaded_vault_changeset,
2200 loaded_payment_changeset,
2201 account_index: self.account_index,
2202 inscribed_utxos: std::collections::HashSet::default(), inscriptions: Vec::new(),
2204 ordinals_verified: false,
2205 ordinals_metadata_complete: false,
2206 master_xprv: xprv,
2207 is_syncing: false,
2208 account_generation: 0,
2209 })
2210 }
2211}
2212
2213#[cfg(test)]
2214mod tests {
2215 use super::*;
2216 use crate::keys::ZincMnemonic;
2217
2218 #[test]
2219 fn test_builder_ignores_mismatched_persistence() {
2220 let mnemonic = ZincMnemonic::generate(12).unwrap();
2222 let seed = mnemonic.to_seed("");
2223 let network = Network::Regtest; let mut builder_acc1 = WalletBuilder::from_seed(network, Seed64::from_array(*seed));
2228 builder_acc1 = builder_acc1.with_account_index(1);
2229 let mut wallet_acc1 = builder_acc1.build().unwrap();
2230
2231 let acc1_address = wallet_acc1.next_taproot_address().unwrap().to_string();
2232 let persistence_json = wallet_acc1.export_changeset().unwrap();
2233 let persistence_str = serde_json::to_string(&persistence_json).unwrap();
2234
2235 let mut builder_acc0 = WalletBuilder::from_seed(network, Seed64::from_array(*seed));
2238 builder_acc0 = builder_acc0
2239 .with_account_index(0)
2240 .with_persistence(&persistence_str) .unwrap();
2242
2243 let mut wallet_acc0 = builder_acc0.build().unwrap();
2244 let acc0_address = wallet_acc0.next_taproot_address().unwrap().to_string();
2245
2246 assert_ne!(
2250 acc0_address, acc1_address,
2251 "Account 0 should have different address than Account 1"
2252 );
2253
2254 let mut pristine_acc0 = WalletBuilder::from_seed(network, Seed64::from_array(*seed))
2256 .with_account_index(0)
2257 .build()
2258 .unwrap();
2259 let expected_acc0_addr = pristine_acc0.next_taproot_address().unwrap().to_string();
2260
2261 assert_eq!(
2262 acc0_address, expected_acc0_addr,
2263 "Wallet should match clean Account 0 address, ignoring mismatched persistence"
2264 );
2265 }
2266
2267 #[test]
2268 fn test_persistence_cycle_mismatch() {
2269 let mnemonic = ZincMnemonic::generate(12).unwrap();
2274 let seed = mnemonic.to_seed("");
2275 let network = Network::Regtest;
2276
2277 let builder = WalletBuilder::from_seed(network, Seed64::from_array(*seed));
2279 let wallet = builder.build().unwrap();
2280
2281 let persistence_struct = wallet.export_changeset().unwrap();
2283 let persistence_str = serde_json::to_string(&persistence_struct).unwrap();
2284
2285 let mut builder_rehydrated = WalletBuilder::from_seed(network, Seed64::from_array(*seed));
2287 builder_rehydrated = builder_rehydrated
2288 .with_persistence(&persistence_str)
2289 .unwrap();
2290
2291 let res = builder_rehydrated.build();
2292 assert!(
2293 res.is_ok(),
2294 "Should build successfully with matching persistence"
2295 );
2296
2297 let wallet_rehydrated = res.unwrap();
2298
2299 assert!(
2306 wallet_rehydrated
2307 .loaded_vault_changeset
2308 .descriptor
2309 .is_some(),
2310 "Vault changeset descriptor should be loaded"
2311 );
2312 }
2313
2314 #[test]
2315 fn test_set_active_account_resets_account_scoped_state() {
2316 let mnemonic = ZincMnemonic::generate(12).unwrap();
2317 let seed = mnemonic.to_seed("");
2318 let network = Network::Regtest;
2319
2320 let mut wallet = WalletBuilder::from_seed(network, Seed64::from_array(*seed))
2321 .build()
2322 .unwrap();
2323 wallet.loaded_vault_changeset.network = Some(network);
2324 wallet.loaded_payment_changeset = Some(bdk_wallet::ChangeSet::default());
2325 wallet.inscribed_utxos.insert(bitcoin::OutPoint::null());
2326 wallet
2327 .inscriptions
2328 .push(crate::ordinals::types::Inscription {
2329 id: "testi0".to_string(),
2330 number: 1,
2331 satpoint: Default::default(),
2332 content_type: Some("image/png".to_string()),
2333 value: Some(1),
2334 content_length: None,
2335 timestamp: None,
2336 });
2337 wallet.ordinals_verified = true;
2338 let original_generation = wallet.account_generation;
2339
2340 wallet.set_active_account(1).unwrap();
2341
2342 assert_eq!(wallet.account_index, 1);
2343 assert!(wallet.loaded_vault_changeset.network.is_none());
2344 assert!(wallet.loaded_payment_changeset.is_none());
2345 assert!(wallet.inscribed_utxos.is_empty());
2346 assert!(wallet.inscriptions.is_empty());
2347 assert!(!wallet.ordinals_verified);
2348 assert_eq!(wallet.account_generation, original_generation + 1);
2349 }
2350
2351 #[test]
2352 fn test_unverified_inscription_cache_does_not_mark_verified() {
2353 let mnemonic = ZincMnemonic::generate(12).unwrap();
2354 let seed = mnemonic.to_seed("");
2355 let network = Network::Regtest;
2356
2357 let mut wallet = WalletBuilder::from_seed(network, Seed64::from_array(*seed))
2358 .build()
2359 .unwrap();
2360
2361 let mut protected = std::collections::HashSet::new();
2362 protected.insert(bitcoin::OutPoint::null());
2363 wallet.apply_verified_ordinals_update(Vec::new(), protected);
2364 assert!(wallet.ordinals_verified);
2365 assert!(!wallet.inscribed_utxos.is_empty());
2366
2367 let count =
2368 wallet.apply_unverified_inscriptions_cache(vec![crate::ordinals::types::Inscription {
2369 id: "testi0".to_string(),
2370 number: 1,
2371 satpoint: Default::default(),
2372 content_type: Some("image/png".to_string()),
2373 value: Some(1),
2374 content_length: None,
2375 timestamp: None,
2376 }]);
2377
2378 assert_eq!(count, 1);
2379 assert_eq!(wallet.inscriptions.len(), 1);
2380 assert!(wallet.inscribed_utxos.is_empty());
2381 assert!(!wallet.ordinals_verified);
2382 assert!(wallet.ordinals_metadata_complete);
2383 }
2384}