From d64c8510980cd7b0395943a976825ec7dcb3a219 Mon Sep 17 00:00:00 2001 From: Boris Oncev Date: Fri, 2 Feb 2024 15:45:58 +0100 Subject: [PATCH] Add wallet command to create a send coins request for cold wallet input --- test-rpc-functions/src/rpc.rs | 26 ++ test/functional/test_framework/__init__.py | 2 + .../test_framework/wallet_cli_controller.py | 11 +- test/functional/test_runner.py | 1 + test/functional/wallet_cold_wallet_send.py | 316 ++++++++++++++++++ wallet/src/account/mod.rs | 161 ++++++--- wallet/src/send_request/mod.rs | 15 + wallet/src/wallet/mod.rs | 42 ++- wallet/src/wallet/tests.rs | 202 +++++++++-- wallet/wallet-cli-lib/src/commands/mod.rs | 58 ++++ .../src/synced_controller.rs | 74 +++- wallet/wallet-rpc-lib/src/rpc/mod.rs | 32 ++ 12 files changed, 857 insertions(+), 83 deletions(-) create mode 100644 test/functional/wallet_cold_wallet_send.py diff --git a/test-rpc-functions/src/rpc.rs b/test-rpc-functions/src/rpc.rs index d5e0dd8987..fc160d0c9b 100644 --- a/test-rpc-functions/src/rpc.rs +++ b/test-rpc-functions/src/rpc.rs @@ -17,6 +17,7 @@ use chainstate_types::vrf_tools::{construct_transcript, verify_vrf_and_get_vrf_output}; use common::{ + address::Address, chain::config::regtest::genesis_values, chain::{ block::timestamp::BlockTimestamp, @@ -107,6 +108,12 @@ trait RpcTestFunctionsRpc { amount_to_spend: u64, fee_per_tx: u64, ) -> rpc::RpcResult>>; + + #[method(name = "address_to_destination")] + async fn address_to_destination( + &self, + address: String, + ) -> rpc::RpcResult>; } #[async_trait::async_trait] @@ -327,6 +334,25 @@ impl RpcTestFunctionsRpcServer for super::RpcTestFunctionsHandle { Ok(transactions) } + + async fn address_to_destination( + &self, + address: String, + ) -> rpc::RpcResult> { + let destination = self + .call(move |this| { + this.get_chain_config().map(|chain| { + Address::::from_str(&chain, &address) + .and_then(|addr| addr.decode_object(&chain)) + }) + }) + .await + .expect("Subsystem call ok") + .expect("chain config is present") + .map(HexEncoded::new); + + rpc::handle_result(destination) + } } async fn assert_genesis_values( diff --git a/test/functional/test_framework/__init__.py b/test/functional/test_framework/__init__.py index ef778b1c97..cb2933cf72 100644 --- a/test/functional/test_framework/__init__.py +++ b/test/functional/test_framework/__init__.py @@ -43,6 +43,8 @@ def init_mintlayer_types(): ], }, + "PublicKeyHash": "[u8; 20]", + "PublicKey": { "type": "struct", "type_mapping": [ diff --git a/test/functional/test_framework/wallet_cli_controller.py b/test/functional/test_framework/wallet_cli_controller.py index 2b02038dc7..481fac7714 100644 --- a/test/functional/test_framework/wallet_cli_controller.py +++ b/test/functional/test_framework/wallet_cli_controller.py @@ -191,9 +191,10 @@ async def set_lookahead_size(self, size: int, force_reduce: bool) -> str: i_know_what_i_am_doing = "i-know-what-i-am-doing" if force_reduce else "" return await self._write_command(f"wallet-set-lookahead-size {size} {i_know_what_i_am_doing}\n") - async def new_public_key(self) -> bytes: - addr = await self.new_address() - public_key = await self._write_command(f"address-reveal-public-key-as-hex {addr}\n") + async def new_public_key(self, address: Optional[str] = None) -> bytes: + if address is None: + address = await self.new_address() + public_key = await self._write_command(f"address-reveal-public-key-as-hex {address}\n") self.log.info(f'pub key output: {public_key}') # remove the pub key enum value, the first one byte @@ -216,6 +217,10 @@ async def get_transaction(self, tx_id: str) -> str: async def get_raw_signed_transaction(self, tx_id: str) -> str: return await self._write_command(f"transaction-get-signed-raw {tx_id}\n") + async def send_from_cold_address(self, address: str, amount: int, selected_utxo: UtxoOutpoint, change_address: Optional[str] = None) -> str: + change_address_str = '' if change_address is None else f"--change {change_address}" + return await self._write_command(f"transaction-send-from-cold-input {address} {amount} {str(selected_utxo)} {change_address_str}\n") + async def send_to_address(self, address: str, amount: int, selected_utxos: List[UtxoOutpoint] = []) -> str: return await self._write_command(f"address-send {address} {amount} {' '.join(map(str, selected_utxos))}\n") diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py index 3b142dc4a3..b7078d1077 100755 --- a/test/functional/test_runner.py +++ b/test/functional/test_runner.py @@ -130,6 +130,7 @@ class UnicodeOnWindowsError(ValueError): 'feature_db_reinit.py', 'feature_lmdb_backend_test.py', 'wallet_conflict.py', + 'wallet_cold_wallet_send.py', 'wallet_tx_compose.py', 'wallet_data_deposit.py', 'wallet_submit_tx.py', diff --git a/test/functional/wallet_cold_wallet_send.py b/test/functional/wallet_cold_wallet_send.py new file mode 100644 index 0000000000..eea6570849 --- /dev/null +++ b/test/functional/wallet_cold_wallet_send.py @@ -0,0 +1,316 @@ +#!/usr/bin/env python3 +# Copyright (c) 2023 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 cold wallet send request test + +Check that: +* We can create a new cold wallet, +* issue a new address +* send some coins to that address +* create a new hot wallet +* from the hot wallet create a send request using the cold wallet's utxo +* sign the new tx with the cold wallet +* send it with the hot wallet +""" + +import base64 +from random import choice, randint +import scalecodec +from scalecodec.base import ScaleBytes +from test_framework.authproxy import JSONRPCException +from test_framework.mintlayer import ( + block_input_data_obj, + signed_tx_obj, + ATOMS_PER_COIN, +) +from test_framework.segwit_addr import bech32_decode +from test_framework.test_framework import BitcoinTestFramework +from test_framework.mintlayer import (make_tx, reward_input) +from test_framework.util import assert_equal, assert_greater_than_or_equal, assert_in +from test_framework.wallet_cli_controller import UtxoOutpoint, WalletCliController + +import asyncio +import sys +import time + +def bech32_decode2(bech32_string): + # Constants for the Bech32 encoding + CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" + BECH32_SEPARATOR = '1' + + # Helper function to convert between integers and base32 characters + def char_to_int(char): + return CHARSET.find(char) + + def int_to_char(integer): + return CHARSET[integer] + + # Ensure the string is lowercase for compatibility + bech32_string = bech32_string.lower() + + # Check for the presence of separator '1' + if BECH32_SEPARATOR not in bech32_string: + raise ValueError("Missing separator '1' in Bech32 string") + + # Split the Bech32 string into human-readable part and data part + hrp, data_part = bech32_string.split(BECH32_SEPARATOR, 1) + + for char in data_part: + if char not in CHARSET: + raise ValueError(f"Invalid characters in Bech32 string '{char}'") + + # Check for valid characters in the string + if any(char not in CHARSET for char in data_part): + raise ValueError("Invalid characters in Bech32 string") + + # Ensure the human-readable part and data part are not empty + if not hrp or not data_part: + raise ValueError("Invalid Bech32 string format") + + # Convert the human-readable part and data part to integers + hrp_int = [char_to_int(char) for char in hrp] + data_int = [char_to_int(char) for char in data_part] + + # Verify the checksum + # if not bech32_verify_checksum(hrp_int, data_int): + # raise ValueError("Invalid Bech32 checksum") + + # Remove the checksum from the data part + data_int = data_int[:-6] + + # Convert the data part to bytes + data_bytes = bytes(data_int) + + return hrp, data_bytes + + +def bech32_verify_checksum(hrp_int, data_int): + """Verify the checksum of a Bech32 string.""" + GENERATOR_POLYNOMIAL = 0x3D6E6AED9EB10A99 + + # Concatenate the human-readable part and data part + values = hrp_int + data_int + + # Calculate the checksum + checksum = bech32_polymod(values) ^ GENERATOR_POLYNOMIAL + + # Verify that the checksum is zero + return checksum == 0 + + +def bech32_polymod(values): + """Calculate the Bech32 polymod.""" + GENERATOR = [ + 0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, + 0x2a1462b3, 0x362a7a0d, 0x2e8be413, 0x28e9b7f0, + 0x36d5ebe9, 0x2b60a476, 0x383929a7, 0x3c4a37e3, + ] + + chk = 1 + for value in values: + top = chk >> 25 + chk = (chk & 0x1ffffff) << 5 ^ value + for i in range(5): + chk ^= (top >> i) & 1 if (value >> i) & 1 else 0 + chk ^= GENERATOR[i] + + return chk + + + +""" +[ + {'Transfer': ( + {'Coin': 100000000000}, + {'Address': '0x8b87696b633ceb51f10fa9c334f42fec6f85780e'} + ) + }, + {'Transfer': ( + {'Coin': 4999899999999805}, + {'PublicKey': {'key': {'Secp256k1Schnorr': + {'pubkey_data': '0x02cdee206d3fd4b770a6489877e85fcf580401207e43a8508a121f5e58b0ea3bc8'} + }}} + ) + } +] +""" +def get_destination(dest): + if 'Address' in dest: + return dest['Address'] + return dest['PublicKey']['key']['Secp256k1Schnorr']['pubkey_data'] + +def get_transfer_coins_and_address(output): + transfer = output['Transfer'] + coins = transfer[0]['Coin'] + dest = transfer[1] + return (coins, get_destination(dest)) + +class WalletColdSend(BitcoinTestFramework): + + def set_test_params(self): + 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 hex_to_dec_array(self, hex_string): + return [int(hex_string[i:i+2], 16) for i in range(0, len(hex_string), 2)] + + def previous_block_id(self): + previous_block_id = self.nodes[0].chainstate_best_block_id() + return self.hex_to_dec_array(previous_block_id) + + def run_test(self): + if 'win32' in sys.platform: + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + asyncio.run(self.async_test()) + + async def async_test(self): + node = self.nodes[0] + cold_wallet_pk = b"" + + async with WalletCliController(node, self.config, self.log, chain_config_args=["--chain-pos-netupgrades", "true", "--cold-wallet"]) as wallet: + # new cold wallet + await wallet.create_wallet("cold_wallet") + + cold_wallet_address = await wallet.new_address() + cold_wallet_pk = await wallet.new_public_key(cold_wallet_address) + assert_equal(len(cold_wallet_pk), 33) + use_different_change = choice([True, False]) + + if use_different_change: + cold_wallet_new_change = await wallet.new_address() + dest = node.test_functions_address_to_destination(cold_wallet_new_change) + dest_obj = scalecodec.base.RuntimeConfiguration().create_scale_object('Destination', ScaleBytes("0x"+dest)) + dest_obj.decode() + expected_change_dest = get_destination(dest_obj.value) + else: + cold_wallet_new_change = None + expected_change_dest = f'0x{cold_wallet_pk.hex()}' + + total_cold_wallet_coins = 50_000 + to_send = randint(1, 100) + + async with WalletCliController(node, self.config, self.log, chain_config_args=["--chain-pos-netupgrades", "true"]) as wallet: + # new hot wallet + await wallet.create_wallet("hot_wallet") + + # check it is on genesis + best_block_height = await wallet.get_best_block_height() + self.log.info(f"best block height = {best_block_height}") + assert_equal(best_block_height, '0') + + # Get chain tip + tip_id = node.chainstate_best_block_id() + self.log.debug(f'Tip: {tip_id}') + + # Submit a valid transaction + output = { + 'Transfer': [ { 'Coin': total_cold_wallet_coins * ATOMS_PER_COIN }, { 'PublicKey': {'key': {'Secp256k1Schnorr' : {'pubkey_data': cold_wallet_pk}}} } ], + } + encoded_tx, tx_id = make_tx([reward_input(tip_id)], [output], 0) + self.log.debug(f"Encoded transaction {tx_id}: {encoded_tx}") + + node.mempool_submit_transaction(encoded_tx, {}) + assert node.mempool_contains_tx(tx_id) + + self.generate_block() + + balance = await wallet.get_balance() + assert_in("Coins amount: 0", balance) + + hot_wallet_address = await wallet.new_address() + + if cold_wallet_new_change: + output = await wallet.send_from_cold_address(hot_wallet_address, to_send, UtxoOutpoint(tx_id, 0), cold_wallet_new_change) + else: + output = await wallet.send_from_cold_address(hot_wallet_address, to_send, UtxoOutpoint(tx_id, 0)) + + assert_in("Send transaction created", output) + send_req = output.split("\n")[2] + + # try to sign decommission request from hot wallet + assert_in("Wallet error: Wallet error: Input cannot be signed", + await wallet.sign_raw_transaction(send_req)) + + signed_tx = "" + + async with WalletCliController(node, self.config, self.log, chain_config_args=["--chain-pos-netupgrades", "true", "--cold-wallet"]) as wallet: + # open cold wallet + await wallet.open_wallet("cold_wallet") + + # sign decommission request + signed_tx_output = await wallet.sign_raw_transaction(send_req) + signed_tx = signed_tx_output.split('\n')[2] + + async with WalletCliController(node, self.config, self.log, chain_config_args=["--chain-pos-netupgrades", "true"]) as wallet: + # open hot wallet + await wallet.open_wallet("hot_wallet") + + output = await wallet.submit_transaction(signed_tx) + assert_in("The transaction was submitted successfully", output) + tx_id = output.split('\n')[1] + + transactions = node.mempool_transactions() + assert_in(signed_tx, transactions) + + self.generate_block() + assert_in("Success", await wallet.sync()) + + balance = await wallet.get_balance() + assert_in(f"Coins amount: {to_send}", balance) + + signed_tx_obj = scalecodec.base.RuntimeConfiguration().create_scale_object('SignedTransaction', ScaleBytes("0x"+signed_tx)) + signed_tx_obj.decode() + outputs = signed_tx_obj['transaction']['outputs'] + + dest = node.test_functions_address_to_destination(hot_wallet_address) + dest_obj = scalecodec.base.RuntimeConfiguration().create_scale_object('Destination', ScaleBytes("0x"+dest)) + dest_obj.decode() + + for out in outputs: + coins, addr = get_transfer_coins_and_address(out.value) + if coins == to_send * ATOMS_PER_COIN: + hot_wallet_dest = get_destination(dest_obj.value) + assert_equal(addr, hot_wallet_dest) + else: + assert_greater_than_or_equal(coins, total_cold_wallet_coins - to_send - 1) + assert_equal(addr, expected_change_dest) + + +if __name__ == '__main__': + WalletColdSend().main() diff --git a/wallet/src/account/mod.rs b/wallet/src/account/mod.rs index 66822bcd55..e32c1a6fdf 100644 --- a/wallet/src/account/mod.rs +++ b/wallet/src/account/mod.rs @@ -39,7 +39,7 @@ use crate::key_chain::{AccountKeyChain, KeyChainError}; use crate::send_request::{ make_address_output, make_address_output_from_delegation, make_address_output_token, make_decommission_stake_pool_output, make_mint_token_outputs, make_stake_output, - make_unmint_token_outputs, IssueNftArguments, StakePoolDataArguments, + make_unmint_token_outputs, IssueNftArguments, SelectedInputs, StakePoolDataArguments, }; use crate::wallet::WalletPoolsFilter; use crate::wallet_events::{WalletEvents, WalletEventsNoOp}; @@ -79,6 +79,7 @@ use wallet_types::{ KeychainUsageState, WalletTx, }; +use self::currency_grouper::Currency; pub use self::output_cache::{ DelegationData, FungibleTokenInfo, PoolData, UnconfirmedTokenInfo, UtxoWithTxOutput, }; @@ -255,14 +256,14 @@ impl Account { self.output_cache.find_used_tokens(current_block_info, input_utxos) } - fn select_inputs_for_send_request( + pub fn select_inputs_for_send_request( &mut self, request: SendRequest, - input_utxos: Vec, + input_utxos: SelectedInputs, + change_addresses: BTreeMap>, db_tx: &mut impl WalletStorageWriteLocked, median_time: BlockTimestamp, - current_fee_rate: FeeRate, - consolidate_fee_rate: FeeRate, + fee_rates: CurrentFeeRate, ) -> WalletResult { // TODO: allow to pay fees with different currency? let pay_fee_with_currency = currency_grouper::Currency::Coin; @@ -279,17 +280,15 @@ impl Account { self.account_info.best_block_height(), )?; - let network_fee: Amount = current_fee_rate + let network_fee: Amount = fee_rates + .current_fee_rate .compute_fee(tx_size_with_outputs(request.outputs())) .map_err(|_| UtxoSelectorError::AmountArithmeticError)? .into(); - let (coin_change_fee, token_change_fee) = - coin_and_token_output_change_fees(current_fee_rate)?; - let mut preselected_inputs = group_preselected_inputs( &request, - current_fee_rate, + fee_rates.current_fee_rate, &self.chain_config, self.account_info.best_block_height(), )?; @@ -305,22 +304,30 @@ impl Account { CoinSelectionAlgo::Randomize, ) } else { - let current_block_info = BlockInfo { - height: self.account_info.best_block_height(), - timestamp: median_time, - }; - ( - self.output_cache.find_utxos(current_block_info, input_utxos)?, - CoinSelectionAlgo::UsePreselected, - ) + match input_utxos { + SelectedInputs::Utxos(input_utxos) => { + let current_block_info = BlockInfo { + height: self.account_info.best_block_height(), + timestamp: median_time, + }; + ( + self.output_cache.find_utxos(current_block_info, input_utxos)?, + CoinSelectionAlgo::UsePreselected, + ) + } + SelectedInputs::Inputs(ref inputs) => ( + inputs + .iter() + .map(|(outpoint, utxo)| (outpoint.clone(), (utxo, None))) + .collect(), + CoinSelectionAlgo::UsePreselected, + ), + } }; - let mut utxos_by_currency = self.utxo_output_groups_by_currency( - current_fee_rate, - consolidate_fee_rate, - &pay_fee_with_currency, - utxos, - )?; + let current_fee_rate = fee_rates.current_fee_rate; + let mut utxos_by_currency = + self.utxo_output_groups_by_currency(fee_rates, &pay_fee_with_currency, utxos)?; let amount_to_be_paid_in_currency_with_fees = output_currency_amounts.remove(&pay_fee_with_currency).unwrap_or(Amount::ZERO); @@ -334,6 +341,12 @@ impl Account { let (preselected_amount, preselected_fee) = preselected_inputs.remove(currency).unwrap_or((Amount::ZERO, Amount::ZERO)); + let (coin_change_fee, token_change_fee) = coin_and_token_output_change_fees( + current_fee_rate, + change_addresses.get(currency), + &self.chain_config, + )?; + let cost_of_change = match currency { currency_grouper::Currency::Coin => coin_change_fee, currency_grouper::Currency::Token(_) => token_change_fee, @@ -382,6 +395,11 @@ impl Account { + total_fees_not_paid) .ok_or(WalletError::OutputAmountOverflow)?; + let (coin_change_fee, token_change_fee) = coin_and_token_output_change_fees( + current_fee_rate, + change_addresses.get(&pay_fee_with_currency), + &self.chain_config, + )?; let cost_of_change = match pay_fee_with_currency { currency_grouper::Currency::Coin => coin_change_fee, currency_grouper::Currency::Token(_) => token_change_fee, @@ -413,13 +431,20 @@ impl Account { selected_inputs.insert(pay_fee_with_currency, selection_result); // Check outputs against inputs and create change - self.check_outputs_and_add_change(output_currency_amounts, selected_inputs, db_tx, request) + self.check_outputs_and_add_change( + output_currency_amounts, + selected_inputs, + change_addresses, + db_tx, + request, + ) } fn check_outputs_and_add_change( &mut self, output_currency_amounts: BTreeMap, selected_inputs: BTreeMap, + mut change_addresses: BTreeMap>, db_tx: &mut impl WalletStorageWriteLocked, mut request: SendRequest, ) -> Result { @@ -428,7 +453,14 @@ impl Account { selected_inputs.get(currency).map_or(Amount::ZERO, |result| result.get_change()); if change_amount > Amount::ZERO { - let (_, change_address) = self.get_new_address(db_tx, KeyPurpose::Change)?; + let change_address = if let Some(change_address) = change_addresses.remove(currency) + { + change_address + } else { + let (_, change_address) = self.get_new_address(db_tx, KeyPurpose::Change)?; + change_address + }; + let change_output = match currency { currency_grouper::Currency::Coin => make_address_output( self.chain_config.as_ref(), @@ -454,8 +486,7 @@ impl Account { fn utxo_output_groups_by_currency( &self, - current_fee_rate: FeeRate, - consolidate_fee_rate: FeeRate, + fee_rates: CurrentFeeRate, pay_fee_with_currency: ¤cy_grouper::Currency, utxos: Vec<(UtxoOutPoint, (&TxOutput, Option))>, ) -> Result>, WalletError> { @@ -466,10 +497,12 @@ impl Account { let inp_sig_size = input_signature_size(&txo)?; - let fee = current_fee_rate + let fee = fee_rates + .current_fee_rate .compute_fee(input_size + inp_sig_size) .map_err(|_| UtxoSelectorError::AmountArithmeticError)?; - let consolidate_fee = consolidate_fee_rate + let consolidate_fee = fee_rates + .consolidate_fee_rate .compute_fee(input_size + inp_sig_size) .map_err(|_| UtxoSelectorError::AmountArithmeticError)?; @@ -511,20 +544,49 @@ impl Account { } pub fn process_send_request( + &mut self, + db_tx: &mut impl WalletStorageWriteLocked, + request: SendRequest, + inputs: SelectedInputs, + change_addresses: BTreeMap>, + median_time: BlockTimestamp, + fee_rate: CurrentFeeRate, + ) -> WalletResult { + let request = self.select_inputs_for_send_request( + request, + inputs, + change_addresses, + db_tx, + median_time, + fee_rate, + )?; + + let (tx, utxos, destinations) = request.into_transaction_and_utxos()?; + let num_inputs = tx.inputs().len(); + PartiallySignedTransaction::new( + tx, + vec![None; num_inputs], + utxos, + destinations.into_iter().map(Some).collect(), + ) + } + + pub fn process_send_request_and_sign( &mut self, db_tx: &mut impl WalletStorageWriteUnlocked, request: SendRequest, - inputs: Vec, + inputs: SelectedInputs, + change_addresses: BTreeMap>, median_time: BlockTimestamp, fee_rate: CurrentFeeRate, ) -> WalletResult { let request = self.select_inputs_for_send_request( request, inputs, + change_addresses, db_tx, median_time, - fee_rate.current_fee_rate, - fee_rate.consolidate_fee_rate, + fee_rate, )?; // TODO: Randomize inputs and outputs @@ -775,11 +837,11 @@ impl Account { let request = SendRequest::new().with_outputs([dummy_stake_output]); let mut request = self.select_inputs_for_send_request( request, - vec![], + SelectedInputs::Utxos(vec![]), + BTreeMap::new(), db_tx, median_time, - fee_rate.current_fee_rate, - fee_rate.consolidate_fee_rate, + fee_rate, )?; let new_pool_id = match request @@ -844,11 +906,11 @@ impl Account { let request = SendRequest::new().with_outputs([dummy_issuance_output]); let mut request = self.select_inputs_for_send_request( request, - vec![], + SelectedInputs::Utxos(vec![]), + BTreeMap::new(), db_tx, median_time, - fee_rate.current_fee_rate, - fee_rate.consolidate_fee_rate, + fee_rate, )?; let new_token_id = make_token_id(request.inputs()).ok_or(WalletError::NoUtxos)?; @@ -1051,11 +1113,11 @@ impl Account { let request = self.select_inputs_for_send_request( request, - vec![], + SelectedInputs::Utxos(vec![]), + BTreeMap::new(), db_tx, median_time, - fee_rate.current_fee_rate, - fee_rate.consolidate_fee_rate, + fee_rate, )?; let tx = self.sign_transaction_from_req(request, db_tx)?; @@ -1932,10 +1994,17 @@ fn group_preselected_inputs( /// Calculate the amount of fee that needs to be paid to add a change output /// Returns the Amounts for Coin output and Token output -fn coin_and_token_output_change_fees(feerate: mempool::FeeRate) -> WalletResult<(Amount, Amount)> { - let pub_key_hash = PublicKeyHash::from_low_u64_ne(0); - - let destination = Destination::PublicKeyHash(pub_key_hash); +fn coin_and_token_output_change_fees( + feerate: mempool::FeeRate, + destination: Option<&Address>, + chain_config: &ChainConfig, +) -> WalletResult<(Amount, Amount)> { + let destination = if let Some(addr) = destination { + addr.decode_object(chain_config)? + } else { + let pub_key_hash = PublicKeyHash::from_low_u64_ne(0); + Destination::PublicKeyHash(pub_key_hash) + }; let coin_output = TxOutput::Transfer(OutputValue::Coin(Amount::MAX), destination.clone()); let token_output = TxOutput::Transfer( diff --git a/wallet/src/send_request/mod.rs b/wallet/src/send_request/mod.rs index f572ac4b19..2959783789 100644 --- a/wallet/src/send_request/mod.rs +++ b/wallet/src/send_request/mod.rs @@ -20,6 +20,7 @@ use common::chain::timelock::OutputTimeLock::ForBlockCount; use common::chain::tokens::{Metadata, TokenId, TokenIssuance}; use common::chain::{ ChainConfig, Destination, PoolId, Transaction, TransactionCreationError, TxInput, TxOutput, + UtxoOutPoint, }; use common::primitives::per_thousand::PerThousand; use common::primitives::{Amount, BlockHeight}; @@ -306,3 +307,17 @@ where | TxOutput::DataDeposit(_) => None, } } + +pub enum SelectedInputs { + Utxos(Vec), + Inputs(Vec<(UtxoOutPoint, TxOutput)>), +} + +impl SelectedInputs { + pub fn is_empty(&self) -> bool { + match self { + Self::Utxos(utxos) => utxos.is_empty(), + Self::Inputs(inputs) => inputs.is_empty(), + } + } +} diff --git a/wallet/src/wallet/mod.rs b/wallet/src/wallet/mod.rs index f6c478ae32..38bbc6cf42 100644 --- a/wallet/src/wallet/mod.rs +++ b/wallet/src/wallet/mod.rs @@ -26,7 +26,9 @@ use crate::key_chain::{ make_account_path, make_path_to_vrf_key, KeyChainError, MasterKeyChain, LOOKAHEAD_SIZE, VRF_INDEX, }; -use crate::send_request::{make_issue_token_outputs, IssueNftArguments, StakePoolDataArguments}; +use crate::send_request::{ + make_issue_token_outputs, IssueNftArguments, SelectedInputs, StakePoolDataArguments, +}; use crate::wallet_events::{WalletEvents, WalletEventsNoOp}; use crate::{Account, SendRequest}; pub use bip39::{Language, Mnemonic}; @@ -1014,6 +1016,8 @@ impl Wallet { /// * `&mut self` - A mutable reference to the wallet instance. /// * `account_index: U31` - The index of the account from which funds will be sent. /// * `outputs: impl IntoIterator` - An iterator over `TxOutput` items representing the addresses and amounts to which funds will be sent. + /// * `inputs`: SelectedInputs - if not empty will try to select inputs from those inestead of the avalable ones + /// * `change_addresses`: if present will use those change_addresses instead of generating new ones /// * `current_fee_rate: FeeRate` - The current fee rate based on the mempool to be used for the transaction. /// * `consolidate_fee_rate: FeeRate` - The fee rate in case of a consolidation event, if the /// current_fee_rate is lower than the consolidate_fee_rate then the wallet will tend to @@ -1027,17 +1031,45 @@ impl Wallet { &mut self, account_index: U31, outputs: impl IntoIterator, - inputs: Vec, + inputs: SelectedInputs, + change_addresses: BTreeMap>, current_fee_rate: FeeRate, consolidate_fee_rate: FeeRate, ) -> WalletResult { let request = SendRequest::new().with_outputs(outputs); let latest_median_time = self.latest_median_time; self.for_account_rw_unlocked_and_check_tx(account_index, |account, db_tx| { + account.process_send_request_and_sign( + db_tx, + request, + inputs, + change_addresses, + latest_median_time, + CurrentFeeRate { + current_fee_rate, + consolidate_fee_rate, + }, + ) + }) + } + + pub fn create_unsigned_transaction_to_addresses( + &mut self, + account_index: U31, + outputs: impl IntoIterator, + inputs: SelectedInputs, + change_addresses: BTreeMap>, + current_fee_rate: FeeRate, + consolidate_fee_rate: FeeRate, + ) -> WalletResult { + let request = SendRequest::new().with_outputs(outputs); + let latest_median_time = self.latest_median_time; + self.for_account_rw(account_index, |account, db_tx| { account.process_send_request( db_tx, request, inputs, + change_addresses, latest_median_time, CurrentFeeRate { current_fee_rate, @@ -1231,7 +1263,8 @@ impl Wallet { let tx = self.create_transaction_to_addresses( account_index, outputs, - vec![], + SelectedInputs::Utxos(vec![]), + BTreeMap::new(), current_fee_rate, consolidate_fee_rate, )?; @@ -1262,7 +1295,8 @@ impl Wallet { let tx = self.create_transaction_to_addresses( account_index, outputs, - vec![], + SelectedInputs::Utxos(vec![]), + BTreeMap::new(), current_fee_rate, consolidate_fee_rate, )?; diff --git a/wallet/src/wallet/tests.rs b/wallet/src/wallet/tests.rs index 9412e7050a..ff319ad3da 100644 --- a/wallet/src/wallet/tests.rs +++ b/wallet/src/wallet/tests.rs @@ -815,7 +815,8 @@ fn wallet_accounts_creation() { OutputValue::Coin(Amount::from_atoms(1)), acc1_pk.decode_object(&chain_config).unwrap(), )], - vec![], + SelectedInputs::Utxos(vec![]), + BTreeMap::new(), FeeRate::from_amount_per_kb(Amount::ZERO), FeeRate::from_amount_per_kb(Amount::ZERO), ) @@ -973,7 +974,8 @@ fn locked_wallet_cant_sign_transaction(#[case] seed: Seed) { wallet.create_transaction_to_addresses( DEFAULT_ACCOUNT_INDEX, [new_output.clone()], - vec![], + SelectedInputs::Utxos(vec![]), + BTreeMap::new(), FeeRate::from_amount_per_kb(Amount::ZERO), FeeRate::from_amount_per_kb(Amount::ZERO), ), @@ -989,7 +991,8 @@ fn locked_wallet_cant_sign_transaction(#[case] seed: Seed) { .create_transaction_to_addresses( DEFAULT_ACCOUNT_INDEX, [new_output], - vec![], + SelectedInputs::Utxos(vec![]), + BTreeMap::new(), FeeRate::from_amount_per_kb(Amount::ZERO), FeeRate::from_amount_per_kb(Amount::ZERO), ) @@ -1017,7 +1020,8 @@ fn locked_wallet_cant_sign_transaction(#[case] seed: Seed) { .create_transaction_to_addresses( DEFAULT_ACCOUNT_INDEX, [new_output], - vec![], + SelectedInputs::Utxos(vec![]), + BTreeMap::new(), FeeRate::from_amount_per_kb(Amount::ZERO), FeeRate::from_amount_per_kb(Amount::ZERO), ) @@ -1043,7 +1047,8 @@ fn wallet_get_transaction(#[case] seed: Seed) { .create_transaction_to_addresses( DEFAULT_ACCOUNT_INDEX, [gen_random_transfer(&mut rng, Amount::from_atoms(1))], - vec![], + SelectedInputs::Utxos(vec![]), + BTreeMap::new(), FeeRate::from_amount_per_kb(Amount::ZERO), FeeRate::from_amount_per_kb(Amount::ZERO), ) @@ -1109,7 +1114,8 @@ fn wallet_transaction_with_fees(#[case] seed: Seed) { .create_transaction_to_addresses( DEFAULT_ACCOUNT_INDEX, [gen_random_transfer(&mut rng, Amount::from_atoms(1))], - vec![], + SelectedInputs::Utxos(vec![]), + BTreeMap::new(), very_big_feerate, very_big_feerate, ) @@ -1132,7 +1138,14 @@ fn wallet_transaction_with_fees(#[case] seed: Seed) { let feerate = FeeRate::from_amount_per_kb(Amount::from_atoms(1000)); let transaction = wallet - .create_transaction_to_addresses(DEFAULT_ACCOUNT_INDEX, outputs, vec![], feerate, feerate) + .create_transaction_to_addresses( + DEFAULT_ACCOUNT_INDEX, + outputs, + SelectedInputs::Utxos(vec![]), + BTreeMap::new(), + feerate, + feerate, + ) .unwrap(); let tx_size = serialization::Encode::encoded_size(&transaction); @@ -1208,7 +1221,8 @@ fn spend_from_user_specified_utxos(#[case] seed: Seed) { .create_transaction_to_addresses( DEFAULT_ACCOUNT_INDEX, [TxOutput::Burn(OutputValue::Coin(burn_amount))], - vec![missing_utxo.clone()], + SelectedInputs::Utxos(vec![missing_utxo.clone()]), + BTreeMap::new(), FeeRate::from_amount_per_kb(Amount::ZERO), FeeRate::from_amount_per_kb(Amount::ZERO), ) @@ -1233,7 +1247,8 @@ fn spend_from_user_specified_utxos(#[case] seed: Seed) { .create_transaction_to_addresses( DEFAULT_ACCOUNT_INDEX, [TxOutput::Burn(OutputValue::Coin(burn_amount))], - selected_utxos.clone(), + SelectedInputs::Utxos(selected_utxos.clone()), + BTreeMap::new(), FeeRate::from_amount_per_kb(Amount::ZERO), FeeRate::from_amount_per_kb(Amount::ZERO), ) @@ -1267,7 +1282,8 @@ fn spend_from_user_specified_utxos(#[case] seed: Seed) { .create_transaction_to_addresses( DEFAULT_ACCOUNT_INDEX, [TxOutput::Burn(OutputValue::Coin(burn_amount))], - selected_utxos.clone(), + SelectedInputs::Utxos(selected_utxos.clone()), + BTreeMap::new(), FeeRate::from_amount_per_kb(Amount::ZERO), FeeRate::from_amount_per_kb(Amount::ZERO), ) @@ -1502,7 +1518,8 @@ fn send_to_unknown_delegation(#[case] seed: Seed) { .create_transaction_to_addresses( DEFAULT_ACCOUNT_INDEX, [TxOutput::DelegateStaking(delegation_amount, unknown_delegation_id)], - vec![], + SelectedInputs::Utxos(vec![]), + BTreeMap::new(), FeeRate::from_amount_per_kb(Amount::ZERO), FeeRate::from_amount_per_kb(Amount::ZERO), ) @@ -1654,7 +1671,8 @@ fn create_spend_from_delegations(#[case] seed: Seed) { .create_transaction_to_addresses( DEFAULT_ACCOUNT_INDEX, [TxOutput::DelegateStaking(delegation_amount, delegation_id)], - vec![], + SelectedInputs::Utxos(vec![]), + BTreeMap::new(), FeeRate::from_amount_per_kb(Amount::ZERO), FeeRate::from_amount_per_kb(Amount::ZERO), ) @@ -1944,7 +1962,8 @@ fn issue_and_transfer_tokens(#[case] seed: Seed) { .create_transaction_to_addresses( DEFAULT_ACCOUNT_INDEX, [new_output], - vec![], + SelectedInputs::Utxos(vec![]), + BTreeMap::new(), FeeRate::from_amount_per_kb(Amount::ZERO), FeeRate::from_amount_per_kb(Amount::ZERO), ) @@ -1992,7 +2011,8 @@ fn issue_and_transfer_tokens(#[case] seed: Seed) { .create_transaction_to_addresses( DEFAULT_ACCOUNT_INDEX, [new_output], - vec![], + SelectedInputs::Utxos(vec![]), + BTreeMap::new(), FeeRate::from_amount_per_kb(Amount::ZERO), FeeRate::from_amount_per_kb(Amount::ZERO), ) @@ -2053,7 +2073,8 @@ fn check_tokens_v0_are_ignored(#[case] seed: Seed) { )))), address2.decode_object(&chain_config).unwrap(), )], - vec![], + SelectedInputs::Utxos(vec![]), + BTreeMap::new(), FeeRate::from_amount_per_kb(Amount::ZERO), FeeRate::from_amount_per_kb(Amount::ZERO), ) @@ -2272,7 +2293,8 @@ fn freeze_and_unfreeze_tokens(#[case] seed: Seed) { .create_transaction_to_addresses( DEFAULT_ACCOUNT_INDEX, [new_output], - vec![], + SelectedInputs::Utxos(vec![]), + BTreeMap::new(), FeeRate::from_amount_per_kb(Amount::ZERO), FeeRate::from_amount_per_kb(Amount::ZERO), ) @@ -3103,7 +3125,8 @@ fn lock_then_transfer(#[case] seed: Seed) { .create_transaction_to_addresses( DEFAULT_ACCOUNT_INDEX, [new_output], - vec![], + SelectedInputs::Utxos(vec![]), + BTreeMap::new(), FeeRate::from_amount_per_kb(Amount::ZERO), FeeRate::from_amount_per_kb(Amount::ZERO), ) @@ -3229,7 +3252,8 @@ fn wallet_multiple_transactions_in_single_block(#[case] seed: Seed) { .create_transaction_to_addresses( DEFAULT_ACCOUNT_INDEX, [new_output], - vec![], + SelectedInputs::Utxos(vec![]), + BTreeMap::new(), FeeRate::from_amount_per_kb(Amount::ZERO), FeeRate::from_amount_per_kb(Amount::ZERO), ) @@ -3319,7 +3343,8 @@ fn wallet_scan_multiple_transactions_from_mempool(#[case] seed: Seed) { .create_transaction_to_addresses( DEFAULT_ACCOUNT_INDEX, [new_output, change_output], - vec![], + SelectedInputs::Utxos(vec![]), + BTreeMap::new(), FeeRate::from_amount_per_kb(Amount::ZERO), FeeRate::from_amount_per_kb(Amount::ZERO), ) @@ -3353,7 +3378,8 @@ fn wallet_scan_multiple_transactions_from_mempool(#[case] seed: Seed) { .create_transaction_to_addresses( DEFAULT_ACCOUNT_INDEX, [new_output], - vec![], + SelectedInputs::Utxos(vec![]), + BTreeMap::new(), FeeRate::from_amount_per_kb(Amount::ZERO), FeeRate::from_amount_per_kb(Amount::ZERO), ) @@ -3391,7 +3417,8 @@ fn wallet_scan_multiple_transactions_from_mempool(#[case] seed: Seed) { .create_transaction_to_addresses( DEFAULT_ACCOUNT_INDEX, [new_output], - vec![], + SelectedInputs::Utxos(vec![]), + BTreeMap::new(), FeeRate::from_amount_per_kb(Amount::ZERO), FeeRate::from_amount_per_kb(Amount::ZERO), ) @@ -3416,7 +3443,8 @@ fn wallet_scan_multiple_transactions_from_mempool(#[case] seed: Seed) { .create_transaction_to_addresses( DEFAULT_ACCOUNT_INDEX, [new_output], - vec![], + SelectedInputs::Utxos(vec![]), + BTreeMap::new(), FeeRate::from_amount_per_kb(Amount::ZERO), FeeRate::from_amount_per_kb(Amount::ZERO), ) @@ -3499,7 +3527,8 @@ fn wallet_abandone_transactions(#[case] seed: Seed) { .create_transaction_to_addresses( DEFAULT_ACCOUNT_INDEX, [new_output, change_output], - vec![], + SelectedInputs::Utxos(vec![]), + BTreeMap::new(), FeeRate::from_amount_per_kb(Amount::ZERO), FeeRate::from_amount_per_kb(Amount::ZERO), ) @@ -4116,3 +4145,130 @@ fn filter_pools(#[case] seed: Seed) { let pool_ids = wallet2.get_pool_ids(DEFAULT_ACCOUNT_INDEX, WalletPoolsFilter::Stake).unwrap(); assert_eq!(pool_ids.len(), 0); } + +#[rstest] +#[trace] +#[case(Seed::from_entropy())] +fn sign_send_request_cold_wallet(#[case] seed: Seed) { + let mut rng = make_seedable_rng(seed); + let chain_config = Arc::new(create_regtest()); + + let mut hot_wallet = create_wallet(chain_config.clone()); + + // create cold wallet that is not synced + let another_mnemonic = + "legal winner thank year wave sausage worth useful legal winner thank yellow"; + let mut cold_wallet = create_wallet_with_mnemonic(chain_config.clone(), another_mnemonic); + let cold_wallet_address = cold_wallet.get_new_address(DEFAULT_ACCOUNT_INDEX).unwrap().1; + + let coin_balance = get_coin_balance(&hot_wallet); + assert_eq!(coin_balance, Amount::ZERO); + + // Generate a new block which sends reward to the cold wallet address + let block1_amount = Amount::from_atoms(rng.gen_range(NETWORK_FEE + 100..NETWORK_FEE + 10000)); + let reward_output = make_address_output( + chain_config.as_ref(), + cold_wallet_address.clone(), + block1_amount, + ) + .unwrap(); + let block1 = Block::new( + vec![], + chain_config.genesis_block_id(), + chain_config.genesis_block().timestamp(), + ConsensusData::None, + BlockReward::new(vec![reward_output.clone()]), + ) + .unwrap(); + + scan_wallet(&mut hot_wallet, BlockHeight::new(0), vec![block1.clone()]); + + // hot wallet has 0 balance + let coin_balance = get_coin_balance(&hot_wallet); + assert_eq!(coin_balance, Amount::ZERO); + let hot_wallet_address = hot_wallet.get_new_address(DEFAULT_ACCOUNT_INDEX).unwrap().1; + + let to_send = Amount::from_atoms(1); + let send_req = hot_wallet + .create_unsigned_transaction_to_addresses( + DEFAULT_ACCOUNT_INDEX, + [TxOutput::Transfer( + OutputValue::Coin(to_send), + hot_wallet_address.decode_object(&chain_config).unwrap(), + )], + SelectedInputs::Inputs(vec![( + UtxoOutPoint::new(OutPointSourceId::BlockReward(block1.get_id().into()), 0), + reward_output, + )]), + [(Currency::Coin, cold_wallet_address.clone())].into(), + FeeRate::from_amount_per_kb(Amount::ZERO), + FeeRate::from_amount_per_kb(Amount::ZERO), + ) + .unwrap(); + + // Try to sign request with the hot wallet + let err = hot_wallet + .sign_raw_transaction( + DEFAULT_ACCOUNT_INDEX, + TransactionToSign::Partial(send_req.clone()), + ) + .unwrap_err(); + assert_eq!(err, WalletError::InputCannotBeSigned); + + // sign the tx with cold wallet + let signed_tx = cold_wallet + .sign_raw_transaction(DEFAULT_ACCOUNT_INDEX, TransactionToSign::Partial(send_req)) + .unwrap() + .into_signed_tx() + .unwrap(); + + let (_, block2) = create_block( + &chain_config, + &mut hot_wallet, + vec![signed_tx], + Amount::ZERO, + 1, + ); + + let currency_balances = hot_wallet + .get_balance( + DEFAULT_ACCOUNT_INDEX, + UtxoType::Transfer | UtxoType::LockThenTransfer | UtxoType::CreateStakePool, + UtxoState::Confirmed.into(), + WithLocked::Unlocked, + ) + .unwrap(); + + assert_eq!( + currency_balances.get(&Currency::Coin).copied().unwrap_or(Amount::ZERO), + to_send, + ); + + // update cold wallet just to check the balance and utxos + cold_wallet + .scan_new_blocks( + DEFAULT_ACCOUNT_INDEX, + BlockHeight::new(0), + vec![block1, block2], + &WalletEventsNoOp, + ) + .unwrap(); + + let balance = (block1_amount - to_send).unwrap(); + assert_eq!(get_coin_balance(&cold_wallet), balance); + + let mut utxos = cold_wallet + .get_utxos( + DEFAULT_ACCOUNT_INDEX, + UtxoType::Transfer.into(), + UtxoState::Confirmed.into(), + WithLocked::Unlocked, + ) + .unwrap(); + + assert_eq!(utxos.len(), 1); + let (_, output) = utxos.pop().unwrap(); + + matches!(output, TxOutput::Transfer(OutputValue::Coin(value), dest) + if value == balance && dest == cold_wallet_address.decode_object(&chain_config).unwrap()); +} diff --git a/wallet/wallet-cli-lib/src/commands/mod.rs b/wallet/wallet-cli-lib/src/commands/mod.rs index b06e0972a2..9a8537b8b8 100644 --- a/wallet/wallet-cli-lib/src/commands/mod.rs +++ b/wallet/wallet-cli-lib/src/commands/mod.rs @@ -380,6 +380,29 @@ pub enum WalletCommand { utxos: Vec, }, + /// Creates a transaction that spends from a specific address, + /// and returns the change to the same address (unless one is specified), without signature. + /// This transaction is used for "withdrawing" small amounts from a cold storage + /// without changing the ownership address. Once this is created, + /// it can be signed using account-sign-raw-transaction in the cold wallet + /// and then broadcast through any hot wallet. + /// In summary, this creates a transaction with one input and two outputs, + /// with one of the outputs being change returned to the same owner of the input. + #[clap(name = "transaction-send-from-cold-input")] + SendFromColdInput { + /// The receiving address of the coins + address: String, + /// The amount to be sent, in decimal format + amount: DecimalAmount, + /// You can choose what utxo to spend. A utxo can be from a transaction output or a block reward output: + /// e.g tx(000000000000000000059fa50103b9683e51e5aba83b8a34c9b98ce67d66136c,1) or + /// block(000000000000000000059fa50103b9683e51e5aba83b8a34c9b98ce67d66136c,2) + utxo: String, + /// Optional change address, if not specified it returns the change to the same address from the input + #[arg(long = "change")] + change_address: Option, + }, + /// Store data on the blockchain, the data is provided as hex encoded string. /// Note that there is a high fee for storing data on the blockchain. #[clap(name = "address-deposit-data")] @@ -1548,6 +1571,41 @@ where Ok(Self::new_tx_submitted_command(new_tx)) } + WalletCommand::SendFromColdInput { + address, + amount, + utxo, + change_address, + } => { + let selected_input = parse_utxo_outpoint(utxo)?; + let selected_account = self.get_selected_acc()?; + let tx = self + .wallet_rpc + .request_send_coins( + selected_account, + address, + amount, + selected_input, + change_address, + self.config, + ) + .await?; + + let summary = tx.tx().text_summary(self.wallet_rpc.chain_config()); + let hex_tx = HexEncoded::new(tx); + + let qr_code = utils::qrcode::qrcode_from_str(hex_tx.to_string()) + .map_err(WalletCliError::QrCodeEncoding)?; + let qr_code_string = qr_code.encode_to_console_string_with_defaults(1); + + let output_str = format!( + "Send transaction created. \ + Pass the following string into the cold wallet with private key to sign:\n\n{hex_tx}\n\n\ + Or scan the Qr code with it:\n\n{qr_code_string}\n\n{summary}" + ); + Ok(ConsoleCommand::Print(output_str)) + } + WalletCommand::SendTokensToAddress { token_id, address, diff --git a/wallet/wallet-controller/src/synced_controller.rs b/wallet/wallet-controller/src/synced_controller.rs index 5ee4014bb0..cdcdf68f7f 100644 --- a/wallet/wallet-controller/src/synced_controller.rs +++ b/wallet/wallet-controller/src/synced_controller.rs @@ -13,7 +13,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use common::{ address::Address, @@ -39,10 +39,14 @@ use logging::log; use mempool::FeeRate; use node_comm::node_traits::NodeInterface; use wallet::{ - account::{PartiallySignedTransaction, TransactionToSign, UnconfirmedTokenInfo}, + account::{ + currency_grouper::Currency, PartiallySignedTransaction, TransactionToSign, + UnconfirmedTokenInfo, + }, + get_tx_output_destination, send_request::{ make_address_output, make_address_output_token, make_create_delegation_output, - make_data_deposit_output, StakePoolDataArguments, + make_data_deposit_output, SelectedInputs, StakePoolDataArguments, }, wallet::WalletPoolsFilter, wallet_events::WalletEvents, @@ -382,7 +386,8 @@ impl<'a, T: NodeInterface, W: WalletEvents> SyncedController<'a, T, W> { wallet.create_transaction_to_addresses( account_index, outputs, - vec![], + SelectedInputs::Utxos(vec![]), + BTreeMap::new(), current_fee_rate, consolidate_fee_rate, ) @@ -409,7 +414,8 @@ impl<'a, T: NodeInterface, W: WalletEvents> SyncedController<'a, T, W> { wallet.create_transaction_to_addresses( account_index, [output], - selected_utxos, + SelectedInputs::Utxos(selected_utxos), + BTreeMap::new(), current_fee_rate, consolidate_fee_rate, ) @@ -418,6 +424,46 @@ impl<'a, T: NodeInterface, W: WalletEvents> SyncedController<'a, T, W> { .await } + pub async fn request_send_to_address( + &mut self, + address: Address, + amount: Amount, + selected_utxo: UtxoOutPoint, + change_address: Option>, + ) -> Result> { + let output = make_address_output(self.chain_config, address, amount) + .map_err(ControllerError::WalletError)?; + + let utxo_output = self.fetch_utxo(&selected_utxo).await?; + let change_address = if let Some(change_address) = change_address { + change_address + } else { + let utxo_dest = + get_tx_output_destination(&utxo_output, &|_| None).ok_or_else(|| { + ControllerError::WalletError(WalletError::UnsupportedTransactionOutput( + Box::new(utxo_output.clone()), + )) + })?; + Address::new(self.chain_config, &utxo_dest).expect("addressable") + }; + + let selected_inputs = SelectedInputs::Inputs(vec![(selected_utxo, utxo_output)]); + + let (current_fee_rate, consolidate_fee_rate) = + self.get_current_and_consolidation_fee_rate().await?; + + self.wallet + .create_unsigned_transaction_to_addresses( + self.account_index, + [output], + selected_inputs, + [(Currency::Coin, change_address)].into(), + current_fee_rate, + consolidate_fee_rate, + ) + .map_err(ControllerError::WalletError) + } + pub async fn create_delegation( &mut self, address: Address, @@ -455,7 +501,8 @@ impl<'a, T: NodeInterface, W: WalletEvents> SyncedController<'a, T, W> { wallet.create_transaction_to_addresses( account_index, [output], - vec![], + SelectedInputs::Utxos(vec![]), + BTreeMap::new(), current_fee_rate, consolidate_fee_rate, ) @@ -523,7 +570,8 @@ impl<'a, T: NodeInterface, W: WalletEvents> SyncedController<'a, T, W> { wallet.create_transaction_to_addresses( account_index, [output], - vec![], + SelectedInputs::Utxos(vec![]), + BTreeMap::new(), current_fee_rate, consolidate_fee_rate, ) @@ -757,4 +805,16 @@ impl<'a, T: NodeInterface, W: WalletEvents> SyncedController<'a, T, W> { let tx_id = self.broadcast_to_mempool(tx).await?; Ok((tx_id, id)) } + + async fn fetch_utxo(&self, input: &UtxoOutPoint) -> Result> { + let utxo = self + .rpc_client + .get_utxo(input.clone()) + .await + .map_err(ControllerError::NodeCallError)?; + + utxo.ok_or(ControllerError::WalletError(WalletError::CannotFindUtxo( + input.clone(), + ))) + } } diff --git a/wallet/wallet-rpc-lib/src/rpc/mod.rs b/wallet/wallet-rpc-lib/src/rpc/mod.rs index 8d9064881d..3302b64d22 100644 --- a/wallet/wallet-rpc-lib/src/rpc/mod.rs +++ b/wallet/wallet-rpc-lib/src/rpc/mod.rs @@ -429,6 +429,38 @@ impl WalletRpc { .await? } + pub async fn request_send_coins( + &self, + account_index: U31, + address: String, + amount_str: DecimalAmount, + selected_utxo: UtxoOutPoint, + change_address: Option, + config: ControllerConfig, + ) -> WRpcResult { + let decimals = self.chain_config.coin_decimals(); + let amount = amount_str.to_amount(decimals).ok_or(RpcError::InvalidCoinAmount)?; + let address = Address::from_str(&self.chain_config, &address) + .map_err(|_| RpcError::InvalidAddress)?; + let change_address = change_address + .map(|change| Address::::from_str(&self.chain_config, &change)) + .transpose() + .map_err(|_| RpcError::InvalidAddress)?; + + self.wallet + .call_async(move |controller| { + Box::pin(async move { + controller + .synced_controller(account_index, config) + .await? + .request_send_to_address(address, amount, selected_utxo, change_address) + .await + .map_err(RpcError::Controller) + }) + }) + .await? + } + pub async fn send_tokens( &self, account_index: U31,