diff --git a/.claude/commands/lint.md b/.claude/commands/lint.md new file mode 100644 index 0000000000000000000000000000000000000000..dc7ec8202625c688b1813bd01c405bee41495b7c --- /dev/null +++ b/.claude/commands/lint.md @@ -0,0 +1,13 @@ +Run all linting and formatting checks on the codebase. + +Execute this command: + +```bash +pre-commit run --all-files +``` + +Report the results: + +- If all checks pass, confirm success +- If checks fail, summarize which hooks failed and the key issues found +- For auto-fixable issues (like formatting), mention if files were modified diff --git a/.claude/commands/reflection.md b/.claude/commands/reflection.md new file mode 100644 index 0000000000000000000000000000000000000000..5368d98e52c039fa6a601cfecedf49e2df1f613d --- /dev/null +++ b/.claude/commands/reflection.md @@ -0,0 +1,48 @@ +You are an expert in prompt engineering, specializing in optimizing AI code assistant instructions. Your task is to +analyze and improve the instructions for Claude Code. Follow these steps carefully: + +1. Analysis Phase: Review the chat history in your context window. + +Then, examine the current Claude instructions, commands and config /CLAUDE.md /.claude/commands/\* +\*\*/CLAUDE.md .claude/settings.json .claude/settings.local.json + +Analyze the chat history, instructions, commands and config to identify areas that could be improved. Look for: + +- Inconsistencies in Claude's responses +- Misunderstandings of user requests +- Areas where Claude could provide more detailed or accurate information +- Opportunities to enhance Claude's ability to handle specific types of queries or tasks +- New commands or improvements to a commands name, function or response +- MCPs we've approved locally that we should add to the config, especially if we've added new tools or require them + for the command to work + +2. Interaction Phase: Present your findings and improvement ideas to the human. For each suggestion: a) Explain the + current issue you've identified b) Propose a specific change or addition to the instructions c) Describe how this + change would improve Claude's performance + +Wait for feedback from the human on each suggestion before proceeding. If the human approves a change, move it to the +implementation phase. If not, refine your suggestion or move on to the next idea. + +3. Implementation Phase: For each approved change: a) Clearly state the section of the instructions you're modifying b) + Present the new or modified text for that section c) Explain how this change addresses the issue identified in the + analysis phase + +4. Output Format: Present your final output in the following structure: + + +[List the issues identified and potential improvements] + + + +[For each approved improvement: +1. Section being modified +2. New or modified instruction text +3. Explanation of how this addresses the identified issue] + + + [Present the complete, updated set of instructions for Claude, incorporating all approved changes] + + +Remember, your goal is to enhance Claude's performance and consistency while maintaining the core functionality and +purpose of the AI assistant. Be thorough in your analysis, clear in your explanations, and precise in your +implementations. diff --git a/.claude/commands/smoke.md b/.claude/commands/smoke.md new file mode 100644 index 0000000000000000000000000000000000000000..10230b31e84ca9917a4fb5fe629e9fe5b025ab97 --- /dev/null +++ b/.claude/commands/smoke.md @@ -0,0 +1,9 @@ +Run the smoke test to quickly verify basic CLI functionality. + +Execute this command: + +```bash +pytest -n 2 tests/functional/cli/show/test_show_account.py::test_show_account tests/functional/cli/process/test_process_transfer.py::test_process_transfer +``` + +Report the results concisely - whether tests passed or failed, and any errors encountered. diff --git a/.claude/commands/test.md b/.claude/commands/test.md new file mode 100644 index 0000000000000000000000000000000000000000..0ec397fc322fe41ef2ed883c773e5f06b8b88889 --- /dev/null +++ b/.claude/commands/test.md @@ -0,0 +1,28 @@ +Run pytest with the provided arguments. + +Arguments: $ARGUMENTS + +First, expand any shortcuts in the arguments: + +- `unit` → `tests/unit/` +- `cli` → `tests/functional/cli/` +- `tui` → `tests/functional/tui/` +- `functional` → `tests/functional/` + +Then execute pytest with the expanded path. Examples: + +- `/test unit` runs `pytest tests/unit/` +- `/test cli` runs `pytest tests/functional/cli/` +- `/test tests/unit/test_date_utils.py -v` runs as-is (full path provided) + +If no arguments provided, run all tests with parallel execution: + +```bash +pytest -n 16 +``` + +Report results concisely: + +- Number of tests passed/failed/skipped +- For failures, show the test name and a brief summary of the error +- Suggest next steps if tests fail diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000000000000000000000000000000000..707610c7924d480a86aacc6ad7b400868c28500d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,311 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +**Clive** is a CLI and TUI (Terminal User Interface) application for interacting with the Hive blockchain. It's written +in Python and designed to replace the original Hive CLI for power users and testing. The TUI features mouse-based +navigation inspired by midnight commander. + +- **Main entry point**: `clive` - automatically launches TUI when run without arguments, or CLI mode when arguments + are provided like `clive` for TUI and `clive --help` for CLI +- **Development entry point**: `clive-dev` - includes extra debugging information +- **Python version**: (see `requires-python` in `pyproject.toml`, restricted due to wax dependency) +- **Build system**: Poetry +- **Main branch**: `develop` (use this for PRs, not `main`) + +## GitLab Instance + +This project uses **gitlab.syncad.com**, NOT gitlab.com. + +- Repository: https://gitlab.syncad.com/hive/clive +- Use `glab api "projects/hive%2Fclive/..."` for API calls + +## Claude Code Commands + +Available slash commands for development workflows: + +| Command | Description | +| -------------- | ------------------------------------------------------------- | +| `/smoke` | Run smoke test | +| `/lint` | Run all pre-commit hooks | +| `/test ` | Run pytest with shortcuts: `unit`, `cli`, `tui`, `functional` | +| `/reflection` | Analyze and improve Claude Code configuration | + +## Essential Commands + +### Installation and Setup + +```bash +# Install dependencies (must be run from repository root) +poetry install + +# When updating dependencies and hive submodule is updated also, test-tools should be forced to uninstall first +pip uninstall -y test-tools && poetry install +``` + +### Running Clive + +```bash +# Launch TUI (default) +clive + +# Use CLI mode +clive --help +clive show profile +clive configure profile create + +# Development mode with debug info (useful for presenting full stack-trace instead of pretty errors in CLI) +clive-dev +``` + +### Linting and Formatting + +```bash +# Run all pre-commit hooks (include tools like ruff, mypy and additional hooks) +pre-commit run --all-files + +# Lint with Ruff +ruff check clive/ tests/ + +# Format code +ruff format clive/ tests/ + +# Type checking +mypy clive/ tests/ +``` + +### Testing + +Use `/smoke`, `/lint`, and `/test` slash commands for common workflows (see +[Claude Code Commands](#claude-code-commands)). + +```bash +# Run a specific test (example) +pytest tests/unit/test_date_utils.py::test_specific_function -v + +# Run with timeout (important for tests that may hang) +pytest --timeout=600 + +# Run without parallelization (for debugging) +pytest -n 0 +``` + +**Note**: Tests require the embedded testnet dependencies. Some tests spawn local Hive nodes using `test-tools`. + +## Architecture + +### Configuration + +**Global settings** can be configured via: + +1. **Settings files** (in order of precedence): + + - `~/.clive/settings.toml` (user settings, higher priority) + - `{project_root}/settings.toml` (project defaults) + +2. **Environment variables** override settings files using the format: + + ```bash + CLIVE_{GROUP}__{KEY}=value + ``` + + Example: `CLIVE_NODE__CHAIN_ID=abc123` overrides `[NODE] CHAIN_ID` in settings.toml + +3. **Special environment variable**: `CLIVE_DATA_PATH` controls the Clive data directory (default: `~/.clive`). This + also determines where user settings are loaded from (`$CLIVE_DATA_PATH/settings.toml`). + +**Per-profile settings** are stored separately for each profile and configured via `clive configure`: + +- Tracked accounts (working account + watched accounts) +- Known accounts (and enable/disable feature) +- Key aliases +- Node address +- Chain ID + +See `clive configure --help` for all available options. + +### Core Architecture Pattern: Command Pattern + +Clive uses a **Command Pattern** for all operations that interact with the blockchain or beekeeper: + +- **Commands location**: `clive/__private/core/commands/` +- **Base classes**: All commands inherit from `Command` (in `abc/command.py`) +- **Execution**: Commands are async and executed via `await command.execute()` +- **Command hierarchy**: + - `Command` - Base class with `_execute()` method + - `CommandWithResult` - Commands that return a value + - `CommandRestricted` - Base for commands with execution preconditions + - `CommandInUnlocked` - Commands requiring unlocked user wallet + - `CommandEncryption` - Commands requiring both unlocked user wallet and encryption wallet + - `CommandPasswordSecured` - Commands requiring a password + - `CommandDataRetrieval` - Commands that fetch data from the node + - `CommandCachedDataRetrieval` - Data retrieval with caching support + +### World Object - Application Container + +`World` (`clive/__private/core/world.py`) is the top-level container and single source of truth: + +- `world.profile` - Current user profile (settings, accounts, keys) +- `world.node` - Hive node connection for API calls +- `world.commands` - Access to all command instances +- `world.beekeeper_manager` - Manages beekeeper (key storage) lifecycle +- `world.app_state` - Application state (locked/unlocked, etc.) + +**Important**: Direct `world.node` API calls should be avoided in CLI/TUI. Use `world.commands` instead, which handles +errors properly. + +### Profile System + +Profiles (`clive/__private/core/profile.py`) store user configuration: + +- **Working account**: The currently active Hive account +- **Watched accounts**: Accounts being monitored +- **Known accounts**: Accounts approved for transactions. CLI requires explicit addition before broadcasting + operations (configurable via `enable`/`disable`). TUI automatically adds accounts when operations are added to cart + (also configurable). Managed via `clive configure known-account` +- **Key aliases**: Named public keys +- **Transaction**: Pending transaction operations +- **Node address**: Hive node endpoint +- **Chain ID**: Blockchain identifier (like a mainnet/mirrornet/testnet) + +Profiles are persisted to disk via `PersistentStorageService` with encryption support. + +**Note**: Tracked accounts is a combination of working account and watched accounts. + +### Dual Interface Architecture + +**CLI Mode** (`clive/__private/cli/`): + +- Built with **Typer** for command-line interface +- Main command groups: `configure`, `show`, `process`, `beekeeper`, `generate`, `unlock`, `lock` +- For complete CLI command structure, see `docs/cli_commands_structure.md` +- CLI implementation in `clive/__private/cli/` +- Most of the commands —especially those that interact with profile— require Beekeeper (via the + `CLIVE_BEEKEEPER__REMOTE_ADDRESS` and `CLIVE_BEEKEEPER__SESSION_TOKEN` environment variables) for profile encryption + and decryption. +- Commands `clive beekeeper spawn` and `clive beekeeper create-session` can be used for preparing the CLI environment. + +**TUI Mode** (`clive/__private/ui/`): + +- Built with **Textual** (Python TUI framework) +- Main app: `clive/__private/ui/app.py` (Clive class) +- Screens in `clive/__private/ui/screens/` +- Widgets in `clive/__private/ui/widgets/` +- Styling: TCSS (a Textual variation of CSS) files stored as .scss due to better syntax highlighting +- TUI can be used in environment where Beekeeper is already running (`CLIVE_BEEKEEPER__REMOTE_ADDRESS` and + `CLIVE_BEEKEEPER__SESSION_TOKEN` env vars are set), but without them, beekeeper will be automatically spawned and + session will be created when starting TUI. + +### Beekeeper Integration + +Clive uses **beekeepy** (async Python wrapper) to communicate with Hive's beekeeper for key management: + +- **BeekeeperManager**: `clive/__private/core/beekeeper_manager.py` +- Beekeeper stores keys in encrypted wallets +- Beekeeper wallets are stored in the `~/.clive/beekeeper` directory (or `$CLIVE_DATA_PATH/beekeeper` if customized) +- Two wallet types: user wallets (for signing) and encryption wallets (for encrypting profile data) +- Wallets must be unlocked before use +- Beekeeper address and session token can be pointed with respective setting in the `settings.toml` file or via env + var that would have higher precedence + +### Blockchain Communication + +- **Node interaction**: `clive/__private/core/node/node.py` +- **API wrapper**: `clive/__private/core/node/async_hived/` - async wrapper around Hive node APIs +- **Wax integration**: Uses `hiveio-wax` for transaction building and signing +- **Operation models**: `clive/__private/models/schemas.py` - Pydantic models for Hive operations + +### Storage and Migrations + +- **Storage service**: `clive/__private/storage/service/service.py` +- **Converters**: Runtime models ↔ Storage models +- **Migrations**: `clive/__private/storage/migrations/` - versioned profile schema migrations +- Profiles are stored as encrypted files (location can controlled by `CLIVE_DATA_PATH` environment variable, default: + `~/.clive/data/`) + +## Test Organization + +Tests are organized into two main categories: + +- **`tests/unit/`** - Unit tests for individual components (keys, storage, commands, etc.) +- **`tests/functional/`** - Functional tests split by interface: + - `functional/cli/` - CLI command tests + - `functional/tui/` - TUI interaction tests + +**Test fixtures and patterns**: + +Common fixtures (`tests/conftest.py`): + +- `world` - Async World instance +- `beekeeper` - Async beekeeper instance from beekeepy (spawned automatically) +- `node` - Local testnet node (spawned automatically via test-tools) + +CLI test patterns (`tests/functional/cli/`): + +- Uses `CLITester` from `clive-local-tools` package +- `cli_tester` fixture - Provides typed CLI testing interface with command invocation and output checking + +TUI test patterns (`tests/functional/tui/`): + +- Uses `ClivePilot` (Textual's async test driver) +- `prepared_env` fixture - Returns `(node, wallet, pilot)` tuple with TUI ready on Unlock screen +- `prepared_tui_on_dashboard` fixture - TUI already authenticated and on Dashboard screen +- `node_with_wallet` fixture - Test node with initialized wallet +- Tests interact with TUI via pilot (e.g., `pilot.click()`, `pilot.press()`) + +## Development Guidelines + +### Code Style + +- **Strict mypy**: Type hints are required and strictly enforced +- **Ruff**: Comprehensive linting with "ALL" rules (see `pyproject.toml` for ignored rules) +- **Future imports**: All files must have `from __future__ import annotations` (enforced by ruff) +- **Docstrings**: Google style, checked by pydoclint (no redundant type hints in docstrings) +- **Line length**: See `line-length` in `pyproject.toml` (currently 120 characters) + +### Important Conventions + +1. **Private modules**: Implementation details are in `__private/` directories +2. **No direct initialization**: Some classes (like `Profile`) use factory methods instead of `__init__` +3. **Command pattern**: Always use commands for blockchain operations, not direct API calls +4. **Async context managers**: Many resources (World, Node) require async context manager usage +5. **Settings**: Use `safe_settings` from `clive/__private/settings` for reading configuration + +### When Working With Tests + +- Pytest tests use `test-tools` from the Hive submodule for spawning local nodes +- For manual tests, local testnet node can be started manually via `testnet_node.py` +- Both `testnet_node.py` and `test-tools` based tests require executables from the `hive` submodule pointed by the + `HIVE_BUILD_ROOT_PATH` environment variable +- Beekeeper is spawned automatically by test fixtures when needed +- Tests modify settings to use test directories, not user's actual Clive data +- The `clive-local-tools` package provides test helpers and checkers + +### Useful Commands + +#### GitLab CLI (glab) + +```bash +# Find MR for a branch +glab mr list --source-branch= + +# Add comment to MR +glab mr note --message "..." + +# Get pipeline job details +glab api "projects/hive%2Fclive/pipelines//jobs" + +# Get job logs +glab api "projects/hive%2Fclive/jobs//trace" +``` + +### CI Environment + +Tests run in CI with: + +- Parallel processes configurable via `PYTEST_NUMBER_OF_PROCESSES` (see `.gitlab-ci.yml`, default: 16) +- Timeout configurable via `PYTEST_TIMEOUT_MINUTES` (typically 10 minutes for most test suites) +- Separate jobs for unit tests, CLI tests, and TUI tests +- Tests run against installed wheel (not editable install) diff --git a/clive/__private/cli/commands/crypt/__init__.py b/clive/__private/cli/commands/crypt/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..9d48db4f9f85e1752cf424c49ee18a6907c3f160 --- /dev/null +++ b/clive/__private/cli/commands/crypt/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/clive/__private/cli/commands/crypt/decrypt.py b/clive/__private/cli/commands/crypt/decrypt.py new file mode 100644 index 0000000000000000000000000000000000000000..909d17bdc73c5e4001a79828597ae5e42abf2b15 --- /dev/null +++ b/clive/__private/cli/commands/crypt/decrypt.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from clive.__private.cli.commands.abc.world_based_command import WorldBasedCommand +from clive.__private.cli.exceptions import CLIPrettyError +from clive.__private.cli.print_cli import print_cli +from clive.__private.core.commands.decrypt_memo import DecodeEncryptedMemoError, DecryptMemoKeyNotImportedError + + +class CLIDecodeEncryptedMemoError(CLIPrettyError): + def __init__(self, encrypted_memo: str) -> None: + message = f"Failed to decode encrypted memo. Memo might have invalid format, received: '{encrypted_memo}'" + super().__init__(message) + + +class CLIDecryptMemoKeyNotImportedError(CLIPrettyError): + def __init__(self) -> None: + super().__init__("Failed to decrypt memo. You might not have the required memo key in your wallet.") + + +class CLIInvalidEncryptedMemoFormatError(CLIPrettyError): + def __init__(self) -> None: + super().__init__("The memo does not appear to be encrypted. Encrypted memos start with '#'.") + + +@dataclass(kw_only=True) +class Decrypt(WorldBasedCommand): + """Decrypt and show an encrypted memo using the memo key from the wallet. + + Attributes: + encrypted_memo: The encrypted memo to decrypt. + """ + + encrypted_memo: str + + async def validate(self) -> None: + self._validate_encrypted_memo_format() + await super().validate() + + async def _run(self) -> None: + try: + result = await self.world.commands.decrypt_memo(encrypted_memo=self.encrypted_memo) + decrypted = result.result_or_raise + except DecodeEncryptedMemoError as error: + raise CLIDecodeEncryptedMemoError(self.encrypted_memo) from error + except DecryptMemoKeyNotImportedError as error: + raise CLIDecryptMemoKeyNotImportedError from error + + print_cli(f"Decrypted memo: '{decrypted}'") + + def _validate_encrypted_memo_format(self) -> None: + if not self.encrypted_memo.startswith("#"): + raise CLIInvalidEncryptedMemoFormatError diff --git a/clive/__private/cli/commands/process/transfer.py b/clive/__private/cli/commands/process/transfer.py index 5302f5287e1364ae52d3e6a424f24a94c7b13da9..536e8c739a8fe5f5677d17b4117e03c606059ef0 100644 --- a/clive/__private/cli/commands/process/transfer.py +++ b/clive/__private/cli/commands/process/transfer.py @@ -4,6 +4,9 @@ from dataclasses import dataclass from typing import TYPE_CHECKING from clive.__private.cli.commands.abc.operation_command import OperationCommand +from clive.__private.cli.exceptions import CLIPrettyError +from clive.__private.core.commands.encrypt_memo import EncryptMemoKeyNotImportedError +from clive.__private.core.commands.encrypt_memo_with_account_names import AccountNotFoundForEncryptionError from clive.__private.models.schemas import TransferOperation if TYPE_CHECKING: @@ -11,6 +14,16 @@ if TYPE_CHECKING: from clive.__private.models.asset import Asset +class CLIEncryptMemoKeyNotImportedError(CLIPrettyError): + def __init__(self) -> None: + super().__init__("Failed to encrypt memo. You might not have the required memo key in your wallet.") + + +class CLIAccountNotFoundForEncryptionError(CLIPrettyError): + def __init__(self, account_name: str) -> None: + super().__init__(f"Cannot encrypt memo: account '{account_name}' was not found on the blockchain.") + + @dataclass(kw_only=True) class Transfer(OperationCommand): from_account: str @@ -19,9 +32,38 @@ class Transfer(OperationCommand): memo: str async def _create_operations(self) -> ComposeTransaction: + memo = await self._maybe_encrypt_memo() yield TransferOperation( from_=self.from_account, to=self.to, amount=self.amount, - memo=self.memo, + memo=memo, ) + + async def _maybe_encrypt_memo(self) -> str: + """ + Encrypt the memo if it starts with '#'. + + Returns: + The encrypted memo if it starts with '#', otherwise the original memo. + + Raises: + CLIEncryptMemoKeyNotImportedError: If encryption fails because memo key is not imported. + CLIAccountNotFoundForEncryptionError: If sender or receiver account doesn't exist. + """ + if not self.memo.startswith("#"): + return self.memo + + try: + # Encrypt the memo using account names + encrypted = await self.world.commands.encrypt_memo_with_account_names( + content=self.memo, + from_account=self.from_account, + to_account=self.to, + ) + except EncryptMemoKeyNotImportedError as error: + raise CLIEncryptMemoKeyNotImportedError from error + except AccountNotFoundForEncryptionError as error: + raise CLIAccountNotFoundForEncryptionError(error.account_name) from error + else: + return encrypted.result_or_raise diff --git a/clive/__private/cli/common/parameters/argument_related_options.py b/clive/__private/cli/common/parameters/argument_related_options.py index 40537daa3e3f913a5980afe9e4bb22ba0a136cbd..64804a1386830204926e20ba862212ecedba12dd 100644 --- a/clive/__private/cli/common/parameters/argument_related_options.py +++ b/clive/__private/cli/common/parameters/argument_related_options.py @@ -90,3 +90,5 @@ memo_key = _make_argument_related_option( help="Memo public key that will be set for account.", ) ) + +encoded_text = _make_argument_related_option("--encoded-text") diff --git a/clive/__private/cli/common/parameters/options.py b/clive/__private/cli/common/parameters/options.py index c957e38ed15385c853a498353ef66bad4ab3d69b..f80ab0b9a8a557b90c1b8de4503407b68a58b35a 100644 --- a/clive/__private/cli/common/parameters/options.py +++ b/clive/__private/cli/common/parameters/options.py @@ -96,7 +96,10 @@ percent = typer.Option( memo_text = typer.Option( "", "--memo", - help="The memo to attach to the transfer.", + help=stylized_help( + "The memo to attach to the transfer. If it starts with '#', it will be encrypted." + " You must have sender memo key in your wallet to perform encryption." + ), ) memo_text_optional = modified_param(memo_text, default=None) diff --git a/clive/__private/cli/crypt/__init__.py b/clive/__private/cli/crypt/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..9d48db4f9f85e1752cf424c49ee18a6907c3f160 --- /dev/null +++ b/clive/__private/cli/crypt/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/clive/__private/cli/crypt/main.py b/clive/__private/cli/crypt/main.py new file mode 100644 index 0000000000000000000000000000000000000000..21dc3de2459ca59b436569c07e641371e4d91923 --- /dev/null +++ b/clive/__private/cli/crypt/main.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import typer + +from clive.__private.cli.clive_typer import CliveTyper +from clive.__private.cli.common.parameters import argument_related_options +from clive.__private.cli.common.parameters.ensure_single_value import EnsureSingleValue +from clive.__private.cli.common.parameters.styling import stylized_help + +crypt = CliveTyper(name="crypt", help="Commands for cryptographic operations (encryption and decryption).") + + +_encoded_text_argument = typer.Argument( + None, + help=stylized_help("The encoded text to decrypt (starts with '#').", required_as_arg_or_option=True), +) + + +@crypt.command(name="decrypt") +async def decrypt_memo( + encoded_text: str | None = _encoded_text_argument, + encoded_text_option: str | None = argument_related_options.encoded_text, +) -> None: + """ + Decrypt an encrypted memo. + + Memo is encrypted using the memo keys of sender and receiver of operation. You must have one of + those private keys in wallet to encrypt/decrypt memo. Other key is public. + + Example: + clive crypt decrypt "#encoded_text_string" + clive crypt decrypt --encoded-text "#encoded_text_string" + """ + from clive.__private.cli.commands.crypt.decrypt import Decrypt # noqa: PLC0415 + + await Decrypt(encrypted_memo=EnsureSingleValue("encoded-text").of(encoded_text, encoded_text_option)).run() diff --git a/clive/__private/cli/main.py b/clive/__private/cli/main.py index 56675ccc77540fab6913a45897a7398bcdde3817..623221d826c7a6d4920e4243a6a9e02822092f2d 100644 --- a/clive/__private/cli/main.py +++ b/clive/__private/cli/main.py @@ -10,6 +10,7 @@ from clive.__private.cli.common.parameters import argument_related_options from clive.__private.cli.common.parameters.ensure_single_value import EnsureSingleProfileNameValue from clive.__private.cli.common.parameters.styling import stylized_help from clive.__private.cli.configure.main import configure +from clive.__private.cli.crypt.main import crypt from clive.__private.cli.generate.main import generate from clive.__private.cli.print_cli import print_cli from clive.__private.cli.process.main import process @@ -26,6 +27,7 @@ cli.add_typer(show) cli.add_typer(process) cli.add_typer(beekeeper) cli.add_typer(generate) +cli.add_typer(crypt) @cli.callback(invoke_without_command=True) diff --git a/clive/__private/core/commands/commands.py b/clive/__private/core/commands/commands.py index 68135c41faaf78647c3b6935fb7f38a201dadae8..8cbbd7ab4cc8ed0a4db4bf402dfdfeffcaca0bc2 100644 --- a/clive/__private/core/commands/commands.py +++ b/clive/__private/core/commands/commands.py @@ -172,6 +172,78 @@ class Commands[WorldT: World]: ) ) + async def encrypt_memo( + self, *, content: str, from_key: PublicKey, to_key: PublicKey + ) -> CommandWithResultWrapper[str]: + """ + Encrypt a memo using the provided memo keys. + + Args: + content: The memo content to encrypt. + from_key: The sender's memo public key. + to_key: The recipient's memo public key. + + Returns: + A wrapper containing the encrypted memo string. + """ + from clive.__private.core.commands.encrypt_memo import EncryptMemo # noqa: PLC0415 + + return await self.__surround_with_exception_handlers( + EncryptMemo( + unlocked_wallet=self._world.beekeeper_manager.user_wallet, + content=content, + from_key=from_key, + to_key=to_key, + ) + ) + + async def encrypt_memo_with_account_names( + self, *, content: str, from_account: str, to_account: str + ) -> CommandWithResultWrapper[str]: + """ + Encrypt a memo using the sender's and recipient's memo keys. + + Args: + content: The memo content to encrypt. + from_account: The sender's account name. + to_account: The recipient's account name. + + Returns: + A wrapper containing the encrypted memo string. + """ + from clive.__private.core.commands.encrypt_memo_with_account_names import ( # noqa: PLC0415 + EncryptMemoWithAccountNames, + ) + + return await self.__surround_with_exception_handlers( + EncryptMemoWithAccountNames( + unlocked_wallet=self._world.beekeeper_manager.user_wallet, + content=content, + from_account=from_account, + to_account=to_account, + node=self._world.node, + ) + ) + + async def decrypt_memo(self, *, encrypted_memo: str) -> CommandWithResultWrapper[str]: + """ + Decrypt an encrypted memo. + + Args: + encrypted_memo: The encrypted memo string (starts with '#'). + + Returns: + A wrapper containing the decrypted memo content. + """ + from clive.__private.core.commands.decrypt_memo import DecryptMemo # noqa: PLC0415 + + return await self.__surround_with_exception_handlers( + DecryptMemo( + unlocked_wallet=self._world.beekeeper_manager.user_wallet, + encrypted_memo=encrypted_memo, + ) + ) + async def unlock( self, *, profile_name: str | None = None, password: str, time: timedelta | None = None, permanent: bool = True ) -> CommandWithResultWrapper[UnlockWalletStatus]: diff --git a/clive/__private/core/commands/decrypt_memo.py b/clive/__private/core/commands/decrypt_memo.py new file mode 100644 index 0000000000000000000000000000000000000000..8e2b6a80a45d1daeb7b658f92e66ca81670913d4 --- /dev/null +++ b/clive/__private/core/commands/decrypt_memo.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from dataclasses import dataclass + +import beekeepy.exceptions as bke + +from clive.__private.core.commands.abc.command import Command, CommandError +from clive.__private.core.commands.abc.command_in_unlocked import CommandInUnlocked +from clive.__private.core.commands.abc.command_with_result import CommandWithResult +from clive.__private.core.keys import PublicKey +from wax import decode_encrypted_memo as wax_decode_encrypted_memo +from wax._private.result_tools import to_cpp_string, to_python_string + + +class DecodeEncryptedMemoError(CommandError): + def __init__(self, command: Command) -> None: + super().__init__(command, "Failed to decode the memo.") + + +class DecryptMemoKeyNotImportedError(CommandError): + def __init__(self, command: Command) -> None: + super().__init__(command, "Failed to decrypt the memo because the memo key was not found in wallet.") + + +@dataclass(kw_only=True) +class DecryptMemo(CommandInUnlocked, CommandWithResult[str]): + """ + Decrypt an encrypted memo using the memo key. + + Attributes: + encrypted_memo: The encrypted memo (should start with '#'). + """ + + encrypted_memo: str + + async def _execute(self) -> None: + try: + # Decode the encrypted memo to extract keys and content + decoded = wax_decode_encrypted_memo(to_cpp_string(self.encrypted_memo)) + except RuntimeError as error: + if "Could not load the crypto memo" in str(error): + raise DecodeEncryptedMemoError(self) from error + raise + + from_key = PublicKey.create(to_python_string(decoded.main_encryption_key)) + to_key = PublicKey.create(to_python_string(decoded.other_encryption_key)) + encrypted_content = to_python_string(decoded.encrypted_content) + try: + # Decrypt using beekeeper + decrypted = await self.unlocked_wallet.decrypt_data( + from_key=from_key.value, + to_key=to_key.value, + content=encrypted_content, + ) + except bke.ErrorInResponseError as error: + if "Decryption failed" in str(error): + raise DecryptMemoKeyNotImportedError(self) from error + raise + + # Remove the leading '#' if present (it's part of the original content) + self._result = decrypted.removeprefix("#") diff --git a/clive/__private/core/commands/encrypt_memo.py b/clive/__private/core/commands/encrypt_memo.py new file mode 100644 index 0000000000000000000000000000000000000000..3d48b5278959b098cd3f8e7d601370d6a10e1efb --- /dev/null +++ b/clive/__private/core/commands/encrypt_memo.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import beekeepy.exceptions as bke + +from clive.__private.core.commands.abc.command import Command, CommandError +from clive.__private.core.commands.abc.command_in_unlocked import CommandInUnlocked +from clive.__private.core.commands.abc.command_with_result import CommandWithResult +from wax import encode_encrypted_memo as wax_encode_encrypted_memo +from wax._private.result_tools import to_cpp_string, to_python_string + +if TYPE_CHECKING: + from clive.__private.core.keys import PublicKey + + +class EncryptMemoKeyNotImportedError(CommandError): + def __init__(self, command: Command) -> None: + message = "Failed to encrypt the memo because the memo key is not imported." + super().__init__(command, message) + + +@dataclass(kw_only=True) +class EncryptMemo(CommandInUnlocked, CommandWithResult[str]): + """ + Encrypt a memo using the memo keys. + + Attributes: + content: The memo content to encrypt (should start with '#'). + from_key: The sender's memo public key. + to_key: The recipient's memo public key. + """ + + content: str + from_key: PublicKey + to_key: PublicKey + + async def _execute(self) -> None: + try: + # Encrypt the content using beekeeper + encrypted_content = await self.unlocked_wallet.encrypt_data( + from_key=self.from_key.value, + to_key=self.to_key.value, + content=self.content, + ) + except bke.NotExistingKeyError as error: + raise EncryptMemoKeyNotImportedError(self) from error + + # Encode the encrypted memo with keys using wax + encoded_memo = wax_encode_encrypted_memo( + to_cpp_string(encrypted_content), + to_cpp_string(self.from_key.value), + to_cpp_string(self.to_key.value), + ) + self._result = to_python_string(encoded_memo) diff --git a/clive/__private/core/commands/encrypt_memo_with_account_names.py b/clive/__private/core/commands/encrypt_memo_with_account_names.py new file mode 100644 index 0000000000000000000000000000000000000000..d330652bffd262667e2406dcaa337b32752ff1d9 --- /dev/null +++ b/clive/__private/core/commands/encrypt_memo_with_account_names.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from clive.__private.core.commands.abc.command import Command, CommandError +from clive.__private.core.commands.abc.command_in_unlocked import CommandInUnlocked +from clive.__private.core.commands.abc.command_with_result import CommandWithResult +from clive.__private.core.commands.encrypt_memo import EncryptMemo +from clive.__private.core.keys import PublicKey + +if TYPE_CHECKING: + from clive.__private.core.node.node import Node + + +class AccountNotFoundForEncryptionError(CommandError): + def __init__(self, command: Command, account_name: str) -> None: + self.account_name = account_name + super().__init__(command, f"Account '{account_name}' was not found on the blockchain.") + + +@dataclass(kw_only=True) +class EncryptMemoWithAccountNames(CommandInUnlocked, CommandWithResult[str]): + """ + Encrypt a memo by looking up accounts and using their memo keys. + + Attributes: + content: The memo content to encrypt. + from_account: The sender's account name. + to_account: The recipient's account name. + node: The node to use for account lookup. + """ + + content: str + from_account: str + to_account: str + node: Node + + async def _execute(self) -> None: + # Find accounts on the blockchain + from clive.__private.core.commands.find_accounts import AccountNotFoundError, FindAccounts # noqa: PLC0415 + + find_accounts_command = FindAccounts( + node=self.node, + accounts=[self.from_account, self.to_account], + ) + try: + accounts = await find_accounts_command.execute_with_result() + except AccountNotFoundError as error: + # Extract account name from error message + account_name = self._extract_missing_account_name(str(error)) + raise AccountNotFoundForEncryptionError(self, account_name) from error + + # Extract memo keys (FindAccounts ensures both accounts exist) + from_account_data = next(acc for acc in accounts if acc.name == self.from_account) + to_account_data = next(acc for acc in accounts if acc.name == self.to_account) + + from_memo_key = PublicKey.create(from_account_data.memo_key) + to_memo_key = PublicKey.create(to_account_data.memo_key) + + # Encrypt using the memo keys + encrypt_command = EncryptMemo( + unlocked_wallet=self.unlocked_wallet, + content=self.content, + from_key=from_memo_key, + to_key=to_memo_key, + ) + self._result = await encrypt_command.execute_with_result() + + def _extract_missing_account_name(self, error_message: str) -> str: + """ + Extract account name from AccountNotFoundError message. + + Args: + error_message: The error message from AccountNotFoundError. + + Returns: + The account name that was not found, or "unknown" if it cannot be determined. + """ + # Message format: "Account {account} not found on node ..." + if self.from_account in error_message: + return self.from_account + if self.to_account in error_message: + return self.to_account + return "unknown" diff --git a/clive/__private/settings/_settings.py b/clive/__private/settings/_settings.py index 553652c6a817c3213f434d453514e06339178274..63a4239ce2781fb657e26548d6eb6d5194e9f49b 100644 --- a/clive/__private/settings/_settings.py +++ b/clive/__private/settings/_settings.py @@ -50,7 +50,7 @@ class Settings: The environment variable ```bash - CLIVE__FIRST_GROUP__NESTED_GROUP__SOME_KEY=124 + CLIVE_FIRST_GROUP__NESTED_GROUP__SOME_KEY=124 ``` would override the setting in the file. diff --git a/pydoclint-errors-baseline.txt b/pydoclint-errors-baseline.txt index cde6ffd639fff6aed4180dd9ac642532eece73c2..262696036bb46336c101d210e2c68f3423a34b13 100644 --- a/pydoclint-errors-baseline.txt +++ b/pydoclint-errors-baseline.txt @@ -91,6 +91,10 @@ clive/__private/cli/configure/working_account.py DOC101: Function `switch_working_account`: Docstring contains fewer arguments than in function signature. DOC103: Function `switch_working_account`: 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, account_name_option: str | None]. -------------------- +clive/__private/cli/crypt/main.py + DOC101: Function `decrypt_memo`: Docstring contains fewer arguments than in function signature. + DOC103: Function `decrypt_memo`: 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: [encoded_text: str | None, encoded_text_option: str | None]. +-------------------- clive/__private/cli/generate/main.py DOC101: Function `generate_key_from_seed`: Docstring contains fewer arguments than in function signature. DOC103: Function `generate_key_from_seed`: 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 | None, account_name_option: str | None, only_private_key: bool, only_public_key: bool, role: AuthorityLevel | None, role_option: AuthorityLevel | None]. diff --git a/tests/clive-local-tools/clive_local_tools/cli/cli_tester.py b/tests/clive-local-tools/clive_local_tools/cli/cli_tester.py index 0efa11963501d1a2aa94eaf198fe031448fb8984..8ebbd799ab30abbfe19169ba9c6100bfd2ec82ef 100644 --- a/tests/clive-local-tools/clive_local_tools/cli/cli_tester.py +++ b/tests/clive-local-tools/clive_local_tools/cli/cli_tester.py @@ -687,3 +687,6 @@ class CLITester: return self.__invoke_command_with_options( ["show", "pending", "decline-voting-rights"], account_name=account_name ) + + def crypt_decrypt(self, *, encrypted_memo: str) -> CLITestResult: + return self.__invoke_command_with_options(["crypt", "decrypt"], (encrypted_memo,)) diff --git a/tests/clive-local-tools/clive_local_tools/testnet_block_log/block_log_with_config/.gitignore b/tests/clive-local-tools/clive_local_tools/testnet_block_log/block_log_with_config/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..5de0ee5124346521115937b9e616b4c580430d83 --- /dev/null +++ b/tests/clive-local-tools/clive_local_tools/testnet_block_log/block_log_with_config/.gitignore @@ -0,0 +1 @@ +*artifacts diff --git a/tests/clive-local-tools/clive_local_tools/testnet_block_log/block_log_with_config/alternate-chain-spec.json b/tests/clive-local-tools/clive_local_tools/testnet_block_log/block_log_with_config/alternate-chain-spec.json index 473cd2c7d852df056adfe9e2c0c2f032ee3dd262..9fc2b5134c117a48c40422801be7652dc491f143 100644 --- a/tests/clive-local-tools/clive_local_tools/testnet_block_log/block_log_with_config/alternate-chain-spec.json +++ b/tests/clive-local-tools/clive_local_tools/testnet_block_log/block_log_with_config/alternate-chain-spec.json @@ -1,5 +1,5 @@ { - "genesis_time": 1749451951, + "genesis_time": 1767788556, "hardfork_schedule": [ { "block_num": 1, diff --git a/tests/clive-local-tools/clive_local_tools/testnet_block_log/block_log_with_config/block_log_part.0001 b/tests/clive-local-tools/clive_local_tools/testnet_block_log/block_log_with_config/block_log_part.0001 index e71cd96615740cfa53d0e5fa665eb046a58bb4aa..2c59298f8d01ec8646ea3ae74b3a1174df4ac0a1 100644 Binary files a/tests/clive-local-tools/clive_local_tools/testnet_block_log/block_log_with_config/block_log_part.0001 and b/tests/clive-local-tools/clive_local_tools/testnet_block_log/block_log_with_config/block_log_part.0001 differ diff --git a/tests/clive-local-tools/clive_local_tools/testnet_block_log/block_log_with_config/block_log_part.0001.artifacts b/tests/clive-local-tools/clive_local_tools/testnet_block_log/block_log_with_config/block_log_part.0001.artifacts deleted file mode 100644 index 5b94ecd488616ca0ef78c9f28a2a3715e2e991ca..0000000000000000000000000000000000000000 Binary files a/tests/clive-local-tools/clive_local_tools/testnet_block_log/block_log_with_config/block_log_part.0001.artifacts and /dev/null differ diff --git a/tests/clive-local-tools/clive_local_tools/testnet_block_log/block_log_with_config/config.ini b/tests/clive-local-tools/clive_local_tools/testnet_block_log/block_log_with_config/config.ini index 9685d470e2e60115663905338438de8d64a8ae93..d43169a42d58844372763c9fe03885aacc41e3e2 100644 --- a/tests/clive-local-tools/clive_local_tools/testnet_block_log/block_log_with_config/config.ini +++ b/tests/clive-local-tools/clive_local_tools/testnet_block_log/block_log_with_config/config.ini @@ -1,6 +1,6 @@ # config automatically generated by helpy enable-stale-production = yes -log-logger = {"name":"default","level":"debug","appender":"stderr"} {"name":"user","level":"debug","appender":"stderr"} {"name":"chainlock","level":"debug","appender":"p2p"} {"name":"sync","level":"debug","appender":"p2p"} {"name":"p2p","level":"debug","appender":"p2p"} +log-logger = {"name":"default","level":"info","appender":"stderr"} {"name":"user","level":"info","appender":"stderr"} {"name":"chainlock","level":"info","appender":"p2p"} {"name":"sync","level":"info","appender":"p2p"} {"name":"p2p","level":"info","appender":"p2p"} p2p-endpoint = 0.0.0.0:0 plugin = account_by_key plugin = account_by_key_api diff --git a/tests/clive-local-tools/clive_local_tools/testnet_block_log/constants.py b/tests/clive-local-tools/clive_local_tools/testnet_block_log/constants.py index 8545f6c4ee2dd3e78695d74fc54b2b61040a9c64..9aae52499a890507387f4c316a1f6fe48409e1e3 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 @@ -61,3 +61,6 @@ ALT_WORKING_ACCOUNT2_DATA: Final[AccountData] = AccountData( KNOWN_EXCHANGES_NAMES: Final[list[str]] = [exchange.name for exchange in KnownExchanges()] WITNESS_ACCOUNT: Final[tt.Account] = WITNESSES[0] + +ENCRYPTED_MEMO_CONTENT: Final[str] = "This is a secret memo for testing encryption" +ACCOUNT_WITH_ENCRYPTED_MEMO_DATA: Final[AccountData] = ALT_WORKING_ACCOUNT1_DATA diff --git a/tests/clive-local-tools/clive_local_tools/testnet_block_log/generate_block_log.py b/tests/clive-local-tools/clive_local_tools/testnet_block_log/generate_block_log.py index fc76c6aa88815d7ead2173c6f75bb669d7d1ffff..c32293a1021f95da98c68698dc2b48c20f26171e 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 @@ -7,11 +7,13 @@ import test_tools as tt from clive.__private.core.date_utils import utc_now from clive_local_tools.testnet_block_log.constants import ( + ACCOUNT_WITH_ENCRYPTED_MEMO_DATA, ALT_WORKING_ACCOUNT1_DATA, ALT_WORKING_ACCOUNT2_DATA, BLOCK_LOG_WITH_CONFIG_DIRECTORY, CREATOR_ACCOUNT, EMPTY_ACCOUNT, + ENCRYPTED_MEMO_CONTENT, KNOWN_EXCHANGES_NAMES, PROPOSALS, WATCHED_ACCOUNTS_DATA, @@ -198,6 +200,20 @@ def prepare_votes_for_witnesses(wallet: tt.Wallet) -> None: wallet.api.vote_for_witness(ALT_WORKING_ACCOUNT2_DATA.account.name, WITNESSES[i].name, approve=True) +def create_transfer_with_encrypted_memo(node: tt.InitNode) -> None: + """Create a transfer with encrypted memo using OldWallet (cli_wallet encrypts memos starting with '#').""" + tt.logger.info("Creating transfer with encrypted memo...") + # OldWallet uses cli_wallet which automatically encrypts memos starting with '#' + old_wallet = tt.OldWallet(attach_to=node) + old_wallet.api.transfer( + CREATOR_ACCOUNT.name, + ACCOUNT_WITH_ENCRYPTED_MEMO_DATA.account.name, + tt.Asset.Test(1), + f"#{ENCRYPTED_MEMO_CONTENT}", + ) + old_wallet.close() + + def main() -> None: node = tt.InitNode() configure(node) @@ -217,6 +233,7 @@ def main() -> None: prepare_votes_for_witnesses(wallet) create_empty_account(wallet) create_known_exchange_accounts(wallet) + create_transfer_with_encrypted_memo(node) 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 647c5387c82b56ac3e5854aaa02e36d8a77850e7..ad63e72fab3f60ef891e112c89c1c1494a0fffd4 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 @@ -25,7 +25,11 @@ def get_alternate_chain_spec() -> tt.AlternateChainSpecs: def get_block_log() -> tt.BlockLog: - return tt.BlockLog(BLOCK_LOG_WITH_CONFIG_DIRECTORY) + """Makes copy of block_log, ensuring artifacts are excluded.""" + block_log = tt.BlockLog(BLOCK_LOG_WITH_CONFIG_DIRECTORY) + directory = tt.context.get_current_directory() / "block_log_copy" + directory.mkdir(exist_ok=True) + return block_log.copy_to(directory, artifacts="excluded") def get_time_control(block_log: tt.BlockLog) -> tt.StartTimeControl: diff --git a/tests/functional/cli/encryption/__init__.py b/tests/functional/cli/encryption/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..9d48db4f9f85e1752cf424c49ee18a6907c3f160 --- /dev/null +++ b/tests/functional/cli/encryption/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/tests/functional/cli/encryption/test_memo_encryption.py b/tests/functional/cli/encryption/test_memo_encryption.py new file mode 100644 index 0000000000000000000000000000000000000000..54fa09eda17c802b17ad41b5b162f2c5796f4bd9 --- /dev/null +++ b/tests/functional/cli/encryption/test_memo_encryption.py @@ -0,0 +1,192 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Final + +import pytest +import test_tools as tt + +from clive.__private.cli.commands.crypt.decrypt import ( + CLIDecryptMemoKeyNotImportedError, + CLIInvalidEncryptedMemoFormatError, +) +from clive.__private.cli.commands.process.transfer import CLIEncryptMemoKeyNotImportedError +from clive.__private.core.keys.keys import PrivateKey, PrivateKeyAliased +from clive.__private.models.schemas import TransferOperation +from clive_local_tools.checkers.blockchain_checkers import ( + assert_operations_placed_in_blockchain, + assert_transaction_in_blockchain, +) +from clive_local_tools.cli.checkers import assert_memo_key +from clive_local_tools.cli.exceptions import CLITestCommandError +from clive_local_tools.data.constants import ALT_WORKING_ACCOUNT1_KEY_ALIAS, WORKING_ACCOUNT_KEY_ALIAS +from clive_local_tools.helpers import get_formatted_error_message, get_transaction_id_from_output +from clive_local_tools.testnet_block_log.constants import ( + ACCOUNT_WITH_ENCRYPTED_MEMO_DATA, + ENCRYPTED_MEMO_CONTENT, + WATCHED_ACCOUNTS_DATA, + WORKING_ACCOUNT_NAME, +) + +if TYPE_CHECKING: + from clive_local_tools.cli.cli_tester import CLITester + +RECEIVER: Final[str] = WATCHED_ACCOUNTS_DATA[0].account.name +AMOUNT: Final[tt.Asset.HiveT] = tt.Asset.Hive(1) + + +def get_encrypted_memo_from_block_log(node: tt.RawNode) -> str: + """Get the encrypted memo from the pregenerated block_log.""" + account_history = node.api.account_history.get_account_history( + account=ACCOUNT_WITH_ENCRYPTED_MEMO_DATA.account.name, + ) + + for entry in account_history.history: + operation = entry[1].op + # Encrypted memo by convention starts with '#' + if isinstance(operation.value, TransferOperation) and operation.value.memo.startswith("#"): + return operation.value.memo + + pytest.fail("Encrypted memo not found in block_log") + + +async def test_process_transfer_with_encrypted_memo( + node: tt.RawNode, + cli_tester: CLITester, +) -> None: + """Check clive process transfer command encrypts memo when it starts with '#'.""" + # ARRANGE + memo_content = "#This is a secret memo" + + # ACT + result = cli_tester.process_transfer( + from_=WORKING_ACCOUNT_NAME, + amount=AMOUNT, + to=RECEIVER, + sign_with=WORKING_ACCOUNT_KEY_ALIAS, + memo=memo_content, + ) + + # ASSERT - the memo should be encrypted (starts with '#' followed by encoded data) + assert result.exit_code == 0 + + # Verify the transaction was placed in blockchain + assert_transaction_in_blockchain(node, result) + + # Get the transaction to check the memo + transaction_id = get_transaction_id_from_output(result.stdout) + node.wait_number_of_blocks(1) + transaction = node.api.account_history.get_transaction(id_=transaction_id, include_reversible=True) + + # Assert exactly one operation of type Transfer + assert len(transaction.operations) == 1, f"Expected 1 operation, got {len(transaction.operations)}" + op = transaction.operations[0] + assert isinstance(op.value, TransferOperation), f"Expected TransferOperation, got {type(op.value).__name__}" + + # Assert the memo is encrypted + assert op.value.memo.startswith("#"), "Encrypted memos start with '#' followed by the encoded key data" + assert len(op.value.memo) > len(memo_content), ( + "The encrypted memo should be longer than the original (contains keys + encrypted content)" + ) + + +async def test_process_transfer_with_plain_memo( + node: tt.RawNode, + cli_tester: CLITester, +) -> None: + """Check clive process transfer command does NOT encrypt memo when it doesn't start with '#'.""" + # ARRANGE + memo_content = "This is a plain memo" + operation = TransferOperation( + from_=WORKING_ACCOUNT_NAME, + to=RECEIVER, + amount=AMOUNT, + memo=memo_content, + ) + + # ACT + result = cli_tester.process_transfer( + from_=WORKING_ACCOUNT_NAME, + amount=AMOUNT, + to=RECEIVER, + sign_with=WORKING_ACCOUNT_KEY_ALIAS, + memo=memo_content, + ) + + # ASSERT + assert_operations_placed_in_blockchain(node, result, operation) + + +async def test_decrypt_memo_from_block_log( + node: tt.RawNode, + cli_tester: CLITester, +) -> None: + """Check clive crypt decrypt command correctly decrypts the memo from testnet block_log.""" + # ARRANGE + # Import memo key needed for decrypting + cli_tester.world.profile.keys.add_to_import( + PrivateKeyAliased( + value=ACCOUNT_WITH_ENCRYPTED_MEMO_DATA.account.private_key, alias=ALT_WORKING_ACCOUNT1_KEY_ALIAS + ) + ) + await cli_tester.world.commands.sync_data_with_beekeeper() + + encrypted_memo = get_encrypted_memo_from_block_log(node) + + # ACT + result = cli_tester.crypt_decrypt(encrypted_memo=encrypted_memo) + + # ASSERT + assert ENCRYPTED_MEMO_CONTENT in result.stdout, ( + f"Decrypted content does not match expected content, excepted {ENCRYPTED_MEMO_CONTENT}, got {result.stdout}" + ) + + +async def test_negative_transfer_with_encrypted_memo_when_no_memo_key_imported( + node: tt.RawNode, + cli_tester: CLITester, +) -> None: + """Check that clive process transfer fails when trying to encrypt memo without having the memo key imported.""" + # ARRANGE + memo_content = "#This is other secret memo" + + # Change memo key to a new key but don't import to beekeeper to test failure case + new_memo_key = PrivateKey.generate().calculate_public_key() + cli_tester.process_update_memo_key(account_name=WORKING_ACCOUNT_NAME, key=new_memo_key.value) + + # Verify the memo key was updated + node.wait_number_of_blocks(1) + assert_memo_key(cli_tester, new_memo_key.value) + + # ACT & ASSERT + with pytest.raises(CLITestCommandError, match=get_formatted_error_message(CLIEncryptMemoKeyNotImportedError())): + cli_tester.process_transfer( + from_=WORKING_ACCOUNT_NAME, + amount=AMOUNT, + to=RECEIVER, + sign_with=WORKING_ACCOUNT_KEY_ALIAS, + memo=memo_content, + ) + + +async def test_negative_decrypt_memo_fails_without_memo_key( + node: tt.RawNode, + cli_tester: CLITester, +) -> None: + """Check that clive crypt decrypt fails when the memo key is not imported.""" + # ARRANGE + # Do NOT import memo key - this is intentional to test the failure case + encrypted_memo = get_encrypted_memo_from_block_log(node) + + # ACT & ASSERT + with pytest.raises(CLITestCommandError, match=get_formatted_error_message(CLIDecryptMemoKeyNotImportedError())): + cli_tester.crypt_decrypt(encrypted_memo=encrypted_memo) + + +async def test_negative_decrypt_memo_fails_with_invalid_format(cli_tester: CLITester) -> None: + """Check that clive crypt decrypt fails when the memo doesn't start with '#'.""" + # ARRANGE + invalid_memo = "This is not an encrypted memo" + + # ACT & ASSERT + with pytest.raises(CLITestCommandError, match=get_formatted_error_message(CLIInvalidEncryptedMemoFormatError())): + cli_tester.crypt_decrypt(encrypted_memo=invalid_memo)