diff --git a/crates/build/src/collector.rs b/crates/build/src/collector.rs index f221c4ef..58f1b965 100644 --- a/crates/build/src/collector.rs +++ b/crates/build/src/collector.rs @@ -82,6 +82,15 @@ impl DepCollector { let loaded_simf_dir = CanonPath::canonicalize(&loaded_context.as_path().join(loaded_src_dir)) .map_err(BuildError::PathCanonicalization)?; + // A dependency may only define import contexts inside its own package. + if !loaded_simf_dir.starts_with(&loaded_context) { + return Err(BuildError::DependencySourceOutsidePackage { + dep_name: dep_name.clone(), + package_root: loaded_context.as_path().to_path_buf(), + source_dir: loaded_simf_dir.as_path().to_path_buf(), + }); + } + self.builder .add_dependency(simf_dir.clone(), dep_name.clone(), loaded_simf_dir.clone()); diff --git a/crates/build/src/error.rs b/crates/build/src/error.rs index d2455151..a00c8271 100644 --- a/crates/build/src/error.rs +++ b/crates/build/src/error.rs @@ -112,6 +112,15 @@ pub enum BuildError { #[error("Failed to build dependency map: {0}")] DependencyMap(String), + #[error( + "Dependency '{dep_name}' src_dir resolves outside its package: '{source_dir}' (package root: '{package_root}')" + )] + DependencySourceOutsidePackage { + dep_name: String, + package_root: PathBuf, + source_dir: PathBuf, + }, + #[error("Failed to flatten program: {0}")] Flattening(String), diff --git a/crates/build/src/resolver.rs b/crates/build/src/resolver.rs index bd4aee90..bde2d744 100644 --- a/crates/build/src/resolver.rs +++ b/crates/build/src/resolver.rs @@ -148,8 +148,7 @@ impl ArtifactsResolver { let key = std::iter::once(url.to_owned()) .chain(reference) .chain(package) - .collect::>() - .join("@"); + .collect::>(); let mut hasher = DefaultHasher::new(); key.hash(&mut hasher); diff --git a/crates/cli/src/commands/install.rs b/crates/cli/src/commands/install.rs index d1d92b66..ee5e6609 100644 --- a/crates/cli/src/commands/install.rs +++ b/crates/cli/src/commands/install.rs @@ -196,9 +196,15 @@ impl Install { clone.args(["clone", "--filter=blob:none", "--no-checkout"]); let checkout_target = match reference { - Some(GitRef::Rev(rev)) => rev.as_str(), - Some(GitRef::Tag(name) | GitRef::Branch(name)) => { - clone.args(["--depth", "1", "--branch", name]); + Some(GitRef::Rev(rev)) => rev.as_ref(), + Some(GitRef::Tag(name)) => { + let qualified_name = format!("refs/tags/{name}"); + clone.args(["--depth", "1", "--revision", &qualified_name]); + "HEAD" + } + Some(GitRef::Branch(name)) => { + let qualified_name = format!("refs/heads/{name}"); + clone.args(["--depth", "1", "--revision", &qualified_name]); "HEAD" } None => { @@ -224,7 +230,7 @@ impl Install { checkout .arg("-C") .arg(target_dir) - .args(["checkout", checkout_target, "--"]); + .args(["checkout", "--detach", checkout_target, "--"]); commands.push(checkout); commands @@ -254,6 +260,137 @@ impl Display for InstalledRepos { mod tests { use super::*; + const URL: &str = "https://example.invalid/org/repo.git"; + const TARGET: &str = r#"/unused/"git fixture""#; + + fn assert_command(command: &Command, args: &[&str]) { + assert_eq!(command.get_program(), "git"); + + let actual: Vec<_> = command.get_args().map(|arg| arg.to_str().unwrap()).collect(); + + assert_eq!(actual, args); + } + + #[test] + fn git_commands_revisions_appended_correctly() { + for rev in [ + "dae86e1950b1277e545cee180551750029cfe735", + "dae86e", + "v1.7.4.2-679-g3bee7fb", + "master", + "refs/heads/master", + "HEAD", + "@", + "master@{yesterday}", + "HEAD@{5 minutes ago}", + "master@{1}", + "@{1}", + "@{-1}", + "master@{upstream}", + "@{push}", + "HEAD^", + "v1.5.1^0", + "master~3", + "v0.99.8^{commit}", + "v0.99.8^{object}", + "v0.99.8^{tag}", + "v0.99.8^{}", + "HEAD^{/fix nasty bug}", + ":/fix nasty bug", + ":/^foo", + ":/!-foo", + ":/!!foo", + ] { + let reference = GitRef::Rev(rev.into()); + let commands = Install::git_commands(URL, Some(&reference), None, Path::new(TARGET)); + + assert_eq!(commands.len(), 2, "revision: {rev}"); + let (clone, checkout) = (&commands[0], &commands[1]); + + assert_command( + clone, + &["clone", "--filter=blob:none", "--no-checkout", "--", URL, TARGET], + ); + assert_command(checkout, &["-C", TARGET, "checkout", "--detach", rev, "--"]); + } + } + + #[test] + fn git_commands_tag_and_branch_are_nonoverlapping() { + // An identically named branch must not substitute for a requested tag. + for (reference, qualified_name) in [ + (GitRef::Tag("release".into()), "refs/tags/release"), + (GitRef::Branch("release".into()), "refs/heads/release"), + ] { + let commands = Install::git_commands(URL, Some(&reference), None, Path::new(TARGET)); + + assert_eq!(commands.len(), 2); + let (clone, checkout) = (&commands[0], &commands[1]); + + assert_command( + clone, + &[ + "clone", + "--filter=blob:none", + "--no-checkout", + "--depth", + "1", + "--revision", + qualified_name, + "--", + URL, + TARGET, + ], + ); + assert_command(checkout, &["-C", TARGET, "checkout", "--detach", "HEAD", "--"]); + } + } + + #[test] + fn git_commands_using_shallow_clone_by_default() { + let commands = Install::git_commands(URL, None, None, Path::new(TARGET)); + + assert_eq!(commands.len(), 2); + let (clone, checkout) = (&commands[0], &commands[1]); + + assert_command( + clone, + &[ + "clone", + "--filter=blob:none", + "--no-checkout", + "--depth", + "1", + "--", + URL, + TARGET, + ], + ); + assert_command(checkout, &["-C", TARGET, "checkout", "--detach", "HEAD", "--"]); + } + + #[test] + fn sparse_checkout_is_before_checking_out_the_revision() { + let reference = GitRef::Rev(":/fix nasty bug".into()); + let commands = Install::git_commands(URL, Some(&reference), Some("libs/math"), Path::new(TARGET)); + + assert_eq!(commands.len(), 3); + let (clone, sparse_checkout, checkout) = (&commands[0], &commands[1], &commands[2]); + + assert_command( + clone, + &["clone", "--filter=blob:none", "--no-checkout", "--", URL, TARGET], + ); + assert_command( + sparse_checkout, + &["-C", TARGET, "sparse-checkout", "set", "--no-cone", "/libs/math/"], + ); + assert_command( + checkout, + &["-C", TARGET, "checkout", "--detach", ":/fix nasty bug", "--"], + ); + } + fn git(dir: &Path, args: &[&str]) { let status = Command::new("git") .arg("-C") diff --git a/crates/sdk/src/signer/core.rs b/crates/sdk/src/signer/core.rs index 0bf4b30b..56b43a60 100644 --- a/crates/sdk/src/signer/core.rs +++ b/crates/sdk/src/signer/core.rs @@ -242,8 +242,12 @@ impl Signer { signer_utxos.retain(|utxo| !spent.contains(&utxo.outpoint)); - // Descending sort of both confidential and explicit utxos - signer_utxos.sort_by_key(|utxo| std::cmp::Reverse(utxo.amount())); + // Resolve amounts before sorting so an unreadable UTXO returns an error. + let mut signer_utxos = signer_utxos + .into_iter() + .map(|utxo| utxo.amount().map(|amount| (amount, utxo))) + .collect::, _>>()?; + signer_utxos.sort_by_key(|(amount, _)| std::cmp::Reverse(*amount)); let mut fee_tx = tx.clone(); let mut curr_fee = MIN_FEE; @@ -271,8 +275,8 @@ impl Signer { } }; - for utxo in signer_utxos { - let policy_amount_delta = fee_tx.calculate_fee_delta(&self.network); + for (_, utxo) in signer_utxos { + let policy_amount_delta = fee_tx.calculate_fee_delta(&self.network)?; if policy_amount_delta >= (curr_fee + inputs_since_estimate * input_fee).cast_signed() { inputs_since_estimate = 0; @@ -289,7 +293,7 @@ impl Signer { } // Need to try one more time after the loop. No UTXO is left to add, so any chance is taken - let policy_amount_delta = fee_tx.calculate_fee_delta(&self.network); + let policy_amount_delta = fee_tx.calculate_fee_delta(&self.network)?; if policy_amount_delta >= curr_fee.cast_signed() { inputs_since_estimate = 0; @@ -313,7 +317,7 @@ impl Signer { pub fn finalize_strict(&self, tx: &FinalTransaction, fee_rate: f32) -> Result<(Transaction, u64), SignerError> { Self::check_known_amounts(tx)?; - let policy_amount_delta = tx.calculate_fee_delta(&self.network); + let policy_amount_delta = tx.calculate_fee_delta(&self.network)?; if policy_amount_delta < MIN_FEE.cast_signed() { return Err(SignerError::DustAmount(policy_amount_delta)); @@ -336,7 +340,7 @@ impl Signer { pub fn estimate_fee(&self, tx: &FinalTransaction, fee_rate: f32) -> Result { Self::check_known_amounts(tx)?; - let available_delta = tx.calculate_fee_delta(&self.network).max(0).cast_unsigned(); + let available_delta = tx.calculate_fee_delta(&self.network)?.max(0).cast_unsigned(); let estimate = self.estimate_tx(tx.clone(), fee_rate, available_delta); ProgramLogger::flush_logs(); @@ -390,7 +394,7 @@ impl Signer { ) -> Result<(PartiallySignedTransaction, HashMap), SignerError> { Self::check_known_amounts(tx)?; - let (mut pst, secrets) = tx.extract_pst(); + let (mut pst, secrets) = tx.extract_pst()?; let inputs = tx.inputs(); let mut signed_witnesses = HashMap::new(); @@ -472,22 +476,24 @@ impl Signer { self.address.clone() } - /// Iterates against the network provider to select and unblind all known UTXOs. + /// Retrieves UTXOs with known assets and amounts, skipping outputs that cannot be unblinded. /// /// # Errors - /// Returns a `SignerError` if querying the network or unblinding operations fail. + /// Returns a `SignerError` if querying the provider fails. #[cfg(feature = "provider")] pub fn get_utxos(&self) -> Result, SignerError> { self.get_utxos_filter(&|_| true, &|_| true) } - /// Finds all known UTXOs belonging to the specific `AssetId`. + /// Finds usable UTXOs belonging to the specific `AssetId`, skipping unreadable outputs. /// /// # Errors - /// Returns a `SignerError` if network interaction or confidential output decryption fails. + /// Returns a `SignerError` if querying the provider fails. #[cfg(feature = "provider")] 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(&|utxo| utxo.asset().is_ok_and(|id| id == asset), &|utxo| { + utxo.asset().is_ok_and(|id| id == asset) + }) } /// Finds all known UTXOs deriving from a targeted `Txid`. @@ -503,8 +509,12 @@ impl Signer { /// Maps UTXOs retrieved from the provider through arbitrary functional filters. /// Separate filtering criteria apply explicitly vs confidentially. /// + /// Outputs with null fields, unresolved partial commitments, or proofs that + /// cannot be rewound with the wallet key are skipped. Already supplied secrets + /// are preserved. A single unreadable payment must not block usable funds. + /// /// # Errors - /// Returns a `SignerError` if retrieving remote outputs or executing confidential node unblinding throws an error. + /// Returns a `SignerError` if retrieving remote outputs fails. #[cfg(feature = "provider")] pub fn get_utxos_filter( &self, @@ -512,25 +522,40 @@ impl Signer { confidential_filter: &dyn Fn(&UTXO) -> bool, ) -> Result, SignerError> { // Fetch explicit and confidential utxos - let mut all_utxos = self + let fetched_utxos = self .get_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()); + let mut all_utxos = Vec::new(); + let mut confidential_utxos = Vec::new(); + + for mut utxo in fetched_utxos { + if utxo.txout.asset.is_null() || utxo.txout.value.is_null() { + continue; + } + + if utxo.txout.asset.is_explicit() && utxo.txout.value.is_explicit() { + all_utxos.push(utxo); + continue; + } + + if utxo.secrets.is_none() { + let blinding_key = self.get_blinding_private_key(); + let Ok(secrets) = utxo.txout.unblind(&self.secp, blinding_key.inner) else { + // Payments to this script may use a foreign blinding key or + // an asset-only commitment. Neither can fund this wallet. + continue; + }; + + utxo.secrets = Some(secrets); + } + + confidential_utxos.push(utxo); + } all_utxos.retain(explicit_filter); confidential_utxos.retain(confidential_filter); - // Push unblinded utxos to explicit ones all_utxos.extend(confidential_utxos); Ok(all_utxos) @@ -601,21 +626,6 @@ impl Signer { Ok((public_key, signature)) } - #[cfg(feature = "provider")] - fn unblind(&self, utxos: Vec) -> Result, SignerError> { - let mut unblinded: Vec = Vec::new(); - - for mut utxo in utxos { - let secrets = utxo.txout.unblind(&self.secp, self.blinding_key.inner)?; - - utxo.secrets = Some(secrets); - - unblinded.push(utxo); - } - - Ok(unblinded) - } - fn estimate_tx( &self, mut fee_tx: FinalTransaction, @@ -664,7 +674,7 @@ impl Signer { outputs[outputs.len() - 2].amount = available_delta - fee; outputs[outputs.len() - 1].amount = fee; - if !fee_tx.is_balanced() { + if !fee_tx.is_balanced()? { return Err(SignerError::Unbalanced); } @@ -702,7 +712,7 @@ impl Signer { // Change the fee output amount outputs[outputs.len() - 1].amount = available_delta; - if !fee_tx.is_balanced() { + if !fee_tx.is_balanced()? { return Err(SignerError::Unbalanced); } @@ -885,7 +895,11 @@ impl Signer { #[cfg(test)] mod tests { + use simplicityhl::elements::confidential::{Asset, AssetBlindingFactor, Value}; + use simplicityhl::elements::{OutPoint, TxOut, Txid}; + use crate::provider::EsploraProvider; + use crate::transaction::{FinalTransactionError, PartialInput, UTXO}; use crate::utils::random_mnemonic; use super::*; @@ -1071,4 +1085,71 @@ mod tests { Err(SignerError::CovenantExecution { index: 1, .. }) )); } + + #[test] + fn unresolved_inputs_are_rejected_before_signing() { + let signer = create_signer(); + let network = signer.network; + let (amount_in, amount_out, fee_rate) = (100_000, 10_000, 100.); + + let script = signer.get_address().script_pubkey(); + let funding = UTXO { + outpoint: OutPoint::new(Txid::from_byte_array(Default::default()), 0), + txout: TxOut { + script_pubkey: script.clone(), + ..TxOut::new_fee(amount_in, network.policy_asset()) + }, + secrets: None, + }; + + let mut ft = FinalTransaction::new(); + ft.add_input(PartialInput::new(funding.clone()), Some(NativeSignature::Ecdsa)); + ft.add_output(PartialOutput::new(script.clone(), amount_out, network.policy_asset())); + + // The signer can finalize valid caller-selected inputs successfully. + let (funded, _) = signer.finalize_strict(&ft, fee_rate).unwrap(); + assert_eq!(funded.input.len(), 1); + funded.verify_tx_amt_proofs(&signer.secp, &[funding.txout]).unwrap(); + + // Adding a malformed input. + let mixed = UTXO { + outpoint: OutPoint::new(Txid::from_byte_array(Default::default()), 0), + txout: TxOut { + asset: Asset::new_confidential( + &signer.secp, + network.policy_asset(), + AssetBlindingFactor::new(&mut thread_rng()), + ), + value: Value::Explicit(600), + script_pubkey: script, + ..Default::default() + }, + secrets: None, + }; + ft.add_input(PartialInput::new(mixed), Some(NativeSignature::Ecdsa)); + + // Direct accounting reports the unresolved input's position. + assert_eq!( + ft.calculate_fee_delta(&network), + Err(FinalTransactionError::MissingInputSecrets { input_index: 1 }) + ); + assert_eq!( + ft.is_balanced(), + Err(FinalTransactionError::MissingInputSecrets { input_index: 1 }) + ); + assert!(matches!( + ft.extract_pst(), + Err(FinalTransactionError::MissingInputSecrets { input_index: 1 }) + )); + + // Signer entry points preserve the error identifying the unresolved outpoint. + for result in [ + signer.estimate_fee(&ft, 100.0).map(|_| ()), + signer.finalize_strict(&ft, 100.0).map(|_| ()), + signer.sign_witnesses(&ft).map(|_| ()), + signer.sign_tx(&ft).map(|_| ()), + ] { + assert!(matches!(result, Err(SignerError::UnknownInputAmount(_)))); + } + } } diff --git a/crates/sdk/src/signer/error.rs b/crates/sdk/src/signer/error.rs index ded3c550..2c6a6435 100644 --- a/crates/sdk/src/signer/error.rs +++ b/crates/sdk/src/signer/error.rs @@ -1,5 +1,6 @@ use crate::program::ProgramError; use crate::provider::ProviderError; +use crate::transaction::{FinalTransactionError, UtxoError}; /// Core error types for the Signer component. #[derive(Debug, thiserror::Error)] @@ -27,6 +28,14 @@ pub enum SignerError { #[error(transparent)] Provider(#[from] ProviderError), + /// A UTXO is missing its asset or amount and has no unblinding secrets. + #[error(transparent)] + Utxo(#[from] UtxoError), + + /// Errors originating from transaction construction or validation. + #[error(transparent)] + FinalTransaction(#[from] FinalTransactionError), + /// Error indicating a provider-backed operation was requested on a signer built without one. #[cfg(feature = "provider")] #[error("This signer was constructed without a provider")] diff --git a/crates/sdk/src/transaction/error.rs b/crates/sdk/src/transaction/error.rs new file mode 100644 index 00000000..8cb441d6 --- /dev/null +++ b/crates/sdk/src/transaction/error.rs @@ -0,0 +1,23 @@ +use simplicityhl::elements::OutPoint; + +/// Errors encountered while assembling or validating a transaction. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum FinalTransactionError { + /// An input has no known asset or amount and no unblinding secrets. + #[error("Input {input_index} requires an explicit asset and amount or unblinding secrets")] + MissingInputSecrets { + /// The index of the unresolved input in the transaction. + input_index: usize, + }, +} + +/// Errors encountered when reading a UTXO's asset or amount. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum UtxoError { + /// Neither an explicit asset ID nor unblinding secrets are available. + #[error("UTXO {0} has no explicit asset or unblinding secrets")] + UnknownAsset(OutPoint), + /// Neither an explicit amount nor unblinding secrets are available. + #[error("UTXO {0} has no explicit amount or unblinding secrets")] + UnknownAmount(OutPoint), +} diff --git a/crates/sdk/src/transaction/final_transaction.rs b/crates/sdk/src/transaction/final_transaction.rs index 60c48350..b626d264 100644 --- a/crates/sdk/src/transaction/final_transaction.rs +++ b/crates/sdk/src/transaction/final_transaction.rs @@ -9,6 +9,7 @@ use simplicityhl::elements::{ use crate::provider::SimplicityNetwork; use super::change_output::ChangeOutput; +use super::error::FinalTransactionError; use super::partial_input::{IssuanceDetails, IssuanceInput, PartialInput, ProgramInput}; use super::partial_output::PartialOutput; use super::required_signature::{NativeSignature, ProgramSignature, RequiredSignature}; @@ -67,6 +68,15 @@ impl FinalInput { .map(|issuance_input| issuance_input.details(&self.partial_input.outpoint)) } + /// Returns the input's asset ID and amount from unblinding secrets, or from + /// explicit fields when no secrets are available. + /// + /// Returns `None` if no secrets are available and either explicit field is missing. + #[must_use] + pub fn get_asset_and_amount(&self) -> Option<(AssetId, u64)> { + self.partial_input.asset_and_amount() + } + /// Converts the current object into an `Input` representation, including any /// issuance input and partial input details. /// @@ -110,7 +120,7 @@ pub struct FinalTransaction { inputs: Vec, outputs: Vec, change: Option, - sequence: Sequence, + sequence: Option, locktime: LockTime, } @@ -123,7 +133,7 @@ impl FinalTransaction { inputs: Vec::new(), outputs: Vec::new(), change: None, - sequence: Sequence::default(), + sequence: None, locktime: LockTime::ZERO, } } @@ -132,7 +142,7 @@ impl FinalTransaction { /// /// Injects this value into the inputs that don't declare their own sequence. pub fn set_sequence(&mut self, sequence: Sequence) { - self.sequence = sequence; + self.sequence = Some(sequence); } /// Sets a specific `LockTime` for the transaction. @@ -142,10 +152,15 @@ impl FinalTransaction { self.locktime = locktime; } - /// The sequence of the transaction, e.g. inputs that don't declare their own. + /// The sequence of the inputs that don't declare their own. The one set with `set_sequence`, + /// or `ENABLE_LOCKTIME_NO_RBF` when a locktime is set to be enforced, and `MAX` otherwise. #[must_use] pub fn sequence(&self) -> Sequence { - self.sequence + self.sequence.unwrap_or(if self.has_locktime() { + Sequence::ENABLE_LOCKTIME_NO_RBF + } else { + Sequence::MAX + }) } /// The locktime of the the transaction, e.g. inputs that don't declare their own. @@ -306,15 +321,17 @@ impl FinalTransaction { /// The function considers the network's policy asset to determine which inputs /// and outputs contribute to the calculation. /// - /// # Panics - /// Function will panic if the asset isn't unblinded correctly, and if PST input asset and amount is confidential. - #[must_use] - pub fn calculate_fee_delta(&self, network: &SimplicityNetwork) -> i64 { + /// # Errors + /// Returns [`FinalTransactionError::MissingInputSecrets`] if an input's asset or + /// amount is unknown and no unblinding secrets are available. A visible amount + /// alone does not identify its asset. + pub fn calculate_fee_delta(&self, network: &SimplicityNetwork) -> Result { let mut available_amount = 0; - for input in &self.inputs { - let (asset, amount) = Self::known_asset_and_amount(input); - + for (input_index, input) in self.inputs.iter().enumerate() { + let (asset, amount) = input + .get_asset_and_amount() + .ok_or(FinalTransactionError::MissingInputSecrets { input_index })?; if asset == network.policy_asset() { available_amount += amount; } @@ -326,7 +343,7 @@ impl FinalTransaction { .filter(|output| output.asset == network.policy_asset()) .fold(0_u64, |acc, output| acc + output.amount); - available_amount.cast_signed() - consumed_amount.cast_signed() + Ok(available_amount.cast_signed() - consumed_amount.cast_signed()) } /// Checks if the transaction is balanced, meaning all inputs - all outputs = 0 for every asset. @@ -334,15 +351,17 @@ impl FinalTransaction { /// Issued and reissued amounts are credited to the input that declares them, so a newly created /// asset is balanced against its declared `issuance_amount`/`inflation_amount`. /// - /// # Panics - /// Function will panic if the assets aren't unblinded correctly, and if PST input assets and amounts are confidential. - #[must_use] - pub fn is_balanced(&self) -> bool { + /// # Errors + /// Returns [`FinalTransactionError::MissingInputSecrets`] if an input's asset or + /// amount is unknown and no unblinding secrets are available. + pub fn is_balanced(&self) -> Result { let mut transfers: HashMap = HashMap::new(); // Collecting all inputs - for input in &self.inputs { - let (asset, amount) = Self::known_asset_and_amount(input); + for (input_index, input) in self.inputs.iter().enumerate() { + let (asset, amount) = input + .get_asset_and_amount() + .ok_or(FinalTransactionError::MissingInputSecrets { input_index })?; let transfer_entry = transfers.entry(asset).or_insert(0); *transfer_entry += amount.cast_signed(); @@ -375,35 +394,43 @@ impl FinalTransaction { Some(value) => { *value -= output.amount.cast_signed(); } - None => return false, + None => return Ok(false), } } // All transfers including fee should sum up to 0 - transfers.values().all(|&value| value == 0) + Ok(transfers.values().all(|&value| value == 0)) } /// Extracts a partially signed transaction (PST) and a mapping of input secrets from the current state. /// + /// # Errors + /// Returns [`FinalTransactionError::MissingInputSecrets`] if an input's asset or + /// amount is unknown and no unblinding secrets are available. + /// /// # Panics - /// Function will panic if the pst input is a confidential issuance. - #[must_use] - pub fn extract_pst(&self) -> (PartiallySignedTransaction, HashMap) { + /// Panics if a reissuance input has no blinding secrets. + pub fn extract_pst( + &self, + ) -> Result<(PartiallySignedTransaction, HashMap), FinalTransactionError> { let mut input_secrets = HashMap::new(); let mut pst = PartiallySignedTransaction::new_v2(); + let sequence = self.sequence(); for i in 0..self.inputs.len() { let mut final_input = self.inputs[i].clone(); // An input that sets no sequence or locktime takes the transaction's - final_input.partial_input.sequence.get_or_insert(self.sequence); + final_input.partial_input.sequence.get_or_insert(sequence); final_input.partial_input.locktime.get_or_insert(self.locktime); - let pst_input = final_input.to_input(); - - // An explicit input's secrets are its amounts with zero blinding factors - let secrets = final_input.partial_input.secrets.unwrap_or_else(|| { - let (asset, value) = Self::known_asset_and_amount(&final_input); + let secrets = if let Some(secrets) = final_input.partial_input.secrets { + secrets + } else { + let (asset, value) = final_input + .partial_input + .asset_and_amount() + .ok_or(FinalTransactionError::MissingInputSecrets { input_index: i })?; TxOutSecrets { asset, @@ -411,36 +438,34 @@ impl FinalTransaction { value, value_bf: ValueBlindingFactor::zero(), } - }); + }; input_secrets.insert(i, secrets); - - pst.add_input(pst_input); + pst.add_input(final_input.to_input()); } self.outputs.iter().for_each(|el| { pst.add_output(el.to_output()); }); - (pst, input_secrets) + Ok((pst, input_secrets)) } - /// # Panics - /// Panics when the input is a confidential UTXO without unblinding secrets. - fn known_asset_and_amount(input: &FinalInput) -> (AssetId, u64) { - input.partial_input.asset_and_amount().unwrap_or_else(|| { - panic!( - "The input spending {} has neither an explicit amount nor unblinding secrets", - input.partial_input.outpoint - ) - }) + /// If the transaction or any of its inputs sets a non-zero locktime. + fn has_locktime(&self) -> bool { + self.locktime != LockTime::ZERO + || self.inputs.iter().any(|input| { + input + .partial_input + .locktime + .is_some_and(|locktime| locktime != LockTime::ZERO) + }) } } #[cfg(test)] mod tests { - - use simplicityhl::elements::{LockTime, OutPoint, Script, TxOut, Txid}; + use simplicityhl::elements::{AssetEntropy, LockTime, OutPoint, Script, TxOut, Txid}; use crate::transaction::UTXO; @@ -544,7 +569,7 @@ mod tests { TxOutSecrets::new(policy, AssetBlindingFactor::zero(), 5000, ValueBlindingFactor::zero()), )]); - let (pst, secrets) = ft.extract_pst(); + let (pst, secrets) = ft.extract_pst().unwrap(); assert_eq!(pst, expected_pst); assert_eq!(secrets, expected_secrets); @@ -560,17 +585,30 @@ mod tests { ft.add_output(PartialOutput::new(Script::new(), 9000, policy)); ft.set_locktime(LockTime::from_height(2_580_990).unwrap()); - let (pst, _) = ft.extract_pst(); + let (pst, _) = ft.extract_pst().unwrap(); assert_eq!( pst.locktime().expect("one height, so no conflict"), LockTime::from_height(2_580_990).unwrap() ); - assert!( - pst.inputs() - .iter() - .all(|input| input.required_height_locktime.is_some()) - ); + assert!(pst.inputs().iter().all(|input| { + input.required_height_locktime.is_some() && input.sequence == Some(Sequence::ENABLE_LOCKTIME_NO_RBF) + })); + } + + #[test] + fn explicit_final_sequence_is_kept_under_a_locktime() { + let policy = dummy_asset_id(0xAA); + let mut ft = FinalTransaction::new(); + + ft.add_input(PartialInput::new(explicit_utxo(0x01, 0, 5000, policy)), None); + ft.add_output(PartialOutput::new(Script::new(), 5000, policy)); + ft.set_locktime(LockTime::from_height(100).unwrap()); + ft.set_sequence(Sequence::MAX); + + let (pst, _) = ft.extract_pst().unwrap(); + + assert_eq!(pst.inputs()[0].sequence, Some(Sequence::MAX)); } #[test] @@ -581,9 +619,10 @@ mod tests { ft.add_input(PartialInput::new(explicit_utxo(0x01, 0, 5000, policy)), None); ft.add_output(PartialOutput::new(Script::new(), 4000, policy)); - let (pst, _) = ft.extract_pst(); + let (pst, _) = ft.extract_pst().unwrap(); assert_eq!(pst.locktime().unwrap(), LockTime::ZERO); + assert_eq!(pst.inputs()[0].sequence, Some(Sequence::MAX)); } #[test] @@ -607,7 +646,7 @@ mod tests { TxOutSecrets::new(policy, AssetBlindingFactor::zero(), 3000, ValueBlindingFactor::zero()), )]); - let (pst, secrets) = ft.extract_pst(); + let (pst, secrets) = ft.extract_pst().unwrap(); assert_eq!(pst, expected_pst); assert_eq!(secrets, expected_secrets); @@ -650,7 +689,7 @@ mod tests { ), ]); - let (pst, secrets) = ft.extract_pst(); + let (pst, secrets) = ft.extract_pst().unwrap(); assert_eq!(pst, expected_pst); assert_eq!(secrets, expected_secrets); @@ -688,7 +727,7 @@ mod tests { TxOutSecrets::new(policy, AssetBlindingFactor::zero(), 5000, ValueBlindingFactor::zero()), )]); - let (pst, secrets) = ft.extract_pst(); + let (pst, secrets) = ft.extract_pst().unwrap(); assert_eq!(pst, expected_pst); assert_eq!(secrets, expected_secrets); @@ -728,7 +767,7 @@ mod tests { TxOutSecrets::new(policy, AssetBlindingFactor::zero(), 1000, ValueBlindingFactor::zero()), )]); - let (pst, secrets) = ft.extract_pst(); + let (pst, secrets) = ft.extract_pst().unwrap(); assert_eq!(pst, expected_pst); assert_eq!(secrets, expected_secrets); @@ -743,7 +782,7 @@ mod tests { ft.add_output(PartialOutput::new(Script::new(), 4000, policy)); ft.add_output(PartialOutput::new(Script::new(), 1000, policy)); - assert!(ft.is_balanced()); + assert!(ft.is_balanced().unwrap()); } #[test] @@ -755,7 +794,7 @@ mod tests { ft.add_output(PartialOutput::new(Script::new(), 5000, policy)); ft.add_output(PartialOutput::new(Script::new(), 1, dummy_asset_id(0xBB))); - assert!(!ft.is_balanced()); + assert!(!ft.is_balanced().unwrap()); } #[test] @@ -766,7 +805,7 @@ mod tests { ft.add_input(PartialInput::new(explicit_utxo(0x01, 0, 5000, policy)), None); ft.add_output(PartialOutput::new(Script::new(), 4000, policy)); - assert!(!ft.is_balanced()); + assert!(!ft.is_balanced().unwrap()); } #[test] @@ -783,7 +822,7 @@ mod tests { ft.add_output(PartialOutput::new(Script::new(), 1, details.inflation_asset_id)); ft.add_output(PartialOutput::new(Script::new(), 1000, policy)); - assert!(ft.is_balanced()); + assert!(ft.is_balanced().unwrap()); } #[test] @@ -799,7 +838,7 @@ mod tests { ft.add_output(PartialOutput::new(Script::new(), 1_000_000_000, details.asset_id)); ft.add_output(PartialOutput::new(Script::new(), 1000, policy)); - assert!(!ft.is_balanced()); + assert!(!ft.is_balanced().unwrap()); } #[test] @@ -816,7 +855,7 @@ mod tests { ft.add_output(PartialOutput::new(Script::new(), 42, details.inflation_asset_id)); ft.add_output(PartialOutput::new(Script::new(), 1000, policy)); - assert!(!ft.is_balanced()); + assert!(!ft.is_balanced().unwrap()); } #[test] @@ -847,7 +886,7 @@ mod tests { ft.add_output(PartialOutput::new(Script::new(), 1, details.inflation_asset_id)); ft.add_output(PartialOutput::new(Script::new(), 1000, policy)); - assert!(ft.is_balanced()); + assert!(ft.is_balanced().unwrap()); } #[test] @@ -858,7 +897,7 @@ mod tests { ft.add_input(PartialInput::new(confidential_utxo(0x01, 0, policy, 5000)), None); ft.add_output(PartialOutput::new(Script::new(), 5000, policy)); - assert!(ft.is_balanced()); + assert!(ft.is_balanced().unwrap()); } #[test] @@ -871,6 +910,58 @@ mod tests { ft.add_output(PartialOutput::new(Script::new(), 1000, policy)); ft.add_output(PartialOutput::new_metadata("burn".as_bytes())); - assert!(ft.is_balanced()); + assert!(ft.is_balanced().unwrap()); + } + + #[test] + #[should_panic(expected = "Reissuance input must be confidential")] + fn reissuance_without_secrets_panics_during_extraction() { + let entropy = [0x42; 32]; + let token = AssetId::reissuance_token_from_entropy(AssetEntropy::from_byte_array(entropy), false); + let input = PartialInput::new(explicit_utxo(1, 0, 1, token)); + + let mut tx = FinalTransaction::new(); + tx.add_issuance_input(input, IssuanceInput::new_reissuance(5_000, entropy), None); + assert_eq!(tx.inputs()[0].get_asset_and_amount(), Some((token, 1))); + + // Reproduces the documented panic in FinalInput::to_input. + let _ = tx.extract_pst(); + } + + #[test] + fn reissuance_with_secrets_preserves_the_blinding_nonce() { + use simplicityhl::elements::confidential::{Asset, Value}; + + let entropy = [0x12; 32]; + let token = AssetId::reissuance_token_from_entropy(AssetEntropy::from_byte_array(entropy), false); + + // A nonzero asset blinder distinguishes reissuance from a new issuance. + let asset_bf = AssetBlindingFactor::from_slice(&[1; 32]).unwrap(); + let value_bf = ValueBlindingFactor::from_slice(&[2; 32]).unwrap(); + let expected_secrets = TxOutSecrets::new(token, asset_bf, 1, value_bf); + + let secp = simplicityhl::elements::secp256k1_zkp::Secp256k1::new(); + + let input = PartialInput::new(UTXO { + outpoint: OutPoint::new(dummy_txid(1), 0), + txout: TxOut { + asset: Asset::new_confidential(&secp, token, asset_bf), + value: Value::new_confidential_from_assetid(&secp, 1, token, value_bf, asset_bf), + ..Default::default() + }, + secrets: Some(expected_secrets), + }); + + let mut tx = FinalTransaction::new(); + tx.add_issuance_input(input, IssuanceInput::new_reissuance(5_000, entropy), None); + + let (pst, secrets) = tx.extract_pst().unwrap(); + + assert_eq!(secrets.get(&0), Some(&expected_secrets)); + assert_eq!(pst.inputs()[0].issuance_value_amount, Some(5_000)); + assert_eq!( + pst.inputs()[0].issuance_blinding_nonce, + Some(AssetBlindingNonce::from_blinding_factor(asset_bf)) + ); } } diff --git a/crates/sdk/src/transaction/mod.rs b/crates/sdk/src/transaction/mod.rs index d73c2ddd..430c502a 100644 --- a/crates/sdk/src/transaction/mod.rs +++ b/crates/sdk/src/transaction/mod.rs @@ -1,5 +1,7 @@ /// Represents outputs under construction before transaction finalization. pub mod change_output; +/// Errors for transaction accounting and UTXO data access. +pub mod error; /// Represents a fully finalized target transaction schema ready for signing and broadcasting. pub mod final_transaction; /// Represents inputs under construction before transaction finalization. @@ -13,6 +15,7 @@ pub mod tx_receipt; pub mod utxo; pub use change_output::ChangeOutput; +pub use error::{FinalTransactionError, UtxoError}; pub use final_transaction::{FinalInput, FinalTransaction}; pub use partial_input::{IssuanceDetails, PartialInput, ProgramInput}; pub use partial_output::PartialOutput; diff --git a/crates/sdk/src/transaction/utxo.rs b/crates/sdk/src/transaction/utxo.rs index 8b4d2de6..ad9348bf 100644 --- a/crates/sdk/src/transaction/utxo.rs +++ b/crates/sdk/src/transaction/utxo.rs @@ -1,5 +1,7 @@ use simplicityhl::elements::{AssetId, OutPoint, Script, TxOut, TxOutSecrets}; +use super::error::UtxoError; + /// Represents an Unspent Transaction Output (UTXO). #[derive(Debug, Clone)] pub struct UTXO { @@ -69,29 +71,42 @@ impl UTXO { self.secrets.expect("The UTXO is not unblinded").value } - /// Retrieves the `AssetId` associated with the instance. - #[must_use] - pub fn asset(&self) -> AssetId { + /// Retrieves the asset ID from unblinding secrets or the explicit output field. + /// + /// # Errors + /// Returns `UtxoError::UnknownAsset` if the asset is confidential or null and + /// no unblinding secrets are available. + pub fn asset(&self) -> Result { self.secrets - .map_or_else(|| self.explicit_asset(), |secrets| secrets.asset) + .map(|secrets| secrets.asset) + .or_else(|| self.txout.asset.explicit()) + .ok_or(UtxoError::UnknownAsset(self.outpoint)) } - /// Retrieves the amount associated with the current instance. + /// Retrieves the amount from unblinding secrets or the explicit output field. /// - /// This function returns the `value` from the `secrets` field if it exists. - /// If no `secrets` are present, it falls back to calculating and returning - /// the explicitly defined amount using the `explicit_amount()` method. - #[must_use] - pub fn amount(&self) -> u64 { + /// # Errors + /// Returns `UtxoError::UnknownAmount` if the value is confidential or null and + /// no unblinding secrets are available. + pub fn amount(&self) -> Result { self.secrets - .map_or_else(|| self.explicit_amount(), |secrets| secrets.value) + .map(|secrets| secrets.value) + .or_else(|| self.txout.value.explicit()) + .ok_or(UtxoError::UnknownAmount(self.outpoint)) } } #[cfg(test)] mod tests { - use super::*; + use simplicityhl::elements::Txid; + use simplicityhl::elements::confidential::{Asset, Value}; use simplicityhl::elements::confidential::{AssetBlindingFactor, ValueBlindingFactor}; + use simplicityhl::elements::secp256k1_zkp::Secp256k1; + + use crate::provider::SimplicityNetwork; + use crate::signer::Signer; + + use super::*; #[test] fn explicit_and_unblinded_accessors_select_the_right_values() { @@ -99,15 +114,15 @@ mod tests { let explicit = UTXO { outpoint: OutPoint::default(), txout: TxOut { - asset: simplicityhl::elements::confidential::Asset::Explicit(asset), - value: simplicityhl::elements::confidential::Value::Explicit(42), + asset: Asset::Explicit(asset), + value: Value::Explicit(42), ..Default::default() }, secrets: None, }; - assert_eq!(explicit.asset(), asset); - assert_eq!(explicit.amount(), 42); + assert_eq!(explicit.asset(), Ok(asset)); + assert_eq!(explicit.amount(), Ok(42)); let unblinded = UTXO { outpoint: OutPoint::default(), @@ -122,7 +137,85 @@ mod tests { assert_eq!(unblinded.unblinded_asset(), asset); assert_eq!(unblinded.unblinded_amount(), 99); - assert_eq!(unblinded.asset(), asset); - assert_eq!(unblinded.amount(), 99); + assert_eq!(unblinded.asset(), Ok(asset)); + assert_eq!(unblinded.amount(), Ok(99)); + } + + fn explicit(network: &SimplicityNetwork, amount: u64) -> UTXO { + const MNEMONIC: &str = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + + let signer = Signer::from_mnemonic(MNEMONIC, *network); + + UTXO { + outpoint: OutPoint::new(Txid::from_byte_array(Default::default()), 0), + txout: TxOut { + script_pubkey: signer.get_address().script_pubkey(), + ..TxOut::new_fee(amount, network.policy_asset()) + }, + secrets: None, + } + } + + #[test] + fn unresolved_utxo_accessors_do_not_panic() { + let network = SimplicityNetwork::default_regtest(); + + let mixed = { + let asset_bf = AssetBlindingFactor::new(&mut bip39::rand::thread_rng()); + + let mut utxo = explicit(&network, 600); + utxo.txout.asset = Asset::new_confidential(&Secp256k1::new(), network.policy_asset(), asset_bf); + + // An explicit value has no rangeproof to rewind for the asset blinding factor. + assert!(utxo.txout.witness.rangeproof.is_empty()); + + utxo + }; + + let null_asset = { + let mut utxo = explicit(&network, 600); + utxo.txout.asset = Asset::Null; + + utxo + }; + + for utxo in [mixed, null_asset] { + assert_eq!(utxo.asset(), Err(UtxoError::UnknownAsset(utxo.outpoint))); + } + } + + #[test] + fn unresolved_utxo_value_does_not_panic() { + let network = SimplicityNetwork::default_regtest(); + + let mut utxo = explicit(&network, 600); + utxo.txout.value = Value::Null; + + assert_eq!(utxo.amount(), Err(UtxoError::UnknownAmount(utxo.outpoint))); + } + + #[test] + fn known_secrets_resolve_an_asset_only_commitment() { + let asset = AssetId::from_byte_array(Default::default()); + let asset_bf = AssetBlindingFactor::new(&mut bip39::rand::thread_rng()); + + let mut utxo = UTXO { + outpoint: OutPoint::new(Txid::from_byte_array(Default::default()), 0), + txout: TxOut { + asset: Asset::new_confidential(&Secp256k1::new(), asset, asset_bf), + value: Value::Explicit(600), + ..Default::default() + }, + secrets: None, + }; + + // The visible amount cannot reveal the committed asset ID. + assert_eq!(utxo.amount(), Ok(600)); + assert_eq!(utxo.asset(), Err(UtxoError::UnknownAsset(utxo.outpoint))); + + utxo.secrets = Some(TxOutSecrets::new(asset, asset_bf, 600, ValueBlindingFactor::zero())); + assert_eq!(utxo.asset(), Ok(asset)); + assert_eq!(utxo.amount(), Ok(600)); } } diff --git a/crates/test/src/fuzz/case.rs b/crates/test/src/fuzz/case.rs index 27226231..8626f830 100644 --- a/crates/test/src/fuzz/case.rs +++ b/crates/test/src/fuzz/case.rs @@ -194,6 +194,7 @@ impl<'a> FuzzCase<'a> { let transaction = self .transaction .extract_pst() + .expect("fuzz inputs have known assets and amounts") .0 .extract_tx() .expect("the transaction has one kind of locktime"); diff --git a/crates/test/src/fuzz/mutations/mutator.rs b/crates/test/src/fuzz/mutations/mutator.rs index 43ac1505..a3327073 100644 --- a/crates/test/src/fuzz/mutations/mutator.rs +++ b/crates/test/src/fuzz/mutations/mutator.rs @@ -47,7 +47,7 @@ impl Mutator { .iter() .all(|input| input.partial_input.asset_and_amount().is_some()); - if !known || !transaction.is_balanced() { + if !known || transaction.is_balanced() != Ok(true) { return Err(SetupError::Transaction( "mutations need a balanced transaction of explicit or unblinded UTXOs".to_string(), )); @@ -401,6 +401,7 @@ impl Mutator { fn locktime(transaction: &FinalTransaction) -> u32 { transaction .extract_pst() + .expect("mutation inputs have known assets and amounts") .0 .extract_tx() .map_or(0, |transaction| transaction.lock_time.to_consensus_u32()) diff --git a/crates/test/src/fuzz/outcome.rs b/crates/test/src/fuzz/outcome.rs index 11af9896..7ce46b75 100644 --- a/crates/test/src/fuzz/outcome.rs +++ b/crates/test/src/fuzz/outcome.rs @@ -141,7 +141,7 @@ mod tests { let mut transaction = FinalTransaction::new(); transaction.add_input(PartialInput::new(utxo), None); - let (pst, _) = transaction.extract_pst(); + let (pst, _) = transaction.extract_pst().unwrap(); Outcome::classify(program.finalize(&pst, &WitnessValues::default(), 0, &network)) } diff --git a/crates/wasm/src/lib.rs b/crates/wasm/src/lib.rs index 80a5a3bf..9316f664 100644 --- a/crates/wasm/src/lib.rs +++ b/crates/wasm/src/lib.rs @@ -418,6 +418,7 @@ impl TransactionBuilder { } /// Sets the block height this transaction may not be mined before. + /// Unless a sequence is set, the inputs get one that enforces it. /// /// # Panics /// Panics if `height` is `500_000_000` or greater, which Elements reads as a time. @@ -427,6 +428,7 @@ impl TransactionBuilder { } /// Sets the block time this transaction may not be mined before. + /// Unless a sequence is set, the inputs get one that enforces it. /// /// # Panics /// Panics if `time` is below `500_000_000`, which Elements reads as a height. @@ -435,7 +437,7 @@ impl TransactionBuilder { self.transaction.set_locktime(LockTime::from_time(time).unwrap()); } - /// Sets the sequence of for this transaction. + /// Sets the sequence of the inputs that don't declare their own. #[wasm_bindgen(js_name = setSequence)] pub fn set_sequence(&mut self, sequence: u32) { self.transaction.set_sequence(Sequence::from_consensus(sequence)); @@ -775,7 +777,10 @@ impl TransactionBuilder { .as_ref() .ok_or_else(|| JsError::new(&format!("Input {input_index} is not a covenant input.")))?; - let (pst, _secrets) = self.transaction.extract_pst(); + let (pst, _secrets) = self + .transaction + .extract_pst() + .map_err(|e| JsError::new(&format!("Could not construct the transaction: {e}")))?; program_input .program @@ -1307,7 +1312,7 @@ mod tests { // Read the issuance back the way Elements does. A null blinding nonce would make // this a new issuance of another asset. - let (pst, _) = builder.transaction.extract_pst(); + let (pst, _) = builder.transaction.extract_pst().unwrap(); let input = &pst.inputs()[0]; let (asset, minted_token) = input.issuance_ids(); diff --git a/fixtures/tests/fuzz_mutation.rs b/fixtures/tests/fuzz_mutation.rs index cf41310b..715003cb 100644 --- a/fixtures/tests/fuzz_mutation.rs +++ b/fixtures/tests/fuzz_mutation.rs @@ -102,14 +102,11 @@ fn unbalanced_transaction_is_a_setup_error(test_context: TestContext) test_context.engine(scenario).mutations(Mutations::default()).run(); } -/// Input 0 enables the locktime, which the template sets to 100. +/// The template sets the locktime to 100, which makes input 0 enforce it. #[simplex::fuzz] fn timelock_passes_with_a_spec_on_the_lock_height(test_context: TestContext) { let mut transaction = FinalTransaction::new(); - transaction.add_input( - PartialInput::default().with_sequence(Sequence::ENABLE_LOCKTIME_NO_RBF), - None, - ); + transaction.add_input(PartialInput::default(), None); transaction.set_locktime(LockTime::from_consensus(100)); let mut scenario = Scenario::new(transaction); @@ -208,7 +205,7 @@ fn sequence_mutation_reaches_the_signed_transaction(test_context: TestContext