From a5b6abf4014ee4aa56d063155f3bceb7daf6e079 Mon Sep 17 00:00:00 2001 From: Artem Chystiakov Date: Thu, 12 Nov 2026 17:29:35 +0200 Subject: [PATCH 1/6] quick wasm fix --- crates/wasm/src/issuance_details.rs | 3 +- crates/wasm/src/transaction_builder.rs | 96 +++++++++++++++++++++++--- 2 files changed, 89 insertions(+), 10 deletions(-) diff --git a/crates/wasm/src/issuance_details.rs b/crates/wasm/src/issuance_details.rs index 48afd6c1..c75ef6db 100644 --- a/crates/wasm/src/issuance_details.rs +++ b/crates/wasm/src/issuance_details.rs @@ -27,7 +27,8 @@ impl IssuanceDetails { self.asset_id.clone() } - /// The asset entropy both IDs derive from, which a later reissuance needs. + /// The asset entropy both IDs derive from, which a later reissuance needs. It is hex with its + /// bytes reversed, the order Elements prints it in. #[wasm_bindgen(getter, js_name = assetEntropy)] #[must_use] pub fn asset_entropy(&self) -> String { diff --git a/crates/wasm/src/transaction_builder.rs b/crates/wasm/src/transaction_builder.rs index ac0e3568..402e3c11 100644 --- a/crates/wasm/src/transaction_builder.rs +++ b/crates/wasm/src/transaction_builder.rs @@ -196,6 +196,8 @@ impl TransactionBuilder { /// `blinding_secrets_json` contains the unblinded details of a confidential output. /// Write its `value` as a JSON number up to 2^53 - 1. Larger amounts must be /// decimal strings because JavaScript may serialize a number with different digits. + /// The blinding factors are hex with their bytes reversed, the order Elements prints them in, + /// so factor 1 is `01` followed by 31 zero bytes. /// /// # Errors /// Returns an error if the txid or the encoded output cannot be parsed. @@ -259,7 +261,8 @@ impl TransactionBuilder { /// Adds a native input that spends an output holding the reissuance token and mints /// `asset_amount_sats` more units of the asset. /// - /// Pass the entropy returned by `IssuanceDetails.assetEntropy` for the first issuance. + /// Pass the entropy returned by `IssuanceDetails.assetEntropy` for the first issuance. Like + /// `assetEntropy`, it is hex with its bytes reversed, the order Elements prints it in. /// Pass the unblinded token details as `blinding_secrets_json`. The method checks /// that the claimed asset ID matches the token derived from the entropy. The caller /// must ensure the secrets open the output. Write `value` as a decimal string if it @@ -313,12 +316,15 @@ impl TransactionBuilder { /// over this transaction. /// Leaving this `None` says the program needs no signature. /// + /// `blinding_secrets_json` contains the unblinded details of a confidential output, as in + /// `addInput`. + /// /// `derivation_path` is the path of the key that signature is made with, relative to the /// account path. Omitting it keeps the signer's default. /// /// # Errors - /// Returns an error if the txid, the encoded output, the arguments, the witness or the - /// derivation path cannot be parsed. + /// Returns an error if the txid, the encoded output, the arguments, the witness, the + /// blinding secrets or the derivation path cannot be parsed. #[wasm_bindgen(js_name = addProgramInput)] #[allow(clippy::too_many_arguments, clippy::needless_pass_by_value)] pub fn add_program_input( @@ -330,12 +336,19 @@ impl TransactionBuilder { arguments_json: Option, witness_json: Option, program_signature: Option, + blinding_secrets_json: Option, extra_leaves_json: Option, include_debug_symbols: Option, derivation_path: Option, ) -> Result<(), JsError> { self.transaction.add_program_input( - Self::partial_input(txid, vout, tx_out_hex, None, derivation_path.as_deref())?, + Self::partial_input( + txid, + vout, + tx_out_hex, + blinding_secrets_json.as_deref(), + derivation_path.as_deref(), + )?, Self::program_input( source, arguments_json, @@ -356,8 +369,8 @@ impl TransactionBuilder { /// /// # Errors /// Returns an error if the txid, the encoded output, the arguments, the witness, the - /// issuer contract or the derivation path cannot be parsed, if either issuance amount - /// exceeds `i64::MAX`, or if both are zero. + /// issuer contract, the blinding secrets or the derivation path cannot be parsed, if either + /// issuance amount exceeds `i64::MAX`, or if both are zero. #[wasm_bindgen(js_name = addProgramIssuanceInput)] #[allow(clippy::too_many_arguments, clippy::needless_pass_by_value)] pub fn add_program_issuance_input( @@ -372,11 +385,18 @@ impl TransactionBuilder { asset_amount_sats: u64, inflation_amount_sats: u64, issuer_contract_hex: Option, + blinding_secrets_json: Option, extra_leaves_json: Option, include_debug_symbols: Option, derivation_path: Option, ) -> Result { - let input = Self::partial_input(txid, vout, tx_out_hex, None, derivation_path.as_deref())?; + let input = Self::partial_input( + txid, + vout, + tx_out_hex, + blinding_secrets_json.as_deref(), + derivation_path.as_deref(), + )?; let issuance = Self::issuance(asset_amount_sats, inflation_amount_sats, issuer_contract_hex.as_deref())?; let details = self.transaction.add_program_issuance_input( @@ -462,8 +482,7 @@ impl TransactionBuilder { /// Adds an output paying `amount_sats` of `asset_hex` to `script_pubkey_hex`. /// - /// A blinding key makes the output confidential. Outputs locked by a program and `OP_RETURN` - /// outputs are always unblinded. + /// A blinding key makes the output confidential, whatever its script. /// /// # Errors /// Returns an error if the script, asset id or blinding key cannot be parsed, @@ -1117,6 +1136,7 @@ mod tests { Some("SIGNATURE".to_string()), None, None, + None, Some("0/7".to_string()), ); @@ -1139,6 +1159,7 @@ mod tests { None, None, None, + None, ); assert!(added.is_ok()); @@ -1162,6 +1183,7 @@ mod tests { None, None, None, + None, Some("0/7".to_string()), ); @@ -1169,6 +1191,61 @@ mod tests { assert_eq!(path_of(&builder, 0).as_deref(), Some("0/7")); } + #[test] + fn program_inputs_take_blinding_secrets() { + let asset = SimplicityNetwork::LiquidTestnet.policy_asset(); + let spent = holding(asset, Some(blinder(TOKEN_BLINDER)), blinder(TOKEN_BLINDER)); + let tx_out_hex = hex::encode(simplicityhl::elements::encode::serialize(&spent.witness_utxo)); + let secrets = serde_json::json!({ + "asset": asset.to_string(), + "assetBlindingFactor": TOKEN_BLINDER, + "value": "1", + "valueBlindingFactor": VALUE_BLINDER, + }) + .to_string(); + + let mut builder = TransactionBuilder::new(); + + builder + .add_program_input( + ON_CHAIN[0].0, + 0, + &tx_out_hex, + TRIVIAL, + None, + None, + None, + Some(secrets.clone()), + None, + None, + None, + ) + .unwrap(); + builder + .add_program_issuance_input( + ON_CHAIN[0].0, + 1, + &tx_out_hex, + TRIVIAL, + None, + None, + None, + 1_000, + 0, + None, + Some(secrets), + None, + None, + None, + ) + .unwrap(); + + // Without the secrets, the confidential spent output has no known asset or amount + for input in builder.transaction.inputs() { + assert_eq!(input.partial_input.asset_and_amount(), Some((asset, 1))); + } + } + #[test] fn native_input_keeps_its_derivation_path() { let mut builder = TransactionBuilder::new(); @@ -1218,6 +1295,7 @@ mod tests { None, None, None, + None, ) .unwrap(); builder From 331fe331e7e26a66f559e8231a7b1d52b3e917f2 Mon Sep 17 00:00:00 2001 From: Artem Chystiakov Date: Thu, 12 Nov 2026 18:27:21 +0200 Subject: [PATCH 2/6] check secrets open assets and amounts --- crates/sdk/src/signer/core.rs | 120 ++++++++++-------- crates/sdk/src/signer/error.rs | 15 +-- crates/sdk/src/transaction/error.rs | 41 ++++-- .../sdk/src/transaction/final_transaction.rs | 31 ++++- crates/sdk/src/transaction/mod.rs | 2 +- crates/sdk/src/transaction/partial_input.rs | 117 ++++++++++++++++- crates/wasm/src/transaction_builder.rs | 5 +- fixtures/tests/strict_fee_test.rs | 73 +++++++++-- 8 files changed, 311 insertions(+), 93 deletions(-) diff --git a/crates/sdk/src/signer/core.rs b/crates/sdk/src/signer/core.rs index 438579ed..2cbd0938 100644 --- a/crates/sdk/src/signer/core.rs +++ b/crates/sdk/src/signer/core.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +#[cfg(feature = "provider")] use std::collections::HashSet; use std::ops::Deref; use std::str::FromStr; @@ -312,8 +313,10 @@ impl Signer { /// /// # Errors /// - `SignerError::NotEnoughFunds` if the wallet runs out before the fee is covered. - /// - `SignerError::UnknownInputAmount` if an input is confidential and has no unblinding secrets. - /// - `SignerError::DuplicateInput` if `tx` spends an outpoint twice. + /// - `SignerError::PartialInput` if an input is confidential and has no unblinding secrets, or + /// its secrets don't open the output it spends. + /// - `SignerError::FinalTransaction` with `FinalTransactionError::DuplicateInput` if `tx` spends + /// an outpoint twice. /// - `SignerError::ConfidentialInputWithoutBlindedOutput` if `tx` spends a confidential input, /// has an explicit change target, and doesn't have any blinded output. /// - `SignerError::Unbalanced` if an asset other than the policy asset doesn't balance. @@ -350,8 +353,10 @@ impl Signer { /// /// # Errors /// - `SignerError::InvalidFeeRate` if `fee_rate` isn't a finite, non-negative number. - /// - `SignerError::UnknownInputAmount` if an input is confidential and has no unblinding secrets. - /// - `SignerError::DuplicateInput` if the transaction spends an outpoint twice. + /// - `SignerError::PartialInput` if an input is confidential and has no unblinding secrets, or + /// its secrets don't open the output it spends. + /// - `SignerError::FinalTransaction` with `FinalTransactionError::DuplicateInput` if the + /// transaction spends an outpoint twice. /// - `SignerError::DustAmount` if the inputs don't exceed the outputs by at least [`MIN_FEE`] of /// the policy asset. /// - `SignerError::NotEnoughFeeAmount` if that surplus can't pay the fee, or the fee and the @@ -385,7 +390,7 @@ impl Signer { /// [`MIN_FEE`] instead: the smallest surplus that pays both the fee and the change. /// /// # Errors - /// Returns `SignerError::InvalidFeeRate`, `UnknownInputAmount`, `DuplicateInput`, + /// Returns `SignerError::InvalidFeeRate`, `PartialInput`, `FinalTransaction(DuplicateInput)`, /// `ConfidentialInputWithoutBlindedOutput` or `Unbalanced` as [`Signer::finalize_strict`] does, /// and `ProgramError::ProgramExecution` if a program rejects the spend. pub fn estimate_strict(&self, tx: &FinalTransaction, fee_rate: f32) -> Result { @@ -404,7 +409,7 @@ impl Signer { /// /// # Errors /// - `SignerError::NotEnoughFeeAmount` if the surplus is below the fee at `fee_rate`. - /// - `SignerError::InvalidFeeRate`, `UnknownInputAmount`, `DuplicateInput`, + /// - `SignerError::InvalidFeeRate`, `PartialInput`, `FinalTransaction(DuplicateInput)`, /// `ConfidentialInputWithoutBlindedOutput` or `Unbalanced` as [`Signer::finalize_strict`] /// does, and the errors of [`Signer::sign_tx`]. pub fn finalize_strict_fee(&self, tx: &FinalTransaction, fee_rate: f32) -> Result<(Transaction, u64), SignerError> { @@ -434,7 +439,7 @@ impl Signer { /// signed transaction needs. /// /// # Errors - /// Returns `SignerError::InvalidFeeRate`, `UnknownInputAmount`, `DuplicateInput` or + /// Returns `SignerError::InvalidFeeRate`, `PartialInput`, `FinalTransaction(DuplicateInput)` or /// `ConfidentialInputWithoutBlindedOutput` as [`Signer::finalize_strict`] does, and /// `ProgramError::ProgramExecution` if a program rejects the spend. pub fn estimate_strict_fee( @@ -489,12 +494,13 @@ impl Signer { /// checks the program with its real signature without finalizing anything. /// /// # Errors - /// Returns a `SignerError` if blinding, signing or injecting a signature into a program witness fails. + /// Returns `SignerError::PartialInput` as [`Signer::finalize_strict`] does, and + /// a `SignerError` if blinding, signing or injecting a signature into a program witness fails. pub fn sign_witnesses( &self, tx: &FinalTransaction, ) -> Result<(PartiallySignedTransaction, HashMap), SignerError> { - Self::check_known_amounts(tx)?; + tx.check_inputs_assets_and_amounts()?; let (mut pst, secrets) = tx.extract_pst()?; let inputs = tx.inputs(); @@ -733,8 +739,8 @@ impl Signer { /// `tx` with wallet inputs, a change output and a fee output added, and its fee. #[cfg(feature = "provider")] fn complete(&self, tx: &FinalTransaction) -> Result<(FinalTransaction, u64), SignerError> { - Self::check_known_amounts(tx)?; - Self::check_unique_inputs(tx)?; + tx.check_inputs_assets_and_amounts()?; + tx.check_unique_inputs()?; let fee_rate = self.get_provider()?.fetch_fee_rate(1)?; Self::check_fee_rate(fee_rate)?; @@ -815,8 +821,8 @@ impl Signer { /// `tx` with a change output and a fee output added at `fee_rate`, or the fee it needs when the /// surplus is too small. fn complete_strict(&self, tx: &FinalTransaction, fee_rate: f32) -> Result { - Self::check_known_amounts(tx)?; - Self::check_unique_inputs(tx)?; + tx.check_inputs_assets_and_amounts()?; + tx.check_unique_inputs()?; Self::check_fee_rate(fee_rate)?; let available_delta = tx.calculate_fee_delta(&self.network)?.max(0).cast_unsigned(); @@ -831,8 +837,8 @@ impl Signer { network: &SimplicityNetwork, fee_rate: f32, ) -> Result<(FinalTransaction, u64), SignerError> { - Self::check_known_amounts(tx)?; - Self::check_unique_inputs(tx)?; + tx.check_inputs_assets_and_amounts()?; + tx.check_unique_inputs()?; Self::check_fee_rate(fee_rate)?; if tx.has_confidential_input() && !tx.needs_blinding() { @@ -1058,32 +1064,6 @@ impl Signer { } } - /// A transaction spending an outpoint twice can never confirm. - fn check_unique_inputs(tx: &FinalTransaction) -> Result<(), SignerError> { - let mut seen = HashSet::new(); - - match tx - .inputs() - .iter() - .find(|input| !seen.insert(input.partial_input.outpoint)) - { - Some(input) => Err(SignerError::DuplicateInput(input.partial_input.outpoint)), - None => Ok(()), - } - } - - /// Balancing and signing need the amount of every input. - fn check_known_amounts(tx: &FinalTransaction) -> Result<(), SignerError> { - match tx - .inputs() - .iter() - .find(|input| input.partial_input.asset_and_amount().is_none()) - { - Some(input) => Err(SignerError::UnknownInputAmount(input.partial_input.outpoint)), - None => Ok(()), - } - } - /// The account path, `m/84h/1776h/0h` on mainnet and `m/84h/1h/0h` elsewhere. fn account_path(network: SimplicityNetwork) -> DerivationPath { let coin_type = if network.is_mainnet() { 1776 } else { 1 }; @@ -1178,7 +1158,7 @@ mod tests { use simplicityhl::elements::confidential::ValueBlindingFactor; use crate::provider::{EsploraProvider, ProviderError}; - use crate::transaction::{FinalTransactionError, PartialInput, UTXO}; + use crate::transaction::{FinalTransactionError, PartialInput, PartialInputError, UTXO}; use crate::utils::random_mnemonic; use super::*; @@ -1293,18 +1273,51 @@ mod tests { use simplicityhl::elements::confidential::{AssetBlindingFactor, ValueBlindingFactor}; use simplicityhl::elements::{TxOut, TxOutSecrets}; + let asset = signer.network.policy_asset(); + let asset_bf = AssetBlindingFactor::new(&mut thread_rng()); + let value_bf = ValueBlindingFactor::new(&mut thread_rng()); + PartialInput::new(UTXO { outpoint: OutPoint::new(Txid::from_byte_array([0x01; 32]), 0), - txout: TxOut::default(), - secrets: Some(TxOutSecrets::new( - signer.network.policy_asset(), - AssetBlindingFactor::zero(), - value, - ValueBlindingFactor::zero(), - )), + txout: TxOut { + asset: Asset::new_confidential(&signer.secp, asset, asset_bf), + value: Value::new_confidential_from_assetid(&signer.secp, value, asset, value_bf, asset_bf), + ..TxOut::default() + }, + secrets: Some(TxOutSecrets::new(asset, asset_bf, value, value_bf)), }) } + #[test] + fn inputs_whose_secrets_do_not_open_them_are_refused() { + let signer = create_signer(); + let mut input = confidential_input(&signer, 100_000); + + input.secrets.as_mut().unwrap().value = 100_001; + + let mut ft = FinalTransaction::new(); + + ft.add_input(input, Some(NativeSignature::Ecdsa)); + ft.add_output( + PartialOutput::new( + signer.get_address().script_pubkey(), + 50_000, + signer.network.policy_asset(), + ) + .with_blinding_key(signer.get_blinding_public_key()), + ); + + for result in [ + signer.estimate_strict(&ft, 1.0).map(|_| ()), + signer.sign_witnesses(&ft).map(|_| ()), + ] { + assert!(matches!( + result, + Err(SignerError::PartialInput(PartialInputError::AmountMismatch(_))) + )); + } + } + #[test] fn explicit_change_target_cannot_balance_a_confidential_input() { let signer = create_signer(); @@ -1407,7 +1420,7 @@ mod tests { assert!(matches!( signer.sign_witnesses(&ft), - Err(SignerError::UnknownInputAmount(unknown)) if unknown == outpoint + Err(SignerError::PartialInput(PartialInputError::UnknownAmount(unknown))) if unknown == outpoint )); } @@ -1530,7 +1543,10 @@ mod tests { signer.sign_witnesses(&ft).map(|_| ()), signer.sign_tx(&ft).map(|_| ()), ] { - assert!(matches!(result, Err(SignerError::UnknownInputAmount(_)))); + assert!(matches!( + result, + Err(SignerError::PartialInput(PartialInputError::UnknownAmount(_))) + )); } } @@ -1595,7 +1611,7 @@ mod tests { assert!(matches!( signer.finalize_strict(&ft, 100.0), - Err(SignerError::DuplicateInput(_)) + Err(SignerError::FinalTransaction(FinalTransactionError::DuplicateInput(_))) )); } diff --git a/crates/sdk/src/signer/error.rs b/crates/sdk/src/signer/error.rs index 20bb26a8..b4dada23 100644 --- a/crates/sdk/src/signer/error.rs +++ b/crates/sdk/src/signer/error.rs @@ -3,7 +3,7 @@ use elements_miniscript::bitcoin::bip32::DerivationPath; use crate::program::ProgramError; use crate::provider::ProviderError; use crate::transaction::error::ProgramInputError; -use crate::transaction::{FinalTransactionError, UtxoError, WitnessWrappingError}; +use crate::transaction::{FinalTransactionError, PartialInputError, UtxoError, WitnessWrappingError}; /// Errors from a [`Signer`](super::Signer). /// @@ -31,6 +31,11 @@ pub enum SignerError { #[error(transparent)] Utxo(#[from] UtxoError), + /// An input's asset and amount are unknown, or its unblinding secrets don't open the output it + /// spends. + #[error(transparent)] + PartialInput(#[from] PartialInputError), + /// Errors originating from transaction construction or validation. #[error(transparent)] FinalTransaction(#[from] FinalTransactionError), @@ -84,10 +89,6 @@ pub enum SignerError { #[error("Invalid fee rate: {0} sats/kvb")] InvalidFeeRate(f32), - /// Error indicating the transaction spends the same outpoint more than once. - #[error("The transaction spends {0} more than once")] - DuplicateInput(simplicityhl::elements::OutPoint), - /// Error indicating that the available UTXO funds are not enough to cover total costs. #[error("Not enough funds on account to cover transaction costs: {0}")] NotEnoughFunds(u64), @@ -135,10 +136,6 @@ pub enum SignerError { /// Error thrown when WPKH address creation fails. #[error("Failed to construct a wpkh address: {0}")] WpkhAddressConstruction(#[from] elements_miniscript::Error), - - /// Error indicating an input whose amount the signer cannot know. - #[error("The input spending {0} is confidential and has no unblinding secrets, so its amount is unknown")] - UnknownInputAmount(simplicityhl::elements::OutPoint), } impl From for SignerError { diff --git a/crates/sdk/src/transaction/error.rs b/crates/sdk/src/transaction/error.rs index 391c564c..5e82fe3b 100644 --- a/crates/sdk/src/transaction/error.rs +++ b/crates/sdk/src/transaction/error.rs @@ -26,6 +26,9 @@ pub enum FinalTransactionError { /// The difference of the policy asset amounts of the inputs and outputs overflows `i64`. #[error("The policy asset amounts overflow the fee delta")] AmountOverflow, + /// The transaction spends the same outpoint more than once. + #[error("The transaction spends {0} more than once")] + DuplicateInput(OutPoint), } /// Errors encountered when reading a UTXO's asset or amount. @@ -39,6 +42,32 @@ pub enum UtxoError { UnknownAmount(OutPoint), } +/// Errors encountered when checking the asset and amount an input spends. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum PartialInputError { + /// The spent UTXO is confidential and the input has no unblinding secrets. + #[error("The input spending {0} is confidential and has no unblinding secrets, so its amount is unknown")] + UnknownAmount(OutPoint), + /// The unblinding secrets don't rebuild the spent UTXO's asset. + #[error("The unblinding secrets of the input spending {0} don't open its asset")] + AssetMismatch(OutPoint), + /// The unblinding secrets rebuild the spent UTXO's asset but not its amount. + #[error("The unblinding secrets of the input spending {0} don't open its amount")] + AmountMismatch(OutPoint), +} + +/// Errors from putting a signature into a program input's witness. +#[derive(Debug, thiserror::Error)] +pub(crate) enum ProgramInputError { + /// The program doesn't compile, so its witness types can't be read. + #[error(transparent)] + Program(#[from] ProgramError), + + /// The signature doesn't fit the witness. + #[error(transparent)] + Injection(#[from] WitnessWrappingError), +} + /// Errors originating from manipulating witness paths and injecting values. #[derive(Debug, thiserror::Error)] pub enum WitnessWrappingError { @@ -68,15 +97,3 @@ pub enum WitnessWrappingError { #[error("The witness value takes the other branch of an Either than the path")] EitherBranchMismatch, } - -/// Errors from putting a signature into a program input's witness. -#[derive(Debug, thiserror::Error)] -pub(crate) enum ProgramInputError { - /// The program doesn't compile, so its witness types can't be read. - #[error(transparent)] - Program(#[from] ProgramError), - - /// The signature doesn't fit the witness. - #[error(transparent)] - Injection(#[from] WitnessWrappingError), -} diff --git a/crates/sdk/src/transaction/final_transaction.rs b/crates/sdk/src/transaction/final_transaction.rs index 95b031f8..302e21d6 100644 --- a/crates/sdk/src/transaction/final_transaction.rs +++ b/crates/sdk/src/transaction/final_transaction.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use simplicityhl::elements::pset::{Input, PartiallySignedTransaction}; use simplicityhl::elements::{ @@ -9,7 +9,7 @@ use simplicityhl::elements::{ use crate::provider::SimplicityNetwork; use super::change_output::ChangeOutput; -use super::error::FinalTransactionError; +use super::error::{FinalTransactionError, PartialInputError}; use super::partial_input::{IssuanceDetails, IssuanceInput, PartialInput, ProgramInput}; use super::partial_output::PartialOutput; use super::required_signature::{NativeSignature, ProgramSignature, RequiredSignature}; @@ -465,6 +465,33 @@ impl FinalTransaction { Ok(transfers.values().all(|&value| value == 0)) } + /// Checks every input with [`PartialInput::check_asset_and_amount`]. + /// + /// # Errors + /// Returns the error of the first input that fails the check. + pub fn check_inputs_assets_and_amounts(&self) -> Result<(), PartialInputError> { + self.inputs + .iter() + .try_for_each(|input| input.partial_input.check_asset_and_amount()) + } + + /// Checks that no two inputs spend the same outpoint. + /// + /// # Errors + /// Returns [`FinalTransactionError::DuplicateInput`] with the first outpoint spent twice. + pub fn check_unique_inputs(&self) -> Result<(), FinalTransactionError> { + let mut seen = HashSet::new(); + + match self + .inputs + .iter() + .find(|input| !seen.insert(input.partial_input.outpoint)) + { + Some(input) => Err(FinalTransactionError::DuplicateInput(input.partial_input.outpoint)), + None => Ok(()), + } + } + /// Converts the transaction into an unsigned PSET, and returns it with each input's secrets, /// by input index. /// diff --git a/crates/sdk/src/transaction/mod.rs b/crates/sdk/src/transaction/mod.rs index 00ff18a3..393bd0cf 100644 --- a/crates/sdk/src/transaction/mod.rs +++ b/crates/sdk/src/transaction/mod.rs @@ -40,7 +40,7 @@ pub mod utxo; mod witness_injector; pub use change_output::ChangeOutput; -pub use error::{FinalTransactionError, UtxoError, WitnessWrappingError}; +pub use error::{FinalTransactionError, PartialInputError, UtxoError, WitnessWrappingError}; 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/partial_input.rs b/crates/sdk/src/transaction/partial_input.rs index 63f4df93..176a1525 100644 --- a/crates/sdk/src/transaction/partial_input.rs +++ b/crates/sdk/src/transaction/partial_input.rs @@ -4,7 +4,9 @@ use std::sync::Arc; use elements_miniscript::bitcoin::bip32::DerivationPath; +use simplicityhl::elements::confidential::{self, AssetBlindingFactor, ValueBlindingFactor}; use simplicityhl::elements::pset::Input; +use simplicityhl::elements::secp256k1_zkp::SECP256K1; use simplicityhl::elements::{AssetEntropy, AssetId, LockTime, OutPoint, Sequence, TxOut, TxOutSecrets}; use simplicityhl::value::ValueConstructible; use simplicityhl::{TemplateProgramWitness, Value, WitnessNameToValueMap, WitnessValues}; @@ -13,7 +15,7 @@ use crate::program::ProgramTrait; use crate::utils; use super::UTXO; -use super::error::{ProgramInputError, WitnessWrappingError}; +use super::error::{PartialInputError, ProgramInputError, WitnessWrappingError}; use super::required_signature::ProgramSignature; use super::witness_injector::WitnessInjector; @@ -163,6 +165,57 @@ impl PartialInput { } } + /// Checks that [`PartialInput::asset_and_amount`] can be trusted: the asset and amount of the + /// spent UTXO are known, and the unblinding secrets, if any, open the UTXO. Opening means the + /// secrets rebuild its asset and amount, whether blinded or explicit. An explicit asset or + /// amount needs a zero blinding factor. + /// + /// # Errors + /// Returns `PartialInputError::UnknownAmount` if the UTXO is confidential and has no secrets, + /// `PartialInputError::AssetMismatch` if the secrets don't open the asset, and + /// `PartialInputError::AmountMismatch` if they open the asset but not the amount. + pub fn check_asset_and_amount(&self) -> Result<(), PartialInputError> { + let Some(secrets) = self.secrets else { + return self + .asset_and_amount() + .map(|_| ()) + .ok_or(PartialInputError::UnknownAmount(self.outpoint)); + }; + + let opens_asset = match self.witness_utxo.asset { + confidential::Asset::Explicit(asset) => { + asset == secrets.asset && secrets.asset_bf == AssetBlindingFactor::zero() + } + committed => committed == confidential::Asset::new_confidential(SECP256K1, secrets.asset, secrets.asset_bf), + }; + + if !opens_asset { + return Err(PartialInputError::AssetMismatch(self.outpoint)); + } + + let opens_amount = match self.witness_utxo.value { + confidential::Value::Explicit(value) => { + value == secrets.value && secrets.value_bf == ValueBlindingFactor::zero() + } + committed => { + committed + == confidential::Value::new_confidential_from_assetid( + SECP256K1, + secrets.value, + secrets.asset, + secrets.value_bf, + secrets.asset_bf, + ) + } + }; + + if !opens_amount { + return Err(PartialInputError::AmountMismatch(self.outpoint)); + } + + Ok(()) + } + /// Converts this input into a PSET input. /// /// An unset sequence becomes `Sequence::MAX` and an unset locktime becomes zero. The @@ -328,3 +381,65 @@ impl IssuanceInput { } } } + +#[cfg(test)] +mod tests { + use bip39::rand::thread_rng; + + use super::*; + + #[test] + fn secrets_must_open_the_spent_utxo() { + let asset = AssetId::from_byte_array([0x01; 32]); + let other_asset = AssetId::from_byte_array([0x02; 32]); + let asset_bf = AssetBlindingFactor::new(&mut thread_rng()); + let value_bf = ValueBlindingFactor::new(&mut thread_rng()); + + let blinded = TxOut { + asset: confidential::Asset::new_confidential(SECP256K1, asset, asset_bf), + value: confidential::Value::new_confidential_from_assetid(SECP256K1, 1000, asset, value_bf, asset_bf), + ..TxOut::default() + }; + let explicit = TxOut::new_fee(1000, asset); + let check = |txout: &TxOut, secrets: TxOutSecrets| { + PartialInput::new(UTXO { + outpoint: OutPoint::default(), + txout: txout.clone(), + secrets: Some(secrets), + }) + .check_asset_and_amount() + }; + + assert_eq!( + check(&blinded, TxOutSecrets::new(asset, asset_bf, 1000, value_bf)), + Ok(()) + ); + assert_eq!( + check(&blinded, TxOutSecrets::new(other_asset, asset_bf, 1000, value_bf)), + Err(PartialInputError::AssetMismatch(OutPoint::default())) + ); + assert_eq!( + check( + &blinded, + TxOutSecrets::new(asset, asset_bf, 1000, ValueBlindingFactor::zero()) + ), + Err(PartialInputError::AmountMismatch(OutPoint::default())) + ); + + // Secrets of an explicit UTXO state its asset and amount with zero factors + assert_eq!( + check( + &explicit, + TxOutSecrets::new(asset, AssetBlindingFactor::zero(), 1000, ValueBlindingFactor::zero()) + ), + Ok(()) + ); + assert_eq!( + check( + &explicit, + TxOutSecrets::new(asset, AssetBlindingFactor::zero(), 999, ValueBlindingFactor::zero()) + ), + Err(PartialInputError::AmountMismatch(OutPoint::default())) + ); + } +} diff --git a/crates/wasm/src/transaction_builder.rs b/crates/wasm/src/transaction_builder.rs index 402e3c11..5e5c1bb4 100644 --- a/crates/wasm/src/transaction_builder.rs +++ b/crates/wasm/src/transaction_builder.rs @@ -264,9 +264,8 @@ impl TransactionBuilder { /// Pass the entropy returned by `IssuanceDetails.assetEntropy` for the first issuance. Like /// `assetEntropy`, it is hex with its bytes reversed, the order Elements prints it in. /// Pass the unblinded token details as `blinding_secrets_json`. The method checks - /// that the claimed asset ID matches the token derived from the entropy. The caller - /// must ensure the secrets open the output. Write `value` as a decimal string if it - /// exceeds 2^53 - 1. + /// that the claimed asset ID matches the token derived from the entropy. Write `value` as a + /// decimal string if it exceeds 2^53 - 1. /// /// The token's asset must be confidential. Elements uses its asset blinding /// factor as the reissuance nonce. An explicit asset has a zero nonce, which diff --git a/fixtures/tests/strict_fee_test.rs b/fixtures/tests/strict_fee_test.rs index 9f20d9ad..91061166 100644 --- a/fixtures/tests/strict_fee_test.rs +++ b/fixtures/tests/strict_fee_test.rs @@ -2,7 +2,9 @@ use simplex::constants::DUMMY_SIGNATURE; use simplex::program::Program; use simplex::signer::Signer; use simplex::simplicityhl::WitnessValues; -use simplex::transaction::{FinalTransaction, PartialInput, PartialOutput, ProgramInput, ProgramSignature}; +use simplex::simplicityhl::elements::Script; +use simplex::simplicityhl::elements::secp256k1_zkp::SECP256K1; +use simplex::transaction::{FinalTransaction, PartialInput, PartialOutput, ProgramInput, ProgramSignature, UTXO}; use simplex_fixtures::artifacts::nested_sig::NestedSigProgram; use simplex_fixtures::artifacts::nested_sig::derived_nested_sig::{NestedSigArguments, NestedSigWitness}; @@ -13,23 +15,30 @@ const FEE_RATE: f32 = 1000.0; const LOCKED: u64 = 50_000; -/// Funds `program`, then spends the funds back to the signer, `blinded` or not, at the fee the -/// keyless estimate gives. +/// Which outputs are blinded: none, the one spending the program's funds, or also the one the +/// program locks. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Blinded { + Neither, + Spend, + Both, +} + +/// Funds `program`, then spends the funds back to the signer at the fee the keyless estimate +/// gives. fn spend_at_strict_fee( context: &simplex::TestContext, program: &Program, witness: impl Into, signature: ProgramSignature, - blinded: bool, + blinded: Blinded, ) -> anyhow::Result<()> { let signer = context.get_default_signer(); let provider = context.get_default_provider(); let script = program.get_script_pubkey(context.get_network()); let witness = witness.into(); - signer.send(script.clone(), LOCKED)?; - - let utxo = provider.fetch_scripthash_utxos(&script)?[0].clone(); + let utxo = lock(context, &script, blinded == Blinded::Both)?; let spend = |fee: u64| { let mut ft = FinalTransaction::new(); @@ -44,10 +53,10 @@ fn spend_at_strict_fee( context.get_network().policy_asset(), ); - ft.add_output(if blinded { - output.with_blinding_key(signer.get_blinding_public_key()) - } else { + ft.add_output(if blinded == Blinded::Neither { output + } else { + output.with_blinding_key(signer.get_blinding_public_key()) }); ft @@ -69,6 +78,28 @@ fn spend_at_strict_fee( Ok(()) } +/// Locks `LOCKED` to `script`, `blinded` or not, and returns the locked output with its secrets. +fn lock(context: &simplex::TestContext, script: &Script, blinded: bool) -> anyhow::Result { + let signer = context.get_default_signer(); + let output = PartialOutput::new(script.clone(), LOCKED, context.get_network().policy_asset()); + let mut ft = FinalTransaction::new(); + + ft.add_output(if blinded { + output.with_blinding_key(signer.get_blinding_public_key()) + } else { + output + }); + signer.broadcast(&ft)?; + + let mut utxo = context.get_default_provider().fetch_scripthash_utxos(script)?[0].clone(); + + if blinded { + utxo.secrets = Some(utxo.txout.unblind(SECP256K1, signer.get_blinding_private_key().inner)?); + } + + Ok(utxo) +} + #[simplex::test] fn strict_fee_pays_a_p2pk_spend(context: simplex::TestContext) -> anyhow::Result<()> { let p2pk = P2pkProgram::new(P2pkArguments { @@ -80,7 +111,7 @@ fn strict_fee_pays_a_p2pk_spend(context: simplex::TestContext) -> anyhow::Result p2pk.as_ref(), P2pkWitness::default(), ProgramSignature::new("SIGNATURE"), - false, + Blinded::Neither, ) } @@ -96,7 +127,23 @@ fn strict_fee_pays_a_spend_with_a_blinded_output(context: simplex::TestContext) p2pk.as_ref(), P2pkWitness::default(), ProgramSignature::new("SIGNATURE"), - true, + Blinded::Spend, + ) +} + +#[simplex::test] +fn strict_fee_pays_a_spend_of_a_blinded_program_output(context: simplex::TestContext) -> anyhow::Result<()> { + let p2pk = P2pkProgram::new(P2pkArguments { + public_key: context.get_default_signer().get_schnorr_public_key().serialize(), + }); + + // The program input carries the secrets that open the output it spends + spend_at_strict_fee( + &context, + p2pk.as_ref(), + P2pkWitness::default(), + ProgramSignature::new("SIGNATURE"), + Blinded::Both, ) } @@ -114,6 +161,6 @@ fn strict_fee_pays_a_spend_with_pruned_branches(context: simplex::TestContext) - nested_sig.as_ref(), cold, ProgramSignature::new("INHERIT_OR_NOT").at_path(["Right", "Left"]), - false, + Blinded::Neither, ) } From 859e41694a6d18ddb2d4b6c2fff775119a9d1d61 Mon Sep 17 00:00:00 2001 From: Artem Chystiakov Date: Thu, 12 Nov 2026 19:43:17 +0200 Subject: [PATCH 3/6] custom blinding factors --- crates/sdk/src/signer/core.rs | 22 +- crates/sdk/src/signer/error.rs | 4 - crates/sdk/src/transaction/error.rs | 25 +- .../sdk/src/transaction/final_transaction.rs | 243 +++++++++++++++++- crates/sdk/src/transaction/partial_output.rs | 33 +++ crates/wasm/src/transaction_builder.rs | 32 ++- fixtures/tests/confidential_test.rs | 32 +++ 7 files changed, 362 insertions(+), 29 deletions(-) diff --git a/crates/sdk/src/signer/core.rs b/crates/sdk/src/signer/core.rs index 2cbd0938..4270955b 100644 --- a/crates/sdk/src/signer/core.rs +++ b/crates/sdk/src/signer/core.rs @@ -5,7 +5,7 @@ use std::ops::Deref; use std::str::FromStr; use simplicityhl::elements::pset::PartiallySignedTransaction; -use simplicityhl::elements::secp256k1_zkp::{All, Keypair, Message, SECP256K1, Secp256k1, ecdsa, schnorr}; +use simplicityhl::elements::secp256k1_zkp::{All, Keypair, Message, Secp256k1, ecdsa, schnorr}; use simplicityhl::elements::{Address, Script, Transaction, Witness}; #[cfg(feature = "provider")] use simplicityhl::elements::{AssetId, OutPoint, Txid}; @@ -13,7 +13,6 @@ use simplicityhl::simplicity::bitcoin::XOnlyPublicKey; use simplicityhl::{WitnessNameToValueMap, WitnessValues}; use bip39::Mnemonic; -use bip39::rand::thread_rng; use elements_miniscript::{ ConfidentialDescriptor, Descriptor, DescriptorPublicKey, @@ -486,9 +485,8 @@ impl Signer { /// without executing or finalizing the programs. Inputs that require no signature stay unsigned. /// /// Returns the PSET, with the native inputs finalized, and each program input's witness with - /// its signature inserted, by input index. Blinding is random, so when `tx` has blinded - /// outputs, every call produces different output commitments and so a different - /// `sighash_all`. + /// its signature inserted, by input index. Blinding draws a new ephemeral key for every blinded + /// output, so when `tx` has blinded outputs, every call produces a different `sighash_all`. /// /// Running a program input with [`ProgramTrait::finalize`] on the result is a dry run: it /// checks the program with its real signature without finalizing anything. @@ -502,14 +500,10 @@ impl Signer { ) -> Result<(PartiallySignedTransaction, HashMap), SignerError> { tx.check_inputs_assets_and_amounts()?; - let (mut pst, secrets) = tx.extract_pst()?; + let mut pst = tx.extract_blinded_pst()?; let inputs = tx.inputs(); let mut signed_witnesses = HashMap::new(); - if tx.needs_blinding() { - pst.blind_last(&mut thread_rng(), &self.secp, &secrets)?; - } - // Native inputs share one extracted transaction and sighash cache. let mut sighash_cache = if inputs .iter() @@ -965,11 +959,7 @@ impl Signer { /// Every program input runs with its signature checks passing, so a placeholder prunes the /// program as the real signature does. fn measure_fee(tx: &FinalTransaction, network: &SimplicityNetwork, fee_rate: f32) -> Result { - let (mut pst, secrets) = tx.extract_pst()?; - - if tx.needs_blinding() { - pst.blind_last(&mut thread_rng(), SECP256K1, &secrets)?; - } + let mut pst = tx.extract_blinded_pst()?; for (index, input_i) in tx.inputs().iter().enumerate() { let stack = if let Some(program_input) = &input_i.program_input { @@ -1151,6 +1141,8 @@ impl Signer { #[cfg(test)] mod tests { + use bip39::rand::thread_rng; + use simplicityhl::elements::confidential::{Asset, AssetBlindingFactor, Value}; use simplicityhl::elements::{OutPoint, TxOut, Txid}; diff --git a/crates/sdk/src/signer/error.rs b/crates/sdk/src/signer/error.rs index b4dada23..2de849eb 100644 --- a/crates/sdk/src/signer/error.rs +++ b/crates/sdk/src/signer/error.rs @@ -65,10 +65,6 @@ pub enum SignerError { #[error("Failed to unblind txout: {0}")] Unblind(#[source] Box), - /// Error thrown when PSET blinding fails. - #[error("Failed to blind a PST: {0}")] - PsetBlind(#[from] simplicityhl::elements::pset::PsetBlindError), - /// Error indicating failure to construct sighash for input spending. #[error("Failed to construct a message for the input spending: {0}")] SighashConstruction(#[from] elements_miniscript::psbt::SighashError), diff --git a/crates/sdk/src/transaction/error.rs b/crates/sdk/src/transaction/error.rs index 5e82fe3b..f2577db4 100644 --- a/crates/sdk/src/transaction/error.rs +++ b/crates/sdk/src/transaction/error.rs @@ -1,9 +1,10 @@ use simplicityhl::elements::OutPoint; +use simplicityhl::elements::pset::PsetBlindError; use crate::program::ProgramError; -/// Errors encountered while assembling or validating a transaction. -#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +/// Errors encountered while assembling, validating or blinding a transaction. +#[derive(Debug, Clone, 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")] @@ -29,6 +30,26 @@ pub enum FinalTransactionError { /// The transaction spends the same outpoint more than once. #[error("The transaction spends {0} more than once")] DuplicateInput(OutPoint), + /// An output without a blinding key chooses a blinding factor. + #[error("Output {output_index} chooses a blinding factor but has no blinding key")] + ChosenFactorOnExplicitOutput { + /// The index of the output in the transaction. + output_index: usize, + }, + /// An output chooses the asset blinding factor an input brings the same asset in with. Its + /// asset commitment then equals the input's, and no surjection proof can be made. + #[error("Output {output_index} chooses the asset blinding factor of an input with the same asset")] + SurjectionWithoutShift { + /// The index of the output in the transaction. + output_index: usize, + }, + /// Every blinded output chooses its value blinding factor, so none is left to balance the + /// others. + #[error("Every blinded output chooses its value blinding factor, so none can balance the transaction")] + NoFreeValueFactor, + /// A commitment or a proof of a blinded output can't be made. + #[error("Failed to blind the transaction: {0}")] + Blinding(#[from] PsetBlindError), } /// Errors encountered when reading a UTXO's asset or amount. diff --git a/crates/sdk/src/transaction/final_transaction.rs b/crates/sdk/src/transaction/final_transaction.rs index 302e21d6..4887baee 100644 --- a/crates/sdk/src/transaction/final_transaction.rs +++ b/crates/sdk/src/transaction/final_transaction.rs @@ -1,9 +1,14 @@ use std::collections::{HashMap, HashSet}; -use simplicityhl::elements::pset::{Input, PartiallySignedTransaction}; +use bip39::rand::thread_rng; + +use simplicityhl::elements::bitcoin::PublicKey; +use simplicityhl::elements::confidential::{Asset, AssetBlindingFactor, Value, ValueBlindingFactor}; +use simplicityhl::elements::pset::{Input, PartiallySignedTransaction, PsetBlindError}; +use simplicityhl::elements::secp256k1_zkp::{SECP256K1, SecretKey}; use simplicityhl::elements::{ - AssetBlindingNonce, AssetId, LockTime, Sequence, TxOutSecrets, - confidential::{AssetBlindingFactor, ValueBlindingFactor}, + AssetBlindingNonce, AssetId, LockTime, RangeProof, RangeProofMessage, Sequence, SurjectionInput, SurjectionProof, + TxOutSecrets, }; use crate::provider::SimplicityNetwork; @@ -492,6 +497,43 @@ impl FinalTransaction { } } + /// Checks that the signer can blind the outputs with the factors they choose. + /// + /// # Errors + /// - [`FinalTransactionError::ChosenFactorOnExplicitOutput`] if an output without a blinding + /// key chooses a factor. + /// - [`FinalTransactionError::SurjectionWithoutShift`] if an output chooses the asset + /// blinding factor an input or an issuance brings the same asset in with. + /// - [`FinalTransactionError::NoFreeValueFactor`] if every blinded output chooses its value + /// blinding factor. + pub fn check_blinding_factors(&self) -> Result<(), FinalTransactionError> { + for (output_index, output) in self.outputs.iter().enumerate() { + let chooses = output.asset_blinding_factor.is_some() || output.value_blinding_factor.is_some(); + + if chooses && output.blinding_key.is_none() { + return Err(FinalTransactionError::ChosenFactorOnExplicitOutput { output_index }); + } + + if output + .asset_blinding_factor + .is_some_and(|asset_bf| self.has_input_with_asset_blinding_factor(output.asset, asset_bf)) + { + return Err(FinalTransactionError::SurjectionWithoutShift { output_index }); + } + } + + let free = self + .outputs + .iter() + .any(|output| output.blinding_key.is_some() && output.value_blinding_factor.is_none()); + + if self.needs_blinding() && !free { + return Err(FinalTransactionError::NoFreeValueFactor); + } + + Ok(()) + } + /// Converts the transaction into an unsigned PSET, and returns it with each input's secrets, /// by input index. /// @@ -551,6 +593,76 @@ impl FinalTransaction { Ok((pst, input_secrets)) } + /// Converts the transaction into a PSET, as [`FinalTransaction::extract_pst`] does, and blinds + /// every output that has a blinding key. + /// + /// Each blinded output uses the blinding factors it chooses and random ones for the rest. The last + /// blinded output that doesn't choose its value blinding factor gets the one that balances + /// the transaction. + /// + /// # Errors + /// Returns the errors of [`FinalTransaction::extract_pst`] and + /// [`FinalTransaction::check_blinding_factors`], and [`FinalTransactionError::Blinding`] if a + /// commitment or a proof can't be made. + pub fn extract_blinded_pst(&self) -> Result { + self.check_blinding_factors()?; + + let (mut pst, input_secrets) = self.extract_pst()?; + + // The check leaves a free value factor whenever an output is blinded + let Some(balancer) = self + .outputs + .iter() + .rposition(|output| output.blinding_key.is_some() && output.value_blinding_factor.is_none()) + else { + return Ok(pst); + }; + + let surjection_inputs = pst.surjection_inputs(&input_secrets)?; + let mut output_factors = Vec::new(); + + for (index, output) in self.outputs.iter().enumerate() { + if index == balancer { + continue; + } + + if output.blinding_key.is_none() { + output_factors.push((output.amount, AssetBlindingFactor::zero(), ValueBlindingFactor::zero())); + + continue; + } + + let asset_bf = output + .asset_blinding_factor + .unwrap_or_else(|| AssetBlindingFactor::new(&mut thread_rng())); + let value_bf = output + .value_blinding_factor + .unwrap_or_else(|| ValueBlindingFactor::new(&mut thread_rng())); + + self.blind_output(&mut pst, index, asset_bf, value_bf, &surjection_inputs)?; + output_factors.push((output.amount, asset_bf, value_bf)); + } + + let input_factors: Vec<_> = input_secrets + .values() + .map(|secrets| (secrets.value, secrets.asset_bf, secrets.value_bf)) + .collect(); + let asset_bf = self.outputs[balancer] + .asset_blinding_factor + .unwrap_or_else(|| AssetBlindingFactor::new(&mut thread_rng())); + let value_bf = ValueBlindingFactor::last( + SECP256K1, + self.outputs[balancer].amount, + asset_bf, + &input_factors, + &output_factors, + ); + + self.blind_output(&mut pst, balancer, asset_bf, value_bf, &surjection_inputs)?; + + Ok(pst) + } + /// The fee in satoshis for `weight` at `fee_rate` sats/kvb, both rounded up. #[allow( clippy::cast_possible_truncation, @@ -563,6 +675,68 @@ impl FinalTransaction { (vsize as f32 * fee_rate / 1000.0).ceil() as u64 } + /// Blinds output `index` of `pst` with `asset_bf` and `value_bf`, using this transaction's + /// output `index` for the asset, amount, script and blinding key. + /// + /// Sets the output's commitments, range and surjection proofs, and ECDH nonce, as + /// `PartiallySignedTransaction::blind_last` does. + fn blind_output( + &self, + pst: &mut PartiallySignedTransaction, + index: usize, + asset_bf: AssetBlindingFactor, + value_bf: ValueBlindingFactor, + surjection_inputs: &[SurjectionInput], + ) -> Result<(), PsetBlindError> { + let rng = &mut thread_rng(); + let output = &self.outputs[index]; + let blinding_key = output.blinding_key.expect("a blinded output has a blinding key"); + + let (asset_commitment, surjection_proof) = Asset::Explicit(output.asset) + .blind(rng, SECP256K1, asset_bf, surjection_inputs) + .map_err(|e| PsetBlindError::ConfidentialTxOutError(index, e))?; + let (value_commitment, nonce, rangeproof) = Value::Explicit(output.amount) + .blind( + SECP256K1, + value_bf, + blinding_key.inner, + SecretKey::new(rng), + &output.script_pubkey, + &RangeProofMessage::new(output.asset, asset_bf), + ) + .map_err(|e| PsetBlindError::ConfidentialTxOutError(index, e))?; + + let asset_generator = asset_commitment.commitment().expect("a blinded asset"); + let value_commitment = value_commitment.commitment().expect("a blinded value"); + let pst_output = &mut pst.outputs_mut()[index]; + + pst_output.asset_comm = Some(asset_generator); + pst_output.amount_comm = Some(value_commitment); + pst_output.asset_surjection_proof = Some(surjection_proof); + pst_output.value_rangeproof = Some(rangeproof); + pst_output.ecdh_pubkey = nonce.commitment().map(|inner| PublicKey { + inner, + compressed: true, + }); + pst_output.blind_asset_proof = Some( + SurjectionProof::blind_asset_proof(rng, SECP256K1, output.asset, asset_bf) + .map_err(|e| PsetBlindError::BlindingProofsCreationError(index, e))?, + ); + pst_output.blind_value_proof = Some( + RangeProof::blind_value_proof( + rng, + SECP256K1, + output.amount, + value_commitment, + asset_generator, + value_bf, + ) + .map_err(|e| PsetBlindError::BlindingProofsCreationError(index, e))?, + ); + + Ok(()) + } + /// If the transaction or any of its inputs sets a non-zero locktime. fn has_locktime(&self) -> bool { self.locktime != LockTime::ZERO @@ -573,6 +747,23 @@ impl FinalTransaction { .is_some_and(|locktime| locktime != LockTime::ZERO) }) } + + /// If an input or an issuance carries `asset` with the asset blinding factor `asset_bf`. + /// Explicit inputs and issuances carry their assets with a zero factor. + fn has_input_with_asset_blinding_factor(&self, asset: AssetId, asset_bf: AssetBlindingFactor) -> bool { + self.inputs.iter().any(|input| { + let spent = input.partial_input.asset_and_amount().map(|(spent, _)| spent); + let spent_bf = input + .partial_input + .secrets + .map_or(AssetBlindingFactor::zero(), |secrets| secrets.asset_bf); + let issues = input + .get_issuance_details() + .is_some_and(|details| details.asset_id == asset || details.inflation_asset_id == asset); + + (spent == Some(asset) && spent_bf == asset_bf) || (issues && asset_bf == AssetBlindingFactor::zero()) + }) + } } #[cfg(test)] @@ -615,6 +806,52 @@ mod tests { } } + #[test] + fn chosen_blinding_factors_are_checked() { + let policy = dummy_asset_id(0xAA); + let asset_bf = AssetBlindingFactor::from_slice(&[0x01; 32]).unwrap(); + let value_bf = ValueBlindingFactor::from_slice(&[0x01; 32]).unwrap(); + let blinded = || PartialOutput::new(Script::new(), 1000, policy).with_blinding_key(dummy_blinding_key()); + let check = |outputs: Vec| { + let mut ft = FinalTransaction::new(); + + ft.add_input(PartialInput::new(explicit_utxo(0x01, 0, 5000, policy)), None); + for output in outputs { + ft.add_output(output); + } + + ft.check_blinding_factors() + }; + + assert_eq!( + check(vec![ + blinded() + .with_asset_blinding_factor(asset_bf) + .with_value_blinding_factor(value_bf), + blinded(), + ]), + Ok(()) + ); + assert_eq!( + check(vec![ + PartialOutput::new(Script::new(), 1000, policy).with_value_blinding_factor(value_bf) + ]), + Err(FinalTransactionError::ChosenFactorOnExplicitOutput { output_index: 0 }) + ); + // The explicit input brings the asset in with a zero factor + assert_eq!( + check(vec![ + blinded(), + blinded().with_asset_blinding_factor(AssetBlindingFactor::zero()), + ]), + Err(FinalTransactionError::SurjectionWithoutShift { output_index: 1 }) + ); + assert_eq!( + check(vec![blinded().with_value_blinding_factor(value_bf)]), + Err(FinalTransactionError::NoFreeValueFactor) + ); + } + #[test] fn explicit_input_is_not_confidential() { let policy = dummy_asset_id(0xAA); diff --git a/crates/sdk/src/transaction/partial_output.rs b/crates/sdk/src/transaction/partial_output.rs index c629106d..9f831f56 100644 --- a/crates/sdk/src/transaction/partial_output.rs +++ b/crates/sdk/src/transaction/partial_output.rs @@ -1,5 +1,6 @@ use elements_miniscript::bitcoin::PublicKey; +use simplicityhl::elements::confidential::{AssetBlindingFactor, ValueBlindingFactor}; use simplicityhl::elements::pset::Output; use simplicityhl::elements::{AssetId, Script}; @@ -18,6 +19,10 @@ pub struct PartialOutput { pub asset: AssetId, /// The blinding public key. With one, the signer blinds this output's amount and asset. pub blinding_key: Option, + /// The asset blinding factor the signer blinds with, instead of a random one. + pub asset_blinding_factor: Option, + /// The value blinding factor the signer blinds with, instead of a random one. + pub value_blinding_factor: Option, } impl Default for PartialOutput { @@ -36,6 +41,8 @@ impl PartialOutput { amount, asset, blinding_key: None, + asset_blinding_factor: None, + value_blinding_factor: None, } } @@ -47,6 +54,8 @@ impl PartialOutput { amount: 0, asset: AssetId::default(), blinding_key: None, + asset_blinding_factor: None, + value_blinding_factor: None, } } @@ -58,6 +67,30 @@ impl PartialOutput { self } + /// Blinds this output's asset with `asset_blinding_factor` instead of a random one. + /// + /// The output needs a blinding key, and the factor must differ from the one any input brings + /// the same asset in with. A covenant can check the factor, and Elements reads a reissuance + /// token's asset factor as the next reissuance's nonce. + #[must_use] + pub fn with_asset_blinding_factor(mut self, asset_blinding_factor: AssetBlindingFactor) -> Self { + self.asset_blinding_factor = Some(asset_blinding_factor); + + self + } + + /// Blinds this output's amount with `value_blinding_factor` instead of a random one. + /// + /// The output needs a blinding key. The signer solves one blinded output's value factor so + /// that the transaction balances, so at least one blinded output, normally the change, must + /// leave this unset. + #[must_use] + pub fn with_value_blinding_factor(mut self, value_blinding_factor: ValueBlindingFactor) -> Self { + self.value_blinding_factor = Some(value_blinding_factor); + + self + } + /// Converts this output into a PSET output. #[must_use] pub fn to_output(&self) -> Output { diff --git a/crates/wasm/src/transaction_builder.rs b/crates/wasm/src/transaction_builder.rs index 5e5c1bb4..09d52a31 100644 --- a/crates/wasm/src/transaction_builder.rs +++ b/crates/wasm/src/transaction_builder.rs @@ -483,9 +483,15 @@ impl TransactionBuilder { /// /// A blinding key makes the output confidential, whatever its script. /// + /// `asset_blinding_factor_hex` and `value_blinding_factor_hex` choose the factors the output is + /// blinded with, instead of random ones. They are hex with their bytes reversed, the order + /// Elements prints them in, so factor 1 is `01` followed by 31 zero bytes. A chosen factor + /// needs a blinding key, and at least one blinded output, normally the change, must leave its + /// value factor unchosen so that the signer can balance the transaction. + /// /// # Errors - /// Returns an error if the script, asset id or blinding key cannot be parsed, - /// or if the amount exceeds `i64::MAX`. + /// Returns an error if the script, asset id, blinding key or a blinding factor cannot be + /// parsed, or if the amount exceeds `i64::MAX`. #[wasm_bindgen(js_name = addOutput)] #[allow(clippy::needless_pass_by_value)] pub fn add_output( @@ -494,6 +500,8 @@ impl TransactionBuilder { amount_sats: u64, asset_hex: &str, blinding_key_hex: Option, + asset_blinding_factor_hex: Option, + value_blinding_factor_hex: Option, ) -> Result<(), JsError> { if i64::try_from(amount_sats).is_err() { return Err(JsError::new("output amount must not exceed i64::MAX")); @@ -512,6 +520,20 @@ impl TransactionBuilder { output = output.with_blinding_key(key); } + if let Some(asset_bf) = asset_blinding_factor_hex.as_deref() { + let factor = AssetBlindingFactor::from_str(asset_bf) + .map_err(|e| JsError::new(&format!("Invalid asset blinding factor: {e}")))?; + + output = output.with_asset_blinding_factor(factor); + } + + if let Some(value_bf) = value_blinding_factor_hex.as_deref() { + let factor = ValueBlindingFactor::from_str(value_bf) + .map_err(|e| JsError::new(&format!("Invalid value blinding factor: {e}")))?; + + output = output.with_value_blinding_factor(factor); + } + self.transaction.add_output(output); Ok(()) @@ -1298,7 +1320,7 @@ mod tests { ) .unwrap(); builder - .add_output(&script_hex, 49_000, &policy.to_string(), None) + .add_output(&script_hex, 49_000, &policy.to_string(), None, None, None) .unwrap(); builder.set_unused_witnesses(0, vec!["B".to_string()]).unwrap(); @@ -1316,8 +1338,8 @@ mod tests { builder .add_input(ON_CHAIN[0].0, 1, &spent_output(), None, None) .unwrap(); - builder.add_output("", 1_000, &policy, None).unwrap(); - builder.add_output("", 2_000, &policy, None).unwrap(); + builder.add_output("", 1_000, &policy, None, None, None).unwrap(); + builder.add_output("", 2_000, &policy, None, None, None).unwrap(); builder.remove_input(0).unwrap(); builder.remove_output(0).unwrap(); diff --git a/fixtures/tests/confidential_test.rs b/fixtures/tests/confidential_test.rs index c8b1b140..d7da612f 100644 --- a/fixtures/tests/confidential_test.rs +++ b/fixtures/tests/confidential_test.rs @@ -1,4 +1,6 @@ use simplex::simplicityhl::elements::AssetId; +use simplex::simplicityhl::elements::confidential::{AssetBlindingFactor, ValueBlindingFactor}; +use simplex::simplicityhl::elements::secp256k1_zkp::SECP256K1; use simplex::signer::Signer; use simplex::transaction::partial_input::IssuanceInput; @@ -63,3 +65,33 @@ fn confidential_test(context: simplex::TestContext) -> anyhow::Result<()> { Ok(()) } + +#[simplex::test] +fn output_keeps_its_chosen_blinding_factors(context: simplex::TestContext) -> anyhow::Result<()> { + let provider = context.get_default_provider(); + let signer = context.get_default_signer(); + let asset_bf = AssetBlindingFactor::from_slice(&[0x01; 32])?; + let value_bf = ValueBlindingFactor::from_slice(&[0x02; 32])?; + + let mut ft = FinalTransaction::new(); + + // The change the signer adds balances the transaction + ft.add_output( + PartialOutput::new( + signer.get_address().script_pubkey(), + 1000, + provider.get_network().policy_asset(), + ) + .with_blinding_key(signer.get_blinding_public_key()) + .with_asset_blinding_factor(asset_bf) + .with_value_blinding_factor(value_bf), + ); + + let txid = signer.broadcast(&ft)?.txid(); + let secrets = + provider.fetch_transaction(&txid)?.output[0].unblind(SECP256K1, signer.get_blinding_private_key().inner)?; + + assert_eq!((secrets.asset_bf, secrets.value_bf), (asset_bf, value_bf)); + + Ok(()) +} From 8a88ca7559a6f9635548a41b2fa94e178f97079b Mon Sep 17 00:00:00 2001 From: Artem Chystiakov Date: Thu, 12 Nov 2026 20:36:42 +0200 Subject: [PATCH 4/6] simplify blinding --- .../sdk/src/transaction/final_transaction.rs | 41 +++++++++---------- 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/crates/sdk/src/transaction/final_transaction.rs b/crates/sdk/src/transaction/final_transaction.rs index 4887baee..1acaacfd 100644 --- a/crates/sdk/src/transaction/final_transaction.rs +++ b/crates/sdk/src/transaction/final_transaction.rs @@ -3,12 +3,11 @@ use std::collections::{HashMap, HashSet}; use bip39::rand::thread_rng; use simplicityhl::elements::bitcoin::PublicKey; -use simplicityhl::elements::confidential::{Asset, AssetBlindingFactor, Value, ValueBlindingFactor}; +use simplicityhl::elements::confidential::{AssetBlindingFactor, ValueBlindingFactor}; use simplicityhl::elements::pset::{Input, PartiallySignedTransaction, PsetBlindError}; use simplicityhl::elements::secp256k1_zkp::{SECP256K1, SecretKey}; use simplicityhl::elements::{ - AssetBlindingNonce, AssetId, LockTime, RangeProof, RangeProofMessage, Sequence, SurjectionInput, SurjectionProof, - TxOutSecrets, + AssetBlindingNonce, AssetId, LockTime, RangeProof, Sequence, SurjectionInput, SurjectionProof, TxOut, TxOutSecrets, }; use crate::provider::SimplicityNetwork; @@ -691,30 +690,28 @@ impl FinalTransaction { let rng = &mut thread_rng(); let output = &self.outputs[index]; let blinding_key = output.blinding_key.expect("a blinded output has a blinding key"); + let ephemeral_key = SecretKey::new(rng); - let (asset_commitment, surjection_proof) = Asset::Explicit(output.asset) - .blind(rng, SECP256K1, asset_bf, surjection_inputs) - .map_err(|e| PsetBlindError::ConfidentialTxOutError(index, e))?; - let (value_commitment, nonce, rangeproof) = Value::Explicit(output.amount) - .blind( - SECP256K1, - value_bf, - blinding_key.inner, - SecretKey::new(rng), - &output.script_pubkey, - &RangeProofMessage::new(output.asset, asset_bf), - ) - .map_err(|e| PsetBlindError::ConfidentialTxOutError(index, e))?; - - let asset_generator = asset_commitment.commitment().expect("a blinded asset"); - let value_commitment = value_commitment.commitment().expect("a blinded value"); + let blinded = TxOut::with_txout_secrets( + rng, + SECP256K1, + output.script_pubkey.clone(), + blinding_key.inner, + ephemeral_key, + TxOutSecrets::new(output.asset, asset_bf, output.amount, value_bf), + surjection_inputs, + ) + .map_err(|e| PsetBlindError::ConfidentialTxOutError(index, e))?; + + let asset_generator = blinded.asset.commitment().expect("a blinded asset"); + let value_commitment = blinded.value.commitment().expect("a blinded value"); let pst_output = &mut pst.outputs_mut()[index]; pst_output.asset_comm = Some(asset_generator); pst_output.amount_comm = Some(value_commitment); - pst_output.asset_surjection_proof = Some(surjection_proof); - pst_output.value_rangeproof = Some(rangeproof); - pst_output.ecdh_pubkey = nonce.commitment().map(|inner| PublicKey { + pst_output.asset_surjection_proof = Some(blinded.witness.surjection_proof); + pst_output.value_rangeproof = Some(blinded.witness.rangeproof); + pst_output.ecdh_pubkey = blinded.nonce.commitment().map(|inner| PublicKey { inner, compressed: true, }); From 3d220659111cb14e736e64a4e64674a5b9ff2c5b Mon Sep 17 00:00:00 2001 From: Artem Chystiakov Date: Thu, 12 Nov 2026 20:43:39 +0200 Subject: [PATCH 5/6] program reissuance test --- fixtures/tests/reissuance_test.rs | 117 +++++++++++++++++++++++++++++- 1 file changed, 115 insertions(+), 2 deletions(-) diff --git a/fixtures/tests/reissuance_test.rs b/fixtures/tests/reissuance_test.rs index 01b595a7..9f866632 100644 --- a/fixtures/tests/reissuance_test.rs +++ b/fixtures/tests/reissuance_test.rs @@ -1,11 +1,17 @@ -use simplex::simplicityhl::elements::AssetId; +use simplex::simplicityhl::elements::confidential::AssetBlindingFactor; +use simplex::simplicityhl::elements::secp256k1_zkp::SECP256K1; +use simplex::simplicityhl::elements::{AssetId, OutPoint, Script, Txid}; use simplex::signer::Signer; use simplex::transaction::partial_input::IssuanceInput; use simplex::transaction::{ - FinalTransaction, IssuanceDetails, NativeSignature, PartialInput, PartialOutput, TxReceipt, + FinalTransaction, IssuanceDetails, NativeSignature, PartialInput, PartialOutput, ProgramInput, ProgramSignature, + TxReceipt, UTXO, }; +use simplex_fixtures::artifacts::p2pk::P2pkProgram; +use simplex_fixtures::artifacts::p2pk::derived_p2pk::{P2pkArguments, P2pkWitness}; + fn make_confidential_to_bob<'a>(alice: &'a Signer, bob: &Signer, asset: AssetId) -> anyhow::Result> { let mut ft = FinalTransaction::new(); @@ -120,3 +126,110 @@ fn reissuance_test(context: simplex::TestContext) -> anyhow::Result<()> { Ok(()) } + +/// The asset blinding factor `n`. +fn factor(n: u8) -> AssetBlindingFactor { + let mut bytes = [0; 32]; + bytes[31] = n; + + AssetBlindingFactor::from_slice(&bytes).expect("a small scalar") +} + +/// The reissuance token at output 1 of `txid`, unblinded with the signer's blinding key. +fn token_of(context: &simplex::TestContext, txid: Txid) -> anyhow::Result { + let txout = context.get_default_provider().fetch_transaction(&txid)?.output[1].clone(); + let secrets = txout.unblind(SECP256K1, context.get_default_signer().get_blinding_private_key().inner)?; + + Ok(UTXO { + outpoint: OutPoint::new(txid, 1), + txout, + secrets: Some(secrets), + }) +} + +/// Reissues 500 of the asset from the token `program` holds, and locks the token back blinded +/// with `next_asset_bf`, at the fee the estimate gives. Returns the reissuance's txid. +fn reissue_from_program( + context: &simplex::TestContext, + program: &P2pkProgram, + token: UTXO, + issuance_details: &IssuanceDetails, + next_asset_bf: AssetBlindingFactor, +) -> anyhow::Result { + let signer = context.get_default_signer(); + let script = program.get_script_pubkey(context.get_network()); + let mut ft = FinalTransaction::new(); + + ft.add_program_issuance_input( + PartialInput::new(token), + ProgramInput::new(Box::new(program.as_ref().clone()), P2pkWitness::default()), + IssuanceInput::new_reissuance(500, issuance_details.asset_entropy.to_byte_array()), + Some(ProgramSignature::new("SIGNATURE")), + ); + ft.add_output(PartialOutput::new( + signer.get_address().script_pubkey(), + 500, + issuance_details.asset_id, + )); + ft.add_output(locked_token(signer, script, issuance_details, next_asset_bf)); + + let fee = signer.estimate(&ft)?; + let (tx, paid) = signer.finalize(&ft)?; + + assert_eq!(paid, fee); + + Ok(context.get_default_provider().broadcast_transaction(&tx)?.txid()) +} + +/// The reissuance token locked to `script`, blinded with `asset_bf`. +fn locked_token( + signer: &Signer, + script: Script, + issuance_details: &IssuanceDetails, + asset_bf: AssetBlindingFactor, +) -> PartialOutput { + PartialOutput::new(script, 1, issuance_details.inflation_asset_id) + .with_blinding_key(signer.get_blinding_public_key()) + .with_asset_blinding_factor(asset_bf) +} + +#[simplex::test] +fn reissuance_from_a_token_a_program_holds(context: simplex::TestContext) -> anyhow::Result<()> { + let signer = context.get_default_signer(); + let p2pk = P2pkProgram::new(P2pkArguments { + public_key: signer.get_schnorr_public_key().serialize(), + }); + + // Issue the asset and lock its token to the program, blinded with factor 1 + let mut ft = FinalTransaction::new(); + let issuance_details = ft.add_issuance_input( + PartialInput::new(signer.get_utxos()?[0].clone()), + IssuanceInput::new_issuance(1000, 1, [2u8; 32]), + Some(NativeSignature::Ecdsa), + ); + + ft.add_output(PartialOutput::new( + signer.get_address().script_pubkey(), + 1000, + issuance_details.asset_id, + )); + ft.add_output(locked_token( + signer, + p2pk.get_script_pubkey(context.get_network()), + &issuance_details, + factor(1), + )); + + let mut txid = signer.broadcast(&ft)?.txid(); + + // Each reissuance spends the token with the factor it was locked with and advances the factor + for n in 1..=2 { + let token = token_of(&context, txid)?; + + assert_eq!(token.secrets.map(|secrets| secrets.asset_bf), Some(factor(n))); + + txid = reissue_from_program(&context, &p2pk, token, &issuance_details, factor(n + 1))?; + } + + Ok(()) +} From 253943e84c0737cdcb16fd54d86bd29f31447386 Mon Sep 17 00:00:00 2001 From: Artem Chystiakov Date: Thu, 12 Nov 2026 20:54:15 +0200 Subject: [PATCH 6/6] doc clean up --- crates/sdk/src/signer/core.rs | 17 ++++++++++------- crates/sdk/src/transaction/error.rs | 4 ++-- crates/sdk/src/transaction/final_transaction.rs | 10 +++++----- crates/sdk/src/transaction/partial_output.rs | 6 +++--- crates/wasm/src/transaction_builder.rs | 12 ++++++------ fixtures/tests/strict_fee_test.rs | 3 ++- 6 files changed, 28 insertions(+), 24 deletions(-) diff --git a/crates/sdk/src/signer/core.rs b/crates/sdk/src/signer/core.rs index 4270955b..c68c0487 100644 --- a/crates/sdk/src/signer/core.rs +++ b/crates/sdk/src/signer/core.rs @@ -315,7 +315,8 @@ impl Signer { /// - `SignerError::PartialInput` if an input is confidential and has no unblinding secrets, or /// its secrets don't open the output it spends. /// - `SignerError::FinalTransaction` with `FinalTransactionError::DuplicateInput` if `tx` spends - /// an outpoint twice. + /// an outpoint twice, or with the errors of [`FinalTransaction::extract_blinded_pst`] if its + /// outputs can't be blinded. /// - `SignerError::ConfidentialInputWithoutBlindedOutput` if `tx` spends a confidential input, /// has an explicit change target, and doesn't have any blinded output. /// - `SignerError::Unbalanced` if an asset other than the policy asset doesn't balance. @@ -355,7 +356,8 @@ impl Signer { /// - `SignerError::PartialInput` if an input is confidential and has no unblinding secrets, or /// its secrets don't open the output it spends. /// - `SignerError::FinalTransaction` with `FinalTransactionError::DuplicateInput` if the - /// transaction spends an outpoint twice. + /// transaction spends an outpoint twice, or with the errors of + /// [`FinalTransaction::extract_blinded_pst`] if its outputs can't be blinded. /// - `SignerError::DustAmount` if the inputs don't exceed the outputs by at least [`MIN_FEE`] of /// the policy asset. /// - `SignerError::NotEnoughFeeAmount` if that surplus can't pay the fee, or the fee and the @@ -389,7 +391,7 @@ impl Signer { /// [`MIN_FEE`] instead: the smallest surplus that pays both the fee and the change. /// /// # Errors - /// Returns `SignerError::InvalidFeeRate`, `PartialInput`, `FinalTransaction(DuplicateInput)`, + /// Returns `SignerError::InvalidFeeRate`, `PartialInput`, `FinalTransaction`, /// `ConfidentialInputWithoutBlindedOutput` or `Unbalanced` as [`Signer::finalize_strict`] does, /// and `ProgramError::ProgramExecution` if a program rejects the spend. pub fn estimate_strict(&self, tx: &FinalTransaction, fee_rate: f32) -> Result { @@ -408,7 +410,7 @@ impl Signer { /// /// # Errors /// - `SignerError::NotEnoughFeeAmount` if the surplus is below the fee at `fee_rate`. - /// - `SignerError::InvalidFeeRate`, `PartialInput`, `FinalTransaction(DuplicateInput)`, + /// - `SignerError::InvalidFeeRate`, `PartialInput`, `FinalTransaction`, /// `ConfidentialInputWithoutBlindedOutput` or `Unbalanced` as [`Signer::finalize_strict`] /// does, and the errors of [`Signer::sign_tx`]. pub fn finalize_strict_fee(&self, tx: &FinalTransaction, fee_rate: f32) -> Result<(Transaction, u64), SignerError> { @@ -438,7 +440,7 @@ impl Signer { /// signed transaction needs. /// /// # Errors - /// Returns `SignerError::InvalidFeeRate`, `PartialInput`, `FinalTransaction(DuplicateInput)` or + /// Returns `SignerError::InvalidFeeRate`, `PartialInput`, `FinalTransaction` or /// `ConfidentialInputWithoutBlindedOutput` as [`Signer::finalize_strict`] does, and /// `ProgramError::ProgramExecution` if a program rejects the spend. pub fn estimate_strict_fee( @@ -492,8 +494,9 @@ impl Signer { /// checks the program with its real signature without finalizing anything. /// /// # Errors - /// Returns `SignerError::PartialInput` as [`Signer::finalize_strict`] does, and - /// a `SignerError` if blinding, signing or injecting a signature into a program witness fails. + /// Returns `SignerError::PartialInput` as [`Signer::finalize_strict`] does, the errors of + /// [`FinalTransaction::extract_blinded_pst`] as `SignerError::FinalTransaction`, and another + /// `SignerError` if signing or injecting a signature into a program witness fails. pub fn sign_witnesses( &self, tx: &FinalTransaction, diff --git a/crates/sdk/src/transaction/error.rs b/crates/sdk/src/transaction/error.rs index f2577db4..a2d39662 100644 --- a/crates/sdk/src/transaction/error.rs +++ b/crates/sdk/src/transaction/error.rs @@ -36,8 +36,8 @@ pub enum FinalTransactionError { /// The index of the output in the transaction. output_index: usize, }, - /// An output chooses the asset blinding factor an input brings the same asset in with. Its - /// asset commitment then equals the input's, and no surjection proof can be made. + /// An output chooses the asset blinding factor of an input or an issuance of the same asset. + /// Its asset commitment then equals the input's, and no surjection proof can be made. #[error("Output {output_index} chooses the asset blinding factor of an input with the same asset")] SurjectionWithoutShift { /// The index of the output in the transaction. diff --git a/crates/sdk/src/transaction/final_transaction.rs b/crates/sdk/src/transaction/final_transaction.rs index 1acaacfd..b61ea369 100644 --- a/crates/sdk/src/transaction/final_transaction.rs +++ b/crates/sdk/src/transaction/final_transaction.rs @@ -502,7 +502,7 @@ impl FinalTransaction { /// - [`FinalTransactionError::ChosenFactorOnExplicitOutput`] if an output without a blinding /// key chooses a factor. /// - [`FinalTransactionError::SurjectionWithoutShift`] if an output chooses the asset - /// blinding factor an input or an issuance brings the same asset in with. + /// blinding factor of an input or an issuance of the same asset. /// - [`FinalTransactionError::NoFreeValueFactor`] if every blinded output chooses its value /// blinding factor. pub fn check_blinding_factors(&self) -> Result<(), FinalTransactionError> { @@ -595,9 +595,9 @@ impl FinalTransaction { /// Converts the transaction into a PSET, as [`FinalTransaction::extract_pst`] does, and blinds /// every output that has a blinding key. /// - /// Each blinded output uses the blinding factors it chooses and random ones for the rest. The last - /// blinded output that doesn't choose its value blinding factor gets the one that balances - /// the transaction. + /// Each blinded output uses the blinding factors it chooses and random ones for the rest. The + /// last blinded output that doesn't choose its value blinding factor gets the one that + /// balances the transaction. /// /// # Errors /// Returns the errors of [`FinalTransaction::extract_pst`] and @@ -835,7 +835,7 @@ mod tests { ]), Err(FinalTransactionError::ChosenFactorOnExplicitOutput { output_index: 0 }) ); - // The explicit input brings the asset in with a zero factor + // The explicit input carries the asset with a zero factor assert_eq!( check(vec![ blinded(), diff --git a/crates/sdk/src/transaction/partial_output.rs b/crates/sdk/src/transaction/partial_output.rs index 9f831f56..959bb6d3 100644 --- a/crates/sdk/src/transaction/partial_output.rs +++ b/crates/sdk/src/transaction/partial_output.rs @@ -69,9 +69,9 @@ impl PartialOutput { /// Blinds this output's asset with `asset_blinding_factor` instead of a random one. /// - /// The output needs a blinding key, and the factor must differ from the one any input brings - /// the same asset in with. A covenant can check the factor, and Elements reads a reissuance - /// token's asset factor as the next reissuance's nonce. + /// The output needs a blinding key, and the factor must differ from the asset blinding factor + /// of every input of the same asset. A covenant can check the factor, and the next reissuance + /// from a token must state the token's asset factor as its nonce. #[must_use] pub fn with_asset_blinding_factor(mut self, asset_blinding_factor: AssetBlindingFactor) -> Self { self.asset_blinding_factor = Some(asset_blinding_factor); diff --git a/crates/wasm/src/transaction_builder.rs b/crates/wasm/src/transaction_builder.rs index 09d52a31..ccd5a81f 100644 --- a/crates/wasm/src/transaction_builder.rs +++ b/crates/wasm/src/transaction_builder.rs @@ -261,8 +261,8 @@ impl TransactionBuilder { /// Adds a native input that spends an output holding the reissuance token and mints /// `asset_amount_sats` more units of the asset. /// - /// Pass the entropy returned by `IssuanceDetails.assetEntropy` for the first issuance. Like - /// `assetEntropy`, it is hex with its bytes reversed, the order Elements prints it in. + /// Pass the entropy as `IssuanceDetails.assetEntropy` returns it for the first issuance: hex + /// with its bytes reversed, the order Elements prints it in. /// Pass the unblinded token details as `blinding_secrets_json`. The method checks /// that the claimed asset ID matches the token derived from the entropy. Write `value` as a /// decimal string if it exceeds 2^53 - 1. @@ -311,9 +311,9 @@ impl TransactionBuilder { /// `witness_json` carries the witness values in `SimplicityHL` `.wit` shape. /// Passing `None` leaves them unset. /// - /// `signature_witness` names the witness the signer must fill with a Schnorr signature - /// over this transaction. - /// Leaving this `None` says the program needs no signature. + /// `program_signature` names the witness the signer fills with a Schnorr signature over this + /// transaction, such as `SIGNATURE`, or a place inside it, such as `SIGNATURE.Left.1`. + /// Leaving it `None` means the program needs no signature. /// /// `blinding_secrets_json` contains the unblinded details of a confidential output, as in /// `addInput`. @@ -1261,7 +1261,7 @@ mod tests { ) .unwrap(); - // Without the secrets, the confidential spent output has no known asset or amount + // The secrets make the asset and amount of the confidential spent output known for input in builder.transaction.inputs() { assert_eq!(input.partial_input.asset_and_amount(), Some((asset, 1))); } diff --git a/fixtures/tests/strict_fee_test.rs b/fixtures/tests/strict_fee_test.rs index 91061166..b5f0a7c7 100644 --- a/fixtures/tests/strict_fee_test.rs +++ b/fixtures/tests/strict_fee_test.rs @@ -78,7 +78,8 @@ fn spend_at_strict_fee( Ok(()) } -/// Locks `LOCKED` to `script`, `blinded` or not, and returns the locked output with its secrets. +/// Locks `LOCKED` to `script`, `blinded` or not, and returns the locked output, with its secrets +/// when blinded. fn lock(context: &simplex::TestContext, script: &Script, blinded: bool) -> anyhow::Result { let signer = context.get_default_signer(); let output = PartialOutput::new(script.clone(), LOCKED, context.get_network().policy_asset());