From 4fa8d82a9711e75355000444ed7ed0ba6bb477ef Mon Sep 17 00:00:00 2001 From: Boris Oncev Date: Tue, 30 Jan 2024 20:01:22 +0100 Subject: [PATCH] Add wallet command to compose transactions with inputs and outputs --- chainstate/src/rpc/mod.rs | 15 +- common/src/chain/tokens/issuance.rs | 82 ++++++- common/src/chain/tokens/mod.rs | 28 ++- common/src/chain/tokens/nft.rs | 19 +- common/src/chain/transaction/output/mod.rs | 2 +- .../chain/transaction/output/output_value.rs | 2 +- .../src/chain/transaction/output/stakelock.rs | 13 +- .../src/chain/transaction/output/timelock.rs | 13 +- common/src/chain/transaction/utxo_outpoint.rs | 26 +- common/src/primitives/per_thousand.rs | 4 +- crypto/src/key/mod.rs | 9 + crypto/src/vrf/mod.rs | 9 + node-gui/src/backend/backend_impl.rs | 2 +- node-gui/src/backend/messages.rs | 2 +- .../main_widget/tabs/wallet/top_panel.rs | 2 +- serialization/src/extras/non_empty_vec.rs | 2 +- .../test_framework/wallet_cli_controller.py | 11 + test/functional/test_runner.py | 1 + wallet/src/account/currency_grouper/mod.rs | 177 ++++++++++++++ wallet/src/account/mod.rs | 231 +++--------------- wallet/src/account/transaction_list/mod.rs | 11 +- wallet/src/wallet/mod.rs | 4 +- wallet/src/wallet/tests.rs | 1 + .../src/commands/helper_types.rs | 57 ++++- wallet/wallet-cli-lib/src/commands/mod.rs | 53 +++- wallet/wallet-controller/src/lib.rs | 133 +++++++++- wallet/wallet-controller/src/read.rs | 30 +-- .../wallet-controller/src/sync/tests/mod.rs | 7 + .../src/handles_client/mod.rs | 12 + wallet/wallet-node-client/src/node_traits.rs | 5 +- .../src/rpc_client/client_impl.rs | 9 +- .../src/rpc_client/cold_wallet_client.rs | 7 + wallet/wallet-rpc-lib/src/rpc/mod.rs | 12 + 33 files changed, 734 insertions(+), 257 deletions(-) create mode 100644 wallet/src/account/currency_grouper/mod.rs diff --git a/chainstate/src/rpc/mod.rs b/chainstate/src/rpc/mod.rs index d62077f271..f32fab73f2 100644 --- a/chainstate/src/rpc/mod.rs +++ b/chainstate/src/rpc/mod.rs @@ -30,7 +30,7 @@ use common::{ address::dehexify::to_dehexified_json, chain::{ tokens::{RPCTokenInfo, TokenId}, - ChainConfig, DelegationId, PoolId, + ChainConfig, DelegationId, PoolId, TxOutput, UtxoOutPoint, }, primitives::{Amount, BlockHeight, Id}, }; @@ -63,6 +63,10 @@ trait ChainstateRpc { max_count: usize, ) -> RpcResult>>; + /// Returns the TxOutput for a specified UtxoOutPoint. + #[method(name = "get_utxo")] + async fn utxo(&self, outpoint: UtxoOutPoint) -> RpcResult>; + /// Submit a block to be included in the chain #[method(name = "submit_block")] async fn submit_block(&self, block_hex: HexEncoded) -> RpcResult<()>; @@ -187,6 +191,15 @@ impl ChainstateRpcServer for super::ChainstateHandle { Ok(blocks.into_iter().map(HexEncoded::new).collect()) } + async fn utxo(&self, outpoint: UtxoOutPoint) -> RpcResult> { + rpc::handle_result( + self.call_mut(move |this| { + this.utxo(&outpoint).map(|utxo| utxo.map(|utxo| utxo.take_output())) + }) + .await, + ) + } + async fn submit_block(&self, block: HexEncoded) -> RpcResult<()> { let res = self .call_mut(move |this| this.process_block(block.take(), BlockSource::Local)) diff --git a/common/src/chain/tokens/issuance.rs b/common/src/chain/tokens/issuance.rs index 85783e0d6c..c5f202eacf 100644 --- a/common/src/chain/tokens/issuance.rs +++ b/common/src/chain/tokens/issuance.rs @@ -17,7 +17,19 @@ use super::Destination; use crate::primitives::Amount; use serialization::{Decode, Encode}; -#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Encode, Decode, serde::Serialize)] +#[derive( + Debug, + Copy, + Clone, + PartialEq, + Eq, + PartialOrd, + Ord, + Encode, + Decode, + serde::Serialize, + serde::Deserialize, +)] pub enum TokenTotalSupply { #[codec(index = 0)] Fixed(Amount), // fixed to a certain amount @@ -28,7 +40,19 @@ pub enum TokenTotalSupply { } // Indicates whether a token an be frozen -#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Encode, Decode, serde::Serialize)] +#[derive( + Debug, + Copy, + Clone, + PartialEq, + Eq, + PartialOrd, + Ord, + Encode, + Decode, + serde::Serialize, + serde::Deserialize, +)] pub enum IsTokenFreezable { #[codec(index = 0)] No, @@ -37,7 +61,19 @@ pub enum IsTokenFreezable { } // Indicates whether a token an be unfrozen after being frozen -#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Encode, Decode, serde::Serialize)] +#[derive( + Debug, + Copy, + Clone, + PartialEq, + Eq, + PartialOrd, + Ord, + Encode, + Decode, + serde::Serialize, + serde::Deserialize, +)] pub enum IsTokenUnfreezable { #[codec(index = 0)] No, @@ -48,7 +84,19 @@ pub enum IsTokenUnfreezable { // Indicates whether a token is frozen at the moment or not. If it is then no operations wish this token can be performed. // Meaning transfers, burns, minting, unminting, supply locks etc. Frozen token can only be unfrozen // is such an option was provided while freezing. -#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Encode, Decode, serde::Serialize)] +#[derive( + Debug, + Copy, + Clone, + PartialEq, + Eq, + PartialOrd, + Ord, + Encode, + Decode, + serde::Serialize, + serde::Deserialize, +)] pub enum IsTokenFrozen { #[codec(index = 0)] No(IsTokenFreezable), @@ -56,13 +104,35 @@ pub enum IsTokenFrozen { Yes(IsTokenUnfreezable), } -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Encode, Decode, serde::Serialize)] +#[derive( + Debug, + Clone, + PartialEq, + Eq, + PartialOrd, + Ord, + Encode, + Decode, + serde::Serialize, + serde::Deserialize, +)] pub enum TokenIssuance { #[codec(index = 1)] V1(TokenIssuanceV1), } -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Encode, Decode, serde::Serialize)] +#[derive( + Debug, + Clone, + PartialEq, + Eq, + PartialOrd, + Ord, + Encode, + Decode, + serde::Serialize, + serde::Deserialize, +)] pub struct TokenIssuanceV1 { pub token_ticker: Vec, pub number_of_decimals: u8, diff --git a/common/src/chain/tokens/mod.rs b/common/src/chain/tokens/mod.rs index 18dbaa67b3..b8b64177be 100644 --- a/common/src/chain/tokens/mod.rs +++ b/common/src/chain/tokens/mod.rs @@ -58,13 +58,35 @@ impl TokenAuxiliaryData { } } -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Encode, Decode, serde::Serialize)] +#[derive( + Debug, + Clone, + PartialEq, + Eq, + PartialOrd, + Ord, + Encode, + Decode, + serde::Serialize, + serde::Deserialize, +)] pub struct TokenTransfer { pub token_id: TokenId, pub amount: Amount, } -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Encode, Decode, serde::Serialize)] +#[derive( + Debug, + Clone, + PartialEq, + Eq, + PartialOrd, + Ord, + Encode, + Decode, + serde::Serialize, + serde::Deserialize, +)] pub struct TokenIssuanceV0 { pub token_ticker: Vec, pub amount_to_issue: Amount, @@ -72,7 +94,7 @@ pub struct TokenIssuanceV0 { pub metadata_uri: Vec, } -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, serde::Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, serde::Serialize, serde::Deserialize)] pub enum TokenData { /// TokenTransfer data to another user. If it is a token, then the token data must also be transferred to the recipient. #[codec(index = 1)] diff --git a/common/src/chain/tokens/nft.rs b/common/src/chain/tokens/nft.rs index 9d6b722544..687a8e67d1 100644 --- a/common/src/chain/tokens/nft.rs +++ b/common/src/chain/tokens/nft.rs @@ -16,13 +16,13 @@ use crypto::key::PublicKey; use serialization::{extras::non_empty_vec::DataOrNoVec, Decode, Encode}; -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, serde::Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, serde::Serialize, serde::Deserialize)] pub enum NftIssuance { #[codec(index = 0)] V0(NftIssuanceV0), } -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, serde::Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, serde::Serialize, serde::Deserialize)] pub struct NftIssuanceV0 { pub metadata: Metadata, // TODO: Implement after additional research payout, royalty and refund. @@ -35,7 +35,18 @@ impl From for NftIssuance { } } -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Encode, Decode, serde::Serialize)] +#[derive( + Debug, + Clone, + PartialEq, + Eq, + PartialOrd, + Ord, + Encode, + Decode, + serde::Serialize, + serde::Deserialize, +)] pub struct TokenCreator { pub public_key: PublicKey, } @@ -46,7 +57,7 @@ impl From for TokenCreator { } } -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, serde::Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, serde::Serialize, serde::Deserialize)] pub struct Metadata { pub creator: Option, pub name: Vec, diff --git a/common/src/chain/transaction/output/mod.rs b/common/src/chain/transaction/output/mod.rs index 05a24cc3a6..090402f6ac 100644 --- a/common/src/chain/transaction/output/mod.rs +++ b/common/src/chain/transaction/output/mod.rs @@ -85,7 +85,7 @@ impl Addressable for Destination { } } -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, serde::Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, serde::Serialize, serde::Deserialize)] pub enum TxOutput { #[codec(index = 0)] Transfer(OutputValue, Destination), diff --git a/common/src/chain/transaction/output/output_value.rs b/common/src/chain/transaction/output/output_value.rs index 088e781db5..29c0746254 100644 --- a/common/src/chain/transaction/output/output_value.rs +++ b/common/src/chain/transaction/output/output_value.rs @@ -20,7 +20,7 @@ use crate::{ primitives::Amount, }; -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, serde::Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, serde::Serialize, serde::Deserialize)] pub enum OutputValue { Coin(Amount), TokenV0(Box), diff --git a/common/src/chain/transaction/output/stakelock.rs b/common/src/chain/transaction/output/stakelock.rs index 1ef46c2bd5..2c752ea939 100644 --- a/common/src/chain/transaction/output/stakelock.rs +++ b/common/src/chain/transaction/output/stakelock.rs @@ -20,7 +20,18 @@ use crate::primitives::{per_thousand::PerThousand, Amount}; use super::Destination; -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Encode, Decode, serde::Serialize)] +#[derive( + Debug, + Clone, + PartialEq, + Eq, + PartialOrd, + Ord, + Encode, + Decode, + serde::Serialize, + serde::Deserialize, +)] pub struct StakePoolData { value: Amount, staker: Destination, diff --git a/common/src/chain/transaction/output/timelock.rs b/common/src/chain/transaction/output/timelock.rs index c55c76730a..b417de8053 100644 --- a/common/src/chain/transaction/output/timelock.rs +++ b/common/src/chain/transaction/output/timelock.rs @@ -17,7 +17,18 @@ use serialization::{Decode, Encode}; use crate::{chain::block::timestamp::BlockTimestamp, primitives::BlockHeight}; -#[derive(Debug, Clone, Ord, PartialOrd, Eq, PartialEq, Encode, Decode, serde::Serialize)] +#[derive( + Debug, + Clone, + Ord, + PartialOrd, + Eq, + PartialEq, + Encode, + Decode, + serde::Serialize, + serde::Deserialize, +)] pub enum OutputTimeLock { #[codec(index = 0)] UntilHeight(BlockHeight), diff --git a/common/src/chain/transaction/utxo_outpoint.rs b/common/src/chain/transaction/utxo_outpoint.rs index 46a4cc374d..0d5ea5374e 100644 --- a/common/src/chain/transaction/utxo_outpoint.rs +++ b/common/src/chain/transaction/utxo_outpoint.rs @@ -17,7 +17,18 @@ use crate::chain::{transaction::Transaction, Block, GenBlock, Genesis}; use crate::primitives::Id; use serialization::{Decode, Encode}; -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, Ord, PartialOrd, serde::Serialize)] +#[derive( + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + Ord, + PartialOrd, + serde::Serialize, + serde::Deserialize, +)] pub enum OutPointSourceId { #[codec(index = 0)] Transaction(Id), @@ -58,7 +69,18 @@ impl OutPointSourceId { } } -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, Ord, PartialOrd, serde::Serialize)] +#[derive( + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + Ord, + PartialOrd, + serde::Serialize, + serde::Deserialize, +)] pub struct UtxoOutPoint { id: OutPointSourceId, index: u32, diff --git a/common/src/primitives/per_thousand.rs b/common/src/primitives/per_thousand.rs index e5dd5de443..4f305afdec 100644 --- a/common/src/primitives/per_thousand.rs +++ b/common/src/primitives/per_thousand.rs @@ -20,7 +20,9 @@ use super::Amount; const DENOMINATOR: u16 = 1000; -#[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Encode, Debug, serde::Serialize)] +#[derive( + PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Encode, Debug, serde::Serialize, serde::Deserialize, +)] pub struct PerThousand(u16); impl PerThousand { diff --git a/crypto/src/key/mod.rs b/crypto/src/key/mod.rs index 506a7a3c42..214e0f7e38 100644 --- a/crypto/src/key/mod.rs +++ b/crypto/src/key/mod.rs @@ -63,6 +63,15 @@ impl serde::Serialize for PublicKey { } } +impl<'d> serde::Deserialize<'d> for PublicKey { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'d>, + { + HexEncoded::::deserialize(deserializer).map(|hex| hex.take()) + } +} + impl PrivateKey { pub fn new_from_entropy(key_kind: KeyKind) -> (PrivateKey, PublicKey) { Self::new_from_rng(&mut make_true_rng(), key_kind) diff --git a/crypto/src/vrf/mod.rs b/crypto/src/vrf/mod.rs index 6e21754655..67fd07693c 100644 --- a/crypto/src/vrf/mod.rs +++ b/crypto/src/vrf/mod.rs @@ -76,6 +76,15 @@ impl serde::Serialize for VRFPublicKey { } } +impl<'d> serde::Deserialize<'d> for VRFPublicKey { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'d>, + { + HexEncoded::::deserialize(deserializer).map(|hex| hex.take()) + } +} + #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Decode, Encode)] pub(crate) enum VRFPublicKeyHolder { #[codec(index = 0)] diff --git a/node-gui/src/backend/backend_impl.rs b/node-gui/src/backend/backend_impl.rs index 35a458efd3..b034e9b8a0 100644 --- a/node-gui/src/backend/backend_impl.rs +++ b/node-gui/src/backend/backend_impl.rs @@ -28,7 +28,7 @@ use tokio::{ task::JoinHandle, }; use wallet::{ - account::{transaction_list::TransactionList, Currency}, + account::{currency_grouper::Currency, transaction_list::TransactionList}, DefaultWallet, }; use wallet_controller::{ diff --git a/node-gui/src/backend/messages.rs b/node-gui/src/backend/messages.rs index 0900173965..0a588733a9 100644 --- a/node-gui/src/backend/messages.rs +++ b/node-gui/src/backend/messages.rs @@ -27,7 +27,7 @@ use common::{ }; use crypto::key::hdkd::{child_number::ChildNumber, u31::U31}; use p2p::P2pEvent; -use wallet::account::{transaction_list::TransactionList, Currency, PoolData}; +use wallet::account::{currency_grouper::Currency, transaction_list::TransactionList, PoolData}; use super::BackendError; diff --git a/node-gui/src/main_window/main_widget/tabs/wallet/top_panel.rs b/node-gui/src/main_window/main_widget/tabs/wallet/top_panel.rs index a49fae9493..dc4ad87cfc 100644 --- a/node-gui/src/main_window/main_widget/tabs/wallet/top_panel.rs +++ b/node-gui/src/main_window/main_widget/tabs/wallet/top_panel.rs @@ -18,7 +18,7 @@ use iced::{ widget::{button, row, tooltip, Row, Text}, Alignment, Element, Length, }; -use wallet::account::Currency; +use wallet::account::currency_grouper::Currency; use crate::{ backend::messages::{AccountInfo, EncryptionState, WalletInfo}, diff --git a/serialization/src/extras/non_empty_vec.rs b/serialization/src/extras/non_empty_vec.rs index 7ecf83a650..02fe07f17c 100644 --- a/serialization/src/extras/non_empty_vec.rs +++ b/serialization/src/extras/non_empty_vec.rs @@ -21,7 +21,7 @@ use serialization_core::{Decode, Encode}; /// - If the Vec has data, it encodes to just the Vec, the Option is omitted /// - If the Vec has no data, it encodes to None /// - Some(vec![]) and None are equivalent when encoded, but when decoded result in None -#[derive(Debug, PartialEq, Eq, Clone, serde::Serialize)] +#[derive(Debug, PartialEq, Eq, Clone, serde::Serialize, serde::Deserialize)] pub struct DataOrNoVec(Option>); impl AsRef>> for DataOrNoVec { diff --git a/test/functional/test_framework/wallet_cli_controller.py b/test/functional/test_framework/wallet_cli_controller.py index 95155b81ce..545b5febc8 100644 --- a/test/functional/test_framework/wallet_cli_controller.py +++ b/test/functional/test_framework/wallet_cli_controller.py @@ -36,6 +36,14 @@ class UtxoOutpoint: def __str__(self): return f'tx({self.id},{self.index})' +@dataclass +class TxOutput: + address: str + amount: str + + def __str__(self): + return f'transfer({self.address},{self.amount})' + @dataclass class PoolData: pool_id: str @@ -196,6 +204,9 @@ async def get_raw_signed_transaction(self, tx_id: str) -> str: 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") + async def compose_transaction(self, outputs: List[TxOutput], selected_utxos: List[UtxoOutpoint]) -> str: + return await self._write_command(f"transaction-compose {' '.join(map(str, outputs))} --utxos {' '.join(map(str, selected_utxos))}\n") + async def send_tokens_to_address(self, token_id: str, address: str, amount: Union[float, str]): return await self._write_command(f"token-send {token_id} {address} {amount}\n") diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py index e2bcb47296..3b142dc4a3 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_tx_compose.py', 'wallet_data_deposit.py', 'wallet_submit_tx.py', 'wallet_select_utxos.py', diff --git a/wallet/src/account/currency_grouper/mod.rs b/wallet/src/account/currency_grouper/mod.rs new file mode 100644 index 0000000000..93e48280ed --- /dev/null +++ b/wallet/src/account/currency_grouper/mod.rs @@ -0,0 +1,177 @@ +// Copyright (c) 2023 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. + +use crate::{WalletError, WalletResult}; + +use std::collections::BTreeMap; + +use common::{ + chain::{output_value::OutputValue, tokens::TokenId, ChainConfig, TxOutput}, + primitives::{Amount, BlockHeight}, +}; + +#[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Debug, serde::Serialize, serde::Deserialize)] +pub enum Currency { + Coin, + Token(TokenId), +} + +pub(crate) fn group_outputs( + outputs: impl Iterator, + get_tx_output: impl Fn(&T) -> &TxOutput, + mut combiner: impl FnMut(&mut Grouped, &T, Amount) -> WalletResult<()>, + init: Grouped, +) -> WalletResult> { + let mut coin_grouped = init.clone(); + let mut tokens_grouped: BTreeMap = BTreeMap::new(); + + // Iterate over all outputs and group them up by currency + for output in outputs { + // Get the supported output value + let output_value = match get_tx_output(&output) { + TxOutput::Transfer(v, _) | TxOutput::LockThenTransfer(v, _, _) | TxOutput::Burn(v) => { + v.clone() + } + TxOutput::CreateStakePool(_, stake) => OutputValue::Coin(stake.value()), + TxOutput::DelegateStaking(amount, _) => OutputValue::Coin(*amount), + TxOutput::CreateDelegationId(_, _) + | TxOutput::IssueFungibleToken(_) + | TxOutput::IssueNft(_, _, _) + | TxOutput::DataDeposit(_) => continue, + TxOutput::ProduceBlockFromStake(_, _) => { + return Err(WalletError::UnsupportedTransactionOutput(Box::new( + get_tx_output(&output).clone(), + ))) + } + }; + + match output_value { + OutputValue::Coin(output_amount) => { + combiner(&mut coin_grouped, &output, output_amount)?; + } + OutputValue::TokenV0(_) => { /* ignore */ } + OutputValue::TokenV1(id, amount) => { + let total_token_amount = + tokens_grouped.entry(Currency::Token(id)).or_insert_with(|| init.clone()); + + combiner(total_token_amount, &output, amount)?; + } + } + } + + tokens_grouped.insert(Currency::Coin, coin_grouped); + Ok(tokens_grouped) +} + +pub fn group_outputs_with_issuance_fee( + outputs: impl Iterator, + get_tx_output: impl Fn(&T) -> &TxOutput, + mut combiner: impl FnMut(&mut Grouped, &T, Amount) -> WalletResult<()>, + init: Grouped, + chain_config: &ChainConfig, + block_height: BlockHeight, +) -> WalletResult> { + let mut coin_grouped = init.clone(); + let mut tokens_grouped: BTreeMap = BTreeMap::new(); + + // Iterate over all outputs and group them up by currency + for output in outputs { + // Get the supported output value + let output_value = match get_tx_output(&output) { + TxOutput::Transfer(v, _) | TxOutput::LockThenTransfer(v, _, _) | TxOutput::Burn(v) => { + v.clone() + } + TxOutput::CreateStakePool(_, stake) => OutputValue::Coin(stake.value()), + TxOutput::DelegateStaking(amount, _) => OutputValue::Coin(*amount), + TxOutput::IssueFungibleToken(_) => { + OutputValue::Coin(chain_config.fungible_token_issuance_fee()) + } + TxOutput::IssueNft(_, _, _) => { + OutputValue::Coin(chain_config.nft_issuance_fee(block_height)) + } + TxOutput::DataDeposit(_) => OutputValue::Coin(chain_config.data_deposit_fee()), + TxOutput::CreateDelegationId(_, _) => continue, + TxOutput::ProduceBlockFromStake(_, _) => { + return Err(WalletError::UnsupportedTransactionOutput(Box::new( + get_tx_output(&output).clone(), + ))) + } + }; + + match output_value { + OutputValue::Coin(output_amount) => { + combiner(&mut coin_grouped, &output, output_amount)?; + } + OutputValue::TokenV0(_) => { /* ignore */ } + OutputValue::TokenV1(id, amount) => { + let total_token_amount = + tokens_grouped.entry(Currency::Token(id)).or_insert_with(|| init.clone()); + + combiner(total_token_amount, &output, amount)?; + } + } + } + + tokens_grouped.insert(Currency::Coin, coin_grouped); + Ok(tokens_grouped) +} + +pub fn group_utxos_for_input( + outputs: impl Iterator, + get_tx_output: impl Fn(&T) -> &TxOutput, + mut combiner: impl FnMut(&mut Grouped, &T, Amount) -> WalletResult<()>, + init: Grouped, +) -> WalletResult> { + let mut coin_grouped = init.clone(); + let mut tokens_grouped: BTreeMap = BTreeMap::new(); + + // Iterate over all outputs and group them up by currency + for output in outputs { + // Get the supported output value + let output_value = match get_tx_output(&output) { + TxOutput::Transfer(v, _) | TxOutput::LockThenTransfer(v, _, _) => v.clone(), + TxOutput::CreateStakePool(_, stake) => OutputValue::Coin(stake.value()), + TxOutput::IssueNft(token_id, _, _) => { + OutputValue::TokenV1(*token_id, Amount::from_atoms(1)) + } + TxOutput::ProduceBlockFromStake(_, _) + | TxOutput::Burn(_) + | TxOutput::CreateDelegationId(_, _) + | TxOutput::DelegateStaking(_, _) + | TxOutput::IssueFungibleToken(_) + | TxOutput::DataDeposit(_) => { + return Err(WalletError::UnsupportedTransactionOutput(Box::new( + get_tx_output(&output).clone(), + ))) + } + }; + + match output_value { + OutputValue::Coin(output_amount) => { + combiner(&mut coin_grouped, &output, output_amount)?; + } + OutputValue::TokenV0(_) => { /* ignore */ } + OutputValue::TokenV1(id, amount) => { + let total_token_amount = + tokens_grouped.entry(Currency::Token(id)).or_insert_with(|| init.clone()); + + combiner(total_token_amount, &output, amount)?; + } + } + } + + tokens_grouped.insert(Currency::Coin, coin_grouped); + Ok(tokens_grouped) +} diff --git a/wallet/src/account/mod.rs b/wallet/src/account/mod.rs index 4d84d6f7c5..a2cbb8063e 100644 --- a/wallet/src/account/mod.rs +++ b/wallet/src/account/mod.rs @@ -13,6 +13,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +pub mod currency_grouper; mod output_cache; pub mod transaction_list; mod utxo_selector; @@ -264,9 +265,9 @@ impl Account { consolidate_fee_rate: FeeRate, ) -> WalletResult { // TODO: allow to pay fees with different currency? - let pay_fee_with_currency = Currency::Coin; + let pay_fee_with_currency = currency_grouper::Currency::Coin; - let mut output_currency_amounts = group_outputs_with_issuance_fee( + let mut output_currency_amounts = currency_grouper::group_outputs_with_issuance_fee( request.outputs().iter(), |&output| output, |grouped: &mut Amount, _, new_amount| -> WalletResult<()> { @@ -334,8 +335,8 @@ impl Account { preselected_inputs.remove(currency).unwrap_or((Amount::ZERO, Amount::ZERO)); let cost_of_change = match currency { - Currency::Coin => coin_change_fee, - Currency::Token(_) => token_change_fee, + currency_grouper::Currency::Coin => coin_change_fee, + currency_grouper::Currency::Token(_) => token_change_fee, }; let selection_result = select_coins( utxos, @@ -382,8 +383,8 @@ impl Account { .ok_or(WalletError::OutputAmountOverflow)?; let cost_of_change = match pay_fee_with_currency { - Currency::Coin => coin_change_fee, - Currency::Token(_) => token_change_fee, + currency_grouper::Currency::Coin => coin_change_fee, + currency_grouper::Currency::Token(_) => token_change_fee, }; let selection_result = select_coins( @@ -417,8 +418,8 @@ impl Account { fn check_outputs_and_add_change( &mut self, - output_currency_amounts: BTreeMap, - selected_inputs: BTreeMap, + output_currency_amounts: BTreeMap, + selected_inputs: BTreeMap, db_tx: &mut impl WalletStorageWriteLocked, mut request: SendRequest, ) -> Result { @@ -429,12 +430,12 @@ impl Account { if change_amount > Amount::ZERO { let (_, change_address) = self.get_new_address(db_tx, KeyPurpose::Change)?; let change_output = match currency { - Currency::Coin => make_address_output( + currency_grouper::Currency::Coin => make_address_output( self.chain_config.as_ref(), change_address, change_amount, )?, - Currency::Token(token_id) => make_address_output_token( + currency_grouper::Currency::Token(token_id) => make_address_output_token( self.chain_config.as_ref(), change_address, change_amount, @@ -455,9 +456,9 @@ impl Account { &self, current_fee_rate: FeeRate, consolidate_fee_rate: FeeRate, - pay_fee_with_currency: &Currency, + pay_fee_with_currency: ¤cy_grouper::Currency, utxos: Vec<(UtxoOutPoint, (&TxOutput, Option))>, - ) -> Result>, WalletError> { + ) -> Result>, WalletError> { let utxo_to_output_group = |(outpoint, txo): (UtxoOutPoint, TxOutput)| -> WalletResult { let tx_input: TxInput = outpoint.into(); @@ -480,7 +481,7 @@ impl Account { Ok(out_group) }; - group_utxos_for_input( + currency_grouper::group_utxos_for_input( utxos.into_iter(), |(_, (tx_output, _))| tx_output, |grouped: &mut Vec<(UtxoOutPoint, TxOutput)>, element, _| -> WalletResult<()> { @@ -491,7 +492,7 @@ impl Account { )? .into_iter() .map( - |(currency, utxos)| -> WalletResult<(Currency, Vec)> { + |(currency, utxos)| -> WalletResult<(currency_grouper::Currency, Vec)> { let utxo_groups = utxos .into_iter() // TODO: group outputs by destination @@ -1448,8 +1449,8 @@ impl Account { utxo_states: UtxoStates, median_time: BlockTimestamp, with_locked: WithLocked, - ) -> WalletResult> { - let amounts_by_currency = group_utxos_for_input( + ) -> WalletResult> { + let amounts_by_currency = currency_grouper::group_utxos_for_input( self.get_utxos(utxo_types, median_time, utxo_states, with_locked).into_iter(), |(_, (tx_output, _))| tx_output, |total: &mut Amount, _, amount| -> WalletResult<()> { @@ -1852,7 +1853,7 @@ fn group_preselected_inputs( current_fee_rate: FeeRate, chain_config: &ChainConfig, block_height: BlockHeight, -) -> Result, WalletError> { +) -> Result, WalletError> { let mut preselected_inputs = BTreeMap::new(); for (input, destination) in request.inputs().iter().zip(request.destinations()) { let input_size = serialization::Encode::encoded_size(&input); @@ -1862,34 +1863,36 @@ fn group_preselected_inputs( .compute_fee(input_size + inp_sig_size) .map_err(|_| UtxoSelectorError::AmountArithmeticError)?; - let mut update_preselected_inputs = - |currency: Currency, amount: Amount, fee: Amount| -> WalletResult<()> { - match preselected_inputs.entry(currency) { - Entry::Vacant(entry) => { - entry.insert((amount, fee)); - } - Entry::Occupied(mut entry) => { - let (existing_amount, existing_fee) = entry.get_mut(); - *existing_amount = - (*existing_amount + amount).ok_or(WalletError::OutputAmountOverflow)?; - *existing_fee = - (*existing_fee + fee).ok_or(WalletError::OutputAmountOverflow)?; - } + let mut update_preselected_inputs = |currency: currency_grouper::Currency, + amount: Amount, + fee: Amount| + -> WalletResult<()> { + match preselected_inputs.entry(currency) { + Entry::Vacant(entry) => { + entry.insert((amount, fee)); } - Ok(()) - }; + Entry::Occupied(mut entry) => { + let (existing_amount, existing_fee) = entry.get_mut(); + *existing_amount = + (*existing_amount + amount).ok_or(WalletError::OutputAmountOverflow)?; + *existing_fee = + (*existing_fee + fee).ok_or(WalletError::OutputAmountOverflow)?; + } + } + Ok(()) + }; match input { TxInput::Utxo(_) => {} TxInput::Account(outpoint) => match outpoint.account() { AccountSpending::DelegationBalance(_, amount) => { - update_preselected_inputs(Currency::Coin, *amount, *fee)?; + update_preselected_inputs(currency_grouper::Currency::Coin, *amount, *fee)?; } }, TxInput::AccountCommand(_, op) => match op { AccountCommand::MintTokens(token_id, amount) => { update_preselected_inputs( - Currency::Token(*token_id), + currency_grouper::Currency::Token(*token_id), *amount, (*fee + chain_config.token_supply_change_fee(block_height)) .ok_or(WalletError::OutputAmountOverflow)?, @@ -1898,7 +1901,7 @@ fn group_preselected_inputs( AccountCommand::LockTokenSupply(token_id) | AccountCommand::UnmintTokens(token_id) => { update_preselected_inputs( - Currency::Token(*token_id), + currency_grouper::Currency::Token(*token_id), Amount::ZERO, (*fee + chain_config.token_supply_change_fee(block_height)) .ok_or(WalletError::OutputAmountOverflow)?, @@ -1907,7 +1910,7 @@ fn group_preselected_inputs( AccountCommand::FreezeToken(token_id, _) | AccountCommand::UnfreezeToken(token_id) => { update_preselected_inputs( - Currency::Token(*token_id), + currency_grouper::Currency::Token(*token_id), Amount::ZERO, (*fee + chain_config.token_freeze_fee(block_height)) .ok_or(WalletError::OutputAmountOverflow)?, @@ -1915,7 +1918,7 @@ fn group_preselected_inputs( } AccountCommand::ChangeTokenAuthority(token_id, _) => { update_preselected_inputs( - Currency::Token(*token_id), + currency_grouper::Currency::Token(*token_id), Amount::ZERO, (*fee + chain_config.token_change_authority_fee(block_height)) .ok_or(WalletError::OutputAmountOverflow)?, @@ -1927,160 +1930,6 @@ fn group_preselected_inputs( Ok(preselected_inputs) } -#[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Debug, serde::Serialize, serde::Deserialize)] -pub enum Currency { - Coin, - Token(TokenId), -} - -fn group_outputs( - outputs: impl Iterator, - get_tx_output: impl Fn(&T) -> &TxOutput, - mut combiner: impl FnMut(&mut Grouped, &T, Amount) -> WalletResult<()>, - init: Grouped, -) -> WalletResult> { - let mut coin_grouped = init.clone(); - let mut tokens_grouped: BTreeMap = BTreeMap::new(); - - // Iterate over all outputs and group them up by currency - for output in outputs { - // Get the supported output value - let output_value = match get_tx_output(&output) { - TxOutput::Transfer(v, _) | TxOutput::LockThenTransfer(v, _, _) | TxOutput::Burn(v) => { - v.clone() - } - TxOutput::CreateStakePool(_, stake) => OutputValue::Coin(stake.value()), - TxOutput::DelegateStaking(amount, _) => OutputValue::Coin(*amount), - TxOutput::CreateDelegationId(_, _) - | TxOutput::IssueFungibleToken(_) - | TxOutput::IssueNft(_, _, _) - | TxOutput::DataDeposit(_) => continue, - TxOutput::ProduceBlockFromStake(_, _) => { - return Err(WalletError::UnsupportedTransactionOutput(Box::new( - get_tx_output(&output).clone(), - ))) - } - }; - - match output_value { - OutputValue::Coin(output_amount) => { - combiner(&mut coin_grouped, &output, output_amount)?; - } - OutputValue::TokenV0(_) => { /* ignore */ } - OutputValue::TokenV1(id, amount) => { - let total_token_amount = - tokens_grouped.entry(Currency::Token(id)).or_insert_with(|| init.clone()); - - combiner(total_token_amount, &output, amount)?; - } - } - } - - tokens_grouped.insert(Currency::Coin, coin_grouped); - Ok(tokens_grouped) -} - -fn group_outputs_with_issuance_fee( - outputs: impl Iterator, - get_tx_output: impl Fn(&T) -> &TxOutput, - mut combiner: impl FnMut(&mut Grouped, &T, Amount) -> WalletResult<()>, - init: Grouped, - chain_config: &ChainConfig, - block_height: BlockHeight, -) -> WalletResult> { - let mut coin_grouped = init.clone(); - let mut tokens_grouped: BTreeMap = BTreeMap::new(); - - // Iterate over all outputs and group them up by currency - for output in outputs { - // Get the supported output value - let output_value = match get_tx_output(&output) { - TxOutput::Transfer(v, _) | TxOutput::LockThenTransfer(v, _, _) | TxOutput::Burn(v) => { - v.clone() - } - TxOutput::CreateStakePool(_, stake) => OutputValue::Coin(stake.value()), - TxOutput::DelegateStaking(amount, _) => OutputValue::Coin(*amount), - TxOutput::IssueFungibleToken(_) => { - OutputValue::Coin(chain_config.fungible_token_issuance_fee()) - } - TxOutput::IssueNft(_, _, _) => { - OutputValue::Coin(chain_config.nft_issuance_fee(block_height)) - } - TxOutput::DataDeposit(_) => OutputValue::Coin(chain_config.data_deposit_fee()), - TxOutput::CreateDelegationId(_, _) => continue, - TxOutput::ProduceBlockFromStake(_, _) => { - return Err(WalletError::UnsupportedTransactionOutput(Box::new( - get_tx_output(&output).clone(), - ))) - } - }; - - match output_value { - OutputValue::Coin(output_amount) => { - combiner(&mut coin_grouped, &output, output_amount)?; - } - OutputValue::TokenV0(_) => { /* ignore */ } - OutputValue::TokenV1(id, amount) => { - let total_token_amount = - tokens_grouped.entry(Currency::Token(id)).or_insert_with(|| init.clone()); - - combiner(total_token_amount, &output, amount)?; - } - } - } - - tokens_grouped.insert(Currency::Coin, coin_grouped); - Ok(tokens_grouped) -} - -fn group_utxos_for_input( - outputs: impl Iterator, - get_tx_output: impl Fn(&T) -> &TxOutput, - mut combiner: impl FnMut(&mut Grouped, &T, Amount) -> WalletResult<()>, - init: Grouped, -) -> WalletResult> { - let mut coin_grouped = init.clone(); - let mut tokens_grouped: BTreeMap = BTreeMap::new(); - - // Iterate over all outputs and group them up by currency - for output in outputs { - // Get the supported output value - let output_value = match get_tx_output(&output) { - TxOutput::Transfer(v, _) | TxOutput::LockThenTransfer(v, _, _) => v.clone(), - TxOutput::CreateStakePool(_, stake) => OutputValue::Coin(stake.value()), - TxOutput::IssueNft(token_id, _, _) => { - OutputValue::TokenV1(*token_id, Amount::from_atoms(1)) - } - TxOutput::ProduceBlockFromStake(_, _) - | TxOutput::Burn(_) - | TxOutput::CreateDelegationId(_, _) - | TxOutput::DelegateStaking(_, _) - | TxOutput::IssueFungibleToken(_) - | TxOutput::DataDeposit(_) => { - return Err(WalletError::UnsupportedTransactionOutput(Box::new( - get_tx_output(&output).clone(), - ))) - } - }; - - match output_value { - OutputValue::Coin(output_amount) => { - combiner(&mut coin_grouped, &output, output_amount)?; - } - OutputValue::TokenV0(_) => { /* ignore */ } - OutputValue::TokenV1(id, amount) => { - let total_token_amount = - tokens_grouped.entry(Currency::Token(id)).or_insert_with(|| init.clone()); - - combiner(total_token_amount, &output, amount)?; - } - } - } - - tokens_grouped.insert(Currency::Coin, coin_grouped); - Ok(tokens_grouped) -} - /// 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)> { diff --git a/wallet/src/account/transaction_list/mod.rs b/wallet/src/account/transaction_list/mod.rs index 4fc2ffc2fc..cbb434399a 100644 --- a/wallet/src/account/transaction_list/mod.rs +++ b/wallet/src/account/transaction_list/mod.rs @@ -26,7 +26,7 @@ use wallet_types::{ use crate::{key_chain::AccountKeyChain, WalletError, WalletResult}; -use super::{group_outputs, output_cache::OutputCache}; +use super::{currency_grouper::group_outputs, output_cache::OutputCache}; // TODO: Show send/recv addresses and amounts // TODO: Show token amounts @@ -182,9 +182,12 @@ fn get_transaction( Amount::ZERO, )?; - let recv_amount = *own_output_amounts.get(&super::Currency::Coin).unwrap_or(&Amount::ZERO); - let non_own_recv_amount = - *non_own_output_amounts.get(&super::Currency::Coin).unwrap_or(&Amount::ZERO); + let recv_amount = *own_output_amounts + .get(&super::currency_grouper::Currency::Coin) + .unwrap_or(&Amount::ZERO); + let non_own_recv_amount = *non_own_output_amounts + .get(&super::currency_grouper::Currency::Coin) + .unwrap_or(&Amount::ZERO); let tx_type = if own_inputs.len() == all_inputs.len() && own_outputs.len() == all_outputs.len() { diff --git a/wallet/src/wallet/mod.rs b/wallet/src/wallet/mod.rs index 31c0259a3d..c6f3edb720 100644 --- a/wallet/src/wallet/mod.rs +++ b/wallet/src/wallet/mod.rs @@ -19,8 +19,8 @@ use std::sync::Arc; use crate::account::transaction_list::TransactionList; use crate::account::{ - Currency, CurrentFeeRate, DelegationData, PartiallySignedTransaction, PoolData, - TransactionToSign, UnconfirmedTokenInfo, UtxoSelectorError, + currency_grouper::Currency, CurrentFeeRate, DelegationData, PartiallySignedTransaction, + PoolData, TransactionToSign, UnconfirmedTokenInfo, UtxoSelectorError, }; use crate::key_chain::{ make_account_path, make_path_to_vrf_key, KeyChainError, MasterKeyChain, LOOKAHEAD_SIZE, diff --git a/wallet/src/wallet/tests.rs b/wallet/src/wallet/tests.rs index c718442d68..9a360f0f67 100644 --- a/wallet/src/wallet/tests.rs +++ b/wallet/src/wallet/tests.rs @@ -14,6 +14,7 @@ // limitations under the License. use crate::{ + account::currency_grouper::Currency, key_chain::{make_account_path, LOOKAHEAD_SIZE}, send_request::{make_address_output, make_create_delegation_output}, wallet_events::WalletEventsNoOp, diff --git a/wallet/wallet-cli-lib/src/commands/helper_types.rs b/wallet/wallet-cli-lib/src/commands/helper_types.rs index 68736ac88a..0507e38475 100644 --- a/wallet/wallet-cli-lib/src/commands/helper_types.rs +++ b/wallet/wallet-cli-lib/src/commands/helper_types.rs @@ -19,11 +19,13 @@ use clap::ValueEnum; use wallet_controller::{NodeInterface, UtxoState, UtxoStates, UtxoType, UtxoTypes}; use common::{ + address::Address, chain::{ + output_value::OutputValue, tokens::{IsTokenFreezable, IsTokenUnfreezable, TokenTotalSupply}, - OutPointSourceId, UtxoOutPoint, + ChainConfig, OutPointSourceId, TxOutput, UtxoOutPoint, }, - primitives::{Amount, Id, H256}, + primitives::{Amount, DecimalAmount, Id, H256}, }; use wallet_rpc_lib::types::PoolInfo; use wallet_types::{seed_phrase::StoreSeedPhrase, with_locked::WithLocked}; @@ -190,6 +192,57 @@ pub fn parse_utxo_outpoint( Ok(UtxoOutPoint::new(source_id, output_index)) } +/// Parses a string into UtxoOutPoint +/// The string format is expected to be +/// transfer(address,amount) +/// +/// e.g transfer(tmt1qy7y8ra99sgmt97lu2kn249yds23pnp7xsv62p77,10.1) +pub fn parse_output( + mut input: String, + chain_config: &ChainConfig, +) -> Result> { + if !input.ends_with(')') { + return Err(WalletCliError::::InvalidInput( + "Invalid output format".into(), + )); + } + input.pop(); + + let mut parts: Vec<&str> = input.split('(').collect(); + let last = parts.pop().ok_or(WalletCliError::::InvalidInput( + "Invalid output format".to_owned(), + ))?; + parts.extend(last.split(',')); + + if parts.len() != 3 { + return Err(WalletCliError::::InvalidInput( + "Invalid output format".into(), + )); + } + + let dest = Address::from_str(chain_config, parts[1]) + .and_then(|addr| addr.decode_object(chain_config)) + .map_err(|err| WalletCliError::::InvalidInput(err.to_string()))?; + + let amount = DecimalAmount::from_str(parts[2]) + .map_err(|err| WalletCliError::::InvalidInput(err.to_string()))? + .to_amount(chain_config.coin_decimals()) + .ok_or(WalletCliError::::InvalidInput( + "invalid coins amount".to_string(), + ))?; + + let output = match parts[0] { + "transfer" => TxOutput::Transfer(OutputValue::Coin(amount), dest), + _ => { + return Err(WalletCliError::::InvalidInput( + "Invalid input: unknown ID type".into(), + )); + } + }; + + Ok(output) +} + /// Try to parse a total token supply from a string /// Valid values are "unlimited", "lockable" and "fixed(Amount)" pub fn parse_token_supply( diff --git a/wallet/wallet-cli-lib/src/commands/mod.rs b/wallet/wallet-cli-lib/src/commands/mod.rs index 6dc7cb25d7..2aed1bbee9 100644 --- a/wallet/wallet-cli-lib/src/commands/mod.rs +++ b/wallet/wallet-cli-lib/src/commands/mod.rs @@ -27,7 +27,7 @@ use common::{ address::Address, chain::{ tokens::{Metadata, TokenCreator}, - Block, ChainConfig, Destination, SignedTransaction, Transaction, UtxoOutPoint, + Block, ChainConfig, Destination, SignedTransaction, Transaction, TxOutput, UtxoOutPoint, }, primitives::{BlockHeight, DecimalAmount, Id, H256}, }; @@ -43,7 +43,10 @@ use wallet_rpc_lib::{ WalletService, }; -use crate::{commands::helper_types::parse_token_supply, errors::WalletCliError}; +use crate::{ + commands::helper_types::{parse_output, parse_token_supply}, + errors::WalletCliError, +}; use self::helper_types::{ format_delegation_info, format_pool_info, parse_utxo_outpoint, CliForceReduce, CliIsFreezable, @@ -608,6 +611,20 @@ pub enum WalletCommand { #[clap(hide = true)] GenerateBlocks { block_count: u32 }, + /// Send a given coin amount to a given address. The wallet will automatically calculate the required information + /// Optionally, one can also mention the utxos to be used. + #[clap(name = "transaction-compose")] + TransactionCompose { + /// The transaction outputs, in the format `transfer(address,amount)` + /// e.g. transfer(tmt1q8lhgxhycm8e6yk9zpnetdwtn03h73z70c3ha4l7,0.9) + outputs: Vec, + /// You can choose what utxos to spend (space separated as additional arguments). A utxo can be from a transaction output or a block reward output: + /// e.g tx(000000000000000000059fa50103b9683e51e5aba83b8a34c9b98ce67d66136c,1) or + /// block(000000000000000000059fa50103b9683e51e5aba83b8a34c9b98ce67d66136c,2) + #[arg(long="utxos", default_values_t = Vec::::new())] + utxos: Vec, + }, + /// Abandon an unconfirmed transaction in the wallet database, and make the consumed inputs available to be used again /// Note that this doesn't necessarily mean that the network will agree. This assumes the transaction is either still /// not confirmed in the network or somehow invalid. @@ -1186,6 +1203,38 @@ where Ok(Self::tx_submitted_command()) } + WalletCommand::TransactionCompose { outputs, utxos } => { + let outputs: Vec = outputs + .into_iter() + .map(|input| parse_output(input, chain_config)) + .collect::, WalletCliError>>()?; + + let input_utxos: Vec = utxos + .into_iter() + .map(parse_utxo_outpoint) + .collect::, WalletCliError>>( + )?; + + let (tx, fees) = self.wallet_rpc.compose_transaction(input_utxos, outputs).await?; + let (coins, tokens) = fees.into_coins_and_tokens(); + + let mut output = + format!("The hex encoded transaction is:\n{}\n", HexEncoded::new(tx)); + + writeln!(&mut output, "Fees that will be paid by the transaction:\nCoins amount: {coins}\n") + .expect("Writing to a memory buffer should not fail"); + + for (token_id, amount) in tokens { + let token_id = Address::new(chain_config, &token_id) + .expect("Encoding token id should never fail"); + writeln!(&mut output, "Token: {token_id} amount: {amount}") + .expect("Writing to a memory buffer should not fail"); + } + output.pop(); + + Ok(ConsoleCommand::Print(output)) + } + WalletCommand::AbandonTransaction { transaction_id } => { let selected_account = self.get_selected_acc()?; self.wallet_rpc diff --git a/wallet/wallet-controller/src/lib.rs b/wallet/wallet-controller/src/lib.rs index 74f23380ab..03b6a709ba 100644 --- a/wallet/wallet-controller/src/lib.rs +++ b/wallet/wallet-controller/src/lib.rs @@ -24,14 +24,16 @@ pub mod types; const NORMAL_DELAY: Duration = Duration::from_secs(1); const ERROR_DELAY: Duration = Duration::from_secs(10); -use futures::never::Never; +use futures::{never::Never, stream::FuturesUnordered, TryStreamExt}; use std::{ collections::{BTreeMap, BTreeSet}, fs, + ops::Add, path::{Path, PathBuf}, sync::Arc, time::Duration, }; +use types::Balances; use read::ReadOnlyController; use sync::InSync; @@ -41,11 +43,12 @@ use common::{ address::AddressError, chain::{ tokens::{RPCTokenInfo, TokenId}, - Block, ChainConfig, GenBlock, PoolId, SignedTransaction, Transaction, TxOutput, + Block, ChainConfig, GenBlock, PoolId, SignedTransaction, Transaction, TxInput, TxOutput, + UtxoOutPoint, }, primitives::{ time::{get_time, Time}, - Amount, BlockHeight, Id, Idable, + Amount, BlockHeight, DecimalAmount, Id, Idable, }, }; use consensus::GenerateBlockInputData; @@ -62,8 +65,10 @@ pub use node_comm::{ rpc_client::NodeRpcClient, }; use wallet::{ - wallet::WalletPoolsFilter, wallet_events::WalletEvents, DefaultWallet, WalletError, - WalletResult, + account::currency_grouper::{self, Currency}, + wallet::WalletPoolsFilter, + wallet_events::WalletEvents, + DefaultWallet, WalletError, WalletResult, }; pub use wallet_types::{ account_info::DEFAULT_ACCOUNT_INDEX, @@ -604,6 +609,97 @@ impl Controll ) } + pub async fn compose_transaction( + &self, + inputs: Vec, + outputs: Vec, + ) -> Result<(Transaction, Balances), ControllerError> { + let fees = self.get_fees(&inputs, &outputs).await?; + let fees = into_balances(&self.rpc_client, &self.chain_config, fees).await?; + + let inputs = inputs.into_iter().map(TxInput::Utxo).collect(); + + let tx = Transaction::new(0, inputs, outputs) + .map_err(|err| ControllerError::WalletError(WalletError::TransactionCreation(err)))?; + + Ok((tx, fees)) + } + + async fn get_fees( + &self, + inputs: &[UtxoOutPoint], + outputs: &[TxOutput], + ) -> Result, ControllerError> { + let mut inputs = self.fetch_and_group_inputs(inputs).await?; + let outputs = self.group_outpus(outputs)?; + + 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 fee = (input_amount - output).ok_or(ControllerError::::WalletError( + WalletError::NotEnoughUtxo(input_amount, output), + ))?; + fees.insert(currency, fee); + } + // add any leftover inputs + fees.extend(inputs.into_iter()); + Ok(fees) + } + + fn group_outpus( + &self, + outputs: &[TxOutput], + ) -> Result, ControllerError> { + let best_block_height = self.best_block().1; + currency_grouper::group_outputs_with_issuance_fee( + outputs.iter(), + |&output| output, + |grouped: &mut Amount, _, new_amount| -> Result<(), WalletError> { + *grouped = grouped.add(new_amount).ok_or(WalletError::OutputAmountOverflow)?; + Ok(()) + }, + Amount::ZERO, + &self.chain_config, + best_block_height, + ) + .map_err(|err| ControllerError::WalletError(err)) + } + + async fn fetch_and_group_inputs( + &self, + inputs: &[UtxoOutPoint], + ) -> Result, ControllerError> { + let tasks: FuturesUnordered<_> = + inputs.iter().map(|input| self.fetch_utxo(input)).collect(); + let input_utxos: Vec = tasks.try_collect().await?; + currency_grouper::group_utxos_for_input( + input_utxos.into_iter(), + |tx_output| tx_output, + |total: &mut Amount, _, amount| -> Result<(), WalletError> { + *total = (*total + amount).ok_or(WalletError::OutputAmountOverflow)?; + Ok(()) + }, + Amount::ZERO, + ) + .map_err(|err| ControllerError::WalletError(err)) + } + + 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(), + ))) + } + /// Synchronize the wallet in the background from the node's blockchain. /// Try staking new blocks if staking was started. pub async fn run(&mut self) -> Result> { @@ -690,3 +786,30 @@ pub async fn fetch_token_info( token_id, ))) } + +pub async fn into_balances( + rpc_client: &T, + chain_config: &ChainConfig, + mut balances: BTreeMap, +) -> Result> { + let coins = balances.remove(&Currency::Coin).unwrap_or(Amount::ZERO); + let coins = DecimalAmount::from_amount_minimal(coins, chain_config.coin_decimals()); + + let tasks: FuturesUnordered<_> = balances + .into_iter() + .map(|(currency, amount)| async move { + let token_id = match currency { + Currency::Coin => panic!("Removed just above"), + Currency::Token(token_id) => token_id, + }; + + fetch_token_info(rpc_client, token_id).await.map(|info| { + let decimals = info.token_number_of_decimals(); + let amount = DecimalAmount::from_amount_minimal(amount, decimals); + (token_id, amount) + }) + }) + .collect(); + + Ok(Balances::new(coins, tasks.try_collect().await?)) +} diff --git a/wallet/wallet-controller/src/read.rs b/wallet/wallet-controller/src/read.rs index 099085973e..00cdab8004 100644 --- a/wallet/wallet-controller/src/read.rs +++ b/wallet/wallet-controller/src/read.rs @@ -20,7 +20,7 @@ use std::collections::BTreeMap; use common::{ address::Address, chain::{ChainConfig, DelegationId, Destination, PoolId, Transaction, TxOutput, UtxoOutPoint}, - primitives::{id::WithId, Amount, DecimalAmount, Id}, + primitives::{id::WithId, Amount, Id}, }; use crypto::{ key::hdkd::{child_number::ChildNumber, u31::U31}, @@ -30,7 +30,9 @@ use futures::{stream::FuturesUnordered, FutureExt, TryStreamExt}; use node_comm::node_traits::NodeInterface; use utils::tap_error_log::LogError; use wallet::{ - account::{transaction_list::TransactionList, Currency, DelegationData, PoolData}, + account::{ + currency_grouper::Currency, transaction_list::TransactionList, DelegationData, PoolData, + }, wallet::WalletPoolsFilter, DefaultWallet, }; @@ -95,28 +97,8 @@ impl<'a, T: NodeInterface> ReadOnlyController<'a, T> { utxo_states: UtxoStates, with_locked: WithLocked, ) -> Result> { - let mut balances = self.get_balance(utxo_states, with_locked)?; - - let coins = balances.remove(&Currency::Coin).unwrap_or(Amount::ZERO); - let coins = DecimalAmount::from_amount_minimal(coins, self.chain_config.coin_decimals()); - - let tasks: FuturesUnordered<_> = balances - .into_iter() - .map(|(currency, amount)| async move { - let token_id = match currency { - Currency::Coin => panic!("Removed just above"), - Currency::Token(token_id) => token_id, - }; - - super::fetch_token_info(&self.rpc_client, token_id).await.map(|info| { - let decimals = info.token_number_of_decimals(); - let amount = DecimalAmount::from_amount_minimal(amount, decimals); - (token_id, amount) - }) - }) - .collect(); - - Ok(Balances::new(coins, tasks.try_collect().await?)) + let balances = self.get_balance(utxo_states, with_locked)?; + super::into_balances(&self.rpc_client, self.chain_config, balances).await } pub fn get_utxos( diff --git a/wallet/wallet-controller/src/sync/tests/mod.rs b/wallet/wallet-controller/src/sync/tests/mod.rs index e1a58500ec..4b680cd082 100644 --- a/wallet/wallet-controller/src/sync/tests/mod.rs +++ b/wallet/wallet-controller/src/sync/tests/mod.rs @@ -286,6 +286,13 @@ impl NodeInterface for MockNode { unreachable!() } + async fn get_utxo( + &self, + _outpoint: common::chain::UtxoOutPoint, + ) -> Result, Self::Error> { + unreachable!() + } + async fn generate_block( &self, _input_data: GenerateBlockInputData, diff --git a/wallet/wallet-node-client/src/handles_client/mod.rs b/wallet/wallet-node-client/src/handles_client/mod.rs index 6fca1751d6..14ce1bd607 100644 --- a/wallet/wallet-node-client/src/handles_client/mod.rs +++ b/wallet/wallet-node-client/src/handles_client/mod.rs @@ -250,6 +250,18 @@ impl NodeInterface for WalletHandlesClient { Ok(()) } + async fn get_utxo( + &self, + outpoint: common::chain::UtxoOutPoint, + ) -> Result, Self::Error> { + let output = self + .chainstate + .call_mut(move |this| this.utxo(&outpoint)) + .await?? + .map(|utxo| utxo.take_output()); + Ok(output) + } + async fn submit_transaction( &self, tx: SignedTransaction, diff --git a/wallet/wallet-node-client/src/node_traits.rs b/wallet/wallet-node-client/src/node_traits.rs index a804483e5b..1315abf316 100644 --- a/wallet/wallet-node-client/src/node_traits.rs +++ b/wallet/wallet-node-client/src/node_traits.rs @@ -17,7 +17,8 @@ use chainstate::ChainInfo; use common::{ chain::{ tokens::{RPCTokenInfo, TokenId}, - Block, DelegationId, GenBlock, PoolId, SignedTransaction, Transaction, + Block, DelegationId, GenBlock, PoolId, SignedTransaction, Transaction, TxOutput, + UtxoOutPoint, }, primitives::{Amount, BlockHeight, Id}, }; @@ -97,4 +98,6 @@ pub trait NodeInterface { async fn mempool_get_fee_rate(&self, in_top_x_mb: usize) -> Result; async fn mempool_get_fee_rate_points(&self) -> Result, Self::Error>; + + async fn get_utxo(&self, outpoint: UtxoOutPoint) -> Result, Self::Error>; } diff --git a/wallet/wallet-node-client/src/rpc_client/client_impl.rs b/wallet/wallet-node-client/src/rpc_client/client_impl.rs index 08e55c50c3..541208b5af 100644 --- a/wallet/wallet-node-client/src/rpc_client/client_impl.rs +++ b/wallet/wallet-node-client/src/rpc_client/client_impl.rs @@ -18,7 +18,8 @@ use chainstate::{rpc::ChainstateRpcClient, ChainInfo}; use common::{ chain::{ tokens::{RPCTokenInfo, TokenId}, - Block, DelegationId, GenBlock, PoolId, SignedTransaction, Transaction, + Block, DelegationId, GenBlock, PoolId, SignedTransaction, Transaction, TxOutput, + UtxoOutPoint, }, primitives::{Amount, BlockHeight, Id}, }; @@ -270,4 +271,10 @@ impl NodeInterface for NodeRpcClient { .await .map_err(NodeRpcError::ResponseError) } + + async fn get_utxo(&self, outpoint: UtxoOutPoint) -> Result, Self::Error> { + ChainstateRpcClient::utxo(&self.http_client, outpoint) + .await + .map_err(NodeRpcError::ResponseError) + } } diff --git a/wallet/wallet-node-client/src/rpc_client/cold_wallet_client.rs b/wallet/wallet-node-client/src/rpc_client/cold_wallet_client.rs index 4dd5fc7e7e..d5a2119d08 100644 --- a/wallet/wallet-node-client/src/rpc_client/cold_wallet_client.rs +++ b/wallet/wallet-node-client/src/rpc_client/cold_wallet_client.rs @@ -206,4 +206,11 @@ impl NodeInterface for ColdWalletClient { async fn mempool_get_fee_rate_points(&self) -> Result, Self::Error> { Err(ColdWalletRpcError::NotAvailable) } + + async fn get_utxo( + &self, + _outpoint: common::chain::UtxoOutPoint, + ) -> Result, Self::Error> { + Err(ColdWalletRpcError::NotAvailable) + } } diff --git a/wallet/wallet-rpc-lib/src/rpc/mod.rs b/wallet/wallet-rpc-lib/src/rpc/mod.rs index 2fc50a56cc..20e691eeee 100644 --- a/wallet/wallet-rpc-lib/src/rpc/mod.rs +++ b/wallet/wallet-rpc-lib/src/rpc/mod.rs @@ -691,6 +691,18 @@ impl WalletRpc { .await? } + pub async fn compose_transaction( + &self, + inputs: Vec, + outputs: Vec, + ) -> WRpcResult<(Transaction, Balances), N> { + self.wallet + .call_async(move |w| { + Box::pin(async move { w.compose_transaction(inputs, outputs).await }) + }) + .await? + } + pub async fn abandon_transaction( &self, account_index: U31,