diff --git a/clive/__private/cli/commands/abc/forceable_cli_command.py b/clive/__private/cli/commands/abc/forceable_cli_command.py new file mode 100644 index 0000000000000000000000000000000000000000..77208c531ef692b6f0f24d1818c28407213eb0a5 --- /dev/null +++ b/clive/__private/cli/commands/abc/forceable_cli_command.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass(kw_only=True) +class ForceableCLICommand: + """A base class for CLI commands that can be forced to execute.""" + + force: bool = field(default=False) + """Whether to force the execution of the command.""" diff --git a/clive/__private/cli/commands/abc/perform_actions_on_transaction_command.py b/clive/__private/cli/commands/abc/perform_actions_on_transaction_command.py index 8687775892118a64698e7b5cb9fed7460fe7299a..dd19c0f5acc3a6ac68f435fe574d29427ffa2d90 100644 --- a/clive/__private/cli/commands/abc/perform_actions_on_transaction_command.py +++ b/clive/__private/cli/commands/abc/perform_actions_on_transaction_command.py @@ -9,18 +9,21 @@ from typing import TYPE_CHECKING import rich import typer +from clive.__private.cli.commands.abc.forceable_cli_command import ForceableCLICommand from clive.__private.cli.commands.abc.world_based_command import WorldBasedCommand from clive.__private.cli.exceptions import ( CLIBroadcastCannotBeUsedWithForceUnsignError, CLIPrettyError, CLITransactionBadAccountError, CLITransactionNotSignedError, + CLITransactionToExchangeError, CLITransactionUnknownAccountError, ) from clive.__private.core.commands.sign import ALREADY_SIGNED_MODE_DEFAULT, AlreadySignedMode from clive.__private.core.ensure_transaction import ensure_transaction from clive.__private.core.formatters.humanize import humanize_validation_result from clive.__private.core.keys.key_manager import KeyNotFoundError +from clive.__private.validators.exchange_operations_validator import ExchangeOperationsValidatorCli from clive.__private.validators.path_validator import PathValidator if TYPE_CHECKING: @@ -30,7 +33,7 @@ if TYPE_CHECKING: @dataclass(kw_only=True) -class PerformActionsOnTransactionCommand(WorldBasedCommand, ABC): +class PerformActionsOnTransactionCommand(WorldBasedCommand, ForceableCLICommand, ABC): sign: str | None = None already_signed_mode: AlreadySignedMode = ALREADY_SIGNED_MODE_DEFAULT force_unsign: bool = False @@ -56,6 +59,7 @@ class PerformActionsOnTransactionCommand(WorldBasedCommand, ABC): await self._validate_bad_accounts() if self.profile.should_enable_known_accounts: await self._validate_unknown_accounts() + await self._validate_operations_to_exchange() await super().validate_inside_context_manager() async def _run(self) -> None: @@ -118,6 +122,17 @@ class PerformActionsOnTransactionCommand(WorldBasedCommand, ABC): if bad_accounts: raise CLITransactionBadAccountError(*bad_accounts) + async def _validate_operations_to_exchange(self) -> None: + transaction_ensured = await self.get_transaction() + exchange_operation_validator = ExchangeOperationsValidatorCli( + transaction=transaction_ensured, + suppress_force_validation=self.force, + ) + for exchange in self.world.known_exchanges: + result = exchange_operation_validator.validate(exchange.name) + if not result.is_valid: + raise CLITransactionToExchangeError(humanize_validation_result(result)) + def _get_transaction_created_message(self) -> str: return "created" diff --git a/clive/__private/cli/commands/configure/profile.py b/clive/__private/cli/commands/configure/profile.py index fd764e993c3da0e2786f5820f52e761a2f901e8f..e9456afe501cfd0d3a0fc7b655ed265f5af79c8d 100644 --- a/clive/__private/cli/commands/configure/profile.py +++ b/clive/__private/cli/commands/configure/profile.py @@ -8,6 +8,7 @@ from getpass import getpass from beekeepy.exceptions import CommunicationError from clive.__private.cli.commands.abc.external_cli_command import ExternalCLICommand +from clive.__private.cli.commands.abc.forceable_cli_command import ForceableCLICommand from clive.__private.cli.commands.abc.world_based_command import WorldBasedCommand from clive.__private.cli.exceptions import ( CLICreatingProfileCommunicationError, @@ -97,9 +98,8 @@ class CreateProfile(WorldBasedCommand): @dataclass(kw_only=True) -class DeleteProfile(ExternalCLICommand): +class DeleteProfile(ExternalCLICommand, ForceableCLICommand): profile_name: str - force: bool async def _run(self) -> None: try: diff --git a/clive/__private/cli/commands/process/process_delegations.py b/clive/__private/cli/commands/process/process_delegations.py index 47019dabe5c96be5424ef10e0eb0585016dba65c..d3c53e1668a5b406f57f3e20a79bd900c79bcf38 100644 --- a/clive/__private/cli/commands/process/process_delegations.py +++ b/clive/__private/cli/commands/process/process_delegations.py @@ -19,6 +19,10 @@ class ProcessDelegations(OperationCommand): delegatee: str amount: Asset.VotingT + async def validate(self) -> None: + await self._validate_amount() + await super().validate() + async def _create_operation(self) -> DelegateVestingSharesOperation: vesting_shares = await ensure_vests_async(self.amount, self.world) @@ -28,10 +32,6 @@ class ProcessDelegations(OperationCommand): vesting_shares=vesting_shares, ) - async def validate(self) -> None: - await self._validate_amount() - await super().validate() - async def _validate_amount(self) -> None: if self.amount in DELEGATION_REMOVE_ASSETS: raise DelegationsZeroAmountError diff --git a/clive/__private/cli/commands/process/process_transaction.py b/clive/__private/cli/commands/process/process_transaction.py index 8e9c8b50a0bb2ab353d90e4d6b3b9ba5818086d0..0a69686c0696b32b158967085ca5b891632cf907 100644 --- a/clive/__private/cli/commands/process/process_transaction.py +++ b/clive/__private/cli/commands/process/process_transaction.py @@ -18,7 +18,6 @@ if TYPE_CHECKING: @dataclass(kw_only=True) class ProcessTransaction(PerformActionsOnTransactionCommand): from_file: str | Path - _loaded_transaction: Transaction | None = None @property @@ -31,15 +30,6 @@ class ProcessTransaction(PerformActionsOnTransactionCommand): self._loaded_transaction = await self.__load_transaction() return self._loaded_transaction - async def __load_transaction(self) -> Transaction: - return await LoadTransaction(file_path=self.from_file_path).execute_with_result() - - async def _get_transaction_content(self) -> Transaction: - return await self.__loaded_transaction - - def _get_transaction_created_message(self) -> str: - return "loaded" - async def validate(self) -> None: """ Validate given options before taking any action. @@ -76,3 +66,12 @@ class ProcessTransaction(PerformActionsOnTransactionCommand): async def _is_transaction_signed(self) -> bool: return (await self.__loaded_transaction).is_signed + + async def _get_transaction_content(self) -> Transaction: + return await self.__loaded_transaction + + def _get_transaction_created_message(self) -> str: + return "loaded" + + async def __load_transaction(self) -> Transaction: + return await LoadTransaction(file_path=self.from_file_path).execute_with_result() diff --git a/clive/__private/cli/commands/process/process_transfer_schedule.py b/clive/__private/cli/commands/process/process_transfer_schedule.py index 7175a1f9e031451901a7efb511c8d5d0fd9cc8c2..8d5466ac67af4b28e5e733f29b0ad43459ddbcba 100644 --- a/clive/__private/cli/commands/process/process_transfer_schedule.py +++ b/clive/__private/cli/commands/process/process_transfer_schedule.py @@ -107,17 +107,6 @@ class _ProcessTransferScheduleCreateModifyCommon(_ProcessTransferScheduleCommon) assert self.frequency is not None, "Value of frequency is known at this point." return self.frequency - async def _create_operation(self) -> RecurrentTransferOperation: - return RecurrentTransferOperation( - from_=self.from_account, - to=self.to, - amount=self.amount, - memo=self.memo, - recurrence=timedelta_to_int_hours(self.frequency_ensure), - executions=self.repeat, - extensions=self._create_recurrent_transfer_pair_id_extension(), - ) - def validate_amount_not_a_removal_value(self) -> None: """ Validate amount for create, and modify calls. @@ -134,6 +123,17 @@ class _ProcessTransferScheduleCreateModifyCommon(_ProcessTransferScheduleCommon) if scheduled_transfer_lifetime > SCHEDULED_TRANSFER_MAX_LIFETIME: raise ProcessTransferScheduleTooLongLifetimeError(requested_lifetime=scheduled_transfer_lifetime) + async def _create_operation(self) -> RecurrentTransferOperation: + return RecurrentTransferOperation( + from_=self.from_account, + to=self.to, + amount=self.amount, + memo=self.memo, + recurrence=timedelta_to_int_hours(self.frequency_ensure), + executions=self.repeat, + extensions=self._create_recurrent_transfer_pair_id_extension(), + ) + @dataclass(kw_only=True) class ProcessTransferScheduleCreate(_ProcessTransferScheduleCreateModifyCommon): diff --git a/clive/__private/cli/commands/process/process_withdraw_routes.py b/clive/__private/cli/commands/process/process_withdraw_routes.py index 517b06ff13d596e96db96fb0fef3198340d0ea68..6fdf661f7fabb04d1b1a14502cfaaacdf340b505 100644 --- a/clive/__private/cli/commands/process/process_withdraw_routes.py +++ b/clive/__private/cli/commands/process/process_withdraw_routes.py @@ -17,6 +17,10 @@ class ProcessWithdrawRoutes(OperationCommand): percent: Decimal auto_vest: bool + async def validate(self) -> None: + await self._validate_percent() + await super().validate() + async def _create_operation(self) -> SetWithdrawVestingRouteOperation: return SetWithdrawVestingRouteOperation( from_account=self.from_account, @@ -25,10 +29,6 @@ class ProcessWithdrawRoutes(OperationCommand): auto_vest=self.auto_vest, ) - async def validate(self) -> None: - await self._validate_percent() - await super().validate() - async def _validate_percent(self) -> None: if self.percent == PERCENT_TO_REMOVE_WITHDRAW_ROUTE: raise WithdrawRoutesZeroPercentError diff --git a/clive/__private/cli/common/parameters/options.py b/clive/__private/cli/common/parameters/options.py index 66433a80d4150f93812a1a60c9b4a8dda34d629d..b9f26440b4e0565d2950a7068987c727c8443a4b 100644 --- a/clive/__private/cli/common/parameters/options.py +++ b/clive/__private/cli/common/parameters/options.py @@ -109,6 +109,16 @@ page_no = typer.Option( help="Page number to display, considering the given page size.", ) +force_value = typer.Option( + default=False, + help=( + "This flag is required when performing operations to exchange accounts.\n" + "Some operations are not handled by exchanges.\n" + "Use --force to explicitly confirm and proceed with the operation despite this limitation." + ), + show_default=False, +) + # OPERATION COMMON OPTIONS >> _operation_common_option = partial(modified_param, rich_help_panel=OPERATION_COMMON_OPTIONS_PANEL_TITLE) diff --git a/clive/__private/cli/exceptions.py b/clive/__private/cli/exceptions.py index d3a27e977e078efb0d23edc294bffa1d09fa05a6..236b5efcf3d9e60e9fbf45fd8afb6a5d68a77d6d 100644 --- a/clive/__private/cli/exceptions.py +++ b/clive/__private/cli/exceptions.py @@ -282,3 +282,12 @@ class CLITransactionBadAccountError(CLIPrettyError): f"target accounts: {self.account_names} are on the list of bad accounts." ) super().__init__(message, errno.EINVAL) + + +class CLITransactionToExchangeError(CLIPrettyError): + """Raise when trying to perform transaction to exchange with operation(s) that cannot be performed.""" + + def __init__(self, reason: str) -> None: + message = f"Cannot perform transaction.\n{reason}" + super().__init__(message, errno.EINVAL) + self.message = message diff --git a/clive/__private/cli/process/hive_power/delegations.py b/clive/__private/cli/process/hive_power/delegations.py index 32511c28b19f56ee3c1271dbbfddb006ea4c78f8..714e7a6fd47405ae4798ee9bac34187ea440f090 100644 --- a/clive/__private/cli/process/hive_power/delegations.py +++ b/clive/__private/cli/process/hive_power/delegations.py @@ -28,6 +28,7 @@ async def process_delegations_set( # noqa: PLR0913 sign: str | None = options.sign, broadcast: bool = options.broadcast, # noqa: FBT001 save_file: str | None = options.save_file, + force: bool = options.force_value, # noqa: FBT001 ) -> None: """Add or modify vesting shares delegation for pair of accounts "account-name" and "delegatee".""" from clive.__private.cli.commands.process.process_delegations import ProcessDelegations @@ -40,6 +41,7 @@ async def process_delegations_set( # noqa: PLR0913 sign=sign, broadcast=broadcast, save_file=save_file, + force=force, ) await operation.run() diff --git a/clive/__private/cli/process/hive_power/power_up.py b/clive/__private/cli/process/hive_power/power_up.py index 3dd7623ab9aa4332d170d5ff4690fc2fcfc39240..79bf6c4ff33d5678a5edcea3a36eed7ec3117e77 100644 --- a/clive/__private/cli/process/hive_power/power_up.py +++ b/clive/__private/cli/process/hive_power/power_up.py @@ -24,6 +24,7 @@ async def process_power_up( # noqa: PLR0913 sign: str | None = options.sign, broadcast: bool = options.broadcast, # noqa: FBT001 save_file: str | None = options.save_file, + force: bool = options.force_value, # noqa: FBT001 ) -> None: """Perform power-up by sending transfer_to_vesting_operation.""" from clive.__private.cli.commands.process.process_power_up import ProcessPowerUp @@ -36,4 +37,5 @@ async def process_power_up( # noqa: PLR0913 sign=sign, broadcast=broadcast, save_file=save_file, + force=force, ).run() diff --git a/clive/__private/cli/process/hive_power/withdraw_routes.py b/clive/__private/cli/process/hive_power/withdraw_routes.py index 250d9788d99a499736ca9a1e03449e80d81a7a5b..fcc2c513dae420b379fa1cf09d3ee5ad7790ecee 100644 --- a/clive/__private/cli/process/hive_power/withdraw_routes.py +++ b/clive/__private/cli/process/hive_power/withdraw_routes.py @@ -23,6 +23,7 @@ async def process_withdraw_routes_set( # noqa: PLR0913 sign: str | None = options.sign, broadcast: bool = options.broadcast, # noqa: FBT001 save_file: str | None = options.save_file, + force: bool = options.force_value, # noqa: FBT001 ) -> None: """Add new withdraw route/modify existing route for pair of accounts "from" and "to".""" from clive.__private.cli.commands.process.process_withdraw_routes import ProcessWithdrawRoutes @@ -35,6 +36,7 @@ async def process_withdraw_routes_set( # noqa: PLR0913 sign=sign, broadcast=broadcast, save_file=save_file, + force=force, ) await operation.run() diff --git a/clive/__private/cli/process/main.py b/clive/__private/cli/process/main.py index d05fa577c884519256554fe9327508508b002ac0..958ded1d0771bcc10811182def77dc5e2c336586 100644 --- a/clive/__private/cli/process/main.py +++ b/clive/__private/cli/process/main.py @@ -90,6 +90,7 @@ async def process_transaction( # noqa: PLR0913 sign: str | None = options.sign, broadcast: bool = options.broadcast, # noqa: FBT001 save_file: str | None = options.save_file, + force: bool = options.force_value, # noqa: FBT001 ) -> None: """Process a transaction from file.""" from clive.__private.cli.commands.process.process_transaction import ProcessTransaction @@ -104,6 +105,7 @@ async def process_transaction( # noqa: PLR0913 sign=sign, broadcast=broadcast, save_file=save_file, + force=force, ).run() diff --git a/clive/__private/cli/process/savings.py b/clive/__private/cli/process/savings.py index 8b410bbd36aafddcc3ebdbc9107dea3c0089aaae..91661a6539bb4caa6f7f4af1c670cae616a2cd8f 100644 --- a/clive/__private/cli/process/savings.py +++ b/clive/__private/cli/process/savings.py @@ -22,6 +22,7 @@ async def process_deposit( # noqa: PLR0913 sign: str | None = options.sign, broadcast: bool = options.broadcast, # noqa: FBT001 save_file: str | None = options.save_file, + force: bool = options.force_value, # noqa: FBT001 ) -> None: """Immediately deposit funds to savings account.""" from clive.__private.cli.commands.process.process_deposit import ProcessDeposit @@ -36,6 +37,7 @@ async def process_deposit( # noqa: PLR0913 sign=sign, broadcast=broadcast, save_file=save_file, + force=force, ).run() @@ -53,6 +55,7 @@ async def process_withdrawal( # noqa: PLR0913 sign: str | None = options.sign, broadcast: bool = options.broadcast, # noqa: FBT001 save_file: str | None = options.save_file, + force: bool = options.force_value, # noqa: FBT001 ) -> None: """Initiate withdrawal of funds from savings account, it takes 3 days to complete.""" from clive.__private.cli.commands.process.process_withdrawal import ProcessWithdrawal @@ -67,6 +70,7 @@ async def process_withdrawal( # noqa: PLR0913 sign=sign, broadcast=broadcast, save_file=save_file, + force=force, ).run() diff --git a/clive/__private/cli/process/transfer_schedule.py b/clive/__private/cli/process/transfer_schedule.py index c6dcbf63ec2f1ddd1829429c804e96a3600a0c35..6134411fbca917b20beedda468f2df17fda84082 100644 --- a/clive/__private/cli/process/transfer_schedule.py +++ b/clive/__private/cli/process/transfer_schedule.py @@ -66,6 +66,7 @@ async def process_transfer_schedule_create( # noqa: PLR0913 sign: str | None = options.sign, broadcast: bool = options.broadcast, # noqa: FBT001 save_file: str | None = options.save_file, + force: bool = options.force_value, # noqa: FBT001 ) -> None: """Create a new recurrent transfer. First recurrent transfer will be sent immediately.""" from clive.__private.cli.commands.process.process_transfer_schedule import ProcessTransferScheduleCreate @@ -81,6 +82,7 @@ async def process_transfer_schedule_create( # noqa: PLR0913 sign=sign, broadcast=broadcast, save_file=save_file, + force=force, ).run() @@ -96,6 +98,7 @@ async def process_transfer_schedule_modify( # noqa: PLR0913 sign: str | None = options.sign, broadcast: bool = options.broadcast, # noqa: FBT001 save_file: str | None = options.save_file, + force: bool = options.force_value, # noqa: FBT001 ) -> None: """ Modify an existing recurrent transfer. @@ -115,6 +118,7 @@ async def process_transfer_schedule_modify( # noqa: PLR0913 sign=sign, broadcast=broadcast, save_file=save_file, + force=force, ).run() diff --git a/clive/__private/ui/screens/transaction_summary/transaction_summary.py b/clive/__private/ui/screens/transaction_summary/transaction_summary.py index a82a99a815f0f0e1eba887246d50e594ef1375a5..a57a1f1e6122570921a2655015b781261ea1b88d 100644 --- a/clive/__private/ui/screens/transaction_summary/transaction_summary.py +++ b/clive/__private/ui/screens/transaction_summary/transaction_summary.py @@ -18,10 +18,12 @@ from clive.__private.core.constants.tui.messages import ( BAD_ACCOUNT_IN_LOADED_TRANSACTION_MESSAGE, ERROR_BAD_ACCOUNT_IN_LOADED_TRANSACTION_MESSAGE, ) +from clive.__private.core.formatters.humanize import humanize_validation_result from clive.__private.core.keys import PublicKey from clive.__private.core.keys.key_manager import KeyNotFoundError from clive.__private.ui.clive_widget import CliveWidget from clive.__private.ui.dialogs import ConfirmInvalidateSignaturesDialog +from clive.__private.ui.dialogs.confirm_action_dialog_with_known_exchange import ConfirmActionDialogWithKnownExchange from clive.__private.ui.get_css import get_relative_css_path from clive.__private.ui.screens.base_screen import BaseScreen from clive.__private.ui.screens.transaction_summary.cart_table import CartTable @@ -38,6 +40,7 @@ from clive.__private.ui.widgets.select_file_to_save_transaction import ( SaveTransactionResult, SelectFileToSaveTransaction, ) +from clive.__private.validators.exchange_operations_validator import ExchangeOperationsValidator from clive.exceptions import NoItemSelectedError if TYPE_CHECKING: @@ -198,7 +201,14 @@ class TransactionSummary(BaseScreen): if self.profile.transaction else None ) - self.app.push_screen(SelectFile(notice=notify_text), self._load_transaction_from_file) + + # It is required to call push_screen_wait from worker. + # Otherwise there was `push_screen must be run from a worker when `wait_for_dismiss` is True ` error observed. + # See: https://textual.textualize.io/guide/screens/#waiting-for-screens + def delegate_work(result: SaveFileResult | None) -> None: + self.run_worker(self._load_transaction_from_file(result)) + + self.app.push_screen(SelectFile(notice=notify_text), delegate_work) @on(ButtonBroadcast.Pressed) async def action_broadcast(self) -> None: @@ -295,6 +305,9 @@ class TransactionSummary(BaseScreen): if self._check_for_unknown_bad_accounts(loaded_transaction): return + if not (await self._validate_operations_to_exchange(loaded_transaction)): + return + if not loaded_transaction.is_tapos_set: self.notify("TaPoS metadata was not set, updating automatically...") await self._update_transaction_metadata() @@ -391,3 +404,29 @@ class TransactionSummary(BaseScreen): self.notify(BAD_ACCOUNT_IN_LOADED_TRANSACTION_MESSAGE, severity="warning") return False + + async def _validate_operations_to_exchange(self, loaded_transaction: Transaction) -> bool: + """Validate operations from transaction to the exchange.""" + operation_validator = ExchangeOperationsValidator(transaction=loaded_transaction) + + is_confirmation_required = False + for exchange in self.world.known_exchanges: + result = operation_validator.validate(exchange.name) + if result.is_valid: + continue + + if ExchangeOperationsValidator.has_unsafe_transfer_to_exchange(result): + self.notify( + f"Cannot load transaction.\n{humanize_validation_result(result)}", + severity="error", + markup=False, + ) + return False + + if ExchangeOperationsValidator.has_unsafe_operation_to_exchange(result): + is_confirmation_required = True + + if is_confirmation_required: + return await self.app.push_screen_wait(ConfirmActionDialogWithKnownExchange()) + + return True diff --git a/clive/__private/validators/exchange_operations_validator.py b/clive/__private/validators/exchange_operations_validator.py new file mode 100644 index 0000000000000000000000000000000000000000..a4b31bce980413c9674c02532797f7950179dcd6 --- /dev/null +++ b/clive/__private/validators/exchange_operations_validator.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Final + +from textual.validation import Function, ValidationResult, Validator + +from clive.__private.visitors.operation.potential_exchange_operations_account_collector import ( + PotentialExchangeOperationsAccountCollector, +) + +if TYPE_CHECKING: + from typing import ClassVar + + from clive.__private.models import Transaction + + +class ExchangeOperationsValidator(Validator): + """Validating operations in a transaction to exchange.""" + + HBD_TRANSFER_MSG_ERROR: Final[str] = "The transfer to the exchange must be in HIVE, not HBD." + MEMOLESS_HIVE_TRANSFER_MSG_ERROR: Final[str] = "The transfer to the exchange must include a memo." + + UNSAFE_EXCHANGE_OPERATION_MSG_ERROR: ClassVar[str] = ( + "Exchanges usually support only the transfer operation, while other operation to a known exchange was detected." + ) + + def __init__( + self, + transaction: Transaction, + *, + suppress_force_validation: bool = False, + ) -> None: + super().__init__() + self._suppress_force_required_validation = suppress_force_validation + self._transaction = transaction + + @classmethod + def has_unsafe_transfer_to_exchange(cls, result: ValidationResult) -> bool: + """Check if unsafe exchange transfer was detected in the result .""" + return ( + cls.HBD_TRANSFER_MSG_ERROR in result.failure_descriptions + or cls.MEMOLESS_HIVE_TRANSFER_MSG_ERROR in result.failure_descriptions + ) + + @classmethod + def has_unsafe_operation_to_exchange(cls, result: ValidationResult) -> bool: + """Check if unsafe exchange operations was detected in the result .""" + return cls.UNSAFE_EXCHANGE_OPERATION_MSG_ERROR in result.failure_descriptions + + def validate(self, value: str) -> ValidationResult: + """Validate the given value - exchange name.""" + validators = [ + Function(self._validate_hbd_transfer_operation, self.HBD_TRANSFER_MSG_ERROR), + Function(self._validate_memoless_transfer_operation, self.MEMOLESS_HIVE_TRANSFER_MSG_ERROR), + ] + if not self._suppress_force_required_validation: + validators.append( + Function( + self._validate_unsafe_exchange_operation, + self.UNSAFE_EXCHANGE_OPERATION_MSG_ERROR, + ) + ) + return ValidationResult.merge([validator.validate(value) for validator in validators]) + + def _validate_hbd_transfer_operation(self, value: str) -> bool: + """Validate if the transaction has a HBD transfer operations.""" + visitor = PotentialExchangeOperationsAccountCollector() + self._transaction.accept(visitor) + return not visitor.has_hbd_transfer_operations_to_exchange(value) + + def _validate_memoless_transfer_operation(self, value: str) -> bool: + """Validate if the transaction has a memoless transfer operations.""" + visitor = PotentialExchangeOperationsAccountCollector() + self._transaction.accept(visitor) + return not visitor.has_memoless_transfer_operations_to_exchange(value) + + def _validate_unsafe_exchange_operation(self, value: str) -> bool: + """Validate if the transaction has unsafe exchange operations.""" + visitor = PotentialExchangeOperationsAccountCollector() + self._transaction.accept(visitor) + return not visitor.has_unsafe_operation_to_exchange(value) + + +class ExchangeOperationsValidatorCli(ExchangeOperationsValidator): + """CLI-specific validator for exchange operations.""" + + UNSAFE_EXCHANGE_OPERATION_MSG_ERROR: ClassVar[str] = ( + f"{ExchangeOperationsValidator.UNSAFE_EXCHANGE_OPERATION_MSG_ERROR}" + " You can force the process by using the `--force` flag." + ) diff --git a/clive/__private/visitors/operation/financial_operations_account_collector.py b/clive/__private/visitors/operation/financial_operations_account_collector.py index fb79ae9593f191b763356d9124b007fb26948acb..c8148b1d80913226ec30dd7e7345b1437e11f9e2 100644 --- a/clive/__private/visitors/operation/financial_operations_account_collector.py +++ b/clive/__private/visitors/operation/financial_operations_account_collector.py @@ -3,7 +3,6 @@ from __future__ import annotations from typing import TYPE_CHECKING, override from clive.__private.core.constants.node import ( - CANCEL_PROXY_VALUE, PERCENT_TO_REMOVE_WITHDRAW_ROUTE, TRANSFER_TO_VESTING_RECEIVER_IS_FROM_VALUE, ) @@ -24,11 +23,6 @@ class FinancialOperationsAccountCollector(OperationVisitor): self.accounts: set[str] = set() """Names of accounts that are target of financial operation.""" - @override - def visit_account_witness_proxy_operation(self, operation: schemas.AccountWitnessProxyOperation) -> None: - if operation.proxy != CANCEL_PROXY_VALUE: - self.accounts.add(operation.proxy) - @override def visit_delegate_vesting_shares_operation(self, operation: schemas.DelegateVestingSharesOperation) -> None: if operation.vesting_shares not in DELEGATION_REMOVE_ASSETS: diff --git a/clive/__private/visitors/operation/potential_bad_account_collector.py b/clive/__private/visitors/operation/potential_bad_account_collector.py index d040a9eeae5f576cbdd940964d415b2a3132413c..6f9b506c545274a5c399ff101d7323fe24b6901a 100644 --- a/clive/__private/visitors/operation/potential_bad_account_collector.py +++ b/clive/__private/visitors/operation/potential_bad_account_collector.py @@ -1,7 +1,8 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, override +from clive.__private.core.constants.node import CANCEL_PROXY_VALUE from clive.__private.visitors.operation.financial_operations_account_collector import ( FinancialOperationsAccountCollector, ) @@ -9,9 +10,16 @@ from clive.__private.visitors.operation.financial_operations_account_collector i if TYPE_CHECKING: from collections.abc import Iterable + from clive.__private.models import schemas + class PotentialBadAccountCollector(FinancialOperationsAccountCollector): """Collects accounts that could potentially be bad basing on the operations that are made to them.""" def get_bad_accounts(self, bad_accounts: Iterable[str]) -> list[str]: return [account for account in self.accounts if account in bad_accounts] + + @override + def visit_account_witness_proxy_operation(self, operation: schemas.AccountWitnessProxyOperation) -> None: + if operation.proxy != CANCEL_PROXY_VALUE: + self.accounts.add(operation.proxy) diff --git a/clive/__private/visitors/operation/potential_exchange_operations_account_collector.py b/clive/__private/visitors/operation/potential_exchange_operations_account_collector.py new file mode 100644 index 0000000000000000000000000000000000000000..5943a23877ee886793817f81eecbc6dbe1d87d90 --- /dev/null +++ b/clive/__private/visitors/operation/potential_exchange_operations_account_collector.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, override + +if TYPE_CHECKING: + from clive.__private.models import schemas +from clive.__private.models.asset import Asset +from clive.__private.visitors.operation.financial_operations_account_collector import ( + FinancialOperationsAccountCollector, +) + + +class PotentialExchangeOperationsAccountCollector(FinancialOperationsAccountCollector): + """ + Collects exchange accounts with potentially problematic operations to them. + + Can be used to check if memoless/HBD transfer was detected and + if there is any other unsafe operations (other than transfer). + """ + + def __init__(self) -> None: + super().__init__() + self.memoless_transfers_accounts: set[str] = set() + """Names of accounts with memoless transfers.""" + self.hbd_transfers_accounts: set[str] = set() + """Names of accounts with hbd transfers.""" + + def has_hbd_transfer_operations_to_exchange(self, account: str) -> bool: + """Check if there are HBD transfer operations to the given exchange account.""" + return account in self.hbd_transfers_accounts + + def has_memoless_transfer_operations_to_exchange(self, account: str) -> bool: + """Check if there are memoless transfer operations to the given exchange account.""" + return account in self.memoless_transfers_accounts + + def has_unsafe_operation_to_exchange(self, account: str) -> bool: + """ + Check if there are unsafe operations to the given exchange account. + + Transfer is the only operation considered a safe operation to the exchange account. + """ + return account in self.accounts + + @override + def visit_transfer_operation(self, operation: schemas.TransferOperation) -> None: + """ + Collect memoless and hbd transfers account names. + + Transfer to exchange should have asset in Hive and memo. + + If: + 1) the memo is empty string, it is considered a memoless transfer. + 2) the amount is Asset.Hbd, this is forbidden operation to exchange. + """ + if operation.memo == "": + self.memoless_transfers_accounts.add(operation.to) + return + if isinstance(operation.amount, Asset.Hbd): + self.hbd_transfers_accounts.add(operation.to) diff --git a/clive/__private/visitors/operation/potential_known_account_collector.py b/clive/__private/visitors/operation/potential_known_account_collector.py index 007bbb2e3e5d3d37cd00f33a62567a88f0b616ce..9208d14c9d7ac3839870c6d45b4eec9f84b99e8a 100644 --- a/clive/__private/visitors/operation/potential_known_account_collector.py +++ b/clive/__private/visitors/operation/potential_known_account_collector.py @@ -1,7 +1,8 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, override +from clive.__private.core.constants.node import CANCEL_PROXY_VALUE from clive.__private.visitors.operation.financial_operations_account_collector import ( FinancialOperationsAccountCollector, ) @@ -10,6 +11,7 @@ if TYPE_CHECKING: from collections.abc import Iterable from clive.__private.core.accounts.accounts import KnownAccount + from clive.__private.models import schemas class PotentialKnownAccountCollector(FinancialOperationsAccountCollector): @@ -18,3 +20,8 @@ class PotentialKnownAccountCollector(FinancialOperationsAccountCollector): def get_unknown_accounts(self, already_known_accounts: Iterable[KnownAccount]) -> list[str]: already_known_accounts_names = [account.name for account in already_known_accounts] return [account for account in self.accounts if account not in already_known_accounts_names] + + @override + def visit_account_witness_proxy_operation(self, operation: schemas.AccountWitnessProxyOperation) -> None: + if operation.proxy != CANCEL_PROXY_VALUE: + self.accounts.add(operation.proxy) diff --git a/docker/Dockerfile b/docker/Dockerfile index a7400d0a092e270461efd54165df17aa64252d0d..b912e3d8aa1f74f62d90f052abfb5c1537ccc511 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -128,6 +128,16 @@ COPY --chown=clive --from=hived_image "${HIVED_BINARIES_DIR_SOURCE}/hived" "${HI RUN strip -s "${HIVED_BINARIES_DIR_DEST}"/* +# Installing faketime lib, because it is needed in embedded testnet. +# The Faketime library is used in embedded testnet images to start blockchain nodes +# with the correct historical timestamp matching the generated block_log. +# Based on https://gitlab.syncad.com/hive/hive/blob/2a04c2be5d4b47c38ab498e588465bf041d294d6/scripts/setup_ubuntu.sh#L70 +RUN git clone --depth 1 --branch bw_timer_settime_fix https://gitlab.syncad.com/bwrona/faketime.git && \ + pushd faketime && CFLAGS="-O2 -DFAKE_STATELESS=1" make && \ + sudo make install && \ + popd && \ + rm -rf faketime + ARG CLIVE_SECRETS__DEFAULT_PRIVATE_KEY="5KTNAYSHVzhnVPrwHpKhc5QqNQt6aW8JsrMT7T4hyrKydzYvYik" ENV CLIVE_SECRETS__DEFAULT_PRIVATE_KEY=${CLIVE_SECRETS__DEFAULT_PRIVATE_KEY} diff --git a/testnet_node.py b/testnet_node.py index 319c71b57d3bd4002e526e7c0b8299caaf8b4f69..7dfea98caff4035ad194cff1324d1837ab2a4895 100644 --- a/testnet_node.py +++ b/testnet_node.py @@ -57,8 +57,6 @@ def init_argparse(args: Sequence[str]) -> argparse.Namespace: def prepare_node() -> tt.RawNode: - # TODO: time_offset/use_faketime option should be used there but faketime is not available in the embedded_testnet - # docker image yet return run_node(webserver_http_endpoint="0.0.0.0:8090") 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 e70f41d334629321c88637778744eee49524eb2e..b41fd5466ba405e5e10a05c594dd2b0e21dfa7e0 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 @@ -174,6 +174,7 @@ class CLITester: amount: tt.Asset.AnyT, memo: str | None = None, from_: str | None = None, + force: bool | None = None, ) -> Result: return self.__invoke_command_with_options( ["process", "savings", "deposit"], @@ -191,6 +192,7 @@ class CLITester: amount: tt.Asset.AnyT, memo: str | None = None, from_: str | None = None, + force: bool | None = None, ) -> Result: return self.__invoke_command_with_options( ["process", "savings", "withdrawal"], @@ -236,6 +238,7 @@ class CLITester: sign: str | None = None, broadcast: bool | None = None, save_file: Path | None = None, + force: bool | None = None, ) -> Result: return self.__invoke_command_with_options( ["process", "transaction"], @@ -265,6 +268,7 @@ class CLITester: sign: str | None = None, broadcast: bool | None = None, save_file: Path | None = None, + force: bool | None = None, ) -> Result: return self.__invoke_command_with_options(["process", "power-up"], **extract_params(locals())) @@ -308,6 +312,7 @@ class CLITester: sign: str | None = None, broadcast: bool | None = None, save_file: Path | None = None, + force: bool | None = None, ) -> Result: return self.__invoke_command_with_options(["process", "delegations", "set"], **extract_params(locals())) @@ -332,6 +337,7 @@ class CLITester: sign: str | None = None, broadcast: bool | None = None, save_file: Path | None = None, + force: bool | None = None, ) -> Result: return self.__invoke_command_with_options(["process", "withdraw-routes", "set"], **extract_params(locals())) @@ -484,6 +490,7 @@ class CLITester: broadcast: bool | None = None, save_file: Path | None = None, memo: str | None = None, + force: bool | None = None, ) -> Result: return self.__invoke_command_with_options( ["process", "transfer-schedule", "create"], **extract_params(locals()) @@ -502,6 +509,7 @@ class CLITester: broadcast: bool | None = None, save_file: Path | None = None, memo: str | None = None, + force: bool | None = None, ) -> Result: return self.__invoke_command_with_options( ["process", "transfer-schedule", "modify"], **extract_params(locals()) diff --git a/tests/clive-local-tools/clive_local_tools/helpers.py b/tests/clive-local-tools/clive_local_tools/helpers.py index 9c404a94004986842ceb5a70dff8f471d65cb896..a445be0782fb09ff1817aa8353133406e023265b 100644 --- a/tests/clive-local-tools/clive_local_tools/helpers.py +++ b/tests/clive-local-tools/clive_local_tools/helpers.py @@ -7,10 +7,13 @@ from rich.panel import Panel from typer import rich_utils from clive.__private.core.constants.terminal import TERMINAL_HEIGHT, TERMINAL_WIDTH +from clive.__private.core.ensure_transaction import TransactionConvertibleType, ensure_transaction from clive.__private.core.validate_schema_field import validate_schema_field from clive.__private.models.schemas import TransactionId if TYPE_CHECKING: + from pathlib import Path + from click import ClickException @@ -40,3 +43,11 @@ def get_formatted_error_message(error: ClickException) -> str: with console.capture() as capture: console.print(panel) return capture.get() + + +def create_transaction_file(path: Path, content: TransactionConvertibleType) -> Path: + transaction_path = path / "trx.json" + transaction = ensure_transaction(content) + transaction_serialized = transaction.json(by_alias=True) + transaction_path.write_text(transaction_serialized) + return transaction_path diff --git a/tests/clive-local-tools/clive_local_tools/testnet_block_log/__init__.py b/tests/clive-local-tools/clive_local_tools/testnet_block_log/__init__.py index f0aaf7408de557b30d022978832d2685e5d7520f..fe8d4ce502fb02c6f835f346f48e573747981ed3 100644 --- a/tests/clive-local-tools/clive_local_tools/testnet_block_log/__init__.py +++ b/tests/clive-local-tools/clive_local_tools/testnet_block_log/__init__.py @@ -8,6 +8,7 @@ from .constants import ( CREATOR_ACCOUNT, EMPTY_ACCOUNT, KNOWN_ACCOUNTS, + KNOWN_EXCHANGES_NAMES, PROPOSALS, UNKNOWN_ACCOUNT, WATCHED_ACCOUNTS_DATA, @@ -26,6 +27,7 @@ __all__ = [ "CREATOR_ACCOUNT", "EMPTY_ACCOUNT", "KNOWN_ACCOUNTS", + "KNOWN_EXCHANGES_NAMES", "PROPOSALS", "UNKNOWN_ACCOUNT", "WATCHED_ACCOUNTS_DATA", diff --git a/tests/clive-local-tools/clive_local_tools/testnet_block_log/alternate-chain-spec.json b/tests/clive-local-tools/clive_local_tools/testnet_block_log/alternate-chain-spec.json index b47cdb72d65a5d8c7598147e7868ee099f85c0e3..9f6d6281a4a498572c50659096d3c50dbddf9744 100644 --- a/tests/clive-local-tools/clive_local_tools/testnet_block_log/alternate-chain-spec.json +++ b/tests/clive-local-tools/clive_local_tools/testnet_block_log/alternate-chain-spec.json @@ -1,5 +1,5 @@ { - "genesis_time": 1722949221, + "genesis_time": 1748956337, "hardfork_schedule": [ { "hardfork": 28, diff --git a/tests/clive-local-tools/clive_local_tools/testnet_block_log/blockchain/block_log b/tests/clive-local-tools/clive_local_tools/testnet_block_log/blockchain/block_log deleted file mode 100644 index e41f5d69812be776285d6bfa79e2174323621fb4..0000000000000000000000000000000000000000 Binary files a/tests/clive-local-tools/clive_local_tools/testnet_block_log/blockchain/block_log and /dev/null differ diff --git a/tests/clive-local-tools/clive_local_tools/testnet_block_log/blockchain/block_log_part.0001 b/tests/clive-local-tools/clive_local_tools/testnet_block_log/blockchain/block_log_part.0001 new file mode 100644 index 0000000000000000000000000000000000000000..97845aba1e25a71cb93a562e0083e3be945d5c4d Binary files /dev/null and b/tests/clive-local-tools/clive_local_tools/testnet_block_log/blockchain/block_log_part.0001 differ diff --git a/tests/clive-local-tools/clive_local_tools/testnet_block_log/config.ini b/tests/clive-local-tools/clive_local_tools/testnet_block_log/config.ini index 023c05a1895d4cf22634b45781381f3866373e31..61f241011f3b25c8d4f11f8d28a2a861d6b4a823 100644 --- a/tests/clive-local-tools/clive_local_tools/testnet_block_log/config.ini +++ b/tests/clive-local-tools/clive_local_tools/testnet_block_log/config.ini @@ -1,43 +1,22 @@ -log-appender = {"appender":"stderr","stream":"std_error","time_format":"iso_8601_microseconds"} {"appender":"p2p","file":"logs/p2p/p2p.log","time_format":"iso_8601_milliseconds"} 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"} -backtrace = yes -plugin = witness -plugin = state_snapshot -plugin = account_by_key_api plugin = account_by_key -plugin = block_api +plugin = state_snapshot +plugin = wallet_bridge_api +plugin = reputation_api +plugin = transaction_status_api plugin = database_api plugin = debug_node_api -plugin = network_node_api -plugin = wallet_bridge_api +plugin = witness plugin = account_history_rocksdb plugin = account_history_api -plugin = reputation_api plugin = rc_api -plugin = transaction_status_api -account-history-rocksdb-path = "blockchain/account-history-rocksdb-storage" -block-data-export-file = NONE -block-data-skip-empty = 0 -block-log-info-print-interval-seconds = 86400 -block-log-info-print-irreversible = 1 -block-log-info-print-file = ILOG -shared-file-dir = "blockchain" +plugin = block_api +plugin = network_node_api +plugin = account_by_key_api shared-file-size = 128M -shared-file-full-threshold = 0 -shared-file-scale-rate = 0 -market-history-bucket-size = [15,60,300,3600,86400] -market-history-buckets-per-size = 5760 p2p-endpoint = 0.0.0.0:0 -rc-stats-report-type = REGULAR -rc-stats-report-output = ILOG -block-log-split = -1 -snapshot-root-dir = "snapshot" -statsd-batchsize = 1 -transaction-status-block-depth = 64000 webserver-http-endpoint = 0.0.0.0:0 webserver-ws-endpoint = 0.0.0.0:0 -webserver-ws-deflate = 0 -webserver-thread-pool-size = 32 enable-stale-production = 1 required-participation = 0 witness = "initminer" @@ -161,15 +140,4 @@ private-key = 5JdQmXYK5ghJJQMEMhevkkBPB8jhjeJzVtEqxGWwo4SQStrXirY private-key = 5JDdGrA41aADWgMBXkvSc6n3V6MUcoFdxzVFrAgud2c75hGHuwu private-key = 5JQ2YZ642GfUmqx31F5nkj5R4GQZuDyjw8fEZ7bUCwEQ3gZW8Fz private-key = 5Jagcf1mdLp4455zuGziHsouu5qyBmE5LzmkiAqwhQuUSKYhvSJ -private-key = 5J8m4zbzqPvEytq9S3fyyUHLo9B8PDF8azbgTy5ZeyHKvVaMezS -enable-block-log-compression = 1 -enable-block-log-auto-fixing = 1 -block-log-compression-level = 15 -blockchain-thread-pool-size = 8 -block-stats-report-type = FULL -block-stats-report-output = ILOG -colony-threads = 4 -colony-start-at-block = 0 -colony-no-broadcast = 0 -pacemaker-min-offset = -300 -pacemaker-max-offset = 20000 \ No newline at end of file +private-key = 5J8m4zbzqPvEytq9S3fyyUHLo9B8PDF8azbgTy5ZeyHKvVaMezS \ No newline at end of file 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 09564ab7b15c650ba7e6c71757764700ec180174..43c5594094ff90e3d0900440131e5ead649f6a06 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 @@ -5,6 +5,7 @@ from typing import Final import test_tools as tt +from clive.__private.core.known_exchanges import KnownExchanges from clive_local_tools.data.generates import generate_proposal_name, generate_witness_name from clive_local_tools.data.models import AccountData @@ -51,10 +52,13 @@ ALT_WORKING_ACCOUNT2_DATA: Final[AccountData] = AccountData( UNKNOWN_ACCOUNT: Final[str] = "null" +KNOWN_EXCHANGES_NAMES: Final[list[str]] = [exchange.name for exchange in KnownExchanges()] + KNOWN_ACCOUNTS: Final[list[str]] = [ EMPTY_ACCOUNT.name, *WATCHED_ACCOUNTS_NAMES, WORKING_ACCOUNT_NAME, ALT_WORKING_ACCOUNT1_NAME, ALT_WORKING_ACCOUNT2_NAME, + *KNOWN_EXCHANGES_NAMES, ] 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 27f1c9246103c15845acb7085ce6ae8cc2996ee8..048d53826da9d8f9f04445cf6438b2cfcda7a6f8 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 @@ -14,6 +14,7 @@ from clive_local_tools.testnet_block_log.constants import ( ALT_WORKING_ACCOUNT2_DATA, CREATOR_ACCOUNT, EMPTY_ACCOUNT, + KNOWN_EXCHANGES_NAMES, PROPOSALS, WATCHED_ACCOUNTS_DATA, WITNESSES, @@ -181,6 +182,14 @@ def create_empty_account(wallet: tt.Wallet) -> None: wallet.create_account(EMPTY_ACCOUNT.name) +def create_known_exchange_accounts(wallet: tt.Wallet) -> None: + tt.logger.info("Creating known exchange accounts...") + for exchange_name in KNOWN_EXCHANGES_NAMES: + wallet.create_account( + exchange_name, + ) + + def prepare_votes_for_witnesses(wallet: tt.Wallet) -> None: tt.logger.info("Prepare votes for witnesses...") with wallet.in_single_transaction(): @@ -213,6 +222,7 @@ def main() -> None: prepare_savings(wallet) prepare_votes_for_witnesses(wallet) create_empty_account(wallet) + create_known_exchange_accounts(wallet) tt.logger.info("Wait 21 blocks to schedule newly created witnesses into future state") node.wait_number_of_blocks(21) 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 75afe6e22a3a04fbdb7bd67ac6e75c41c9cefbff..06b1bf703206ead17272adcab883e620786c15e2 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 @@ -29,11 +29,11 @@ def get_time_offset() -> str: return file.read() -def run_node(webserver_http_endpoint: str | None = None, *, use_faketime: bool = False) -> tt.RawNode: +def run_node(webserver_http_endpoint: str | None = None) -> tt.RawNode: config_lines = get_config().write_to_lines() block_log = get_block_log() alternate_chain_spec = tt.AlternateChainSpecs.parse_file(get_alternate_chain_spec_path()) - time_offset = get_time_offset() if use_faketime else None + time_offset = get_time_offset() node = tt.RawNode() node.config.load_from_lines(config_lines) diff --git a/tests/clive-local-tools/clive_local_tools/testnet_block_log/timestamp b/tests/clive-local-tools/clive_local_tools/testnet_block_log/timestamp index f2df189e510b7578da2a9dac6d4e60dccd2af36f..d6008c5db4553b552c0ae61edb30fcddb33e1542 100644 --- a/tests/clive-local-tools/clive_local_tools/testnet_block_log/timestamp +++ b/tests/clive-local-tools/clive_local_tools/testnet_block_log/timestamp @@ -1 +1 @@ -@2024-08-07 13:03:39+00:00 \ No newline at end of file +@2025-06-04 13:15:51+00:00 \ No newline at end of file diff --git a/tests/functional/cli/accounts_validation/test_bad_accounts_validation.py b/tests/functional/cli/accounts_validation/test_bad_accounts_validation.py index 292368b2e5a63f87a10cce52111a53e104a8319d..723ffc4f0133273a0e31125b42fb9fbe39d64e49 100644 --- a/tests/functional/cli/accounts_validation/test_bad_accounts_validation.py +++ b/tests/functional/cli/accounts_validation/test_bad_accounts_validation.py @@ -244,7 +244,8 @@ async def test_no_validation_of_canceling_savings_withdrawal_to_account_that_bec ) -> None: """It should be possible to cancel savings withdrawal, even if account is on bad account list.""" # ARRANGE - request_id: int = 0 + request_id = 1 # there is already one withdrawal pending in the block log + cli_tester.process_savings_withdrawal( to=TEMPORARY_BAD_ACCOUNT, amount=AMOUNT, sign=WORKING_ACCOUNT_KEY_ALIAS, request_id=request_id ) diff --git a/tests/functional/cli/accounts_validation/test_known_accounts_enabled.py b/tests/functional/cli/accounts_validation/test_known_accounts_enabled.py index dca7542aaf1ba7d49fcfcb6c6614c476da5a8fb6..99eb22b9bb5205aa10b385dfdb762a210c61d156 100644 --- a/tests/functional/cli/accounts_validation/test_known_accounts_enabled.py +++ b/tests/functional/cli/accounts_validation/test_known_accounts_enabled.py @@ -165,10 +165,12 @@ async def test_no_validation_of_canceling_savings_withdrawal_to_account_that_was ) -> None: """It should be possible to cancel savings withdrawal, even if account is not on known account list.""" # ARRANGE - request_id: int = 0 + request_id = 1 # there is already one withdrawal pending in the block log + cli_tester.process_savings_withdrawal( to=KNOWN_ACCOUNT, amount=AMOUNT, sign=WORKING_ACCOUNT_KEY_ALIAS, request_id=request_id ) + cli_tester.configure_known_account_remove(account_name=KNOWN_ACCOUNT) # ACT & ASSERT diff --git a/tests/functional/cli/exchange_operations/__init__.py b/tests/functional/cli/exchange_operations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/functional/cli/exchange_operations/test_force_required_operations.py b/tests/functional/cli/exchange_operations/test_force_required_operations.py new file mode 100644 index 0000000000000000000000000000000000000000..fca60667856347e86c8e0a130cb19e96d0591cc4 --- /dev/null +++ b/tests/functional/cli/exchange_operations/test_force_required_operations.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Final + +import pytest +import test_tools as tt + +from clive.__private.cli.exceptions import CLITransactionToExchangeError +from clive.__private.models.schemas import TransferFromSavingsOperation, TransferToSavingsOperation +from clive.__private.validators.exchange_operations_validator import ExchangeOperationsValidatorCli +from clive_local_tools.cli.checkers import assert_output_contains +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 create_transaction_file, get_formatted_error_message +from clive_local_tools.testnet_block_log import WORKING_ACCOUNT_NAME +from clive_local_tools.testnet_block_log.constants import KNOWN_EXCHANGES_NAMES + +if TYPE_CHECKING: + from collections.abc import Callable + from pathlib import Path + + from clive_local_tools.cli.cli_tester import CLITester + + +AMOUNT: Final[tt.Asset.HiveT] = tt.Asset.Hive(1) +KNOWN_EXCHANGE_NAME: Final[str] = KNOWN_EXCHANGES_NAMES[0] + + +@pytest.fixture +def transaction_with_forceable_operation_path(tmp_path: Path) -> Path: + operations = [ + TransferToSavingsOperation( + from_=WORKING_ACCOUNT_NAME, + to=KNOWN_EXCHANGE_NAME, + amount=tt.Asset.Hive(1), + memo="transfer_to_savings_operation forceable test", + ), + TransferFromSavingsOperation( + from_=WORKING_ACCOUNT_NAME, + to=KNOWN_EXCHANGE_NAME, + amount=tt.Asset.Hive(1), + memo="transfer_from_savings_operation forceable test", + request_id=1, # there is already one withdrawal pending in the block log + ), + ] + return create_transaction_file(tmp_path, operations) + + +def _assert_operation_to_exchange(send_operation_cb: Callable[[], None], *, force: bool) -> None: + if force: + send_operation_cb() + else: + expected_error_msg = get_formatted_error_message( + CLITransactionToExchangeError(ExchangeOperationsValidatorCli.UNSAFE_EXCHANGE_OPERATION_MSG_ERROR) + ) + with pytest.raises(CLITestCommandError) as error: + send_operation_cb() + assert_output_contains(expected_error_msg, error.value.stdout) + + +@pytest.mark.parametrize("force", [True, False]) +async def test_loading_transaction( + cli_tester: CLITester, transaction_with_forceable_operation_path: Path, *, force: bool +) -> None: + # ARRANGE + def send_operation() -> None: + cli_tester.process_transaction( + from_file=transaction_with_forceable_operation_path, + sign=WORKING_ACCOUNT_KEY_ALIAS, + force=force, + ) + + # ACT & ASSERT + _assert_operation_to_exchange(send_operation, force=force) + + +@pytest.mark.parametrize("force", [True, False]) +async def test_validate_of_performing_recurrent_transfer_to_exchange(cli_tester: CLITester, *, force: bool) -> None: + # ARRANGE + def send_operation() -> None: + cli_tester.process_transfer_schedule_create( + to=KNOWN_EXCHANGE_NAME, + amount=AMOUNT, + sign=WORKING_ACCOUNT_KEY_ALIAS, + repeat=2, + frequency="24h", + force=force, + ) + + # ACT & ASSERT + _assert_operation_to_exchange(send_operation, force=force) + + +@pytest.mark.parametrize("force", [True, False]) +async def test_validate_of_performing_withdrawing_to_exchange(cli_tester: CLITester, *, force: bool) -> None: + # ARRANGE + request_id = 1 # there is already one withdrawal pending in the block log + + def send_operation() -> None: + cli_tester.process_savings_withdrawal( + to=KNOWN_EXCHANGE_NAME, amount=AMOUNT, sign=WORKING_ACCOUNT_KEY_ALIAS, force=force, request_id=request_id + ) + + # ACT & ASSERT + _assert_operation_to_exchange(send_operation, force=force) + + +@pytest.mark.parametrize("force", [True, False]) +async def test_validate_of_performing_deposit_to_exchange(cli_tester: CLITester, *, force: bool) -> None: + # ARRANGE + def send_operation() -> None: + cli_tester.process_savings_deposit( + to=KNOWN_EXCHANGE_NAME, + amount=AMOUNT, + sign=WORKING_ACCOUNT_KEY_ALIAS, + force=force, + ) + + # ACT & ASSERT + _assert_operation_to_exchange(send_operation, force=force) + + +@pytest.mark.parametrize("force", [True, False]) +async def test_validation_of_powering_up_to_exchange(cli_tester: CLITester, *, force: bool) -> None: + # ARRANGE + def send_operation() -> None: + cli_tester.process_power_up( + amount=AMOUNT, + to=KNOWN_EXCHANGE_NAME, + sign=WORKING_ACCOUNT_KEY_ALIAS, + force=force, + ) + + # ACT & ASSERT + _assert_operation_to_exchange(send_operation, force=force) + + +@pytest.mark.parametrize("force", [True, False]) +async def test_validate_of_performing_delegation_set_to_exchange(cli_tester: CLITester, *, force: bool) -> None: + # ARRANGE + def send_operation() -> None: + cli_tester.process_delegations_set( + delegatee=KNOWN_EXCHANGE_NAME, + amount=AMOUNT, + sign=WORKING_ACCOUNT_KEY_ALIAS, + force=force, + ) + + # ACT & ASSERT + _assert_operation_to_exchange(send_operation, force=force) + + +@pytest.mark.parametrize("force", [True, False]) +async def test_validate_of_performing_of_withdrawal_routes_set_to_exchange( + cli_tester: CLITester, *, force: bool +) -> None: + # ARRANGE + def send_operation() -> None: + percent: int = 30 + cli_tester.process_withdraw_routes_set( + to=KNOWN_EXCHANGE_NAME, + percent=percent, + sign=WORKING_ACCOUNT_KEY_ALIAS, + force=force, + ) + + # ACT & ASSERT + _assert_operation_to_exchange(send_operation, force=force) diff --git a/tests/functional/cli/exchange_operations/test_transfer.py b/tests/functional/cli/exchange_operations/test_transfer.py new file mode 100644 index 0000000000000000000000000000000000000000..b1e49c4917ebb25cdb1b715b94331694d23642bc --- /dev/null +++ b/tests/functional/cli/exchange_operations/test_transfer.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Final + +import pytest +import test_tools as tt + +from clive.__private.cli.exceptions import CLITransactionToExchangeError +from clive.__private.models.schemas import TransferOperation +from clive.__private.validators.exchange_operations_validator import ( + ExchangeOperationsValidatorCli, +) +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 create_transaction_file, get_formatted_error_message +from clive_local_tools.testnet_block_log import WORKING_ACCOUNT_NAME +from clive_local_tools.testnet_block_log.constants import KNOWN_EXCHANGES_NAMES + +if TYPE_CHECKING: + from collections.abc import Callable + from pathlib import Path + + from clive_local_tools.cli.cli_tester import CLITester + +KNOWN_EXCHANGE_NAME: Final[str] = KNOWN_EXCHANGES_NAMES[0] +ACCOUNT_DOES_NOT_EXIST_ERROR_MSG: Final[str] = f"Account {KNOWN_EXCHANGE_NAME} doesn't exist" +MEMOLESS_TRANSFER_MSG_ERROR: Final[str] = get_formatted_error_message( + CLITransactionToExchangeError(ExchangeOperationsValidatorCli.MEMOLESS_HIVE_TRANSFER_MSG_ERROR) +) + +HBD_TRANSFER_OPERATION_MSG_ERROR: Final[str] = get_formatted_error_message( + CLITransactionToExchangeError(ExchangeOperationsValidatorCli.HBD_TRANSFER_MSG_ERROR) +) + +FORCE_REQUIRED_OPERATION_MSG_ERROR: Final[str] = get_formatted_error_message( + CLITransactionToExchangeError(ExchangeOperationsValidatorCli.UNSAFE_EXCHANGE_OPERATION_MSG_ERROR) +) + +MEMO_MSG: Final[str] = "test memo to exchange" +EMPTY_MEMO_MSG: Final[str] = "" + + +def _assert_operation_error(operation_cb: Callable[[], None], expected_message: str) -> None: + with pytest.raises(CLITestCommandError, match=expected_message): + operation_cb() + + +@pytest.fixture +def transaction_with_memoless_transfer_path(tmp_path: Path) -> Path: + operations = [ + TransferOperation( + from_=WORKING_ACCOUNT_NAME, + to=KNOWN_EXCHANGE_NAME, + amount=tt.Asset.Hive(1000), + memo=EMPTY_MEMO_MSG, + ) + ] + return create_transaction_file(tmp_path, operations) + + +@pytest.fixture +def transaction_with_hbd_transfer_path(tmp_path: Path) -> Path: + operations = [ + TransferOperation( + from_=WORKING_ACCOUNT_NAME, + to=KNOWN_EXCHANGE_NAME, + amount=tt.Asset.Hbd(1000), + memo="HBD transfer to exchange", + ) + ] + return create_transaction_file(tmp_path, operations) + + +@pytest.mark.parametrize("amount", [tt.Asset.Hive(10), tt.Asset.Hbd(10)]) +async def test_validate_memoless_transfer_to_exchange( + cli_tester: CLITester, amount: tt.Asset.HiveT | tt.Asset.HbdT +) -> None: + """ + Verify that memoless transfer to exchange is not allowed. + + This test checks performing transfer that has no memo to exchange, + it will generate an error, and transfer will not be broadcasted. + """ + + # ARRANGE + def operation() -> None: + cli_tester.process_transfer( + to=KNOWN_EXCHANGE_NAME, + amount=amount, + sign=WORKING_ACCOUNT_KEY_ALIAS, + memo=EMPTY_MEMO_MSG, + ) + + # ACT & ASSERT + _assert_operation_error(operation, MEMOLESS_TRANSFER_MSG_ERROR) + + +async def test_validate_performing_transaction_with_memoless_transfer_to_exchange( + cli_tester: CLITester, + transaction_with_memoless_transfer_path: Path, +) -> None: + """ + Verify performing transaction memoless transfer to exchange. + + This test checks if transaction with memoless transfer to exchange is not allowed. + It will generate an error, and transaction will not be broadcasted. + """ + + # ARRANGE + def operation() -> None: + cli_tester.process_transaction( + from_file=transaction_with_memoless_transfer_path, + sign=WORKING_ACCOUNT_KEY_ALIAS, + ) + + # ACT & ASSERT + _assert_operation_error(operation, MEMOLESS_TRANSFER_MSG_ERROR) + + +async def test_validate_performing_transaction_with_hbd_transfer_to_exchange( + cli_tester: CLITester, + transaction_with_hbd_transfer_path: Path, +) -> None: + """ + Verify performing transaction hbd transfer to exchange. + + This test checks if transaction with hbd transfer to exchange is not allowed. + It will generate an error, and transaction will not be broadcasted. + """ + + # ARRANGE + def operation() -> None: + cli_tester.process_transaction( + from_file=transaction_with_hbd_transfer_path, + sign=WORKING_ACCOUNT_KEY_ALIAS, + ) + + # ACT & ASSERT + _assert_operation_error(operation, HBD_TRANSFER_OPERATION_MSG_ERROR) + + +async def test_validate_hive_transfer_with_memo_to_exchange( + cli_tester: CLITester, +) -> None: + """HIVE transfers with memo are allowed to exchanges.""" + # ARRANGE + amount = tt.Asset.Hive(10.000) + + # ACT & ASSERT + cli_tester.process_transfer( + to=KNOWN_EXCHANGE_NAME, + amount=amount, + sign=WORKING_ACCOUNT_KEY_ALIAS, + memo=MEMO_MSG, + ) + + +async def test_validate_hbd_transfer_with_memo_to_exchange( + cli_tester: CLITester, +) -> None: + """HBD transfer operations to exchanges are not allowed.""" + # ARRANGE + amount = tt.Asset.Hbd(10.000) + + def operation() -> None: + cli_tester.process_transfer( + to=KNOWN_EXCHANGE_NAME, + amount=amount, + sign=WORKING_ACCOUNT_KEY_ALIAS, + memo=MEMO_MSG, + ) + + # ACT & ASSERT + _assert_operation_error(operation, HBD_TRANSFER_OPERATION_MSG_ERROR) diff --git a/tests/tui/conftest.py b/tests/tui/conftest.py index 71af1f7ed50c907fc0c2bf02c6b51580e3ab1834..15202847e7a40fdd6c7392fa0df5a482de1fd6b5 100644 --- a/tests/tui/conftest.py +++ b/tests/tui/conftest.py @@ -67,7 +67,7 @@ async def _prepare_profile_with_wallet_tui() -> None: @pytest.fixture def node_with_wallet() -> NodeWithWallet: - node = run_node(use_faketime=True) + node = run_node() wallet = tt.Wallet(attach_to=node) wallet.api.import_key(node.config.private_key[0])