diff --git a/chainstate/src/rpc/mod.rs b/chainstate/src/rpc/mod.rs index d62077f271..1335a4e7e6 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 get_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 get_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..2b02038dc7 100644 --- a/test/functional/test_framework/wallet_cli_controller.py +++ b/test/functional/test_framework/wallet_cli_controller.py @@ -24,7 +24,7 @@ from typing import Optional, List, Tuple, Union -ONE_MB = 2**20 +TEN_MB = 10*2**20 READ_TIMEOUT_SEC = 30 DEFAULT_ACCOUNT_INDEX = 0 @@ -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 @@ -88,14 +96,28 @@ async def __aexit__(self, exc_type, exc_value, traceback): self.wallet_commands_file.close() async def _read_available_output(self) -> str: + result = '' + output_buf = bytes([]) + num_tries = 0 try: - output = await asyncio.wait_for(self.process.stdout.read(ONE_MB), timeout=READ_TIMEOUT_SEC) - self.wallet_commands_file.write(output) - result = output.decode().strip() + while not result and num_tries < 5: + output = await asyncio.wait_for(self.process.stdout.read(TEN_MB), timeout=READ_TIMEOUT_SEC) + self.wallet_commands_file.write(output) + output_buf = output_buf + output + num_tries = num_tries + 1 + if not output_buf: + continue + # try to decode, sometimes the read can split a utf-8 symbol in half and the decode can fail + # in that case try to read the rest of the output and try to parse again + try: + result = output_buf.decode().strip() + except: + pass + try: while True: - output = await asyncio.wait_for(self.process.stdout.read(ONE_MB), timeout=0.1) + output = await asyncio.wait_for(self.process.stdout.read(TEN_MB), timeout=0.1) if not output: break self.wallet_commands_file.write(output) @@ -104,7 +126,8 @@ async def _read_available_output(self) -> str: pass return result - except: + except Exception as e: + self.log.error(f"read timeout '{e}'") self.wallet_commands_file.write(b"read from stdout timedout\n") return '' @@ -196,6 +219,10 @@ 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], only_transaction: bool = False) -> str: + only_tx = "--only-transaction" if only_transaction else "" + return await self._write_command(f"transaction-compose {' '.join(map(str, outputs))} --utxos {' --utxos '.join(map(str, selected_utxos))} {only_tx}\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/test/functional/wallet_tx_compose.py b/test/functional/wallet_tx_compose.py new file mode 100644 index 0000000000..f6f0fd0d80 --- /dev/null +++ b/test/functional/wallet_tx_compose.py @@ -0,0 +1,179 @@ +#!/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 transaction compose test + +Check that: +* We can create a new wallet, +* get an address +* send coins to the wallet's address +* sync the wallet with the node +* check balance +* get utxos +* create a transaction using the utxo and 2 outputs +* check the fee is as expected +* sign and submit the transaction +""" + +import json +from test_framework.test_framework import BitcoinTestFramework +from test_framework.mintlayer import (make_tx, reward_input, tx_input, ATOMS_PER_COIN) +from test_framework.util import assert_in, assert_equal +from test_framework.mintlayer import mintlayer_hash, block_input_data_obj +from test_framework.wallet_cli_controller import DEFAULT_ACCOUNT_INDEX, TxOutput, WalletCliController + +import asyncio +import sys +import random + + +class WalletComposeTransaction(BitcoinTestFramework): + + def set_test_params(self): + self.setup_clean_chain = True + self.num_nodes = 1 + relay_fee_rate = random.randint(1, 100_000_000) + self.extra_args = [[ + "--blockprod-min-peers-to-produce-blocks=0", + f"--min-tx-relay-fee-rate={relay_fee_rate}", + ]] + + def setup_network(self): + self.setup_nodes() + self.sync_all(self.nodes[0:1]) + + def generate_block(self): + node = self.nodes[0] + + block_input_data = { "PoW": { "reward_destination": "AnyoneCanSpend" } } + block_input_data = block_input_data_obj.encode(block_input_data).to_hex()[2:] + + # create a new block, taking transactions from mempool + block = node.blockprod_generate_block(block_input_data, [], [], "FillSpaceFromMempool") + node.chainstate_submit_block(block) + block_id = node.chainstate_best_block_id() + + # Wait for mempool to sync + self.wait_until(lambda: node.mempool_local_best_block_id() == block_id, timeout = 5) + + return block_id + + def run_test(self): + 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] + async with WalletCliController(node, self.config, self.log) as wallet: + # new wallet + await wallet.create_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}') + + coins_to_send = random.randint(2, 10) + # new address + addresses = [] + num_utxos = random.randint(1, 3) + for _ in range(num_utxos): + pub_key_bytes = await wallet.new_public_key() + assert_equal(len(pub_key_bytes), 33) + addresses.append(pub_key_bytes) + + # Submit a valid transaction + def make_output(pub_key_bytes): + return { + 'Transfer': [ { 'Coin': coins_to_send * ATOMS_PER_COIN }, { 'PublicKey': {'key': {'Secp256k1Schnorr' : {'pubkey_data': pub_key_bytes}}} } ], + } + encoded_tx, tx_id = make_tx([reward_input(tip_id)], [make_output(pk) for pk in addresses], 0) + + self.log.debug(f"Encoded transaction {tx_id}: {encoded_tx}") + + assert_in("No transaction found", await wallet.get_transaction(tx_id)) + + node.mempool_submit_transaction(encoded_tx, {}) + assert node.mempool_contains_tx(tx_id) + + self.generate_block() + assert not node.mempool_contains_tx(tx_id) + + # sync the wallet + assert_in("Success", await wallet.sync()) + + assert_in(f"Coins amount: {coins_to_send * len(addresses)}", await wallet.get_balance()) + + ## create a new account and get an address + await wallet.create_new_account() + await wallet.select_account(1) + acc1_address = await wallet.new_address() + await wallet.select_account(DEFAULT_ACCOUNT_INDEX) + + change_address = await wallet.new_address() + # transfer all except 1 coin to the new acc, and add 0.1 fee + num_outputs = random.randint(0, len(addresses) - 1) + outputs = [TxOutput(acc1_address, str(coins_to_send)) for _ in range(num_outputs)] + [ TxOutput(acc1_address, str(coins_to_send - 1)), TxOutput(change_address, "0.9") ] + + # check we have unspent utxos + utxos = await wallet.list_utxos() + assert_equal(len(utxos), len(addresses)) + + # compose a transaction with all our utxos and n outputs to the other acc and 1 as change + output = await wallet.compose_transaction(outputs, utxos, True) + assert_in("The hex encoded transaction is", output) + # check the fees include the 0.1 + any extra utxos + assert_in(f"Coins amount: {((len(addresses) - (num_outputs + 1))*coins_to_send)}.1", output) + encoded_tx = output.split('\n')[1] + + output = await wallet.compose_transaction(outputs, utxos, False) + assert_in("The hex encoded transaction is", output) + # check the fees include the 0.1 + any extra utxos + assert_in(f"Coins amount: {((len(addresses) - (num_outputs + 1))*coins_to_send)}.1", output) + encoded_ptx = output.split('\n')[1] + + # partially_signed_tx is bigger than just the tx + assert len(encoded_tx) < len(encoded_ptx) + + output = await wallet.sign_raw_transaction(encoded_tx) + assert_in("The transaction has been fully signed signed", output) + signed_tx = output.split('\n')[2] + + assert_in("The transaction was submitted successfully", await wallet.submit_transaction(signed_tx)) + + transactions = node.mempool_transactions() + assert_in(signed_tx, transactions) + self.generate_block() + + assert_in("Success", await wallet.sync()) + # check we have the change + assert_in(f"Coins amount: 0.9", await wallet.get_balance()) + # and 1 new utxo + assert_equal(1, len(await wallet.list_utxos())) + + await wallet.select_account(1) + assert_in(f"Coins amount: {num_outputs * coins_to_send + coins_to_send-1}", await wallet.get_balance()) + assert_equal(num_outputs + 1, len(await wallet.list_utxos())) + + +if __name__ == '__main__': + WalletComposeTransaction().main() + 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..ecd66b64dc 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,61 @@ 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(format!("invalid address {} {err}", parts[1])) + })?; + + let amount = DecimalAmount::from_str(parts[2]) + .map_err(|err| { + WalletCliError::::InvalidInput(format!("invalid amount {} {err}", parts[2])) + })? + .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 output: unknown 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..abb3da45c2 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}, }; @@ -36,14 +36,21 @@ use mempool::tx_options::TxOptionsOverrides; use p2p_types::{bannable_address::BannableAddress, ip_or_socket_address::IpOrSocketAddress}; use serialization::{hex::HexEncode, hex_encoded::HexEncoded}; use utils::qrcode::QrCode; -use wallet::{account::PartiallySignedTransaction, version::get_version, WalletError}; +use wallet::{ + account::{PartiallySignedTransaction, TransactionToSign}, + version::get_version, + WalletError, +}; use wallet_controller::{ControllerConfig, NodeInterface, PeerId, DEFAULT_ACCOUNT_INDEX}; use wallet_rpc_lib::{ config::WalletRpcConfig, types::NewTransaction, CreatedWallet, WalletRpc, WalletRpcServer, 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 +615,28 @@ pub enum WalletCommand { #[clap(hide = true)] GenerateBlocks { block_count: u32 }, + /// Compose a new transaction from the specified outputs and selected utxos + /// The transaction is returned in a hex encoded form that can be passed to account-sign-raw-transaction + /// and also prints the fees that will be paid by the transaction + /// example usage: + /// transaction-compose transfer(tmt1q8lhgxhycm8e6yk9zpnetdwtn03h73z70c3ha4l7,0.9) transfer(tmt1q8lhgxhycm8e6yk9zpnetdwtn03h73z70c3ha4l7,50) + /// --utxos tx(000000000000000000059fa50103b9683e51e5aba83b8a34c9b98ce67d66136c,1) tx(000000000000000000059fa50103b9683e51e5aba83b8a34c9b98ce67d66136c,0) + /// which creates a transaction with 2 outputs and 2 input + #[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, + + #[arg(long = "only-transaction", default_value_t = false)] + only_transaction: bool, + }, + /// 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. @@ -751,11 +780,6 @@ where .ok_or(WalletCliError::NoWallet) } - pub fn tx_submitted_command() -> ConsoleCommand { - let status_text = "The transaction was submitted successfully"; - ConsoleCommand::Print(status_text.to_owned()) - } - pub fn new_tx_submitted_command(new_tx: NewTransaction) -> ConsoleCommand { let status_text = format!( "The transaction was submitted successfully with ID:\n{}", @@ -1010,16 +1034,23 @@ where Ok(signed_tx) => { let result_hex: HexEncoded = signed_tx.into(); - let qr_code = utils::qrcode::qrcode_from_str(result_hex.to_string()) - .map_err(WalletCliError::QrCodeEncoding)?; - let qr_code_string = qr_code.encode_to_console_string_with_defaults(1); + let qr_code_string = utils::qrcode::qrcode_from_str(result_hex.to_string()) + .map(|qr_code| qr_code.encode_to_console_string_with_defaults(1)); - format!( + match qr_code_string { + Ok(qr_code_string) => format!( "The transaction has been fully signed signed as is ready to be broadcast to network. \ You can use the command `node-submit-transaction` in a wallet connected to the internet (this one or elsewhere). \ Pass the following data to the wallet to broadcast:\n\n{result_hex}\n\n\ Or scan the Qr code with it:\n\n{qr_code_string}" - ) + ), + Err(_) => format!( + "The transaction has been fully signed signed as is ready to be broadcast to network. \ + You can use the command `node-submit-transaction` in a wallet connected to the internet (this one or elsewhere). \ + Pass the following data to the wallet to broadcast:\n\n{result_hex}\n\n\ + Transaction is too long to be put into a Qr code" + ), + } } Err(WalletError::FailedToConvertPartiallySignedTx(partially_signed_tx)) => { let result_hex: HexEncoded = @@ -1180,10 +1211,57 @@ where } WalletCommand::SubmitTransaction { transaction } => { - self.wallet_rpc + let new_tx = self + .wallet_rpc .submit_raw_transaction(transaction, TxOptionsOverrides::default()) .await?; - Ok(Self::tx_submitted_command()) + Ok(Self::new_tx_submitted_command(new_tx)) + } + + WalletCommand::TransactionCompose { + outputs, + utxos, + only_transaction, + } => { + eprintln!("outputs: {outputs:?}"); + eprintln!("utxos: {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, only_transaction) + .await?; + let (coins, tokens) = fees.into_coins_and_tokens(); + let encoded_tx = match tx { + TransactionToSign::Tx(tx) => HexEncoded::new(tx).to_string(), + TransactionToSign::Partial(tx) => HexEncoded::new(tx).to_string(), + }; + let mut output = format!("The hex encoded transaction is:\n{encoded_tx}\n"); + + 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 } => { diff --git a/wallet/wallet-cli-lib/tests/basic.rs b/wallet/wallet-cli-lib/tests/basic.rs index 46ab9c374b..863772f19f 100644 --- a/wallet/wallet-cli-lib/tests/basic.rs +++ b/wallet/wallet-cli-lib/tests/basic.rs @@ -159,10 +159,9 @@ async fn produce_blocks_decommission_genesis_pool(#[case] seed: Seed) { // submit the tx test.create_genesis_wallet(); assert_eq!(test.exec("wallet-sync"), "Success"); - assert_eq!( - test.exec(&format!("node-submit-transaction {signed_tx}")), - "The transaction was submitted successfully" - ); + assert!(test + .exec(&format!("node-submit-transaction {signed_tx}")) + .starts_with("The transaction was submitted successfully with ID")); // stake with the other acc assert_eq!(test.exec("account-select 1"), "Success"); diff --git a/wallet/wallet-controller/src/lib.rs b/wallet/wallet-controller/src/lib.rs index 74f23380ab..4dfd7123c3 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,14 @@ pub use node_comm::{ rpc_client::NodeRpcClient, }; use wallet::{ - wallet::WalletPoolsFilter, wallet_events::WalletEvents, DefaultWallet, WalletError, - WalletResult, + account::{ + currency_grouper::{self, Currency}, + PartiallySignedTransaction, TransactionToSign, + }, + get_tx_output_destination, + wallet::WalletPoolsFilter, + wallet_events::WalletEvents, + DefaultWallet, WalletError, WalletResult, }; pub use wallet_types::{ account_info::DEFAULT_ACCOUNT_INDEX, @@ -604,6 +613,131 @@ impl Controll ) } + pub async fn compose_transaction( + &self, + inputs: Vec, + outputs: Vec, + only_transaction: bool, + ) -> Result<(TransactionToSign, Balances), ControllerError> { + let input_utxos = self.fetch_utxos(&inputs).await?; + let fees = self.get_fees(&input_utxos, &outputs)?; + let fees = into_balances(&self.rpc_client, &self.chain_config, fees).await?; + + let num_inputs = inputs.len(); + let inputs = inputs.into_iter().map(TxInput::Utxo).collect(); + + let tx = Transaction::new(0, inputs, outputs) + .map_err(|err| ControllerError::WalletError(WalletError::TransactionCreation(err)))?; + + let tx = if only_transaction { + TransactionToSign::Tx(tx) + } else { + let destinations = input_utxos + .iter() + .map(|txo| { + get_tx_output_destination(txo, &|_| None).ok_or_else(|| { + WalletError::UnsupportedTransactionOutput(Box::new(txo.clone())) + }) + }) + .collect::, WalletError>>() + .map_err(ControllerError::WalletError)?; + + let tx = PartiallySignedTransaction::new( + tx, + vec![None; num_inputs], + input_utxos.into_iter().map(Option::Some).collect(), + destinations.into_iter().map(Option::Some).collect(), + ) + .map_err(ControllerError::WalletError)?; + + TransactionToSign::Partial(tx) + }; + + Ok((tx, fees)) + } + + fn get_fees( + &self, + inputs: &[TxOutput], + outputs: &[TxOutput], + ) -> Result, ControllerError> { + let mut inputs = self.group_inputs(inputs)?; + 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); + 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)) + } + + fn group_inputs( + &self, + input_utxos: &[TxOutput], + ) -> Result, ControllerError> { + currency_grouper::group_utxos_for_input( + input_utxos.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_utxos( + &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?; + Ok(input_utxos) + } + + 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 +824,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..64bfa3855c 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::get_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/interface.rs b/wallet/wallet-rpc-lib/src/rpc/interface.rs index b66a1211ec..05d97c62ef 100644 --- a/wallet/wallet-rpc-lib/src/rpc/interface.rs +++ b/wallet/wallet-rpc-lib/src/rpc/interface.rs @@ -120,7 +120,7 @@ trait WalletRpc { &self, tx: HexEncoded, options: TxOptionsOverrides, - ) -> rpc::RpcResult<()>; + ) -> rpc::RpcResult; #[method(name = "address_send")] async fn send_coins( diff --git a/wallet/wallet-rpc-lib/src/rpc/mod.rs b/wallet/wallet-rpc-lib/src/rpc/mod.rs index 2fc50a56cc..cb24fc8f34 100644 --- a/wallet/wallet-rpc-lib/src/rpc/mod.rs +++ b/wallet/wallet-rpc-lib/src/rpc/mod.rs @@ -39,7 +39,9 @@ use common::{ Block, ChainConfig, DelegationId, Destination, GenBlock, PoolId, SignedTransaction, Transaction, TxOutput, UtxoOutPoint, }, - primitives::{id::WithId, per_thousand::PerThousand, Amount, BlockHeight, DecimalAmount, Id}, + primitives::{ + id::WithId, per_thousand::PerThousand, Amount, BlockHeight, DecimalAmount, Id, Idable, + }, }; pub use interface::WalletRpcServer; pub use rpc::{rpc_creds::RpcCreds, Rpc}; @@ -353,7 +355,7 @@ impl WalletRpc { &self, tx: HexEncoded, options: TxOptionsOverrides, - ) -> WRpcResult<(), N> { + ) -> WRpcResult { let tx = tx.take(); let block_height = self.best_block().await?.height; check_transaction(&self.chain_config, block_height, &tx).map_err(|err| { @@ -361,7 +363,9 @@ impl WalletRpc { WalletError::InvalidTransaction(err), )) })?; - self.node.submit_transaction(tx, options).await.map_err(RpcError::RpcError) + let tx_id = tx.transaction().get_id(); + self.node.submit_transaction(tx, options).await.map_err(RpcError::RpcError)?; + Ok(NewTransaction { tx_id }) } pub async fn sign_raw_transaction( @@ -691,6 +695,21 @@ impl WalletRpc { .await? } + pub async fn compose_transaction( + &self, + inputs: Vec, + outputs: Vec, + only_transaction: bool, + ) -> WRpcResult<(TransactionToSign, Balances), N> { + self.wallet + .call_async(move |w| { + Box::pin( + async move { w.compose_transaction(inputs, outputs, only_transaction).await }, + ) + }) + .await? + } + pub async fn abandon_transaction( &self, account_index: U31, diff --git a/wallet/wallet-rpc-lib/src/rpc/server_impl.rs b/wallet/wallet-rpc-lib/src/rpc/server_impl.rs index 35103344c4..bfbe0f6fed 100644 --- a/wallet/wallet-rpc-lib/src/rpc/server_impl.rs +++ b/wallet/wallet-rpc-lib/src/rpc/server_impl.rs @@ -185,7 +185,7 @@ impl WalletRpcServer f &self, tx: HexEncoded, options: TxOptionsOverrides, - ) -> rpc::RpcResult<()> { + ) -> rpc::RpcResult { rpc::handle_result(self.submit_raw_transaction(tx, options).await) }