diff --git a/clive/__private/cli/commands/process/process_claim_rewards.py b/clive/__private/cli/commands/process/process_claim_rewards.py new file mode 100644 index 0000000000000000000000000000000000000000..56d91bc7f39d1040f2c2796752d173682a987499 --- /dev/null +++ b/clive/__private/cli/commands/process/process_claim_rewards.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import errno +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, override + +from clive.__private.cli.commands.abc.operation_command import OperationCommand +from clive.__private.cli.exceptions import CLIPrettyError +from clive.__private.models.schemas import ClaimRewardBalanceOperation + +if TYPE_CHECKING: + from clive.__private.cli.types import ComposeTransaction + from clive.__private.models.schemas import Account + + +class CLIClaimRewardsZeroBalanceError(CLIPrettyError): + def __init__(self, account_name: str) -> None: + self.account_name = account_name + message = f"Account `{account_name}` has no rewards to claim." + super().__init__(message, errno.ENODATA) + + +@dataclass(kw_only=True) +class ProcessClaimRewards(OperationCommand): + account_name: str + _account: Account = field(init=False) + + @override + async def fetch_data(self) -> None: + accounts = (await self.world.commands.find_accounts(accounts=[self.account_name])).result_or_raise + assert len(accounts) == 1, f"Expected exactly one account, got {len(accounts)}" + self._account = accounts[0] + + @override + async def validate_inside_context_manager(self) -> None: + if self._has_zero_rewards(): + raise CLIClaimRewardsZeroBalanceError(self.account_name) + await super().validate_inside_context_manager() + + async def _create_operations(self) -> ComposeTransaction: + yield ClaimRewardBalanceOperation( + account=self.account_name, + reward_hive=self._account.reward_hive_balance, + reward_hbd=self._account.reward_hbd_balance, + reward_vests=self._account.reward_vesting_balance, + ) + + def _has_zero_rewards(self) -> bool: + return ( + self._account.reward_hive_balance.amount == 0 + and self._account.reward_hbd_balance.amount == 0 + and self._account.reward_vesting_balance.amount == 0 + ) diff --git a/clive/__private/cli/commands/show/show_balances.py b/clive/__private/cli/commands/show/show_balances.py index 713ead2659ad306848dab9d3d503568e33d2b633..281b09464c0841424378a098cddb6c7750e1c82d 100644 --- a/clive/__private/cli/commands/show/show_balances.py +++ b/clive/__private/cli/commands/show/show_balances.py @@ -30,5 +30,7 @@ class ShowBalances(WorldBasedCommand): table.add_row("HIVE Liquid", f"{Asset.pretty_amount(data.hive_balance)}") table.add_row("HIVE Savings", f"{Asset.pretty_amount(data.hive_savings)}") table.add_row("HIVE Unclaimed", f"{Asset.pretty_amount(data.hive_unclaimed)}") + table.add_row("STAKE Owned", f"{Asset.pretty_amount(data.owned_hp_balance.hp_balance)}") + table.add_row("STAKE Unclaimed", f"{Asset.pretty_amount(data.unclaimed_hp_balance.hp_balance)}") print_cli(table) diff --git a/clive/__private/cli/process/claim.py b/clive/__private/cli/process/claim.py index 02ea81f8c4899ae42649d323e1538dae94af221d..444ac03088686000ff5a0268e0dbdb4705229d12 100644 --- a/clive/__private/cli/process/claim.py +++ b/clive/__private/cli/process/claim.py @@ -42,3 +42,23 @@ async def process_claim_new_account_token( # noqa: PLR0913 save_file=save_file, autosign=autosign, ).run() + + +@claim.command(name="rewards") +async def process_claim_rewards( + account_name: str = options.account_name, + sign_with: str | None = options.sign_with, + autosign: bool | None = options.autosign, # noqa: FBT001 + broadcast: bool | None = options.broadcast, # noqa: FBT001 + save_file: str | None = options.save_file, +) -> None: + """Claim all pending blockchain rewards in HBD, HP (VESTS), and HIVE. Requires posting authority.""" + from clive.__private.cli.commands.process.process_claim_rewards import ProcessClaimRewards # noqa: PLC0415 + + await ProcessClaimRewards( + account_name=account_name, + sign_with=sign_with, + broadcast=broadcast, + save_file=save_file, + autosign=autosign, + ).run() diff --git a/docker/Dockerfile b/docker/Dockerfile index 4faf4b9e1341ef9e41b3b54d89a771545ee00271..764289dca5ca0196c103dd5f2dde7927849ae9fc 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -85,6 +85,9 @@ WORKDIR /clive SHELL ["/bin/bash", "-c"] +# Create the virtual environment (not pre-existing in the base image) +RUN python3 -m venv "${PYTHON_VENV_PATH}" + # Project IDS: # - 198 -> hive (generated APIs) # - 362 -> schemas diff --git a/docs/cli_commands_structure.md b/docs/cli_commands_structure.md index 177575168443c2343b0f356bcd19b27118b7e2a6..cf5fd0f84f431e9fad9eea17259a47b9fe6b34b2 100644 --- a/docs/cli_commands_structure.md +++ b/docs/cli_commands_structure.md @@ -73,7 +73,8 @@ clive │ ├── account-creation │ ├── change-recovery-account │ ├── claim -│ │ └── new-account-token +│ │ ├── new-account-token +│ │ └── rewards │ ├── custom-json │ ├── delegations │ │ ├── remove diff --git a/pydoclint-errors-baseline.txt b/pydoclint-errors-baseline.txt index cde6ffd639fff6aed4180dd9ac642532eece73c2..8d038921e2043a8eeef9a7fb8a29fb1c53c4151b 100644 --- a/pydoclint-errors-baseline.txt +++ b/pydoclint-errors-baseline.txt @@ -104,6 +104,8 @@ clive/__private/cli/main.py clive/__private/cli/process/claim.py DOC101: Function `process_claim_new_account_token`: Docstring contains fewer arguments than in function signature. DOC103: Function `process_claim_new_account_token`: Docstring arguments are different from function arguments. (Or could be other formatting issues: https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). Arguments in the function signature but not in the docstring: [autosign: bool | None, broadcast: bool | None, creator: str, fee: str | None, save_file: str | None, sign_with: str | None]. + DOC101: Function `process_claim_rewards`: Docstring contains fewer arguments than in function signature. + DOC103: Function `process_claim_rewards`: Docstring arguments are different from function arguments. (Or could be other formatting issues: https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). Arguments in the function signature but not in the docstring: [account_name: str, autosign: bool | None, broadcast: bool | None, save_file: str | None, sign_with: str | None]. -------------------- clive/__private/cli/process/custom_operations/custom_json.py DOC101: Function `process_custom_json`: Docstring contains fewer arguments than in function signature. diff --git a/tests/clive-local-tools/clive_local_tools/cli/cli_tester.py b/tests/clive-local-tools/clive_local_tools/cli/cli_tester.py index 0efa11963501d1a2aa94eaf198fe031448fb8984..b5d65fd240c3599220c2d5ea8e5e21429ee84818 100644 --- a/tests/clive-local-tools/clive_local_tools/cli/cli_tester.py +++ b/tests/clive-local-tools/clive_local_tools/cli/cli_tester.py @@ -687,3 +687,14 @@ class CLITester: return self.__invoke_command_with_options( ["show", "pending", "decline-voting-rights"], account_name=account_name ) + + def process_claim_rewards( + self, + *, + account_name: str | None = None, + sign_with: str | None = None, + broadcast: bool | None = None, + save_file: Path | None = None, + autosign: bool | None = None, + ) -> CLITestResult: + return self.__invoke_command_with_options(["process", "claim", "rewards"], **extract_params(locals())) diff --git a/tests/clive-local-tools/clive_local_tools/testnet_block_log/block_log_with_config/.gitignore b/tests/clive-local-tools/clive_local_tools/testnet_block_log/block_log_with_config/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..2db9f3eea526f569e81a09351b9c98d1cd131714 --- /dev/null +++ b/tests/clive-local-tools/clive_local_tools/testnet_block_log/block_log_with_config/.gitignore @@ -0,0 +1 @@ +*.artifacts diff --git a/tests/clive-local-tools/clive_local_tools/testnet_block_log/block_log_with_config/alternate-chain-spec.json b/tests/clive-local-tools/clive_local_tools/testnet_block_log/block_log_with_config/alternate-chain-spec.json index 473cd2c7d852df056adfe9e2c0c2f032ee3dd262..7c1dc81f65b16b6abd10c391188937157812575b 100644 --- a/tests/clive-local-tools/clive_local_tools/testnet_block_log/block_log_with_config/alternate-chain-spec.json +++ b/tests/clive-local-tools/clive_local_tools/testnet_block_log/block_log_with_config/alternate-chain-spec.json @@ -1,5 +1,5 @@ { - "genesis_time": 1749451951, + "genesis_time": 1765888694, "hardfork_schedule": [ { "block_num": 1, diff --git a/tests/clive-local-tools/clive_local_tools/testnet_block_log/block_log_with_config/block_log_part.0001 b/tests/clive-local-tools/clive_local_tools/testnet_block_log/block_log_with_config/block_log_part.0001 index e71cd96615740cfa53d0e5fa665eb046a58bb4aa..375901127a556a538010c86a0d26d8a6e160e0ae 100644 Binary files a/tests/clive-local-tools/clive_local_tools/testnet_block_log/block_log_with_config/block_log_part.0001 and b/tests/clive-local-tools/clive_local_tools/testnet_block_log/block_log_with_config/block_log_part.0001 differ diff --git a/tests/clive-local-tools/clive_local_tools/testnet_block_log/block_log_with_config/block_log_part.0001.artifacts b/tests/clive-local-tools/clive_local_tools/testnet_block_log/block_log_with_config/block_log_part.0001.artifacts deleted file mode 100644 index 5b94ecd488616ca0ef78c9f28a2a3715e2e991ca..0000000000000000000000000000000000000000 Binary files a/tests/clive-local-tools/clive_local_tools/testnet_block_log/block_log_with_config/block_log_part.0001.artifacts and /dev/null differ diff --git a/tests/clive-local-tools/clive_local_tools/testnet_block_log/block_log_with_config/config.ini b/tests/clive-local-tools/clive_local_tools/testnet_block_log/block_log_with_config/config.ini index 9685d470e2e60115663905338438de8d64a8ae93..d43169a42d58844372763c9fe03885aacc41e3e2 100644 --- a/tests/clive-local-tools/clive_local_tools/testnet_block_log/block_log_with_config/config.ini +++ b/tests/clive-local-tools/clive_local_tools/testnet_block_log/block_log_with_config/config.ini @@ -1,6 +1,6 @@ # config automatically generated by helpy enable-stale-production = yes -log-logger = {"name":"default","level":"debug","appender":"stderr"} {"name":"user","level":"debug","appender":"stderr"} {"name":"chainlock","level":"debug","appender":"p2p"} {"name":"sync","level":"debug","appender":"p2p"} {"name":"p2p","level":"debug","appender":"p2p"} +log-logger = {"name":"default","level":"info","appender":"stderr"} {"name":"user","level":"info","appender":"stderr"} {"name":"chainlock","level":"info","appender":"p2p"} {"name":"sync","level":"info","appender":"p2p"} {"name":"p2p","level":"info","appender":"p2p"} p2p-endpoint = 0.0.0.0:0 plugin = account_by_key plugin = account_by_key_api diff --git a/tests/clive-local-tools/clive_local_tools/testnet_block_log/constants.py b/tests/clive-local-tools/clive_local_tools/testnet_block_log/constants.py index 8545f6c4ee2dd3e78695d74fc54b2b61040a9c64..1b45bb5b4a35592f4b97a5a44d400120d4d2742a 100644 --- a/tests/clive-local-tools/clive_local_tools/testnet_block_log/constants.py +++ b/tests/clive-local-tools/clive_local_tools/testnet_block_log/constants.py @@ -14,7 +14,7 @@ if TYPE_CHECKING: from datetime import timedelta BLOCK_LOG_WITH_CONFIG_DIRECTORY: Final[Path] = Path(__file__).parent / "block_log_with_config" -EXTRA_TIME_SHIFT_FOR_GOVERNANCE: Final[timedelta] = tt.Time.days(1) +EXTRA_TIME_SHIFT_FOR_GOVERNANCE_AND_REWARDS: Final[timedelta] = tt.Time.days(1) WITNESSES: Final[list[tt.Account]] = [tt.Account(name) for name in [generate_witness_name(i) for i in range(60)]] diff --git a/tests/clive-local-tools/clive_local_tools/testnet_block_log/generate_block_log.py b/tests/clive-local-tools/clive_local_tools/testnet_block_log/generate_block_log.py index fc76c6aa88815d7ead2173c6f75bb669d7d1ffff..0aedb6f592427d3d7fa789ce9aa3495eca270f52 100644 --- a/tests/clive-local-tools/clive_local_tools/testnet_block_log/generate_block_log.py +++ b/tests/clive-local-tools/clive_local_tools/testnet_block_log/generate_block_log.py @@ -198,6 +198,25 @@ def prepare_votes_for_witnesses(wallet: tt.Wallet) -> None: wallet.api.vote_for_witness(ALT_WORKING_ACCOUNT2_DATA.account.name, WITNESSES[i].name, approve=True) +def create_example_post_and_vote(wallet: tt.Wallet) -> None: + tt.logger.info("Creating example post and vote for alice...") + wallet.api.post_comment( + WORKING_ACCOUNT_DATA.account.name, + "example-post", + "", + "parent-example-post", + "Example Post Title", + "This is an example post body.", + "{}", + ) + wallet.api.vote( + WORKING_ACCOUNT_DATA.account.name, + WORKING_ACCOUNT_DATA.account.name, + "example-post", + 100, + ) + + def main() -> None: node = tt.InitNode() configure(node) @@ -215,6 +234,7 @@ def main() -> None: create_watched_accounts(wallet) prepare_savings(wallet) prepare_votes_for_witnesses(wallet) + create_example_post_and_vote(wallet) create_empty_account(wallet) create_known_exchange_accounts(wallet) diff --git a/tests/clive-local-tools/clive_local_tools/testnet_block_log/prepared_data.py b/tests/clive-local-tools/clive_local_tools/testnet_block_log/prepared_data.py index 647c5387c82b56ac3e5854aaa02e36d8a77850e7..83953609c90ea51c2afadd849a768448c22933be 100644 --- a/tests/clive-local-tools/clive_local_tools/testnet_block_log/prepared_data.py +++ b/tests/clive-local-tools/clive_local_tools/testnet_block_log/prepared_data.py @@ -6,7 +6,7 @@ import test_tools as tt from clive_local_tools.testnet_block_log.constants import ( BLOCK_LOG_WITH_CONFIG_DIRECTORY, - EXTRA_TIME_SHIFT_FOR_GOVERNANCE, + EXTRA_TIME_SHIFT_FOR_GOVERNANCE_AND_REWARDS, ) if TYPE_CHECKING: @@ -25,12 +25,16 @@ def get_alternate_chain_spec() -> tt.AlternateChainSpecs: def get_block_log() -> tt.BlockLog: - return tt.BlockLog(BLOCK_LOG_WITH_CONFIG_DIRECTORY) + """Makes copy of block_log, ensuring artifacts are excluded.""" + block_log = tt.BlockLog(BLOCK_LOG_WITH_CONFIG_DIRECTORY) + directory = tt.context.get_current_directory() / "block_log_copy" + directory.mkdir(exist_ok=True) + return block_log.copy_to(directory, artifacts="excluded") def get_time_control(block_log: tt.BlockLog) -> tt.StartTimeControl: block_log_time = block_log.get_head_block_time() - return tt.StartTimeControl(start_time=block_log_time + EXTRA_TIME_SHIFT_FOR_GOVERNANCE) + return tt.StartTimeControl(start_time=block_log_time + EXTRA_TIME_SHIFT_FOR_GOVERNANCE_AND_REWARDS) def run_node(webserver_http_endpoint: HttpUrl | None = None) -> tt.RawNode: diff --git a/tests/functional/cli/process/test_process_claim_rewards.py b/tests/functional/cli/process/test_process_claim_rewards.py new file mode 100644 index 0000000000000000000000000000000000000000..5ae014e5b64b9ca0717047c4700cab3947429648 --- /dev/null +++ b/tests/functional/cli/process/test_process_claim_rewards.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from clive.__private.cli.commands.process.process_claim_rewards import CLIClaimRewardsZeroBalanceError +from clive.__private.models.schemas import ClaimRewardBalanceOperation +from clive_local_tools.checkers.blockchain_checkers import assert_operations_placed_in_blockchain +from clive_local_tools.cli.exceptions import CLITestCommandError +from clive_local_tools.data.constants import WORKING_ACCOUNT_KEY_ALIAS +from clive_local_tools.helpers import get_formatted_error_message +from clive_local_tools.testnet_block_log.constants import EMPTY_ACCOUNT, WORKING_ACCOUNT_DATA + +if TYPE_CHECKING: + import test_tools as tt + + from clive_local_tools.cli.cli_tester import CLITester + + +async def test_claim_rewards_success(node: tt.RawNode, cli_tester: CLITester) -> None: + """Test claiming rewards for alice account which has non-zero rewards from block log.""" + # ARRANGE + account_name = WORKING_ACCOUNT_DATA.account.name + accounts = node.api.database.find_accounts(accounts=[account_name]) + account = accounts.accounts[0] + + # ASSERT precondition - alice should have some rewards to claim + has_rewards = ( + int(account.reward_hive_balance.amount) > 0 + or int(account.reward_hbd_balance.amount) > 0 + or int(account.reward_vesting_balance.amount) > 0 + ) + assert has_rewards, f"Account {account_name} should have rewards to claim for this test" + + operation = ClaimRewardBalanceOperation( + account=account_name, + reward_hive=account.reward_hive_balance, + reward_hbd=account.reward_hbd_balance, + reward_vests=account.reward_vesting_balance, + ) + + # ACT + result = cli_tester.process_claim_rewards(sign_with=WORKING_ACCOUNT_KEY_ALIAS) + + # ASSERT + assert_operations_placed_in_blockchain(node, result, operation) + + +async def test_negative_claim_rewards_fails_with_zero_balance(cli_tester: CLITester) -> None: + """Test that claiming rewards fails when account has no rewards to claim.""" + # ARRANGE + account_name = EMPTY_ACCOUNT.name + expected_error_message = get_formatted_error_message(CLIClaimRewardsZeroBalanceError(account_name)) + + # ACT & ASSERT + with pytest.raises(CLITestCommandError, match=expected_error_message): + cli_tester.process_claim_rewards(account_name=account_name, sign_with=WORKING_ACCOUNT_KEY_ALIAS)