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)