diff --git a/lwk_simplicity/src/error.rs b/lwk_simplicity/src/error.rs index 7a301c815..127dae336 100644 --- a/lwk_simplicity/src/error.rs +++ b/lwk_simplicity/src/error.rs @@ -76,4 +76,7 @@ pub enum ProgramError { #[error("Input index exceeds u32 maximum: {0}")] InputIndexOverflow(#[from] std::num::TryFromIntError), + + #[error("Invalid control block: {0}")] + InvalidControlBlock(String), } diff --git a/lwk_simplicity/src/signer.rs b/lwk_simplicity/src/signer.rs index 054a79bd2..bd04f381f 100644 --- a/lwk_simplicity/src/signer.rs +++ b/lwk_simplicity/src/signer.rs @@ -2,6 +2,8 @@ use crate::error::ProgramError; use crate::runner::run_program; use crate::scripts::{control_block, create_p2tr_address}; +use simplicityhl::elements::taproot; + use std::sync::Arc; use lwk_common::Network; @@ -86,6 +88,88 @@ pub fn finalize_transaction( Ok(tx) } +/// Build an Elements environment from a pre-computed control block (no script pubkey check). +/// +/// Use this for storage-aware UTXOs whose taproot tree includes hidden storage slot leaves that +/// differ from the single-CMR-leaf tree that `get_and_verify_env` reconstructs. +/// +/// # Errors +/// Returns error if UTXO index is invalid or control block bytes cannot be parsed. +pub fn get_env_with_cb( + tx: &Transaction, + program: &CompiledProgram, + utxos: &[TxOut], + input_index: usize, + control_block_bytes: &[u8], + network: Network, +) -> Result>, ProgramError> { + let cmr = program.commit().cmr(); + + if utxos.len() <= input_index { + return Err(ProgramError::UtxoIndexOutOfBounds { + input_index, + utxo_count: utxos.len(), + }); + } + + let cb = taproot::ControlBlock::from_slice(control_block_bytes) + .map_err(|e| ProgramError::InvalidControlBlock(e.to_string()))?; + + Ok(ElementsEnv::new( + Arc::new(tx.clone()), + utxos + .iter() + .map(|utxo| ElementsUtxo { + script_pubkey: utxo.script_pubkey.clone(), + asset: utxo.asset, + value: utxo.value, + }) + .collect(), + u32::try_from(input_index)?, + cmr, + cb, + None, + network.genesis_hash(), + )) +} + +/// Finalize a transaction using a pre-computed control block for a storage-aware UTXO. +/// +/// # Errors +/// Returns error if the control block is invalid or program execution fails. +#[allow(clippy::too_many_arguments)] +pub fn finalize_transaction_with_cb( + mut tx: Transaction, + program: &CompiledProgram, + utxos: &[TxOut], + input_index: usize, + witness_values: WitnessValues, + control_block_bytes: &[u8], + network: Network, + log_level: TrackerLogLevel, +) -> Result { + let env = get_env_with_cb(&tx, program, utxos, input_index, control_block_bytes, network)?; + + let pruned = run_program(program, witness_values, &env, log_level)?.0; + + let (simplicity_program_bytes, simplicity_witness_bytes) = pruned.to_vec_with_witness(); + let cmr = pruned.cmr(); + + tx.input[input_index].witness = TxInWitness { + amount_rangeproof: None, + inflation_keys_rangeproof: None, + script_witness: vec![ + simplicity_witness_bytes, + simplicity_program_bytes, + cmr.as_ref().to_vec(), + control_block_bytes.to_vec(), + ], + pegin_witness: vec![], + }; + + Ok(tx) +} + /// Build and verify an Elements environment for program execution. /// /// # Errors diff --git a/lwk_wasm/src/simplicity/program.rs b/lwk_wasm/src/simplicity/program.rs index 45fe56d07..28b1641ab 100644 --- a/lwk_wasm/src/simplicity/program.rs +++ b/lwk_wasm/src/simplicity/program.rs @@ -159,6 +159,40 @@ impl SimplicityProgram { Ok(signature.serialize().to_hex()) } + /// Finalize a transaction with a storage-aware taproot spend info for the specified input. + /// + /// NOTE: The utxos object is destroyed during the execution of the function, so the argument that was + /// passed in the JS code cannot be reused. + #[allow(clippy::too_many_arguments)] + #[wasm_bindgen(js_name = finalizeTransactionWithSpendInfo)] + pub fn finalize_transaction_with_spend_info( + &self, + tx: &Transaction, + spend_info: &super::state_utils::StateTaprootSpendInfo, + utxos: Vec, + input_index: u32, + witness_values: &SimplicityWitnessValues, + network: &Network, + log_level: SimplicityLogLevel, + ) -> Result { + let cmr: Cmr = self.inner.commit().cmr().into(); + let cb_bytes = spend_info.control_block(&cmr)?.to_bytes(); + let utxos_inner = convert_utxos(&utxos); + + let finalized = signer::finalize_transaction_with_cb( + tx.as_ref().clone(), + &self.inner, + &utxos_inner, + input_index as usize, + witness_values.to_inner()?, + &cb_bytes, + network.into(), + log_level.into(), + )?; + + Ok(finalized.into()) + } + /// Satisfy and execute this program in a transaction environment. /// /// NOTE: The utxos object is destroyed during the execution of the function, so the argument that was diff --git a/lwk_wasm/src/tx_builder.rs b/lwk_wasm/src/tx_builder.rs index bc58b340f..ab638e066 100644 --- a/lwk_wasm/src/tx_builder.rs +++ b/lwk_wasm/src/tx_builder.rs @@ -4,20 +4,58 @@ use lwk_wollet::{elements, UnvalidatedRecipient, Validated}; use wasm_bindgen::prelude::*; use crate::{ - liquidex::ValidatedLiquidexProposal, Address, AssetId, Contract, Error, Network, OutPoint, - Pset, Transaction, Wollet, + liquidex::ValidatedLiquidexProposal, Address, AssetId, Contract, Error, ExternalUtxo, Network, + OutPoint, Pset, Script, Transaction, Wollet, }; +/// A recipient of newly issued asset units. +#[wasm_bindgen] +#[derive(Debug)] +pub struct IssuanceRecipient { + inner: lwk_wollet::IssuanceRecipient, +} + +impl From for lwk_wollet::IssuanceRecipient { + fn from(value: IssuanceRecipient) -> Self { + value.inner + } +} + +#[wasm_bindgen] +impl IssuanceRecipient { + /// Create an issuance recipient using the next wallet external address. + pub fn wallet(satoshi: u64) -> IssuanceRecipient { + IssuanceRecipient { + inner: lwk_wollet::IssuanceRecipient::wallet(satoshi), + } + } + + /// Create an issuance recipient from an address. + #[wasm_bindgen(js_name = fromAddress)] + pub fn from_address(satoshi: u64, address: &Address) -> IssuanceRecipient { + IssuanceRecipient { + inner: lwk_wollet::IssuanceRecipient::from_address(satoshi, address.as_ref()), + } + } +} + /// A transaction builder #[wasm_bindgen] #[derive(Debug)] pub struct TxBuilder { inner: lwk_wollet::TxBuilder, + /// Per-input sequence overrides applied in `finish`. Only populated by `setInputSequence`. + /// Must be set after all other builder methods — those methods go through + /// `From` which resets this field to an empty vec. + input_sequences: Vec<(elements::OutPoint, elements::Sequence)>, } impl From for TxBuilder { fn from(value: lwk_wollet::TxBuilder) -> Self { - Self { inner: value } + Self { + inner: value, + input_sequences: vec![], + } } } @@ -34,12 +72,38 @@ impl TxBuilder { pub fn new(network: &Network) -> TxBuilder { TxBuilder { inner: lwk_wollet::TxBuilder::new(network.into()), + input_sequences: vec![], } } - /// Build the transaction + /// Build the transaction, applying any per-input sequence overrides set via + /// `setInputSequence`. pub fn finish(self, wollet: &Wollet) -> Result { - Ok(self.inner.finish(wollet.as_ref())?.into()) + let input_sequences = self.input_sequences; + let mut pset = self.inner.finish(wollet.as_ref())?; + for input in pset.inputs_mut() { + let op = elements::OutPoint::new(input.previous_txid, input.previous_output_index); + if let Some(seq) = + input_sequences.iter().find_map(|(o, s)| (*o == op).then_some(*s)) + { + input.sequence = Some(seq); + } + } + Ok(pset.into()) + } + + /// Override the nSequence for a specific input identified by its outpoint. + /// + /// Must be called **after** all other builder methods, because those methods go through + /// `From` which resets the per-input sequence list to empty. + /// + /// Pass `0xFFFFFFFE` (ENABLE_LOCKTIME_NO_RBF) to enable absolute locktime without RBF — + /// required by Simplicity's `check_lock_height` jet (returns 0 when all inputs are final). + #[wasm_bindgen(js_name = setInputSequence)] + pub fn set_input_sequence(mut self, outpoint: &OutPoint, sequence: u32) -> TxBuilder { + self.input_sequences + .push((outpoint.into(), elements::Sequence::from_consensus(sequence))); + self } /// Build the transaction for AMP0 @@ -125,6 +189,39 @@ impl TxBuilder { .into()) } + /// Add an unblinded output with an arbitrary script pubkey to the post-issuance output list. + #[wasm_bindgen(js_name = addPostIssuanceScriptOutput)] + pub fn add_post_issuance_script_output( + self, + script_pubkey: &Script, + satoshi: u64, + asset: &AssetId, + ) -> TxBuilder { + self.inner + .add_post_issuance_script_output(script_pubkey.as_ref().clone(), satoshi, (*asset).into()) + .into() + } + + /// Add an address-based output to the post-issuance output list. + /// + /// Like `addPostIssuanceScriptOutput`, outputs are appended after all asset and issuance + /// outputs but before L-BTC change and fee, enabling precise vout ordering for covenant + /// transactions that depend on output indexes. + /// + /// Unlike `addPostIssuanceScriptOutput`, a confidential `address` produces a blinded + /// output visible to the wallet; an unconfidential address produces an explicit one. + #[wasm_bindgen(js_name = addPostIssuanceRecipient)] + pub fn add_post_issuance_recipient( + self, + address: &Address, + satoshi: u64, + asset: &AssetId, + ) -> TxBuilder { + self.inner + .add_post_issuance_recipient(address.as_ref(), satoshi, (*asset).into()) + .into() + } + /// Issue an asset /// /// There will be `asset_sats` units of this asset that will be received by @@ -158,6 +255,32 @@ impl TxBuilder { .into()) } + /// Issue an asset and send issued units to recipients. + /// + /// Recipient amounts are summed to determine the issued asset amount. + #[wasm_bindgen(js_name = issueAssetToRecipients)] + pub fn issue_asset_to_recipients( + self, + asset_recipients: Vec, + token_sats: u64, + token_receiver: Option
, + contract: Option, + input_outpoint: Option, + ) -> Result { + let asset_recipients = asset_recipients.into_iter().map(Into::into).collect(); + Ok(self + .inner + .issue_asset_to_recipients_at_input( + asset_recipients, + token_sats, + token_receiver.map(Into::into), + contract.map(Into::into), + input_outpoint.map(Into::into), + )? + .into()) + } + + /// Reissue an asset /// /// reissue the asset defined by `asset_to_reissue`, provided the reissuance token is owned @@ -205,6 +328,23 @@ impl TxBuilder { self.inner.set_wallet_utxos(outpoints).into() } + /// Set the exact order in which selected wallet and external inputs are added. + #[wasm_bindgen(js_name = setInputOrder)] + pub fn set_input_order(self, outpoints: Vec) -> TxBuilder { + let outpoints: Vec = outpoints.into_iter().map(Into::into).collect(); + self.inner.set_input_order(outpoints).into() + } + + /// Adds external UTXOs + /// + /// Note: unblinded UTXOs with the same scriptpubkeys as the wallet, are considered external. + #[wasm_bindgen(js_name = addExternalUtxos)] + pub fn add_external_utxos(self, utxos: Vec) -> Result { + let utxos: Vec = + utxos.iter().map(lwk_wollet::ExternalUtxo::from).collect(); + Ok(self.inner.add_external_utxos(utxos)?.into()) + } + /// Return a string representation of the transaction builder (mostly for debugging) #[wasm_bindgen(js_name = toString)] pub fn to_string_js(&self) -> String { @@ -240,6 +380,14 @@ impl TxBuilder { pub fn add_input_rangeproofs(self, add_rangeproofs: bool) -> TxBuilder { self.inner.add_input_rangeproofs(add_rangeproofs).into() } + + /// Set the fallback locktime on the transaction as a block height. + #[wasm_bindgen(js_name = setFallbackLocktimeHeight)] + pub fn set_fallback_locktime_height(self, height: u32) -> Result { + let locktime = elements::LockTime::from_height(height) + .map_err(|e| Error::Generic(e.to_string()))?; + Ok(self.inner.set_fallback_locktime(locktime).into()) + } } impl Display for TxBuilder { @@ -264,19 +412,19 @@ mod tests { let policy = network.policy_asset(); let mut builder = TxBuilder::new(&network); - assert_eq!(builder.to_string(), "TxBuilder { network: Liquid, recipients: [], fee_rate: 100.0, ct_discount: true, issuance_request: None, drain_lbtc: false, drain_to: None, external_utxos: [], selected_utxos: None, add_input_rangeproofs: true, is_liquidex_make: false, liquidex_proposals: [] }"); + assert_eq!(builder.to_string(), "TxBuilder { network: Liquid, recipients: [], post_issuance_recipients: [], fee_rate: 100.0, ct_discount: true, issuance_request: None, drain_lbtc: false, drain_to: None, external_utxos: [], selected_utxos: None, add_input_rangeproofs: true, is_liquidex_make: false, liquidex_proposals: [] }"); builder = builder.fee_rate(Some(200.0)); - assert_eq!(builder.to_string(), "TxBuilder { network: Liquid, recipients: [], fee_rate: 200.0, ct_discount: true, issuance_request: None, drain_lbtc: false, drain_to: None, external_utxos: [], selected_utxos: None, add_input_rangeproofs: true, is_liquidex_make: false, liquidex_proposals: [] }"); + assert_eq!(builder.to_string(), "TxBuilder { network: Liquid, recipients: [], post_issuance_recipients: [], fee_rate: 200.0, ct_discount: true, issuance_request: None, drain_lbtc: false, drain_to: None, external_utxos: [], selected_utxos: None, add_input_rangeproofs: true, is_liquidex_make: false, liquidex_proposals: [] }"); builder = builder.add_burn(1000, &policy); - assert_eq!(builder.to_string(), "TxBuilder { network: Liquid, recipients: [Recipient { satoshi: 1000, script_pubkey: Script(OP_RETURN), blinding_pubkey: None, asset: 6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d }], fee_rate: 200.0, ct_discount: true, issuance_request: None, drain_lbtc: false, drain_to: None, external_utxos: [], selected_utxos: None, add_input_rangeproofs: true, is_liquidex_make: false, liquidex_proposals: [] }"); + assert_eq!(builder.to_string(), "TxBuilder { network: Liquid, recipients: [Recipient { satoshi: 1000, script_pubkey: Script(OP_RETURN), blinding_pubkey: None, asset: 6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d }], post_issuance_recipients: [], fee_rate: 200.0, ct_discount: true, issuance_request: None, drain_lbtc: false, drain_to: None, external_utxos: [], selected_utxos: None, add_input_rangeproofs: true, is_liquidex_make: false, liquidex_proposals: [] }"); let o = OutPoint::new( "[elements]b93dbfb3fa1929b6f82ed46c4a5d8e1c96239ca8b3d9fce00c321d7dadbdf6e0:0", ) .unwrap(); builder = builder.set_wallet_utxos(vec![o]); - assert_eq!(builder.to_string(), "TxBuilder { network: Liquid, recipients: [Recipient { satoshi: 1000, script_pubkey: Script(OP_RETURN), blinding_pubkey: None, asset: 6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d }], fee_rate: 200.0, ct_discount: true, issuance_request: None, drain_lbtc: false, drain_to: None, external_utxos: [], selected_utxos: Some([OutPoint { txid: b93dbfb3fa1929b6f82ed46c4a5d8e1c96239ca8b3d9fce00c321d7dadbdf6e0, vout: 0 }]), add_input_rangeproofs: true, is_liquidex_make: false, liquidex_proposals: [] }"); + assert_eq!(builder.to_string(), "TxBuilder { network: Liquid, recipients: [Recipient { satoshi: 1000, script_pubkey: Script(OP_RETURN), blinding_pubkey: None, asset: 6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d }], post_issuance_recipients: [], fee_rate: 200.0, ct_discount: true, issuance_request: None, drain_lbtc: false, drain_to: None, external_utxos: [], selected_utxos: Some([OutPoint { txid: b93dbfb3fa1929b6f82ed46c4a5d8e1c96239ca8b3d9fce00c321d7dadbdf6e0, vout: 0 }]), add_input_rangeproofs: true, is_liquidex_make: false, liquidex_proposals: [] }"); } } diff --git a/lwk_wollet/src/error.rs b/lwk_wollet/src/error.rs index dd62e4a75..7d9cf673d 100644 --- a/lwk_wollet/src/error.rs +++ b/lwk_wollet/src/error.rs @@ -226,6 +226,9 @@ pub enum Error { #[error("Missing wallet UTXO {0}")] MissingWalletUtxo(OutPoint), + #[error("Input order contains duplicated outpoint {0}")] + DuplicatedOutpoint(OutPoint), + #[error("Transaction has empty witness, did you forget to sign and finalize?")] EmptyWitness, diff --git a/lwk_wollet/src/lib.rs b/lwk_wollet/src/lib.rs index 8d74016b9..28c134cdc 100644 --- a/lwk_wollet/src/lib.rs +++ b/lwk_wollet/src/lib.rs @@ -133,6 +133,7 @@ pub use crate::model::{ WalletTxOut, }; pub use crate::pegin::fed_peg_script; +pub use crate::pset_create::IssuanceRecipient; #[cfg(feature = "registry")] pub use crate::registry::RegistryAssetData; pub use crate::tx_details::{TxDetails, TxOpt, TxOutDetails, TxsOpt}; diff --git a/lwk_wollet/src/pset_create.rs b/lwk_wollet/src/pset_create.rs index 24557a630..9193a6ed4 100644 --- a/lwk_wollet/src/pset_create.rs +++ b/lwk_wollet/src/pset_create.rs @@ -3,7 +3,7 @@ use crate::contract::Contract; use crate::elements::confidential::AssetBlindingFactor; use crate::elements::issuance::ContractHash; use crate::elements::pset::{Output, PartiallySignedTransaction}; -use crate::elements::{Address, AssetId, OutPoint, Transaction, TxOut, TxOutSecrets, Txid}; +use crate::elements::{Address, AssetId, OutPoint, Script, Transaction, TxOut, TxOutSecrets, Txid}; use crate::error::Error; use crate::hashes::Hash; use crate::model::{Recipient, WalletTxOut}; @@ -19,10 +19,80 @@ pub const SECP256K1_SURJECTIONPROOF_MAX_N_INPUTS: usize = 256; // We make issuance and reissuance are mutually exclusive for simplicity pub enum IssuanceRequest { None, - Issuance(u64, Option
, u64, Option
, Option), + Issuances(Vec), Reissuance(AssetId, u64, Option
, Option), } +#[derive(Debug)] +pub struct IssuanceRequestItem { + pub asset_recipients: Vec, + pub token_sats: u64, + pub token_receiver: Option
, + pub contract: Option, + pub input_outpoint: Option, +} + +/// Recipient of newly issued asset units. +#[derive(Debug, Clone)] +pub struct IssuanceRecipient { + satoshi: u64, + script_pubkey: Option