diff --git a/clive/__private/cli/commands/abc/contextual_cli_command.py b/clive/__private/cli/commands/abc/contextual_cli_command.py index fe7414f6736c9740076f1eb7539d22a4496600fe..4ecbeb8aa1a4f228becbbe45ac0366a65c0dbf5b 100644 --- a/clive/__private/cli/commands/abc/contextual_cli_command.py +++ b/clive/__private/cli/commands/abc/contextual_cli_command.py @@ -64,3 +64,4 @@ class ContextualCLICommand[AsyncContextManagerT: AbstractAsyncContextManager[Any await self.validate_inside_context_manager() await self._configure_inside_context_manager() await self._run() + await self.post_run() diff --git a/clive/__private/cli/commands/abc/external_cli_command.py b/clive/__private/cli/commands/abc/external_cli_command.py index 84d001bf025281855ea73c5f34b979cb6d3d1717..98f0a8d6a51944feb24e9626305351ee86557522 100644 --- a/clive/__private/cli/commands/abc/external_cli_command.py +++ b/clive/__private/cli/commands/abc/external_cli_command.py @@ -34,6 +34,11 @@ class ExternalCLICommand(ABC): await self.validate() await self._configure() await self._run() + await self.post_run() + + async def post_run(self) -> None: + """Performed after _run, can be overridden in subclass.""" + return async def validate(self) -> None: """ diff --git a/clive/__private/cli/commands/process/process_account_creation.py b/clive/__private/cli/commands/process/process_account_creation.py new file mode 100644 index 0000000000000000000000000000000000000000..1ba987a9a003f28cf07e8f1d169e204f4d6c3f73 --- /dev/null +++ b/clive/__private/cli/commands/process/process_account_creation.py @@ -0,0 +1,219 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Final, override + +from clive.__private.cli.commands.abc.operation_command import OperationCommand +from clive.__private.cli.exceptions import CLIPrettyError +from clive.__private.cli.print_cli import print_cli +from clive.__private.core.constants.cli import ( + DEFAULT_AUTHORITY_THRESHOLD, + DEFAULT_AUTHORITY_WEIGHT, +) +from clive.__private.core.keys.keys import PublicKey +from clive.__private.models.asset import Asset +from clive.__private.models.schemas import ( + AccountCreateOperation, + Authority, + CreateClaimedAccountOperation, +) + +if TYPE_CHECKING: + from clive.__private.cli.types import KeyOrAccountWithWeight + from clive.__private.core.types import AuthorityLevelRegular + + +class MissingAuthorityError(CLIPrettyError): + """ + Raised when trying to create a account without authority given as parameter. + + Args: + level: A type of authority that is missing. + """ + + def __init__(self, level: AuthorityLevelRegular) -> None: + super().__init__(f"There must be at least one key or account in {level} authority.") + + +class MissingMemoKeyError(CLIPrettyError): + """ + Raised when trying to create a account without memo key. + + Attributes: + MESSAGE: A message displayed to user when this error occurs. + """ + + MESSAGE: Final[str] = "Memo key must be set when creating account." + + def __init__(self) -> None: + super().__init__(self.MESSAGE) + + +class FetchAccountCreationFeeError(CLIPrettyError): + """ + Raised when failed to fetch account creation fe from node. + + Attributes: + MESSAGE: A message displayed to user when this error occurs. + """ + + MESSAGE: Final[str] = ( + "Could not fetch account creation fee from the node." + " You can display node with command `clive show node` and set node with command `clive configure node set`." + ) + + def __init__(self) -> None: + super().__init__(self.MESSAGE) + + +def _create_empty_authority() -> Authority: + return Authority(weight_threshold=DEFAULT_AUTHORITY_THRESHOLD, account_auths=[], key_auths=[]) + + +@dataclass(kw_only=True) +class ProcessAccountCreation(OperationCommand): + creator: str + new_account_name: str + fee: bool + json_metadata: str + _owner_authority: Authority = field(default_factory=_create_empty_authority) + _active_authority: Authority = field(default_factory=_create_empty_authority) + _posting_authority: Authority = field(default_factory=_create_empty_authority) + _memo_key: PublicKey | None = None + _fee_value: Asset.Hive | None = None # Set after fetching from node + + @property + def fee_value_ensure(self) -> Asset.Hive: + assert self._fee_value is not None, "Value of fee must be fetched from node" + return self._fee_value + + @property + def is_active_authority_set(self) -> bool: + return self._is_authority_set(self._active_authority) + + @property + def is_owner_authority_set(self) -> bool: + return self._is_authority_set(self._owner_authority) + + @property + def is_posting_authority_set(self) -> bool: + return self._is_authority_set(self._posting_authority) + + @property + def memo_key_ensure(self) -> PublicKey: + assert self._memo_key is not None, "Memo key must be specified by user and set with method `set_memo_key`" + return self._memo_key + + @property + def is_memo_key_set(self) -> bool: + return self._memo_key is not None + + def add_authority( + self, + level: AuthorityLevelRegular, + threshold: int, + entries: list[KeyOrAccountWithWeight], + ) -> None: + self._set_threshold(level, threshold) + for key_or_account, weight in entries: + if isinstance(key_or_account, PublicKey): + self._add_key_authority(level, key_or_account, weight) + else: + self._add_account_authority(level, key_or_account, weight) + + @override + async def fetch_data(self) -> None: + if self.fee: + witness_schedule = await self.world.node.api.database_api.get_witness_schedule() + if witness_schedule.median_props.account_creation_fee is None: + raise FetchAccountCreationFeeError + self._fee_value = witness_schedule.median_props.account_creation_fee + print_cli(f"Account creation fee: `{Asset.to_legacy(self._fee_value)}` will be paid.") + else: + self._fee_value = Asset.hive(0) + + def is_fee_given(self, *, fee: bool | None) -> bool: + return fee is not None + + @override + async def post_run(self) -> None: + if not self.profile.accounts.is_account_known(self.new_account_name): + print_cli(f"Adding account `{self.new_account_name}` to known accounts.") + self.profile.accounts.add_known_account(self.new_account_name) + + def set_memo_key(self, key: PublicKey) -> None: + self._memo_key = key + + def set_keys(self, owner: PublicKey, active: PublicKey, posting: PublicKey) -> None: + for authority_type in ("owner", "active", "posting"): + self._set_threshold(authority_type, DEFAULT_AUTHORITY_THRESHOLD) + self._add_key_authority( + authority_type, + { + "owner": owner, + "active": active, + "posting": posting, + }[authority_type], + DEFAULT_AUTHORITY_WEIGHT, + ) + + @override + async def validate(self) -> None: + self._validate_all_authorities_are_set() + await super().validate() + + def _add_key_authority(self, level: AuthorityLevelRegular, key: PublicKey, weight: int) -> None: + self._get_authority(level).key_auths.append((key.value, weight)) + + def _add_account_authority(self, level: AuthorityLevelRegular, account_name: str, weight: int) -> None: + self._get_authority(level).account_auths.append((account_name, weight)) + + async def _create_operation(self) -> AccountCreateOperation | CreateClaimedAccountOperation: + if self.fee: + return AccountCreateOperation( + fee=self.fee_value_ensure, + creator=self.creator, + new_account_name=self.new_account_name, + json_metadata=self.json_metadata, + owner=self._owner_authority, + active=self._active_authority, + posting=self._posting_authority, + memo_key=self.memo_key_ensure.value, + ) + return CreateClaimedAccountOperation( + creator=self.creator, + new_account_name=self.new_account_name, + json_metadata=self.json_metadata, + owner=self._owner_authority, + active=self._active_authority, + posting=self._posting_authority, + memo_key=self.memo_key_ensure.value, + ) + + def _get_authority(self, level: AuthorityLevelRegular) -> Authority: + mapping: dict[AuthorityLevelRegular, Authority] = { + "owner": self._owner_authority, + "active": self._active_authority, + "posting": self._posting_authority, + } + try: + return mapping[level] + except KeyError as err: + raise ValueError(f"Unknown authority type: {level}") from err + + @staticmethod + def _is_authority_set(auth: Authority) -> bool: + return bool(auth.account_auths) or bool(auth.key_auths) + + def _set_threshold(self, level: AuthorityLevelRegular, threshold: int) -> None: + self._get_authority(level).weight_threshold = threshold + + def _validate_all_authorities_are_set(self) -> None: + if not self.is_owner_authority_set: + raise MissingAuthorityError("owner") + if not self.is_active_authority_set: + raise MissingAuthorityError("active") + if not self.is_posting_authority_set: + raise MissingAuthorityError("posting") + if not self.is_memo_key_set: + raise MissingMemoKeyError diff --git a/clive/__private/cli/common/parameters/argument_related_options.py b/clive/__private/cli/common/parameters/argument_related_options.py index ad943c527ac8cddc05dadac99db4012aeb1426da..476a3fd25aba30a14608af688cc79228ac3ab2da 100644 --- a/clive/__private/cli/common/parameters/argument_related_options.py +++ b/clive/__private/cli/common/parameters/argument_related_options.py @@ -38,6 +38,8 @@ account_name = _make_argument_related_option(options.account_name) profile_name = _make_argument_related_option(options.profile_name) +new_account_name = _make_argument_related_option(options.new_account_name) + name = _make_argument_related_option("--name") key = _make_argument_related_option("--key") diff --git a/clive/__private/cli/common/parameters/options.py b/clive/__private/cli/common/parameters/options.py index 8a7148d9c1fec2ff4852fc55a135a2ebaeddce0b..73e4f0453a2f7310c5e58a051c0af4113d79f29e 100644 --- a/clive/__private/cli/common/parameters/options.py +++ b/clive/__private/cli/common/parameters/options.py @@ -114,6 +114,12 @@ force_value = typer.Option( ), ) +new_account_name = typer.Option( + None, + "--new-account-name", + help="The name of the new account.", +) + # OPERATION COMMON OPTIONS >> _operation_common_option = partial(modified_param, rich_help_panel=OPERATION_COMMON_OPTIONS_PANEL_TITLE) diff --git a/clive/__private/cli/common/parsers.py b/clive/__private/cli/common/parsers.py index 1a7555b1189fb90c30cc379ea99fbe56ec245c38..0dfbec5bc7c17ba5cda893d7486da5d53dedae53 100644 --- a/clive/__private/cli/common/parsers.py +++ b/clive/__private/cli/common/parsers.py @@ -5,11 +5,16 @@ from typing import TYPE_CHECKING, get_args import typer +from clive.__private.cli.exceptions import CLIParsingAuthorityKeyOrAccountError, CLIPublicKeyInvalidFormatError +from clive.__private.core.constants.cli import DEFAULT_AUTHORITY_WEIGHT, WEIGHT_MARK + if TYPE_CHECKING: from collections.abc import Callable from datetime import timedelta from decimal import Decimal + from clive.__private.cli.types import KeyOrAccountWithWeight + from clive.__private.core.keys.keys import PublicKey from clive.__private.models import Asset @@ -124,3 +129,36 @@ def scheduled_transfer_frequency_parser(raw: str) -> timedelta: if status.is_valid: return shorthand_timedelta_to_timedelta(raw) raise typer.BadParameter(humanize_validation_result(status)) + + +def public_key(raw: str) -> PublicKey: + from clive.__private.core.keys.keys import PublicKey, PublicKeyInvalidFormatError # noqa: PLC0415 + + try: + parsed = PublicKey(value=raw) + except PublicKeyInvalidFormatError as error: + raise CLIPublicKeyInvalidFormatError(raw) from error + return parsed + + +def _parse_with_weight(raw: str, default_weight: int) -> tuple[str, int]: + if WEIGHT_MARK in raw: + value, weight_str = raw.split(WEIGHT_MARK, 1) + try: + weight = int(weight_str) + except ValueError as error: + raise typer.BadParameter(f"Weight must be an integer, got: {weight_str}") from error + return value, weight + return raw, default_weight + + +def public_key_or_account_with_weight(raw: str) -> KeyOrAccountWithWeight: + raw, weight = _parse_with_weight(raw, DEFAULT_AUTHORITY_WEIGHT) + if len(raw) <= 15: # noqa: PLR2004 TODO: perform better validation here maybe there is regex in schemas for this + return raw, weight + try: + from clive.__private.core.keys.keys import PublicKey, PublicKeyInvalidFormatError # noqa: PLC0415 + + return PublicKey(value=raw), weight + except PublicKeyInvalidFormatError as error: + raise CLIParsingAuthorityKeyOrAccountError(raw) from error diff --git a/clive/__private/cli/exceptions.py b/clive/__private/cli/exceptions.py index 9a60d8b65d2b644be4a54ab758b259a2753d7769..f5393a726175b6a64c88c93b1aa912b925580b49 100644 --- a/clive/__private/cli/exceptions.py +++ b/clive/__private/cli/exceptions.py @@ -462,3 +462,31 @@ class CLITransactionAlreadySignedError(CLIPrettyError): def __init__(self) -> None: super().__init__(self.MESSAGE, errno.EINVAL) + + +class CLIPublicKeyInvalidFormatError(CLIPrettyError): + """ + Raise when trying to load public key in invalid format. + + Args: + invalid_key: Key that did not pass validation and will be displayed on screen. + """ + + def __init__(self, invalid_key: str) -> None: + message = f"Given public key: `{invalid_key}` has an invalid format." + super().__init__(message, errno.EINVAL) + + +class CLIParsingAuthorityKeyOrAccountError(CLIPrettyError): + """ + Raise when trying to parse entry that is supposed to contain public key or account name. + + Args: + entry: Authority entry (account or key) that failed parsing. + """ + + def __init__(self, entry: str) -> None: + super().__init__( + f"Could not parse `{entry}`. It should contain public key or account name (possibly with weight)", + errno.EINVAL, + ) diff --git a/clive/__private/cli/process/main.py b/clive/__private/cli/process/main.py index 54d26117bf243a01e8bab14e188321854bf41893..b088f82ec646dde9628689d21c381bce3fffe2f7 100644 --- a/clive/__private/cli/process/main.py +++ b/clive/__private/cli/process/main.py @@ -8,6 +8,12 @@ import typer from clive.__private.cli.clive_typer import CliveTyper from clive.__private.cli.common import options +from clive.__private.cli.common.parameters import argument_related_options, modified_param +from clive.__private.cli.common.parameters.ensure_single_value import ( + EnsureSingleValue, +) +from clive.__private.cli.common.parsers import public_key, public_key_or_account_with_weight +from clive.__private.cli.exceptions import CLIPrettyError from clive.__private.cli.process.claim import claim from clive.__private.cli.process.custom_operations.custom_json import custom_json from clive.__private.cli.process.hive_power.delegations import delegations @@ -20,9 +26,17 @@ from clive.__private.cli.process.transfer_schedule import transfer_schedule from clive.__private.cli.process.update_authority import get_update_authority_typer from clive.__private.cli.process.vote_proposal import vote_proposal from clive.__private.cli.process.vote_witness import vote_witness +from clive.__private.core.constants.cli import ( + DEFAULT_AUTHORITY_THRESHOLD, + DEFAULT_AUTHORITY_WEIGHT, + FOUR_PUBLIC_KEYS_METAVAR, + REQUIRED_AS_ARG_OR_OPTION, +) from clive.__private.core.constants.data_retrieval import ALREADY_SIGNED_MODE_DEFAULT, ALREADY_SIGNED_MODES if TYPE_CHECKING: + from clive.__private.cli.types import KeyOrAccountWithWeight + from clive.__private.core.keys.keys import PublicKey from clive.__private.models import Asset process = CliveTyper(name="process", help="Process something (e.g. perform a transfer).") @@ -137,3 +151,130 @@ async def process_update_memo_key( # noqa: PLR0913 ) operation.add_callback(update_memo_key_callback) await operation.run() + + +_new_account_name_argument = typer.Argument( + None, + help=f"The name of the new profile. ({REQUIRED_AS_ARG_OR_OPTION})", +) + + +@process.command(name="account-creation") +async def process_account_creation( # noqa: PLR0913 + creator: str = modified_param(options.working_account_template, param_decls=("--creator",)), + new_account_name: str | None = _new_account_name_argument, + new_account_name_option: str | None = argument_related_options.new_account_name, + keys: tuple[str, str, str, str] | None = typer.Argument( + None, + parser=public_key, + help="Specify owner, active, posting and memo public keys, separated by space. Thresholds and weights" + f" have default values of {DEFAULT_AUTHORITY_THRESHOLD} and {DEFAULT_AUTHORITY_WEIGHT}.", + metavar=FOUR_PUBLIC_KEYS_METAVAR, + ), + owner_option: list[str] | None = typer.Option( + None, + "--owner", + parser=public_key_or_account_with_weight, + help="Owner public key that will be set for account.", + ), + active_option: list[str] | None = typer.Option( + None, + "--active", + parser=public_key_or_account_with_weight, + help="Active public key that will be set for account.", + ), + posting_option: list[str] | None = typer.Option( + None, + "--posting", + parser=public_key_or_account_with_weight, + help="Posting public key that will be set for account.", + ), + memo_option: str | None = typer.Option( + None, + "--memo", + parser=public_key, + help="Memo public key that will be set for account.", + ), + fee: bool = typer.Option( # noqa: FBT001 + default=False, + help="If set to true then account creation fee will be paid, you can check it with command `clive show chain`." + " If set to false then new account token will be used. Default is false.", + ), + json_metadata: str = typer.Option( + "", + "--json-metadata", + help="The json metadata of the new account passed as string. Default is empty string.", + ), + owner_threshold: int = typer.Option( + DEFAULT_AUTHORITY_THRESHOLD, + help="Allows to set threshold for owner authority.", + ), + active_threshold: int = typer.Option( + DEFAULT_AUTHORITY_THRESHOLD, + help="Allows to set threshold for active authority.", + ), + posting_threshold: int = typer.Option( + DEFAULT_AUTHORITY_THRESHOLD, + help="Allows to set threshold for posting authority.", + ), + sign_with: str | None = options.sign_with, + autosign: bool | None = options.autosign, # noqa: FBT001 + broadcast: bool = options.broadcast, # noqa: FBT001 + save_file: str | None = options.save_file, +) -> None: + """ + Simplified version of command for account creation where only 4 keys are required. + + Example: + clive process account-creation "alice-$(date +%s)" --fee + STM4xDsaG7wZu8gENJLC1GyVmorPGZrJePwazZsgcThWWnfkQKVbx + STM4xDsaG7wZu8gENJLC1GyVmorPGZrJePwazZsgcThWWnfkQKVbx + STM4xDsaG7wZu8gENJLC1GyVmorPGZrJePwazZsgcThWWnfkQKVbx + STM4xDsaG7wZu8gENJLC1GyVmorPGZrJePwazZsgcThWWnfkQKVbx + + clive-dev process account-creation "alice-$(date +%s)" --fee + --owner STM4xDsaG7wZu8gENJLC1GyVmorPGZrJePwazZsgcThWWnfkQKVbx=3 + --active=STM4xDsaG7wZu8gENJLC1GyVmorPGZrJePwazZsgcThWWnfkQKVbx + --posting STM4xDsaG7wZu8gENJLC1GyVmorPGZrJePwazZsgcThWWnfkQKVbx=3 + --posting mary=2 + --memo=STM4xDsaG7wZu8gENJLC1GyVmorPGZrJePwazZsgcThWWnfkQKVbx + --owner STM4xDsaG7wZu8gENJLC1GyVmorPGZrJePwazZsgcThWWnfkQKVbx=3 + --owner STM4xDsaG7wZu8gENJLC1GyVmorPGZrJePwazZsgcThWWnfkQKVbx=4 + --owner STM4xDsaG7wZu8gENJLC1GyVmorPGZrJePwazZsgcThWWnfkQKVbx=5 + """ + from clive.__private.cli.commands.process.process_account_creation import ProcessAccountCreation # noqa: PLC0415 + + account_creation_command = ProcessAccountCreation( + creator=creator, + new_account_name=EnsureSingleValue("new-account-name").of(new_account_name, new_account_name_option), + fee=fee, + json_metadata=json_metadata, + sign_with=sign_with, + broadcast=broadcast, + save_file=save_file, + autosign=autosign, + ) + + if keys is not None: + owner, active, posting, memo = (cast("PublicKey", key) for key in keys) + account_creation_command.set_keys(owner, active, posting) + account_creation_command.set_memo_key(memo) + elif owner_option is None: + raise CLIPrettyError("Missing owner option.") + elif active_option is None: + raise CLIPrettyError("Missing active option.") + elif posting_option is None: + raise CLIPrettyError("Missing posting option.") + else: + account_creation_command.add_authority( + "owner", owner_threshold, cast("list[KeyOrAccountWithWeight]", owner_option) + ) + account_creation_command.add_authority( + "active", active_threshold, cast("list[KeyOrAccountWithWeight]", active_option) + ) + account_creation_command.add_authority( + "posting", posting_threshold, cast("list[KeyOrAccountWithWeight]", posting_option) + ) + account_creation_command.set_memo_key(cast("PublicKey", memo_option)) + + await account_creation_command.run() diff --git a/clive/__private/cli/types.py b/clive/__private/cli/types.py index ba401c4094d74802e5af22d6c026538f8c7db97b..705e7db8812ac5a5121b38e2d608dbd3a897e446 100644 --- a/clive/__private/cli/types.py +++ b/clive/__private/cli/types.py @@ -5,10 +5,13 @@ from typing import TYPE_CHECKING, Literal if TYPE_CHECKING: from collections.abc import Callable + from clive.__private.core.keys.keys import PublicKey from clive.__private.models.schemas import AccountUpdate2Operation, Authority AccountUpdateFunction = Callable[[AccountUpdate2Operation], AccountUpdate2Operation] AuthorityUpdateFunction = Callable[[Authority], Authority] + type KeyOrAccountWithWeight = tuple[str | PublicKey, int] + AuthorityType = Literal["owner", "active", "posting"] diff --git a/clive/__private/core/constants/cli.py b/clive/__private/core/constants/cli.py index 612cc8f8dc198ae964af072286cc7c8190af376b..a53c3025da4c8950382bebe6d518369157d2b056 100644 --- a/clive/__private/core/constants/cli.py +++ b/clive/__private/core/constants/cli.py @@ -23,3 +23,16 @@ UNLOCK_CREATE_PROFILE_SELECT: Final[str] = "create a new profile" PAGE_SIZE_OPTION_MINIMAL_VALUE: Final[int] = 1 PAGE_NUMBER_OPTION_MINIMAL_VALUE: Final[int] = 0 + +DEFAULT_AUTHORITY_THRESHOLD: Final[int] = 1 +DEFAULT_AUTHORITY_WEIGHT: Final[int] = 1 +WEIGHT_MARK: Final[str] = "=" +KEY_WITH_WEIGHT_METAVAR: Final[str] = ( + f"KEY[{WEIGHT_MARK}WEIGHT]\nExamples:\n" + " --key STM1111111111111111111111111111111114T1Anm\n" + f" --key STM1111111111111111111111111111111114T1Anm{WEIGHT_MARK}2" +) +ACCOUNT_WITH_WEIGHT_METAVAR: Final[str] = ( + f"ACCOUNT[{WEIGHT_MARK}WEIGHT]\nExamples:\n --account alice\n --account alice{WEIGHT_MARK}2" +) +FOUR_PUBLIC_KEYS_METAVAR: Final[str] = "OWNER_KEY ACTIVE_KEY POSTING_KEY MEMO_KEY" diff --git a/pydoclint-errors-baseline.txt b/pydoclint-errors-baseline.txt index 2171fe88b180c2314cec5b69e6971bc3853f0480..41ddca07b765cac5afe883b97c8d7e4bfc7e1edc 100644 --- a/pydoclint-errors-baseline.txt +++ b/pydoclint-errors-baseline.txt @@ -139,6 +139,10 @@ clive/__private/cli/process/main.py DOC103: Function `process_transaction`: 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: [already_signed_mode: AlreadySignedModeEnum, autosign: bool | None, broadcast: bool, force: bool, force_unsign: bool, from_file: str, save_file: str | None, sign_with: str | None]. DOC101: Function `process_update_memo_key`: Docstring contains fewer arguments than in function signature. DOC103: Function `process_update_memo_key`: 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, memo_key: str, save_file: str | None, sign_with: str | None]. + DOC101: Function `process_account_creation`: Docstring contains fewer arguments than in function signature. + DOC103: Function `process_account_creation`: 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: [active_option: list[str] | None, active_threshold: int, autosign: bool | None, broadcast: bool, creator: str, fee: bool, json_metadata: str, keys: tuple[str, str, str, str] | None, memo_option: str | None, new_account_name: str | None, new_account_name_option: str | None, owner_option: list[str] | None, owner_threshold: int, posting_option: list[str] | None, posting_threshold: int, save_file: str | None, sign_with: str | None]. + DOC501: Function `process_account_creation` has raise statements, but the docstring does not have a "Raises" section + DOC503: Function `process_account_creation` exceptions in the "Raises" section in the docstring do not match those in the function body. Raised exceptions in the docstring: []. Raised exceptions in the body: ['CLIPrettyError']. -------------------- clive/__private/cli/process/proxy.py DOC101: Function `process_proxy_set`: 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 170bf1b70acc67be151733e5e6f2225ac1a3f359..72f177d150ca831c7649331ff23de1d424726744 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 @@ -24,7 +24,7 @@ if TYPE_CHECKING: from clive.__private.core.types import AlreadySignedMode from clive.__private.core.world import World from clive.__private.models.schemas import PublicKey - from clive_local_tools.cli.command_options import CliOptionT + from clive_local_tools.cli.command_options import CliOptionT, StringConvertibleOptionTypes class CLITester: @@ -577,3 +577,38 @@ class CLITester: def generate_secret_phrase(self) -> Result: return self.__invoke_command_with_options(["generate", "secret-phrase"]) + + def process_claim_new_account_token( # noqa: PLR0913 + self, + *, + account_name: str | None = None, + fee: tt.Asset.HiveT | None = None, + sign_with: str | None = None, + broadcast: bool | None = None, + save_file: Path | None = None, + autosign: bool | None = None, + ) -> Result: + return self.__invoke_command_with_options(["process", "claim", "new-account-token"], **extract_params(locals())) + + def process_account_creation( # noqa: PLR0913 + self, + *args: StringConvertibleOptionTypes, + creator: str | None = None, + new_account_name: str | None = None, + fee: bool | None = None, + json_metadata: str | None = None, + owner: PublicKey | None = None, + active: PublicKey | None = None, + posting: PublicKey | None = None, + memo: PublicKey | None = None, + sign_with: str | None = None, + broadcast: bool | None = None, + save_file: Path | None = None, + autosign: bool | None = None, + ) -> Result: + named_params = locals() + named_params.pop("args") + positional_args = [str(arg) for arg in args] + return self.__invoke_command_with_options( + ["process", "account-creation", *positional_args], **extract_params(named_params) + ) diff --git a/tests/functional/cli/process/test_process_account_creation.py b/tests/functional/cli/process/test_process_account_creation.py new file mode 100644 index 0000000000000000000000000000000000000000..b5976f1c23c901e10200a0d744effb6a404da99f --- /dev/null +++ b/tests/functional/cli/process/test_process_account_creation.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Final + +from clive.__private.core.constants.cli import DEFAULT_AUTHORITY_THRESHOLD, DEFAULT_AUTHORITY_WEIGHT +from clive.__private.core.keys.keys import PrivateKey +from clive.__private.models.schemas import ( + AccountCreateOperation, + AssetHive, + Authority, + CreateClaimedAccountOperation, + PublicKey, +) +from clive_local_tools.checkers.blockchain_checkers import ( + assert_operations_placed_in_blockchain, + assert_transaction_in_blockchain, +) +from clive_local_tools.testnet_block_log import WATCHED_ACCOUNTS_DATA, WORKING_ACCOUNT_DATA + +if TYPE_CHECKING: + from pathlib import Path + + import test_tools as tt + + from clive.__private.core.types import AuthorityLevel + from clive_local_tools.cli.cli_tester import CLITester + + +NEW_ACCOUNT_NAME: Final[str] = "newaccount" +OTHER_ACCOUNT2: Final[tt.Account] = WATCHED_ACCOUNTS_DATA[1].account +WEIGHT: Final[int] = 213 +MODIFIED_WEIGHT: Final[int] = 214 +WEIGHT_THRESHOLD: Final[int] = 2 + + +def create_public_key_for_role(new_account_name: str = NEW_ACCOUNT_NAME, *, role: AuthorityLevel) -> PublicKey: + private_key = PrivateKey.create_from_seed("seed", new_account_name, role=role) + return private_key.calculate_public_key().value + + +OWNER_KEY: Final[PublicKey] = create_public_key_for_role(role="owner") +OWNER_AUTHORITY: Final[Authority] = Authority( + weight_threshold=DEFAULT_AUTHORITY_THRESHOLD, + account_auths=[], + key_auths=[(OWNER_KEY, DEFAULT_AUTHORITY_WEIGHT)], +) +ACTIVE_KEY: Final[PublicKey] = create_public_key_for_role(role="active") +ACTIVE_AUTHORITY: Final[Authority] = Authority( + weight_threshold=DEFAULT_AUTHORITY_THRESHOLD, + account_auths=[], + key_auths=[(ACTIVE_KEY, DEFAULT_AUTHORITY_WEIGHT)], +) +POSTING_KEY: Final[PublicKey] = create_public_key_for_role(role="posting") +POSTING_AUTHORITY: Final[Authority] = Authority( + weight_threshold=DEFAULT_AUTHORITY_THRESHOLD, + account_auths=[], + key_auths=[(POSTING_KEY, DEFAULT_AUTHORITY_WEIGHT)], +) +MEMO_KEY: Final[PublicKey] = create_public_key_for_role(role="memo") + + +def format_auths_arg(authority_entries: list[tuple[PublicKey | str, int]]) -> list[str]: + return [f"{entry[0]}={entry[1]}" for entry in authority_entries] + + +def fetch_account_creation_fee(node: tt.RawNode) -> AssetHive: + fee = node.api.database_api.get_witness_schedule().median_props.account_creation_fee + assert fee is not None, "Account creation fee should be present in database_api.get_witness_schedule" + return fee + + +async def test_account_creation_with_fee(node: tt.RawNode, cli_tester: CLITester) -> None: + # ARRANGE + fee = fetch_account_creation_fee(node) + operation = AccountCreateOperation( + fee=fee, + creator=WORKING_ACCOUNT_DATA.account.name, + new_account_name=NEW_ACCOUNT_NAME, + owner=OWNER_AUTHORITY, + active=ACTIVE_AUTHORITY, + posting=POSTING_AUTHORITY, + memo_key=MEMO_KEY, + json_metadata="", + ) + + # ACT + result = cli_tester.process_account_creation( + new_account_name=NEW_ACCOUNT_NAME, + owner=OWNER_KEY, + active=ACTIVE_KEY, + posting=POSTING_KEY, + memo=MEMO_KEY, + fee=True, + ) + + # ASSERT + assert_operations_placed_in_blockchain(node, result, operation) + + +async def test_account_creation_with_token(node: tt.RawNode, cli_tester: CLITester) -> None: + # ARRANGE + # in block_log only initminer has account subsidies so we pay fee to get token + fee = fetch_account_creation_fee(node) + cli_tester.process_claim_new_account_token(fee=fee) + operation = CreateClaimedAccountOperation( + creator=WORKING_ACCOUNT_DATA.account.name, + new_account_name=NEW_ACCOUNT_NAME, + owner=OWNER_AUTHORITY, + active=ACTIVE_AUTHORITY, + posting=POSTING_AUTHORITY, + memo_key=MEMO_KEY, + json_metadata="", + ) + + # ACT + result = cli_tester.process_account_creation( + new_account_name=NEW_ACCOUNT_NAME, + owner=OWNER_KEY, + active=ACTIVE_KEY, + posting=POSTING_KEY, + memo=MEMO_KEY, + ) + + # ASSERT + assert_operations_placed_in_blockchain(node, result, operation) + + +async def test_save_to_file(node: tt.RawNode, cli_tester: CLITester, tmp_path: Path) -> None: + # ARRANGE + trx_path = tmp_path / "account_creation.json" + + # ACT + result = cli_tester.process_account_creation( + new_account_name=NEW_ACCOUNT_NAME, + owner=OWNER_KEY, + active=ACTIVE_KEY, + posting=POSTING_KEY, + memo=MEMO_KEY, + fee=True, + broadcast=False, + save_file=trx_path, + ) + + # ASSERT + result = cli_tester.process_transaction(from_file=trx_path) + + # ASSERT + assert_transaction_in_blockchain(node, result) + + +async def test_positional_arguments_are_allowed(node: tt.RawNode, cli_tester: CLITester) -> None: + # ARRANGE + fee = fetch_account_creation_fee(node) + operation = AccountCreateOperation( + fee=fee, + creator=WORKING_ACCOUNT_DATA.account.name, + new_account_name=NEW_ACCOUNT_NAME, + owner=OWNER_AUTHORITY, + active=ACTIVE_AUTHORITY, + posting=POSTING_AUTHORITY, + memo_key=MEMO_KEY, + json_metadata="", + ) + + # ACT + result = cli_tester.process_account_creation( + NEW_ACCOUNT_NAME, + OWNER_KEY, + ACTIVE_KEY, + POSTING_KEY, + MEMO_KEY, + fee=True, + ) + + # ASSERT + assert_operations_placed_in_blockchain(node, result, operation)