use bdk_chain::spk_client::{FullScanRequest, SyncRequest};
use bdk_chain::Merge;
use bdk_esplora::EsploraAsyncExt;
use bdk_wallet::{KeychainKind, Wallet};
use bitcoin::address::NetworkUnchecked;
use bitcoin::psbt::Psbt;
use bitcoin::{Address, Amount, FeeRate, Network, Transaction};
use serde::{Deserialize, Serialize};
use crate::error::ZincError;
use crate::keys::ZincMnemonic;
const LOG_TARGET_BUILDER: &str = "zinc_core::builder";
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct SignOptions {
pub sign_inputs: Option<Vec<usize>>,
pub sighash: Option<u8>,
#[serde(default)]
pub finalize: bool,
}
use zeroize::{Zeroize, ZeroizeOnDrop};
#[derive(Debug, Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)]
pub struct Seed64([u8; 64]);
impl Seed64 {
#[must_use]
pub const fn from_array(bytes: [u8; 64]) -> Self {
Self(bytes)
}
}
impl AsRef<[u8]> for Seed64 {
fn as_ref(&self) -> &[u8] {
&self.0
}
}
impl TryFrom<&[u8]> for Seed64 {
type Error = ZincError;
fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
let array: [u8; 64] = value.try_into().map_err(|_| {
ZincError::ConfigError(format!(
"Invalid seed length: {}. Expected 64 bytes.",
value.len()
))
})?;
Ok(Self(array))
}
}
#[derive(Debug, Clone)]
pub struct CreatePsbtRequest {
pub recipient: Address<NetworkUnchecked>,
pub amount: Amount,
pub fee_rate: FeeRate,
}
impl CreatePsbtRequest {
pub fn from_parts(
recipient: &str,
amount_sats: u64,
fee_rate_sat_vb: u64,
) -> Result<Self, ZincError> {
let recipient = recipient
.parse::<Address<NetworkUnchecked>>()
.map_err(|e| ZincError::ConfigError(format!("Invalid address: {e}")))?;
let fee_rate = FeeRate::from_sat_per_vb(fee_rate_sat_vb)
.ok_or_else(|| ZincError::ConfigError("Invalid fee rate".to_string()))?;
Ok(Self {
recipient,
amount: Amount::from_sat(amount_sats),
fee_rate,
})
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CreatePsbtTransportRequest {
pub recipient: String,
pub amount_sats: u64,
pub fee_rate_sat_vb: u64,
}
impl TryFrom<CreatePsbtTransportRequest> for CreatePsbtRequest {
type Error = ZincError;
fn try_from(value: CreatePsbtTransportRequest) -> Result<Self, Self::Error> {
Self::from_parts(&value.recipient, value.amount_sats, value.fee_rate_sat_vb)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AddressScheme {
Unified,
Dual,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[derive(Default)]
pub enum PaymentAddressType {
#[default]
NativeSegwit,
NestedSegwit,
Legacy,
}
impl PaymentAddressType {
#[must_use]
pub fn purpose(self) -> u32 {
match self {
Self::NativeSegwit => 84,
Self::NestedSegwit => 49,
Self::Legacy => 44,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[derive(Default)]
pub enum DerivationMode {
#[default]
Account,
Index,
}
#[cfg(target_arch = "wasm32")]
#[derive(Debug, Clone, Copy, Default)]
pub struct WasmSleeper;
#[cfg(target_arch = "wasm32")]
pub struct WasmSleep(gloo_timers::future::TimeoutFuture);
#[cfg(target_arch = "wasm32")]
impl std::future::Future for WasmSleep {
type Output = ();
fn poll(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Self::Output> {
std::pin::Pin::new(&mut self.0).poll(cx)
}
}
#[cfg(target_arch = "wasm32")]
#[allow(unsafe_code)]
unsafe impl Send for WasmSleep {}
#[cfg(target_arch = "wasm32")]
impl esplora_client::Sleeper for WasmSleeper {
type Sleep = WasmSleep;
fn sleep(dur: std::time::Duration) -> Self::Sleep {
WasmSleep(gloo_timers::future::TimeoutFuture::new(
dur.as_millis() as u32
))
}
}
#[cfg(target_arch = "wasm32")]
pub type SyncSleeper = WasmSleeper;
#[cfg(not(target_arch = "wasm32"))]
#[derive(Debug, Clone, Copy, Default)]
pub struct TokioSleeper;
#[cfg(not(target_arch = "wasm32"))]
impl esplora_client::Sleeper for TokioSleeper {
type Sleep = tokio::time::Sleep;
fn sleep(dur: std::time::Duration) -> Self::Sleep {
tokio::time::sleep(dur)
}
}
#[cfg(not(target_arch = "wasm32"))]
pub type SyncSleeper = TokioSleeper;
pub fn now_unix() -> u64 {
#[cfg(target_arch = "wasm32")]
{
(js_sys::Date::now() / 1000.0) as u64
}
#[cfg(not(target_arch = "wasm32"))]
{
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
}
pub struct WalletBuilder {
network: Network,
seed: Vec<u8>,
scheme: AddressScheme,
derivation_mode: DerivationMode,
payment_address_type: PaymentAddressType,
persistence: Option<ZincPersistence>,
account_index: u32,
}
pub struct ZincWallet {
pub(crate) vault_wallet: Wallet,
pub(crate) payment_wallet: Option<Wallet>,
pub(crate) scheme: AddressScheme,
pub(crate) derivation_mode: DerivationMode,
pub(crate) payment_address_type: PaymentAddressType,
pub(crate) loaded_vault_changeset: bdk_wallet::ChangeSet,
pub(crate) loaded_payment_changeset: Option<bdk_wallet::ChangeSet>,
pub(crate) account_index: u32,
pub(crate) inscribed_utxos: std::collections::HashSet<bitcoin::OutPoint>,
pub(crate) inscriptions: Vec<crate::ordinals::types::Inscription>,
pub(crate) rune_balances: Vec<crate::ordinals::types::RuneBalance>,
pub(crate) ordinals_verified: bool,
pub(crate) ordinals_metadata_complete: bool,
master_xprv: bdk_wallet::bitcoin::bip32::Xpriv,
#[allow(dead_code)]
pub(crate) is_syncing: bool,
pub(crate) account_generation: u64,
}
pub enum SyncRequestType {
Full(FullScanRequest<KeychainKind>),
Incremental(SyncRequest<(KeychainKind, u32)>),
}
pub struct ZincSyncRequest {
pub taproot: SyncRequestType,
pub payment: Option<SyncRequestType>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
pub struct ZincBalance {
pub total: bdk_wallet::Balance,
pub spendable: bdk_wallet::Balance,
pub display_spendable: bdk_wallet::Balance,
pub inscribed: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Account {
pub index: u32,
pub label: String,
#[serde(alias = "vaultAddress")]
pub taproot_address: String,
#[serde(alias = "vaultPublicKey")]
pub taproot_public_key: String,
pub payment_address: Option<String>,
pub payment_public_key: Option<String>,
}
#[derive(Debug, Clone)]
pub struct DiscoveryAccountPlan {
pub index: u32,
pub taproot_descriptor: String,
pub taproot_change_descriptor: String,
pub taproot_public_key: String,
pub taproot_receive_index: u32,
pub payment_descriptor: Option<String>,
pub payment_change_descriptor: Option<String>,
pub payment_public_key: Option<String>,
pub payment_receive_index: Option<u32>,
}
#[derive(Debug, Clone)]
pub struct DiscoveryContext {
pub network: Network,
pub scheme: AddressScheme,
pub derivation_mode: DerivationMode,
pub payment_address_type: PaymentAddressType,
pub accounts: Vec<DiscoveryAccountPlan>,
}
fn payment_descriptor_for_xprv(
xprv: &bdk_wallet::bitcoin::bip32::Xpriv,
address_type: PaymentAddressType,
coin_type: i32,
account: u32,
chain: u32,
) -> String {
let purpose = address_type.purpose();
match address_type {
PaymentAddressType::NativeSegwit => {
format!("wpkh({xprv}/{purpose}'/{coin_type}'/{account}'/{chain}/*)")
}
PaymentAddressType::NestedSegwit => {
format!("sh(wpkh({xprv}/{purpose}'/{coin_type}'/{account}'/{chain}/*))")
}
PaymentAddressType::Legacy => {
format!("pkh({xprv}/{purpose}'/{coin_type}'/{account}'/{chain}/*)")
}
}
}
fn payment_descriptor_for_xpub(
xpub: &bdk_wallet::bitcoin::bip32::Xpub,
address_type: PaymentAddressType,
chain: u32,
) -> String {
match address_type {
PaymentAddressType::NativeSegwit => format!("wpkh({xpub}/{chain}/*)"),
PaymentAddressType::NestedSegwit => format!("sh(wpkh({xpub}/{chain}/*))"),
PaymentAddressType::Legacy => format!("pkh({xpub}/{chain}/*)"),
}
}
impl ZincWallet {
#[must_use]
pub fn inscriptions(&self) -> &[crate::ordinals::types::Inscription] {
&self.inscriptions
}
#[must_use]
pub fn rune_balances(&self) -> &[crate::ordinals::types::RuneBalance] {
&self.rune_balances
}
#[must_use]
pub fn account_generation(&self) -> u64 {
self.account_generation
}
#[must_use]
pub fn active_account_index(&self) -> u32 {
self.account_index
}
#[must_use]
pub fn is_syncing(&self) -> bool {
self.is_syncing
}
#[must_use]
pub fn ordinals_verified(&self) -> bool {
self.ordinals_verified
}
#[must_use]
pub fn ordinals_metadata_complete(&self) -> bool {
self.ordinals_metadata_complete
}
pub fn is_unified(&self) -> bool {
self.scheme == AddressScheme::Unified
}
#[must_use]
pub fn derivation_mode(&self) -> DerivationMode {
self.derivation_mode
}
#[must_use]
pub fn payment_address_type(&self) -> PaymentAddressType {
self.payment_address_type
}
fn logical_account_path(&self, logical_account_index: u32) -> (u32, u32) {
match self.derivation_mode {
DerivationMode::Account => (logical_account_index, 0),
DerivationMode::Index => (0, logical_account_index),
}
}
fn active_receive_index(&self) -> u32 {
self.logical_account_path(self.account_index).1
}
fn active_derivation_account(&self) -> u32 {
self.logical_account_path(self.account_index).0
}
fn dual_payment_purpose(&self) -> u32 {
self.payment_address_type.purpose()
}
pub fn needs_full_scan(&self) -> bool {
self.vault_wallet.local_chain().tip().height() == 0
}
pub fn next_taproot_address(&mut self) -> Result<Address, String> {
if self.derivation_mode == DerivationMode::Index {
return Ok(self.peek_taproot_address(0));
}
let info = self
.vault_wallet
.reveal_next_address(KeychainKind::External);
Ok(info.address)
}
pub fn peek_taproot_address(&self, index: u32) -> Address {
let resolved_index = self.active_receive_index().saturating_add(index);
self.vault_wallet
.peek_address(KeychainKind::External, resolved_index)
.address
}
pub fn get_payment_address(&mut self) -> Result<bitcoin::Address, String> {
if self.scheme == AddressScheme::Dual {
if self.derivation_mode == DerivationMode::Index {
return self
.peek_payment_address(0)
.ok_or_else(|| "Payment wallet not initialized".to_string());
}
if let Some(wallet) = &mut self.payment_wallet {
Ok(wallet.reveal_next_address(KeychainKind::External).address)
} else {
Err("Payment wallet not initialized".to_string())
}
} else {
self.next_taproot_address()
}
}
pub fn peek_payment_address(&self, index: u32) -> Option<Address> {
if self.scheme == AddressScheme::Dual {
let resolved_index = self.active_receive_index().saturating_add(index);
self.payment_wallet.as_ref().map(|w| {
w.peek_address(KeychainKind::External, resolved_index)
.address
})
} else {
Some(self.peek_taproot_address(index))
}
}
pub fn export_changeset(&self) -> Result<ZincPersistence, String> {
let mut vault_changeset = self.loaded_vault_changeset.clone();
if let Some(staged) = self.vault_wallet.staged() {
vault_changeset.merge(staged.clone());
}
let network = self.vault_wallet.network();
vault_changeset.network = Some(network);
vault_changeset.descriptor = Some(
self.vault_wallet
.public_descriptor(KeychainKind::External)
.clone(),
);
vault_changeset.change_descriptor = Some(
self.vault_wallet
.public_descriptor(KeychainKind::Internal)
.clone(),
);
let genesis_hash = bitcoin::blockdata::constants::genesis_block(network)
.header
.block_hash();
vault_changeset
.local_chain
.blocks
.entry(0)
.or_insert(Some(genesis_hash));
let mut payment_changeset = self.loaded_payment_changeset.clone();
if let Some(w) = &self.payment_wallet {
let mut pcs = payment_changeset.take().unwrap_or_default();
if let Some(staged) = w.staged() {
pcs.merge(staged.clone());
}
let net = w.network();
pcs.network = Some(net);
pcs.descriptor = Some(w.public_descriptor(KeychainKind::External).clone());
pcs.change_descriptor = Some(w.public_descriptor(KeychainKind::Internal).clone());
let gen_hash = bitcoin::blockdata::constants::genesis_block(net)
.header
.block_hash();
pcs.local_chain.blocks.entry(0).or_insert(Some(gen_hash));
payment_changeset = Some(pcs);
} else {
payment_changeset = None;
}
Ok(ZincPersistence {
taproot: Some(vault_changeset),
payment: payment_changeset,
})
}
pub async fn check_connection(esplora_url: &str) -> bool {
let client =
esplora_client::Builder::new(esplora_url).build_async_with_sleeper::<SyncSleeper>();
match client {
Ok(c) => c.get_height().await.is_ok(),
Err(_) => false,
}
}
pub fn prepare_requests(&self) -> ZincSyncRequest {
let now = now_unix();
let active_receive_index = self.active_receive_index();
let vault = SyncRequestType::Full(Self::main_only_full_scan_request(
&self.vault_wallet,
now,
active_receive_index,
));
let payment = self.payment_wallet.as_ref().map(|w| {
SyncRequestType::Full(Self::main_only_full_scan_request(
w,
now,
active_receive_index,
))
});
ZincSyncRequest {
taproot: vault,
payment,
}
}
pub fn apply_sync(
&mut self,
vault_update: impl Into<bdk_wallet::Update>,
payment_update: Option<impl Into<bdk_wallet::Update>>,
) -> Result<Vec<String>, String> {
let mut all_events = Vec::new();
let vault_events = self
.vault_wallet
.apply_update_events(vault_update)
.map_err(|e| e.to_string())?;
for event in vault_events {
all_events.push(format!("taproot:{event:?}"));
}
if let (Some(w), Some(u)) = (&mut self.payment_wallet, payment_update) {
let payment_events = w.apply_update_events(u).map_err(|e| e.to_string())?;
for event in payment_events {
all_events.push(format!("payment:{event:?}"));
}
}
Ok(all_events)
}
pub fn reset_sync_state(&mut self) -> Result<(), String> {
zinc_log_info!(
target: LOG_TARGET_BUILDER,
"resetting wallet sync state (chain mismatch recovery)"
);
let vault_desc = self
.vault_wallet
.public_descriptor(KeychainKind::External)
.to_string();
let vault_change_desc = self
.vault_wallet
.public_descriptor(KeychainKind::Internal)
.to_string();
let network = self.vault_wallet.network();
self.vault_wallet = Wallet::create(vault_desc, vault_change_desc)
.network(network)
.create_wallet_no_persist()
.map_err(|e| format!("Failed to reset taproot wallet: {e}"))?;
self.loaded_vault_changeset = bdk_wallet::ChangeSet::default();
if let Some(w) = &self.payment_wallet {
let pay_desc = w.public_descriptor(KeychainKind::External).to_string();
let pay_change_desc = w.public_descriptor(KeychainKind::Internal).to_string();
self.payment_wallet = Some(
Wallet::create(pay_desc, pay_change_desc)
.network(network)
.create_wallet_no_persist()
.map_err(|e| format!("Failed to reset payment wallet: {e}"))?,
);
self.loaded_payment_changeset = Some(bdk_wallet::ChangeSet::default());
}
self.account_generation += 1;
self.ordinals_verified = false;
self.ordinals_metadata_complete = false;
Ok(())
}
pub async fn sync(&mut self, esplora_url: &str) -> Result<Vec<String>, String> {
let client = esplora_client::Builder::new(esplora_url)
.build_async_with_sleeper::<SyncSleeper>()
.map_err(|e| format!("{e:?}"))?;
let now = now_unix();
let active_receive_index = self.active_receive_index();
let vault_req =
Self::main_only_full_scan_request(&self.vault_wallet, now, active_receive_index);
let payment_req = self
.payment_wallet
.as_ref()
.map(|w| Self::main_only_full_scan_request(w, now, active_receive_index));
let vault_update = client
.full_scan(vault_req, 20, 1)
.await
.map_err(|e| e.to_string())?;
let payment_update = if let Some(req) = payment_req {
Some(
client
.full_scan(req, 20, 1)
.await
.map_err(|e| e.to_string())?,
)
} else {
None
};
self.apply_sync(vault_update, payment_update)
}
pub fn collect_active_addresses(&self) -> Vec<String> {
let mut addresses = Vec::new();
let mut seen = std::collections::HashSet::new();
let taproot_main = self.peek_taproot_address(0).to_string();
if seen.insert(taproot_main.clone()) {
addresses.push(taproot_main);
}
if let Some(payment_main) = self
.peek_payment_address(0)
.map(|address| address.to_string())
{
if seen.insert(payment_main.clone()) {
addresses.push(payment_main);
}
}
addresses
}
pub fn apply_verified_ordinals_update(
&mut self,
inscriptions: Vec<crate::ordinals::types::Inscription>,
protected_outpoints: std::collections::HashSet<bitcoin::OutPoint>,
rune_balances: Vec<crate::ordinals::types::RuneBalance>,
) -> usize {
zinc_log_info!(
target: LOG_TARGET_BUILDER,
"applying ordinals update: {} inscriptions received",
inscriptions.len()
);
for inscription in &inscriptions {
zinc_log_debug!(
target: LOG_TARGET_BUILDER,
"inscribed outpoint updated: {}",
inscription.satpoint.outpoint
);
}
self.inscribed_utxos = protected_outpoints;
self.inscriptions = inscriptions;
self.rune_balances = rune_balances;
self.ordinals_verified = true;
self.ordinals_metadata_complete = true;
zinc_log_info!(
target: LOG_TARGET_BUILDER,
"total inscribed_utxos set size: {}",
self.inscribed_utxos.len()
);
self.inscriptions.len()
}
pub fn apply_unverified_inscriptions_cache(
&mut self,
inscriptions: Vec<crate::ordinals::types::Inscription>,
) -> usize {
zinc_log_info!(
target: LOG_TARGET_BUILDER,
"applying unverified inscription cache: {} inscriptions received",
inscriptions.len()
);
self.inscribed_utxos.clear();
self.inscriptions = inscriptions;
self.rune_balances.clear();
self.ordinals_verified = false;
self.ordinals_metadata_complete = true;
self.inscriptions.len()
}
fn verify_ord_indexer_is_current(
&mut self,
ord_height: u32,
wallet_height: u32,
) -> Result<(), String> {
if ord_height < wallet_height.saturating_sub(1) {
self.ordinals_verified = false;
return Err(format!(
"Ord Indexer is lagging! Ord: {ord_height}, Wallet: {wallet_height}. Safety lock engaged."
));
}
Ok(())
}
pub async fn sync_ordinals_protection(&mut self, ord_url: &str) -> Result<usize, String> {
self.ordinals_verified = false;
let addresses = self.collect_active_addresses();
let client = crate::ordinals::OrdClient::new(ord_url.to_string());
let ord_height = client
.get_indexing_height()
.await
.map_err(|e| e.to_string())?;
let wallet_height = self.vault_wallet.local_chain().tip().height();
self.verify_ord_indexer_is_current(ord_height, wallet_height)?;
let mut protected_outpoints = std::collections::HashSet::new();
for addr_str in addresses {
let snapshot = client
.get_address_asset_snapshot(&addr_str)
.await
.map_err(|e| format!("Failed to fetch for {addr_str}: {e}"))?;
let protected = client
.get_protected_outpoints_from_outputs(&snapshot.outputs)
.await
.map_err(|e| format!("Failed to fetch protected outputs for {addr_str}: {e}"))?;
protected_outpoints.extend(protected);
}
self.inscribed_utxos = protected_outpoints;
self.ordinals_verified = true;
Ok(self.inscribed_utxos.len())
}
pub async fn sync_ordinals_metadata(&mut self, ord_url: &str) -> Result<usize, String> {
self.ordinals_metadata_complete = false;
let addresses = self.collect_active_addresses();
let client = crate::ordinals::OrdClient::new(ord_url.to_string());
let ord_height = client
.get_indexing_height()
.await
.map_err(|e| e.to_string())?;
let wallet_height = self.vault_wallet.local_chain().tip().height();
self.verify_ord_indexer_is_current(ord_height, wallet_height)?;
let rune_balances = client
.get_rune_balances_for_addresses(&addresses)
.await
.map_err(|e| format!("Failed to fetch rune balances: {e}"))?;
let mut all_inscriptions = Vec::new();
for addr_str in addresses {
let snapshot = client
.get_address_asset_snapshot(&addr_str)
.await
.map_err(|e| format!("Failed to fetch for {addr_str}: {e}"))?;
for inscription_id in snapshot.inscription_ids {
let inscription = client
.get_inscription_details(&inscription_id)
.await
.map_err(|e| format!("Failed to fetch details for {inscription_id}: {e}"))?;
all_inscriptions.push(inscription);
}
}
self.inscriptions = all_inscriptions;
self.rune_balances = rune_balances;
self.ordinals_metadata_complete = true;
Ok(self.inscriptions.len())
}
pub async fn sync_ordinals(&mut self, ord_url: &str) -> Result<usize, String> {
self.sync_ordinals_protection(ord_url).await?;
self.sync_ordinals_metadata(ord_url).await
}
pub fn get_raw_balance(&self) -> bdk_wallet::Balance {
let vault_bal = self.vault_wallet.balance();
if let Some(payment_wallet) = &self.payment_wallet {
let pay_bal = payment_wallet.balance();
bdk_wallet::Balance {
immature: vault_bal.immature + pay_bal.immature,
trusted_pending: vault_bal.trusted_pending + pay_bal.trusted_pending,
untrusted_pending: vault_bal.untrusted_pending + pay_bal.untrusted_pending,
confirmed: vault_bal.confirmed + pay_bal.confirmed,
}
} else {
vault_bal
}
}
pub fn get_balance(&self) -> ZincBalance {
let raw = self.get_raw_balance();
let calc_balance = |wallet: &Wallet| {
let mut bal = bdk_wallet::Balance::default();
for utxo in wallet.list_unspent() {
if self.inscribed_utxos.contains(&utxo.outpoint) {
zinc_log_debug!(
target: LOG_TARGET_BUILDER,
"skipping inscribed UTXO while calculating balance: {:?}",
utxo.outpoint
);
continue;
}
match utxo.keychain {
KeychainKind::Internal | KeychainKind::External => {
match utxo.chain_position {
bdk_chain::ChainPosition::Confirmed { .. } => {
bal.confirmed += utxo.txout.value;
}
bdk_chain::ChainPosition::Unconfirmed { .. } => {
bal.trusted_pending += utxo.txout.value;
}
}
}
}
}
bal
};
let mut safe_bal = calc_balance(&self.vault_wallet);
if let Some(w) = &self.payment_wallet {
let p_bal = calc_balance(w);
safe_bal.confirmed += p_bal.confirmed;
safe_bal.trusted_pending += p_bal.trusted_pending;
safe_bal.untrusted_pending += p_bal.untrusted_pending;
safe_bal.immature += p_bal.immature;
}
let display_spendable = if let Some(payment_wallet) = &self.payment_wallet {
calc_balance(payment_wallet)
} else {
safe_bal.clone()
};
ZincBalance {
total: raw.clone(),
spendable: safe_bal.clone(),
display_spendable,
inscribed: raw
.confirmed
.to_sat()
.saturating_sub(safe_bal.confirmed.to_sat())
+ raw
.trusted_pending
.to_sat()
.saturating_sub(safe_bal.trusted_pending.to_sat()), }
}
pub fn create_psbt_tx(&mut self, request: &CreatePsbtRequest) -> Result<Psbt, ZincError> {
if !self.ordinals_verified {
return Err(ZincError::WalletError(
"Ordinals verification failed - safety lock engaged. Please retry sync."
.to_string(),
));
}
let active_receive_index = self.active_receive_index();
let wallet = if self.scheme == AddressScheme::Dual {
self.payment_wallet.as_mut().ok_or_else(|| {
ZincError::WalletError("Payment wallet not initialized".to_string())
})?
} else {
&mut self.vault_wallet
};
let recipient = request
.recipient
.clone()
.require_network(wallet.network())
.map_err(|e| ZincError::ConfigError(format!("Network mismatch: {e}")))?;
let change_script = wallet
.peek_address(KeychainKind::External, active_receive_index)
.script_pubkey();
let mut builder = wallet.build_tx();
if !self.inscribed_utxos.is_empty() {
builder.unspendable(self.inscribed_utxos.iter().copied().collect());
}
builder
.add_recipient(recipient.script_pubkey(), request.amount)
.fee_rate(request.fee_rate)
.drain_to(change_script);
builder
.finish()
.map_err(|e| ZincError::WalletError(format!("Failed to build tx: {e}")))
}
pub fn create_psbt_base64(&mut self, request: &CreatePsbtRequest) -> Result<String, ZincError> {
let psbt = self.create_psbt_tx(request)?;
Ok(Self::encode_psbt_base64(&psbt))
}
pub fn create_offer(
&mut self,
request: &crate::offer_create::CreateOfferRequest,
) -> Result<crate::offer_create::OfferCreateResultV1, ZincError> {
crate::offer_create::create_offer(self, request)
}
#[doc(hidden)]
#[deprecated(note = "Use create_psbt_base64 with CreatePsbtRequest")]
pub fn create_psbt(
&mut self,
recipient: &str,
amount_sats: u64,
fee_rate_sat_vb: u64,
) -> Result<String, String> {
let request = CreatePsbtRequest::from_parts(recipient, amount_sats, fee_rate_sat_vb)
.map_err(|e| e.to_string())?;
self.create_psbt_base64(&request).map_err(|e| e.to_string())
}
fn encode_psbt_base64(psbt: &Psbt) -> String {
use base64::Engine;
base64::engine::general_purpose::STANDARD.encode(psbt.serialize())
}
#[allow(deprecated)]
pub fn sign_psbt(
&mut self,
psbt_base64: &str,
options: Option<SignOptions>,
) -> Result<String, String> {
use base64::Engine;
let psbt_bytes = base64::engine::general_purpose::STANDARD
.decode(psbt_base64)
.map_err(|e| format!("Invalid base64: {e}"))?;
let mut psbt = Psbt::deserialize(&psbt_bytes).map_err(|e| format!("Invalid PSBT: {e}"))?;
use std::collections::HashMap;
let mut known_utxos = HashMap::new();
let collect_utxos = |w: &Wallet, map: &mut HashMap<bitcoin::OutPoint, bitcoin::TxOut>| {
for utxo in w.list_unspent() {
map.insert(utxo.outpoint, utxo.txout);
}
};
collect_utxos(&self.vault_wallet, &mut known_utxos);
if let Some(w) = &self.payment_wallet {
collect_utxos(w, &mut known_utxos);
}
for (i, input) in psbt.inputs.iter_mut().enumerate() {
if input.witness_utxo.is_none() && input.non_witness_utxo.is_none() {
let outpoint = psbt.unsigned_tx.input[i].previous_output;
if let Some(txout) = known_utxos.get(&outpoint) {
input.witness_utxo = Some(txout.clone());
}
}
}
let should_finalize = options.as_ref().is_some_and(|o| o.finalize);
let bdk_options = bdk_wallet::SignOptions {
trust_witness_utxo: true,
try_finalize: should_finalize,
..Default::default()
};
let mut inputs_to_sign: Option<Vec<usize>> = None;
if let Some(opts) = &options {
if let Some(sighash_u8) = opts.sighash {
let target_sighash =
bitcoin::psbt::PsbtSighashType::from_u32(u32::from(sighash_u8));
for input in &mut psbt.inputs {
input.sighash_type = Some(target_sighash);
}
}
inputs_to_sign = opts.sign_inputs.clone();
}
if let Some(indices) = inputs_to_sign.as_ref() {
let mut seen = std::collections::HashSet::new();
for index in indices {
if *index >= psbt.inputs.len() {
return Err(format!(
"Security Violation: sign_inputs index {} is out of bounds for {} inputs",
index,
psbt.inputs.len()
));
}
if !seen.insert(*index) {
return Err(format!(
"Security Violation: sign_inputs index {index} is duplicated"
));
}
let input = &psbt.inputs[*index];
if input.witness_utxo.is_none() && input.non_witness_utxo.is_none() {
return Err(format!(
"Security Violation: Requested input #{index} is missing UTXO metadata"
));
}
}
}
for (index, input) in psbt.inputs.iter().enumerate() {
if let Some(sighash) = input.sighash_type {
let value = sighash.to_u32();
let base_type = value & 0x1f;
let anyone_can_pay = (value & 0x80) != 0;
let is_allowed_base = base_type == 0 || base_type == 1;
if anyone_can_pay || !is_allowed_base {
return Err(format!(
"Security Violation: Sighash type is not allowed on input #{index} (value={value})"
));
}
}
}
let mut known_inscriptions: HashMap<(bitcoin::Txid, u32), Vec<(String, u64)>> =
HashMap::new();
for ins in &self.inscriptions {
known_inscriptions
.entry((ins.satpoint.outpoint.txid, ins.satpoint.outpoint.vout))
.or_default()
.push((ins.id.clone(), ins.satpoint.offset));
}
for items in known_inscriptions.values_mut() {
items.sort_by_key(|(_, offset)| *offset);
}
if let Err(e) = crate::ordinals::shield::audit_psbt(
&psbt,
&known_inscriptions,
inputs_to_sign.as_deref(),
self.vault_wallet.network(),
) {
return Err(format!("Security Violation: {e}"));
}
let original_psbt = if inputs_to_sign.is_some() {
Some(psbt.clone())
} else {
None
};
self.vault_wallet
.sign(&mut psbt, bdk_options.clone())
.map_err(|e| format!("Vault signing failed: {e}"))?;
if let Some(payment_wallet) = &self.payment_wallet {
payment_wallet
.sign(&mut psbt, bdk_options)
.map_err(|e| format!("Payment signing failed: {e}"))?;
}
self.sign_inscription_script_paths(&mut psbt, should_finalize, inputs_to_sign.as_deref())?;
if let Some(indices) = inputs_to_sign.as_ref() {
let original = original_psbt
.as_ref()
.ok_or_else(|| "Security Violation: missing original PSBT snapshot".to_string())?;
for (i, input) in psbt.inputs.iter_mut().enumerate() {
if !indices.contains(&i) {
*input = original.inputs[i].clone();
}
}
}
if let Some(indices) = inputs_to_sign.as_ref() {
let original = original_psbt
.as_ref()
.ok_or_else(|| "Security Violation: missing original PSBT snapshot".to_string())?;
for index in indices {
let before = &original.inputs[*index];
let after = &psbt.inputs[*index];
let signature_changed = before.tap_key_sig != after.tap_key_sig
|| before.tap_script_sigs != after.tap_script_sigs
|| before.partial_sigs != after.partial_sigs
|| before.final_script_witness != after.final_script_witness;
if !signature_changed {
return Err(format!(
"Security Violation: Requested input #{index} was not signed by this wallet"
));
}
}
}
let signed_bytes = psbt.serialize();
let signed_base64 = base64::engine::general_purpose::STANDARD.encode(&signed_bytes);
Ok(signed_base64)
}
pub fn analyze_psbt(&self, psbt_base64: &str) -> Result<String, String> {
use crate::ordinals::shield::analyze_psbt;
use base64::Engine;
use std::collections::HashMap;
let psbt_bytes = base64::engine::general_purpose::STANDARD
.decode(psbt_base64)
.map_err(|e| format!("Invalid base64: {e}"))?;
let mut psbt = match Psbt::deserialize(&psbt_bytes) {
Ok(p) => p,
Err(e) => {
return Err(format!("Invalid PSBT: {e}"));
}
};
let mut known_utxos = HashMap::new();
let collect_utxos = |w: &Wallet, map: &mut HashMap<bitcoin::OutPoint, bitcoin::TxOut>| {
for utxo in w.list_unspent() {
map.insert(utxo.outpoint, utxo.txout);
}
};
collect_utxos(&self.vault_wallet, &mut known_utxos);
if let Some(w) = &self.payment_wallet {
collect_utxos(w, &mut known_utxos);
}
let mut enriched_count = 0;
for (i, input) in psbt.inputs.iter_mut().enumerate() {
if input.witness_utxo.is_none() && input.non_witness_utxo.is_none() {
let outpoint = psbt.unsigned_tx.input[i].previous_output;
if let Some(txout) = known_utxos.get(&outpoint) {
input.witness_utxo = Some(txout.clone());
enriched_count += 1;
}
}
}
let mut known_inscriptions: HashMap<(bitcoin::Txid, u32), Vec<(String, u64)>> =
HashMap::new();
for ins in &self.inscriptions {
known_inscriptions
.entry((ins.satpoint.outpoint.txid, ins.satpoint.outpoint.vout))
.or_default()
.push((ins.id.clone(), ins.satpoint.offset));
}
for items in known_inscriptions.values_mut() {
items.sort_by_key(|(_, offset)| *offset);
}
let result = match analyze_psbt(&psbt, &known_inscriptions, self.vault_wallet.network()) {
Ok(r) => r,
Err(e) => {
return Err(e.to_string());
}
};
serde_json::to_string(&result).map_err(|e| e.to_string())
}
pub async fn broadcast(
&mut self,
signed_psbt_base64: &str,
esplora_url: &str,
) -> Result<String, String> {
use base64::Engine;
let psbt_bytes = base64::engine::general_purpose::STANDARD
.decode(signed_psbt_base64)
.map_err(|e| format!("Invalid base64: {e}"))?;
let psbt = Psbt::deserialize(&psbt_bytes).map_err(|e| format!("Invalid PSBT: {e}"))?;
let tx: Transaction = psbt
.extract_tx()
.map_err(|e| format!("Failed to extract tx: {e}"))?;
let client = esplora_client::Builder::new(esplora_url)
.build_async_with_sleeper::<SyncSleeper>()
.map_err(|e| format!("Failed to create client: {e:?}"))?;
let broadcast_res: Result<(), _> = client.broadcast(&tx).await;
broadcast_res.map_err(|e| format!("Broadcast failed: {e}"))?;
Ok(tx.compute_txid().to_string())
}
pub fn sign_message(&self, address: &str, message: &str) -> Result<String, String> {
use base64::Engine;
use bitcoin::hashes::Hash;
use bitcoin::secp256k1::{Message, Secp256k1};
let active_receive_index = self.active_receive_index();
let vault_addr = self
.vault_wallet
.peek_address(KeychainKind::External, active_receive_index)
.address
.to_string();
let (is_vault, is_payment) = if address == vault_addr {
(true, false)
} else if let Some(w) = &self.payment_wallet {
let pay_addr = w
.peek_address(KeychainKind::External, active_receive_index)
.address
.to_string();
(false, address == pay_addr)
} else {
(false, false)
};
if !is_vault && !is_payment {
return Err("Address not found in wallet".to_string());
}
let secp = Secp256k1::new();
let (purpose, chain) = if is_vault {
(86, 0)
} else {
(self.dual_payment_purpose(), 0)
};
let priv_key = self.derive_private_key(purpose, chain, 0)?;
let signature_hash = bitcoin::sign_message::signed_msg_hash(message);
let msg = Message::from_digest(signature_hash.to_byte_array());
let sig = secp.sign_ecdsa_recoverable(&msg, &priv_key);
let (rec_id, sig_bytes_compact) = sig.serialize_compact();
let mut header = 27 + u8::try_from(rec_id.to_i32()).unwrap();
header += 4;
let mut sig_bytes = Vec::with_capacity(65);
sig_bytes.push(header);
sig_bytes.extend_from_slice(&sig_bytes_compact);
Ok(base64::engine::general_purpose::STANDARD.encode(&sig_bytes))
}
pub fn get_pairing_secret_key_hex(&self) -> Result<String, String> {
let key = self.derive_private_key(86, 0, 0)?;
Ok(bytes_to_lower_hex(&key.secret_bytes()))
}
pub fn get_taproot_public_key(&self, index: u32) -> Result<String, String> {
self.derive_public_key(86, index)
}
pub fn get_payment_public_key(&self, index: u32) -> Result<String, String> {
let purpose = if self.scheme == AddressScheme::Dual {
self.dual_payment_purpose()
} else {
86
};
self.derive_public_key(purpose, index)
}
fn derive_public_key(&self, purpose: u32, index: u32) -> Result<String, String> {
let account = self.active_derivation_account();
let effective_index = self.active_receive_index().saturating_add(index);
self.derive_public_key_internal(purpose, account, effective_index)
}
fn derive_private_key(
&self,
purpose: u32,
chain: u32,
index: u32,
) -> Result<bitcoin::secp256k1::SecretKey, String> {
let account = self.active_derivation_account();
let effective_index = self.active_receive_index().saturating_add(index);
self.derive_private_key_internal(purpose, account, chain, effective_index)
}
fn derive_private_key_internal(
&self,
purpose: u32,
account: u32,
chain: u32,
index: u32,
) -> Result<bitcoin::secp256k1::SecretKey, String> {
use bitcoin::secp256k1::Secp256k1;
let secp = Secp256k1::new();
let coin_type = u32::from(self.vault_wallet.network() != Network::Bitcoin);
let derivation_path = [
bdk_wallet::bitcoin::bip32::ChildNumber::from_hardened_idx(purpose).unwrap(),
bdk_wallet::bitcoin::bip32::ChildNumber::from_hardened_idx(coin_type).unwrap(),
bdk_wallet::bitcoin::bip32::ChildNumber::from_hardened_idx(account).unwrap(),
bdk_wallet::bitcoin::bip32::ChildNumber::from_normal_idx(chain).unwrap(),
bdk_wallet::bitcoin::bip32::ChildNumber::from_normal_idx(index).unwrap(),
];
let child_xprv = self
.master_xprv
.derive_priv(&secp, &derivation_path)
.map_err(|e| format!("Key derivation failed: {e}"))?;
Ok(child_xprv.private_key)
}
fn sign_inscription_script_paths(
&self,
psbt: &mut Psbt,
should_finalize: bool,
allowed_inputs: Option<&[usize]>,
) -> Result<(), String> {
use bitcoin::secp256k1::{Keypair, Message, Secp256k1};
use bitcoin::sighash::{Prevouts, SighashCache, TapSighashType};
use bitcoin::taproot::TapLeafHash;
let secp = Secp256k1::new();
let coin_type = u32::from(self.vault_wallet.network() != Network::Bitcoin);
let derivation_path = [
bdk_wallet::bitcoin::bip32::ChildNumber::from_hardened_idx(86).unwrap(),
bdk_wallet::bitcoin::bip32::ChildNumber::from_hardened_idx(coin_type).unwrap(),
bdk_wallet::bitcoin::bip32::ChildNumber::from_hardened_idx(self.account_index).unwrap(),
bdk_wallet::bitcoin::bip32::ChildNumber::from_normal_idx(0).unwrap(), bdk_wallet::bitcoin::bip32::ChildNumber::from_normal_idx(0).unwrap(), ];
let ordinals_xprv = self
.master_xprv
.derive_priv(&secp, &derivation_path)
.map_err(|e| format!("Failed to derive ordinals key: {e}"))?;
let ordinals_keypair = Keypair::from_secret_key(&secp, &ordinals_xprv.private_key);
let (ordinals_xonly, _) = ordinals_keypair.x_only_public_key();
let prevouts: Vec<bitcoin::TxOut> = psbt
.inputs
.iter()
.map(|inp| {
inp.witness_utxo.clone().unwrap_or_else(|| {
bitcoin::TxOut {
value: bitcoin::Amount::ZERO,
script_pubkey: bitcoin::ScriptBuf::new(),
}
})
})
.collect();
for (i, input) in psbt.inputs.iter_mut().enumerate() {
if let Some(indices) = allowed_inputs {
if !indices.contains(&i) {
continue;
}
}
if !input.tap_script_sigs.is_empty()
|| input.tap_key_sig.is_some()
|| input.final_script_witness.is_some()
{
continue;
}
if input.tap_scripts.is_empty() {
continue;
}
let has_our_key_origin = input.tap_key_origins.keys().any(|k| *k == ordinals_xonly);
let has_matching_internal_key = input
.tap_internal_key
.is_some_and(|key| key == ordinals_xonly);
if !has_our_key_origin && !has_matching_internal_key {
continue;
}
let (control_block, (script, leaf_version)) = input
.tap_scripts
.iter()
.next()
.ok_or_else(|| format!("Input {i} has empty tap_scripts"))?;
let leaf_hash = TapLeafHash::from_script(script, *leaf_version);
let mut sighash_cache = SighashCache::new(&psbt.unsigned_tx);
let sighash = sighash_cache
.taproot_script_spend_signature_hash(
i,
&Prevouts::All(&prevouts),
leaf_hash,
TapSighashType::Default,
)
.map_err(|e| format!("Failed to compute script sighash for input {i}: {e}"))?;
let msg = Message::from_digest_slice(sighash.as_ref())
.map_err(|e| format!("Invalid sighash message: {e}"))?;
let signature = secp.sign_schnorr(&msg, &ordinals_keypair);
let tap_sig = bitcoin::taproot::Signature {
signature,
sighash_type: TapSighashType::Default,
};
let tap_sig_serialized = tap_sig.serialize();
input
.tap_script_sigs
.insert((ordinals_xonly, leaf_hash), tap_sig);
if should_finalize {
let mut witness = bitcoin::Witness::new();
witness.push(tap_sig_serialized);
witness.push(script.as_bytes());
witness.push(control_block.serialize());
input.final_script_witness = Some(witness);
}
}
Ok(())
}
pub fn get_accounts(&self, count: u32) -> Vec<Account> {
let mut accounts = Vec::new();
let network = self.vault_wallet.network();
let coin_type = i32::from(network != Network::Bitcoin);
for logical_index in 0..count {
let (derivation_account, receive_index) = self.logical_account_path(logical_index);
let vault_desc = format!(
"tr({}/86'/{coin_type}'/{derivation_account}'/0/*)",
self.master_xprv
);
let vault_change_desc = format!(
"tr({}/86'/{coin_type}'/{derivation_account}'/1/*)",
self.master_xprv
);
if let Ok(vw) = Wallet::create(vault_desc, vault_change_desc)
.network(network)
.create_wallet_no_persist()
{
let taproot_address = vw
.peek_address(KeychainKind::External, receive_index)
.address
.to_string();
let taproot_public_key = self
.derive_public_key_internal(86, derivation_account, receive_index)
.unwrap_or_default();
let (payment_address, payment_public_key) = if self.scheme == AddressScheme::Dual {
let pay_desc = payment_descriptor_for_xprv(
&self.master_xprv,
self.payment_address_type,
coin_type,
derivation_account,
0,
);
let pay_change_desc = payment_descriptor_for_xprv(
&self.master_xprv,
self.payment_address_type,
coin_type,
derivation_account,
1,
);
if let Ok(pw) = Wallet::create(pay_desc, pay_change_desc)
.network(network)
.create_wallet_no_persist()
{
(
Some(
pw.peek_address(KeychainKind::External, receive_index)
.address
.to_string(),
),
Some(
self.derive_public_key_internal(
self.dual_payment_purpose(),
derivation_account,
receive_index,
)
.unwrap_or_default(),
),
)
} else {
(None, None)
}
} else {
(
Some(taproot_address.clone()),
Some(taproot_public_key.clone()),
)
};
accounts.push(Account {
index: logical_index,
label: format!("Account {}", logical_index + 1),
taproot_address,
taproot_public_key,
payment_address,
payment_public_key,
});
}
}
accounts
}
fn child_hardened(index: u32) -> Result<bdk_wallet::bitcoin::bip32::ChildNumber, String> {
bdk_wallet::bitcoin::bip32::ChildNumber::from_hardened_idx(index)
.map_err(|e| format!("Invalid hardened child index {index}: {e}"))
}
fn child_normal(index: u32) -> Result<bdk_wallet::bitcoin::bip32::ChildNumber, String> {
bdk_wallet::bitcoin::bip32::ChildNumber::from_normal_idx(index)
.map_err(|e| format!("Invalid normal child index {index}: {e}"))
}
fn account_discovery_plan_from_xprv(
master_xprv: bdk_wallet::bitcoin::bip32::Xpriv,
network: Network,
scheme: AddressScheme,
derivation_mode: DerivationMode,
payment_address_type: PaymentAddressType,
logical_account_index: u32,
) -> Result<DiscoveryAccountPlan, String> {
use bitcoin::secp256k1::Secp256k1;
let secp = Secp256k1::new();
let coin_type = u32::from(network != Network::Bitcoin);
let (derivation_account, receive_index) = match derivation_mode {
DerivationMode::Account => (logical_account_index, 0),
DerivationMode::Index => (0, logical_account_index),
};
let vault_path = [
Self::child_hardened(86)?,
Self::child_hardened(coin_type)?,
Self::child_hardened(derivation_account)?,
];
let vault_account_xprv = master_xprv.derive_priv(&secp, &vault_path).map_err(|e| {
format!(
"Failed to derive taproot account xprv for account {logical_account_index}: {e}"
)
})?;
let vault_account_xpub =
bdk_wallet::bitcoin::bip32::Xpub::from_priv(&secp, &vault_account_xprv);
let taproot_descriptor = format!("tr({vault_account_xpub}/0/*)");
let taproot_change_descriptor = format!("tr({vault_account_xpub}/1/*)");
let vault_pub_path = [Self::child_normal(0)?, Self::child_normal(receive_index)?];
let vault_pubkey = vault_account_xpub
.derive_pub(&secp, &vault_pub_path)
.map_err(|e| {
format!(
"Failed to derive taproot public key for account {logical_account_index}: {e}"
)
})?
.public_key;
let taproot_public_key = vault_pubkey.x_only_public_key().0.to_string();
let (payment_descriptor, payment_change_descriptor, payment_public_key) = if scheme
== AddressScheme::Dual
{
let payment_path = [
Self::child_hardened(payment_address_type.purpose())?,
Self::child_hardened(coin_type)?,
Self::child_hardened(derivation_account)?,
];
let payment_account_xprv =
master_xprv.derive_priv(&secp, &payment_path).map_err(|e| {
format!(
"Failed to derive payment account xprv for account {logical_account_index}: {e}"
)
})?;
let payment_account_xpub =
bdk_wallet::bitcoin::bip32::Xpub::from_priv(&secp, &payment_account_xprv);
let payment_pubkey = payment_account_xpub
.derive_pub(&secp, &vault_pub_path)
.map_err(|e| {
format!(
"Failed to derive payment public key for account {logical_account_index}: {e}"
)
})?
.public_key
.to_string();
(
Some(payment_descriptor_for_xpub(
&payment_account_xpub,
payment_address_type,
0,
)),
Some(payment_descriptor_for_xpub(
&payment_account_xpub,
payment_address_type,
1,
)),
Some(payment_pubkey),
)
} else {
(None, None, None)
};
Ok(DiscoveryAccountPlan {
index: logical_account_index,
taproot_descriptor,
taproot_change_descriptor,
taproot_public_key,
taproot_receive_index: receive_index,
payment_descriptor,
payment_change_descriptor,
payment_public_key,
payment_receive_index: if scheme == AddressScheme::Dual {
Some(receive_index)
} else {
None
},
})
}
fn build_discovery_context_from_xprv(
master_xprv: bdk_wallet::bitcoin::bip32::Xpriv,
network: Network,
scheme: AddressScheme,
derivation_mode: DerivationMode,
payment_address_type: PaymentAddressType,
start: u32,
count: u32,
) -> Result<DiscoveryContext, String> {
let mut accounts = Vec::new();
let end = start.saturating_add(count);
for logical_account_index in start..end {
accounts.push(Self::account_discovery_plan_from_xprv(
master_xprv,
network,
scheme,
derivation_mode,
payment_address_type,
logical_account_index,
)?);
}
Ok(DiscoveryContext {
network,
scheme,
derivation_mode,
payment_address_type,
accounts,
})
}
pub fn build_discovery_context(
&self,
start: u32,
count: u32,
) -> Result<DiscoveryContext, String> {
Self::build_discovery_context_from_xprv(
self.master_xprv,
self.vault_wallet.network(),
self.scheme,
self.derivation_mode,
self.payment_address_type,
start,
count,
)
}
pub async fn discover_active_accounts(
&self,
esplora_url: &str,
count: u32,
gap: u32,
) -> Result<Vec<Account>, String> {
self.discover_active_accounts_range(esplora_url, 0, count, gap)
.await
}
pub async fn discover_active_accounts_range(
&self,
esplora_url: &str,
start: u32,
count: u32,
gap: u32,
) -> Result<Vec<Account>, String> {
let context = self.build_discovery_context(start, count)?;
Self::discover_accounts_with_context(context, esplora_url, gap).await
}
pub async fn discover_accounts_with_context(
context: DiscoveryContext,
esplora_url: &str,
_gap: u32,
) -> Result<Vec<Account>, String> {
let client = esplora_client::Builder::new(esplora_url)
.build_async_with_sleeper::<SyncSleeper>()
.map_err(|e| format!("{e:?}"))?;
let mut active_accounts = Vec::new();
for plan in context.accounts {
let vault_wallet = Wallet::create(
plan.taproot_descriptor.clone(),
plan.taproot_change_descriptor.clone(),
)
.network(context.network)
.create_wallet_no_persist()
.map_err(|e| e.to_string())?;
let taproot_main = vault_wallet
.peek_address(KeychainKind::External, plan.taproot_receive_index)
.address;
let taproot_stats = client
.get_address_stats(&taproot_main)
.await
.map_err(|e| e.to_string())?;
let mut has_activity =
taproot_stats.chain_stats.tx_count > 0 || taproot_stats.mempool_stats.tx_count > 0;
let mut payment_wallet: Option<Wallet> = None;
if let (Some(pay_desc), Some(pay_change_desc)) = (
plan.payment_descriptor.as_ref(),
plan.payment_change_descriptor.as_ref(),
) {
if let Ok(created_wallet) =
Wallet::create(pay_desc.clone(), pay_change_desc.clone())
.network(context.network)
.create_wallet_no_persist()
{
if !has_activity {
let payment_main = created_wallet
.peek_address(
KeychainKind::External,
plan.payment_receive_index.unwrap_or(0),
)
.address;
let payment_stats = client
.get_address_stats(&payment_main)
.await
.map_err(|e| e.to_string())?;
if payment_stats.chain_stats.tx_count > 0
|| payment_stats.mempool_stats.tx_count > 0
{
has_activity = true;
}
}
payment_wallet = Some(created_wallet);
}
}
if has_activity {
let taproot_address = vault_wallet
.peek_address(KeychainKind::External, plan.taproot_receive_index)
.address
.to_string();
let taproot_public_key = plan.taproot_public_key.clone();
let payment_address = if context.scheme == AddressScheme::Dual {
payment_wallet.as_ref().map(|wallet| {
wallet
.peek_address(
KeychainKind::External,
plan.payment_receive_index.unwrap_or(0),
)
.address
.to_string()
})
} else {
Some(taproot_address.clone())
};
let payment_public_key = if context.scheme == AddressScheme::Dual {
plan.payment_public_key.clone()
} else {
Some(taproot_public_key.clone())
};
active_accounts.push(Account {
index: plan.index,
label: format!("Account {}", plan.index + 1),
taproot_address,
taproot_public_key,
payment_address,
payment_public_key,
});
}
}
Ok(active_accounts)
}
fn main_only_full_scan_request(
wallet: &Wallet,
start_time: u64,
receive_index: u32,
) -> FullScanRequest<KeychainKind> {
let main_receive_spk = wallet
.peek_address(KeychainKind::External, receive_index)
.script_pubkey();
FullScanRequest::builder_at(start_time)
.chain_tip(wallet.local_chain().tip())
.spks_for_keychain(
KeychainKind::External,
std::iter::once((receive_index, main_receive_spk)),
)
.build()
}
fn build_vault_wallet_for_logical_account(
&self,
logical_account_index: u32,
) -> Result<Wallet, String> {
let network = self.vault_wallet.network();
let coin_type = i32::from(network != Network::Bitcoin);
let (derivation_account, _) = self.logical_account_path(logical_account_index);
let vault_desc = format!(
"tr({}/86'/{coin_type}'/{derivation_account}'/0/*)",
self.master_xprv
);
let vault_change_desc = format!(
"tr({}/86'/{coin_type}'/{derivation_account}'/1/*)",
self.master_xprv
);
Wallet::create(vault_desc, vault_change_desc)
.network(network)
.create_wallet_no_persist()
.map_err(|e| e.to_string())
}
fn build_payment_wallet_for_logical_account(
&self,
logical_account_index: u32,
) -> Result<Option<Wallet>, String> {
if self.scheme != AddressScheme::Dual {
return Ok(None);
}
let network = self.vault_wallet.network();
let coin_type = i32::from(network != Network::Bitcoin);
let (derivation_account, _) = self.logical_account_path(logical_account_index);
let pay_desc = payment_descriptor_for_xprv(
&self.master_xprv,
self.payment_address_type,
coin_type,
derivation_account,
0,
);
let pay_change_desc = payment_descriptor_for_xprv(
&self.master_xprv,
self.payment_address_type,
coin_type,
derivation_account,
1,
);
let wallet = Wallet::create(pay_desc, pay_change_desc)
.network(network)
.create_wallet_no_persist()
.map_err(|e| e.to_string())?;
Ok(Some(wallet))
}
pub fn set_active_account(&mut self, index: u32) -> Result<(), String> {
if self.account_index == index {
return Ok(());
}
let next_vault_wallet = self.build_vault_wallet_for_logical_account(index)?;
let next_payment_wallet = self.build_payment_wallet_for_logical_account(index)?;
self.account_index = index;
self.vault_wallet = next_vault_wallet;
self.payment_wallet = next_payment_wallet;
self.loaded_vault_changeset = bdk_wallet::ChangeSet::default();
self.loaded_payment_changeset = None;
self.inscribed_utxos.clear();
self.inscriptions.clear();
self.rune_balances.clear();
self.ordinals_verified = false;
self.ordinals_metadata_complete = false;
self.is_syncing = false;
self.account_generation = self.account_generation.wrapping_add(1);
Ok(())
}
pub fn set_address_scheme(&mut self, scheme: AddressScheme) -> Result<(), String> {
if self.scheme == scheme {
return Ok(());
}
self.scheme = scheme;
self.payment_wallet = self.build_payment_wallet_for_logical_account(self.account_index)?;
self.loaded_payment_changeset = None;
self.rune_balances.clear();
self.ordinals_verified = false;
self.ordinals_metadata_complete = false;
self.account_generation = self.account_generation.wrapping_add(1);
Ok(())
}
pub fn set_derivation_mode(&mut self, mode: DerivationMode) -> Result<(), String> {
if self.derivation_mode == mode {
return Ok(());
}
self.derivation_mode = mode;
self.vault_wallet = self.build_vault_wallet_for_logical_account(self.account_index)?;
self.payment_wallet = self.build_payment_wallet_for_logical_account(self.account_index)?;
self.loaded_vault_changeset = bdk_wallet::ChangeSet::default();
self.loaded_payment_changeset = None;
self.inscribed_utxos.clear();
self.inscriptions.clear();
self.rune_balances.clear();
self.ordinals_verified = false;
self.ordinals_metadata_complete = false;
self.is_syncing = false;
self.account_generation = self.account_generation.wrapping_add(1);
Ok(())
}
pub fn set_payment_address_type(
&mut self,
address_type: PaymentAddressType,
) -> Result<(), String> {
if self.payment_address_type == address_type {
return Ok(());
}
self.payment_address_type = address_type;
self.payment_wallet = self.build_payment_wallet_for_logical_account(self.account_index)?;
self.loaded_payment_changeset = None;
self.rune_balances.clear();
self.ordinals_verified = false;
self.ordinals_metadata_complete = false;
self.account_generation = self.account_generation.wrapping_add(1);
Ok(())
}
fn derive_public_key_internal(
&self,
purpose: u32,
account: u32,
index: u32,
) -> Result<String, String> {
use bitcoin::secp256k1::Secp256k1;
let secp = Secp256k1::new();
let secret_key = self.derive_private_key_internal(purpose, account, 0, index)?;
let public_key = secret_key.public_key(&secp);
if purpose == 86 {
let (x_only, _parity) = public_key.x_only_public_key();
Ok(x_only.to_string())
} else {
Ok(public_key.to_string())
}
}
}
fn bytes_to_lower_hex(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut output = String::with_capacity(bytes.len() * 2);
for byte in bytes {
output.push(HEX[(byte >> 4) as usize] as char);
output.push(HEX[(byte & 0x0f) as usize] as char);
}
output
}
#[derive(serde::Serialize, serde::Deserialize, Clone)]
pub struct ZincPersistence {
#[serde(default, alias = "vault")]
pub taproot: Option<bdk_wallet::ChangeSet>,
pub payment: Option<bdk_wallet::ChangeSet>,
}
impl WalletBuilder {
pub fn from_seed(network: Network, seed: Seed64) -> Self {
Self {
network,
seed: seed.as_ref().to_vec(),
scheme: AddressScheme::Unified,
derivation_mode: DerivationMode::Account,
payment_address_type: PaymentAddressType::NativeSegwit,
persistence: None,
account_index: 0,
}
}
pub fn from_mnemonic(network: Network, mnemonic: &ZincMnemonic) -> Self {
let seed = mnemonic.to_seed("");
Self::from_seed(network, Seed64::from_array(*seed))
}
#[doc(hidden)]
#[deprecated(note = "Use from_seed or from_mnemonic")]
pub fn new(network: Network, seed: &[u8]) -> Self {
Self {
network,
seed: seed.to_vec(),
scheme: AddressScheme::Unified,
derivation_mode: DerivationMode::Account,
payment_address_type: PaymentAddressType::NativeSegwit,
persistence: None,
account_index: 0,
}
}
#[must_use]
pub fn with_scheme(mut self, scheme: AddressScheme) -> Self {
self.scheme = scheme;
self
}
#[must_use]
pub fn with_account_index(mut self, account_index: u32) -> Self {
self.account_index = account_index;
self
}
#[must_use]
pub fn with_derivation_mode(mut self, derivation_mode: DerivationMode) -> Self {
self.derivation_mode = derivation_mode;
self
}
#[must_use]
pub fn with_payment_address_type(mut self, payment_address_type: PaymentAddressType) -> Self {
self.payment_address_type = payment_address_type;
self
}
#[must_use]
pub fn with_persistence_state(mut self, persistence: ZincPersistence) -> Self {
self.persistence = Some(persistence);
self
}
pub fn with_persistence(mut self, json: &str) -> Result<Self, String> {
let parsed = serde_json::from_str::<ZincPersistence>(json)
.map_err(|e| format!("Persistence deserialization failed: {e}"))?;
self.persistence = Some(parsed);
Ok(self)
}
pub fn build(self) -> Result<ZincWallet, String> {
let xprv = bdk_wallet::bitcoin::bip32::Xpriv::new_master(self.network, &self.seed)
.map_err(|e| e.to_string())?;
let coin_type = i32::from(self.network != Network::Bitcoin);
let (derivation_account, _receive_index) = match self.derivation_mode {
DerivationMode::Account => (self.account_index, 0),
DerivationMode::Index => (0, self.account_index),
};
let vault_desc_str = format!("tr({xprv}/86'/{coin_type}'/{derivation_account}'/0/*)");
let vault_change_desc_str =
format!("tr({xprv}/86'/{coin_type}'/{derivation_account}'/1/*)");
let (vault_wallet, loaded_vault_changeset) = if let Some(p) = &self.persistence {
let (wallet, changeset) = if let Some(changeset) = &p.taproot {
let res = Wallet::load()
.descriptor(KeychainKind::External, Some(vault_desc_str.clone()))
.descriptor(KeychainKind::Internal, Some(vault_change_desc_str.clone()))
.extract_keys()
.load_wallet_no_persist(changeset.clone());
match res {
Ok(Some(w)) => (w, changeset.clone()),
Ok(None) => {
let w = Wallet::create(vault_desc_str, vault_change_desc_str)
.network(self.network)
.create_wallet_no_persist()
.map_err(|e| e.to_string())?;
(w, bdk_wallet::ChangeSet::default())
}
Err(_e) => {
let w = Wallet::create(vault_desc_str, vault_change_desc_str)
.network(self.network)
.create_wallet_no_persist()
.map_err(|e| e.to_string())?;
(w, bdk_wallet::ChangeSet::default())
}
}
} else {
let w = Wallet::create(vault_desc_str, vault_change_desc_str)
.network(self.network)
.create_wallet_no_persist()
.map_err(|e| e.to_string())?;
(w, bdk_wallet::ChangeSet::default())
};
(wallet, changeset)
} else {
let wallet = Wallet::create(vault_desc_str, vault_change_desc_str)
.network(self.network)
.create_wallet_no_persist()
.map_err(|e| e.to_string())?;
(wallet, bdk_wallet::ChangeSet::default())
};
let (payment_wallet, loaded_payment_changeset) = if self.scheme == AddressScheme::Dual {
let payment_desc_str = payment_descriptor_for_xprv(
&xprv,
self.payment_address_type,
coin_type,
derivation_account,
0,
);
let payment_change_desc_str = payment_descriptor_for_xprv(
&xprv,
self.payment_address_type,
coin_type,
derivation_account,
1,
);
let (wallet, changeset) = if let Some(p) = &self.persistence {
if let Some(changeset) = &p.payment {
let res = Wallet::load()
.descriptor(KeychainKind::External, Some(payment_desc_str.clone()))
.descriptor(
KeychainKind::Internal,
Some(payment_change_desc_str.clone()),
)
.extract_keys()
.load_wallet_no_persist(changeset.clone());
match res {
Ok(Some(w)) => (w, Some(changeset.clone())),
Ok(None) => {
let w = Wallet::create(
payment_desc_str.clone(),
payment_change_desc_str.clone(),
)
.network(self.network)
.create_wallet_no_persist()
.map_err(|e| e.to_string())?;
(w, None)
}
Err(_e) => {
let w = Wallet::create(
payment_desc_str.clone(),
payment_change_desc_str.clone(),
)
.network(self.network)
.create_wallet_no_persist()
.map_err(|e| e.to_string())?;
(w, None)
}
}
} else {
let wallet =
Wallet::create(payment_desc_str.clone(), payment_change_desc_str.clone())
.network(self.network)
.create_wallet_no_persist()
.map_err(|e| e.to_string())?;
(wallet, None)
}
} else {
let wallet = Wallet::create(payment_desc_str, payment_change_desc_str)
.network(self.network)
.create_wallet_no_persist()
.map_err(|e| e.to_string())?;
(wallet, None)
};
(Some(wallet), changeset)
} else {
(None, None)
};
Ok(ZincWallet {
vault_wallet,
payment_wallet,
scheme: self.scheme,
derivation_mode: self.derivation_mode,
payment_address_type: self.payment_address_type,
loaded_vault_changeset,
loaded_payment_changeset,
account_index: self.account_index,
inscribed_utxos: std::collections::HashSet::default(), inscriptions: Vec::new(),
rune_balances: Vec::new(),
ordinals_verified: false,
ordinals_metadata_complete: false,
master_xprv: xprv,
is_syncing: false,
account_generation: 0,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::keys::ZincMnemonic;
#[test]
fn test_builder_ignores_mismatched_persistence() {
let mnemonic = ZincMnemonic::generate(12).unwrap();
let seed = mnemonic.to_seed("");
let network = Network::Regtest;
let mut builder_acc1 = WalletBuilder::from_seed(network, Seed64::from_array(*seed));
builder_acc1 = builder_acc1.with_account_index(1);
let mut wallet_acc1 = builder_acc1.build().unwrap();
let acc1_address = wallet_acc1.next_taproot_address().unwrap().to_string();
let persistence_json = wallet_acc1.export_changeset().unwrap();
let persistence_str = serde_json::to_string(&persistence_json).unwrap();
let mut builder_acc0 = WalletBuilder::from_seed(network, Seed64::from_array(*seed));
builder_acc0 = builder_acc0
.with_account_index(0)
.with_persistence(&persistence_str) .unwrap();
let mut wallet_acc0 = builder_acc0.build().unwrap();
let acc0_address = wallet_acc0.next_taproot_address().unwrap().to_string();
assert_ne!(
acc0_address, acc1_address,
"Account 0 should have different address than Account 1"
);
let mut pristine_acc0 = WalletBuilder::from_seed(network, Seed64::from_array(*seed))
.with_account_index(0)
.build()
.unwrap();
let expected_acc0_addr = pristine_acc0.next_taproot_address().unwrap().to_string();
assert_eq!(
acc0_address, expected_acc0_addr,
"Wallet should match clean Account 0 address, ignoring mismatched persistence"
);
}
#[test]
fn test_persistence_cycle_mismatch() {
let mnemonic = ZincMnemonic::generate(12).unwrap();
let seed = mnemonic.to_seed("");
let network = Network::Regtest;
let builder = WalletBuilder::from_seed(network, Seed64::from_array(*seed));
let wallet = builder.build().unwrap();
let persistence_struct = wallet.export_changeset().unwrap();
let persistence_str = serde_json::to_string(&persistence_struct).unwrap();
let mut builder_rehydrated = WalletBuilder::from_seed(network, Seed64::from_array(*seed));
builder_rehydrated = builder_rehydrated
.with_persistence(&persistence_str)
.unwrap();
let res = builder_rehydrated.build();
assert!(
res.is_ok(),
"Should build successfully with matching persistence"
);
let wallet_rehydrated = res.unwrap();
assert!(
wallet_rehydrated
.loaded_vault_changeset
.descriptor
.is_some(),
"Vault changeset descriptor should be loaded"
);
}
#[test]
fn test_set_active_account_resets_account_scoped_state() {
let mnemonic = ZincMnemonic::generate(12).unwrap();
let seed = mnemonic.to_seed("");
let network = Network::Regtest;
let mut wallet = WalletBuilder::from_seed(network, Seed64::from_array(*seed))
.build()
.unwrap();
wallet.loaded_vault_changeset.network = Some(network);
wallet.loaded_payment_changeset = Some(bdk_wallet::ChangeSet::default());
wallet.inscribed_utxos.insert(bitcoin::OutPoint::null());
wallet
.inscriptions
.push(crate::ordinals::types::Inscription {
id: "testi0".to_string(),
number: 1,
satpoint: Default::default(),
content_type: Some("image/png".to_string()),
value: Some(1),
content_length: None,
timestamp: None,
});
wallet
.rune_balances
.push(crate::ordinals::types::RuneBalance {
rune: "NO•ORDINARY•KIND".to_string(),
amount: "1".to_string(),
divisibility: Some(0),
symbol: Some("🚪".to_string()),
});
wallet.ordinals_verified = true;
let original_generation = wallet.account_generation;
wallet.set_active_account(1).unwrap();
assert_eq!(wallet.account_index, 1);
assert!(wallet.loaded_vault_changeset.network.is_none());
assert!(wallet.loaded_payment_changeset.is_none());
assert!(wallet.inscribed_utxos.is_empty());
assert!(wallet.inscriptions.is_empty());
assert!(wallet.rune_balances.is_empty());
assert!(!wallet.ordinals_verified);
assert_eq!(wallet.account_generation, original_generation + 1);
}
#[test]
fn test_unverified_inscription_cache_does_not_mark_verified() {
let mnemonic = ZincMnemonic::generate(12).unwrap();
let seed = mnemonic.to_seed("");
let network = Network::Regtest;
let mut wallet = WalletBuilder::from_seed(network, Seed64::from_array(*seed))
.build()
.unwrap();
let mut protected = std::collections::HashSet::new();
protected.insert(bitcoin::OutPoint::null());
wallet.apply_verified_ordinals_update(
Vec::new(),
protected,
vec![crate::ordinals::types::RuneBalance {
rune: "NO•ORDINARY•KIND".to_string(),
amount: "10".to_string(),
divisibility: Some(0),
symbol: Some("🚪".to_string()),
}],
);
assert!(wallet.ordinals_verified);
assert!(!wallet.inscribed_utxos.is_empty());
assert_eq!(wallet.rune_balances.len(), 1);
let count =
wallet.apply_unverified_inscriptions_cache(vec![crate::ordinals::types::Inscription {
id: "testi0".to_string(),
number: 1,
satpoint: Default::default(),
content_type: Some("image/png".to_string()),
value: Some(1),
content_length: None,
timestamp: None,
}]);
assert_eq!(count, 1);
assert_eq!(wallet.inscriptions.len(), 1);
assert!(wallet.inscribed_utxos.is_empty());
assert!(wallet.rune_balances.is_empty());
assert!(!wallet.ordinals_verified);
assert!(wallet.ordinals_metadata_complete);
}
#[test]
fn test_set_address_scheme_clears_rune_balances_and_ordinals_state() {
let mnemonic = ZincMnemonic::generate(12).unwrap();
let seed = mnemonic.to_seed("");
let network = Network::Regtest;
let mut wallet = WalletBuilder::from_seed(network, Seed64::from_array(*seed))
.with_scheme(AddressScheme::Dual)
.build()
.unwrap();
wallet
.rune_balances
.push(crate::ordinals::types::RuneBalance {
rune: "NO•ORDINARY•KIND".to_string(),
amount: "99".to_string(),
divisibility: Some(0),
symbol: Some("🚪".to_string()),
});
wallet.ordinals_verified = true;
wallet.ordinals_metadata_complete = true;
let original_generation = wallet.account_generation;
wallet.set_address_scheme(AddressScheme::Unified).unwrap();
assert_eq!(wallet.scheme, AddressScheme::Unified);
assert!(wallet.rune_balances.is_empty());
assert!(!wallet.ordinals_verified);
assert!(!wallet.ordinals_metadata_complete);
assert_eq!(wallet.account_generation, original_generation + 1);
}
#[test]
fn test_set_payment_address_type_clears_rune_balances_and_ordinals_state() {
let mnemonic = ZincMnemonic::generate(12).unwrap();
let seed = mnemonic.to_seed("");
let network = Network::Regtest;
let mut wallet = WalletBuilder::from_seed(network, Seed64::from_array(*seed))
.with_scheme(AddressScheme::Dual)
.build()
.unwrap();
wallet
.rune_balances
.push(crate::ordinals::types::RuneBalance {
rune: "NO•ORDINARY•KIND".to_string(),
amount: "21".to_string(),
divisibility: Some(0),
symbol: Some("🚪".to_string()),
});
wallet.ordinals_verified = true;
wallet.ordinals_metadata_complete = true;
let original_generation = wallet.account_generation;
wallet
.set_payment_address_type(PaymentAddressType::NestedSegwit)
.unwrap();
assert_eq!(
wallet.payment_address_type,
PaymentAddressType::NestedSegwit
);
assert!(wallet.rune_balances.is_empty());
assert!(!wallet.ordinals_verified);
assert!(!wallet.ordinals_metadata_complete);
assert_eq!(wallet.account_generation, original_generation + 1);
}
#[test]
fn test_collect_active_addresses_returns_only_main_addresses() {
let mnemonic = ZincMnemonic::generate(12).unwrap();
let seed = mnemonic.to_seed("");
let wallet = WalletBuilder::from_seed(Network::Regtest, Seed64::from_array(*seed))
.with_scheme(AddressScheme::Dual)
.build()
.unwrap();
let addresses = wallet.collect_active_addresses();
assert_eq!(addresses.len(), 2);
assert_eq!(addresses[0], wallet.peek_taproot_address(0).to_string());
assert_eq!(
addresses[1],
wallet
.peek_payment_address(0)
.expect("payment address")
.to_string()
);
}
#[test]
fn test_prepare_requests_scans_only_external_index_zero() {
let mnemonic = ZincMnemonic::generate(12).unwrap();
let seed = mnemonic.to_seed("");
let wallet = WalletBuilder::from_seed(Network::Regtest, Seed64::from_array(*seed))
.with_scheme(AddressScheme::Dual)
.build()
.unwrap();
let expected_taproot_spk = wallet.peek_taproot_address(0).script_pubkey();
let expected_payment_spk = wallet
.peek_payment_address(0)
.expect("payment address")
.script_pubkey();
let requests = wallet.prepare_requests();
match requests.taproot {
SyncRequestType::Full(mut req) => {
let keychains = req.keychains();
assert_eq!(keychains, vec![KeychainKind::External]);
let spks: Vec<_> = req.iter_spks(KeychainKind::External).collect();
assert_eq!(spks.len(), 1);
assert_eq!(spks[0].0, 0);
assert_eq!(spks[0].1, expected_taproot_spk);
}
SyncRequestType::Incremental(_) => panic!("expected full scan request"),
}
match requests.payment {
Some(SyncRequestType::Full(mut req)) => {
let keychains = req.keychains();
assert_eq!(keychains, vec![KeychainKind::External]);
let spks: Vec<_> = req.iter_spks(KeychainKind::External).collect();
assert_eq!(spks.len(), 1);
assert_eq!(spks[0].0, 0);
assert_eq!(spks[0].1, expected_payment_spk);
}
Some(SyncRequestType::Incremental(_)) => panic!("expected full scan request"),
None => panic!("expected payment request in dual mode"),
}
}
}