use bdk_chain::spk_client::{FullScanRequest, SyncRequest};
use bdk_chain::Merge;
use bdk_esplora::EsploraAsyncExt;
use bdk_wallet::{KeychainKind, Wallet};
use bitcoin::address::{AddressType, NetworkUnchecked};
use bitcoin::hashes::Hash;
use bitcoin::psbt::Psbt;
use bitcoin::{Address, Amount, FeeRate, Network, Transaction};
use crate::error::ZincError;
use crate::keys::ZincMnemonic;
use serde::{Deserialize, Serialize};
use std::str::FromStr;
const LOG_TARGET_BUILDER: &str = "zinc_core::builder";
fn wasm_now_secs() -> u64 {
#[cfg(target_arch = "wasm32")]
{
(js_sys::Date::now() / 1000.0) as u64
}
#[cfg(not(target_arch = "wasm32"))]
{
std::time::UNIX_EPOCH
.elapsed()
.unwrap_or_default()
.as_secs()
}
}
#[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,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ProfileMode {
Seed,
Watch,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScanPolicy {
pub account_gap_limit: u32,
pub address_scan_depth: u32,
}
impl Default for ScanPolicy {
fn default() -> Self {
Self {
account_gap_limit: 20,
address_scan_depth: 1,
}
}
}
#[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()
}
}
#[derive(Debug, Clone)]
pub enum WalletKind {
Seed {
master_xprv: bdk_wallet::bitcoin::bip32::Xpriv,
},
Hardware {
fingerprint: [u8; 4],
taproot_external: String,
payment_external: Option<String>,
},
WatchAddress(Address),
}
impl WalletKind {
#[must_use]
pub fn is_watch(&self) -> bool {
!matches!(self, Self::Seed { .. })
}
pub fn derive_descriptors(
&self,
scheme: AddressScheme,
payment_type: PaymentAddressType,
network: Network,
account: u32,
) -> (String, String, Option<String>, Option<String>) {
let coin_type = u32::from(network != Network::Bitcoin);
match self {
Self::Seed {
master_xprv: master,
} => {
let vault_ext = format!("tr({master}/86'/{coin_type}'/{account}'/0/*)");
let vault_int = format!("tr({master}/86'/{coin_type}'/{account}'/1/*)");
if scheme == AddressScheme::Dual {
let pay_ext =
payment_descriptor_for_xprv(master, payment_type, coin_type, account, 0);
let pay_int =
payment_descriptor_for_xprv(master, payment_type, coin_type, account, 1);
(vault_ext, vault_int, Some(pay_ext), Some(pay_int))
} else {
(vault_ext, vault_int, None, None)
}
}
Self::Hardware {
taproot_external,
payment_external,
..
} => {
(
taproot_external.clone(),
taproot_external.replace("/0/*", "/1/*"),
payment_external.clone(),
payment_external.as_ref().map(|e| e.replace("/0/*", "/1/*")),
)
}
Self::WatchAddress(address) => {
let descriptor = taproot_watch_descriptor(address)
.expect("watch-address identity must hold a validated taproot address");
(descriptor.clone(), descriptor, None, None)
}
}
}
}
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) mode: ProfileMode,
pub(crate) scan_policy: ScanPolicy,
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,
pub(crate) kind: WalletKind,
#[allow(dead_code)]
pub(crate) is_syncing: bool,
pub(crate) account_generation: u64,
pub(crate) force_next_full: bool,
}
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,
}
const MIN_SALVAGE_PADDING_SATS: u64 = 546;
const DEFAULT_DAPP_ADDRESS_SCAN_DEPTH: u32 = 20;
const DAPP_SIGN_ADDRESS_SEARCH_DEPTH: u32 = 1000;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UtxoItem {
pub txid: String,
pub vout: u32,
pub value_sats: u64,
pub address: Option<String>,
pub keychain: String,
pub wallet_role: String,
pub confirmed: bool,
pub confirmations: u32,
pub is_inscribed: bool,
pub has_inscription: bool,
pub inscription_ids: Vec<String>,
pub inscription_offsets: Vec<u64>,
pub cardinal_salvageable_sats: u64,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DappAddressCandidate {
pub address: String,
pub public_key: Option<String>,
pub address_type: String,
pub wallet_role: String,
pub keychain: String,
pub derivation_index: u32,
pub clean_btc_sats: u64,
pub clean_confirmed_btc_sats: u64,
pub protected_asset_count: u32,
pub inscription_ids: Vec<String>,
pub classification_complete: bool,
pub eligible_payment: bool,
pub eligible_ordinals: bool,
pub payment_disabled_reason: Option<String>,
pub ordinals_disabled_reason: Option<String>,
}
#[derive(Debug, Clone, Copy)]
struct OwnedAddressMatch {
purpose: u32,
keychain: KeychainKind,
absolute_index: u32,
}
#[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 kind: WalletKind,
pub accounts: Vec<DiscoveryAccountPlan>,
pub is_syncing: bool,
pub account_generation: u64,
}
fn payment_descriptor_for_xprv(
xprv: &bdk_wallet::bitcoin::bip32::Xpriv,
address_type: PaymentAddressType,
coin_type: u32,
account: u32,
chain: u32,
) -> String {
let pay_purpose = address_type.purpose();
match address_type {
PaymentAddressType::NativeSegwit => {
format!("wpkh({xprv}/{pay_purpose}'/{coin_type}'/{account}'/{chain}/*)")
}
PaymentAddressType::NestedSegwit => {
format!("sh(wpkh({xprv}/{pay_purpose}'/{coin_type}'/{account}'/{chain}/*))")
}
PaymentAddressType::Legacy => {
format!("pkh({xprv}/{pay_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}/*)"),
}
}
fn parse_extended_public_key(xpub: &str) -> Result<bdk_wallet::bitcoin::bip32::Xpub, String> {
use bdk_wallet::bitcoin::bip32::Xpub;
if let Ok(parsed) = Xpub::from_str(xpub) {
return Ok(parsed);
}
let mut data = bdk_wallet::bitcoin::base58::decode_check(xpub)
.map_err(|e| format!("Invalid extended public key: {e}"))?;
if data.len() != 78 {
return Err(format!(
"Invalid extended public key payload length: {} (expected 78)",
data.len()
));
}
let version: [u8; 4] = [data[0], data[1], data[2], data[3]];
let normalized_version = match version {
[0x04, 0x88, 0xB2, 0x1E]
| [0x04, 0x9D, 0x7C, 0xB2]
| [0x04, 0xB2, 0x47, 0x46]
| [0x02, 0x95, 0xB4, 0x3F]
| [0x02, 0xAA, 0x7E, 0xD3] => [0x04, 0x88, 0xB2, 0x1E],
[0x04, 0x35, 0x87, 0xCF]
| [0x04, 0x4A, 0x52, 0x62]
| [0x04, 0x5F, 0x1C, 0xF6]
| [0x02, 0x42, 0x89, 0xEF]
| [0x02, 0x57, 0x54, 0x83] => [0x04, 0x35, 0x87, 0xCF],
_ => {
return Err(
"Unsupported extended public key prefix (expected xpub/ypub/zpub/tpub/upub/vpub)"
.to_string(),
);
}
};
data[0..4].copy_from_slice(&normalized_version);
Xpub::decode(&data).map_err(|e| format!("Invalid extended public key: {e}"))
}
fn taproot_output_key_from_address(
address: &Address,
) -> Result<bitcoin::secp256k1::XOnlyPublicKey, String> {
if address.address_type() != Some(AddressType::P2tr) {
return Err(
"Address watch mode currently supports taproot (bc1p/tb1p/bcrt1p) addresses only"
.to_string(),
);
}
let witness_program = address
.witness_program()
.ok_or_else(|| "Taproot address missing witness program".to_string())?;
let key_bytes = witness_program.program().as_bytes();
if key_bytes.len() != 32 {
return Err(format!(
"Invalid taproot witness program length: {}",
key_bytes.len()
));
}
bitcoin::secp256k1::XOnlyPublicKey::from_slice(key_bytes)
.map_err(|e| format!("Invalid taproot output key: {e}"))
}
fn taproot_watch_descriptor(address: &Address) -> Result<String, String> {
let output_key = taproot_output_key_from_address(address)?;
Ok(format!("tr({output_key})"))
}
#[derive(Clone)]
pub struct WalletBuilder {
network: Network,
kind: Option<WalletKind>,
mode: ProfileMode,
scheme: AddressScheme,
derivation_mode: DerivationMode,
payment_address_type: PaymentAddressType,
persistence: Option<ZincPersistence>,
account_index: u32,
scan_policy: ScanPolicy,
}
impl ZincWallet {
fn watched_address(&self) -> Option<&Address> {
match &self.kind {
WalletKind::WatchAddress(address) => Some(address),
_ => None,
}
}
pub fn derive_public_key_internal(
&self,
purpose: u32,
_network: Network,
account: u32,
index: u32,
) -> Result<String, String> {
self.derive_public_key_for_chain_internal(purpose, account, 0, index)
}
fn derive_public_key_for_chain_internal(
&self,
purpose: u32,
account: u32,
chain: u32,
index: u32,
) -> Result<String, String> {
use bitcoin::secp256k1::Secp256k1;
let secp = Secp256k1::new();
match &self.kind {
WalletKind::Seed { master_xprv } => {
let network = self.vault_wallet.network();
let coin_type = if network == Network::Bitcoin { 0 } else { 1 };
let derivation_path = [
bdk_wallet::bitcoin::bip32::ChildNumber::from_hardened_idx(purpose)
.map_err(|e| format!("Invalid purpose index: {e}"))?,
bdk_wallet::bitcoin::bip32::ChildNumber::from_hardened_idx(coin_type)
.map_err(|e| format!("Invalid coin_type index: {e}"))?,
bdk_wallet::bitcoin::bip32::ChildNumber::from_hardened_idx(account)
.map_err(|e| format!("Invalid account index: {e}"))?,
bdk_wallet::bitcoin::bip32::ChildNumber::from_normal_idx(chain)
.map_err(|e| format!("Invalid chain index: {e}"))?,
bdk_wallet::bitcoin::bip32::ChildNumber::from_normal_idx(index)
.map_err(|e| format!("Invalid index: {e}"))?,
];
let child_xprv = master_xprv
.derive_priv(&secp, &derivation_path)
.map_err(|e| format!("Key derivation failed: {e}"))?;
let public_key = child_xprv.private_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())
}
}
WalletKind::Hardware {
taproot_external,
payment_external,
..
} => {
let desc_str = if purpose == 86 {
taproot_external
} else {
payment_external.as_ref().ok_or_else(|| {
"Payment descriptor missing for this hardware wallet".to_string()
})?
};
let xpub_start_part = if let Some(pos) = desc_str.find(']') {
&desc_str[pos + 1..]
} else if let Some(pos) = desc_str.find('(') {
&desc_str[pos + 1..]
} else {
desc_str
};
let xpub_end_pos = xpub_start_part.find('/').unwrap_or(xpub_start_part.len());
let xpub_str = xpub_start_part[..xpub_end_pos].trim_end_matches(')');
use bitcoin::bip32::{ChildNumber, Xpub};
use std::str::FromStr;
let xpub = Xpub::from_str(xpub_str).map_err(|e| {
format!(
"Failed to parse xpub from descriptor (part: {}): {}",
xpub_str, e
)
})?;
let derived_xpub = xpub
.derive_pub(
&secp,
&[
ChildNumber::from_normal_idx(0)
.map_err(|e| format!("Invalid chain index: {e}"))?,
ChildNumber::from_normal_idx(index)
.map_err(|e| format!("Invalid index: {e}"))?,
],
)
.map_err(|e| format!("Failed to derive public key from xpub: {}", e))?;
let public_key = derived_xpub.public_key;
if purpose == 86 {
let (x_only, _parity) = public_key.x_only_public_key();
Ok(x_only.to_string())
} else {
Ok(public_key.to_string())
}
}
WalletKind::WatchAddress(address) => {
if purpose != 86 {
return Err(ZincError::CapabilityMissing.to_string());
}
let output_key = taproot_output_key_from_address(address)
.map_err(|_| ZincError::CapabilityMissing.to_string())?;
return Ok(output_key.to_string());
}
}
}
#[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 profile_mode(&self) -> ProfileMode {
self.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 let Some(address) = self.watched_address() {
return Ok(address.clone());
}
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 next_unused_taproot_address(&mut self) -> Result<Address, String> {
if let Some(address) = self.watched_address() {
return Ok(address.clone());
}
if self.derivation_mode == DerivationMode::Index {
return Ok(self.peek_taproot_address(0));
}
Ok(self
.vault_wallet
.next_unused_address(KeychainKind::External)
.address)
}
pub fn peek_taproot_address(&self, index: u32) -> Address {
if let Some(address) = self.watched_address() {
let _ = index;
return address.clone();
}
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.trim_end_matches('/'))
.build_async_with_sleeper::<SyncSleeper>();
match client {
Ok(c) => c.get_height().await.is_ok(),
Err(_) => false,
}
}
pub fn prepare_requests(&self, force_full: bool) -> ZincSyncRequest {
let now = wasm_now_secs();
let full = force_full || self.needs_full_scan();
let policy = self.scan_policy;
let build = |w: &Wallet| -> SyncRequestType {
if full {
SyncRequestType::Full(Self::flexible_full_scan_request(w, policy, now))
} else {
SyncRequestType::Incremental(Self::flexible_sync_request(w, now))
}
};
let vault = build(&self.vault_wallet);
let payment = self.payment_wallet.as_ref().map(|w| build(w));
ZincSyncRequest {
taproot: vault,
payment,
}
}
pub fn request_full_rescan(&mut self) {
self.force_next_full = true;
}
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 network = self.vault_wallet.network();
self.vault_wallet = if matches!(&self.kind, WalletKind::WatchAddress(_)) {
Wallet::create_single(vault_desc)
.network(network)
.create_wallet_no_persist()
.map_err(|e| format!("Failed to reset taproot wallet: {e}"))?
} else {
let vault_change_desc = self
.vault_wallet
.public_descriptor(KeychainKind::Internal)
.to_string();
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.trim_end_matches('/'))
.build_async_with_sleeper::<SyncSleeper>()
.map_err(|e| format!("{e:?}"))?;
let now = wasm_now_secs();
let vault_req = Self::flexible_full_scan_request(&self.vault_wallet, self.scan_policy, now);
let payment_req = self
.payment_wallet
.as_ref()
.map(|w| Self::flexible_full_scan_request(w, self.scan_policy, now));
let stop_gap = self.scan_policy.account_gap_limit as usize;
let parallel_requests = 5;
let vault_update = client
.full_scan(vault_req, stop_gap, parallel_requests)
.await
.map_err(|e| e.to_string())?;
let payment_update = if let Some(req) = payment_req {
Some(
client
.full_scan(req, stop_gap, parallel_requests)
.await
.map_err(|e| e.to_string())?,
)
} else {
None
};
self.apply_sync(vault_update, payment_update)
}
pub fn collect_active_addresses(&self) -> Vec<String> {
if let Some(address) = self.watched_address() {
return vec![address.to_string()];
}
let mut addresses = Vec::new();
let mut seen = std::collections::HashSet::new();
let mut collect_from_wallet = |wallet: &Wallet| {
for i in 0..self.scan_policy.address_scan_depth {
let addr = wallet
.peek_address(KeychainKind::External, i)
.address
.to_string();
if seen.insert(addr.clone()) {
addresses.push(addr);
}
}
for u in wallet.list_unspent() {
let addr = wallet
.peek_address(u.keychain, u.derivation_index)
.address
.to_string();
if seen.insert(addr.clone()) {
addresses.push(addr);
}
}
for info in wallet.list_unused_addresses(KeychainKind::External) {
let addr = info.address.to_string();
if seen.insert(addr.clone()) {
addresses.push(addr);
}
}
};
collect_from_wallet(&self.vault_wallet);
if let Some(w) = &self.payment_wallet {
collect_from_wallet(w);
}
addresses
}
pub fn collect_utxo_addresses(&self) -> Vec<String> {
if let Some(address) = self.watched_address() {
return vec![address.to_string()];
}
let mut addresses = Vec::new();
let mut seen = std::collections::HashSet::new();
let mut collect_from_wallet = |wallet: &Wallet| {
for u in wallet.list_unspent() {
let addr = wallet
.peek_address(u.keychain, u.derivation_index)
.address
.to_string();
if seen.insert(addr.clone()) {
addresses.push(addr);
}
}
};
collect_from_wallet(&self.vault_wallet);
if let Some(w) = &self.payment_wallet {
collect_from_wallet(w);
}
addresses
}
pub fn collect_utxo_outpoints(&self) -> Vec<String> {
let mut outpoints = Vec::new();
let mut seen = std::collections::HashSet::new();
let mut collect_from_wallet = |wallet: &Wallet| {
for u in wallet.list_unspent() {
let op = u.outpoint.to_string();
if seen.insert(op.clone()) {
outpoints.push(op);
}
}
};
collect_from_wallet(&self.vault_wallet);
if let Some(w) = &self.payment_wallet {
collect_from_wallet(w);
}
outpoints
}
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_accounts(&self, count: u32) -> Vec<Account> {
match &self.kind {
WalletKind::WatchAddress(address) => {
let taproot_address = address.to_string();
let taproot_public_key = self.get_taproot_public_key(0).unwrap_or_default();
vec![Account {
index: self.account_index,
label: format!("Account {}", self.account_index + 1),
taproot_address: taproot_address.clone(),
taproot_public_key: taproot_public_key.clone(),
payment_address: Some(taproot_address),
payment_public_key: Some(taproot_public_key),
}]
}
WalletKind::Hardware { .. } => {
let taproot_address = self.peek_taproot_address(0).to_string();
let taproot_public_key = self.get_taproot_public_key(0).unwrap_or_default();
let (payment_address, payment_public_key) = if self.scheme == AddressScheme::Dual {
(
self.peek_payment_address(0).map(|a| a.to_string()),
self.get_payment_public_key(0).ok(),
)
} else {
(
Some(taproot_address.clone()),
Some(taproot_public_key.clone()),
)
};
vec![Account {
index: self.account_index,
label: format!("Account {}", self.account_index + 1),
taproot_address,
taproot_public_key,
payment_address,
payment_public_key,
}]
}
WalletKind::Seed { master_xprv } => {
let mut accounts = Vec::new();
for i in 0..count {
let builder = WalletBuilder::new(self.vault_wallet.network())
.kind(WalletKind::Seed {
master_xprv: *master_xprv,
})
.with_scheme(self.scheme)
.with_derivation_mode(self.derivation_mode)
.with_payment_address_type(self.payment_address_type)
.with_account_index(i);
if let Ok(zwallet) = builder.build() {
let taproot_address = zwallet.peek_taproot_address(0).to_string();
let taproot_public_key =
zwallet.get_taproot_public_key(0).unwrap_or_default();
let (payment_address, payment_public_key) =
if self.scheme == AddressScheme::Dual {
(
zwallet.peek_payment_address(0).map(|a| a.to_string()),
zwallet.get_payment_public_key(0).ok(),
)
} else {
(
Some(taproot_address.clone()),
Some(taproot_public_key.clone()),
)
};
accounts.push(Account {
index: i,
label: format!("Account {}", i + 1),
taproot_address,
taproot_public_key,
payment_address,
payment_public_key,
});
}
}
accounts
}
}
}
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 list_utxos(&self) -> Vec<UtxoItem> {
let tip = self.vault_wallet.local_chain().tip().height();
let mut out = Vec::new();
self.collect_utxos_from(&self.vault_wallet, "taproot", tip, &mut out);
if let Some(w) = &self.payment_wallet {
self.collect_utxos_from(w, "payment", tip, &mut out);
}
out
}
pub fn dapp_address_candidates(&self, scan_depth: Option<u32>) -> Vec<DappAddressCandidate> {
let scan_depth = scan_depth.unwrap_or(DEFAULT_DAPP_ADDRESS_SCAN_DEPTH).max(1);
let mut out = Vec::new();
self.collect_dapp_address_candidates_from(
&self.vault_wallet,
"taproot",
"p2tr",
86,
scan_depth,
&mut out,
);
if let Some(payment_wallet) = &self.payment_wallet {
self.collect_dapp_address_candidates_from(
payment_wallet,
"payment",
self.payment_provider_address_type(),
self.dual_payment_purpose(),
scan_depth,
&mut out,
);
}
out
}
fn collect_dapp_address_candidates_from(
&self,
wallet: &Wallet,
wallet_role: &str,
address_type: &str,
purpose: u32,
scan_depth: u32,
out: &mut Vec<DappAddressCandidate>,
) {
let active_receive_index = self.active_receive_index();
let mut indexed_keychains: std::collections::BTreeSet<(KeychainKind, u32)> = (0
..scan_depth)
.map(|offset| {
(
KeychainKind::External,
active_receive_index.saturating_add(offset),
)
})
.collect();
for utxo in wallet.list_unspent() {
indexed_keychains.insert((utxo.keychain, utxo.derivation_index));
}
let classification_complete = self.ordinals_verified && self.ordinals_metadata_complete;
let is_taproot = address_type == "p2tr";
let account = self.active_derivation_account();
for (keychain, absolute_index) in indexed_keychains {
let address = wallet
.peek_address(keychain, absolute_index)
.address
.to_string();
let chain = match keychain {
KeychainKind::External => 0,
KeychainKind::Internal => 1,
};
let keychain_label = match keychain {
KeychainKind::External => "external",
KeychainKind::Internal => "internal",
};
let public_key = self
.derive_public_key_for_chain_internal(purpose, account, chain, absolute_index)
.ok();
let mut clean_btc_sats = 0u64;
let mut clean_confirmed_btc_sats = 0u64;
let mut protected_asset_count = 0u32;
let mut inscription_ids = Vec::new();
for utxo in wallet.list_unspent() {
if utxo.keychain != keychain || utxo.derivation_index != absolute_index {
continue;
}
let mut utxo_inscription_ids = Vec::new();
for inscription in &self.inscriptions {
if inscription.satpoint.outpoint == utxo.outpoint {
utxo_inscription_ids.push(inscription.id.clone());
}
}
let is_protected = self.inscribed_utxos.contains(&utxo.outpoint)
|| !utxo_inscription_ids.is_empty();
if is_protected {
protected_asset_count = protected_asset_count
.saturating_add(utxo_inscription_ids.len().max(1) as u32);
inscription_ids.extend(utxo_inscription_ids);
} else {
let value_sats = utxo.txout.value.to_sat();
clean_btc_sats = clean_btc_sats.saturating_add(value_sats);
if matches!(
utxo.chain_position,
bdk_chain::ChainPosition::Confirmed { .. }
) {
clean_confirmed_btc_sats =
clean_confirmed_btc_sats.saturating_add(value_sats);
}
}
}
inscription_ids.sort();
inscription_ids.dedup();
let has_clean_btc = clean_btc_sats > 0;
let has_protected_assets = protected_asset_count > 0;
let is_mixed = has_clean_btc && has_protected_assets;
let eligible_payment =
classification_complete && has_clean_btc && !has_protected_assets;
let eligible_ordinals = classification_complete && is_taproot && !has_clean_btc;
let payment_disabled_reason = if eligible_payment {
None
} else if !classification_complete {
Some("Sync ordinals before connecting".to_string())
} else if is_mixed {
Some("Holds BTC and artifacts; split in coin control first".to_string())
} else if has_protected_assets {
Some("Holds inscriptions; split in coin control first".to_string())
} else {
Some("No spendable BTC".to_string())
};
let ordinals_disabled_reason = if eligible_ordinals {
None
} else if !classification_complete {
Some("Sync ordinals before connecting".to_string())
} else if !is_taproot {
Some("Collectibles address must be Taproot".to_string())
} else if is_mixed {
Some("Holds BTC and artifacts; split in coin control first".to_string())
} else if has_clean_btc {
Some("Holds regular BTC; split in coin control first".to_string())
} else {
None
};
out.push(DappAddressCandidate {
address,
public_key,
address_type: address_type.to_string(),
wallet_role: wallet_role.to_string(),
keychain: keychain_label.to_string(),
derivation_index: absolute_index,
clean_btc_sats,
clean_confirmed_btc_sats,
protected_asset_count,
inscription_ids,
classification_complete,
eligible_payment,
eligible_ordinals,
payment_disabled_reason,
ordinals_disabled_reason,
});
}
}
fn payment_provider_address_type(&self) -> &'static str {
match self.payment_address_type {
PaymentAddressType::NativeSegwit => "p2wpkh",
PaymentAddressType::NestedSegwit => "p2sh",
PaymentAddressType::Legacy => "p2pkh",
}
}
fn find_owned_address(&self, address: &str, scan_depth: u32) -> Option<OwnedAddressMatch> {
self.find_owned_address_in_wallet(&self.vault_wallet, address, 86, scan_depth)
.or_else(|| {
self.payment_wallet.as_ref().and_then(|wallet| {
self.find_owned_address_in_wallet(
wallet,
address,
self.dual_payment_purpose(),
scan_depth,
)
})
})
}
fn find_owned_address_in_wallet(
&self,
wallet: &Wallet,
address: &str,
purpose: u32,
scan_depth: u32,
) -> Option<OwnedAddressMatch> {
let active_receive_index = self.active_receive_index();
let mut indexed_keychains: std::collections::BTreeSet<(KeychainKind, u32)> =
std::collections::BTreeSet::new();
for offset in 0..scan_depth.max(1) {
indexed_keychains.insert((KeychainKind::External, offset));
indexed_keychains.insert((KeychainKind::Internal, offset));
indexed_keychains.insert((
KeychainKind::External,
active_receive_index.saturating_add(offset),
));
}
for utxo in wallet.list_unspent() {
indexed_keychains.insert((utxo.keychain, utxo.derivation_index));
}
indexed_keychains
.into_iter()
.find_map(|(keychain, absolute_index)| {
let candidate = wallet
.peek_address(keychain, absolute_index)
.address
.to_string();
if candidate == address {
Some(OwnedAddressMatch {
purpose,
keychain,
absolute_index,
})
} else {
None
}
})
}
pub fn confirm_dapp_address_selection(
&mut self,
payment_address: &str,
ordinals_address: &str,
scan_depth: Option<u32>,
) -> Result<(), String> {
let scan_depth = scan_depth.unwrap_or(DEFAULT_DAPP_ADDRESS_SCAN_DEPTH).max(1);
let payment_match = self
.find_owned_address(payment_address, scan_depth)
.ok_or_else(|| "Selected BTC address is not owned by this wallet".to_string())?;
let ordinals_match = self
.find_owned_address(ordinals_address, scan_depth)
.ok_or_else(|| {
"Selected collectibles address is not owned by this wallet".to_string()
})?;
self.reveal_address_match(payment_match)?;
self.reveal_address_match(ordinals_match)?;
Ok(())
}
fn reveal_address_match(&mut self, address_match: OwnedAddressMatch) -> Result<(), String> {
let absolute_index = address_match.absolute_index;
if address_match.purpose == 86 {
let _revealed: Vec<_> = self
.vault_wallet
.reveal_addresses_to(address_match.keychain, absolute_index)
.collect();
return Ok(());
}
let payment_wallet = self
.payment_wallet
.as_mut()
.ok_or_else(|| "Payment wallet not initialized".to_string())?;
let _revealed: Vec<_> = payment_wallet
.reveal_addresses_to(address_match.keychain, absolute_index)
.collect();
Ok(())
}
fn collect_utxos_from(&self, wallet: &Wallet, role: &str, tip: u32, out: &mut Vec<UtxoItem>) {
for u in wallet.list_unspent() {
let (confirmed, confirmations) = match &u.chain_position {
bdk_chain::ChainPosition::Confirmed { anchor, .. } => (
true,
tip.saturating_sub(anchor.block_id.height).saturating_add(1),
),
bdk_chain::ChainPosition::Unconfirmed { .. } => (false, 0),
};
let address = Some(
wallet
.peek_address(u.keychain, u.derivation_index)
.address
.to_string(),
);
let is_inscribed = self.inscribed_utxos.contains(&u.outpoint);
let mut inscription_ids = Vec::new();
let mut inscription_offsets = Vec::new();
for ins in &self.inscriptions {
if ins.satpoint.outpoint == u.outpoint {
inscription_ids.push(ins.id.clone());
inscription_offsets.push(ins.satpoint.offset);
}
}
let has_inscription = !inscription_ids.is_empty();
let value_sats = u.txout.value.to_sat();
let cardinal_salvageable_sats = if has_inscription {
value_sats.saturating_sub(inscription_ids.len() as u64 * MIN_SALVAGE_PADDING_SATS)
} else {
0
};
let keychain = match u.keychain {
KeychainKind::Internal => "internal",
KeychainKind::External => "external",
};
out.push(UtxoItem {
txid: u.outpoint.txid.to_string(),
vout: u.outpoint.vout,
value_sats,
address,
keychain: keychain.to_string(),
wallet_role: role.to_string(),
confirmed,
confirmations,
is_inscribed,
has_inscription,
inscription_ids,
inscription_offsets,
cardinal_salvageable_sats,
});
}
}
fn resolve_owned_txout(&self, op: &bitcoin::OutPoint) -> Option<bitcoin::TxOut> {
let find = |w: &Wallet| {
w.list_unspent()
.find(|u| u.outpoint == *op)
.map(|u| u.txout.clone())
};
find(&self.vault_wallet).or_else(|| self.payment_wallet.as_ref().and_then(find))
}
pub fn plan_salvage_tx(
&self,
outpoints: &[bitcoin::OutPoint],
fee_rate: FeeRate,
target_postage: u64,
ordinals_address: &Address,
destination: &Address,
) -> Result<Psbt, ZincError> {
use bitcoin::{ScriptBuf, Sequence, TxIn, TxOut, Witness};
if !self.ordinals_verified {
return Err(ZincError::WalletError(
"Ordinals verification failed - safety lock engaged. Please retry sync."
.to_string(),
));
}
if outpoints.is_empty() {
return Err(ZincError::WalletError(
"No UTXOs selected for salvage".to_string(),
));
}
let dust = target_postage.max(546);
struct SalvageInput {
op: bitcoin::OutPoint,
txo: TxOut,
offset: u64,
}
let mut metas: Vec<SalvageInput> = Vec::new();
for op in outpoints {
let txo = self
.resolve_owned_txout(op)
.ok_or_else(|| ZincError::WalletError(format!("UTXO not found in wallet: {op}")))?;
let mut offsets: Vec<u64> = self
.inscriptions
.iter()
.filter(|ins| ins.satpoint.outpoint == *op)
.map(|ins| ins.satpoint.offset)
.collect();
offsets.sort_unstable();
if offsets.len() > 1 {
return Err(ZincError::WalletError(format!(
"UTXO {op} holds multiple inscriptions; salvage it on its own"
)));
}
let offset = offsets.first().copied().unwrap_or(0);
let value = txo.value.to_sat();
if offset.saturating_add(target_postage) > value {
return Err(ZincError::WalletError(format!(
"Inscription in {op} sits too close to the UTXO end to pad to {target_postage} sats"
)));
}
metas.push(SalvageInput {
op: *op,
txo,
offset,
});
}
metas.sort_by_key(|m| {
m.txo
.value
.to_sat()
.saturating_sub(m.offset + target_postage)
});
let ord_spk = ordinals_address.script_pubkey();
let dest_spk = destination.script_pubkey();
let mut segments: Vec<(u64, ScriptBuf, bool)> = Vec::new();
for m in &metas {
let value = m.txo.value.to_sat();
let offset = m.offset;
let trail = value - offset - target_postage;
let mut postage = target_postage;
if offset >= dust {
segments.push((offset, dest_spk.clone(), true));
} else {
postage += offset; }
if trail >= dust {
segments.push((postage, ord_spk.clone(), false));
segments.push((trail, dest_spk.clone(), true));
} else {
segments.push((postage + trail, ord_spk.clone(), false)); }
}
let mut dummy_inputs: Vec<TxIn> = Vec::with_capacity(metas.len());
for m in &metas {
let mut input = TxIn {
previous_output: m.op,
script_sig: ScriptBuf::new(),
sequence: Sequence::ENABLE_RBF_NO_LOCKTIME,
witness: Witness::new(),
};
if m.txo.script_pubkey.is_p2tr() {
input.witness.push(vec![0u8; 64]);
} else {
input.witness.push(vec![0u8; 72]);
input.witness.push(vec![0u8; 33]);
}
dummy_inputs.push(input);
}
let dummy_outputs: Vec<TxOut> = segments
.iter()
.map(|(v, spk, _)| TxOut {
value: Amount::from_sat(*v),
script_pubkey: spk.clone(),
})
.collect();
let dummy_tx = Transaction {
version: bitcoin::transaction::Version::TWO,
lock_time: bitcoin::absolute::LockTime::ZERO,
input: dummy_inputs,
output: dummy_outputs,
};
let fee = (dummy_tx.vsize() as u64).saturating_mul(fee_rate.to_sat_per_vb_ceil());
let last_idx = segments.len() - 1;
if !segments[last_idx].2 {
return Err(ZincError::WalletError(
"Selected inscriptions have no cardinal sats above the dust threshold to salvage"
.to_string(),
));
}
let last_after_fee = segments[last_idx].0.checked_sub(fee).ok_or_else(|| {
ZincError::WalletError(format!(
"Salvage is too small to cover the network fee ({fee} sats)"
))
})?;
let mut outputs: Vec<TxOut> = Vec::with_capacity(segments.len());
for (i, (v, spk, _)) in segments.iter().enumerate() {
if i == last_idx {
if last_after_fee >= dust {
outputs.push(TxOut {
value: Amount::from_sat(last_after_fee),
script_pubkey: spk.clone(),
});
}
} else {
outputs.push(TxOut {
value: Amount::from_sat(*v),
script_pubkey: spk.clone(),
});
}
}
if outputs.is_empty() {
return Err(ZincError::WalletError(
"Salvage produced no spendable outputs".to_string(),
));
}
let real_inputs: Vec<TxIn> = metas
.iter()
.map(|m| TxIn {
previous_output: m.op,
script_sig: ScriptBuf::new(),
sequence: Sequence::ENABLE_RBF_NO_LOCKTIME,
witness: Witness::new(),
})
.collect();
let tx = Transaction {
version: bitcoin::transaction::Version::TWO,
lock_time: bitcoin::absolute::LockTime::ZERO,
input: real_inputs,
output: outputs,
};
let mut psbt = Psbt::from_unsigned_tx(tx)
.map_err(|e| ZincError::WalletError(format!("Failed to build salvage PSBT: {e}")))?;
for (i, m) in metas.iter().enumerate() {
psbt.inputs[i].witness_utxo = Some(m.txo.clone());
}
Ok(psbt)
}
pub fn plan_consolidate_tx(
&self,
outpoints: &[bitcoin::OutPoint],
fee_rate: FeeRate,
destination: &Address,
) -> Result<Psbt, ZincError> {
use bitcoin::{ScriptBuf, Sequence, TxIn, TxOut, Witness};
if outpoints.is_empty() {
return Err(ZincError::WalletError(
"No UTXOs selected for consolidation".to_string(),
));
}
let mut resolved: Vec<(bitcoin::OutPoint, TxOut)> = Vec::new();
for op in outpoints {
if self.inscribed_utxos.contains(op) {
return Err(ZincError::WalletError(format!(
"Refusing to consolidate a protected (inscription/rune) UTXO: {op}"
)));
}
let txo = self
.resolve_owned_txout(op)
.ok_or_else(|| ZincError::WalletError(format!("UTXO not found in wallet: {op}")))?;
resolved.push((*op, txo));
}
let total_input: u64 = resolved.iter().map(|(_, t)| t.value.to_sat()).sum();
let dest_spk = destination.script_pubkey();
let mut dummy_inputs = Vec::new();
for (op, txo) in &resolved {
let mut input = TxIn {
previous_output: *op,
script_sig: ScriptBuf::new(),
sequence: Sequence::ENABLE_RBF_NO_LOCKTIME,
witness: Witness::new(),
};
if txo.script_pubkey.is_p2tr() {
input.witness.push(vec![0u8; 64]);
} else {
input.witness.push(vec![0u8; 72]);
input.witness.push(vec![0u8; 33]);
}
dummy_inputs.push(input);
}
let dummy_tx = Transaction {
version: bitcoin::transaction::Version::TWO,
lock_time: bitcoin::absolute::LockTime::ZERO,
input: dummy_inputs.clone(),
output: vec![TxOut {
value: Amount::from_sat(total_input),
script_pubkey: dest_spk.clone(),
}],
};
let fee = (dummy_tx.vsize() as u64).saturating_mul(fee_rate.to_sat_per_vb_ceil());
let consolidated = total_input
.checked_sub(fee)
.filter(|v| *v >= 546)
.ok_or_else(|| {
ZincError::WalletError(format!(
"Insufficient funds for consolidation: input {total_input}, fee {fee}"
))
})?;
let mut inputs = dummy_inputs;
for input in &mut inputs {
input.witness = Witness::new();
}
let tx = Transaction {
version: bitcoin::transaction::Version::TWO,
lock_time: bitcoin::absolute::LockTime::ZERO,
input: inputs,
output: vec![TxOut {
value: Amount::from_sat(consolidated),
script_pubkey: dest_spk,
}],
};
let mut psbt = Psbt::from_unsigned_tx(tx).map_err(|e| {
ZincError::WalletError(format!("Failed to build consolidation PSBT: {e}"))
})?;
for (i, (_, txo)) in resolved.iter().enumerate() {
psbt.inputs[i].witness_utxo = Some(txo.clone());
}
Ok(psbt)
}
pub fn plan_consolidate_base64(
&self,
outpoints: &[String],
fee_rate_sat_vb: u64,
destination: &str,
) -> Result<String, ZincError> {
let network = self.vault_wallet.network();
let fee_rate = FeeRate::from_sat_per_vb(fee_rate_sat_vb)
.ok_or_else(|| ZincError::ConfigError("Invalid fee rate".to_string()))?;
let dest_addr = Address::from_str(destination)
.map_err(|e| ZincError::ConfigError(format!("Invalid destination address: {e}")))?
.require_network(network)
.map_err(|e| {
ZincError::ConfigError(format!("Destination address network mismatch: {e}"))
})?;
let mut ops = Vec::with_capacity(outpoints.len());
for s in outpoints {
ops.push(
bitcoin::OutPoint::from_str(s)
.map_err(|e| ZincError::ConfigError(format!("Invalid outpoint {s}: {e}")))?,
);
}
let psbt = self.plan_consolidate_tx(&ops, fee_rate, &dest_addr)?;
Ok(Self::encode_psbt_base64(&psbt))
}
#[allow(clippy::too_many_arguments)]
pub fn plan_send_with_salvage_tx(
&self,
input_outpoints: &[bitcoin::OutPoint],
recipient: &Address,
amount_sats: u64,
fee_rate: FeeRate,
target_postage: u64,
ordinals_address: &Address,
change_address: &Address,
) -> Result<Psbt, ZincError> {
use bitcoin::{ScriptBuf, Sequence, TxIn, TxOut, Witness};
if !self.ordinals_verified {
return Err(ZincError::WalletError(
"Ordinals verification failed - safety lock engaged. Please retry sync."
.to_string(),
));
}
if input_outpoints.is_empty() {
return Err(ZincError::WalletError(
"No inputs provided for send".to_string(),
));
}
if amount_sats == 0 {
return Err(ZincError::WalletError(
"Send amount must be greater than zero".to_string(),
));
}
let dust = target_postage.max(546);
struct SendInput {
op: bitcoin::OutPoint,
txo: TxOut,
offset: u64,
}
let mut inscribed: Vec<SendInput> = Vec::new();
let mut clean: Vec<SendInput> = Vec::new();
for op in input_outpoints {
let txo = self
.resolve_owned_txout(op)
.ok_or_else(|| ZincError::WalletError(format!("UTXO not found in wallet: {op}")))?;
if self.inscribed_utxos.contains(op) {
let mut offsets: Vec<u64> = self
.inscriptions
.iter()
.filter(|ins| ins.satpoint.outpoint == *op)
.map(|ins| ins.satpoint.offset)
.collect();
offsets.sort_unstable();
if offsets.len() > 1 {
return Err(ZincError::WalletError(format!(
"UTXO {op} holds multiple inscriptions; salvage it on its own first"
)));
}
let offset = offsets.first().copied().unwrap_or(0);
if offset.saturating_add(target_postage) > txo.value.to_sat() {
return Err(ZincError::WalletError(format!(
"Inscription in {op} sits too close to the UTXO end to pad to {target_postage} sats"
)));
}
inscribed.push(SendInput {
op: *op,
txo,
offset,
});
} else {
clean.push(SendInput {
op: *op,
txo,
offset: 0,
});
}
}
inscribed.sort_by_key(|m| {
m.txo
.value
.to_sat()
.saturating_sub(m.offset + target_postage)
});
let ordered: Vec<&SendInput> = inscribed.iter().chain(clean.iter()).collect();
let ord_spk = ordinals_address.script_pubkey();
let recipient_spk = recipient.script_pubkey();
let change_spk = change_address.script_pubkey();
let build_inputs = || {
ordered
.iter()
.map(|m| {
let mut input = TxIn {
previous_output: m.op,
script_sig: ScriptBuf::new(),
sequence: Sequence::ENABLE_RBF_NO_LOCKTIME,
witness: Witness::new(),
};
if m.txo.script_pubkey.is_p2tr() {
input.witness.push(vec![0u8; 64]);
} else {
input.witness.push(vec![0u8; 72]);
input.witness.push(vec![0u8; 33]);
}
input
})
.collect::<Vec<_>>()
};
let mut leading: Vec<TxOut> = Vec::new();
let inscribed_count = inscribed.len();
for (idx, m) in inscribed.iter().enumerate() {
let value = m.txo.value.to_sat();
let offset = m.offset;
let trail = value - offset - target_postage;
let mut postage = target_postage;
if offset >= dust {
leading.push(TxOut {
value: Amount::from_sat(offset),
script_pubkey: change_spk.clone(),
});
} else {
postage += offset;
}
if idx + 1 == inscribed_count {
leading.push(TxOut {
value: Amount::from_sat(postage),
script_pubkey: ord_spk.clone(),
});
} else if trail >= dust {
leading.push(TxOut {
value: Amount::from_sat(postage),
script_pubkey: ord_spk.clone(),
});
leading.push(TxOut {
value: Amount::from_sat(trail),
script_pubkey: change_spk.clone(),
});
} else {
leading.push(TxOut {
value: Amount::from_sat(postage + trail),
script_pubkey: ord_spk.clone(),
});
}
}
let last_trail = inscribed
.last()
.map(|m| m.txo.value.to_sat() - m.offset - target_postage)
.unwrap_or(0);
let clean_total: u64 = clean.iter().map(|m| m.txo.value.to_sat()).sum();
let pool = last_trail + clean_total;
let mut dummy_out = leading.clone();
dummy_out.push(TxOut {
value: Amount::from_sat(amount_sats),
script_pubkey: recipient_spk.clone(),
});
dummy_out.push(TxOut {
value: Amount::from_sat(0),
script_pubkey: change_spk.clone(),
});
let dummy_tx = Transaction {
version: bitcoin::transaction::Version::TWO,
lock_time: bitcoin::absolute::LockTime::ZERO,
input: build_inputs(),
output: dummy_out,
};
let fee = (dummy_tx.vsize() as u64).saturating_mul(fee_rate.to_sat_per_vb_ceil());
let change = pool.checked_sub(amount_sats + fee).ok_or_else(|| {
ZincError::WalletError(format!(
"Insufficient funds: the amount must be covered by the largest inscription's cardinal sats plus clean UTXOs (have {pool}, need {} = amount {amount_sats} + fee {fee})",
amount_sats + fee
))
})?;
let mut outputs = leading;
outputs.push(TxOut {
value: Amount::from_sat(amount_sats),
script_pubkey: recipient_spk,
});
if change >= dust {
outputs.push(TxOut {
value: Amount::from_sat(change),
script_pubkey: change_spk,
});
}
let mut real_inputs = build_inputs();
for input in &mut real_inputs {
input.witness = Witness::new();
}
let tx = Transaction {
version: bitcoin::transaction::Version::TWO,
lock_time: bitcoin::absolute::LockTime::ZERO,
input: real_inputs,
output: outputs,
};
let mut psbt = Psbt::from_unsigned_tx(tx)
.map_err(|e| ZincError::WalletError(format!("Failed to build send PSBT: {e}")))?;
for (i, m) in ordered.iter().enumerate() {
psbt.inputs[i].witness_utxo = Some(m.txo.clone());
}
Ok(psbt)
}
#[allow(clippy::too_many_arguments)]
pub fn plan_send_with_salvage_base64(
&self,
input_outpoints: &[String],
recipient: &str,
amount_sats: u64,
fee_rate_sat_vb: u64,
target_postage: u64,
ordinals_address: &str,
change_address: &str,
) -> Result<String, ZincError> {
let network = self.vault_wallet.network();
let fee_rate = FeeRate::from_sat_per_vb(fee_rate_sat_vb)
.ok_or_else(|| ZincError::ConfigError("Invalid fee rate".to_string()))?;
let parse_addr = |s: &str, what: &str| -> Result<Address, ZincError> {
Address::from_str(s)
.map_err(|e| ZincError::ConfigError(format!("Invalid {what} address: {e}")))?
.require_network(network)
.map_err(|e| {
ZincError::ConfigError(format!("{what} address network mismatch: {e}"))
})
};
let recipient_addr = parse_addr(recipient, "recipient")?;
let ord_addr = parse_addr(ordinals_address, "ordinals")?;
let change_addr = parse_addr(change_address, "change")?;
let mut ops = Vec::with_capacity(input_outpoints.len());
for s in input_outpoints {
ops.push(
bitcoin::OutPoint::from_str(s)
.map_err(|e| ZincError::ConfigError(format!("Invalid outpoint {s}: {e}")))?,
);
}
let psbt = self.plan_send_with_salvage_tx(
&ops,
&recipient_addr,
amount_sats,
fee_rate,
target_postage,
&ord_addr,
&change_addr,
)?;
Ok(Self::encode_psbt_base64(&psbt))
}
pub fn plan_salvage_base64(
&self,
outpoints: &[String],
fee_rate_sat_vb: u64,
target_postage: u64,
ordinals_address: &str,
destination: &str,
) -> Result<String, ZincError> {
let network = self.vault_wallet.network();
let fee_rate = FeeRate::from_sat_per_vb(fee_rate_sat_vb)
.ok_or_else(|| ZincError::ConfigError("Invalid fee rate".to_string()))?;
let ord_addr = Address::from_str(ordinals_address)
.map_err(|e| ZincError::ConfigError(format!("Invalid ordinals address: {e}")))?
.require_network(network)
.map_err(|e| {
ZincError::ConfigError(format!("Ordinals address network mismatch: {e}"))
})?;
let dest_addr = Address::from_str(destination)
.map_err(|e| ZincError::ConfigError(format!("Invalid destination address: {e}")))?
.require_network(network)
.map_err(|e| {
ZincError::ConfigError(format!("Destination address network mismatch: {e}"))
})?;
let mut ops = Vec::with_capacity(outpoints.len());
for s in outpoints {
ops.push(
bitcoin::OutPoint::from_str(s)
.map_err(|e| ZincError::ConfigError(format!("Invalid outpoint {s}: {e}")))?,
);
}
let psbt = self.plan_salvage_tx(&ops, fee_rate, target_postage, &ord_addr, &dest_addr)?;
Ok(Self::encode_psbt_base64(&psbt))
}
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)
}
pub fn create_listing_purchase(
&mut self,
request: &crate::listing::CreateListingPurchaseRequest,
) -> Result<crate::listing::CreateListingPurchaseResultV1, ZincError> {
crate::listing::create_listing_purchase(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::with_capacity(self.inscriptions.len());
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 prepare_external_sign_psbt(
&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());
}
}
}
#[allow(deprecated)]
let _ = self
.vault_wallet
.sign(&mut psbt, bdk_wallet::SignOptions::default());
if let Some(w) = &self.payment_wallet {
#[allow(deprecated)]
let _ = w.sign(&mut psbt, bdk_wallet::SignOptions::default());
}
if let Some(opts) = &options {
if let Some(sighash_u8) = opts.sighash {
let target_sighash = bitcoin::psbt::PsbtSighashType::from_u32(sighash_u8 as u32);
for input in psbt.inputs.iter_mut() {
input.sighash_type = Some(target_sighash);
}
}
}
let inputs_to_sign = options.as_ref().and_then(|o| o.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 {} is duplicated",
index
));
}
let input = &psbt.inputs[*index];
if input.witness_utxo.is_none() && input.non_witness_utxo.is_none() {
return Err(format!(
"Security Violation: Requested input #{} is missing UTXO metadata",
index
));
}
}
}
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 #{} (value={})",
index, value
));
}
}
}
let mut known_inscriptions: HashMap<(bitcoin::Txid, u32), Vec<(String, u64)>> =
HashMap::with_capacity(self.inscriptions.len());
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 prepared_bytes = psbt.serialize();
Ok(base64::engine::general_purpose::STANDARD.encode(&prepared_bytes))
}
pub fn verify_external_signed_psbt(
&self,
original_psbt_base64: &str,
signed_psbt_base64: &str,
required_input_indices: Option<&[usize]>,
finalize: bool,
) -> Result<String, String> {
use base64::Engine;
use bitcoin::consensus::Encodable;
let decode = |b64: &str, label: &str| -> Result<Psbt, String> {
let bytes = base64::engine::general_purpose::STANDARD
.decode(b64)
.map_err(|e| format!("Invalid base64 in {label}: {e}"))?;
Psbt::deserialize(&bytes).map_err(|e| format!("Invalid PSBT in {label}: {e}"))
};
let original = decode(original_psbt_base64, "original")?;
let mut signed = decode(signed_psbt_base64, "signed")?;
let mut orig_tx_bytes = Vec::new();
original
.unsigned_tx
.consensus_encode(&mut orig_tx_bytes)
.map_err(|e| format!("Failed to encode original tx: {e}"))?;
let mut signed_tx_bytes = Vec::new();
signed
.unsigned_tx
.consensus_encode(&mut signed_tx_bytes)
.map_err(|e| format!("Failed to encode signed tx: {e}"))?;
if orig_tx_bytes != signed_tx_bytes {
return Err(
"Security Violation: Device returned a PSBT with a modified transaction. \
The unsigned_tx bytes do not match the original."
.to_string(),
);
}
let check_indices: Vec<usize> = required_input_indices
.map(|v| v.to_vec())
.unwrap_or_else(|| (0..signed.inputs.len()).collect());
for &idx in &check_indices {
if idx >= signed.inputs.len() {
return Err(format!(
"Security Violation: required input index {} is out of bounds",
idx
));
}
let input = &signed.inputs[idx];
let has_signature = input.tap_key_sig.is_some()
|| !input.tap_script_sigs.is_empty()
|| !input.partial_sigs.is_empty()
|| input.final_script_witness.is_some();
if !has_signature {
return Err(format!(
"Security Violation: Required input #{} was not signed by the device",
idx
));
}
}
if required_input_indices.is_some() {
let required_set: std::collections::HashSet<usize> =
check_indices.iter().copied().collect();
for (i, (orig_input, signed_input)) in
original.inputs.iter().zip(signed.inputs.iter()).enumerate()
{
if required_set.contains(&i) {
continue;
}
let signatures_changed = orig_input.tap_key_sig != signed_input.tap_key_sig
|| orig_input.tap_script_sigs != signed_input.tap_script_sigs
|| orig_input.partial_sigs != signed_input.partial_sigs
|| orig_input.final_script_witness != signed_input.final_script_witness;
if signatures_changed {
return Err(format!(
"Security Violation: Input #{} received an unauthorized signature \
(not in required_input_indices)",
i
));
}
}
}
if !finalize {
for input in signed.inputs.iter_mut() {
input.bip32_derivation.clear();
input.tap_key_origins.clear();
}
}
if finalize {
for input in signed.inputs.iter_mut() {
if let Some(sig) = input.tap_key_sig {
let mut witness = bitcoin::Witness::new();
witness.push(sig.to_vec());
input.final_script_witness = Some(witness);
input.tap_key_sig = None;
input.tap_internal_key = None;
input.tap_merkle_root = None;
input.tap_key_origins.clear();
input.witness_utxo = None;
input.sighash_type = None;
} else if !input.partial_sigs.is_empty() {
if let Some((pubkey, sig)) = input.partial_sigs.iter().next() {
let mut witness = bitcoin::Witness::new();
witness.push(sig.to_vec());
witness.push(pubkey.to_bytes());
input.final_script_witness = Some(witness);
input.partial_sigs.clear();
input.bip32_derivation.clear();
input.witness_utxo = None;
input.sighash_type = None;
}
}
}
}
let verified_bytes = signed.serialize();
Ok(base64::engine::general_purpose::STANDARD.encode(&verified_bytes))
}
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);
}
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 mut known_inscriptions: HashMap<(bitcoin::Txid, u32), Vec<(String, u64)>> =
HashMap::with_capacity(self.inscriptions.len());
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.trim_end_matches('/'))
.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};
if let Some(watched) = self.watched_address() {
if watched.to_string() == address {
let _ = message;
return Err(ZincError::CapabilityMissing.to_string());
}
}
let owned = self
.find_owned_address(address, DAPP_SIGN_ADDRESS_SEARCH_DEPTH)
.ok_or_else(|| "Address not found in wallet".to_string())?;
let secp = Secp256k1::new();
let chain = match owned.keychain {
KeychainKind::External => 0,
KeychainKind::Internal => 1,
};
let priv_key = self
.derive_private_key_internal(
owned.purpose,
self.active_derivation_account(),
chain,
owned.absolute_index,
)
.map_err(|_| ZincError::CapabilityMissing.to_string())?;
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 sign_bip322_simple_hex(&self, address: &str, message: &str) -> Result<String, String> {
use bitcoin::PrivateKey;
if let Some(watched) = self.watched_address() {
if watched.to_string() == address {
let _ = message;
return Err(ZincError::CapabilityMissing.to_string());
}
}
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 (purpose, chain) = if is_vault {
(86, 0)
} else {
(self.dual_payment_purpose(), 0)
};
let secret_key = self
.derive_private_key(purpose, chain, 0)
.map_err(|_| ZincError::CapabilityMissing.to_string())?;
let network = self.vault_wallet.network();
let private_key = PrivateKey::new(secret_key, network);
let witness = bip322::sign_simple(
&address
.parse::<bitcoin::Address<bitcoin::address::NetworkUnchecked>>()
.map_err(|e| format!("invalid address: {e}"))?
.require_network(network)
.map_err(|e| format!("address network mismatch: {e}"))?,
message,
private_key,
)
.map_err(|e| format!("failed to sign BIP-322 message: {e}"))?;
let bytes = bitcoin::consensus::serialize(&witness);
Ok(hex::encode(bytes))
}
pub fn get_pairing_secret_key_hex(&self) -> Result<String, String> {
let key = self.derive_private_key(86, 0, 0)?;
Ok(hex::encode(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,
self.vault_wallet.network(),
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();
match &self.kind {
WalletKind::Seed { master_xprv } => {
let network = self.vault_wallet.network();
let coin_type = u32::from(network != Network::Bitcoin);
let derivation_path = [
bdk_wallet::bitcoin::bip32::ChildNumber::from_hardened_idx(purpose)
.map_err(|e| format!("Invalid purpose index: {e}"))?,
bdk_wallet::bitcoin::bip32::ChildNumber::from_hardened_idx(coin_type)
.map_err(|e| format!("Invalid coin_type index: {e}"))?,
bdk_wallet::bitcoin::bip32::ChildNumber::from_hardened_idx(account)
.map_err(|e| format!("Invalid account index: {e}"))?,
bdk_wallet::bitcoin::bip32::ChildNumber::from_normal_idx(chain)
.map_err(|e| format!("Invalid chain index: {e}"))?,
bdk_wallet::bitcoin::bip32::ChildNumber::from_normal_idx(index)
.map_err(|e| format!("Invalid index: {e}"))?,
];
let child_xprv = master_xprv
.derive_priv(&secp, &derivation_path)
.map_err(|e| format!("Key derivation failed: {e}"))?;
Ok(child_xprv.private_key)
}
_ => Err("Private key derivation not supported for this wallet kind".to_string()),
}
}
fn sign_inscription_script_paths(
&self,
psbt: &mut Psbt,
finalize: bool,
indices: Option<&[usize]>,
) -> Result<(), String> {
use bitcoin::secp256k1::{Message, Secp256k1};
use bitcoin::sighash::{Prevouts, SighashCache};
let secp = Secp256k1::new();
let network = self.vault_wallet.network();
let mut prevouts = Vec::with_capacity(psbt.unsigned_tx.input.len());
for (i, input) in psbt.inputs.iter().enumerate() {
let utxo = input
.witness_utxo
.as_ref()
.or_else(|| {
input.non_witness_utxo.as_ref().and_then(|tx| {
tx.output
.get(psbt.unsigned_tx.input[i].previous_output.vout as usize)
})
})
.ok_or_else(|| format!("Missing witness_utxo for input #{i}"))?;
prevouts.push(utxo.clone());
}
let prevouts_all = Prevouts::All(&prevouts);
for i in 0..psbt.inputs.len() {
if let Some(allowed) = indices {
if !allowed.contains(&i) {
continue;
}
}
let input = &mut psbt.inputs[i];
if input.tap_key_sig.is_some() || !input.tap_script_sigs.is_empty() {
continue; }
let mut key_found = false;
for (pubkey, (_, origin)) in &input.tap_key_origins {
if *origin.0.as_bytes() == [0, 0, 0, 0] {
let account = self.active_derivation_account();
let effective_index = self.active_receive_index();
if let Ok(derived_pubkey_hex) =
self.derive_public_key_internal(86, network, account, effective_index)
{
if pubkey.to_string() == derived_pubkey_hex {
let priv_key = self.derive_private_key(86, 0, 0)?;
let mut cache = SighashCache::new(&psbt.unsigned_tx);
let sighash_type = input
.sighash_type
.unwrap_or(bitcoin::psbt::PsbtSighashType::from_u32(0));
for (_control_block, (script, _)) in &input.tap_scripts {
let leaf_hash = bitcoin::taproot::TapLeafHash::from_script(
script,
bitcoin::taproot::LeafVersion::TapScript,
);
let tap_sighash_type = match sighash_type.to_u32() {
0 => bitcoin::sighash::TapSighashType::Default,
1 => bitcoin::sighash::TapSighashType::All,
2 => bitcoin::sighash::TapSighashType::None,
3 => bitcoin::sighash::TapSighashType::Single,
0x81 => bitcoin::sighash::TapSighashType::AllPlusAnyoneCanPay,
0x82 => bitcoin::sighash::TapSighashType::NonePlusAnyoneCanPay,
0x83 => {
bitcoin::sighash::TapSighashType::SinglePlusAnyoneCanPay
}
_ => bitcoin::sighash::TapSighashType::Default,
};
let sighash = cache
.taproot_script_spend_signature_hash(
i,
&prevouts_all,
leaf_hash,
tap_sighash_type,
)
.map_err(|e| format!("Sighash calculation failed: {e}"))?;
let msg = Message::from_digest(sighash.to_byte_array());
let sig = secp.sign_schnorr(&msg, &priv_key.keypair(&secp));
let mut final_sig = sig.as_ref().to_vec();
if tap_sighash_type != bitcoin::sighash::TapSighashType::Default {
final_sig.push(tap_sighash_type as u8);
}
let tap_sig =
bitcoin::taproot::Signature::from_slice(&final_sig)
.map_err(|e| format!("Invalid taproot signature: {e}"))?;
input.tap_script_sigs.insert((*pubkey, leaf_hash), tap_sig);
key_found = true;
}
}
}
}
}
if key_found && finalize {
}
}
Ok(())
}
pub fn get_revealed_addresses(&self, keychain: KeychainKind) -> Vec<String> {
let wallet = match keychain {
KeychainKind::External => &self.vault_wallet,
KeychainKind::Internal => &self.vault_wallet,
};
wallet
.list_unused_addresses(keychain)
.into_iter()
.map(|info| info.address.to_string())
.collect()
}
fn flexible_full_scan_request(
wallet: &Wallet,
_policy: ScanPolicy,
now: u64,
) -> FullScanRequest<KeychainKind> {
wallet.start_full_scan_at(now).build()
}
fn flexible_sync_request(wallet: &Wallet, now: u64) -> SyncRequest<(KeychainKind, u32)> {
wallet.start_sync_with_revealed_spks_at(now).build()
}
}
impl WalletBuilder {
#[must_use]
pub fn new(network: Network) -> Self {
Self {
network,
kind: None,
mode: ProfileMode::Seed,
scheme: AddressScheme::Unified,
derivation_mode: DerivationMode::Account,
payment_address_type: PaymentAddressType::NativeSegwit,
persistence: None,
account_index: 0,
scan_policy: ScanPolicy::default(),
}
}
pub fn from_mnemonic(network: Network, mnemonic: &ZincMnemonic) -> Self {
use bdk_wallet::bitcoin::bip32::Xpriv;
let seed = mnemonic.to_seed("");
let master_xprv = Xpriv::new_master(network, seed.as_ref()).expect("valid seed");
Self::new(network).kind(WalletKind::Seed { master_xprv })
}
pub fn from_seed(network: Network, seed: Seed64) -> Self {
use bdk_wallet::bitcoin::bip32::Xpriv;
let master_xprv = Xpriv::new_master(network, seed.as_ref()).expect("valid seed");
Self::new(network).kind(WalletKind::Seed { master_xprv })
}
pub fn from_watch_only(network: Network) -> Self {
Self::new(network).mode(ProfileMode::Watch)
}
pub fn with_watch_address(mut self, address: &str) -> Result<Self, String> {
let addr = address
.parse::<Address<NetworkUnchecked>>()
.map_err(|e| format!("Invalid address: {e}"))?
.require_network(self.network)
.map_err(|e| format!("Network mismatch: {e}"))?;
if addr.address_type() != Some(AddressType::P2tr) {
return Err(
"Address watch mode currently supports taproot (bc1p/tb1p/bcrt1p) addresses only"
.to_string(),
);
}
self.kind = Some(WalletKind::WatchAddress(addr));
Ok(self)
}
pub fn with_xpub(mut self, xpub: &str) -> Result<Self, String> {
let parsed = parse_extended_public_key(xpub)?;
let taproot_desc = format!("tr({parsed}/0/*)");
let payment_desc = payment_descriptor_for_xpub(&parsed, self.payment_address_type, 0);
self.kind = Some(WalletKind::Hardware {
fingerprint: [0, 0, 0, 0],
taproot_external: taproot_desc,
payment_external: Some(payment_desc),
});
Ok(self)
}
pub fn with_taproot_xpub(mut self, xpub: &str) -> Result<Self, String> {
let parsed = parse_extended_public_key(xpub)?;
let taproot_desc = format!("tr({parsed}/0/*)");
let mut kind = self.kind.take().unwrap_or(WalletKind::Hardware {
fingerprint: [0, 0, 0, 0],
taproot_external: String::new(),
payment_external: None,
});
if let WalletKind::Hardware {
ref mut taproot_external,
..
} = kind
{
*taproot_external = taproot_desc;
}
self.kind = Some(kind);
Ok(self)
}
pub fn with_payment_xpub(mut self, xpub: &str) -> Result<Self, String> {
let parsed = parse_extended_public_key(xpub)?;
let payment_desc = payment_descriptor_for_xpub(&parsed, self.payment_address_type, 0);
let mut kind = self.kind.take().unwrap_or(WalletKind::Hardware {
fingerprint: [0, 0, 0, 0],
taproot_external: String::new(),
payment_external: None,
});
if let WalletKind::Hardware {
ref mut payment_external,
..
} = kind
{
*payment_external = Some(payment_desc);
}
self.kind = Some(kind);
Ok(self)
}
#[must_use]
pub fn kind(mut self, kind: WalletKind) -> Self {
self.kind = Some(kind);
self
}
#[must_use]
pub fn mode(mut self, mode: ProfileMode) -> Self {
self.mode = mode;
self
}
#[must_use]
pub fn with_scheme(mut self, scheme: AddressScheme) -> Self {
self.scheme = scheme;
self
}
#[must_use]
pub fn with_derivation_mode(mut self, mode: DerivationMode) -> Self {
self.derivation_mode = mode;
self
}
#[must_use]
pub fn with_payment_address_type(mut self, address_type: PaymentAddressType) -> Self {
self.payment_address_type = address_type;
self
}
#[must_use]
pub fn with_account_index(mut self, index: u32) -> Self {
self.account_index = index;
self
}
#[must_use]
pub fn scan_policy(mut self, policy: ScanPolicy) -> Self {
self.scan_policy = policy;
self
}
pub fn with_persistence(mut self, persistence_json: &str) -> Result<Self, String> {
let persistence: ZincPersistence = serde_json::from_str(persistence_json)
.map_err(|e| format!("Failed to parse persistence JSON: {e}"))?;
self.persistence = Some(persistence);
Ok(self)
}
#[must_use]
pub fn persistence(mut self, persistence: ZincPersistence) -> Self {
self.persistence = Some(persistence);
self
}
pub fn build(self) -> Result<ZincWallet, String> {
let kind = self
.kind
.ok_or_else(|| "Wallet identity must be set".to_string())?;
let mut scheme = self.scheme;
if matches!(kind, WalletKind::WatchAddress(_)) {
if scheme == AddressScheme::Dual {
return Err("Address watch profiles support unified scheme only".to_string());
}
scheme = AddressScheme::Unified;
}
let derivation_account = match self.derivation_mode {
DerivationMode::Account => self.account_index,
DerivationMode::Index => 0,
};
let (vault_ext, vault_int, payment_ext, payment_int) = kind.derive_descriptors(
scheme,
self.payment_address_type,
self.network,
derivation_account,
);
let (vault_wallet, loaded_vault_changeset) = if let Some(p) = &self.persistence {
if let Some(changeset) = &p.taproot {
let mut loader =
Wallet::load().descriptor(KeychainKind::External, Some(vault_ext.clone()));
if !matches!(kind, WalletKind::WatchAddress(_)) {
loader = loader.descriptor(KeychainKind::Internal, Some(vault_int.clone()));
}
let res = loader
.extract_keys()
.load_wallet_no_persist(changeset.clone());
match res {
Ok(Some(w)) => (w, changeset.clone()),
Ok(None) | Err(_) => {
let creator = if matches!(kind, WalletKind::WatchAddress(_)) {
Wallet::create_single(vault_ext)
} else {
Wallet::create(vault_ext, vault_int)
};
let w = creator
.network(self.network)
.create_wallet_no_persist()
.map_err(|e| format!("Failed to create taproot wallet: {e}"))?;
(w, bdk_wallet::ChangeSet::default())
}
}
} else {
let creator = if matches!(kind, WalletKind::WatchAddress(_)) {
Wallet::create_single(vault_ext)
} else {
Wallet::create(vault_ext, vault_int)
};
let w = creator
.network(self.network)
.create_wallet_no_persist()
.map_err(|e| format!("Failed to create taproot wallet: {e}"))?;
(w, bdk_wallet::ChangeSet::default())
}
} else {
let creator = if matches!(kind, WalletKind::WatchAddress(_)) {
Wallet::create_single(vault_ext)
} else {
Wallet::create(vault_ext, vault_int)
};
let w = creator
.network(self.network)
.create_wallet_no_persist()
.map_err(|e| format!("Failed to create taproot wallet: {e}"))?;
(w, bdk_wallet::ChangeSet::default())
};
let (payment_wallet, loaded_payment_changeset) =
if let (Some(pay_ext), Some(pay_int)) = (payment_ext, payment_int) {
let (wallet, changeset) = if let Some(p) = &self.persistence {
if let Some(changeset) = &p.payment {
let res = Wallet::load()
.descriptor(KeychainKind::External, Some(pay_ext.clone()))
.descriptor(KeychainKind::Internal, Some(pay_int.clone()))
.extract_keys()
.load_wallet_no_persist(changeset.clone());
match res {
Ok(Some(w)) => (w, Some(changeset.clone())),
Ok(None) | Err(_) => {
let w = Wallet::create(pay_ext, pay_int)
.network(self.network)
.create_wallet_no_persist()
.map_err(|e| format!("Failed to create payment wallet: {e}"))?;
(w, None)
}
}
} else {
let w = Wallet::create(pay_ext, pay_int)
.network(self.network)
.create_wallet_no_persist()
.map_err(|e| format!("Failed to create payment wallet: {e}"))?;
(w, None)
}
} else {
let w = Wallet::create(pay_ext, pay_int)
.network(self.network)
.create_wallet_no_persist()
.map_err(|e| format!("Failed to create payment wallet: {e}"))?;
(w, 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,
mode: self.mode,
scan_policy: self.scan_policy,
inscribed_utxos: std::collections::HashSet::default(),
inscriptions: Vec::new(),
rune_balances: Vec::new(),
ordinals_verified: false,
ordinals_metadata_complete: false,
kind,
is_syncing: false,
account_generation: 0,
force_next_full: false,
})
}
pub fn build_hardware(
self,
fingerprint_hex: &str,
taproot_external_desc: String,
taproot_internal_desc: String,
payment_external_desc: Option<String>,
payment_internal_desc: Option<String>,
) -> Result<ZincWallet, String> {
let fingerprint_vec =
hex::decode(fingerprint_hex).map_err(|e| format!("Invalid fingerprint hex: {e}"))?;
let fingerprint: [u8; 4] = fingerprint_vec
.try_into()
.map_err(|_| "Fingerprint must be 4 bytes".to_string())?;
let network = self.network;
let account_index = self.account_index;
let persistence = self.persistence;
let scheme = if payment_external_desc.is_some() {
AddressScheme::Dual
} else {
AddressScheme::Unified
};
let (vault_wallet, loaded_vault_changeset) = if let Some(p) = &persistence {
if let Some(changeset) = &p.taproot {
let res = Wallet::load()
.descriptor(KeychainKind::External, Some(taproot_external_desc.clone()))
.descriptor(KeychainKind::Internal, Some(taproot_internal_desc.clone()))
.extract_keys()
.load_wallet_no_persist(changeset.clone());
match res {
Ok(Some(w)) => (w, changeset.clone()),
Ok(None) | Err(_) => {
let w = Wallet::create(
taproot_external_desc.clone(),
taproot_internal_desc.clone(),
)
.network(network)
.create_wallet_no_persist()
.map_err(|e| {
format!("Failed to create taproot wallet from descriptor: {e}")
})?;
(w, bdk_wallet::ChangeSet::default())
}
}
} else {
let w =
Wallet::create(taproot_external_desc.clone(), taproot_internal_desc.clone())
.network(network)
.create_wallet_no_persist()
.map_err(|e| {
format!("Failed to create taproot wallet from descriptor: {e}")
})?;
(w, bdk_wallet::ChangeSet::default())
}
} else {
let w = Wallet::create(taproot_external_desc.clone(), taproot_internal_desc.clone())
.network(network)
.create_wallet_no_persist()
.map_err(|e| format!("Failed to create taproot wallet from descriptor: {e}"))?;
(w, bdk_wallet::ChangeSet::default())
};
let (payment_wallet, loaded_payment_changeset) = if let (Some(pay_ext), Some(pay_int)) =
(&payment_external_desc, &payment_internal_desc)
{
let (wallet, changeset) = if let Some(p) = &persistence {
if let Some(changeset) = &p.payment {
let res = Wallet::load()
.descriptor(KeychainKind::External, Some(pay_ext.clone()))
.descriptor(KeychainKind::Internal, Some(pay_int.clone()))
.extract_keys()
.load_wallet_no_persist(changeset.clone());
match res {
Ok(Some(w)) => (w, Some(changeset.clone())),
Ok(None) | Err(_) => {
let w = Wallet::create(pay_ext.clone(), pay_int.clone())
.network(network)
.create_wallet_no_persist()
.map_err(|e| {
format!("Failed to create payment wallet from descriptor: {e}")
})?;
(w, None)
}
}
} else {
let w = Wallet::create(pay_ext.clone(), pay_int.clone())
.network(network)
.create_wallet_no_persist()
.map_err(|e| {
format!("Failed to create payment wallet from descriptor: {e}")
})?;
(w, None)
}
} else {
let w = Wallet::create(pay_ext.clone(), pay_int.clone())
.network(network)
.create_wallet_no_persist()
.map_err(|e| format!("Failed to create payment wallet from descriptor: {e}"))?;
(w, None)
};
(Some(wallet), changeset)
} else {
(None, None)
};
Ok(ZincWallet {
vault_wallet,
payment_wallet,
scheme,
derivation_mode: self.derivation_mode,
payment_address_type: self.payment_address_type,
loaded_vault_changeset,
loaded_payment_changeset,
account_index,
mode: ProfileMode::Watch,
scan_policy: self.scan_policy,
inscribed_utxos: std::collections::HashSet::default(),
inscriptions: Vec::new(),
rune_balances: Vec::new(),
ordinals_verified: false,
ordinals_metadata_complete: false,
kind: WalletKind::Hardware {
fingerprint,
taproot_external: taproot_external_desc,
payment_external: payment_external_desc,
},
is_syncing: false,
account_generation: 0,
force_next_full: false,
})
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ZincPersistence {
pub taproot: Option<bdk_wallet::ChangeSet>,
pub payment: Option<bdk_wallet::ChangeSet>,
}
#[cfg(test)]
mod tests {
use super::*;
use bitcoin::Network;
#[test]
fn test_builder_basic() {
use bdk_wallet::bitcoin::bip32::Xpriv;
let mnemonic = ZincMnemonic::generate(12).unwrap();
let seed = mnemonic.to_seed("");
let master_xprv = Xpriv::new_master(Network::Signet, seed.as_ref()).expect("valid seed");
let wallet = WalletBuilder::new(Network::Signet)
.kind(WalletKind::Seed { master_xprv })
.build()
.unwrap();
assert_eq!(wallet.vault_wallet.network(), Network::Signet);
assert!(wallet.is_unified());
}
#[test]
fn flexible_full_scan_request_uses_explicit_start_time() {
use bdk_wallet::bitcoin::bip32::Xpriv;
let mnemonic = ZincMnemonic::generate(12).unwrap();
let seed = mnemonic.to_seed("");
let master_xprv = Xpriv::new_master(Network::Signet, seed.as_ref()).expect("valid seed");
let wallet = WalletBuilder::new(Network::Signet)
.kind(WalletKind::Seed { master_xprv })
.build()
.unwrap();
let explicit_start = 1_777_777_777_u64;
let req = ZincWallet::flexible_full_scan_request(
&wallet.vault_wallet,
ScanPolicy::default(),
explicit_start,
);
assert_eq!(req.start_time(), explicit_start);
}
}