use bitcoin::{
bip32::{DerivationPath as BitcoinDerivationPath, ExtendedPrivKey, ExtendedPubKey},
hashes::Hash as BitcoinHash,
key::TapTweak,
secp256k1::{self, Secp256k1, SecretKey, XOnlyPublicKey},
Address, Network, OutPoint, Txid,
};
use std::collections::{HashMap, HashSet};
use std::str::FromStr;
use std::sync::Mutex;
use bitcoin::secp256k1::rand::{rngs::OsRng, RngCore};
#[allow(unused_imports)]
use crate::types::BitcoinSealRef;
const HARDENED: u32 = 0x8000_0000;
const BIP86_PURPOSE: u32 = 86;
fn coin_type(network: &Network) -> u32 {
match network {
Network::Bitcoin => 0,
_ => 1,
}
}
#[derive(Clone, Debug)]
pub struct Bip86Path {
pub account: u32,
pub change: u32,
pub index: u32,
}
impl Bip86Path {
pub fn new(account: u32, change: u32, index: u32) -> Self {
Self {
account,
change,
index,
}
}
pub fn external(account: u32, index: u32) -> Self {
Self::new(account, 0, index)
}
pub fn internal(account: u32, index: u32) -> Self {
Self::new(account, 1, index)
}
pub fn to_bitcoin_path(&self, network: &Network) -> BitcoinDerivationPath {
let coin = coin_type(network);
format!(
"m/{}'/{}'/{}'/{}/{}",
BIP86_PURPOSE, coin, self.account, self.change, self.index
)
.parse()
.expect("valid BIP-32 path")
}
pub fn to_string(&self, network: &Network) -> String {
let coin = coin_type(network);
format!(
"m/{}'/{}'/{}'/{}/{}",
BIP86_PURPOSE, coin, self.account, self.change, self.index
)
}
}
#[derive(Clone, Debug)]
pub struct WalletUtxo {
pub outpoint: OutPoint,
pub amount_sat: u64,
pub path: Bip86Path,
pub reserved: bool,
pub reserved_for: Option<String>,
}
#[derive(Clone, Debug)]
pub struct DerivedTaprootKey {
pub internal_xonly: XOnlyPublicKey,
pub output_key: bitcoin::key::TweakedPublicKey,
pub path: Bip86Path,
pub address: Address,
}
pub struct SealWallet {
master_key: ExtendedPrivKey,
network: Network,
utxos: Mutex<HashMap<OutPoint, WalletUtxo>>,
used_seals: Mutex<HashSet<Vec<u8>>>,
secp: Secp256k1<secp256k1::All>,
next_index: Mutex<HashMap<u32, u32>>,
}
impl SealWallet {
pub fn from_mnemonic(
mnemonic: &str,
password: &str,
network: Network,
) -> Result<Self, WalletError> {
let seed = bip32::Mnemonic::new(mnemonic, bip32::Language::English)
.map_err(|e| WalletError::InvalidMnemonic(e.to_string()))?
.to_seed(password);
Self::from_seed(seed.as_bytes(), network)
}
pub fn from_seed(seed: &[u8; 64], network: Network) -> Result<Self, WalletError> {
let btc_net = match network {
Network::Bitcoin => bitcoin::Network::Bitcoin,
Network::Testnet => bitcoin::Network::Testnet,
Network::Signet => bitcoin::Network::Signet,
Network::Regtest => bitcoin::Network::Regtest,
_ => bitcoin::Network::Testnet,
};
let secp = Secp256k1::new();
let master_key = ExtendedPrivKey::new_master(btc_net, seed)
.map_err(|e| WalletError::KeyDerivationFailed(e.to_string()))?;
Ok(Self {
master_key,
network,
utxos: Mutex::new(HashMap::new()),
used_seals: Mutex::new(HashSet::new()),
secp,
next_index: Mutex::new(HashMap::new()),
})
}
pub fn generate_random(network: Network) -> Self {
let mut seed = [0u8; 64];
OsRng.fill_bytes(&mut seed);
Self::from_seed(&seed, network).expect("valid seed")
}
pub fn from_xpub(xpub: &str, network: Network) -> Result<Self, WalletError> {
let extended_pub = ExtendedPubKey::from_str(xpub)
.map_err(|e| WalletError::InvalidKey(format!("Invalid xpub: {}", e)))?;
let btc_net = match network {
Network::Bitcoin => bitcoin::Network::Bitcoin,
Network::Testnet => bitcoin::Network::Testnet,
Network::Signet => bitcoin::Network::Signet,
Network::Regtest => bitcoin::Network::Regtest,
_ => bitcoin::Network::Testnet,
};
if extended_pub.network != btc_net {
return Err(WalletError::InvalidKey(format!(
"xpub network mismatch: expected {:?}, got {:?}",
btc_net, extended_pub.network
)));
}
let mut seed = [0u8; 64];
OsRng.fill_bytes(&mut seed);
let wallet = Self::from_seed(&seed, network)?;
Ok(wallet)
}
fn derive_private_key(&self, path: &Bip86Path) -> Result<SecretKey, WalletError> {
let btc_path = path.to_bitcoin_path(&self.network);
let child = self
.master_key
.derive_priv(&self.secp, &btc_path)
.map_err(|e| WalletError::KeyDerivationFailed(format!("{:?}", e)))?;
Ok(child.private_key)
}
pub fn derive_key(&self, path: &Bip86Path) -> Result<DerivedTaprootKey, WalletError> {
let secret_key = self.derive_private_key(path)?;
let kp = secp256k1::KeyPair::from_secret_key(&self.secp, &secret_key);
let (xonly, _parity) = XOnlyPublicKey::from_keypair(&kp);
let (output_key, _) = xonly.tap_tweak(&self.secp, None);
let address = Address::p2tr_tweaked(output_key, self.network);
Ok(DerivedTaprootKey {
internal_xonly: xonly,
output_key,
path: path.clone(),
address,
})
}
pub fn sign_taproot_keypath(
&self,
path: &Bip86Path,
sighash: &[u8; 32],
) -> Result<Vec<u8>, WalletError> {
let secret_key = self.derive_private_key(path)?;
let kp = secp256k1::KeyPair::from_secret_key(&self.secp, &secret_key);
let tweaked_kp = kp.tap_tweak(&self.secp, None);
let msg = secp256k1::Message::from_slice(sighash)
.map_err(|e| WalletError::SigningFailed(e.to_string()))?;
let sig = self
.secp
.sign_schnorr_with_rng(&msg, &tweaked_kp.to_inner(), &mut OsRng);
Ok(sig.as_ref().to_vec())
}
pub fn next_address(
&self,
account: u32,
) -> Result<(DerivedTaprootKey, Bip86Path), WalletError> {
let mut ni = self.next_index.lock().unwrap_or_else(|e| e.into_inner());
let idx = ni.entry(account).or_insert(0);
let path = Bip86Path::external(account, *idx);
let key = self.derive_key(&path)?;
*idx += 1;
Ok((key, path))
}
pub fn get_funding_address(
&self,
account: u32,
index: u32,
) -> Result<DerivedTaprootKey, WalletError> {
self.derive_key(&Bip86Path::external(account, index))
}
pub fn get_account_xpub(&self, account: u32) -> Result<String, WalletError> {
let coin = coin_type(&self.network);
let account_path: BitcoinDerivationPath =
format!("m/{}'/{}'/{}'", BIP86_PURPOSE, coin, account)
.parse()
.map_err(|e| WalletError::KeyDerivationFailed(format!("{:?}", e)))?;
let account_key = self
.master_key
.derive_priv(&self.secp, &account_path)
.map_err(|e| WalletError::KeyDerivationFailed(format!("{:?}", e)))?;
Ok(ExtendedPubKey::from_priv(&self.secp, &account_key).to_string())
}
pub fn add_utxo(&self, outpoint: OutPoint, amount_sat: u64, path: Bip86Path) {
self.utxos.lock().unwrap_or_else(|e| e.into_inner()).insert(
outpoint,
WalletUtxo {
outpoint,
amount_sat,
path,
reserved: false,
reserved_for: None,
},
);
}
pub fn balance(&self) -> u64 {
self.utxos
.lock()
.unwrap_or_else(|e| e.into_inner())
.values()
.filter(|u| !u.reserved)
.map(|u| u.amount_sat)
.sum()
}
pub fn utxo_count(&self) -> usize {
self.utxos
.lock()
.unwrap_or_else(|e| e.into_inner())
.values()
.filter(|u| !u.reserved)
.count()
}
pub fn select_utxos(&self, target_sat: u64) -> Result<Vec<WalletUtxo>, WalletError> {
let mut available: Vec<_> = self
.utxos
.lock()
.unwrap_or_else(|e| e.into_inner())
.values()
.filter(|u| !u.reserved)
.cloned()
.collect();
available.sort_by(|a, b| b.amount_sat.cmp(&a.amount_sat));
let mut sel = Vec::new();
let mut total = 0u64;
for utxo in available {
if total >= target_sat {
break;
}
total += utxo.amount_sat;
sel.push(utxo);
}
if total < target_sat {
Err(WalletError::InsufficientFunds {
available: total,
needed: target_sat,
})
} else {
Ok(sel)
}
}
pub fn reserve_utxos(&self, ops: &[OutPoint], reason: &str) {
let mut u = self.utxos.lock().unwrap_or_else(|e| e.into_inner());
for op in ops {
if let Some(x) = u.get_mut(op) {
x.reserved = true;
x.reserved_for = Some(reason.to_string());
}
}
}
pub fn unreserve_utxos(&self, ops: &[OutPoint]) {
let mut u = self.utxos.lock().unwrap_or_else(|e| e.into_inner());
for op in ops {
if let Some(x) = u.get_mut(op) {
x.reserved = false;
x.reserved_for = None;
}
}
}
pub fn sign_with_key(
&self,
path: &Bip86Path,
msg: &[u8; 32],
) -> Result<secp256k1::ecdsa::Signature, WalletError> {
let sk = self.derive_private_key(path)?;
let msg = secp256k1::Message::from_slice(msg.as_ref())
.map_err(|e| WalletError::SigningFailed(e.to_string()))?;
Ok(self.secp.sign_ecdsa(&msg, &sk))
}
pub fn mark_seal_used(&self, seal: &BitcoinSealRef) -> Result<(), WalletError> {
let mut used = self.used_seals.lock().unwrap_or_else(|e| e.into_inner());
let key = seal.to_vec();
if used.contains(&key) {
return Err(WalletError::SealAlreadyUsed);
}
used.insert(key);
Ok(())
}
pub fn is_seal_used(&self, seal: &BitcoinSealRef) -> bool {
self.used_seals
.lock()
.unwrap_or_else(|e| e.into_inner())
.contains(&seal.to_vec())
}
pub fn network(&self) -> Network {
self.network
}
pub fn secp(&self) -> &Secp256k1<secp256k1::All> {
&self.secp
}
pub fn get_utxo(&self, op: &OutPoint) -> Option<WalletUtxo> {
self.utxos
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(op)
.cloned()
}
pub fn list_utxos(&self) -> Vec<WalletUtxo> {
self.utxos
.lock()
.unwrap_or_else(|e| e.into_inner())
.values()
.cloned()
.collect()
}
pub fn scan_chain_for_utxos<F>(
&self,
mut fetch_utxos: F,
account: u32,
address_gap_limit: usize,
) -> Result<usize, WalletError>
where
F: FnMut(&Address) -> Result<Vec<(OutPoint, u64)>, String>,
{
let mut discovered_count = 0;
let mut consecutive_empty = 0;
let mut index = 0;
loop {
if consecutive_empty >= address_gap_limit {
break;
}
let path = Bip86Path::external(account, index);
let derived = self.derive_key(&path)?;
match fetch_utxos(&derived.address) {
Ok(utxos) => {
if utxos.is_empty() {
consecutive_empty += 1;
} else {
consecutive_empty = 0;
for (outpoint, amount) in utxos {
self.add_utxo(outpoint, amount, path.clone());
discovered_count += 1;
}
}
}
Err(e) => {
return Err(WalletError::KeyDerivationFailed(e));
}
}
index += 1;
}
Ok(discovered_count)
}
pub fn add_utxo_from_address(
&self,
outpoint: OutPoint,
amount_sat: u64,
account: u32,
index: u32,
) -> Result<(), WalletError> {
let path = Bip86Path::external(account, index);
let _derived = self.derive_key(&path)?;
self.add_utxo(outpoint, amount_sat, path);
Ok(())
}
}
#[derive(Debug, thiserror::Error)]
pub enum WalletError {
#[error("No available UTXOs")]
NoAvailableUtxos,
#[error("Insufficient funds: available {available} sat, needed {needed} sat")]
InsufficientFunds { available: u64, needed: u64 },
#[error("UTXO not found")]
UtxoNotFound,
#[error("Seal already used")]
SealAlreadyUsed,
#[error("Invalid mnemonic: {0}")]
InvalidMnemonic(String),
#[error("Key derivation failed: {0}")]
KeyDerivationFailed(String),
#[error("Invalid key: {0}")]
InvalidKey(String),
#[error("Signing failed: {0}")]
SigningFailed(String),
#[error("PSBT error: {0}")]
PsbtError(String),
#[error("Script error: {0}")]
ScriptError(String),
}
pub struct MockSealWallet {
pub utxos: Vec<(OutPoint, u64)>,
pub used_seals: Mutex<HashSet<Vec<u8>>>,
}
impl MockSealWallet {
pub fn new() -> Self {
Self {
utxos: Vec::new(),
used_seals: Mutex::new(HashSet::new()),
}
}
pub fn add_utxo(&mut self, txid: [u8; 32], vout: u32, amount_sat: u64) {
let txid = Txid::from_raw_hash(bitcoin::hashes::sha256d::Hash::from_slice(&txid).unwrap());
self.utxos.push((OutPoint::new(txid, vout), amount_sat));
}
}
impl Default for MockSealWallet {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_wallet_creation_from_random() {
let w = SealWallet::generate_random(Network::Signet);
assert_eq!(w.balance(), 0);
}
#[test]
fn test_wallet_key_derivation() {
let w = SealWallet::generate_random(Network::Signet);
let k = w.derive_key(&Bip86Path::external(0, 0)).unwrap();
assert_eq!(k.address.network, Network::Signet);
assert!(k.address.script_pubkey().is_witness_program());
}
#[test]
fn test_wallet_key_derivation_deterministic() {
let seed = [42u8; 64];
let w1 = SealWallet::from_seed(&seed, Network::Signet).unwrap();
let w2 = SealWallet::from_seed(&seed, Network::Signet).unwrap();
let k1 = w1.derive_key(&Bip86Path::external(0, 0)).unwrap();
let k2 = w2.derive_key(&Bip86Path::external(0, 0)).unwrap();
assert_eq!(k1.output_key, k2.output_key);
assert_eq!(k1.address, k2.address);
}
#[test]
fn test_wallet_different_paths() {
let w = SealWallet::generate_random(Network::Signet);
let k0 = w.derive_key(&Bip86Path::external(0, 0)).unwrap();
let k1 = w.derive_key(&Bip86Path::external(0, 1)).unwrap();
let k2 = w.derive_key(&Bip86Path::external(1, 0)).unwrap();
assert_ne!(k0.output_key, k1.output_key);
assert_ne!(k0.output_key, k2.output_key);
assert_ne!(k1.output_key, k2.output_key);
}
#[test]
fn test_wallet_utxo_selection() {
let w = SealWallet::generate_random(Network::Signet);
let path = Bip86Path::external(0, 0);
let t1 =
Txid::from_raw_hash(bitcoin::hashes::sha256d::Hash::from_slice(&[1u8; 32]).unwrap());
let t2 =
Txid::from_raw_hash(bitcoin::hashes::sha256d::Hash::from_slice(&[2u8; 32]).unwrap());
let t3 =
Txid::from_raw_hash(bitcoin::hashes::sha256d::Hash::from_slice(&[3u8; 32]).unwrap());
w.add_utxo(OutPoint::new(t1, 0), 50_000, path.clone());
w.add_utxo(OutPoint::new(t2, 0), 30_000, path.clone());
w.add_utxo(OutPoint::new(t3, 0), 20_000, path);
let sel = w.select_utxos(70_000).unwrap();
assert_eq!(sel.len(), 2);
assert_eq!(sel.iter().map(|u| u.amount_sat).sum::<u64>(), 80_000);
}
#[test]
fn test_wallet_insufficient_funds() {
let w = SealWallet::generate_random(Network::Signet);
let txid =
Txid::from_raw_hash(bitcoin::hashes::sha256d::Hash::from_slice(&[1u8; 32]).unwrap());
w.add_utxo(OutPoint::new(txid, 0), 10_000, Bip86Path::external(0, 0));
assert!(w.select_utxos(20_000).is_err());
}
#[test]
fn test_wallet_reserve_utxos() {
let w = SealWallet::generate_random(Network::Signet);
let txid =
Txid::from_raw_hash(bitcoin::hashes::sha256d::Hash::from_slice(&[1u8; 32]).unwrap());
let op = OutPoint::new(txid, 0);
w.add_utxo(op, 100_000, Bip86Path::external(0, 0));
assert_eq!(w.balance(), 100_000);
w.reserve_utxos(&[op], "test");
assert_eq!(w.balance(), 0);
w.unreserve_utxos(&[op]);
assert_eq!(w.balance(), 100_000);
}
#[test]
fn test_seal_lifecycle() {
let w = SealWallet::generate_random(Network::Signet);
let seal = BitcoinSealRef::new([1u8; 32], 0, Some(42));
assert!(!w.is_seal_used(&seal));
w.mark_seal_used(&seal).unwrap();
assert!(w.is_seal_used(&seal));
assert!(w.mark_seal_used(&seal).is_err());
}
#[test]
fn test_derivation_path_string() {
assert_eq!(
Bip86Path::new(0, 0, 5).to_string(&Network::Bitcoin),
"m/86'/0'/0'/0/5"
);
}
#[test]
fn test_mock_wallet() {
let mut w = MockSealWallet::new();
w.add_utxo([1u8; 32], 0, 100_000);
assert_eq!(w.utxos.len(), 1);
}
}