From 803a5c72254ec1cd294daec08d3c346da32bd5ef Mon Sep 17 00:00:00 2001 From: Marcin Sobczyk Date: Thu, 2 Oct 2025 07:48:49 +0000 Subject: [PATCH 1/4] Cli command clive process account-creation Allowing to pass public keys as positional arguments/options --- .../process/process_account_creation.py | 219 ++++++++++++++++++ .../parameters/argument_related_options.py | 2 + .../cli/common/parameters/options.py | 6 + clive/__private/cli/common/parsers.py | 36 +++ clive/__private/cli/exceptions.py | 13 ++ clive/__private/cli/process/main.py | 110 +++++++++ clive/__private/cli/types.py | 4 + clive/__private/core/constants/cli.py | 13 ++ pydoclint-errors-baseline.txt | 2 + 9 files changed, 405 insertions(+) create mode 100644 clive/__private/cli/commands/process/process_account_creation.py 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 0000000000..c607f5e98b --- /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.models.asset import Asset +from clive.__private.models.schemas import ( + AccountCreateOperation, + Authority, + CreateClaimedAccountOperation, +) + +if TYPE_CHECKING: + from clive.__private.cli.types import AccountWithWeight, KeyWithWeight + from clive.__private.core.keys.keys import PublicKey + 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, + keys_with_weight: list[KeyWithWeight], + accounts_with_weight: list[AccountWithWeight], + ) -> None: + self._set_threshold(level, threshold) + for key, weight in keys_with_weight: + self._add_key_authority(level, key, weight) + for account_name, weight in accounts_with_weight: + self._add_account_authority(level, account_name, 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 + + 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) + + async def _run(self) -> None: + await super()._run() + 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_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 ad943c527a..476a3fd25a 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 8a7148d9c1..73e4f0453a 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 1a7555b118..d82eae599b 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 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 AccountWithWeight, KeyWithWeight + from clive.__private.core.keys.keys import PublicKey from clive.__private.models import Asset @@ -124,3 +129,34 @@ 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 account_with_weight(raw: str) -> AccountWithWeight: + account_name, weight = _parse_with_weight(raw, DEFAULT_AUTHORITY_WEIGHT) + return account_name, weight + + +def key_with_weight(raw: str) -> KeyWithWeight: + public_key_raw, weight = _parse_with_weight(raw, DEFAULT_AUTHORITY_WEIGHT) + return public_key(public_key_raw), weight diff --git a/clive/__private/cli/exceptions.py b/clive/__private/cli/exceptions.py index 9a60d8b65d..04c0bc27cd 100644 --- a/clive/__private/cli/exceptions.py +++ b/clive/__private/cli/exceptions.py @@ -462,3 +462,16 @@ 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) diff --git a/clive/__private/cli/process/main.py b/clive/__private/cli/process/main.py index 54d26117bf..cdfd1ed1f6 100644 --- a/clive/__private/cli/process/main.py +++ b/clive/__private/cli/process/main.py @@ -8,6 +8,11 @@ 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 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 +25,16 @@ 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.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 +149,101 @@ 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: str | None = typer.Option( + None, + "--owner", + parser=public_key, + help="Owner public key that will be set for account.", + ), + active_option: str | None = typer.Option( + None, + "--active", + parser=public_key, + help="Active public key that will be set for account.", + ), + posting_option: str | None = typer.Option( + None, + "--posting", + parser=public_key, + 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.", + ), + 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 + """ + from clive.__private.cli.commands.process.process_account_creation import ProcessAccountCreation # noqa: PLC0415 + from clive.__private.core.keys.keys import PublicKey # noqa: PLC0415 + + if keys is not None: + owner, active, posting, memo = (cast("PublicKey", key) for key in keys) + else: + # because in typer complex tuple types are not supported + owner, active, posting, memo = None, None, None, None + owner_option_ = cast("PublicKey | None", owner_option) + active_option_ = cast("PublicKey | None", active_option) + posting_option_ = cast("PublicKey | None", posting_option) + memo_option_ = cast("PublicKey | None", memo_option) + + 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, + ) + account_creation_command.set_keys( + EnsureSingleValue[PublicKey]("owner").of(owner, owner_option_), + EnsureSingleValue[PublicKey]("active").of(active, active_option_), + EnsureSingleValue[PublicKey]("posting").of(posting, posting_option_), + ) + account_creation_command.set_memo_key(EnsureSingleValue[PublicKey]("memo").of(memo, memo_option_)) + await account_creation_command.run() diff --git a/clive/__private/cli/types.py b/clive/__private/cli/types.py index ba401c4094..0c0f5dba53 100644 --- a/clive/__private/cli/types.py +++ b/clive/__private/cli/types.py @@ -5,10 +5,14 @@ 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 AccountWithWeight = tuple[str, int] + type KeyWithWeight = tuple[PublicKey, int] + AuthorityType = Literal["owner", "active", "posting"] diff --git a/clive/__private/core/constants/cli.py b/clive/__private/core/constants/cli.py index 612cc8f8dc..a53c3025da 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 2171fe88b1..e02564ed80 100644 --- a/pydoclint-errors-baseline.txt +++ b/pydoclint-errors-baseline.txt @@ -139,6 +139,8 @@ 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: str | None, 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: str | None, posting_option: str | None, save_file: str | None, sign_with: str | None]. -------------------- clive/__private/cli/process/proxy.py DOC101: Function `process_proxy_set`: Docstring contains fewer arguments than in function signature. -- GitLab From c6501a3fe31aa3b683383bee29def1c8bb8d9e3e Mon Sep 17 00:00:00 2001 From: Marcin Sobczyk Date: Thu, 2 Oct 2025 09:59:03 +0000 Subject: [PATCH 2/4] Tests for new command --- .../clive_local_tools/cli/cli_tester.py | 37 +++- .../process/test_process_account_creation.py | 176 ++++++++++++++++++ 2 files changed, 212 insertions(+), 1 deletion(-) create mode 100644 tests/functional/cli/process/test_process_account_creation.py 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 170bf1b70a..72f177d150 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 0000000000..b5976f1c23 --- /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) -- GitLab From 49172fbcef96a987e6a9dd785ac2e0aa981970dd Mon Sep 17 00:00:00 2001 From: Marcin Sobczyk Date: Thu, 2 Oct 2025 11:23:54 +0000 Subject: [PATCH 3/4] Add method post_run in ExternalCLICommand --- .../cli/commands/abc/contextual_cli_command.py | 1 + .../cli/commands/abc/external_cli_command.py | 5 +++++ .../cli/commands/process/process_account_creation.py | 12 ++++++------ 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/clive/__private/cli/commands/abc/contextual_cli_command.py b/clive/__private/cli/commands/abc/contextual_cli_command.py index fe7414f673..4ecbeb8aa1 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 84d001bf02..98f0a8d6a5 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 index c607f5e98b..ff314beecf 100644 --- a/clive/__private/cli/commands/process/process_account_creation.py +++ b/clive/__private/cli/commands/process/process_account_creation.py @@ -135,6 +135,12 @@ class ProcessAccountCreation(OperationCommand): 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 @@ -199,12 +205,6 @@ class ProcessAccountCreation(OperationCommand): def _is_authority_set(auth: Authority) -> bool: return bool(auth.account_auths) or bool(auth.key_auths) - async def _run(self) -> None: - await super()._run() - 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_threshold(self, level: AuthorityLevelRegular, threshold: int) -> None: self._get_authority(level).weight_threshold = threshold -- GitLab From 4a22d1a8a4ba9f18a6e8bf6e924475b4822e5b19 Mon Sep 17 00:00:00 2001 From: Marcin Sobczyk Date: Thu, 2 Oct 2025 11:36:43 +0000 Subject: [PATCH 4/4] Allow to create new account with multiple accounts/keys in authority --- .../process/process_account_creation.py | 16 ++-- clive/__private/cli/common/parsers.py | 20 ++--- clive/__private/cli/exceptions.py | 15 ++++ clive/__private/cli/process/main.py | 81 +++++++++++++------ clive/__private/cli/types.py | 3 +- pydoclint-errors-baseline.txt | 4 +- 6 files changed, 94 insertions(+), 45 deletions(-) diff --git a/clive/__private/cli/commands/process/process_account_creation.py b/clive/__private/cli/commands/process/process_account_creation.py index ff314beecf..1ba987a9a0 100644 --- a/clive/__private/cli/commands/process/process_account_creation.py +++ b/clive/__private/cli/commands/process/process_account_creation.py @@ -10,6 +10,7 @@ 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, @@ -18,8 +19,7 @@ from clive.__private.models.schemas import ( ) if TYPE_CHECKING: - from clive.__private.cli.types import AccountWithWeight, KeyWithWeight - from clive.__private.core.keys.keys import PublicKey + from clive.__private.cli.types import KeyOrAccountWithWeight from clive.__private.core.types import AuthorityLevelRegular @@ -112,14 +112,14 @@ class ProcessAccountCreation(OperationCommand): self, level: AuthorityLevelRegular, threshold: int, - keys_with_weight: list[KeyWithWeight], - accounts_with_weight: list[AccountWithWeight], + entries: list[KeyOrAccountWithWeight], ) -> None: self._set_threshold(level, threshold) - for key, weight in keys_with_weight: - self._add_key_authority(level, key, weight) - for account_name, weight in accounts_with_weight: - self._add_account_authority(level, account_name, weight) + 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: diff --git a/clive/__private/cli/common/parsers.py b/clive/__private/cli/common/parsers.py index d82eae599b..0dfbec5bc7 100644 --- a/clive/__private/cli/common/parsers.py +++ b/clive/__private/cli/common/parsers.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, get_args import typer -from clive.__private.cli.exceptions import CLIPublicKeyInvalidFormatError +from clive.__private.cli.exceptions import CLIParsingAuthorityKeyOrAccountError, CLIPublicKeyInvalidFormatError from clive.__private.core.constants.cli import DEFAULT_AUTHORITY_WEIGHT, WEIGHT_MARK if TYPE_CHECKING: @@ -13,7 +13,7 @@ if TYPE_CHECKING: from datetime import timedelta from decimal import Decimal - from clive.__private.cli.types import AccountWithWeight, KeyWithWeight + from clive.__private.cli.types import KeyOrAccountWithWeight from clive.__private.core.keys.keys import PublicKey from clive.__private.models import Asset @@ -152,11 +152,13 @@ def _parse_with_weight(raw: str, default_weight: int) -> tuple[str, int]: return raw, default_weight -def account_with_weight(raw: str) -> AccountWithWeight: - account_name, weight = _parse_with_weight(raw, DEFAULT_AUTHORITY_WEIGHT) - return account_name, 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 -def key_with_weight(raw: str) -> KeyWithWeight: - public_key_raw, weight = _parse_with_weight(raw, DEFAULT_AUTHORITY_WEIGHT) - return public_key(public_key_raw), weight + 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 04c0bc27cd..f5393a7261 100644 --- a/clive/__private/cli/exceptions.py +++ b/clive/__private/cli/exceptions.py @@ -475,3 +475,18 @@ class CLIPublicKeyInvalidFormatError(CLIPrettyError): 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 cdfd1ed1f6..b088f82ec6 100644 --- a/clive/__private/cli/process/main.py +++ b/clive/__private/cli/process/main.py @@ -12,7 +12,8 @@ from clive.__private.cli.common.parameters import argument_related_options, modi from clive.__private.cli.common.parameters.ensure_single_value import ( EnsureSingleValue, ) -from clive.__private.cli.common.parsers import public_key +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 @@ -34,6 +35,7 @@ from clive.__private.core.constants.cli import ( 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 @@ -169,22 +171,22 @@ async def process_account_creation( # noqa: PLR0913 f" have default values of {DEFAULT_AUTHORITY_THRESHOLD} and {DEFAULT_AUTHORITY_WEIGHT}.", metavar=FOUR_PUBLIC_KEYS_METAVAR, ), - owner_option: str | None = typer.Option( + owner_option: list[str] | None = typer.Option( None, "--owner", - parser=public_key, + parser=public_key_or_account_with_weight, help="Owner public key that will be set for account.", ), - active_option: str | None = typer.Option( + active_option: list[str] | None = typer.Option( None, "--active", - parser=public_key, + parser=public_key_or_account_with_weight, help="Active public key that will be set for account.", ), - posting_option: str | None = typer.Option( + posting_option: list[str] | None = typer.Option( None, "--posting", - parser=public_key, + parser=public_key_or_account_with_weight, help="Posting public key that will be set for account.", ), memo_option: str | None = typer.Option( @@ -203,6 +205,18 @@ async def process_account_creation( # noqa: PLR0913 "--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 @@ -211,24 +225,24 @@ async def process_account_creation( # noqa: PLR0913 """ Simplified version of command for account creation where only 4 keys are required. - Example: clive process account-creation "alice-$(date +%s)" --fee + 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 - from clive.__private.core.keys.keys import PublicKey # noqa: PLC0415 - - if keys is not None: - owner, active, posting, memo = (cast("PublicKey", key) for key in keys) - else: - # because in typer complex tuple types are not supported - owner, active, posting, memo = None, None, None, None - owner_option_ = cast("PublicKey | None", owner_option) - active_option_ = cast("PublicKey | None", active_option) - posting_option_ = cast("PublicKey | None", posting_option) - memo_option_ = cast("PublicKey | None", memo_option) account_creation_command = ProcessAccountCreation( creator=creator, @@ -240,10 +254,27 @@ async def process_account_creation( # noqa: PLR0913 save_file=save_file, autosign=autosign, ) - account_creation_command.set_keys( - EnsureSingleValue[PublicKey]("owner").of(owner, owner_option_), - EnsureSingleValue[PublicKey]("active").of(active, active_option_), - EnsureSingleValue[PublicKey]("posting").of(posting, posting_option_), - ) - account_creation_command.set_memo_key(EnsureSingleValue[PublicKey]("memo").of(memo, memo_option_)) + + 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 0c0f5dba53..705e7db881 100644 --- a/clive/__private/cli/types.py +++ b/clive/__private/cli/types.py @@ -11,8 +11,7 @@ if TYPE_CHECKING: AccountUpdateFunction = Callable[[AccountUpdate2Operation], AccountUpdate2Operation] AuthorityUpdateFunction = Callable[[Authority], Authority] - type AccountWithWeight = tuple[str, int] - type KeyWithWeight = tuple[PublicKey, int] + type KeyOrAccountWithWeight = tuple[str | PublicKey, int] AuthorityType = Literal["owner", "active", "posting"] diff --git a/pydoclint-errors-baseline.txt b/pydoclint-errors-baseline.txt index e02564ed80..41ddca07b7 100644 --- a/pydoclint-errors-baseline.txt +++ b/pydoclint-errors-baseline.txt @@ -140,7 +140,9 @@ clive/__private/cli/process/main.py 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: str | None, 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: str | None, posting_option: str | None, save_file: str | None, sign_with: str | None]. + 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. -- GitLab