diff --git a/chainstate/src/rpc/mod.rs b/chainstate/src/rpc/mod.rs index a8099fdcbb..57bd74104d 100644 --- a/chainstate/src/rpc/mod.rs +++ b/chainstate/src/rpc/mod.rs @@ -24,7 +24,7 @@ use chainstate_types::BlockIndex; use common::{ chain::{ tokens::{RPCTokenInfo, TokenId}, - PoolId, SignedTransaction, Transaction, + DelegationId, PoolId, SignedTransaction, Transaction, }, primitives::{Amount, BlockHeight, Id}, }; @@ -110,6 +110,13 @@ trait ChainstateRpc { #[method(name = "stake_pool_pledge")] async fn stake_pool_pledge(&self, pool_id: PoolId) -> RpcResult>; + #[method(name = "delegation_share")] + async fn delegation_share( + &self, + pool_id: PoolId, + delegation_id: DelegationId, + ) -> RpcResult>; + /// Get token information #[method(name = "token_info")] async fn token_info(&self, token_id: TokenId) -> RpcResult>; @@ -246,6 +253,17 @@ impl ChainstateRpcServer for super::ChainstateHandle { ) } + async fn delegation_share( + &self, + pool_id: PoolId, + delegation_id: DelegationId, + ) -> RpcResult> { + rpc::handle_result( + self.call(move |this| this.get_stake_pool_delegation_share(pool_id, delegation_id)) + .await, + ) + } + async fn token_info(&self, token_id: TokenId) -> RpcResult> { rpc::handle_result(self.call(move |this| this.get_token_info_for_rpc(token_id)).await) } diff --git a/test/functional/test_framework/wallet_cli_controller.py b/test/functional/test_framework/wallet_cli_controller.py index 84866af4ed..40799581be 100644 --- a/test/functional/test_framework/wallet_cli_controller.py +++ b/test/functional/test_framework/wallet_cli_controller.py @@ -26,6 +26,7 @@ ONE_MB = 2**20 READ_TIMEOUT_SEC = 30 +DEFAULT_ACCOUNT_INDEX = 0 @dataclass class UtxoOutpoint: @@ -35,6 +36,15 @@ class UtxoOutpoint: def __str__(self): return f'tx({self.id},{self.index})' +@dataclass +class PoolData: + pool_id: str + balance: int + +@dataclass +class DelegationData: + delegation_id: str + balance: int class WalletCliController: @@ -192,15 +202,42 @@ async def issue_new_nft(self, return None async def create_stake_pool(self, - amount: str, - cost_per_block: str, - margin_ratio_per_thousand: str, + amount: int, + cost_per_block: int, + margin_ratio_per_thousand: float, decommission_key: Optional[str] = '') -> str: return await self._write_command(f"createstakepool {amount} {cost_per_block} {margin_ratio_per_thousand} {decommission_key}\n") + async def list_pool_ids(self) -> List[PoolData]: + output = await self._write_command("listpoolids\n") + pattern = r'Pool Id: ([a-zA-Z0-9]+), Balance: (\d+),' + matches = re.findall(pattern, output) + return [PoolData(pool_id, int(balance)) for pool_id, balance in matches] + + async def create_delegation(self, address: str, pool_id: str) -> Optional[str]: + output = await self._write_command(f"createdelegation {address} {pool_id}\n") + pattern = r'Delegation id: ([a-zA-Z0-9]+)' + match = re.search(pattern, output) + if match: + return match.group(1) + else: + return None + + async def stake_delegation(self, amount: int, delegation_id: str) -> str: + return await self._write_command(f"delegatestaking {amount} {delegation_id}\n") + + async def list_delegation_ids(self) -> List[DelegationData]: + output = await self._write_command("listdelegationids\n") + pattern = r'Delegation Id: ([a-zA-Z0-9]+), Balance: (\d+)' + matches = re.findall(pattern, output) + return [DelegationData(delegation_id, int(balance)) for delegation_id, balance in matches] + async def sync(self) -> str: return await self._write_command("syncwallet\n") + async def start_staking(self) -> str: + return await self._write_command(f"startstaking\n") + async def get_addresses_usage(self) -> str: return await self._write_command("showreceiveaddresses\n") diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py index b7db4b490a..33cac0dc7e 100755 --- a/test/functional/test_runner.py +++ b/test/functional/test_runner.py @@ -131,6 +131,7 @@ class UnicodeOnWindowsError(ValueError): 'wallet_get_address_usage.py', 'wallet_tokens.py', 'wallet_nfts.py', + 'wallet_delegations.py', 'mempool_basic_reorg.py', 'mempool_eviction.py', 'mempool_ibd.py', diff --git a/test/functional/wallet_delegations.py b/test/functional/wallet_delegations.py new file mode 100644 index 0000000000..62959523a6 --- /dev/null +++ b/test/functional/wallet_delegations.py @@ -0,0 +1,389 @@ +#!/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 delegations 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 stake pool +* in another account create a delegation to that pool +* stake to that delegation +* transfer from that delegation +* get reward to that delegation +""" + +from hashlib import blake2b +from test_framework.authproxy import JSONRPCException +from test_framework.mintlayer import ( + base_tx_obj, + block_input_data_obj, + mintlayer_hash, + MLT_COIN, + outpoint_obj, + signed_tx_obj, +) +from scalecodec.base import ScaleBytes, RuntimeConfiguration, ScaleDecoder +from test_framework.test_framework import BitcoinTestFramework +from test_framework.mintlayer import (make_tx, reward_input, tx_input) +from test_framework.util import assert_raises_rpc_error +from test_framework.mintlayer import mintlayer_hash, block_input_data_obj +from test_framework.wallet_cli_controller import DEFAULT_ACCOUNT_INDEX, WalletCliController +from test_framework.util import ( + assert_equal, +) + +import asyncio +import sys +import random, time + +GENESIS_POOL_ID = "123c4c600097c513e088b9be62069f0c74c7671c523c8e3469a1c3f14b7ea2c4" +GENESIS_STAKE_PRIVATE_KEY = "8717e6946febd3a33ccdc3f3a27629ec80c33461c33a0fc56b4836fcedd26638" +GENESIS_STAKE_PUBLIC_KEY = "03c53526caf73cd990148e127cb57249a5e266d78df23968642c976a532197fdaa" +GENESIS_VRF_PUBLIC_KEY = "fa2f59dc7a7e176058e4f2d155cfa03ee007340e0285447892158823d332f744" + +GENESIS_VRF_PRIVATE_KEY = ( + "3fcf7b813bec2a293f574b842988895278b396dd72471de2583b242097a59f06" + "e9f3cd7b78d45750afd17292031373fddb5e7a8090db51221038f5e05f29998e" +) + +GENESIS_POOL_ID_ADDR = "rpool1zg7yccqqjlz38cyghxlxyp5lp36vwecu2g7gudrf58plzjm75tzq99fr6v" + +class WalletSubmitTransaction(BitcoinTestFramework): + + def set_test_params(self): + self.setup_clean_chain = True + self.num_nodes = 1 + self.extra_args = [[ + "--chain-pos-netupgrades=true", + "--blockprod-min-peers-to-produce-blocks=0", + ]] + + def setup_network(self): + self.setup_nodes() + self.sync_all(self.nodes[0:1]) + + def assert_chain(self, block, previous_tip): + assert_equal(block["header"]["header"]["prev_block_id"][2:], previous_tip) + + def assert_height(self, expected_height, expected_block): + block_id = self.nodes[0].chainstate_block_id_at_height(expected_height) + block = self.nodes[0].chainstate_get_block(block_id) + assert_equal(block, expected_block) + + def assert_pos_consensus(self, block): + if block["header"]["header"]["consensus_data"].get("PoS") is None: + raise AssertionError("Block {} was not PoS".format(block)) + + def assert_tip(self, expected_block): + tip = self.nodes[0].chainstate_best_block_id() + block = self.nodes[0].chainstate_get_block(tip) + assert_equal(block, expected_block) + + def block_height(self, n): + tip = self.nodes[n].chainstate_best_block_id() + return self.nodes[n].chainstate_block_height_in_main_chain(tip) + def generate_block(self, expected_height, block_input_data, transactions): + previous_block_id = self.nodes[0].chainstate_best_block_id() + + # Block production may fail if the Job Manager found a new tip, so try and sleep + for _ in range(5): + try: + block_hex = self.nodes[0].blockprod_generate_block(block_input_data, transactions) + break + except JSONRPCException: + block_hex = self.nodes[0].blockprod_generate_block(block_input_data, transactions) + time.sleep(1) + + block_hex_array = bytearray.fromhex(block_hex) + # block = ScaleDecoder.get_decoder_class('BlockV1', ScaleBytes(block_hex_array)).decode() + + self.nodes[0].chainstate_submit_block(block_hex) + + self.assert_tip(block_hex) + self.assert_height(expected_height, block_hex) + # self.assert_pos_consensus(block) + # self.assert_chain(block, previous_block_id) + + def generate_pool_id(self, transaction_id): + kernel_input_outpoint = outpoint_obj.encode({ + "id": { + "Transaction": self.hex_to_dec_array(transaction_id), + }, + "index": 0, + }).to_hex()[2:] + + # Include PoolId pre-image suffix of [0, 0, 0, 0] + blake2b_hasher = blake2b() + blake2b_hasher.update(bytes.fromhex(kernel_input_outpoint)) + blake2b_hasher.update(bytes.fromhex("00000000")) + + # Truncate output to match Rust's split() + return self.hex_to_dec_array(blake2b_hasher.hexdigest()[:64]) + + def genesis_pool_id(self): + return self.hex_to_dec_array(GENESIS_POOL_ID) + + def hex_to_dec_array(self, hex_string): + return [int(hex_string[i:i+2], 16) for i in range(0, len(hex_string), 2)] + + def new_stake_keys(self): + new_stake_private_key_hex = self.nodes[0].test_functions_new_private_key() + new_stake_private_key = self.stake_private_key(new_stake_private_key_hex[2:]) + + new_stake_public_key_hex = self.nodes[0].test_functions_public_key_from_private_key(new_stake_private_key_hex) + new_stake_public_key = self.stake_public_key(new_stake_public_key_hex[2:]) + + return (new_stake_private_key, new_stake_public_key) + + def new_vrf_keys(self): + new_vrf_private_key_hex = self.nodes[0].test_functions_new_vrf_private_key() + new_vrf_private_key = self.vrf_private_key(new_vrf_private_key_hex[2:]) + + new_vrf_public_key_hex = self.nodes[0].test_functions_vrf_public_key_from_private_key(new_vrf_private_key_hex) + new_vrf_public_key = self.vrf_public_key(new_vrf_public_key_hex[2:]) + + return (new_vrf_private_key, new_vrf_public_key) + + def pack_transaction(self, transaction): + transaction_encoded = signed_tx_obj.encode(transaction).to_hex()[2:] + transaction_id = ScaleBytes( + mintlayer_hash(base_tx_obj.encode(transaction["transaction"]).data) + ).to_hex()[2:] + + return (transaction_encoded, transaction_id) + + def previous_block_id(self): + previous_block_id = self.nodes[0].chainstate_best_block_id() + return self.hex_to_dec_array(previous_block_id) + + def stake_private_key(self, stake_private_key): + return { + "key": { + "Secp256k1Schnorr": { + "data": self.hex_to_dec_array(stake_private_key), + }, + }, + } + + def stake_public_key(self, stake_public_key): + return { + "key": { + "Secp256k1Schnorr": { + "pubkey_data": self.hex_to_dec_array(stake_public_key), + }, + }, + } + + def vrf_private_key(self, vrf_private_key): + return { + "key": { + "Schnorrkel": { + "key": self.hex_to_dec_array(vrf_private_key), + }, + }, + } + + def vrf_public_key(self, vrf_public_key): + return { + "key": { + "Schnorrkel": { + "key": self.hex_to_dec_array(vrf_public_key), + }, + }, + } + def run_test(self): + if 'win32' in sys.platform: + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + asyncio.run(self.async_test()) + + def setup_pool_and_transfer(self, transactions): + block_input_data = block_input_data_obj.encode({ + "PoS": { + "stake_private_key": self.stake_private_key(GENESIS_STAKE_PRIVATE_KEY), + "vrf_private_key": self.vrf_private_key(GENESIS_VRF_PRIVATE_KEY), + "pool_id": self.genesis_pool_id(), + "kernel_inputs": [ + { + "Utxo": { + "id": { + "BlockReward": self.previous_block_id() + }, + "index": 1, + }, + }, + ], + "kernel_input_utxo": [ + { + "CreateStakePool": [ + self.genesis_pool_id(), + { + "value": 40_000*MLT_COIN, + "staker": { + "PublicKey": self.stake_public_key(GENESIS_STAKE_PUBLIC_KEY), + }, + "vrf_public_key": self.vrf_public_key(GENESIS_VRF_PUBLIC_KEY), + "decommission_key": { + "PublicKey": self.stake_public_key(GENESIS_STAKE_PUBLIC_KEY), + }, + "margin_ratio_per_thousand": 1000, + "cost_per_block" : "0" + }, + ], + } + ], + } + }).to_hex()[2:] + + self.generate_block(1, block_input_data, transactions) + + def gen_pos_block(self, transactions, block_height): + block_input_data = block_input_data_obj.encode({ + "PoS": { + "stake_private_key": self.stake_private_key(GENESIS_STAKE_PRIVATE_KEY), + "vrf_private_key": self.vrf_private_key(GENESIS_VRF_PRIVATE_KEY), + "pool_id": self.genesis_pool_id(), + "kernel_inputs": [ + { + "Utxo": { + "id": { + "BlockReward": self.previous_block_id() + }, + "index": 0, + }, + }, + ], + "kernel_input_utxo": [ + { + "ProduceBlockFromStake": [ + { + "PublicKey": self.stake_public_key(GENESIS_STAKE_PUBLIC_KEY), + }, + self.genesis_pool_id(), + ], + } + ], + } + }).to_hex()[2:] + + self.generate_block(block_height, block_input_data, transactions) + + 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 best_block_height == '0' + + # new address + pub_key_bytes = await wallet.new_public_key() + assert len(pub_key_bytes) == 33 + + # Get chain tip + tip_id = node.chainstate_best_block_id() + self.log.debug(f'Tip: {tip_id}') + + # Submit a valid transaction + output = { + 'Transfer': [ { 'Coin': 50_000_000_000_000_000 }, { 'PublicKey': {'key': {'Secp256k1Schnorr' : {'pubkey_data': pub_key_bytes}}} } ], + } + encoded_tx, tx_id = make_tx([reward_input(tip_id)], [output], 0) + self.log.debug(f"Encoded transaction {tx_id}: {encoded_tx}") + + self.setup_pool_and_transfer([encoded_tx]) + + # sync the wallet + assert "Success" in await wallet.sync() + + # check wallet best block if it is synced + best_block_height = await wallet.get_best_block_height() + assert best_block_height == '1' + + balance = await wallet.get_balance() + assert "Coins amount: 50000" in balance + + assert "Success" in await wallet.create_new_account() + assert "Success" in await wallet.select_account(1) + address = await wallet.new_address() + assert "Success" in await wallet.select_account(DEFAULT_ACCOUNT_INDEX) + assert "The transaction was submitted successfully" in await wallet.send_to_address(address, 100) + assert "Success" in await wallet.select_account(1) + transactions = node.mempool_transactions() + + assert "Success" in await wallet.select_account(DEFAULT_ACCOUNT_INDEX) + assert "The transaction was submitted successfully" in await wallet.create_stake_pool(40000, 0, 0.5) + transactions2 = node.mempool_transactions() + for tx in transactions2: + if tx not in transactions: + transactions.append(tx) + + self.gen_pos_block(transactions, 2) + assert "Success" in await wallet.sync() + + pools = await wallet.list_pool_ids() + assert len(pools) == 1 + assert pools[0].balance == 40000 + + assert "Success" in await wallet.select_account(1) + delegation_id = await wallet.create_delegation(address, pools[0].pool_id) + assert delegation_id is not None + transactions = node.mempool_transactions() + + # still not in a block + delegations = await wallet.list_delegation_ids() + assert len(delegations) == 0 + + assert "Success" in await wallet.stake_delegation(10, delegation_id) + transactions2 = node.mempool_transactions() + for tx in transactions2: + if tx not in transactions: + transactions.append(tx) + + self.gen_pos_block(transactions, 3) + assert "Success" in await wallet.sync() + + delegations = await wallet.list_delegation_ids() + assert len(delegations) == 1 + assert delegations[0].balance == 10 + + assert "Success" in await wallet.select_account(DEFAULT_ACCOUNT_INDEX) + assert "Staking started successfully" in await wallet.start_staking() + assert "Success" in await wallet.select_account(1) + + last_delegation_balance = delegations[0].balance + for _ in range(4, 10): + tip_id = node.chainstate_best_block_id() + assert "The transaction was submitted successfully" in await wallet.send_to_address(address, 1) + transactions = node.mempool_transactions() + self.wait_until(lambda: node.chainstate_best_block_id() != tip_id, timeout = 5) + + delegations = await wallet.list_delegation_ids() + assert len(delegations) == 1 + assert delegations[0].balance > last_delegation_balance + last_delegation_balance = delegations[0].balance + + +if __name__ == '__main__': + WalletSubmitTransaction().main() + + diff --git a/test/functional/wallet_recover_accounts.py b/test/functional/wallet_recover_accounts.py index f7e3a244e3..4170dd80f9 100644 --- a/test/functional/wallet_recover_accounts.py +++ b/test/functional/wallet_recover_accounts.py @@ -32,7 +32,7 @@ from test_framework.mintlayer import (make_tx, reward_input, tx_input) from test_framework.util import assert_raises_rpc_error from test_framework.mintlayer import mintlayer_hash, block_input_data_obj -from test_framework.wallet_cli_controller import WalletCliController +from test_framework.wallet_cli_controller import DEFAULT_ACCOUNT_INDEX, WalletCliController import asyncio import sys @@ -118,7 +118,6 @@ async def async_test(self): assert "Coins amount: 10" in balance # create 3 new accounts - DEFAULT_ACCOUNT_INDEX = 0 num_accounts = 3 for idx in range(num_accounts): assert "Success" in await wallet.create_new_account() diff --git a/wallet/src/account/mod.rs b/wallet/src/account/mod.rs index 4e02c47425..6ee67b77c8 100644 --- a/wallet/src/account/mod.rs +++ b/wallet/src/account/mod.rs @@ -25,6 +25,7 @@ use common::primitives::Idable; use common::Uint256; use crypto::key::hdkd::child_number::ChildNumber; use mempool::FeeRate; +use utils::ensure; pub use utxo_selector::UtxoSelectorError; use wallet_types::with_locked::WithLocked; @@ -66,6 +67,7 @@ use wallet_types::{ KeychainUsageState, WalletTx, }; +pub use self::output_cache::DelegationData; use self::output_cache::OutputCache; use self::transaction_list::{get_transaction_list, TransactionList}; use self::utxo_selector::{CoinSelectionAlgo, PayFee}; @@ -438,6 +440,7 @@ impl Account { address: Address, amount: Amount, delegation_id: DelegationId, + delegation_share: Amount, current_fee_rate: FeeRate, ) -> WalletResult { let current_block_height = self.best_block().1; @@ -477,6 +480,11 @@ impl Account { .map_err(|_| UtxoSelectorError::AmountArithmeticError)? .into()) .ok_or(WalletError::OutputAmountOverflow)?; + ensure!( + new_amount_with_fee <= delegation_share, + UtxoSelectorError::NotEnoughFunds(delegation_share, new_amount_with_fee) + ); + tx_input = TxInput::Account(AccountOutPoint::new( nonce, Delegation(delegation_id, new_amount_with_fee), @@ -518,10 +526,12 @@ impl Account { self.output_cache.pool_ids() } - pub fn get_delegations(&self) -> impl Iterator { - self.output_cache - .delegation_ids() - .map(|(delegation_id, data)| (delegation_id, data.balance)) + pub fn get_delegations(&self) -> impl Iterator { + self.output_cache.delegation_ids() + } + + pub fn find_delegation(&self, delegation_id: DelegationId) -> WalletResult<&DelegationData> { + self.output_cache.delegation_data(delegation_id) } pub fn create_stake_pool_tx( diff --git a/wallet/src/account/output_cache/mod.rs b/wallet/src/account/output_cache/mod.rs index 21dc1b4802..ccfd82b2e8 100644 --- a/wallet/src/account/output_cache/mod.rs +++ b/wallet/src/account/output_cache/mod.rs @@ -23,7 +23,7 @@ use common::{ DelegationId, Destination, OutPointSourceId, PoolId, Transaction, TxInput, TxOutput, UtxoOutPoint, }, - primitives::{id::WithId, Amount, Id}, + primitives::{id::WithId, Id}, }; use pos_accounting::make_delegation_id; use utils::ensure; @@ -37,16 +37,18 @@ use wallet_types::{ use crate::{WalletError, WalletResult}; pub struct DelegationData { - pub balance: Amount, + pub pool_id: PoolId, pub destination: Destination, pub last_nonce: Option, + pub not_staked_yet: bool, } impl DelegationData { - fn new(destination: Destination) -> DelegationData { + fn new(pool_id: PoolId, destination: Destination) -> DelegationData { DelegationData { - balance: Amount::ZERO, + pool_id, destination, last_nonce: None, + not_staked_yet: true, } } } @@ -217,21 +219,14 @@ impl OutputCache { ) }); } - TxOutput::DelegateStaking(amount, delegation_id) => { - match self.delegations.entry(*delegation_id) { - Entry::Vacant(_) => { - return Err(WalletError::InconsistentDelegationAddition( - *delegation_id, - )); - } - Entry::Occupied(mut entry) => { - let pool_data = entry.get_mut(); - pool_data.balance = (pool_data.balance + *amount) - .ok_or(WalletError::OutputAmountOverflow)?; - } - } + TxOutput::DelegateStaking(_, delegation_id) => { + let delegation_data = self + .delegations + .get_mut(delegation_id) + .ok_or(WalletError::InconsistentDelegationAddition(*delegation_id))?; + delegation_data.not_staked_yet = false; } - TxOutput::CreateDelegationId(destination, _) => { + TxOutput::CreateDelegationId(destination, pool_id) => { let input0_outpoint = tx .inputs() .get(0) @@ -239,8 +234,10 @@ impl OutputCache { .utxo_outpoint() .ok_or(WalletError::NoUtxos)?; let delegation_id = make_delegation_id(input0_outpoint); - self.delegations - .insert(delegation_id, DelegationData::new(destination.clone())); + self.delegations.insert( + delegation_id, + DelegationData::new(*pool_id, destination.clone()), + ); } | TxOutput::Burn(_) | TxOutput::Transfer(_, _) @@ -275,12 +272,9 @@ impl OutputCache { TxInput::Account(outpoint) => { if !already_present { match outpoint.account() { - Delegation(delegation_id, amount) => { + Delegation(delegation_id, _) => { match self.delegations.get_mut(delegation_id) { Some(data) => { - data.balance = (data.balance - *amount).ok_or( - WalletError::NegativeDelegationAmount(*delegation_id), - )?; let next_nonce = data .last_nonce .map_or(Some(AccountNonce::new(0)), |nonce| { @@ -323,12 +317,9 @@ impl OutputCache { self.unconfirmed_descendants.remove(tx_id); } TxInput::Account(outpoint) => match outpoint.account() { - Delegation(delegation_id, amount) => { + Delegation(delegation_id, _) => { match self.delegations.get_mut(delegation_id) { Some(data) => { - data.balance = (data.balance - *amount).ok_or( - WalletError::InconsistentDelegationRemoval(*delegation_id), - )?; data.last_nonce = outpoint.nonce().decrement(); } None => { @@ -496,23 +487,19 @@ impl OutputCache { TxInput::Utxo(outpoint) => { self.consumed.insert(outpoint.clone(), *tx.state()); } - TxInput::Account(outpoint) => { - match outpoint.account() { - Delegation(delegation_id, amount) => { - match self.delegations.get_mut(delegation_id) { - Some(data) => { - data.last_nonce = - outpoint.nonce().decrement(); - data.balance = (data.balance + *amount) - .ok_or(WalletError::DelegationAmountOverflow(*delegation_id))?; - } - None => { - return Err(WalletError::InconsistentDelegationRemoval(*delegation_id)); - } + TxInput::Account(outpoint) => match outpoint.account() { + Delegation(delegation_id, _) => { + match self.delegations.get_mut(delegation_id) { + Some(data) => { + data.last_nonce = + outpoint.nonce().decrement(); + } + None => { + return Err(WalletError::InconsistentDelegationRemoval(*delegation_id)); } } } - } + }, } } Ok(()) @@ -589,97 +576,3 @@ fn valid_timelock( fn is_in_state(tx: &WalletTx, utxo_states: UtxoStates) -> bool { utxo_states.contains(get_utxo_state(&tx.state())) } - -#[cfg(test)] -mod tests { - use common::{ - chain::block::timestamp::BlockTimestamp, - primitives::{BlockHeight, H256}, - }; - use crypto::key::extended::{ExtendedKeyKind, ExtendedPrivateKey}; - use crypto::random::Rng; - use rstest::rstest; - use test_utils::random::{make_seedable_rng, Seed}; - use wallet_types::{wallet_tx::TxData, AccountId}; - - use super::*; - - fn make_delegation_tx(output_index: u32) -> (Transaction, DelegationId) { - let input0_outpoint = UtxoOutPoint::new( - OutPointSourceId::Transaction(Id::::new(H256::zero())), - output_index, - ); - let delegation_id = make_delegation_id(&input0_outpoint); - let tx = Transaction::new( - 0, - vec![TxInput::from(input0_outpoint)], - vec![TxOutput::CreateDelegationId( - Destination::AnyoneCanSpend, - PoolId::new(H256::zero()), - )], - ) - .unwrap(); - - (tx, delegation_id) - } - - fn make_stake_delegation_tx(delegation_id: DelegationId) -> Transaction { - Transaction::new( - 0, - vec![], - vec![TxOutput::DelegateStaking(Amount::ZERO, delegation_id)], - ) - .unwrap() - } - - #[rstest] - #[trace] - #[case(Seed::from_entropy())] - fn test_wallet_transaction_sorting_on_load(#[case] seed: Seed) { - let mut rng = make_seedable_rng(seed); - - let account_id = AccountId::new_from_xpub( - &ExtendedPrivateKey::new_from_entropy(ExtendedKeyKind::Secp256k1Schnorr).1, - ); - - let mut delegation_ids = BTreeMap::new(); - let txs = (0..100) - .map(|idx| { - let tx: WithId = if rng.gen::() || delegation_ids.is_empty() { - let (tx, delegation_id) = make_delegation_tx(delegation_ids.len() as u32); - delegation_ids.insert(delegation_id, Amount::ZERO); - tx.into() - } else { - let delegation_id = - delegation_ids.keys().nth(rng.gen_range(0..delegation_ids.len())).unwrap(); - make_stake_delegation_tx(*delegation_id).into() - }; - - let tx_id = WithId::id(&tx); - - let wtx = WalletTx::Tx(TxData::new( - tx, - TxState::Confirmed( - BlockHeight::new(0), - BlockTimestamp::from_int_seconds(0), - idx as u64, - ), - )); - - ( - AccountWalletTxId::new(account_id.clone(), OutPointSourceId::from(tx_id)), - wtx, - ) - }) - .collect(); - - let cache = OutputCache::new(txs).unwrap(); - - let delegations: BTreeMap<&DelegationId, _> = cache.delegation_ids().collect(); - - for (delegation_id, balance) in delegation_ids { - let data = delegations.get(&delegation_id).unwrap(); - assert_eq!(data.balance, balance); - } - } -} diff --git a/wallet/src/wallet/mod.rs b/wallet/src/wallet/mod.rs index 39c57c1806..4e0a2a680d 100644 --- a/wallet/src/wallet/mod.rs +++ b/wallet/src/wallet/mod.rs @@ -18,7 +18,7 @@ use std::path::Path; use std::sync::Arc; use crate::account::transaction_list::TransactionList; -use crate::account::{Currency, UtxoSelectorError}; +use crate::account::{Currency, DelegationData, UtxoSelectorError}; use crate::key_chain::{KeyChainError, MasterKeyChain}; use crate::send_request::{ make_issue_nft_outputs, make_issue_token_outputs, StakePoolDataArguments, @@ -668,11 +668,19 @@ impl Wallet { pub fn get_delegations( &self, account_index: U31, - ) -> WalletResult> { + ) -> WalletResult> { let delegations = self.get_account(account_index)?.get_delegations(); Ok(delegations) } + pub fn get_delegation( + &self, + account_index: U31, + delegation_id: DelegationId, + ) -> WalletResult<&DelegationData> { + self.get_account(account_index)?.find_delegation(delegation_id) + } + pub fn get_new_address( &mut self, account_index: U31, @@ -768,10 +776,18 @@ impl Wallet { address: Address, amount: Amount, delegation_id: DelegationId, + delegation_share: Amount, current_fee_rate: FeeRate, ) -> WalletResult { self.for_account_rw_unlocked(account_index, |account, db_tx| { - account.spend_from_delegation(db_tx, address, amount, delegation_id, current_fee_rate) + account.spend_from_delegation( + db_tx, + address, + amount, + delegation_id, + delegation_share, + current_fee_rate, + ) }) } diff --git a/wallet/src/wallet/tests.rs b/wallet/src/wallet/tests.rs index 5dbd12c91c..d252b9523c 100644 --- a/wallet/src/wallet/tests.rs +++ b/wallet/src/wallet/tests.rs @@ -1654,6 +1654,7 @@ fn create_spend_from_delegations(#[case] seed: Seed) { address.clone(), Amount::from_atoms(1), delegation_id, + Amount::from_atoms(2), FeeRate::new(Amount::ZERO), ) .unwrap(); @@ -1667,6 +1668,7 @@ fn create_spend_from_delegations(#[case] seed: Seed) { address, Amount::from_atoms(1), delegation_id, + Amount::from_atoms(1), FeeRate::new(Amount::ZERO), ) .unwrap(); @@ -1676,12 +1678,8 @@ fn create_spend_from_delegations(#[case] seed: Seed) { // Check delegation balance after unconfirmed tx status let mut delegations = wallet.get_delegations(DEFAULT_ACCOUNT_INDEX).unwrap().collect_vec(); assert_eq!(delegations.len(), 1); - let (deleg_id, deleg_amount) = delegations.pop().unwrap(); + let (deleg_id, _pool_id) = delegations.pop().unwrap(); assert_eq!(*deleg_id, delegation_id); - assert_eq!( - deleg_amount, - (delegation_amount - Amount::from_atoms(2)).unwrap() - ); let block5 = Block::new( delegation_tx1, @@ -1705,12 +1703,8 @@ fn create_spend_from_delegations(#[case] seed: Seed) { // Check delegation balance after confirmed tx status let mut delegations = wallet.get_delegations(DEFAULT_ACCOUNT_INDEX).unwrap().collect_vec(); assert_eq!(delegations.len(), 1); - let (deleg_id, deleg_amount) = delegations.pop().unwrap(); + let (deleg_id, _pool_id) = delegations.pop().unwrap(); assert_eq!(*deleg_id, delegation_id); - assert_eq!( - deleg_amount, - (delegation_amount - Amount::from_atoms(2)).unwrap() - ); } #[rstest] diff --git a/wallet/wallet-cli-lib/src/commands/mod.rs b/wallet/wallet-cli-lib/src/commands/mod.rs index 0d942c498f..4206008998 100644 --- a/wallet/wallet-cli-lib/src/commands/mod.rs +++ b/wallet/wallet-cli-lib/src/commands/mod.rs @@ -1114,19 +1114,21 @@ impl CommandHandler { ) }) .collect(); - Ok(ConsoleCommand::Print(format!("[{}]", pool_ids.join(", ")))) + Ok(ConsoleCommand::Print(pool_ids.join("\n").to_string())) } WalletCommand::ListDelegationIds => { let (controller, selected_account) = self.get_controller_and_selected_acc()?; - let pool_ids: Vec<_> = controller + let delegations: Vec<_> = controller .get_delegations(selected_account) + .await .map_err(WalletCliError::Controller)? + .into_iter() .map(|(delegation_id, balance)| { - format_delegation_info(*delegation_id, balance, chain_config.as_ref()) + format_delegation_info(delegation_id, balance, chain_config.as_ref()) }) .collect(); - Ok(ConsoleCommand::Print(format!("[{}]", pool_ids.join(", ")))) + Ok(ConsoleCommand::Print(delegations.join("\n").to_string())) } WalletCommand::NodeShutdown => { diff --git a/wallet/wallet-controller/src/lib.rs b/wallet/wallet-controller/src/lib.rs index c00f8a8cc7..48b036e5c3 100644 --- a/wallet/wallet-controller/src/lib.rs +++ b/wallet/wallet-controller/src/lib.rs @@ -67,8 +67,8 @@ pub use node_comm::{ handles_client::WalletHandlesClient, make_rpc_client, rpc_client::NodeRpcClient, }; use wallet::{ - account::transaction_list::TransactionList, account::Currency, + account::{transaction_list::TransactionList, DelegationData}, send_request::{ make_address_output, make_address_output_token, make_create_delegation_output, StakePoolDataArguments, @@ -481,6 +481,30 @@ impl Controll .log_err() } + async fn get_delegation_share( + &self, + chain_config: &ChainConfig, + delegation_data: &DelegationData, + delegation_id: DelegationId, + ) -> Result<(DelegationId, Amount), ControllerError> { + if delegation_data.not_staked_yet { + return Ok((delegation_id, Amount::ZERO)); + } + + self.rpc_client + .get_delegation_share(delegation_data.pool_id, delegation_id) + .await + .map_err(ControllerError::NodeCallError) + .and_then(|balance| { + balance.ok_or(ControllerError::SyncError(format!( + "Delegation id {} from wallet not found in node", + Address::new(chain_config, &delegation_id)? + ))) + }) + .map(|balance| (delegation_id, balance)) + .log_err() + } + pub async fn get_pool_ids( &self, chain_config: &ChainConfig, @@ -497,11 +521,27 @@ impl Controll tasks.try_collect().await } - pub fn get_delegations( + pub async fn get_delegations( &mut self, account_index: U31, - ) -> Result, ControllerError> { - self.wallet.get_delegations(account_index).map_err(ControllerError::WalletError) + ) -> Result, ControllerError> { + let delegations = self + .wallet + .get_delegations(account_index) + .map_err(ControllerError::WalletError)?; + + let tasks: FuturesUnordered<_> = delegations + .into_iter() + .map(|(delegation_id, delegation_data)| { + self.get_delegation_share( + self.chain_config.as_ref(), + delegation_data, + *delegation_id, + ) + }) + .collect(); + + tasks.try_collect().await } pub fn get_vrf_public_key( @@ -634,6 +674,21 @@ impl Controll .await .map_err(ControllerError::NodeCallError)?; + let pool_id = self + .wallet + .get_delegation(account_index, delegation_id) + .map_err(ControllerError::WalletError)? + .pool_id; + + let delegation_share = self + .rpc_client + .get_delegation_share(pool_id, delegation_id) + .await + .map_err(ControllerError::NodeCallError)? + .ok_or(ControllerError::WalletError( + WalletError::DelegationNotFound(delegation_id), + ))?; + let tx = self .wallet .create_transaction_to_addresses_from_delegation( @@ -641,6 +696,7 @@ impl Controll address, amount, delegation_id, + delegation_share, current_fee_rate, ) .map_err(ControllerError::WalletError)?; diff --git a/wallet/wallet-controller/src/sync/tests/mod.rs b/wallet/wallet-controller/src/sync/tests/mod.rs index 3c54ee6a2b..0917c1862e 100644 --- a/wallet/wallet-controller/src/sync/tests/mod.rs +++ b/wallet/wallet-controller/src/sync/tests/mod.rs @@ -23,7 +23,7 @@ use chainstate_test_framework::TestFramework; use common::{ chain::{ tokens::{RPCTokenInfo, TokenId}, - PoolId, SignedTransaction, + DelegationId, PoolId, SignedTransaction, }, primitives::Amount, }; @@ -252,6 +252,14 @@ impl NodeInterface for MockNode { unreachable!() } + async fn get_delegation_share( + &self, + _pool_id: PoolId, + _delegation_id: DelegationId, + ) -> Result, Self::Error> { + unreachable!() + } + async fn get_token_info( &self, _token_id: TokenId, diff --git a/wallet/wallet-node-client/src/handles_client/mod.rs b/wallet/wallet-node-client/src/handles_client/mod.rs index bd76a5e7b5..a9c7886bbf 100644 --- a/wallet/wallet-node-client/src/handles_client/mod.rs +++ b/wallet/wallet-node-client/src/handles_client/mod.rs @@ -18,7 +18,7 @@ use chainstate::{BlockSource, ChainInfo, ChainstateError, ChainstateHandle}; use common::{ chain::{ tokens::{RPCTokenInfo, TokenId}, - Block, GenBlock, PoolId, SignedTransaction, + Block, DelegationId, GenBlock, PoolId, SignedTransaction, }, primitives::{Amount, BlockHeight, Id}, }; @@ -165,6 +165,18 @@ impl NodeInterface for WalletHandlesClient { Ok(result) } + async fn get_delegation_share( + &self, + pool_id: PoolId, + delegation_id: DelegationId, + ) -> Result, Self::Error> { + let result = self + .chainstate + .call(move |this| this.get_stake_pool_delegation_share(pool_id, delegation_id)) + .await??; + Ok(result) + } + async fn get_token_info(&self, token_id: TokenId) -> Result, Self::Error> { let result = self .chainstate diff --git a/wallet/wallet-node-client/src/node_traits.rs b/wallet/wallet-node-client/src/node_traits.rs index f4323ab8a1..f19908933c 100644 --- a/wallet/wallet-node-client/src/node_traits.rs +++ b/wallet/wallet-node-client/src/node_traits.rs @@ -17,7 +17,7 @@ use chainstate::ChainInfo; use common::{ chain::{ tokens::{RPCTokenInfo, TokenId}, - Block, GenBlock, PoolId, SignedTransaction, + Block, DelegationId, GenBlock, PoolId, SignedTransaction, }, primitives::{Amount, BlockHeight, Id}, }; @@ -51,6 +51,11 @@ pub trait NodeInterface { ) -> Result, BlockHeight)>, Self::Error>; async fn get_stake_pool_balance(&self, pool_id: PoolId) -> Result, Self::Error>; async fn get_stake_pool_pledge(&self, pool_id: PoolId) -> Result, Self::Error>; + async fn get_delegation_share( + &self, + pool_id: PoolId, + delegation_id: DelegationId, + ) -> Result, Self::Error>; async fn get_token_info(&self, token_id: TokenId) -> Result, Self::Error>; async fn generate_block( &self, 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 087c6e8cc0..22a7ba9f4e 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,7 @@ use chainstate::{rpc::ChainstateRpcClient, ChainInfo}; use common::{ chain::{ tokens::{RPCTokenInfo, TokenId}, - Block, GenBlock, PoolId, SignedTransaction, + Block, DelegationId, GenBlock, PoolId, SignedTransaction, }, primitives::{Amount, BlockHeight, Id}, }; @@ -112,6 +112,16 @@ impl NodeInterface for NodeRpcClient { .map_err(NodeRpcError::ResponseError) } + async fn get_delegation_share( + &self, + pool_id: PoolId, + delegation_id: DelegationId, + ) -> Result, Self::Error> { + ChainstateRpcClient::delegation_share(&self.http_client, pool_id, delegation_id) + .await + .map_err(NodeRpcError::ResponseError) + } + async fn get_token_info(&self, token_id: TokenId) -> Result, Self::Error> { ChainstateRpcClient::token_info(&self.http_client, token_id) .await