diff --git a/CHANGELOG.md b/CHANGELOG.md index bac98e1ff8..8a0782f376 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,13 +15,14 @@ The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/ - Node RPC: new methods added - `chainstate_tokens_info`, `chainstate_orders_info_by_currencies`. - Wallet RPC: - - new methods added: `node_get_tokens_info`, `order_list_own`, `order_list_all_active`. + - new methods added: `node_get_tokens_info`, `order_list_own`, `order_list_all_active`, `utxo_spend`. - new value `ledger` in the `hardware_wallet` option for `wallet_create`, `wallet_recover` and `wallet_open` methods. - Wallet CLI: - - the commands `order-create`, `order-fill`, `order-freeze`, `order-conclude` were added, - mirroring their existing RPC counterparts; - - other new commands added: `order-list-own`, `order-list-all-active`; + - the commands `order-create`, `order-fill`, `order-freeze`, `order-conclude`, `htlc-create-transaction` were added, + mirroring their existing RPC counterparts. + - other new commands added: `order-list-own`, `order-list-all-active`, `utxo-spend`, `htlc-generate-secret`, + `htlc-calc-secret-hash`. - `wallet-create`/`wallet-recover`/`wallet-open` support the `ledger` subcommand, in addition to the existing `software` and `trezor`, which specifies the type of the wallet to operate on. diff --git a/Cargo.lock b/Cargo.lock index 5c77b0bce6..cf12c5bf1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9818,6 +9818,7 @@ dependencies = [ "blockprod", "chainstate", "chainstate-storage", + "chrono", "clap", "common", "consensus", diff --git a/common/src/chain/transaction/output/htlc.rs b/common/src/chain/transaction/output/htlc.rs index 449aa0884f..0e0f05e1fd 100644 --- a/common/src/chain/transaction/output/htlc.rs +++ b/common/src/chain/transaction/output/htlc.rs @@ -19,7 +19,7 @@ use hex::FromHex as _; use crypto::hash::{self, hash}; -use randomness::Rng; +use randomness::{CryptoRng, Rng}; use serialization::{Decode, Encode}; use super::{timelock::OutputTimeLock, Destination}; @@ -47,7 +47,7 @@ impl HtlcSecret { Self { secret } } - pub fn new_from_rng(rng: &mut impl Rng) -> Self { + pub fn new_from_rng(rng: &mut (impl Rng + CryptoRng)) -> Self { let secret: [u8; HTLC_SECRET_SIZE] = std::array::from_fn(|_| rng.gen::()); Self { secret } } diff --git a/common/src/primitives/time.rs b/common/src/primitives/time.rs index 3f26f8a9c9..0bf3eee20f 100644 --- a/common/src/primitives/time.rs +++ b/common/src/primitives/time.rs @@ -110,6 +110,9 @@ impl Time { self.time.saturating_sub(t.time) } + /// Convert this `Time` instance to `chrono::DateTime`. + /// + /// This may fail if the time is too far into the future. pub fn as_absolute_time(&self) -> Option> { TryInto::::try_into(self.time.as_secs()).ok().and_then(|secs| { // Note: chrono::DateTime supports time values up to about 262,000 years away @@ -118,6 +121,19 @@ impl Time { chrono::Utc.timestamp_opt(secs, self.time.subsec_nanos()).single() }) } + + /// Create a new `Time` instance from `chrono::DateTime`. + /// + /// This will fail if the date-time is before the Unix epoch or if it doesn't represent + /// a whole number of seconds. + pub fn from_absolute_time(time: &chrono::DateTime) -> Option { + if time.timestamp_subsec_nanos() != 0 { + return None; + } + + let seconds: u64 = time.timestamp().try_into().ok()?; + Some(Self::from_secs_since_epoch(seconds)) + } } impl std::ops::Add for Time { @@ -173,7 +189,11 @@ impl Display for Time { #[cfg(test)] mod tests { + use rstest::rstest; + use logging::log; + use randomness::Rng as _; + use test_utils::random::{make_seedable_rng, Seed}; use super::*; @@ -243,4 +263,40 @@ mod tests { let s = format!("{t}"); assert_eq!(s, "18446744073709551615.999999999s since Unix epoch"); } + + #[rstest] + #[trace] + #[case(Seed::from_entropy())] + fn from_to_absolute_time(#[case] seed: Seed) { + use chrono::prelude::*; + + let mut rng = make_seedable_rng(seed); + + for _ in 0..10 { + let year = rng.gen_range(1970..=3000); + let month = rng.gen_range(1..=12); + let days_in_month = + NaiveDate::from_ymd_opt(year, month, 1).unwrap().num_days_in_month(); + let day = rng.gen_range(1..=days_in_month); + let hour = rng.gen_range(0..24); + let min = rng.gen_range(0..60); + let sec = rng.gen_range(0..60); + let abs_time = DateTime::from_naive_utc_and_offset( + NaiveDateTime::new( + NaiveDate::from_ymd_opt(year, month, day.into()).unwrap(), + NaiveTime::from_hms_opt(hour, min, sec).unwrap(), + ), + Utc, + ); + + let time = Time::from_absolute_time(&abs_time).unwrap(); + let abs_time_from_conversion = time.as_absolute_time().unwrap(); + assert_eq!(abs_time_from_conversion, abs_time); + + // Only a whole number of seconds should be allowed. + let millis = rng.gen_range(1..1000); + let abs_time_with_millis = abs_time + Duration::from_millis(millis); + assert!(Time::from_absolute_time(&abs_time_with_millis).is_none()); + } + } } diff --git a/common/src/size_estimation/mod.rs b/common/src/size_estimation/mod.rs index 90cf38e1a7..97dbf9a51b 100644 --- a/common/src/size_estimation/mod.rs +++ b/common/src/size_estimation/mod.rs @@ -23,16 +23,21 @@ use serialization::{CompactLen, Encode}; use crate::chain::{ classic_multisig::ClassicMultisigChallenge, + htlc::{HtlcSecret, HTLC_SECRET_SIZE}, signature::{ inputsig::{ + authorize_hashed_timelock_contract_spend::{ + AuthorizedHashedTimelockContractSpend, AuthorizedHashedTimelockContractSpendTag, + }, authorize_pubkey_spend::AuthorizedPublicKeySpend, authorize_pubkeyhash_spend::AuthorizedPublicKeyHashSpend, classical_multisig::authorize_classical_multisig::AuthorizedClassicalMultisigSpend, - standard_signature::StandardInputSignature, InputWitness, + standard_signature::StandardInputSignature, + InputWitness, }, sighash::sighashtype::SigHashType, }, - Destination, SignedTransaction, Transaction, TxOutput, + Destination, SignedTransaction, Transaction, TxInput, TxOutput, }; /// Wallet errors @@ -51,10 +56,11 @@ pub enum SizeEstimationError { /// provided destination. pub fn input_signature_size( txo: &TxOutput, + htlc_spend_tag: Option, dest_info_provider: Option<&dyn DestinationInfoProvider>, ) -> Result { get_tx_output_destination(txo).map_or(Ok(0), |dest| { - input_signature_size_from_destination(dest, dest_info_provider) + input_signature_size_from_destination(dest, htlc_spend_tag, dest_info_provider) }) } @@ -107,9 +113,7 @@ lazy_static::lazy_static! { static ref NO_SIGNATURE_SIZE: usize = { InputWitness::NoSignature(None).encoded_size() }; -} -lazy_static::lazy_static! { static ref PUB_KEY_SIGNATURE_SIZE: usize = { let raw_signature = AuthorizedPublicKeySpend::new(BOGUS_KEY_PAIR_AND_SIGNATURE.2.clone()).encode(); @@ -119,9 +123,7 @@ lazy_static::lazy_static! { ); InputWitness::Standard(standard).encoded_size() }; -} -lazy_static::lazy_static! { static ref ADDRESS_SIGNATURE_SIZE: usize = { let raw_signature = AuthorizedPublicKeyHashSpend::new( BOGUS_KEY_PAIR_AND_SIGNATURE.1.clone(), @@ -134,6 +136,36 @@ lazy_static::lazy_static! { ); InputWitness::Standard(standard).encoded_size() }; + + static ref HTLC_SPEND_SIGNATURE_OVERHEAD: usize = { + let pkh_spend_encoded = AuthorizedPublicKeyHashSpend::new( + BOGUS_KEY_PAIR_AND_SIGNATURE.1.clone(), + BOGUS_KEY_PAIR_AND_SIGNATURE.2.clone(), + ) + .encode(); + let pkh_spend_encoded_size = pkh_spend_encoded.len(); + + let htlc_spend = AuthorizedHashedTimelockContractSpend::Spend( + HtlcSecret::new([0; HTLC_SECRET_SIZE]), pkh_spend_encoded + ); + let htlc_spend_size = htlc_spend.encoded_size(); + + htlc_spend_size.checked_sub(pkh_spend_encoded_size).expect("HTLC spend size is known to be bigger") + }; + + static ref HTLC_REFUND_SIGNATURE_OVERHEAD: usize = { + let pkh_spend_encoded = AuthorizedPublicKeyHashSpend::new( + BOGUS_KEY_PAIR_AND_SIGNATURE.1.clone(), + BOGUS_KEY_PAIR_AND_SIGNATURE.2.clone(), + ) + .encode(); + let pkh_spend_encoded_size = pkh_spend_encoded.len(); + + let htlc_spend = AuthorizedHashedTimelockContractSpend::Refund(pkh_spend_encoded); + let htlc_spend_size = htlc_spend.encoded_size(); + + htlc_spend_size.checked_sub(pkh_spend_encoded_size).expect("HTLC spend size is known to be bigger") + }; } #[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd)] @@ -177,21 +209,36 @@ pub trait DestinationInfoProvider { /// provided destination. pub fn input_signature_size_from_destination( destination: &Destination, + htlc_spend_tag: Option, dest_info_provider: Option<&dyn DestinationInfoProvider>, ) -> Result { // Sizes calculated upfront - match destination { - Destination::PublicKeyHash(_) => Ok(*ADDRESS_SIGNATURE_SIZE), - Destination::PublicKey(_) => Ok(*PUB_KEY_SIGNATURE_SIZE), - Destination::AnyoneCanSpend => Ok(*NO_SIGNATURE_SIZE), - Destination::ScriptHash(_) => Err(SizeEstimationError::UnsupportedInputDestination( - destination.clone(), - )), + let size = match destination { + Destination::PublicKeyHash(_) => *ADDRESS_SIGNATURE_SIZE, + Destination::PublicKey(_) => *PUB_KEY_SIGNATURE_SIZE, + Destination::AnyoneCanSpend => *NO_SIGNATURE_SIZE, + Destination::ScriptHash(_) => { + return Err(SizeEstimationError::UnsupportedInputDestination( + destination.clone(), + )); + } Destination::ClassicMultisig(_) => dest_info_provider .and_then(|dest_info_provider| dest_info_provider.get_multisig_info(destination)) .map(multisig_signature_size) - .ok_or_else(|| SizeEstimationError::UnsupportedInputDestination(destination.clone())), - } + .ok_or_else(|| SizeEstimationError::UnsupportedInputDestination(destination.clone()))?, + }; + + let adjusted_size = match htlc_spend_tag { + None => size, + Some(AuthorizedHashedTimelockContractSpendTag::Spend) => { + size + *HTLC_SPEND_SIGNATURE_OVERHEAD + } + Some(AuthorizedHashedTimelockContractSpendTag::Refund) => { + size + *HTLC_REFUND_SIGNATURE_OVERHEAD + } + }; + + Ok(adjusted_size) } /// Return the encoded size for a SignedTransaction also accounting for the compact encoding of the @@ -232,8 +279,29 @@ pub fn tx_size_with_num_inputs_and_outputs( Ok(*EMPTY_SIGNED_TX_SIZE + output_compact_size_diff + (input_compact_size_diff * 2)) } -pub fn outputs_encoded_size(outputs: &[TxOutput]) -> usize { - outputs.iter().map(serialization::Encode::encoded_size).sum() +pub fn outputs_encoded_size<'a>(outputs: impl IntoIterator) -> usize { + outputs.into_iter().map(serialization::Encode::encoded_size).sum() +} + +pub fn inputs_encoded_size<'a>(inputs: impl IntoIterator) -> usize { + inputs.into_iter().map(serialization::Encode::encoded_size).sum() +} + +pub fn input_signatures_size_from_destinations<'a>( + destinations: impl IntoIterator< + Item = ( + &'a Destination, + Option, + ), + >, + dest_info_provider: Option<&dyn DestinationInfoProvider>, +) -> Result { + destinations + .into_iter() + .map(|(dest, htlc_spend_tag)| { + input_signature_size_from_destination(dest, htlc_spend_tag, dest_info_provider) + }) + .sum() } fn get_tx_output_destination(txo: &TxOutput) -> Option<&Destination> { diff --git a/common/src/size_estimation/tests.rs b/common/src/size_estimation/tests.rs index d9e763ba03..74479055f3 100644 --- a/common/src/size_estimation/tests.rs +++ b/common/src/size_estimation/tests.rs @@ -13,39 +13,65 @@ // See the License for the specific language governing permissions and // limitations under the License. -use randomness::Rng; +use std::{collections::BTreeMap, num::NonZeroU8}; + +use itertools::Itertools as _; +use randomness::seq::IteratorRandom; use rstest::rstest; + +use crypto::key::{KeyKind, PrivateKey}; +use logging::log; +use randomness::Rng; +use serialization::Encode; use test_utils::random::{make_seedable_rng, Seed}; -use crate::chain::{ - signature::{ - inputsig::{standard_signature::StandardInputSignature, InputWitness}, - sighash::sighashtype::SigHashType, +use crate::{ + address::pubkeyhash::PublicKeyHash, + chain::{ + classic_multisig::ClassicMultisigChallenge, + config::create_unit_test_config, + htlc::HtlcSecret, + output_value::OutputValue, + signature::{ + inputsig::{ + authorize_hashed_timelock_contract_spend::{ + AuthorizedHashedTimelockContractSpend, AuthorizedHashedTimelockContractSpendTag, + }, + authorize_pubkey_spend::{sign_public_key_spending, AuthorizedPublicKeySpend}, + authorize_pubkeyhash_spend::sign_public_key_hash_spending, + classical_multisig::authorize_classical_multisig::{ + sign_classical_multisig_spending, AuthorizedClassicalMultisigSpend, + }, + standard_signature::StandardInputSignature, + InputWitness, + }, + sighash::sighashtype::SigHashType, + }, + Destination, OutPointSourceId, SignedTransaction, Transaction, TxInput, TxOutput, + }, + primitives::{Amount, Id, H256}, + size_estimation::{ + input_signature_size_from_destination, tx_size_with_num_inputs_and_outputs, + DestinationInfoProvider, MultisigInfo, }, - OutPointSourceId, SignedTransaction, Transaction, TxInput, }; -use crate::primitives::{Amount, Id}; + +struct TestDestInfoProvider(BTreeMap); + +impl DestinationInfoProvider for TestDestInfoProvider { + fn get_multisig_info(&self, destination: &Destination) -> Option { + self.0.get(destination).cloned() + } +} #[rstest] #[trace] #[case(Seed::from_entropy())] -fn estimate_tx_size( +fn estimate_tx_size_basic( #[case] seed: Seed, #[values(1..64, 64..0x4000, 0x4000..0x4001)] inputs_range: std::ops::Range, #[values(1..64, 64..0x4000, 0x4000..0x4001)] outputs_range: std::ops::Range, ) { - use crypto::key::{KeyKind, PrivateKey}; - use serialization::Encode; - - use crate::{ - chain::{ - output_value::OutputValue, - signature::inputsig::authorize_pubkey_spend::AuthorizedPublicKeySpend, Destination, - TxOutput, - }, - size_estimation::tx_size_with_num_inputs_and_outputs, - }; - let mut rng = make_seedable_rng(seed); let num_inputs = rng.gen_range(inputs_range); @@ -73,25 +99,208 @@ fn estimate_tx_size( .collect(); let tx = Transaction::new(0, inputs, outputs).unwrap(); - let signatures = (0..num_inputs) + let signatures_with_dests = (0..num_inputs) .map(|_| { - let private_key = - PrivateKey::new_from_rng(&mut rng, crypto::key::KeyKind::Secp256k1Schnorr).0; - let signature = private_key.sign_message(&[0; 32], &mut rng).unwrap(); + let (prv_key, pub_key) = + PrivateKey::new_from_rng(&mut rng, crypto::key::KeyKind::Secp256k1Schnorr); + let signature = prv_key.sign_message(&[0; 32], &mut rng).unwrap(); let raw_signature = AuthorizedPublicKeySpend::new(signature).encode(); let standard = StandardInputSignature::new(SigHashType::all(), raw_signature); - InputWitness::Standard(standard) + let dest = Destination::PublicKey(pub_key); + (InputWitness::Standard(standard), dest) + }) + .collect_vec(); + let signatures = signatures_with_dests.iter().map(|(sig, _)| sig.clone()).collect_vec(); + let tx = SignedTransaction::new(tx, signatures).unwrap(); + + let base_tx_size = + tx_size_with_num_inputs_and_outputs(num_outputs as usize, num_inputs as usize).unwrap(); + let inputs_size = tx.inputs().iter().map(Encode::encoded_size).sum::(); + let outputs_size = tx.outputs().iter().map(Encode::encoded_size).sum::(); + + let signatures_size = signatures_with_dests + .iter() + .map(|(_, dest)| input_signature_size_from_destination(dest, None, None).unwrap()) + .sum::(); + + let estimated_tx_size = base_tx_size + inputs_size + signatures_size + outputs_size; + let actual_tx_size = Encode::encoded_size(&tx); + + assert_eq!(estimated_tx_size, actual_tx_size); +} + +#[rstest] +#[trace] +#[case(Seed::from_entropy())] +fn estimate_tx_size_different_sigs(#[case] seed: Seed) { + let mut rng = make_seedable_rng(seed); + + let chain_config = create_unit_test_config(); + + let num_inputs = rng.gen_range(1..10); + let inputs = (0..num_inputs) + .map(|_| { + TxInput::from_utxo( + OutPointSourceId::Transaction(Id::random_using(&mut rng)), + rng.gen_range(0..100), + ) + }) + .collect(); + + let num_outputs = rng.gen_range(1..10); + let outputs = (0..num_outputs) + .map(|_| { + let destination = Destination::PublicKey( + crypto::key::PrivateKey::new_from_rng(&mut rng, KeyKind::Secp256k1Schnorr).1, + ); + + TxOutput::Transfer( + OutputValue::Coin(Amount::from_atoms(rng.gen_range(1..10000))), + destination, + ) }) .collect(); + + let mut dest_info_provider = TestDestInfoProvider(BTreeMap::new()); + + let tx = Transaction::new(0, inputs, outputs).unwrap(); + let signatures_with_dests = (0..num_inputs) + .map(|_| { + let (raw_sig, destination) = match rng.gen_range(0..3) { + 0 => { + let (prv_key, pub_key) = + PrivateKey::new_from_rng(&mut rng, crypto::key::KeyKind::Secp256k1Schnorr); + + let sighash = H256::random_using(&mut rng); + let spending = + sign_public_key_spending(&prv_key, &pub_key, &sighash, &mut rng).unwrap(); + + (spending.encode(), Destination::PublicKey(pub_key)) + } + 1 => { + let (prv_key, pub_key) = + PrivateKey::new_from_rng(&mut rng, crypto::key::KeyKind::Secp256k1Schnorr); + + let sighash = H256::random_using(&mut rng); + let pubkeyhash: PublicKeyHash = (&pub_key).into(); + let spending = + sign_public_key_hash_spending(&prv_key, &pubkeyhash, &sighash, &mut rng) + .unwrap(); + + (spending.encode(), Destination::PublicKeyHash(pubkeyhash)) + } + _ => { + let max_sig_count: u8 = rng + .gen_range(2..=chain_config.max_classic_multisig_public_keys_count()) + .try_into() + .unwrap(); + let min_sig_count = rng.gen_range(1..=max_sig_count); + + let keys = (0..max_sig_count) + .map(|_| { + PrivateKey::new_from_rng( + &mut rng, + crypto::key::KeyKind::Secp256k1Schnorr, + ) + }) + .collect_vec(); + + let challenge = ClassicMultisigChallenge::new( + &chain_config, + NonZeroU8::new(min_sig_count).unwrap(), + keys.iter().map(|(_, pubkey)| pubkey.clone()).collect_vec(), + ) + .unwrap(); + + let keys_with_indices = + keys.iter().enumerate().choose_multiple(&mut rng, min_sig_count as usize); + + let mut spending = + AuthorizedClassicalMultisigSpend::new_empty(challenge.clone()); + let sighash = H256::random_using(&mut rng); + + for (loop_idx, (key_idx, (prv_key, _))) in keys_with_indices.iter().enumerate() + { + let spending_status = sign_classical_multisig_spending( + &chain_config, + *key_idx as u8, + prv_key, + &challenge, + &sighash, + spending, + &mut rng, + ) + .unwrap(); + + if loop_idx == keys_with_indices.len() - 1 { + assert!(spending_status.is_complete()); + } else { + assert!(!spending_status.is_complete()); + } + + spending = spending_status.take(); + } + + let destination = Destination::ClassicMultisig((&challenge).into()); + + dest_info_provider.0.insert( + destination.clone(), + MultisigInfo::from_challenge(&challenge), + ); + + (spending.encode(), destination) + } + }; + + let (raw_sig, htlc_spend_tag) = match rng.gen_range(0..3) { + 0 => (raw_sig, None), + 1 => { + let secret = HtlcSecret::new(rng.gen()); + let raw_sig = + AuthorizedHashedTimelockContractSpend::Spend(secret, raw_sig).encode(); + + ( + raw_sig, + Some(AuthorizedHashedTimelockContractSpendTag::Spend), + ) + } + _ => { + let raw_sig = AuthorizedHashedTimelockContractSpend::Refund(raw_sig).encode(); + + ( + raw_sig, + Some(AuthorizedHashedTimelockContractSpendTag::Refund), + ) + } + }; + + let witness = + InputWitness::Standard(StandardInputSignature::new(SigHashType::all(), raw_sig)); + + (witness, destination, htlc_spend_tag) + }) + .collect_vec(); + + log::debug!("signatures_with_dests = {signatures_with_dests:?}"); + + let signatures = signatures_with_dests.iter().map(|(sig, _, _)| sig.clone()).collect_vec(); let tx = SignedTransaction::new(tx, signatures).unwrap(); - let estimated_tx_size = - tx_size_with_num_inputs_and_outputs(num_outputs as usize, num_inputs as usize).unwrap() - + tx.inputs().iter().map(Encode::encoded_size).sum::() - + tx.signatures().iter().map(Encode::encoded_size).sum::() - + tx.outputs().iter().map(Encode::encoded_size).sum::(); + let base_tx_size = + tx_size_with_num_inputs_and_outputs(num_outputs as usize, num_inputs as usize).unwrap(); + let inputs_size = tx.inputs().iter().map(Encode::encoded_size).sum::(); + let outputs_size = tx.outputs().iter().map(Encode::encoded_size).sum::(); + + let signatures_size = signatures_with_dests + .iter() + .map(|(_, dest, htlc_spend_tag)| { + input_signature_size_from_destination(dest, *htlc_spend_tag, Some(&dest_info_provider)) + .unwrap() + }) + .sum::(); - let expected_tx_size = Encode::encoded_size(&tx); + let estimated_tx_size = base_tx_size + inputs_size + signatures_size + outputs_size; + let actual_tx_size = Encode::encoded_size(&tx); - assert_eq!(estimated_tx_size, expected_tx_size); + assert_eq!(estimated_tx_size, actual_tx_size); } diff --git a/test/functional/test_framework/wallet_cli_controller.py b/test/functional/test_framework/wallet_cli_controller.py index 2ea9cf1d3e..828fb08225 100644 --- a/test/functional/test_framework/wallet_cli_controller.py +++ b/test/functional/test_framework/wallet_cli_controller.py @@ -292,7 +292,7 @@ async def list_multisig_utxos(self, utxo_type: str = '', with_locked: str = '', return [UtxoOutpoint(id=match["outpoint"]["source_id"]["content"]["tx_id"], index=int(match["outpoint"]["index"])) for match in output] # Same as list_multisig_utxos, but return a raw dict. - async def list_multisig_utxos_raw(self, utxo_type: str = '', with_locked: str = '', utxo_states: List[str] = []) -> List[UtxoOutpoint]: + async def list_multisig_utxos_raw(self, utxo_type: str = '', with_locked: str = '', utxo_states: List[str] = []) -> any: output = await self._write_command(f"standalone-multisig-utxos {utxo_type} {with_locked} {' '.join(utxo_states)}\n") return json.loads(output) @@ -323,6 +323,14 @@ async def sweep_delegation(self, destination_address: str, delegation_id: str) - async def send_to_address(self, address: str, amount: int | float | Decimal | str, selected_utxos: List[UtxoOutpoint] = []) -> str: return await self._write_command(f"address-send {address} {amount} {' '.join(map(str, selected_utxos))}\n") + async def send_to_address_return_tx_id(self, address: str, amount: int, selected_utxos: List[UtxoOutpoint] = []) -> str: + output = await self.send_to_address(address, amount, selected_utxos) + + match = re.search(r".*The transaction was submitted successfully with ID:\n(.+)", output) + assert match is not None, f"match failed for {output}" + + return match.group(1) + async def compose_transaction(self, outputs: List[TxOutput], selected_utxos: List[UtxoOutpoint], only_transaction: bool = False) -> str: only_tx = "--only-transaction" if only_transaction else "" utxos = f"--utxos {' --utxos '.join(map(str, selected_utxos))}" if selected_utxos else "" @@ -331,10 +339,15 @@ async def compose_transaction(self, outputs: List[TxOutput], selected_utxos: Lis async def send_tokens_to_address(self, token_id: str, address: str, amount: int | float | Decimal | str): return await self._write_command(f"token-send {token_id} {address} {amount}\n") - # Note: unlike send_tokens_to_address, this function behaves identically both for wallet_cli_controller and wallet_rpc_controller. - async def send_tokens_to_address_or_fail(self, token_id: str, address: str, amount: int | float | Decimal | str): + # Note: unlike send_tokens_to_address, this function behaves identically both for wallet_cli_controller and wallet_rpc_controller, + # namely it fails on error and returns the tx id on success. + async def send_tokens_to_address_return_tx_id(self, token_id: str, address: str, amount: int | float | Decimal | str) -> str: output = await self.send_tokens_to_address(token_id, address, amount) - assert_in("The transaction was submitted successfully", output) + + match = re.search(r".*The transaction was submitted successfully with ID:\n(.+)", output) + assert match is not None, f"match failed for {output}" + + return match.group(1) async def issue_new_token(self, token_ticker: str, @@ -378,24 +391,35 @@ async def change_token_authority(self, token_id: str, new_authority: str) -> str async def change_token_metadata_uri(self, token_id: str, new_metadata_uri: str) -> str: return await self._write_command(f"token-change-metadata-uri {token_id} {new_metadata_uri}\n") - async def issue_new_nft(self, - destination_address: str, - media_hash: str, - name: str, - description: str, - ticker: str, - creator: Optional[str] = '', - icon_uri: Optional[str] = '', - media_uri: Optional[str] = '', - additional_metadata_uri: Optional[str] = ''): - output = await self._write_command(f'token-nft-issue-new {destination_address} "{media_hash}" "{name}" "{description}" "{ticker}" {creator} {icon_uri} {media_uri} {additional_metadata_uri}\n') - if output.startswith("A new NFT has been issued with ID"): - begin = output.find(':') + 2 - end = output.find(' ', begin) - return output[begin:end], None - - self.log.error(f"err: {output}") - return None, output + # Returns (nft_id, tx_id, None) on success and (None, None, output) on failure. + async def issue_new_nft( + self, + destination_address: str, + media_hash: str, + name: str, + description: str, + ticker: str, + creator: str | None = None, + icon_uri: str | None = None, + media_uri: str | None = None, + additional_metadata_uri: str | None = None + ) -> tuple[str | None, str | None, str | None]: + creator = "" if creator is None else creator + icon_uri = "" if icon_uri is None else icon_uri + media_uri = "" if media_uri is None else media_uri + additional_metadata_uri = "" if additional_metadata_uri is None else additional_metadata_uri + + output = await self._write_command( + f'token-nft-issue-new {destination_address} "{media_hash}" "{name}" "{description}" "{ticker}" ' + + f'{creator} {icon_uri} {media_uri} {additional_metadata_uri}\n' + ) + + match = re.search(r"A new NFT has been issued with ID:\s+(\w+)\s+in tx:\s+(\w+)", output) + + if match is not None: + return match.group(1), match.group(2), None + else: + return None, None, output async def create_stake_pool(self, amount: int, @@ -594,6 +618,66 @@ async def make_tx_to_send_tokens_with_intent( return (match.group(1), match.group(2), match.group(3)) + # This function returns a dict similar to the one returned by its RPC counterpart, except for + # the "fees" item. + async def create_htlc_transaction( + self, + amount: int, + token_id: Optional[str], + secret_hash: str, + spend_address: str, + refund_address: str, + refund_lock_for_blocks: int + ) -> any: + currency = token_id if token_id is not None else "coin" + output = await self._write_command( + f"htlc-create-transaction {currency} {amount} {secret_hash} {spend_address} block_count({refund_lock_for_blocks}) {refund_address}\n") + + match = re.search( + r".*Transaction id:\s*([^\n]+)\n.*The transaction was created and is ready to be submitted:\n(.+)", + output, + flags=re.DOTALL + ) + assert match is not None, f"match failed for {output}" + + return { + "tx_id": match.group(1), + "tx": match.group(2), + } + + async def generate_htlc_secret(self) -> tuple[str, str]: + output = await self._write_command("htlc-generate-secret\n") + + match = re.search(r"New HTLC secret:\s+(.*)\nand its hash:\s+(.*)", output) + assert match is not None, f"match failed for {output}" + + return (match.group(1), match.group(2)) + + async def calc_htlc_secret_hash(self, secret: str) -> str: + output = await self._write_command(f"htlc-calc-secret-hash {secret}\n") + + match = re.search(r"The hash of the provided secret is:\s+(.*)", output) + assert match is not None, f"match failed for {output}" + + return match.group(1) + + async def spend_utxo_return_tx_id( + self, + utxo: UtxoOutpoint, + output_address: str, + htlc_secret: str | None = None, + ) -> str: + htlc_secret_part = "" if htlc_secret is None else htlc_secret + output = await self._write_command(f"utxo-spend {utxo} {output_address} {htlc_secret_part}\n") + + if "Wallet error" in output: + raise Exception(f"Cannot spent UTXO: {output}") + + match = re.search(r".*The transaction was submitted successfully with ID:\n(.+)", output) + assert match is not None, f"match failed for {output}" + + return match.group(1) + async def create_order( self, ask_token_id: Optional[str], @@ -664,3 +748,6 @@ async def list_all_active_orders(self, ask_currency_filter: str | None, give_cur assert_in("The list of active orders goes below", result[0]) assert_in("WARNING: token tickers are not unique", result[1]) return result[2:] + + def is_cli(self) -> bool: + return True diff --git a/test/functional/test_framework/wallet_rpc_controller.py b/test/functional/test_framework/wallet_rpc_controller.py index 49e7689c86..1f4173a46d 100644 --- a/test/functional/test_framework/wallet_rpc_controller.py +++ b/test/functional/test_framework/wallet_rpc_controller.py @@ -321,13 +321,18 @@ async def send_to_address(self, address: str, amount: int, selected_utxos: List[ self._write_command("address_send", [self.account, address, {'decimal': str(amount)}, selected_utxos, {'in_top_x_mb': 5}]) return "The transaction was submitted successfully" + async def send_to_address_return_tx_id(self, address: str, amount: int, selected_utxos: List[UtxoOutpoint] = []) -> str: + result = self._write_command("address_send", [self.account, address, {'decimal': str(amount)}, selected_utxos, {'in_top_x_mb': 5}]) + return result["result"]["tx_id"] + async def send_tokens_to_address(self, token_id: str, address: str, amount: int | float | Decimal | str): return self._write_command("token_send", [self.account, token_id, address, {'decimal': str(amount)}, {'in_top_x_mb': 5}])['result'] - # Note: unlike send_tokens_to_address, this function behaves identically both for wallet_cli_controller and wallet_rpc_controller. - async def send_tokens_to_address_or_fail(self, token_id: str, address: str, amount: int | float | Decimal | str): - # send_tokens_to_address already fails on error. - await self.send_tokens_to_address(token_id, address, amount) + # Note: unlike send_tokens_to_address, this function behaves identically both for wallet_cli_controller and wallet_rpc_controller, + # namely it fails on error and returns the tx id on success. + async def send_tokens_to_address_return_tx_id(self, token_id: str, address: str, amount: int | float | Decimal | str) -> str: + result = await self.send_tokens_to_address(token_id, address, amount) + return result["tx_id"] async def issue_new_token(self, token_ticker: str, @@ -386,16 +391,20 @@ async def change_token_authority(self, token_id: str, new_authority: str) -> New async def change_token_metadata_uri(self, token_id: str, new_metadata_uri: str) -> NewTxResult: return self._write_command("token_change_metadata_uri", [self.account, token_id, new_metadata_uri, {'in_top_x_mb': 5}])['result'] - async def issue_new_nft(self, - destination_address: str, - media_hash: str, - name: str, - description: str, - ticker: str, - creator: Optional[str] = '', - icon_uri: Optional[str] = '', - media_uri: Optional[str] = '', - additional_metadata_uri: Optional[str] = ''): + # Returns (nft_id, tx_id, None) on success and (None, None, output) on failure (i.e. same as + # cli controller's issue_new_nft). + async def issue_new_nft( + self, + destination_address: str, + media_hash: str, + name: str, + description: str, + ticker: str, + creator: str | None = None, + icon_uri: str | None = None, + media_uri: str | None = None, + additional_metadata_uri: str | None = None + ): output = self._write_command("token_nft_issue_new", [ self.account, destination_address, @@ -410,8 +419,12 @@ async def issue_new_nft(self, 'additional_metadata_uri': additional_metadata_uri }, {'in_top_x_mb': 5} - ])['result'] - return output + ]) + + if output.get("result") is not None: + return output["result"]["token_id"], output["result"]["tx_id"], None + else: + return None, None, output["error"]["message"] async def create_stake_pool(self, amount: int, @@ -651,6 +664,21 @@ async def create_htlc_transaction(self, result = self._write_command("create_htlc_transaction", params) return result['result'] + async def spend_utxo_return_tx_id( + self, + utxo: UtxoOutpoint, + output_address: str, + htlc_secret: str | None = None, + ) -> str: + params = [self.account, to_json(utxo), output_address, htlc_secret, {'in_top_x_mb': 5}] + result = self._write_command("utxo_spend", params) + + if result.get("error") is not None: + error_msg = result["error"]["message"] + raise Exception(f"Cannot spent UTXO: {error_msg}") + + return result["result"]["tx_id"] + async def create_order( self, ask_token_id: Optional[str], @@ -722,6 +750,9 @@ def make_currency(currency): result = self._write_command("order_list_all_active", params) return result['result'] + def is_cli(self) -> bool: + return False + def get_tx_submission_success_or_error(submission_result: dict) -> str: success_res = submission_result.get('result') diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py index e33d11b27a..5fcc0fd92a 100755 --- a/test/functional/test_runner.py +++ b/test/functional/test_runner.py @@ -181,6 +181,8 @@ class UnicodeOnWindowsError(ValueError): 'wallet_orders_v1_rpc.py', 'wallet_order_double_fill_with_same_dest_v0.py', 'wallet_order_double_fill_with_same_dest_v1.py', + 'wallet_utxo_spend.py', + 'wallet_utxo_spend_rpc.py', 'mempool_basic_reorg.py', 'mempool_eviction.py', 'mempool_ibd.py', diff --git a/test/functional/wallet_nfts.py b/test/functional/wallet_nfts.py index 428d330943..24e7fa19f8 100644 --- a/test/functional/wallet_nfts.py +++ b/test/functional/wallet_nfts.py @@ -117,13 +117,13 @@ async def async_test(self): # invalid ticker # > max len invalid_ticker = ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(random.randint(13, 20))) - nft_id, err = await wallet.issue_new_nft(address, "123456", "Name", "SomeNFT", invalid_ticker) + nft_id, _, err = await wallet.issue_new_nft(address, "123456", "Name", "SomeNFT", invalid_ticker) assert nft_id is None assert err is not None assert_in("Invalid ticker length", err) # non alphanumeric invalid_ticker = "asd" + random.choice(r"#$%&'()*+,-./:;<=>?@[]^_`{|}~") - nft_id, err = await wallet.issue_new_nft(address, "123456", "Name", "SomeNFT", invalid_ticker) + nft_id, _, err = await wallet.issue_new_nft(address, "123456", "Name", "SomeNFT", invalid_ticker) assert nft_id is None assert err is not None assert_in("Invalid character in token ticker", err) @@ -132,13 +132,13 @@ async def async_test(self): # > max len invalid_name = ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(random.randint(11, 20))) nft_ticker = "XXX" - nft_id, err = await wallet.issue_new_nft(address, "123456", invalid_name, "SomeNFT", nft_ticker) + nft_id, _, err = await wallet.issue_new_nft(address, "123456", invalid_name, "SomeNFT", nft_ticker) assert nft_id is None assert err is not None assert_in("Invalid name length", err) # non alphanumeric invalid_name = "asd" + random.choice(r"#$%&'()*+,-./:;<=>?@[]^_`{|}~") - nft_id, err = await wallet.issue_new_nft(address, "123456", invalid_name, "SomeNFT", nft_ticker) + nft_id, _, err = await wallet.issue_new_nft(address, "123456", invalid_name, "SomeNFT", nft_ticker) assert nft_id is None assert err is not None assert_in("Invalid character in token name", err) @@ -146,13 +146,13 @@ async def async_test(self): # invalid description # > max len invalid_desc = ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(random.randint(101, 200))) - nft_id, err = await wallet.issue_new_nft(address, "123456", "Name", invalid_desc, nft_ticker) + nft_id, _, err = await wallet.issue_new_nft(address, "123456", "Name", invalid_desc, nft_ticker) assert nft_id is None assert err is not None assert_in("Invalid description length", err) # issue a valid NFT - nft_id, err = await wallet.issue_new_nft(address, "123456", "Name", "SomeNFT", nft_ticker) + nft_id, _, err = await wallet.issue_new_nft(address, "123456", "Name", "SomeNFT", nft_ticker) assert err is None assert nft_id is not None self.log.info(f"new nft id: '{nft_id}'") diff --git a/test/functional/wallet_tokens_transfer_from_multisig_addr.py b/test/functional/wallet_tokens_transfer_from_multisig_addr.py index 8c01996c2f..dfbd79fd8c 100644 --- a/test/functional/wallet_tokens_transfer_from_multisig_addr.py +++ b/test/functional/wallet_tokens_transfer_from_multisig_addr.py @@ -127,8 +127,8 @@ async def setup_multisig_addresses(self, wallet, another_pub_key_as_addr, tokens ms_addr_2_of_2 = await wallet.add_standalone_multisig_address_get_result(2, [pubkey_as_addr, another_pub_key_as_addr], None, True) for token, amount1, amount2 in tokens_amounts: - await wallet.send_tokens_to_address_or_fail(token, ms_addr_1_of_2, amount1) - await wallet.send_tokens_to_address_or_fail(token, ms_addr_2_of_2, amount2) + await wallet.send_tokens_to_address_return_tx_id(token, ms_addr_1_of_2, amount1) + await wallet.send_tokens_to_address_return_tx_id(token, ms_addr_2_of_2, amount2) self.generate_block() await self.sync_wallet(wallet) diff --git a/test/functional/wallet_utxo_spend.py b/test/functional/wallet_utxo_spend.py new file mode 100644 index 0000000000..779353b365 --- /dev/null +++ b/test/functional/wallet_utxo_spend.py @@ -0,0 +1,377 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 RBB S.r.l +# Copyright (c) 2017-2021 The Bitcoin Core developers +# opensource@mintlayer.org +# SPDX-License-Identifier: MIT +# Licensed under the MIT License; +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://github.com/mintlayer/mintlayer-core/blob/master/LICENSE +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Wallet utxo spend test. + +Check spending of: +- non-htlc coin utxo; +- non-htlc fungible token utxo; +- non-htlc nft utxo; +- htlc coin utxo; +- htlc fungible token utxo; +- htlc nft utxo; +""" + +from scalecodec import ScaleBytes +from test_framework.script import hash160 +from test_framework.test_framework import BitcoinTestFramework +from test_framework.mintlayer import make_tx, reward_input, ATOMS_PER_COIN, signed_tx_obj +from test_framework.util import assert_in, assert_equal +from test_framework.mintlayer import block_input_data_obj +from test_framework.wallet_cli_controller import WalletCliController +from test_framework.wallet_rpc_controller import UtxoOutpoint + +import asyncio +import random + + +class WalletUtxoSpend(BitcoinTestFramework): + def set_test_params(self): + self.wallet_controller = WalletCliController + self.setup_clean_chain = True + self.num_nodes = 1 + self.extra_args = [[ + "--blockprod-min-peers-to-produce-blocks=0", + ]] + + def setup_network(self): + self.setup_nodes() + self.sync_all(self.nodes[0:1]) + + def generate_block(self): + node = self.nodes[0] + + block_input_data = { "PoW": { "reward_destination": "AnyoneCanSpend" } } + block_input_data = block_input_data_obj.encode(block_input_data).to_hex()[2:] + + # create a new block, taking transactions from mempool + block = node.blockprod_generate_block(block_input_data, [], [], "FillSpaceFromMempool") + node.chainstate_submit_block(block) + block_id = node.chainstate_best_block_id() + + # Wait for mempool to sync + self.wait_until(lambda: node.mempool_local_best_block_id() == block_id, timeout = 5) + + return block_id + + def run_test(self): + asyncio.run(self.async_test()) + + async def async_test(self): + node = self.nodes[0] + + async with self.wallet_controller(node, self.config, self.log) as wallet: + await wallet.create_wallet() + + # Fund the wallet from genesis. + pub_key = await wallet.new_public_key() + genesis_id = node.chainstate_best_block_id() + + funding_amount = random.randint(2000, 4000) + output = { + "Transfer": [ + { "Coin": funding_amount * ATOMS_PER_COIN }, + { "PublicKey": { "key": { "Secp256k1Schnorr": { "pubkey_data": pub_key } } } }, + ], + } + funding_tx, funding_tx_id = make_tx([reward_input(genesis_id)], [output], 0) + node.mempool_submit_transaction(funding_tx, {}) + assert node.mempool_contains_tx(funding_tx_id) + + self.generate_block() + assert_in("Success", await wallet.sync()) + + assert_in("Success", await wallet.create_new_account()) + assert_in("Success", await wallet.select_account(1)) + acc1_address = await wallet.new_address() + assert_in("Success", await wallet.select_account(0)) + + address = await wallet.new_address() + + token_decimals = random.randint(2, 10) + atoms_per_token = 10**token_decimals + token_id, _, output = await wallet.issue_new_token("TKN", token_decimals, "http://uri", address) + assert token_id is not None, f"Error issuing a token: {output}" + + # Create a bunch of NFTs; nft1 and nft2 will be locked in the corresponding HTLCs, + # nft3 will be first transferred and then spent, and nft4 issuance utxo will be spent directly. + + nft1_id, _, output = await wallet.issue_new_nft(address, "123456", "Name1", "Descr1", "NFTKN1") + assert nft1_id is not None, f"Error issuing an NFT: {output}" + + nft2_id, _, output = await wallet.issue_new_nft(address, "234567", "Name2", "Descr2", "NFTKN2") + assert nft2_id is not None, f"Error issuing an NFT: {output}" + + nft3_id, _, output = await wallet.issue_new_nft(address, "345678", "Name3", "Descr3", "NFTKN3") + assert nft3_id is not None, f"Error issuing an NFT: {output}" + + nft4_id, nft4_issuance_tx_id, output = await wallet.issue_new_nft(address, "456789", "Name4", "Descr4", "NFTKN4") + assert nft4_id is not None, f"Error issuing an NFT: {output}" + + self.generate_block() + assert_in("Success", await wallet.sync()) + + token_amount = random.randint(1000, 2000) + await wallet.mint_tokens_or_fail(token_id, address, token_amount) + + self.generate_block() + assert_in("Success", await wallet.sync()) + + # Fund account1 with coins, so that it can spend htlcs later. + await wallet.send_to_address_return_tx_id(acc1_address, 100) + + self.generate_block() + assert_in("Success", await wallet.sync()) + + async def spend_utxo_check_tx( + utxo: UtxoOutpoint, + output_address: str, + htlc_secret: str | None, + token_id: str | None, + expected_amount_atoms: int + ): + output_address_as_pubkeyhash_hex = node.test_functions_address_to_destination(output_address) + + tx_id = await wallet.spend_utxo_return_tx_id(utxo, output_address, htlc_secret) + assert node.mempool_contains_tx(tx_id) + + self.generate_block() + assert_in("Success", await wallet.sync()) + + assert not node.mempool_contains_tx(tx_id) + + tx = await wallet.get_raw_signed_transaction(tx_id) + decoded_tx = signed_tx_obj.decode(ScaleBytes(f"0x{tx}")) + + tx_inputs = decoded_tx["transaction"]["inputs"] + tx_outputs = decoded_tx["transaction"]["outputs"] + + if token_id is not None: + # When spending a token utxo, there should be a coin input to pay fees from. + assert_equal(len(tx_inputs), 2) + # A coin change output may not be present if the entire coin amount was spent + # to pay fees (though in this test this is unlikely to happen). + assert(len(tx_outputs) <= 2) + else: + # When spending a coin utxo, no additional inputs or outputs should be needed. + assert_equal(len(tx_inputs), 1) + assert_equal(len(tx_outputs), 1) + + spent_utxo_input = tx_inputs[0]["Utxo"] + assert_equal(spent_utxo_input["id"]["Transaction"], f"0x{utxo.id}") + assert_equal(spent_utxo_input["index"], utxo.index) + + new_utxo_xfer = tx_outputs[0]["Transfer"] + + if token_id is not None: + xfer_token_id_hex = new_utxo_xfer[0]["TokenV1"][0] + xfer_amount = new_utxo_xfer[0]["TokenV1"][1] + + xfer_token_id = node.test_functions_dehexify_all_addresses( + f"HexifiedTokenId{{{xfer_token_id_hex}}}" + ) + + assert_equal(xfer_token_id, token_id) + assert_equal(xfer_amount, expected_amount_atoms) + else: + xfer_amount = new_utxo_xfer[0]["Coin"] + assert \ + xfer_amount >= expected_amount_atoms - ATOMS_PER_COIN and xfer_amount <= expected_amount_atoms, \ + f"Unexpected transfer amount {xfer_amount}, should be {expected_amount_atoms} or slightly less" + + # Note: "Address" means PubKeyHash. + new_utxo_xfer_addr = new_utxo_xfer[1]["Address"].removeprefix("0x") + assert_equal(f"01{new_utxo_xfer_addr}", output_address_as_pubkeyhash_hex) + + utxos_output_address = await wallet.new_address() + + ############################################################################################################ + # Create and spend non-htlc utxos + + xfer_amount = random.randint(100, 200) + tx_id = await wallet.send_to_address_return_tx_id(address, xfer_amount) + await spend_utxo_check_tx(UtxoOutpoint(tx_id, 0), utxos_output_address, None, None, xfer_amount * ATOMS_PER_COIN) + + xfer_amount = random.randint(100, 200) + tx_id = await wallet.send_tokens_to_address_return_tx_id(token_id, address, xfer_amount) + await spend_utxo_check_tx(UtxoOutpoint(tx_id, 0), utxos_output_address, None, token_id, xfer_amount * atoms_per_token) + + tx_id = await wallet.send_tokens_to_address_return_tx_id(nft3_id, address, 1) + await spend_utxo_check_tx(UtxoOutpoint(tx_id, 0), utxos_output_address, None, nft3_id, 1) + + # Here we spend the nft4 issuance utxo directly. + await spend_utxo_check_tx(UtxoOutpoint(nft4_issuance_tx_id, 0), utxos_output_address, None, nft4_id, 1) + + ############################################################################################################ + # Creeate and spend htlc utxos + + async def make_htlc_secret(): + # If using wallet-cli, also check the secret generating and hash calculating commands. + if wallet.is_cli(): + (secret, hash) = await wallet.generate_htlc_secret() + hash_from_secret = await wallet.calc_htlc_secret_hash(secret) + assert_equal(hash, hash_from_secret) + + # Just in case, check hash calculation for a manually generated secret. + another_secret_bytes = bytes([random.randint(0, 255) for _ in range(32)]) + another_hash = hash160(another_secret_bytes).hex() + another_hash_from_secret = await wallet.calc_htlc_secret_hash(another_secret_bytes.hex()) + assert_equal(another_hash, another_hash_from_secret) + + return (secret, hash) + else: + secret_bytes = bytes([random.randint(0, 255) for _ in range(32)]) + hash_bytes = hash160(secret_bytes) + return (secret_bytes.hex(), hash_bytes.hex()) + + mempool_future_timelock_tolerance_blocks = 5 + + # The spend address belongs to account 1 and the refund address to account 0. + # The htlc output will be the first output of the produced tx. + # The refund timelock is chosen such that when we get to the point of testing the refunding, + # the tip height is too low for the refund txs to be even accepted to the mempool, + # but after `mempool_future_timelock_tolerance_blocks` blocks they can be included + # in a block. + async def create_htlc(amount: int, token_id: str | None): + # We'll be generating 4 blocks before attempting the refund - one that icnludes + # the htlcs creation and one in each call to spend_utxo_check_tx that spends + # an htlc from account 1. + htlc_refund_lock_for_blocks = 4 + mempool_future_timelock_tolerance_blocks + + (secret, secret_hash) = await make_htlc_secret() + result = await wallet.create_htlc_transaction( + amount, + token_id, + secret_hash, + acc1_address, + address, + htlc_refund_lock_for_blocks, + ) + submit_result = await wallet.submit_transaction(result["tx"]) + assert_in("The transaction was submitted successfully", submit_result) + + return (result["tx_id"], secret) + + coin_htlc1_amount = random.randint(100, 200) + (coin_htlc1_tx_id, coin_htlc1_secret) = await create_htlc(coin_htlc1_amount, None) + + coin_htlc2_amount = random.randint(100, 200) + (coin_htlc2_tx_id, coin_htlc2_secret) = await create_htlc(coin_htlc2_amount, None) + + token_htlc1_amount = random.randint(100, 200) + (token_htlc1_tx_id, token_htlc1_secret) = await create_htlc(token_htlc1_amount, token_id) + + token_htlc2_amount = random.randint(100, 200) + (token_htlc2_tx_id, token_htlc2_secret) = await create_htlc(token_htlc2_amount, token_id) + + (nft_htlc1_tx_id, nft_htlc1_secret) = await create_htlc(1, nft1_id) + + (nft_htlc2_tx_id, nft_htlc2_secret) = await create_htlc(1, nft2_id) + + self.generate_block() + assert_in("Success", await wallet.sync()) + + # Try spending the htlcs from account 0, which doesn't own the spend key. + for (tx_id, htlc_secret) in [ + (coin_htlc1_tx_id, coin_htlc1_secret), + (coin_htlc2_tx_id, coin_htlc2_secret), + (token_htlc1_tx_id, token_htlc1_secret), + (token_htlc2_tx_id, token_htlc2_secret), + (nft_htlc1_tx_id, nft_htlc1_secret), + (nft_htlc2_tx_id, nft_htlc2_secret) + ]: + error = None + try: + await wallet.spend_utxo_return_tx_id(UtxoOutpoint(tx_id, 0), utxos_output_address, htlc_secret) + except Exception as e: + error = str(e) + + assert_in("This account doesn't have the keys necessary to sign the transaction", error) + + # Switch to account 1 + assert_in("Success", await wallet.select_account(1)) + assert_in("Success", await wallet.sync()) + + # Try refunding the htlcs from account 1, which doesn't own the refund key. + for tx_id in [ + coin_htlc1_tx_id, + coin_htlc2_tx_id, + token_htlc1_tx_id, + token_htlc2_tx_id, + nft_htlc1_tx_id, + nft_htlc2_tx_id + ]: + error = None + try: + await wallet.spend_utxo_return_tx_id(UtxoOutpoint(tx_id, 0), utxos_output_address, None) + except Exception as e: + error = str(e) + + assert_in("This account doesn't have the keys necessary to sign the transaction", error) + + # Spend coin_htlc2, token_htlc2 and nft_htlc2 from account 1 + await spend_utxo_check_tx( + UtxoOutpoint(coin_htlc2_tx_id, 0), utxos_output_address, coin_htlc2_secret, None, coin_htlc2_amount * ATOMS_PER_COIN) + await spend_utxo_check_tx( + UtxoOutpoint(token_htlc2_tx_id, 0), utxos_output_address, token_htlc2_secret, token_id, token_htlc2_amount * atoms_per_token) + await spend_utxo_check_tx( + UtxoOutpoint(nft_htlc2_tx_id, 0), utxos_output_address, nft_htlc2_secret, nft2_id, 1) + + # Switch to account 0 + assert_in("Success", await wallet.select_account(0)) + assert_in("Success", await wallet.sync()) + + tip_height = node.chainstate_best_block_height() + mempool_effective_tip_height = tip_height + mempool_future_timelock_tolerance_blocks + + # Try refunding coin_htlc1, token_htlc1 and nft_htlc1 from account 0, this should fail because the txs + # can't be accepted by mempool yet. + for tx_id in [ + coin_htlc1_tx_id, + token_htlc1_tx_id, + nft_htlc1_tx_id + ]: + error = None + try: + await wallet.spend_utxo_return_tx_id(UtxoOutpoint(tx_id, 0), utxos_output_address, None) + except Exception as e: + error = str(e) + + assert_in( + f"Mempool error: Error verifying input #0: Spending at height {mempool_effective_tip_height}, " + + f"locked until height {mempool_effective_tip_height + 1}", + error + ) + + # Generate enough blocks so that the txs can be included in the next block. + for _ in range(0, mempool_future_timelock_tolerance_blocks): + self.generate_block() + assert_in("Success", await wallet.sync()) + + # Refund coin_htlc1, token_htlc1 and nft_htlc1 from account 0, this time it should succeed. + await spend_utxo_check_tx( + UtxoOutpoint(coin_htlc1_tx_id, 0), utxos_output_address, None, None, coin_htlc1_amount * ATOMS_PER_COIN) + await spend_utxo_check_tx( + UtxoOutpoint(token_htlc1_tx_id, 0), utxos_output_address, None, token_id, token_htlc1_amount * atoms_per_token) + await spend_utxo_check_tx( + UtxoOutpoint(nft_htlc1_tx_id, 0), utxos_output_address, None, nft1_id, 1) + + self.generate_block() + assert_in("Success", await wallet.sync()) + + +if __name__ == '__main__': + WalletUtxoSpend().main() diff --git a/test/functional/wallet_utxo_spend_rpc.py b/test/functional/wallet_utxo_spend_rpc.py new file mode 100644 index 0000000000..9a9c5b9a3a --- /dev/null +++ b/test/functional/wallet_utxo_spend_rpc.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 RBB S.r.l +# Copyright (c) 2017-2021 The Bitcoin Core developers +# opensource@mintlayer.org +# SPDX-License-Identifier: MIT +# Licensed under the MIT License; +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://github.com/mintlayer/mintlayer-core/blob/master/LICENSE +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Wallet utxo spend test. + +Same as 'wallet_utxo_spend', but for the corresponding RPC calls. +""" + +from wallet_utxo_spend import WalletUtxoSpend +from test_framework.wallet_rpc_controller import WalletRpcController + + +class WalletUtxoSpendRpc(WalletUtxoSpend): + def set_test_params(self): + super().set_test_params() + self.wallet_controller = WalletRpcController + + def run_test(self): + super().run_test() + + +if __name__ == '__main__': + WalletUtxoSpendRpc().main() diff --git a/utils/src/iter.rs b/utils/src/iter.rs new file mode 100644 index 0000000000..e4bf19263e --- /dev/null +++ b/utils/src/iter.rs @@ -0,0 +1,18 @@ +// Copyright (c) 2021-2026 RBB S.r.l +// opensource@mintlayer.org +// SPDX-License-Identifier: MIT +// Licensed under the MIT License; +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://github.com/mintlayer/mintlayer-core/blob/master/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +pub trait CloneableExactSizeIterator: ExactSizeIterator + Clone {} + +impl CloneableExactSizeIterator for T {} diff --git a/utils/src/lib.rs b/utils/src/lib.rs index 3f546147d8..a62d01c24e 100644 --- a/utils/src/lib.rs +++ b/utils/src/lib.rs @@ -33,6 +33,7 @@ pub mod env_utils; pub mod eventhandler; pub mod exp_rand; pub mod graph_traversals; +pub mod iter; pub mod log_utils; pub mod maybe_encrypted; pub mod newtype; diff --git a/wallet/src/account/mod.rs b/wallet/src/account/mod.rs index 918cbddd5c..8415440b07 100644 --- a/wallet/src/account/mod.rs +++ b/wallet/src/account/mod.rs @@ -32,9 +32,10 @@ use common::{ chain::{ block::timestamp::BlockTimestamp, classic_multisig::ClassicMultisigChallenge, - htlc::HashedTimelockContract, + htlc::{HashedTimelockContract, HtlcSecret}, make_token_id, output_value::{OutputValue, RpcOutputValue}, + signature::inputsig::authorize_hashed_timelock_contract_spend::AuthorizedHashedTimelockContractSpendTag, tokens::{ IsTokenUnfreezable, NftIssuance, NftIssuanceV0, RPCFungibleTokenInfo, TokenId, TokenIssuance, @@ -48,7 +49,8 @@ use common::{ Amount, BlockHeight, Id, }, size_estimation::{ - input_signature_size, input_signature_size_from_destination, outputs_encoded_size, + input_signature_size, input_signature_size_from_destination, + input_signatures_size_from_destinations, inputs_encoded_size, outputs_encoded_size, tx_size_with_num_inputs_and_outputs, DestinationInfoProvider, }, Uint256, @@ -64,7 +66,7 @@ use crypto::{ }; use mempool::FeeRate; use serialization::hex_encoded::HexEncoded; -use utils::ensure; +use utils::{ensure, iter::CloneableExactSizeIterator}; use wallet_storage::{ StoreTxRw, WalletStorageReadLocked, WalletStorageReadUnlocked, WalletStorageWriteLocked, WalletStorageWriteUnlocked, @@ -109,6 +111,7 @@ pub use self::{ utxo_selector::{CoinSelectionAlgo, UtxoSelectorError}, }; +#[derive(Debug)] pub struct CurrentFeeRate { pub current_fee_rate: FeeRate, pub consolidate_fee_rate: FeeRate, @@ -485,6 +488,7 @@ impl Account { request.with_inputs(selected_inputs, &pool_data_getter) } + // Note: this function is supposed to be called for non-htlc utxos only. fn utxo_output_groups_by_currency( &self, fee_rates: CurrentFeeRate, @@ -496,7 +500,7 @@ impl Account { let tx_input: TxInput = outpoint.into(); let input_size = serialization::Encode::encoded_size(&tx_input); - let inp_sig_size = input_signature_size(&txo, Some(self))?; + let inp_sig_size = input_signature_size(&txo, None, Some(self))?; let weight = input_size + inp_sig_size; let fee = fee_rates @@ -582,10 +586,9 @@ impl Account { .collect::>(); let coin_output = TxOutput::Transfer( - OutputValue::Coin( - (coin_input.amount - input_fees) - .ok_or(WalletError::NotEnoughUtxo(coin_input.amount, input_fees))?, - ), + OutputValue::Coin((coin_input.amount - input_fees).ok_or( + WalletError::InsufficientUtxoAmount(coin_input.amount, input_fees), + )?), destination.clone(), ); @@ -599,10 +602,9 @@ impl Account { outputs.pop(); let coin_output = TxOutput::Transfer( - OutputValue::Coin( - (coin_input.amount - total_fee) - .ok_or(WalletError::NotEnoughUtxo(coin_input.amount, input_fees))?, - ), + OutputValue::Coin((coin_input.amount - total_fee).ok_or( + WalletError::InsufficientUtxoAmount(coin_input.amount, input_fees), + )?), destination, ); outputs.push(coin_output); @@ -645,6 +647,7 @@ impl Account { + input_size + input_signature_size_from_destination( &delegation_data.destination, + None, Some(self), )?, ) @@ -671,6 +674,36 @@ impl Account { Ok(req) } + fn calculate_tx_fee<'a>( + &self, + current_fee_rate: FeeRate, + outputs: impl ExactSizeIterator, + inputs_with_destinations: impl CloneableExactSizeIterator< + Item = ( + &'a TxInput, + Option<( + &'a Destination, + Option, + )>, + ), + >, + ) -> WalletResult { + let size = + tx_size_with_num_inputs_and_outputs(outputs.len(), inputs_with_destinations.len())? + + outputs_encoded_size(outputs) + + inputs_encoded_size(inputs_with_destinations.clone().map(|(input, _)| input)) + + input_signatures_size_from_destinations( + inputs_with_destinations + .filter_map(|(_, dest_and_htlc_spend_tag)| dest_and_htlc_spend_tag), + Some(self), + )?; + + Ok(current_fee_rate + .compute_fee(size) + .map_err(|_| UtxoSelectorError::AmountArithmeticError)? + .into()) + } + fn decommission_stake_pool_impl( &mut self, db_tx: &mut impl WalletStorageWriteLocked, @@ -689,41 +722,32 @@ impl Account { let best_block_height = self.best_block().1; let tx_input = TxInput::Utxo(pool_data.utxo_outpoint.clone()); - let network_fee: Amount = { - let output = make_decommission_stake_pool_output( + let network_fee = self.calculate_tx_fee( + current_fee_rate, + [make_decommission_stake_pool_output( self.chain_config.as_ref(), output_destination.clone(), pool_balance, best_block_height, - )?; - let outputs = vec![output]; - - current_fee_rate - .compute_fee( - tx_size_with_num_inputs_and_outputs(outputs.len(), 1)? - + outputs_encoded_size(outputs.as_slice()) - + input_signature_size_from_destination( - &pool_data.decommission_key, - Some(self), - )? - + serialization::Encode::encoded_size(&tx_input), - ) - .map_err(|_| UtxoSelectorError::AmountArithmeticError)? - .into() - }; + )?] + .iter(), + [(&tx_input, Some((&pool_data.decommission_key, None)))].into_iter(), + )?; let output = make_decommission_stake_pool_output( self.chain_config.as_ref(), output_destination, - (pool_balance - network_fee) - .ok_or(WalletError::NotEnoughUtxo(network_fee, pool_balance))?, + (pool_balance - network_fee).ok_or(WalletError::InsufficientUtxoAmount( + network_fee, + pool_balance, + ))?, best_block_height, )?; let input_utxo = self .output_cache .get_txo(&pool_data.utxo_outpoint) - .ok_or(WalletError::NoUtxos)?; + .ok_or(WalletError::UtxoMissing(pool_data.utxo_outpoint.clone()))?; let mut req = SendRequest::new() .with_inputs([(tx_input, input_utxo.clone(), None)], &|id| { @@ -797,6 +821,7 @@ impl Account { + outputs_encoded_size(outputs.as_slice()) + input_signature_size_from_destination( &delegation_data.destination, + None, Some(self), )?, ) @@ -1140,6 +1165,136 @@ impl Account { ) } + pub fn create_spend_utxo_tx( + &mut self, + db_tx: &mut impl WalletStorageWriteLocked, + utxo_outpoint: UtxoOutPoint, + output_address: Destination, + htlc_secret: Option, + median_time: BlockTimestamp, + fee_rate: CurrentFeeRate, + ) -> WalletResult { + let utxo = self + .output_cache + .get_txo(&utxo_outpoint) + .ok_or_else(|| WalletError::UtxoMissing(utxo_outpoint.clone()))? + .clone(); + + let (utxo_output_value, utxo_destination, htlc_secret_hash) = match &utxo { + TxOutput::Transfer(output_value, dest) + | TxOutput::LockThenTransfer(output_value, dest, _) => { + (output_value.clone(), dest, None) + } + | TxOutput::Htlc(output_value, htlc) => { + let dest = match &htlc_secret { + Some(_) => &htlc.spend_key, + None => &htlc.refund_key, + }; + (output_value.clone(), dest, Some(&htlc.secret_hash)) + } + TxOutput::IssueNft(nft_id, _, dest) => ( + OutputValue::TokenV1(*nft_id, Amount::from_atoms(1)), + dest, + None, + ), + TxOutput::Burn(_) + | TxOutput::CreateStakePool(_, _) + | TxOutput::ProduceBlockFromStake(_, _) + | TxOutput::CreateDelegationId(_, _) + | TxOutput::DelegateStaking(_, _) + | TxOutput::IssueFungibleToken(_) + | TxOutput::DataDeposit(_) + | TxOutput::CreateOrder(_) => { + return Err(WalletError::UnsupportedUtxoType((&utxo).into())); + } + }; + + // Check that the htlc secret is consistent with the utxo and determine the spend tag. + let htlc_spend_tag = match (htlc_secret.as_ref(), htlc_secret_hash) { + (None, None) => { + // Non-htlc utxo, no secret. + None + } + (None, Some(_)) => { + // Htlc utxo, no secret => we're refunding. + Some(AuthorizedHashedTimelockContractSpendTag::Refund) + } + (Some(_), None) => { + // Non-htlc utxo, but a secret was provided + return Err(WalletError::HtlcSecretProvidedForNonHtlcUtxo); + } + (Some(secret), Some(secret_hash)) => { + // Got htlc utxo and the secret => we're spending. + // Check that the hashes match. + let actual_secret_hash = secret.hash(); + ensure!( + &actual_secret_hash == secret_hash, + WalletError::HtlcSecretDoesntMatchHash { + actual: actual_secret_hash, + expected: *secret_hash + } + ); + + Some(AuthorizedHashedTimelockContractSpendTag::Spend) + } + }; + + // Check that the destination belongs to this account, to avoid a fairly cryptic error message + // "Failed to convert partially signed tx to signed". + ensure!( + self.is_destination_mine(utxo_destination), + WalletError::NoKeysToSignTx + ); + + let tx_input = TxInput::Utxo(utxo_outpoint); + + // When dealing with coin utxo we calculate the fee manually instead of using + // `select_inputs_for_send_request`, to avoid selecting redundant extra utxos for the fee. + if let Some(coin_amount) = utxo_output_value.coin_amount() { + let fee = self.calculate_tx_fee( + fee_rate.current_fee_rate, + [TxOutput::Transfer(OutputValue::Coin(coin_amount), output_address.clone())].iter(), + [(&tx_input, Some((utxo_destination, htlc_spend_tag)))].into_iter(), + )?; + + let transfer_amount = + (coin_amount - fee).ok_or(WalletError::InsufficientUtxoAmount(fee, coin_amount))?; + + let output = TxOutput::Transfer(OutputValue::Coin(transfer_amount), output_address); + + let mut request = SendRequest::new().with_outputs([output]).with_all_inputs_data([( + tx_input, + utxo_destination.clone(), + Some(utxo), + htlc_secret, + )]); + + request.add_fee(Currency::Coin, fee)?; + + Ok(request) + } else { + let output = TxOutput::Transfer(utxo_output_value.clone(), output_address); + + let request = SendRequest::new().with_outputs([output]).with_all_inputs_data([( + tx_input, + utxo_destination.clone(), + Some(utxo), + htlc_secret, + )]); + + self.select_inputs_for_send_request( + request, + SelectedInputs::Utxos(vec![]), + None, + BTreeMap::new(), + db_tx, + median_time, + fee_rate, + None, + ) + } + } + pub fn create_issue_nft_tx( &mut self, db_tx: &mut impl WalletStorageWriteLocked, @@ -2495,11 +2650,41 @@ fn group_preselected_inputs( let mut preselected_inputs = BTreeMap::new(); let mut total_input_sizes = 0; let mut total_input_fees = Amount::ZERO; - for (input, destination, utxo) in - izip!(request.inputs(), request.destinations(), request.utxos()) - { + for (input, destination, utxo, htlc_secret) in izip!( + request.inputs(), + request.destinations(), + request.utxos(), + request.htlc_secrets() + ) { + let htlc_spend_tag = if let Some(utxo) = utxo { + match utxo { + TxOutput::Htlc(_, _) => { + if htlc_secret.is_some() { + Some(AuthorizedHashedTimelockContractSpendTag::Spend) + } else { + Some(AuthorizedHashedTimelockContractSpendTag::Refund) + } + } + + TxOutput::Transfer(_, _) + | TxOutput::LockThenTransfer(_, _, _) + | TxOutput::Burn(_) + | TxOutput::CreateStakePool(_, _) + | TxOutput::ProduceBlockFromStake(_, _) + | TxOutput::CreateDelegationId(_, _) + | TxOutput::DelegateStaking(_, _) + | TxOutput::IssueFungibleToken(_) + | TxOutput::IssueNft(_, _, _) + | TxOutput::DataDeposit(_) + | TxOutput::CreateOrder(_) => None, + } + } else { + None + }; + let input_size = serialization::Encode::encoded_size(&input); - let inp_sig_size = input_signature_size_from_destination(destination, dest_info_provider)?; + let inp_sig_size = + input_signature_size_from_destination(destination, htlc_spend_tag, dest_info_provider)?; let total_input_size = input_size + inp_sig_size; let fee = current_fee_rate diff --git a/wallet/src/send_request/mod.rs b/wallet/src/send_request/mod.rs index e684f7021b..814e63e3d6 100644 --- a/wallet/src/send_request/mod.rs +++ b/wallet/src/send_request/mod.rs @@ -255,18 +255,15 @@ impl SendRequest { &self.outputs } + pub fn htlc_secrets(&self) -> &[Option] { + &self.htlc_secrets + } + pub fn with_inputs_and_destinations( - mut self, + self, utxos: impl IntoIterator, ) -> Self { - for (outpoint, destination) in utxos { - self.inputs.push(outpoint); - self.destinations.push(destination); - self.utxos.push(None); - self.htlc_secrets.push(None); - } - - self + self.with_all_inputs_data(utxos.into_iter().map(|(input, dest)| (input, dest, None, None))) } pub fn with_inputs<'a, PoolDataGetter>( @@ -277,26 +274,38 @@ impl SendRequest { where PoolDataGetter: Fn(&PoolId) -> Option<&'a PoolData>, { - for (outpoint, txo, secret) in utxos { - self.inputs.push(outpoint); + for (input, utxo, secret) in utxos { let htlc_spending_condition = match &secret { Some(_) => HtlcSpendingCondition::WithSpend, None => HtlcSpendingCondition::WithRefund, }; - self.destinations.push( - get_tx_output_destination(&txo, &pool_data_getter, htlc_spending_condition) - .ok_or_else(|| { - WalletError::UnsupportedTransactionOutput(Box::new(txo.clone())) - })?, - ); - self.utxos.push(Some(txo)); + let dest = get_tx_output_destination(&utxo, pool_data_getter, htlc_spending_condition) + .ok_or_else(|| WalletError::UnsupportedTransactionOutput(Box::new(utxo.clone())))?; + + self.inputs.push(input); + self.destinations.push(dest); + self.utxos.push(Some(utxo)); self.htlc_secrets.push(secret); } Ok(self) } + pub fn with_all_inputs_data( + mut self, + utxos: impl IntoIterator, Option)>, + ) -> Self { + for (input, destination, utxo, htlc_secret) in utxos { + self.inputs.push(input); + self.destinations.push(destination); + self.utxos.push(utxo); + self.htlc_secrets.push(htlc_secret); + } + + self + } + pub fn add_outputs(&mut self, outputs: impl IntoIterator) { self.outputs.extend(outputs); } diff --git a/wallet/src/signer/utils.rs b/wallet/src/signer/utils.rs index 12d0c4a2d8..40c25d32c0 100644 --- a/wallet/src/signer/utils.rs +++ b/wallet/src/signer/utils.rs @@ -29,6 +29,7 @@ use common::chain::{ Destination, Transaction, TxOutput, }; use crypto::key::{PrivateKey, SigAuxDataProvider}; +use utils::debug_assert_or_log; use crate::signer::{SignerError, SignerResult}; @@ -86,7 +87,10 @@ pub fn produce_uniparty_signature_for_input( ), } } else { - assert!(htlc_secret.is_none()); + debug_assert_or_log!( + htlc_secret.is_none(), + "HTLC secret provided for non-HTLC input" + ); StandardInputSignature::produce_uniparty_signature_for_input( private_key, diff --git a/wallet/src/wallet/mod.rs b/wallet/src/wallet/mod.rs index a19c3db88a..b99edde205 100644 --- a/wallet/src/wallet/mod.rs +++ b/wallet/src/wallet/mod.rs @@ -24,7 +24,7 @@ use common::{ chain::{ block::timestamp::BlockTimestamp, classic_multisig::ClassicMultisigChallenge, - htlc::HashedTimelockContract, + htlc::{HashedTimelockContract, HtlcSecret, HtlcSecretHash}, make_delegation_id, make_order_id, make_token_id, output_value::OutputValue, signature::{ @@ -35,7 +35,7 @@ use common::{ AccountCommand, AccountOutPoint, Block, ChainConfig, Currency, DelegationId, Destination, GenBlock, IdCreationError, OrderAccountCommand, OrderId, OutPointSourceId, PoolId, RpcOrderInfo, SignedTransaction, SignedTransactionIntent, Transaction, - TransactionCreationError, TxInput, TxOutput, UtxoOutPoint, + TransactionCreationError, TxInput, TxOutput, TxOutputTag, UtxoOutPoint, }, primitives::{ id::{hash_encoded, WithId}, @@ -174,8 +174,8 @@ pub enum WalletError { TransactionSig(#[from] DestinationSigError), #[error("Delegation not found with id {0}")] DelegationNotFound(DelegationId), - #[error("Not enough UTXOs amount: {0:?}, required: {1:?}")] - NotEnoughUtxo(Amount, Amount), + #[error("Insufficient UTXO amount: {0:?}, required: {1:?}")] + InsufficientUtxoAmount(Amount, Amount), #[error("Token issuance error: {0}")] TokenIssuance(#[from] TokenIssuanceError), #[error("{0}")] @@ -286,6 +286,24 @@ pub enum WalletError { IdCreationError(#[from] IdCreationError), #[error("Output cache inconsistency error: {0}")] OutputCacheInconsistencyError(#[from] OutputCacheInconsistencyError), + + #[error("This UTXO type is not supported here: {0:?}")] + UnsupportedUtxoType(TxOutputTag), + + #[error("This account doesn't have the keys necessary to sign the transaction")] + NoKeysToSignTx, + + #[error("UTXO missing: {0:?}")] + UtxoMissing(UtxoOutPoint), + + #[error("HTLC secret provided for non-HTLC UTXO")] + HtlcSecretProvidedForNonHtlcUtxo, + + #[error("The provided HTLC secret doesn't match the hash (expected: {expected:x}, actual: {actual:x})")] + HtlcSecretDoesntMatchHash { + expected: HtlcSecretHash, + actual: HtlcSecretHash, + }, } /// Result type used for the wallet @@ -1215,11 +1233,11 @@ where .await } - async fn async_for_account_rw_unlocked_and_check_tx_custom_error( + async fn async_for_account_rw_unlocked_and_check_tx_generic( &mut self, account_index: U31, additional_info: TxAdditionalInfo, - f: impl FnOnce( + create_request: impl FnOnce( &mut Account, &mut StoreTxRwUnlocked, ) -> WalletResult<(SendRequest, AddlData)>, @@ -1230,7 +1248,7 @@ where self.async_for_account_rw_unlocked( account_index, - f, + create_request, async move |request, key_chain, store, chain_config, mut signer| { let (mut request, additional_data) = request?; @@ -1291,12 +1309,15 @@ where &mut self, account_index: U31, additional_info: TxAdditionalInfo, - f: impl FnOnce(&mut Account, &mut StoreTxRwUnlocked) -> WalletResult, + create_request: impl FnOnce( + &mut Account, + &mut StoreTxRwUnlocked, + ) -> WalletResult, ) -> WalletResult { - self.async_for_account_rw_unlocked_and_check_tx_custom_error( + self.async_for_account_rw_unlocked_and_check_tx_generic( account_index, additional_info, - |account, db_tx| Ok((f(account, db_tx)?, ())), + |account, db_tx| Ok((create_request(account, db_tx)?, ())), |err| err, ) .await @@ -1659,10 +1680,11 @@ where consolidate_fee_rate: FeeRate, additional_info: TxAdditionalInfo, ) -> WalletResult { + let request = SendRequest::new().with_outputs(outputs); Ok(self .create_transaction_to_addresses_impl( account_index, - outputs, + request, inputs, change_addresses, current_fee_rate, @@ -1688,10 +1710,11 @@ where consolidate_fee_rate: FeeRate, additional_info: TxAdditionalInfo, ) -> WalletResult<(SignedTxWithFees, SignedTransactionIntent)> { + let request = SendRequest::new().with_outputs(outputs); let (signed_tx, input_destinations) = self .create_transaction_to_addresses_impl( account_index, - outputs, + request, inputs, change_addresses, current_fee_rate, @@ -1727,7 +1750,7 @@ where async fn create_transaction_to_addresses_impl( &mut self, account_index: U31, - outputs: impl IntoIterator, + request: SendRequest, inputs: SelectedInputs, change_addresses: BTreeMap>, current_fee_rate: FeeRate, @@ -1735,9 +1758,8 @@ where additional_data_getter: impl Fn(&SendRequest) -> AddlData, additional_info: TxAdditionalInfo, ) -> WalletResult<(SignedTxWithFees, AddlData)> { - let request = SendRequest::new().with_outputs(outputs); let latest_median_time = self.latest_median_time; - self.async_for_account_rw_unlocked_and_check_tx_custom_error( + self.async_for_account_rw_unlocked_and_check_tx_generic( account_index, additional_info, |account, db_tx| { @@ -2209,7 +2231,7 @@ where ) -> WalletResult { let additional_info = TxAdditionalInfo::new().with_pool_info(pool_id, PoolAdditionalInfo { staker_balance }); - self.async_for_account_rw_unlocked_and_check_tx_custom_error( + self.async_for_account_rw_unlocked_and_check_tx_generic( account_index, additional_info, |account: &mut Account<

::K>, db_tx| { @@ -2443,6 +2465,38 @@ where .await } + #[allow(clippy::too_many_arguments)] + pub async fn create_spend_utxo_tx( + &mut self, + account_index: U31, + utxo_outpoint: UtxoOutPoint, + output_address: Destination, + htlc_secret: Option, + current_fee_rate: FeeRate, + consolidate_fee_rate: FeeRate, + additional_info: TxAdditionalInfo, + ) -> WalletResult { + let latest_median_time = self.latest_median_time; + self.async_for_account_rw_unlocked_and_check_tx( + account_index, + additional_info, + |account, db_tx| { + account.create_spend_utxo_tx( + db_tx, + utxo_outpoint, + output_address, + htlc_secret, + latest_median_time, + CurrentFeeRate { + current_fee_rate, + consolidate_fee_rate, + }, + ) + }, + ) + .await + } + pub async fn sign_raw_transaction( &mut self, account_index: U31, diff --git a/wallet/wallet-cli-commands/Cargo.toml b/wallet/wallet-cli-commands/Cargo.toml index 893880ff94..ac3c7f055e 100644 --- a/wallet/wallet-cli-commands/Cargo.toml +++ b/wallet/wallet-cli-commands/Cargo.toml @@ -30,6 +30,7 @@ wallet-types = { path = "../types" } async-trait.workspace = true bigdecimal.workspace = true +chrono.workspace = true clap = { workspace = true, features = ["derive"] } crossterm.workspace = true derive_more.workspace = true diff --git a/wallet/wallet-cli-commands/src/command_handler/mod.rs b/wallet/wallet-cli-commands/src/command_handler/mod.rs index 09814469c0..dbf5a65d92 100644 --- a/wallet/wallet-cli-commands/src/command_handler/mod.rs +++ b/wallet/wallet-cli-commands/src/command_handler/mod.rs @@ -24,8 +24,8 @@ use chainstate::rpc::RpcOutputValueOut; use common::{ address::{Address, RpcAddress}, chain::{ - config::checkpoints_data::print_block_heights_ids_as_checkpoints_data, tokens::TokenId, - ChainConfig, Destination, RpcCurrency, SignedTransaction, + config::checkpoints_data::print_block_heights_ids_as_checkpoints_data, htlc::HtlcSecret, + tokens::TokenId, ChainConfig, Destination, RpcCurrency, SignedTransaction, }, primitives::{Idable as _, H256}, text_summary::TextSummary, @@ -34,6 +34,7 @@ use crypto::key::hdkd::u31::U31; use logging::log; use mempool::tx_options::TxOptionsOverrides; use node_comm::node_traits::NodeInterface; +use rpc::types::RpcHexString; use serialization::{hex::HexEncode, hex_encoded::HexEncoded}; use utils::{ ensure, @@ -45,9 +46,9 @@ use wallet_controller::types::WalletExtraInfo; use wallet_rpc_client::wallet_rpc_traits::{PartialOrSignedTx, WalletInterface}; use wallet_rpc_lib::types::{ Balances, ComposedTransaction, ControllerConfig, HardwareWalletType, MnemonicInfo, - NewOrderTransaction, NewSubmittedTransaction, NftMetadata, RpcInspectTransaction, - RpcNewTransaction, RpcSignatureStats, RpcSignatureStatus, RpcStandaloneAddressDetails, - RpcValidatedSignatures, TokenMetadata, + NewOrderTransaction, NewSubmittedTransaction, NftMetadata, RpcHashedTimelockContract, + RpcInspectTransaction, RpcNewTransaction, RpcPreparedTransaction, RpcSignatureStats, + RpcSignatureStatus, RpcStandaloneAddressDetails, RpcValidatedSignatures, TokenMetadata, }; use wallet_types::partially_signed_transaction::PartiallySignedTransaction; @@ -150,6 +151,21 @@ where ConsoleCommand::Print(Self::new_tx_command_status_text(new_tx, chain_config)) } + pub fn prepared_tx_command( + prepared_tx: RpcPreparedTransaction, + chain_config: &ChainConfig, + ) -> ConsoleCommand { + let RpcPreparedTransaction { tx_id, tx, fees } = prepared_tx; + let new_tx = RpcNewTransaction { + tx_id, + tx, + fees, + broadcasted: false, + }; + + Self::new_tx_command(new_tx, chain_config) + } + pub fn new_order_tx_command( new_tx: NewOrderTransaction, chain_config: &ChainConfig, @@ -2076,6 +2092,79 @@ where ask_currency, give_currency, } => self.list_all_active_orders(chain_config, ask_currency, give_currency).await, + + WalletCommand::CreateHtlcTx { + currency, + amount, + secret_hash, + spend_address, + refund_timelock, + refund_address, + } => { + let (wallet, selected_account) = wallet_and_selected_acc(&mut self.wallet).await?; + + let token_id = + match currency.to_fully_parsed(chain_config)?.to_rpc_currency(chain_config)? { + RpcCurrency::Coin => Ok(None), + RpcCurrency::Token(token_id) => Ok(Some(token_id.into_string())), + }?; + + let htlc = RpcHashedTimelockContract { + secret_hash: RpcHexString::from_str(&secret_hash)?, + spend_address: RpcAddress::from_string(spend_address), + refund_address: RpcAddress::from_string(refund_address), + refund_timelock: refund_timelock.into(), + }; + + let prepared_tx = wallet + .create_htlc_transaction(selected_account, amount, token_id, htlc, self.config) + .await?; + + Ok(Self::prepared_tx_command(prepared_tx, chain_config)) + } + + WalletCommand::GenerateHtlcSecret => { + let secret = HtlcSecret::new_from_rng(&mut randomness::make_true_rng()); + let secret_hash = secret.hash(); + + Ok(ConsoleCommand::Print(format!( + "New HTLC secret: {}\nand its hash: {:x}", + hex::encode(secret.secret()), + secret_hash + ))) + } + + WalletCommand::CalcHtlcSecretHash { secret } => { + let secret = hex::decode(&secret) + .ok() + .and_then(|bytes| Some(HtlcSecret::new(bytes.try_into().ok()?))) + .ok_or(WalletCliCommandError::InvalidHtlcSecret)?; + let secret_hash = secret.hash(); + + Ok(ConsoleCommand::Print(format!( + "The hash of the provided secret is: {secret_hash:x}", + ))) + } + + WalletCommand::SpendUtxo { + utxo, + address, + htlc_secret, + } => { + let (wallet, selected_account) = wallet_and_selected_acc(&mut self.wallet).await?; + + let new_tx = wallet + .spend_utxo( + selected_account, + utxo.into(), + address, + htlc_secret, + self.config, + ) + .await?; + + Ok(Self::new_tx_command(new_tx, chain_config)) + } } } diff --git a/wallet/wallet-cli-commands/src/errors.rs b/wallet/wallet-cli-commands/src/errors.rs index 752b9e1520..2e22a6f3bf 100644 --- a/wallet/wallet-cli-commands/src/errors.rs +++ b/wallet/wallet-cli-commands/src/errors.rs @@ -77,4 +77,10 @@ pub enum WalletCliCommandError { #[error(transparent)] ParseError(#[from] helper_types::ParseError), + + #[error(transparent)] + HexEncodingError(#[from] hex::FromHexError), + + #[error("Invalid HTLC secret")] + InvalidHtlcSecret, } diff --git a/wallet/wallet-cli-commands/src/helper_types.rs b/wallet/wallet-cli-commands/src/helper_types.rs index 0947bfa42d..27c6588e34 100644 --- a/wallet/wallet-cli-commands/src/helper_types.rs +++ b/wallet/wallet-cli-commands/src/helper_types.rs @@ -23,6 +23,8 @@ use chainstate::rpc::RpcOutputValueOut; use common::{ address::{decode_address, Address, AddressError, RpcAddress}, chain::{ + block::timestamp::BlockTimestamp, + timelock::OutputTimeLock, tokens::{RPCTokenInfo, TokenId}, ChainConfig, Currency, Destination, OrderId, OutPointSourceId, TxOutput, UtxoOutPoint, }, @@ -30,7 +32,8 @@ use common::{ amount::decimal::{ subtract_decimal_amounts_of_same_currency, DecimalAmountWithIsSameComparison, }, - DecimalAmount, Id, H256, + time::Time, + BlockHeight, DecimalAmount, Id, H256, }, }; use utils::ensure; @@ -589,6 +592,67 @@ impl CliTokenTotalSupply { } } +/// An OutputTimeLock that can be parsed from strings of the form "block_count(num)", "seconds(num)", +/// "until_height(num)" and "until_time(RFC 3339 datetime)". +#[derive(Debug, Clone)] +pub enum CliOutputTimeLock { + UntilHeight(BlockHeight), + UntilTime(BlockTimestamp), + ForBlockCount(u64), + ForSeconds(u64), +} + +impl FromStr for CliOutputTimeLock { + type Err = ParseError; + + fn from_str(input: &str) -> Result { + let (lock_type, mut args) = + parse_funclike_expr(input).ok_or(ParseError::InvalidInputFormat)?; + + let arg = match (args.next(), args.next()) { + (Some(arg), None) => arg, + (_, _) => { + return Err(ParseError::InvalidInputFormat); + } + }; + + let parse_u64 = |s: &str| -> Result { + s.parse().map_err(|_| ParseError::InvalidNumber(s.to_owned())) + }; + + let result = if lock_type.eq_ignore_ascii_case("block_count") { + Self::ForBlockCount(parse_u64(arg)?) + } else if lock_type.eq_ignore_ascii_case("seconds") { + Self::ForSeconds(parse_u64(arg)?) + } else if lock_type.eq_ignore_ascii_case("until_height") { + Self::UntilHeight(parse_u64(arg)?.into()) + } else if lock_type.eq_ignore_ascii_case("until_time") { + let date_time = arg + .parse::>() + .map_err(|_| ParseError::InvalidTime(arg.to_owned()))?; + let time = + Time::from_absolute_time(&date_time).ok_or(ParseError::BadTime(date_time))?; + + Self::UntilTime(BlockTimestamp::from_time(time)) + } else { + return Err(ParseError::UnknownOutputTimeLockType(lock_type.to_owned())); + }; + + Ok(result) + } +} + +impl From for OutputTimeLock { + fn from(value: CliOutputTimeLock) -> Self { + match value { + CliOutputTimeLock::UntilHeight(val) => OutputTimeLock::UntilHeight(val), + CliOutputTimeLock::UntilTime(val) => OutputTimeLock::UntilTime(val), + CliOutputTimeLock::ForBlockCount(val) => OutputTimeLock::ForBlockCount(val), + CliOutputTimeLock::ForSeconds(val) => OutputTimeLock::ForSeconds(val), + } + } +} + /// Parse a decimal amount pub fn parse_decimal_amount(input: &str) -> Result { DecimalAmount::from_str(input).map_err(|_| ParseError::InvalidDecimalAmount(input.to_owned())) @@ -715,6 +779,9 @@ pub enum ParseError { #[error("Invalid decimal amount: {0}")] InvalidDecimalAmount(String), + #[error("Invalid number: {0}")] + InvalidNumber(String), + #[error("Invalid destination: {0}")] InvalidDestination(String), @@ -727,6 +794,15 @@ pub enum ParseError { #[error("Unknown token supply type: {0}")] UnknownTokenSupplyType(String), + #[error("Unknown output timelock type: {0}")] + UnknownOutputTimeLockType(String), + + #[error("Invalid time: {0}")] + InvalidTime(String), + + #[error("Bad time: {0}")] + BadTime(chrono::DateTime), + #[error("Invalid currency: {0}")] InvalidCurrency(String), @@ -744,6 +820,8 @@ pub enum ParseError { #[cfg(test)] mod tests { + use std::time::Duration; + use rstest::rstest; use common::{ @@ -1120,6 +1198,141 @@ mod tests { } } + #[rstest] + #[trace] + #[case(Seed::from_entropy())] + fn test_parse_output_timelock(#[case] seed: Seed) { + use chrono::prelude::*; + + let mut rng = make_seedable_rng(seed); + + for _ in 0..10 { + let u64_val = rng.gen::(); + + for tag in ["block_count", "Block_count", "blocK_Count"] { + let parsed_timelock: OutputTimeLock = + CliOutputTimeLock::from_str(&format!("{tag}({u64_val})")).unwrap().into(); + assert_eq!(parsed_timelock, OutputTimeLock::ForBlockCount(u64_val)); + } + + for tag in ["seconds", "Seconds", "secONds"] { + let parsed_timelock: OutputTimeLock = + CliOutputTimeLock::from_str(&format!("{tag}({u64_val})")).unwrap().into(); + assert_eq!(parsed_timelock, OutputTimeLock::ForSeconds(u64_val)); + } + + for tag in ["until_height", "Until_height", "until_HEight"] { + let parsed_timelock: OutputTimeLock = + CliOutputTimeLock::from_str(&format!("{tag}({u64_val})")).unwrap().into(); + assert_eq!(parsed_timelock, OutputTimeLock::UntilHeight(u64_val.into())); + } + + let year = rng.gen_range(1970..=3000); + let month = rng.gen_range(1..=12); + let days_in_month = + NaiveDate::from_ymd_opt(year, month, 1).unwrap().num_days_in_month(); + let day = rng.gen_range(1..=days_in_month); + let hour = rng.gen_range(0..24); + let min = rng.gen_range(0..60); + let sec = rng.gen_range(0..60); + let expected_time = NaiveDateTime::new( + NaiveDate::from_ymd_opt(year, month, day.into()).unwrap(), + NaiveTime::from_hms_opt(hour, min, sec).unwrap(), + ); + let expected_time_utc = DateTime::from_naive_utc_and_offset(expected_time, Utc); + let tz_hours = rng.gen_range(0..24); + let tz_mins = rng.gen_range(0..60); + let tz_positive = rng.gen_bool(0.5); + let expected_time_with_tz = { + let offset = Duration::from_secs(tz_hours * 3600 + tz_mins * 60); + if tz_positive { + expected_time_utc - offset + } else { + expected_time_utc + offset + } + }; + let datetime_base_str = format!("{year}-{month}-{day}T{hour}:{min}:{sec}"); + let datetime_str_utc = format!("{datetime_base_str}Z"); + let tz_plus_minus = if tz_positive { "+" } else { "-" }; + let tz_str = format!("{tz_plus_minus}{tz_hours:02}:{tz_mins:02}"); + + for tag in ["until_time", "Until_time", "untIL_time"] { + let parsed_timelock: OutputTimeLock = + CliOutputTimeLock::from_str(&format!("{tag}({datetime_str_utc})")) + .unwrap() + .into(); + + assert_eq!( + parsed_timelock, + OutputTimeLock::UntilTime(BlockTimestamp::from_time( + Time::from_absolute_time(&expected_time_utc).unwrap() + )) + ); + + let parsed_timelock: OutputTimeLock = + CliOutputTimeLock::from_str(&format!("{tag}({datetime_base_str}{tz_str})")) + .unwrap() + .into(); + + assert_eq!( + parsed_timelock, + OutputTimeLock::UntilTime(BlockTimestamp::from_time( + Time::from_absolute_time(&expected_time_with_tz).unwrap() + )) + ); + } + + let sec_frac = rng.gen_range(1..1000); + let err = CliOutputTimeLock::from_str(&format!( + "until_time({datetime_base_str}.{sec_frac}Z)" + )) + .unwrap_err(); + assert_matches!(err, ParseError::BadTime(_)); + + let err = CliOutputTimeLock::from_str("foo").unwrap_err(); + assert_eq!(err, ParseError::InvalidInputFormat); + + let err = CliOutputTimeLock::from_str(&format!("foo({u64_val})")).unwrap_err(); + assert_eq!(err, ParseError::UnknownOutputTimeLockType("foo".to_owned())); + + for (valid_tag, is_date) in [ + ("block_count", false), + ("seconds", false), + ("until_height", false), + ("until_time", true), + ] { + let arg = if is_date { + datetime_str_utc.clone() + } else { + u64_val.to_string() + }; + + // Sanity check + CliOutputTimeLock::from_str(&format!("{valid_tag}({arg})")).unwrap(); + + let err = CliOutputTimeLock::from_str(valid_tag).unwrap_err(); + assert_eq!(err, ParseError::InvalidInputFormat); + + let err = CliOutputTimeLock::from_str(&format!("{valid_tag}()")).unwrap_err(); + if is_date { + assert_eq!(err, ParseError::InvalidTime("".to_owned())); + } else { + assert_eq!(err, ParseError::InvalidNumber("".to_owned())); + }; + + let err = + CliOutputTimeLock::from_str(&format!("{valid_tag}({arg},{arg})")).unwrap_err(); + assert_eq!(err, ParseError::InvalidInputFormat); + + let err = CliOutputTimeLock::from_str(&format!("{valid_tag}({arg}")).unwrap_err(); + assert_eq!(err, ParseError::InvalidInputFormat); + + let err = CliOutputTimeLock::from_str(&format!("{valid_tag} {arg})")).unwrap_err(); + assert_eq!(err, ParseError::InvalidInputFormat); + } + } + } + #[test] fn test_parse_currency() { let chain_config = chain::config::create_unit_test_config(); diff --git a/wallet/wallet-cli-commands/src/lib.rs b/wallet/wallet-cli-commands/src/lib.rs index d1f4386d98..208f09bd71 100644 --- a/wallet/wallet-cli-commands/src/lib.rs +++ b/wallet/wallet-cli-commands/src/lib.rs @@ -17,21 +17,13 @@ mod command_handler; mod errors; mod helper_types; -pub use command_handler::CommandHandler; -use dyn_clone::DynClone; -pub use errors::WalletCliCommandError; -use helper_types::CliYesNo; -use regex::Regex; -use rpc::description::{Described, Module}; -use wallet_controller::types::WalletTypeArgs; -use wallet_rpc_lib::{ - types::{FoundDevice, NodeInterface}, - ColdWalletRpcDescription, WalletRpcDescription, -}; - use std::{collections::BTreeMap, fmt::Debug, num::NonZeroUsize, path::PathBuf, time::Duration}; use clap::{Command, FromArgMatches, Parser, Subcommand}; +use dyn_clone::DynClone; +use regex::Regex; + +use helper_types::CliYesNo; use common::{ chain::{Block, SignedTransaction, Transaction}, @@ -39,11 +31,18 @@ use common::{ }; use crypto::key::{hdkd::u31::U31, PrivateKey, PublicKey}; use p2p_types::{bannable_address::BannableAddress, PeerId}; +use rpc::description::{Described, Module}; use serialization::hex_encoded::HexEncoded; use utils_networking::IpOrSocketAddress; +use wallet_controller::types::WalletTypeArgs; +use wallet_rpc_lib::{ + types::{FoundDevice, NodeInterface}, + ColdWalletRpcDescription, WalletRpcDescription, +}; use crate::helper_types::{ - CliCurrency, CliTokenTotalSupply, CliUnspecifiedCurrencyTransfer, CliUtxoOutPoint, + CliCurrency, CliOutputTimeLock, CliTokenTotalSupply, CliUnspecifiedCurrencyTransfer, + CliUtxoOutPoint, }; use self::helper_types::{ @@ -51,6 +50,9 @@ use self::helper_types::{ CliStoreSeedPhrase, CliTokenTransfer, CliUtxoState, CliUtxoTypes, CliWithLocked, }; +pub use command_handler::CommandHandler; +pub use errors::WalletCliCommandError; + #[derive(Debug, Subcommand, Clone)] pub enum CreateWalletSubCommand { /// Create a software wallet @@ -1216,6 +1218,60 @@ pub enum WalletCommand { #[arg(long = "give-currency")] give_currency: Option, }, + + /// Create a transaction with an HTLC (Hashed TimeLock Contract) output without broadcasting it. + #[clap(name = "htlc-create-transaction")] + CreateHtlcTx { + /// The currency to lock inside the HTLC - a token id or "coin" for coins. + currency: CliCurrency, + + /// The amount to lock inside the HTLC. + /// + /// If the currency is an NFT, specify 1 here. + amount: DecimalAmount, + + /// Hex-encoded hash of the HTLC secret. + secret_hash: String, + + /// The address (key) that can spend the HTLC providing the secret. + spend_address: String, + + /// The refund timelock. + /// + /// The format is one of: block_count(num), seconds(num), until_height(num) and until_time(RFC 3339 datetime), + /// e.g. block_count(720), seconds(86400), until_height(500000), until_time(2026-02-26T12:30:00Z). + refund_timelock: CliOutputTimeLock, + + /// The address (key) that can refund the HTLC after the refund timelock expires. + refund_address: String, + }, + + /// Generate a new HTLC secret and print it to the console. + #[clap(name = "htlc-generate-secret")] + GenerateHtlcSecret, + + /// Calculate the hash of the provided HTLC secret. + #[clap(name = "htlc-calc-secret-hash")] + CalcHtlcSecretHash { secret: String }, + + /// Spend the specified utxo, moving the corresponding funds (coins or tokens) to the specified + /// address. + #[clap(name = "utxo-spend")] + SpendUtxo { + /// The utxo to spend, e.g. tx(000000000000000000059fa50103b9683e51e5aba83b8a34c9b98ce67d66136c,1). + utxo: CliUtxoOutPoint, + + /// The destination address that will receive the funds from the spent utxo. + address: String, + + /// (For HTLC utxos only) optional hex-encoded HTLC secret. + /// + /// Specifying the HTLC secret for an HTLC utxo means that you are spending the HTLC, + /// in which case HTLC's spend address must be owned by the current account. + /// Omitting the secret means that you are refunding the HTLC, in which case HTLC's + /// refund address must be owned by the current account. + htlc_secret: Option, + }, } #[derive(Debug, Parser)] diff --git a/wallet/wallet-controller/src/helpers/mod.rs b/wallet/wallet-controller/src/helpers/mod.rs index 7c95cc3031..57bf710d5c 100644 --- a/wallet/wallet-controller/src/helpers/mod.rs +++ b/wallet/wallet-controller/src/helpers/mod.rs @@ -46,7 +46,7 @@ use wallet_types::partially_signed_transaction::{ use crate::{runtime_wallet::RuntimeWallet, types::Balances, ControllerError}; -pub async fn fetch_token_info( +pub async fn fetch_rpc_token_info( rpc_client: &T, token_id: TokenId, ) -> Result> { @@ -65,7 +65,7 @@ pub async fn fetch_token_infos_into( dest_info: &mut TokensAdditionalInfo, ) -> Result<(), ControllerError> { for token_id in token_ids { - let token_info = fetch_token_info(rpc_client, *token_id).await?; + let token_info = fetch_rpc_token_info(rpc_client, *token_id).await?; dest_info.add_info( *token_id, @@ -234,7 +234,7 @@ pub async fn into_balances( Currency::Token(token_id) => token_id, }; - fetch_token_info(rpc_client, token_id).await.map(|info| { + fetch_rpc_token_info(rpc_client, token_id).await.map(|info| { let decimals = info.token_number_of_decimals(); let amount = RpcAmountOut::from_amount_no_padding(amount, decimals); let token_id = RpcAddress::new(chain_config, token_id).expect("addressable"); diff --git a/wallet/wallet-controller/src/lib.rs b/wallet/wallet-controller/src/lib.rs index 277d5f2729..246491ffe5 100644 --- a/wallet/wallet-controller/src/lib.rs +++ b/wallet/wallet-controller/src/lib.rs @@ -35,7 +35,7 @@ use chainstate::tx_verifier::{ use futures::StreamExt; use futures::{never::Never, stream::FuturesOrdered, TryStreamExt}; use helpers::{ - fetch_input_infos, fetch_token_info, fetch_utxo, fetch_utxo_extra_info, into_balances, + fetch_input_infos, fetch_rpc_token_info, fetch_utxo, fetch_utxo_extra_info, into_balances, }; use itertools::Itertools as _; use node_comm::rpc_client::ColdWalletClient; @@ -134,54 +134,79 @@ use crate::types::WalletExtraInfo; pub enum ControllerError { #[error("Node call error: {0}")] NodeCallError(N::Error), + #[error("Wallet sync error: {0}")] SyncError(String), + #[error("Synchronization is paused until the node has {0} blocks ({1} blocks currently)")] NotEnoughBlockHeight(BlockHeight, BlockHeight), + #[error("Wallet file {0} error: {1}")] WalletFileError(PathBuf, String), + #[error("Wallet error: {0}")] WalletError(#[from] wallet::wallet::WalletError), + #[error("Encoding error: {0}")] AddressEncodingError(#[from] AddressError), + #[error("No staking pool found")] NoStakingPool, + #[error("Token with Id {0} is frozen")] FrozenToken(TokenId), + #[error("Wallet is locked")] WalletIsLocked, + #[error("Cannot lock wallet because staking is running")] StakingRunning, + #[error("End-to-end encryption error: {0}")] EndToEndEncryptionError(#[from] crypto::ephemeral_e2e::error::Error), + #[error("The node is not in sync yet")] NodeNotInSyncYet, + #[error("Lookahead size cannot be 0")] InvalidLookaheadSize, + #[error("Wallet file already open")] WalletFileAlreadyOpen, + #[error("Please open or create wallet file first")] NoWallet, + #[error("Search for timestamps failed: {0}")] SearchForTimestampsFailed(BlockProductionError), + #[error("Expecting non-empty inputs")] ExpectingNonEmptyInputs, + #[error("Expecting non-empty outputs")] ExpectingNonEmptyOutputs, + #[error("No coin UTXOs to pay fee from")] NoCoinUtxosToPayFeeFrom, + #[error("Invalid tx output: {0}")] InvalidTxOutput(GenericCurrencyTransferToTxOutputConversionError), + #[error("The specified token {0} is not a fungible token")] NotFungibleToken(TokenId), + #[error("Invalid coin amount")] InvalidCoinAmount, + #[error("Partially signed transaction error: {0}")] PartiallySignedTransactionError(#[from] PartiallySignedTransactionError), + #[error("Invalid token ID")] InvalidTokenId, + #[error("Error creating sighash input commitment")] SighashInputCommitmentCreationError(#[from] SighashInputCommitmentCreationError), + #[error("The number of htlc secrets does not match the number of inputs")] InvalidHtlcSecretsCount, } @@ -673,7 +698,7 @@ where &self, token_id: TokenId, ) -> Result> { - fetch_token_info(&self.rpc_client, token_id).await + fetch_rpc_token_info(&self.rpc_client, token_id).await } pub async fn generate_block_by_pool( @@ -1320,12 +1345,13 @@ where let mut fees = BTreeMap::new(); for (currency, output) in outputs { - let input_amount = inputs.remove(¤cy).ok_or( - ControllerError::::WalletError(WalletError::NotEnoughUtxo(Amount::ZERO, output)), - )?; + let input_amount = + inputs.remove(¤cy).ok_or(ControllerError::::WalletError( + WalletError::InsufficientUtxoAmount(Amount::ZERO, output), + ))?; let fee = (input_amount - output).ok_or(ControllerError::::WalletError( - WalletError::NotEnoughUtxo(input_amount, output), + WalletError::InsufficientUtxoAmount(input_amount, output), ))?; if fee != Amount::ZERO { fees.insert(currency, fee); diff --git a/wallet/wallet-controller/src/runtime_wallet.rs b/wallet/wallet-controller/src/runtime_wallet.rs index 04de3d71ee..25633d1e24 100644 --- a/wallet/wallet-controller/src/runtime_wallet.rs +++ b/wallet/wallet-controller/src/runtime_wallet.rs @@ -19,7 +19,7 @@ use common::{ address::{pubkeyhash::PublicKeyHash, Address}, chain::{ classic_multisig::ClassicMultisigChallenge, - htlc::HashedTimelockContract, + htlc::{HashedTimelockContract, HtlcSecret}, output_value::OutputValue, signature::inputsig::arbitrary_message::ArbitraryMessageSignature, tokens::{IsTokenUnfreezable, Metadata, RPCFungibleTokenInfo, TokenId, TokenIssuance}, @@ -1841,6 +1841,59 @@ where Ok(orders) } + #[allow(clippy::too_many_arguments)] + pub async fn create_spend_utxo_tx( + &mut self, + account_index: U31, + utxo_outpoint: UtxoOutPoint, + output_address: Destination, + htlc_secret: Option, + current_fee_rate: FeeRate, + consolidate_fee_rate: FeeRate, + additional_info: TxAdditionalInfo, + ) -> WalletResult { + match self { + RuntimeWallet::Software(w) => { + w.create_spend_utxo_tx( + account_index, + utxo_outpoint, + output_address, + htlc_secret, + current_fee_rate, + consolidate_fee_rate, + additional_info, + ) + .await + } + #[cfg(feature = "trezor")] + RuntimeWallet::Trezor(w) => { + w.create_spend_utxo_tx( + account_index, + utxo_outpoint, + output_address, + htlc_secret, + current_fee_rate, + consolidate_fee_rate, + additional_info, + ) + .await + } + #[cfg(feature = "ledger")] + RuntimeWallet::Ledger(w) => { + w.create_spend_utxo_tx( + account_index, + utxo_outpoint, + output_address, + htlc_secret, + current_fee_rate, + consolidate_fee_rate, + additional_info, + ) + .await + } + } + } + pub async fn sign_raw_transaction( &mut self, account_index: U31, diff --git a/wallet/wallet-controller/src/synced_controller.rs b/wallet/wallet-controller/src/synced_controller.rs index 074e702deb..56dbc5543c 100644 --- a/wallet/wallet-controller/src/synced_controller.rs +++ b/wallet/wallet-controller/src/synced_controller.rs @@ -22,8 +22,9 @@ use common::{ address::{pubkeyhash::PublicKeyHash, Address}, chain::{ classic_multisig::ClassicMultisigChallenge, - htlc::HashedTimelockContract, + htlc::{HashedTimelockContract, HtlcSecret}, output_value::OutputValue, + output_values_holder::collect_token_v1_ids_from_output_values_holder, signature::inputsig::arbitrary_message::ArbitraryMessageSignature, tokens::{ get_referenced_token_ids_ignore_issuance, IsTokenFreezable, IsTokenUnfreezable, @@ -70,8 +71,8 @@ use wallet_types::{ use crate::{ helpers::{ - fetch_order_info, fetch_token_info, fetch_token_infos, fetch_token_infos_into, fetch_utxo, - get_referenced_token_ids_from_partially_signed_transaction, into_balances, + fetch_order_info, fetch_rpc_token_info, fetch_token_infos, fetch_token_infos_into, + fetch_utxo, get_referenced_token_ids_from_partially_signed_transaction, into_balances, tx_to_partially_signed_tx, }, runtime_wallet::RuntimeWallet, @@ -117,13 +118,13 @@ where } } - async fn fetch_token_infos( + async fn fetch_rpc_token_infos( &self, tokens: impl IntoIterator, ) -> Result, ControllerError> { let tasks: FuturesUnordered<_> = tokens .into_iter() - .map(|token_id| fetch_token_info(&self.rpc_client, token_id)) + .map(|token_id| fetch_rpc_token_info(&self.rpc_client, token_id)) .collect(); tasks.try_collect().await } @@ -138,7 +139,7 @@ where .find_used_tokens(self.account_index, input_utxos) .map_err(ControllerError::WalletError)?; - for token_info in self.fetch_token_infos(token_ids).await? { + for token_info in self.fetch_rpc_token_infos(token_ids).await? { match token_info { RPCTokenInfo::FungibleToken(token_info) => { self.check_fungible_token_is_usable(token_info)? @@ -174,7 +175,7 @@ where if token_ids.is_empty() { result.push(utxo); } else { - let token_infos = self.fetch_token_infos(token_ids).await?; + let token_infos = self.fetch_rpc_token_infos(token_ids).await?; let ok_to_use = token_infos.iter().try_fold( true, |all_ok, token_info| -> Result> { @@ -569,8 +570,8 @@ where .await } - /// Create a transaction that transfers coins to the destination address and specified amount - /// and broadcast it to the mempool. + /// Create a transaction that transfers the specified amount of coins to the destination address + /// and broadcasts it to the mempool. /// If the selected_utxos are not empty it will try to select inputs from those for the /// transaction, else it will use available ones from the wallet. pub async fn send_to_address( @@ -788,7 +789,7 @@ where let mut result = Vec::new(); for (token_id, outputs_vec) in outputs { - let token_info = fetch_token_info(&self.rpc_client, token_id).await?; + let token_info = fetch_rpc_token_info(&self.rpc_client, token_id).await?; match &token_info { RPCTokenInfo::FungibleToken(token_info) => { @@ -1362,6 +1363,52 @@ where .await } + pub async fn spend_utxo( + &mut self, + utxo_outpoint: UtxoOutPoint, + output_address: Destination, + htlc_secret: Option, + ) -> Result> { + let utxo = fetch_utxo(&self.rpc_client, self.wallet, &utxo_outpoint).await?; + let mut tx_additional_info = TxAdditionalInfo::new(); + + let token_ids = collect_token_v1_ids_from_output_values_holder(&utxo); + let token_infos = self.fetch_rpc_token_infos(token_ids).await?; + + for token_info in token_infos { + tx_additional_info = tx_additional_info.with_token_info( + token_info.token_id(), + TokenAdditionalInfo { + num_decimals: token_info.token_number_of_decimals(), + ticker: token_info.token_ticker().to_vec(), + }, + ); + + let unconfirmed_token_info = self.unconfirmed_token_info(token_info)?; + unconfirmed_token_info.check_can_be_used()?; + } + + self.create_and_send_tx( + async move |current_fee_rate: FeeRate, + consolidate_fee_rate: FeeRate, + wallet: &mut RuntimeWallet, + account_index: U31| { + wallet + .create_spend_utxo_tx( + account_index, + utxo_outpoint, + output_address, + htlc_secret, + current_fee_rate, + consolidate_fee_rate, + tx_additional_info, + ) + .await + }, + ) + .await + } + async fn convert_rpc_amount_in( &self, amount: RpcAmountIn, @@ -1370,7 +1417,7 @@ where ) -> Result> { let output_value = match token_id { Some(token_id) => { - let token_info = fetch_token_info(&self.rpc_client, token_id).await?; + let token_info = fetch_rpc_token_info(&self.rpc_client, token_id).await?; let amount = amount .to_amount(token_info.token_number_of_decimals()) .ok_or(ControllerError::InvalidCoinAmount)?; @@ -1582,7 +1629,7 @@ where &UnconfirmedTokenInfo, ) -> WalletResult, { - let unconfirmed_token_info = self.unconfirmed_token_info(token_info)?; + let token_unconfirmed_info = self.unconfirmed_token_info(token_info)?; let (current_fee_rate, consolidate_fee_rate) = self.get_current_and_consolidation_fee_rate().await?; @@ -1592,7 +1639,7 @@ where consolidate_fee_rate, self.wallet, self.account_index, - &unconfirmed_token_info, + &token_unconfirmed_info, ) .await .map_err(ControllerError::WalletError)?; @@ -1631,7 +1678,7 @@ where &mut self, token_info: RPCTokenInfo, ) -> Result> { - let unconfirmed_token_info = match token_info { + let token_unconfirmed_info = match token_info { RPCTokenInfo::FungibleToken(token_info) => { self.wallet.get_token_unconfirmed_info(self.account_index, token_info)? } @@ -1639,7 +1686,7 @@ where UnconfirmedTokenInfo::NonFungibleToken(info.token_id, info.as_ref().into()) } }; - Ok(unconfirmed_token_info) + Ok(token_unconfirmed_info) } /// Similar to create_and_send_tx but some transactions also create an ID diff --git a/wallet/wallet-rpc-client/src/handles_client/mod.rs b/wallet/wallet-rpc-client/src/handles_client/mod.rs index 23e5ee8767..41f6db7e65 100644 --- a/wallet/wallet-rpc-client/src/handles_client/mod.rs +++ b/wallet/wallet-rpc-client/src/handles_client/mod.rs @@ -82,6 +82,9 @@ pub enum WalletRpcHandlesClientError { #[error(transparent)] ChainstateRpcTypeError(#[from] chainstate::rpc::RpcTypeError), + + #[error("Invalid HTLC secret")] + InvalidHtlcSecret, } impl WalletRpcHandlesClient @@ -1229,6 +1232,30 @@ where .map_err(WalletRpcHandlesClientError::WalletRpcError) } + async fn spend_utxo( + &self, + account_index: U31, + utxo: UtxoOutPoint, + output_address: String, + htlc_secret: Option, + config: ControllerConfig, + ) -> Result { + self.wallet_rpc + .spend_utxo( + account_index, + utxo.into(), + output_address.into(), + htlc_secret + .as_deref() + .map(RpcHexString::from_str) + .transpose() + .map_err(|_| WalletRpcHandlesClientError::InvalidHtlcSecret)?, + config, + ) + .await + .map_err(WalletRpcHandlesClientError::WalletRpcError) + } + async fn node_version(&self) -> Result { self.wallet_rpc .node_version() diff --git a/wallet/wallet-rpc-client/src/rpc_client/client_impl.rs b/wallet/wallet-rpc-client/src/rpc_client/client_impl.rs index 71de1fa14f..2ca842d22f 100644 --- a/wallet/wallet-rpc-client/src/rpc_client/client_impl.rs +++ b/wallet/wallet-rpc-client/src/rpc_client/client_impl.rs @@ -1147,6 +1147,31 @@ impl WalletInterface for ClientWalletRpc { .map_err(WalletRpcError::ResponseError) } + async fn spend_utxo( + &self, + account_index: U31, + utxo: UtxoOutPoint, + output_address: String, + htlc_secret: Option, + config: ControllerConfig, + ) -> Result { + let options = TransactionOptions::from_controller_config(&config); + WalletRpcClient::spend_utxo( + &self.http_client, + account_index.into(), + utxo.into(), + output_address.into(), + htlc_secret + .as_deref() + .map(RpcHexString::from_str) + .transpose() + .map_err(|_| WalletRpcError::InvalidHtlcSecret)?, + options, + ) + .await + .map_err(WalletRpcError::ResponseError) + } + async fn node_version(&self) -> Result { WalletRpcClient::node_version(&self.http_client) .await diff --git a/wallet/wallet-rpc-client/src/rpc_client/mod.rs b/wallet/wallet-rpc-client/src/rpc_client/mod.rs index 79b502260a..47ad432c78 100644 --- a/wallet/wallet-rpc-client/src/rpc_client/mod.rs +++ b/wallet/wallet-rpc-client/src/rpc_client/mod.rs @@ -26,12 +26,18 @@ use crate::wallet_rpc_traits::WalletInterface; pub enum WalletRpcError { #[error("Initialization error: {0}")] InitializationError(Box), + #[error("Decoding error: {0}")] DecodingError(#[from] serialization::hex::HexError), + #[error("Client creation error: {0}")] ClientCreationError(ClientError), + #[error("Response error: {0}")] ResponseError(ClientError), + + #[error("Invalid HTLC secret")] + InvalidHtlcSecret, } impl From for WalletRpcError { diff --git a/wallet/wallet-rpc-client/src/wallet_rpc_traits.rs b/wallet/wallet-rpc-client/src/wallet_rpc_traits.rs index 1a7c9426b4..540deb5eb3 100644 --- a/wallet/wallet-rpc-client/src/wallet_rpc_traits.rs +++ b/wallet/wallet-rpc-client/src/wallet_rpc_traits.rs @@ -555,6 +555,15 @@ pub trait WalletInterface { give_curency: Option, ) -> Result, Self::Error>; + async fn spend_utxo( + &self, + account_index: U31, + utxo: UtxoOutPoint, + output_address: String, + htlc_secret: Option, + config: ControllerConfig, + ) -> Result; + async fn node_version(&self) -> Result; async fn node_shutdown(&self) -> Result<(), Self::Error>; diff --git a/wallet/wallet-rpc-daemon/docs/RPC.md b/wallet/wallet-rpc-daemon/docs/RPC.md index 1bd8ae206e..70dd38e0d6 100644 --- a/wallet/wallet-rpc-daemon/docs/RPC.md +++ b/wallet/wallet-rpc-daemon/docs/RPC.md @@ -2710,6 +2710,63 @@ Returns: }, .. ] ``` +### Method `utxo_spend` + +Spend the specified utxo, moving the corresponding funds (coins or tokens) to the specified +address. If the utxo is an HTLC, specifying `htlc_secret` means that the HTLC will be spent, +and omitting it means that the HTLC will be refunded. + + +Parameters: +``` +{ + "account": number, + "utxo": { + "source_id": EITHER OF + 1) { + "type": "Transaction", + "content": { "tx_id": hex string }, + } + 2) { + "type": "BlockReward", + "content": { "block_id": hex string }, + }, + "index": number, + }, + "output_address": bech32 string, + "htlc_secret": EITHER OF + 1) hex string + 2) null, + "options": { + "in_top_x_mb": EITHER OF + 1) number + 2) null, + "broadcast_to_mempool": EITHER OF + 1) bool + 2) null, + }, +} +``` + +Returns: +``` +{ + "tx_id": hex string, + "tx": hex string, + "fees": { + "coins": { + "atoms": number string, + "decimal": decimal string, + }, + "tokens": { bech32 string: { + "atoms": number string, + "decimal": decimal string, + }, .. }, + }, + "broadcasted": bool, +} +``` + ### Method `node_version` Obtain the node version diff --git a/wallet/wallet-rpc-lib/src/rpc/interface.rs b/wallet/wallet-rpc-lib/src/rpc/interface.rs index 60b42a0e55..b6c6019496 100644 --- a/wallet/wallet-rpc-lib/src/rpc/interface.rs +++ b/wallet/wallet-rpc-lib/src/rpc/interface.rs @@ -884,6 +884,19 @@ trait WalletRpc { give_currency: Option, ) -> rpc::RpcResult>; + /// Spend the specified utxo, moving the corresponding funds (coins or tokens) to the specified + /// address. If the utxo is an HTLC, specifying `htlc_secret` means that the HTLC will be spent, + /// and omitting it means that the HTLC will be refunded. + #[method(name = "utxo_spend")] + async fn spend_utxo( + &self, + account: AccountArg, + utxo: RpcUtxoOutpoint, + output_address: RpcAddress, + htlc_secret: Option, + options: TransactionOptions, + ) -> rpc::RpcResult; + /// Obtain the node version #[method(name = "node_version")] async fn node_version(&self) -> rpc::RpcResult; diff --git a/wallet/wallet-rpc-lib/src/rpc/mod.rs b/wallet/wallet-rpc-lib/src/rpc/mod.rs index b8a8f27f77..da50c09587 100644 --- a/wallet/wallet-rpc-lib/src/rpc/mod.rs +++ b/wallet/wallet-rpc-lib/src/rpc/mod.rs @@ -1924,6 +1924,40 @@ where Ok(result) } + pub async fn spend_utxo( + &self, + account_index: U31, + utxo: RpcUtxoOutpoint, + output_address: RpcAddress, + htlc_secret: Option, + config: ControllerConfig, + ) -> WRpcResult { + let utxo = utxo.into_outpoint(); + let output_address = output_address + .decode_object(&self.chain_config) + .map_err(|_| RpcError::InvalidAddress)?; + let htlc_secret = htlc_secret + .map(|s| -> WRpcResult<_, N> { + Ok(HtlcSecret::new( + s.into_bytes().try_into().map_err(|_| RpcError::InvalidHtlcSecret)?, + )) + }) + .transpose()?; + + self.wallet + .call_async(move |w| { + Box::pin(async move { + w.synced_controller(account_index, config) + .await? + .spend_utxo(utxo, output_address, htlc_secret) + .await + .map_err(RpcError::Controller) + .map(RpcNewTransaction::new) + }) + }) + .await? + } + pub async fn compose_transaction( &self, inputs: Vec, diff --git a/wallet/wallet-rpc-lib/src/rpc/server_impl.rs b/wallet/wallet-rpc-lib/src/rpc/server_impl.rs index 815c517b6b..c899de8198 100644 --- a/wallet/wallet-rpc-lib/src/rpc/server_impl.rs +++ b/wallet/wallet-rpc-lib/src/rpc/server_impl.rs @@ -1143,6 +1143,26 @@ where ) } + async fn spend_utxo( + &self, + account_arg: AccountArg, + utxo: RpcUtxoOutpoint, + output_address: RpcAddress, + htlc_secret: Option, + options: TransactionOptions, + ) -> rpc::RpcResult { + rpc::handle_result( + self.spend_utxo( + account_arg.index::()?, + utxo, + output_address, + htlc_secret, + options.into(), + ) + .await, + ) + } + async fn stake_pool_balance( &self, pool_id: RpcAddress, diff --git a/wasm-wrappers/WASM-API.md b/wasm-wrappers/WASM-API.md index 1b13961833..96263505f2 100644 --- a/wasm-wrappers/WASM-API.md +++ b/wasm-wrappers/WASM-API.md @@ -205,6 +205,7 @@ for Account inputs that spend from a delegation it is the owning address of that and in the case of AccountCommand inputs which change a token it is the token's authority destination) and the outputs, estimate the transaction size. ScriptHash and ClassicMultisig destinations are not supported. +Also, the function assumes that the input UTXOs are not HTLC. ### Function: `encode_transaction` diff --git a/wasm-wrappers/src/lib.rs b/wasm-wrappers/src/lib.rs index 170698291e..5e1f45de5f 100644 --- a/wasm-wrappers/src/lib.rs +++ b/wasm-wrappers/src/lib.rs @@ -653,6 +653,7 @@ pub fn extract_htlc_secret( /// and in the case of AccountCommand inputs which change a token it is the token's authority destination) /// and the outputs, estimate the transaction size. /// ScriptHash and ClassicMultisig destinations are not supported. +/// Also, the function assumes that the input UTXOs are not HTLC. #[wasm_bindgen] pub fn estimate_transaction_size( inputs: &[u8], @@ -673,7 +674,7 @@ pub fn estimate_transaction_size( for destination in input_utxos_destinations { let destination = parse_addressable(&chain_config, &destination)?; let signature_size = - input_signature_size_from_destination(&destination, Option::<&_>::None) + input_signature_size_from_destination(&destination, None, Option::<&_>::None) .map_err(Error::TransactionSizeEstimationError)?; total_size += signature_size;