diff --git a/crates/regtest/src/error.rs b/crates/regtest/src/error.rs index 77d953b0..8957871a 100644 --- a/crates/regtest/src/error.rs +++ b/crates/regtest/src/error.rs @@ -2,6 +2,7 @@ use std::io; use smplx_sdk::provider::RpcError; use smplx_sdk::signer::SignerError; +use smplx_sdk::signer::error::KeyOriginError; #[derive(thiserror::Error, Debug)] pub enum RegtestError { @@ -11,6 +12,9 @@ pub enum RegtestError { #[error(transparent)] Signer(#[from] SignerError), + #[error(transparent)] + KeyOrigin(#[from] KeyOriginError), + #[error("Failed to terminate elements")] ElementsTermination(), diff --git a/crates/regtest/src/regtest.rs b/crates/regtest/src/regtest.rs index b4c27576..1565a578 100644 --- a/crates/regtest/src/regtest.rs +++ b/crates/regtest/src/regtest.rs @@ -3,7 +3,7 @@ use std::time::Duration; use smplx_sdk::provider::ElementsRpc; use smplx_sdk::provider::SimplexProvider; use smplx_sdk::provider::SimplicityNetwork; -use smplx_sdk::signer::Signer; +use smplx_sdk::signer::{HDKey, KeyOrigin, Signer}; use smplx_sdk::utils::btc2sat; use super::RegtestConfig; @@ -23,7 +23,7 @@ impl Regtest { /// /// # Panics /// Panics if the background indexer (`electrs`) fails to index the unspent outputs within the timeout window (10 seconds). - pub fn from_config(config: &RegtestConfig) -> Result<(RegtestClient, Signer), RegtestError> { + pub fn from_config(config: &RegtestConfig) -> Result<(RegtestClient, Signer), RegtestError> { let client = RegtestClient::new(config); let provider = Box::new(SimplexProvider::new( @@ -33,14 +33,18 @@ impl Regtest { SimplicityNetwork::default_regtest(), )); - let signer = Signer::new(config.mnemonic.as_str(), provider); + let signer = Signer::new(HDKey::new(config.mnemonic.as_str())?, provider); Self::prepare_signer(&client, &signer, config.bitcoins)?; Ok((client, signer)) } - fn prepare_signer(client: &RegtestClient, signer: &Signer, bitcoins: u64) -> Result<(), RegtestError> { + fn prepare_signer( + client: &RegtestClient, + signer: &Signer, + bitcoins: u64, + ) -> Result<(), RegtestError> { let rpc_provider = ElementsRpc::new(client.rpc_url(), client.auth())?; rpc_provider.generate_blocks(1)?; diff --git a/crates/sdk/src/signer/core.rs b/crates/sdk/src/signer/core.rs index dc53b4d2..3355db43 100644 --- a/crates/sdk/src/signer/core.rs +++ b/crates/sdk/src/signer/core.rs @@ -1,5 +1,4 @@ use std::collections::{HashMap, HashSet}; -use std::str::FromStr; use std::sync::Arc; use simplicityhl::Value; @@ -12,20 +11,13 @@ use simplicityhl::simplicity::hashes::Hash; use simplicityhl::str::WitnessName; use simplicityhl::value::ValueConstructible; -use bip39::Mnemonic; use bip39::rand::thread_rng; use elements_miniscript::{ - ConfidentialDescriptor, Descriptor, DescriptorPublicKey, - bitcoin::{NetworkKind, PrivateKey, PublicKey, bip32::DerivationPath}, - elements::{ - EcdsaSighashType, - bitcoin::bip32::{Fingerprint, Xpriv, Xpub}, - sighash::SighashCache, - }, + bitcoin::{PrivateKey, PublicKey}, + elements::{EcdsaSighashType, sighash::SighashCache}, elementssig_to_rawsig, psbt::PsbtExt, - slip77::MasterBlindingKey, }; use crate::constants::MIN_FEE; @@ -33,10 +25,11 @@ use crate::program::ProgramTrait; use crate::program::logger::ProgramLogger; use crate::provider::ProviderTrait; use crate::provider::SimplicityNetwork; +use crate::signer::KeyOrigin; use crate::signer::wtns_injector::WtnsInjector; use crate::transaction::{FinalTransaction, PartialInput, PartialOutput, RequiredSignature, TxReceipt, UTXO}; -use super::error::SignerError; +use super::error::{KeyOriginError, SignerError}; /// A placeholder dummy fee amount used during transaction estimation. pub const PLACEHOLDER_FEE: u64 = 1; @@ -68,15 +61,14 @@ pub trait SignerTrait { /// Core interface responsible for managing keys, interfacing with the blockchain provider, /// assembling descriptors, estimating fees, and finalizing/signing transactions. -pub struct Signer { - mnemonic: Mnemonic, - xprv: Xpriv, +pub struct Signer { + key_origin: K, provider: Box, network: SimplicityNetwork, secp: Secp256k1, } -impl SignerTrait for Signer { +impl SignerTrait for Signer { fn sign_program( &self, pst: &PartiallySignedTransaction, @@ -121,32 +113,39 @@ enum Estimate { Failure(u64), } -impl Signer { - /// Creates a new `Signer` instance seeded from the provided mnemonic and paired with the specified provider. - /// - /// # Panics - /// Panics if the mnemonic fails to parse, or if deriving the master private key fails. +impl Signer { + /// Returns a reference to the active configured network provider. #[must_use] - pub fn new(mnemonic: &str, provider: Box) -> Self { - let secp = Secp256k1::new(); - let mnemonic: Mnemonic = mnemonic - .parse() - .map_err(|e: bip39::Error| SignerError::Mnemonic(e.to_string())) - .unwrap(); - let seed = mnemonic.to_seed(""); - let xprv = Xpriv::new_master(NetworkKind::Test, &seed).unwrap(); + pub fn get_provider(&self) -> &dyn ProviderTrait { + self.provider.as_ref() + } +} +impl Signer { + /// Creates a new `Signer` instance seeded from the provided key origin and paired with the specified provider. + #[must_use] + pub fn new(key_origin: K, provider: Box) -> Self { + let secp = Secp256k1::new(); let network = *provider.get_network(); Self { - mnemonic, - xprv, + key_origin, provider, network, secp, } } + /// Evaluates, funds, and broadcasts an already assembled `FinalTransaction`. + /// + /// # Errors + /// Returns a `SignerError` if finalizing the payload fails or if the network rejects the broadcast. + pub fn broadcast(&self, tx: &FinalTransaction) -> Result, SignerError> { + let (tx, _fee) = self.finalize(tx)?; + + Ok(self.provider.broadcast_transaction(&tx)?) + } + /// Composes, funds, and broadcasts a standard network transaction sending the specified value of the primary policy asset. /// /// # Errors @@ -162,14 +161,58 @@ impl Signer { Ok(self.provider.broadcast_transaction(&tx)?) } - /// Evaluates, funds, and broadcasts an already assembled `FinalTransaction`. + /// Proxies to the underlying key provider to get the Schnorr public key. + pub fn get_schnorr_public_key(&self) -> XOnlyPublicKey { + self.key_origin.get_schnorr_public_key(&self.secp, &self.network) + } + + /// Proxies to the underlying key provider to get the ECDSA public key. + pub fn get_ecdsa_public_key(&self) -> PublicKey { + self.key_origin.get_ecdsa_public_key(&self.secp, &self.network) + } + + /// Proxies to the underlying key provider to get the blinding public key. /// /// # Errors - /// Returns a `SignerError` if finalizing the payload fails or if the network rejects the broadcast. - pub fn broadcast(&self, tx: &FinalTransaction) -> Result, SignerError> { - let (tx, _fee) = self.finalize(tx)?; + /// Returns a `SignerError` if the unblinding key is missing or fails to derive. + pub fn get_blinding_public_key(&self) -> Result { + Ok(self + .key_origin + .get_blinding_public_key(&self.secp, &self.network) + .ok_or(KeyOriginError::RequiredUnblindingKey)?) + } - Ok(self.provider.broadcast_transaction(&tx)?) + /// Proxies to the underlying key provider to get the active private key. + pub fn get_private_key(&self) -> PrivateKey { + self.key_origin.get_private_key(&self.secp, &self.network) + } + + /// Proxies to the underlying key provider to get the blinding private key. + /// + /// # Errors + /// Returns a `SignerError` if the unblinding key is missing or fails to derive. + pub fn get_blinding_private_key(&self) -> Result { + Ok(self + .key_origin + .get_blinding_private_key(&self.secp, &self.network) + .ok_or(KeyOriginError::RequiredUnblindingKey)?) + } + + /// Proxies to the underlying key provider to get the confidential address. + /// + /// # Errors + /// Returns a `SignerError` if the unblinding key or confidential address fails to derive. + pub fn get_confidential_address(&self) -> Result { + Ok(self + .key_origin + .get_confidential_address(&self.secp, &self.network) + .ok_or(KeyOriginError::RequiredUnblindingKey)?) + } + + /// Proxies to the underlying key provider to get the standard unblinded address. + #[must_use] + pub fn get_address(&self) -> Address { + self.key_origin.get_address(&self.secp, &self.network) } /// Evaluates the input components of a `FinalTransaction`, iteratively selecting available wallet UTXOs to cover outputs and estimated fees. @@ -257,56 +300,12 @@ impl Signer { } } - /// Returns a reference to the active configured network provider. - #[must_use] - pub fn get_provider(&self) -> &dyn ProviderTrait { - self.provider.as_ref() - } - - /// Returns the confidential elements address matching the local wallet logic. - /// - /// # Panics - /// Panics if the SLIP77 descriptor cannot be generated or parsed, or if address derivation fails. - #[must_use] - pub fn get_confidential_address(&self) -> Address { - let mut descriptor = - ConfidentialDescriptor::::from_str(&self.get_slip77_descriptor().unwrap()) - .map_err(|e| SignerError::Slip77Descriptor(e.to_string())) - .unwrap(); - - // confidential descriptor doesn't support multipath - descriptor.descriptor = descriptor.descriptor.into_single_descriptors().unwrap()[0].clone(); - - descriptor - .at_derivation_index(1) - .unwrap() - .address(&self.secp, self.network.address_params()) - .unwrap() - } - - /// Returns the standard unblinded address matching the local wallet logic. - /// - /// # Panics - /// Panics if the WPKH descriptor cannot be generated or parsed, or if address derivation fails. - #[must_use] - pub fn get_address(&self) -> Address { - let descriptor = Descriptor::::from_str(&self.get_wpkh_descriptor().unwrap()) - .map_err(|e| SignerError::WpkhDescriptor(e.to_string())) - .unwrap(); - - descriptor.into_single_descriptors().unwrap()[0] - .at_derivation_index(1) - .unwrap() - .address(self.network.address_params()) - .unwrap() - } - /// Iterates against the network provider to select and unblind all known UTXOs. /// /// # Errors /// Returns a `SignerError` if querying the network or unblinding operations fail. pub fn get_utxos(&self) -> Result, SignerError> { - self.get_utxos_filter(&|_| true, &|_| true) + self.get_utxos_filter(|_| true, |_| true) } /// Finds all known UTXOs belonging to the specific `AssetId`. @@ -314,7 +313,7 @@ impl Signer { /// # Errors /// Returns a `SignerError` if network interaction or confidential output decryption fails. pub fn get_utxos_asset(&self, asset: AssetId) -> Result, SignerError> { - self.get_utxos_filter(&|utxo| utxo.asset() == asset, &|utxo| utxo.asset() == asset) + self.get_utxos_filter(move |utxo| utxo.asset() == asset, move |utxo| utxo.asset() == asset) } /// Finds all known UTXOs deriving from a targeted `Txid`. @@ -323,7 +322,10 @@ impl Signer { /// Returns a `SignerError` if querying the network fails. // TODO: can this be optimized to not populate TxOuts that are filtered out? pub fn get_utxos_txid(&self, txid: Txid) -> Result, SignerError> { - self.get_utxos_filter(&|utxo| utxo.outpoint.txid == txid, &|utxo| utxo.outpoint.txid == txid) + self.get_utxos_filter( + move |utxo| utxo.outpoint.txid == txid, + move |utxo| utxo.outpoint.txid == txid, + ) } /// Maps UTXOs retrieved from the provider through arbitrary functional filters. @@ -333,103 +335,31 @@ impl Signer { /// Returns a `SignerError` if retrieving remote outputs or executing confidential node unblinding throws an error. pub fn get_utxos_filter( &self, - explicit_filter: &dyn Fn(&UTXO) -> bool, - confidential_filter: &dyn Fn(&UTXO) -> bool, + explicit_filter: impl Fn(&UTXO) -> bool, + confidential_filter: impl Fn(&UTXO) -> bool, ) -> Result, SignerError> { - // fetch explicit and confidential utxos - let mut all_utxos = self.provider.fetch_address_utxos(&self.get_confidential_address())?; - - // filter out only confidential utxos and unblind them - let mut confidential_utxos = self.unblind( - all_utxos - .iter() - .filter(|utxo| utxo.txout.value.is_confidential()) - .cloned() - .collect(), - )?; - // leave only explicit utxos - all_utxos.retain(|utxo| !utxo.txout.value.is_confidential()); - - all_utxos.retain(explicit_filter); - confidential_utxos.retain(confidential_filter); + let confidential_addr = self.get_confidential_address()?; + let all_utxos = self.provider.fetch_address_utxos(&confidential_addr)?; - // push unblinded utxos to explicit ones - all_utxos.extend(confidential_utxos); + // partition confidential and explicit utxos + let (confidential_utxos, mut explicit_utxos): (Vec, Vec) = all_utxos + .into_iter() + .partition(|utxo| utxo.txout.value.is_confidential()); - Ok(all_utxos) - } + let mut unblinded_utxos = self.unblind(confidential_utxos)?; - /// Derives the X-Only public key specifically used for Schnorr and Taproot structures. - #[must_use] - pub fn get_schnorr_public_key(&self) -> XOnlyPublicKey { - let private_key = self.get_private_key(); - let keypair = Keypair::from_secret_key(&self.secp, &private_key.inner); + unblinded_utxos.retain(confidential_filter); + explicit_utxos.retain(explicit_filter); - keypair.x_only_public_key().0 - } - - /// Resolves the standard format ECDSA public key. - #[must_use] - pub fn get_ecdsa_public_key(&self) -> PublicKey { - self.get_private_key().public_key(&self.secp) - } - - /// Resolves the corresponding blinding public key. - #[must_use] - pub fn get_blinding_public_key(&self) -> PublicKey { - self.get_blinding_private_key().public_key(&self.secp) - } - - /// Internally derives and exposes the wallet's signing active private key. - /// - /// # Panics - /// Panics if the master private key or derivation path cannot be derived. - #[must_use] - pub fn get_private_key(&self) -> PrivateKey { - let master_xprv = self.master_xpriv().unwrap(); - let full_path = self.get_derivation_path().unwrap(); - - let derived = full_path.extend( - DerivationPath::from_str("0/1") - .map_err(|e| SignerError::DerivationPath(e.to_string())) - .unwrap(), - ); - - let ext_derived = master_xprv.derive_priv(&self.secp, &derived).unwrap(); - - PrivateKey::new(ext_derived.private_key, NetworkKind::Test) - } - - /// Generates the private key linked to confidential payload blinding. - /// - /// The generated `PrivateKey` is associated with the `Test` (non-Bitcoin-mainnet) network kind. - /// Retrieves the blinding private key derived from the master SLIP77 key and the script public key of the address. - /// - /// # Panics - /// Panics if the master SLIP77 key cannot be derived. - #[must_use] - pub fn get_blinding_private_key(&self) -> PrivateKey { - let blinding_key = self - .master_slip77() - .unwrap() - .blinding_private_key(&self.get_address().script_pubkey()); + // push unblinded utxos to explicit ones + explicit_utxos.extend(unblinded_utxos); - PrivateKey::new(blinding_key, NetworkKind::Test) + Ok(explicit_utxos) } + /// Proxies to the underlying key provider to get unblinded UTXOs. fn unblind(&self, utxos: Vec) -> Result, SignerError> { - let mut unblinded: Vec = Vec::new(); - - for mut utxo in utxos { - let blinding_key = self.get_blinding_private_key(); - let secrets = utxo.txout.unblind(&self.secp, blinding_key.inner)?; - - utxo.secrets = Some(secrets); - - unblinded.push(utxo); - } - - Ok(unblinded) + Ok(self.key_origin.unblind(&self.secp, &self.network, utxos)?) } fn estimate_tx( @@ -584,71 +514,24 @@ impl Signer { Ok(WitnessValues::from(hm)) } - - #[allow(clippy::unnecessary_wraps)] - fn master_slip77(&self) -> Result { - let seed = self.mnemonic.to_seed(""); - - Ok(MasterBlindingKey::from_seed(&seed[..])) - } - - fn derive_xpriv(&self, path: &DerivationPath) -> Result { - Ok(self.xprv.derive_priv(&self.secp, &path)?) - } - - fn master_xpriv(&self) -> Result { - self.derive_xpriv(&DerivationPath::master()) - } - - fn derive_xpub(&self, path: &DerivationPath) -> Result { - let derived = self.derive_xpriv(path)?; - - Ok(Xpub::from_priv(&self.secp, &derived)) - } - - fn master_xpub(&self) -> Result { - self.derive_xpub(&DerivationPath::master()) - } - - fn fingerprint(&self) -> Result { - Ok(self.master_xpub()?.fingerprint()) - } - - fn get_slip77_descriptor(&self) -> Result { - let wpkh_descriptor = self.get_wpkh_descriptor()?; - let blinding_key = self.master_slip77()?; - - Ok(format!("ct(slip77({blinding_key}),{wpkh_descriptor})")) - } - - fn get_wpkh_descriptor(&self) -> Result { - let fingerprint = self.fingerprint()?; - let path = self.get_derivation_path()?; - let xpub = self.derive_xpub(&path)?; - - Ok(format!("elwpkh([{fingerprint}/{path}]{xpub}/<0;1>/*)")) - } - - fn get_derivation_path(&self) -> Result { - let coin_type = if self.network.is_mainnet() { 1776 } else { 1 }; - let path = format!("84h/{coin_type}h/0h"); - - DerivationPath::from_str(&format!("m/{path}")).map_err(|e| SignerError::DerivationPath(e.to_string())) - } } #[cfg(test)] mod tests { use crate::provider::EsploraProvider; + use crate::signer::HDKey; use crate::utils::random_mnemonic; use super::*; - fn create_signer() -> Signer { + fn create_signer() -> Signer { let url = "https://blockstream.info/liquidtestnet/api".to_string(); let network = SimplicityNetwork::LiquidTestnet; - Signer::new(random_mnemonic().as_str(), Box::new(EsploraProvider::new(url, network))) + Signer::new( + HDKey::new(random_mnemonic().as_str()).unwrap(), + Box::new(EsploraProvider::new(url, network)), + ) } #[test] @@ -668,6 +551,6 @@ mod tests { let signer = create_signer(); println!("{}", signer.get_address()); - println!("{}", signer.get_confidential_address()); + println!("{}", signer.get_confidential_address().unwrap()); } } diff --git a/crates/sdk/src/signer/error.rs b/crates/sdk/src/signer/error.rs index d41c7076..f8db9028 100644 --- a/crates/sdk/src/signer/error.rs +++ b/crates/sdk/src/signer/error.rs @@ -16,18 +16,10 @@ pub enum SignerError { #[error(transparent)] WtnsInjectError(#[from] WtnsWrappingError), - /// Error indicating an incorrectly formatted mnemonic phrase. - #[error("Failed to parse a mnemonic: {0}")] - Mnemonic(String), - /// Error thrown when PSET transaction extraction fails. #[error("Failed to extract tx from pst: {0}")] TxExtraction(#[from] simplicityhl::elements::pset::Error), - /// Error indicating failure to unblind a confidential transaction output. - #[error("Failed to unblind txout: {0}")] - Unblind(#[from] simplicityhl::elements::UnblindError), - /// Error thrown when PSET blinding fails. #[error("Failed to blind a PST: {0}")] PsetBlind(#[from] simplicityhl::elements::pset::PsetBlindError), @@ -52,22 +44,6 @@ pub enum SignerError { #[error("Invalid secret key")] InvalidSecretKey(#[from] simplicityhl::elements::secp256k1_zkp::UpstreamError), - /// Error thrown when HD wallet private key derivation fails. - #[error("Failed to derive a private key: {0}")] - PrivateKeyDerivation(#[from] elements_miniscript::bitcoin::bip32::Error), - - /// Error thrown when constructing a derivation path string fails. - #[error("Failed to construct a derivation path: {0}")] - DerivationPath(String), - - /// Error indicating failure to construct a valid WPKH (Witness Public Key Hash) descriptor. - #[error("Failed to construct a wpkh descriptor: {0}")] - WpkhDescriptor(String), - - /// Error indicating failure to construct a valid SLIP77 blinding key descriptor. - #[error("Failed to construct a slip77 descriptor: {0}")] - Slip77Descriptor(String), - /// Error thrown if there's a problem during descriptor conversion. #[error("Failed to convert a descriptor: {0}")] DescriptorConversion(#[from] elements_miniscript::descriptor::ConversionError), @@ -79,6 +55,10 @@ pub enum SignerError { /// Error indicating an expected witness field could not be found. #[error("Missing such witness field: {0}")] WtnsFieldNotFound(String), + + /// Error forwarded from `KeyOrigin`. + #[error(transparent)] + KeyOrigin(#[from] KeyOriginError), } /// Errors originating from manipulating witness paths and injecting values. @@ -104,3 +84,35 @@ pub enum WtnsWrappingError { #[error("Path reached undefined branch of Either")] EitherBranchMismatch, } + +/// Errors originating from manipulating witness paths and injecting values. +#[derive(Debug, thiserror::Error)] +pub enum KeyOriginError { + /// Error indicating an incorrectly formatted mnemonic phrase. + #[error("Failed to parse a mnemonic: {0}")] + Mnemonic(String), + + /// Error thrown when constructing a derivation path string fails. + #[error("Failed to construct a derivation path: {0}")] + DerivationPath(String), + + /// Error indicating failure to construct a valid SLIP77 blinding key descriptor. + #[error("Failed to construct a slip77 descriptor: {0}")] + Slip77Descriptor(String), + + /// Error indicating failure to construct a valid WPKH (Witness Public Key Hash) descriptor. + #[error("Failed to construct a wpkh descriptor: {0}")] + WpkhDescriptor(String), + + /// Error thrown when HD wallet private key derivation fails. + #[error("Failed to derive a private key: {0}")] + PrivateKeyDerivation(#[from] elements_miniscript::bitcoin::bip32::Error), + + /// Error thrown when there is no pissibility to obtain a . + #[error("Required unblinding key, but it's empty")] + RequiredUnblindingKey, + + /// Error indicating failure to unblind a confidential transaction output. + #[error("Failed to unblind txout: {0}")] + Unblind(#[from] simplicityhl::elements::UnblindError), +} diff --git a/crates/sdk/src/signer/key_origin.rs b/crates/sdk/src/signer/key_origin.rs new file mode 100644 index 00000000..9afd2fcd --- /dev/null +++ b/crates/sdk/src/signer/key_origin.rs @@ -0,0 +1,347 @@ +use std::str::FromStr; + +use bip39::Mnemonic; +use bitcoincore_rpc::bitcoin::bip32::{DerivationPath, Fingerprint, Xpriv, Xpub}; +use bitcoincore_rpc::bitcoin::key::{Keypair, Secp256k1}; +use bitcoincore_rpc::bitcoin::secp256k1::{All, SecretKey}; +use bitcoincore_rpc::bitcoin::{NetworkKind, PrivateKey, PublicKey, XOnlyPublicKey}; + +use elements_miniscript::elements::Address; +use elements_miniscript::slip77::MasterBlindingKey; +use elements_miniscript::{ConfidentialDescriptor, Descriptor, DescriptorPublicKey}; + +use crate::provider::SimplicityNetwork; +use crate::signer::error::KeyOriginError; +use crate::transaction::UTXO; + +/// A generalized interface for providing cryptographic keys and addresses. +/// +/// This trait abstracts the origin of the wallet's keys, allowing the `Signer` to remain +/// agnostic to whether the keys are derived from a BIP39 mnemonic, a hardware wallet, +/// or a single injected secret key. +pub trait KeyOrigin { + /// Derives the X-Only public key specifically used for Schnorr and Taproot structures. + #[must_use] + fn get_schnorr_public_key(&self, secp: &Secp256k1, network: &SimplicityNetwork) -> XOnlyPublicKey; + + /// Resolves the standard format ECDSA public key. + #[must_use] + fn get_ecdsa_public_key(&self, secp: &Secp256k1, network: &SimplicityNetwork) -> PublicKey; + + /// Resolves the corresponding blinding public key. + #[must_use] + fn get_blinding_public_key(&self, secp: &Secp256k1, network: &SimplicityNetwork) -> Option; + + /// Internally derives and exposes the wallet's signing active private key. + /// + /// # Panics + /// Panics if the master private key or derivation path cannot be derived. + #[must_use] + fn get_private_key(&self, secp: &Secp256k1, network: &SimplicityNetwork) -> PrivateKey; + + /// Generates the private key linked to confidential payload blinding. + /// + /// The generated `PrivateKey` is associated with the `Test` (non-Bitcoin-mainnet) network kind. + /// Retrieves the blinding private key derived from the master SLIP77 key and the script public key of the address. + /// + /// # Panics + /// Panics if the master SLIP77 key cannot be derived. + #[must_use] + fn get_blinding_private_key(&self, secp: &Secp256k1, network: &SimplicityNetwork) -> Option; + + /// Returns the confidential elements address matching the local wallet logic. + /// + /// # Panics + /// Panics if the SLIP77 descriptor cannot be generated or parsed, or if address derivation fails. + #[must_use] + fn get_confidential_address(&self, secp: &Secp256k1, network: &SimplicityNetwork) -> Option
; + + /// Returns the standard unblinded address matching the local wallet logic. + /// + /// # Panics + /// Panics if the WPKH descriptor cannot be generated or parsed, or if address derivation fails. + #[must_use] + fn get_address(&self, secp: &Secp256k1, network: &SimplicityNetwork) -> Address; + + /// Unblinds a list of transaction outputs (UTXOs) using the blinding keys. + /// + /// # Errors + /// Returns a `KeyOriginError` if the blinding keys cannot be resolved or if unblinding fails. + fn unblind( + &self, + secp: &Secp256k1, + network: &SimplicityNetwork, + utxos: Vec, + ) -> Result, KeyOriginError>; +} + +/// A Hierarchical Deterministic (HD) key provider based on BIP39 and SLIP77. +/// +/// `HDKey` derives its key material from a standard mnemonic seed phrase. +/// It handles BIP32 derivation paths for standard transaction signing and uses +/// SLIP77 to generate deterministic blinding keys for confidential transactions +/// on the Elements/Liquid network. +pub struct HDKey { + xprv: Xpriv, + master_blinding: MasterBlindingKey, +} + +impl HDKey { + /// Constructs a new `HDKey` from a BIP39 mnemonic phrase. + /// + /// # Errors + /// Returns a `KeyOriginError::Mnemonic` if the provided phrase is invalid. + pub fn new(mnemonic: impl AsRef) -> Result { + let mnemonic: Mnemonic = mnemonic + .as_ref() + .parse() + .map_err(|e: bip39::Error| KeyOriginError::Mnemonic(e.to_string()))?; + let seed = mnemonic.to_seed(""); + let xprv = Xpriv::new_master(NetworkKind::Test, &seed)?; + + let master_blinding_key = MasterBlindingKey::from_seed(&seed[..]); + + Ok(Self { + master_blinding: master_blinding_key, + xprv, + }) + } + + fn derive_xpriv(&self, path: &DerivationPath, secp: &Secp256k1) -> Result { + Ok(self.xprv.derive_priv(secp, path)?) + } + + fn master_xpriv(&self, secp: &Secp256k1) -> Result { + self.derive_xpriv(&DerivationPath::master(), secp) + } + + fn derive_xpub(&self, path: &DerivationPath, secp: &Secp256k1) -> Result { + let derived = self.derive_xpriv(path, secp)?; + + Ok(Xpub::from_priv(secp, &derived)) + } + + fn master_xpub(&self, secp: &Secp256k1) -> Result { + self.derive_xpub(&DerivationPath::master(), secp) + } + + fn fingerprint(&self, secp: &Secp256k1) -> Result { + Ok(self.master_xpub(secp)?.fingerprint()) + } + + fn get_slip77_descriptor( + &self, + secp: &Secp256k1, + network: &SimplicityNetwork, + ) -> Result { + let wpkh_descriptor = self.get_wpkh_descriptor(secp, network)?; + let blinding_key = self.master_blinding; + + Ok(format!("ct(slip77({blinding_key}),{wpkh_descriptor})")) + } + + fn get_wpkh_descriptor( + &self, + secp: &Secp256k1, + network: &SimplicityNetwork, + ) -> Result { + let fingerprint = self.fingerprint(secp)?; + let path = self.get_derivation_path(network)?; + let xpub = self.derive_xpub(&path, secp)?; + + Ok(format!("elwpkh([{fingerprint}/{path}]{xpub}/<0;1>/*)")) + } + + #[allow(clippy::unused_self)] + fn get_derivation_path(&self, network: &SimplicityNetwork) -> Result { + let coin_type = if network.is_mainnet() { 1776 } else { 1 }; + let path = format!("84h/{coin_type}h/0h"); + + DerivationPath::from_str(&format!("m/{path}")).map_err(|e| KeyOriginError::DerivationPath(e.to_string())) + } +} + +impl KeyOrigin for HDKey { + fn get_schnorr_public_key(&self, secp: &Secp256k1, network: &SimplicityNetwork) -> XOnlyPublicKey { + let private_key = self.get_private_key(secp, network); + let keypair = Keypair::from_secret_key(secp, &private_key.inner); + + keypair.x_only_public_key().0 + } + + fn get_ecdsa_public_key(&self, secp: &Secp256k1, network: &SimplicityNetwork) -> PublicKey { + self.get_private_key(secp, network).public_key(secp) + } + + fn get_blinding_public_key(&self, secp: &Secp256k1, network: &SimplicityNetwork) -> Option { + Some(self.get_blinding_private_key(secp, network).unwrap().public_key(secp)) + } + + fn get_private_key(&self, secp: &Secp256k1, network: &SimplicityNetwork) -> PrivateKey { + let master_xprv = self.master_xpriv(secp).unwrap(); + let full_path = self.get_derivation_path(network).unwrap(); + + let derived = full_path.extend( + DerivationPath::from_str("0/1") + .map_err(|e| KeyOriginError::DerivationPath(e.to_string())) + .unwrap(), + ); + + let ext_derived = master_xprv.derive_priv(secp, &derived).unwrap(); + + PrivateKey::new(ext_derived.private_key, NetworkKind::Test) + } + + fn get_blinding_private_key(&self, secp: &Secp256k1, network: &SimplicityNetwork) -> Option { + let blinding_key = self + .master_blinding + .blinding_private_key(&self.get_address(secp, network).script_pubkey()); + + Some(PrivateKey::new(blinding_key, NetworkKind::Test)) + } + + fn get_confidential_address(&self, secp: &Secp256k1, network: &SimplicityNetwork) -> Option
{ + let mut descriptor = ConfidentialDescriptor::::from_str( + &self.get_slip77_descriptor(secp, network).unwrap(), + ) + .map_err(|e| KeyOriginError::Slip77Descriptor(e.to_string())) + .unwrap(); + + // confidential descriptor doesn't support multipath + descriptor.descriptor = descriptor.descriptor.into_single_descriptors().unwrap()[0].clone(); + + Some( + descriptor + .at_derivation_index(1) + .unwrap() + .address(secp, network.address_params()) + .unwrap(), + ) + } + + fn get_address(&self, secp: &Secp256k1, network: &SimplicityNetwork) -> Address { + let descriptor = Descriptor::::from_str(&self.get_wpkh_descriptor(secp, network).unwrap()) + .map_err(|e| KeyOriginError::WpkhDescriptor(e.to_string())) + .unwrap(); + + descriptor.into_single_descriptors().unwrap()[0] + .at_derivation_index(1) + .unwrap() + .address(network.address_params()) + .unwrap() + } + + fn unblind( + &self, + secp: &Secp256k1, + network: &SimplicityNetwork, + utxos: Vec, + ) -> Result, KeyOriginError> { + let blinding_key = self.get_blinding_private_key(secp, network).unwrap(); + let mut unblinded: Vec = Vec::with_capacity(utxos.len()); + + for mut utxo in utxos { + let secrets = utxo.txout.unblind(secp, blinding_key.inner)?; + + utxo.secrets = Some(secrets); + + unblinded.push(utxo); + } + + Ok(unblinded) + } +} + +/// A simplified key provider powered by a single static secret key. +/// +/// Unlike `HDKey` which derives paths hierarchically, `SingleKey` uses +/// exactly one `SecretKey` for all signing operations. It can optionally accept a +/// `MasterBlindingKey` to support confidential transactions and blinded addresses. +pub struct SingleKey { + secret_key: SecretKey, + blinding_key: Option, +} + +impl SingleKey { + /// Creates a new `SingleKey`. + /// + /// # Arguments + /// * `secret_key` - The base static secret key used for ECDSA and Schnorr signatures. + /// * `blinding_key` - An optional SLIP77 master blinding key. + #[must_use] + pub fn new(secret_key: SecretKey, blinding_key: Option) -> Self { + Self { + secret_key, + blinding_key, + } + } +} + +impl KeyOrigin for SingleKey { + fn get_schnorr_public_key(&self, secp: &Secp256k1, _network: &SimplicityNetwork) -> XOnlyPublicKey { + let keypair = Keypair::from_secret_key(secp, &self.secret_key); + keypair.x_only_public_key().0 + } + + fn get_ecdsa_public_key(&self, secp: &Secp256k1, network: &SimplicityNetwork) -> PublicKey { + self.get_private_key(secp, network).public_key(secp) + } + + fn get_blinding_public_key(&self, secp: &Secp256k1, network: &SimplicityNetwork) -> Option { + self.get_blinding_private_key(secp, network).map(|x| x.public_key(secp)) + } + + fn get_private_key(&self, _secp: &Secp256k1, _network: &SimplicityNetwork) -> PrivateKey { + PrivateKey::new(self.secret_key, NetworkKind::Test) + } + + fn get_blinding_private_key(&self, secp: &Secp256k1, network: &SimplicityNetwork) -> Option { + let master_blinding = self.blinding_key?; + + let script_pubkey = self.get_address(secp, network).script_pubkey(); + let blinding_key = master_blinding.blinding_private_key(&script_pubkey); + + Some(PrivateKey::new(blinding_key, NetworkKind::Test)) + } + + fn get_confidential_address(&self, secp: &Secp256k1, network: &SimplicityNetwork) -> Option
{ + let blinding_pubkey = self.get_blinding_public_key(secp, network)?; + let ecdsa_pubkey = self.get_ecdsa_public_key(secp, network); + + Some(Address::p2wpkh( + &ecdsa_pubkey, + Some(blinding_pubkey.inner), + network.address_params(), + )) + } + + fn get_address(&self, secp: &Secp256k1, network: &SimplicityNetwork) -> Address { + let ecdsa_pubkey = self.get_ecdsa_public_key(secp, network); + + Address::p2wpkh(&ecdsa_pubkey, None, network.address_params()) + } + + fn unblind( + &self, + secp: &Secp256k1, + network: &SimplicityNetwork, + mut utxos: Vec, + ) -> Result, KeyOriginError> { + let Some(blinding_key) = self.get_blinding_private_key(secp, network) else { + utxos.clear(); + return Ok(utxos); + }; + + let mut unblinded: Vec = Vec::with_capacity(utxos.len()); + + for mut utxo in utxos { + let secrets = utxo.txout.unblind(secp, blinding_key.inner)?; + + utxo.secrets = Some(secrets); + + unblinded.push(utxo); + } + + Ok(unblinded) + } +} diff --git a/crates/sdk/src/signer/mod.rs b/crates/sdk/src/signer/mod.rs index dc8007e9..f26e0aca 100644 --- a/crates/sdk/src/signer/mod.rs +++ b/crates/sdk/src/signer/mod.rs @@ -2,8 +2,11 @@ pub mod core; /// Signer-specific error enumerations capturing execution constraints and mapping internal failure types. pub mod error; +/// Contains abstractions that allow the `Signer` to remain agnostic to the origin of its keys. +mod key_origin; /// Utilities for injecting witness data bindings into Simplicity environments. mod wtns_injector; pub use core::{Signer, SignerTrait}; pub use error::SignerError; +pub use key_origin::{HDKey, KeyOrigin, SingleKey}; diff --git a/crates/test/src/context.rs b/crates/test/src/context.rs index 92ce061b..243f1942 100644 --- a/crates/test/src/context.rs +++ b/crates/test/src/context.rs @@ -9,7 +9,7 @@ use smplx_sdk::global::GlobalConfig; use smplx_sdk::provider::{ ElementsRpc, EsploraProvider, ProviderInfo, ProviderTrait, SimplexProvider, SimplicityNetwork, }; -use smplx_sdk::signer::Signer; +use smplx_sdk::signer::{HDKey, Signer}; use smplx_sdk::utils::random_mnemonic; use crate::config::TestConfig; @@ -22,7 +22,7 @@ pub struct TestContext { // since providers can't be cloned, we need this variable to create new signers _provider_info: ProviderInfo, config: TestConfig, - signer: Signer, + signer: Signer, } impl TestContext { @@ -42,7 +42,7 @@ impl TestContext { }) } - pub fn create_signer(&self, mnemonic: &str) -> Signer { + pub fn create_signer(&self, mnemonic: &str) -> Signer { let provider: Box = if self._provider_info.elements_url.is_some() { // local regtest or external regtest Box::new(SimplexProvider::new( @@ -59,14 +59,14 @@ impl TestContext { )) }; - Signer::new(mnemonic, provider) + Signer::new(HDKey::new(mnemonic).unwrap(), provider) } - pub fn random_signer(&self) -> Signer { + pub fn random_signer(&self) -> Signer { self.create_signer(random_mnemonic().as_str()) } - pub fn get_default_signer(&self) -> &Signer { + pub fn get_default_signer(&self) -> &Signer { &self.signer } @@ -100,10 +100,10 @@ impl TestContext { NetworkUtils::new(regtest_rpc, esplora) } - fn setup(config: &TestConfig) -> Result<(Signer, ProviderInfo, Option), TestError> { + fn setup(config: &TestConfig) -> Result<(Signer, ProviderInfo, Option), TestError> { let client: Option; let provider_info: ProviderInfo; - let signer: Signer; + let signer: Signer; match config.esplora.clone() { Some(esplora) => match config.rpc.clone() { @@ -122,7 +122,7 @@ impl TestContext { elements_url: Some(rpc.url), auth: Some(auth), }; - signer = Signer::new(config.mnemonic.as_str(), provider); + signer = Signer::new(HDKey::new(config.mnemonic.as_str()).unwrap(), provider); client = None; } None => { @@ -140,7 +140,7 @@ impl TestContext { elements_url: None, auth: None, }; - signer = Signer::new(config.mnemonic.as_str(), provider); + signer = Signer::new(HDKey::new(config.mnemonic.as_str()).unwrap(), provider); client = None; } }, diff --git a/examples/basic/tests/confidential_test.rs b/examples/basic/tests/confidential_test.rs index f5bbab22..cedc8d09 100644 --- a/examples/basic/tests/confidential_test.rs +++ b/examples/basic/tests/confidential_test.rs @@ -1,15 +1,19 @@ use simplex::simplicityhl::elements::AssetId; -use simplex::signer::Signer; +use simplex::signer::{KeyOrigin, Signer}; use simplex::transaction::partial_input::IssuanceInput; use simplex::transaction::{FinalTransaction, PartialInput, PartialOutput, RequiredSignature, TxReceipt}; -fn make_confidential_to_bob<'a>(alice: &'a Signer, bob: &Signer, asset: AssetId) -> anyhow::Result> { +fn make_confidential_to_bob<'a, K1: KeyOrigin, K2: KeyOrigin>( + alice: &'a Signer, + bob: &Signer, + asset: AssetId, +) -> anyhow::Result> { let mut ft = FinalTransaction::new(); ft.add_output( PartialOutput::new(bob.get_address().script_pubkey(), 1000, asset) - .with_blinding_key(bob.get_blinding_public_key()), + .with_blinding_key(bob.get_blinding_public_key()?), ); let tx_receipt = alice.broadcast(&ft)?; @@ -18,7 +22,10 @@ fn make_confidential_to_bob<'a>(alice: &'a Signer, bob: &Signer, asset: AssetId) Ok(tx_receipt) } -fn issue_confidential_to_alice<'a>(alice: &Signer, bob: &'a Signer) -> anyhow::Result> { +fn issue_confidential_to_alice<'a, K1: KeyOrigin, K2: KeyOrigin>( + alice: &Signer, + bob: &'a Signer, +) -> anyhow::Result> { let utxos = bob.get_utxos()?; let mut ft = FinalTransaction::new(); @@ -31,7 +38,7 @@ fn issue_confidential_to_alice<'a>(alice: &Signer, bob: &'a Signer) -> anyhow::R ft.add_output( PartialOutput::new(alice.get_address().script_pubkey(), 1000, issuance_details.asset_id) - .with_blinding_key(alice.get_blinding_public_key()), + .with_blinding_key(alice.get_blinding_public_key()?), ); ft.add_output( PartialOutput::new( @@ -39,7 +46,7 @@ fn issue_confidential_to_alice<'a>(alice: &Signer, bob: &'a Signer) -> anyhow::R 100, issuance_details.inflation_asset_id, ) - .with_blinding_key(alice.get_blinding_public_key()), + .with_blinding_key(alice.get_blinding_public_key()?), ); let tx_receipt = bob.broadcast(&ft)?; diff --git a/fixtures/tests/confidential_test.rs b/fixtures/tests/confidential_test.rs index 5e1784d8..af11de28 100644 --- a/fixtures/tests/confidential_test.rs +++ b/fixtures/tests/confidential_test.rs @@ -1,15 +1,19 @@ use simplex::simplicityhl::elements::AssetId; -use simplex::signer::Signer; +use simplex::signer::{KeyOrigin, Signer}; use simplex::transaction::partial_input::IssuanceInput; use simplex::transaction::{FinalTransaction, PartialInput, PartialOutput, RequiredSignature}; -fn make_confidential_to_bob(alice: &Signer, bob: &Signer, asset: AssetId) -> anyhow::Result<()> { +fn make_confidential_to_bob( + alice: &Signer, + bob: &Signer, + asset: AssetId, +) -> anyhow::Result<()> { let mut ft = FinalTransaction::new(); ft.add_output( PartialOutput::new(bob.get_address().script_pubkey(), 1000, asset) - .with_blinding_key(bob.get_blinding_public_key()), + .with_blinding_key(bob.get_blinding_public_key()?), ); let tx_receipt = alice.broadcast(&ft)?; @@ -18,7 +22,10 @@ fn make_confidential_to_bob(alice: &Signer, bob: &Signer, asset: AssetId) -> any Ok(()) } -fn issue_confidential_to_alice(alice: &Signer, bob: &Signer) -> anyhow::Result<()> { +fn issue_confidential_to_alice( + alice: &Signer, + bob: &Signer, +) -> anyhow::Result<()> { let utxos = bob.get_utxos()?; let mut ft = FinalTransaction::new(); @@ -31,7 +38,7 @@ fn issue_confidential_to_alice(alice: &Signer, bob: &Signer) -> anyhow::Result<( ft.add_output( PartialOutput::new(alice.get_address().script_pubkey(), 1000, issuance_details.asset_id) - .with_blinding_key(alice.get_blinding_public_key()), + .with_blinding_key(alice.get_blinding_public_key()?), ); ft.add_output( PartialOutput::new( @@ -39,7 +46,7 @@ fn issue_confidential_to_alice(alice: &Signer, bob: &Signer) -> anyhow::Result<( 100, issuance_details.inflation_asset_id, ) - .with_blinding_key(alice.get_blinding_public_key()), + .with_blinding_key(alice.get_blinding_public_key()?), ); let tx_receipt = bob.broadcast(&ft)?; diff --git a/fixtures/tests/reissuance_test.rs b/fixtures/tests/reissuance_test.rs index a4e4f2ca..33aa8154 100644 --- a/fixtures/tests/reissuance_test.rs +++ b/fixtures/tests/reissuance_test.rs @@ -1,17 +1,21 @@ use simplex::simplicityhl::elements::AssetId; -use simplex::signer::Signer; +use simplex::signer::{KeyOrigin, Signer}; use simplex::transaction::partial_input::IssuanceInput; use simplex::transaction::{ FinalTransaction, IssuanceDetails, PartialInput, PartialOutput, RequiredSignature, TxReceipt, }; -fn make_confidential_to_bob<'a>(alice: &'a Signer, bob: &Signer, asset: AssetId) -> anyhow::Result> { +fn make_confidential_to_bob<'a, K1: KeyOrigin, K2: KeyOrigin>( + alice: &'a Signer, + bob: &Signer, + asset: AssetId, +) -> anyhow::Result> { let mut ft = FinalTransaction::new(); ft.add_output( PartialOutput::new(bob.get_address().script_pubkey(), 1000, asset) - .with_blinding_key(bob.get_blinding_public_key()), + .with_blinding_key(bob.get_blinding_public_key()?), ); let tx_receipt = alice.broadcast(&ft)?; @@ -20,9 +24,9 @@ fn make_confidential_to_bob<'a>(alice: &'a Signer, bob: &Signer, asset: AssetId) Ok(tx_receipt) } -fn issue_explicit_to_alice_with_reissuance<'a>( - alice: &Signer, - bob: &'a Signer, +fn issue_explicit_to_alice_with_reissuance<'a, K1: KeyOrigin, K2: KeyOrigin>( + alice: &Signer, + bob: &'a Signer, ) -> anyhow::Result<(TxReceipt<'a>, IssuanceDetails)> { let utxos = bob.get_utxos()?; @@ -45,7 +49,7 @@ fn issue_explicit_to_alice_with_reissuance<'a>( 100, issuance_details.inflation_asset_id, ) - .with_blinding_key(bob.get_blinding_public_key()), + .with_blinding_key(bob.get_blinding_public_key()?), ); let tx_receipt = bob.broadcast(&ft)?; @@ -54,8 +58,8 @@ fn issue_explicit_to_alice_with_reissuance<'a>( Ok((tx_receipt, issuance_details)) } -fn reissue_tokens_to_bob<'a>( - bob: &'a Signer, +fn reissue_tokens_to_bob<'a, K: KeyOrigin>( + bob: &'a Signer, issuance_details: &IssuanceDetails, reissuance_amount: u64, ) -> anyhow::Result> { @@ -69,7 +73,7 @@ fn reissue_tokens_to_bob<'a>( reissuance_token_utxo.unblinded_amount(), reissuance_token_utxo.unblinded_asset(), ) - .with_blinding_key(bob.get_blinding_public_key()), + .with_blinding_key(bob.get_blinding_public_key()?), ); ft.add_issuance_input(