From a8ea2ae9a35d7eb96fe74702539097f5910d1209 Mon Sep 17 00:00:00 2001 From: Boris Oncev Date: Thu, 1 Feb 2024 19:36:10 +0100 Subject: [PATCH] add list transactions command to the wallet --- .../test_framework/wallet_cli_controller.py | 5 + test/functional/test_runner.py | 1 + test/functional/wallet_list_txs.py | 134 ++++++++++++++++++ wallet/src/account/mod.rs | 10 +- wallet/src/account/output_cache/mod.rs | 75 +++++++++- wallet/src/wallet/mod.rs | 12 ++ wallet/src/wallet/tests.rs | 64 +++++++++ wallet/types/src/wallet_tx.rs | 9 ++ wallet/wallet-cli-lib/src/commands/mod.rs | 35 +++++ wallet/wallet-controller/src/read.rs | 11 ++ wallet/wallet-rpc-lib/src/rpc/mod.rs | 23 ++- 11 files changed, 375 insertions(+), 4 deletions(-) create mode 100644 test/functional/wallet_list_txs.py diff --git a/test/functional/test_framework/wallet_cli_controller.py b/test/functional/test_framework/wallet_cli_controller.py index 2b02038dc7..0a2dc2c95d 100644 --- a/test/functional/test_framework/wallet_cli_controller.py +++ b/test/functional/test_framework/wallet_cli_controller.py @@ -367,5 +367,10 @@ async def list_pending_transactions(self) -> List[str]: pattern = r'id: Id\{0x([^}]*)\}' return re.findall(pattern, output) + async def list_transactions_by_address(self, address: Optional[str] = None, limit: int = 100) -> List[str]: + address = address if address else '' + output = await self._write_command(f"transaction-list-by-address {address} --limit {limit}\n") + return output.split('\n')[3:][::2] + async def abandon_transaction(self, tx_id: str) -> str: return await self._write_command(f"transaction-abandon {tx_id}\n") diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py index 3b142dc4a3..ae312f10a6 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_list_txs.py', 'wallet_tx_compose.py', 'wallet_data_deposit.py', 'wallet_submit_tx.py', diff --git a/test/functional/wallet_list_txs.py b/test/functional/wallet_list_txs.py new file mode 100644 index 0000000000..8f55691bd4 --- /dev/null +++ b/test/functional/wallet_list_txs.py @@ -0,0 +1,134 @@ +#!/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 submission 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 +* create a new address +* create some txs for that address +* list the txs for that address +""" + +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 WalletCliController + +import asyncio +import sys +import random + + +class WalletListTransactions(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') + + # new address + pub_key_bytes = await wallet.new_public_key() + assert_equal(len(pub_key_bytes), 33) + + # Get chain tip + tip_id = node.chainstate_best_block_id() + genesis_block_id = tip_id + self.log.debug(f'Tip: {tip_id}') + + # Submit a valid transaction + coins_to_send = random.randint(200, 300) + output = { + '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)], [output], 0) + + node.mempool_submit_transaction(encoded_tx, {}) + assert node.mempool_contains_tx(tx_id) + + self.generate_block() # Block 1 + assert not node.mempool_contains_tx(tx_id) + + # sync the wallet + assert_in("Success", await wallet.sync()) + + address = await wallet.new_address() + num_txs_to_create = random.randint(1, 10) + for _ in range(num_txs_to_create): + output = await wallet.send_to_address(address, 1) + assert_in("The transaction was submitted successfully", output) + + self.generate_block() + assert_in("Success", await wallet.sync()) + + limit = random.randint(1, 100) + txs = await wallet.list_transactions_by_address(address, limit) + assert_equal(len(txs), min(num_txs_to_create, limit)) + + # without an address + txs = await wallet.list_transactions_by_address() + assert_equal(len(txs), num_txs_to_create+1) + + +if __name__ == '__main__': + WalletListTransactions().main() + diff --git a/wallet/src/account/mod.rs b/wallet/src/account/mod.rs index 66822bcd55..3214b40b67 100644 --- a/wallet/src/account/mod.rs +++ b/wallet/src/account/mod.rs @@ -80,7 +80,7 @@ use wallet_types::{ }; pub use self::output_cache::{ - DelegationData, FungibleTokenInfo, PoolData, UnconfirmedTokenInfo, UtxoWithTxOutput, + DelegationData, FungibleTokenInfo, PoolData, TxInfo, UnconfirmedTokenInfo, UtxoWithTxOutput, }; use self::output_cache::{OutputCache, TokenIssuanceData}; use self::transaction_list::{get_transaction_list, TransactionList}; @@ -1804,6 +1804,14 @@ impl Account { self.output_cache.pending_transactions() } + pub fn mainchain_transactions( + &self, + destination: Option, + limit: usize, + ) -> Vec { + self.output_cache.mainchain_transactions(destination, limit) + } + pub fn abandon_transaction( &mut self, tx_id: Id, diff --git a/wallet/src/account/output_cache/mod.rs b/wallet/src/account/output_cache/mod.rs index 2377c5efa1..51d005abd4 100644 --- a/wallet/src/account/output_cache/mod.rs +++ b/wallet/src/account/output_cache/mod.rs @@ -14,12 +14,14 @@ // limitations under the License. use std::{ + cmp::Reverse, collections::{btree_map::Entry, BTreeMap, BTreeSet}, ops::Add, }; use common::{ chain::{ + block::timestamp::BlockTimestamp, output_value::OutputValue, stakelock::StakePoolData, tokens::{ @@ -30,7 +32,7 @@ use common::{ AccountCommand, AccountNonce, AccountSpending, DelegationId, Destination, GenBlock, OutPointSourceId, PoolId, Transaction, TxInput, TxOutput, UtxoOutPoint, }, - primitives::{id::WithId, per_thousand::PerThousand, Amount, BlockHeight, Id}, + primitives::{id::WithId, per_thousand::PerThousand, Amount, BlockHeight, Id, Idable}, }; use crypto::vrf::VRFPublicKey; use itertools::Itertools; @@ -44,10 +46,26 @@ use wallet_types::{ AccountWalletTxId, BlockInfo, WalletTx, }; -use crate::{WalletError, WalletResult}; +use crate::{get_tx_output_destination, WalletError, WalletResult}; pub type UtxoWithTxOutput<'a> = (UtxoOutPoint, (&'a TxOutput, Option)); +pub struct TxInfo { + pub id: Id, + pub height: BlockHeight, + pub timestamp: BlockTimestamp, +} + +impl TxInfo { + fn new(id: Id, height: BlockHeight, timestamp: BlockTimestamp) -> Self { + Self { + id, + height, + timestamp, + } + } +} + pub struct DelegationData { pub pool_id: PoolId, pub destination: Destination, @@ -1173,6 +1191,59 @@ impl OutputCache { .collect() } + pub fn mainchain_transactions( + &self, + destination: Option, + limit: usize, + ) -> Vec { + let mut txs: Vec<&WalletTx> = self.txs.values().collect(); + txs.sort_by_key(|tx| Reverse((tx.state().block_height(), tx.state().block_order_index()))); + + txs.iter() + .filter_map(|tx| match tx { + WalletTx::Block(_) => None, + WalletTx::Tx(tx) => match tx.state() { + TxState::Confirmed(block_height, timestamp, _) => { + let tx_with_id = tx.get_transaction_with_id(); + if let Some(dest) = &destination { + (self.destination_in_tx_outputs(&tx_with_id, dest) + || self.destination_in_tx_inputs(&tx_with_id, dest)) + .then_some(TxInfo::new(tx_with_id.get_id(), *block_height, *timestamp)) + } else { + Some(TxInfo::new(tx_with_id.get_id(), *block_height, *timestamp)) + } + } + TxState::Inactive(_) + | TxState::Conflicted(_) + | TxState::InMempool(_) + | TxState::Abandoned => None, + }, + }) + .take(limit) + .collect() + } + + /// Returns true if the destination is found in the transaction's inputs + fn destination_in_tx_inputs(&self, tx: &WithId<&Transaction>, dest: &Destination) -> bool { + tx.inputs().iter().any(|inp| match inp { + TxInput::Utxo(utxo) => self + .txs + .get(&utxo.source_id()) + .and_then(|tx| tx.outputs().get(utxo.output_index() as usize)) + .and_then(|txo| get_tx_output_destination(txo, &|pool_id| self.pools.get(pool_id))) + .map_or(false, |output_dest| &output_dest == dest), + TxInput::Account(_) | TxInput::AccountCommand(_, _) => false, + }) + } + + /// Returns true if the destination is found in the transaction's outputs + fn destination_in_tx_outputs(&self, tx: &WithId<&Transaction>, dest: &Destination) -> bool { + tx.outputs().iter().any(|txo| { + get_tx_output_destination(txo, &|pool_id| self.pools.get(pool_id)) + .map_or(false, |output_dest| &output_dest == dest) + }) + } + /// Mark a transaction and its descendants as abandoned /// Returns a Vec of the transaction Ids that have been abandoned pub fn abandon_transaction( diff --git a/wallet/src/wallet/mod.rs b/wallet/src/wallet/mod.rs index f6c478ae32..6b383f650a 100644 --- a/wallet/src/wallet/mod.rs +++ b/wallet/src/wallet/mod.rs @@ -18,6 +18,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use crate::account::transaction_list::TransactionList; +use crate::account::TxInfo; use crate::account::{ currency_grouper::Currency, CurrentFeeRate, DelegationData, PartiallySignedTransaction, PoolData, TransactionToSign, UnconfirmedTokenInfo, UtxoSelectorError, @@ -875,6 +876,17 @@ impl Wallet { Ok(transactions) } + pub fn mainchain_transactions( + &self, + account_index: U31, + destination: Option, + limit: usize, + ) -> WalletResult> { + let account = self.get_account(account_index)?; + let transactions = account.mainchain_transactions(destination, limit); + Ok(transactions) + } + pub fn abandon_transaction( &mut self, account_index: U31, diff --git a/wallet/src/wallet/tests.rs b/wallet/src/wallet/tests.rs index 9412e7050a..c660feefdb 100644 --- a/wallet/src/wallet/tests.rs +++ b/wallet/src/wallet/tests.rs @@ -1078,6 +1078,70 @@ fn wallet_get_transaction(#[case] seed: Seed) { assert_eq!(found_tx.get_transaction(), tx.transaction()); } +#[rstest] +#[trace] +#[case(Seed::from_entropy())] +fn wallet_list_mainchain_transactions(#[case] seed: Seed) { + let mut rng = make_seedable_rng(seed); + let chain_config = Arc::new(create_regtest()); + + let mut wallet = create_wallet(chain_config.clone()); + // Generate a new block which sends reward to the wallet + let block1_amount = Amount::from_atoms(rng.gen_range(100000..1000000)); + let (addr, _) = create_block(&chain_config, &mut wallet, vec![], block1_amount, 0); + let dest = addr.decode_object(&chain_config).unwrap(); + + let coin_balance = get_coin_balance(&wallet); + assert_eq!(coin_balance, block1_amount); + + // send some coins to the address + let tx = wallet + .create_transaction_to_addresses( + DEFAULT_ACCOUNT_INDEX, + [TxOutput::Transfer(OutputValue::Coin(block1_amount), dest.clone())], + vec![], + FeeRate::from_amount_per_kb(Amount::ZERO), + FeeRate::from_amount_per_kb(Amount::ZERO), + ) + .unwrap(); + + let send_tx_id = tx.transaction().get_id(); + + // put the tx in a block and scan it as confirmed + let _ = create_block( + &chain_config, + &mut wallet, + vec![tx.clone()], + Amount::ZERO, + 1, + ); + + let tx = wallet + .create_transaction_to_addresses( + DEFAULT_ACCOUNT_INDEX, + [gen_random_transfer(&mut rng, block1_amount)], + vec![], + FeeRate::from_amount_per_kb(Amount::ZERO), + FeeRate::from_amount_per_kb(Amount::ZERO), + ) + .unwrap(); + let spend_from_tx_id = tx.transaction().get_id(); + + let _ = create_block( + &chain_config, + &mut wallet, + vec![tx.clone()], + Amount::ZERO, + 2, + ); + + let txs = wallet.mainchain_transactions(DEFAULT_ACCOUNT_INDEX, Some(dest), 100).unwrap(); + // should have 2 txs the send to and the spent from + assert_eq!(txs.len(), 2); + assert!(txs.iter().any(|info| info.id == send_tx_id)); + assert!(txs.iter().any(|info| info.id == spend_from_tx_id)); +} + #[rstest] #[trace] #[case(Seed::from_entropy())] diff --git a/wallet/types/src/wallet_tx.rs b/wallet/types/src/wallet_tx.rs index 31dc00a0ea..617146ee8c 100644 --- a/wallet/types/src/wallet_tx.rs +++ b/wallet/types/src/wallet_tx.rs @@ -56,6 +56,15 @@ impl TxState { } } + pub fn block_order_index(&self) -> Option { + match self { + TxState::Confirmed(_, _, idx) | TxState::InMempool(idx) | TxState::Inactive(idx) => { + Some(*idx) + } + TxState::Conflicted(_) | TxState::Abandoned => None, + } + } + pub fn timestamp(&self) -> Option { match self { TxState::Confirmed(_block_height, timestamp, _idx) => Some(*timestamp), diff --git a/wallet/wallet-cli-lib/src/commands/mod.rs b/wallet/wallet-cli-lib/src/commands/mod.rs index b06e0972a2..3c1c093907 100644 --- a/wallet/wallet-cli-lib/src/commands/mod.rs +++ b/wallet/wallet-cli-lib/src/commands/mod.rs @@ -668,6 +668,16 @@ pub enum WalletCommand { #[clap(name = "transaction-list-pending")] ListPendingTransactions, + /// List mainchain transactions with optional address filter + #[clap(name = "transaction-list-by-address")] + ListMainchainTransactions { + /// Address to filter by + address: Option, + /// limit the number of printed transactions, default is 100 + #[arg(long = "limit", default_value_t = 100)] + limit: usize, + }, + /// Get a transaction from the wallet, if present #[clap(name = "transaction-get")] GetTransaction { @@ -1497,6 +1507,31 @@ where Ok(ConsoleCommand::Print(format!("{utxos:#?}"))) } + WalletCommand::ListMainchainTransactions { address, limit } => { + let selected_account = self.get_selected_acc()?; + let txs = self + .wallet_rpc + .mainchain_transactions(selected_account, address, limit) + .await?; + + let table = { + let mut table = prettytable::Table::new(); + table.set_titles(prettytable::row!["Id", "BlockHeight", "BlockTimestamp",]); + + table.extend(txs.into_iter().map(|info| { + prettytable::row![ + id_to_hex_string(*info.id.as_hash()), + info.height, + info.timestamp + ] + })); + + table + }; + + Ok(ConsoleCommand::Print(table.to_string())) + } + WalletCommand::GetTransaction { transaction_id } => { let selected_account = self.get_selected_acc()?; let tx = self diff --git a/wallet/wallet-controller/src/read.rs b/wallet/wallet-controller/src/read.rs index 00cdab8004..0123e3f5af 100644 --- a/wallet/wallet-controller/src/read.rs +++ b/wallet/wallet-controller/src/read.rs @@ -32,6 +32,7 @@ use utils::tap_error_log::LogError; use wallet::{ account::{ currency_grouper::Currency, transaction_list::TransactionList, DelegationData, PoolData, + TxInfo, }, wallet::WalletPoolsFilter, DefaultWallet, @@ -118,6 +119,16 @@ impl<'a, T: NodeInterface> ReadOnlyController<'a, T> { .map_err(ControllerError::WalletError) } + pub fn mainchain_transactions( + &self, + destination: Option, + limit: usize, + ) -> Result, ControllerError> { + self.wallet + .mainchain_transactions(self.account_index, destination, limit) + .map_err(ControllerError::WalletError) + } + pub fn get_transaction_list( &self, skip: usize, diff --git a/wallet/wallet-rpc-lib/src/rpc/mod.rs b/wallet/wallet-rpc-lib/src/rpc/mod.rs index 8d9064881d..6f02891e93 100644 --- a/wallet/wallet-rpc-lib/src/rpc/mod.rs +++ b/wallet/wallet-rpc-lib/src/rpc/mod.rs @@ -29,7 +29,7 @@ use serialization::{hex_encoded::HexEncoded, Decode, DecodeAll}; use std::{collections::BTreeMap, fmt::Debug, path::PathBuf, sync::Arc, time::Duration}; use utils::{ensure, shallow_clone::ShallowClone}; use wallet::{ - account::{PartiallySignedTransaction, PoolData, TransactionToSign}, + account::{PartiallySignedTransaction, PoolData, TransactionToSign, TxInfo}, WalletError, }; @@ -353,6 +353,27 @@ impl WalletRpc { .await? } + pub async fn mainchain_transactions( + &self, + account_index: U31, + address: Option, + limit: usize, + ) -> WRpcResult, N> { + let address = address + .map(|address| { + Address::::from_str(&self.chain_config, &address) + .and_then(|dest| dest.decode_object(&self.chain_config)) + }) + .transpose() + .map_err(|_| RpcError::InvalidAddress)?; + + self.wallet + .call(move |w| { + w.readonly_controller(account_index).mainchain_transactions(address, limit) + }) + .await? + } + pub async fn submit_raw_transaction( &self, tx: HexEncoded,