From 1af07f96ba9f90e234467e5639440653c9df8d04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= Date: Mon, 15 Dec 2025 10:28:07 +0100 Subject: [PATCH 1/9] Adjust docstring --- clive/__private/settings/_settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clive/__private/settings/_settings.py b/clive/__private/settings/_settings.py index 553652c6a8..63a4239ce2 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. -- GitLab From 69919a0f578ee0174f3a05d3c5d3765b54214cb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= Date: Thu, 11 Dec 2025 09:16:32 +0000 Subject: [PATCH 2/9] Add CLAUDE.md with project documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add comprehensive project overview and architecture documentation - Document CLI and TUI structure with reference to docs/cli_commands_structure.md - Include testing patterns, fixtures, and CI configuration - Document code style, conventions, and development guidelines - Add known accounts, tracked accounts, and profile system details - Reference configuration sources instead of hardcoded values 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- CLAUDE.md | 295 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..b59dfa3eb0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,295 @@ +# 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 + +## 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 + +```bash +# Run smoke test +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 + +# Run all tests in parallel (default process count set in .gitlab-ci.yml) +pytest -n 16 + +# Run unit tests only +pytest tests/unit/ + +# Run functional tests (CLI or TUI) +pytest tests/functional/cli/ +pytest tests/functional/tui/ + +# Run a single test file (example) +pytest tests/unit/test_date_utils.py + +# 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 + +### 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) -- GitLab From 2edd1a70207917bc3c84899d380420e10ce43b8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= Date: Mon, 15 Dec 2025 10:53:51 +0000 Subject: [PATCH 3/9] Add Claude Code slash commands for common workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add /smoke, /lint, /test, and /reflection commands to streamline development workflows like running smoke tests, linting, and pytest. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .claude/commands/lint.md | 13 +++++++++ .claude/commands/reflection.md | 48 ++++++++++++++++++++++++++++++++++ .claude/commands/smoke.md | 9 +++++++ .claude/commands/test.md | 28 ++++++++++++++++++++ 4 files changed, 98 insertions(+) create mode 100644 .claude/commands/lint.md create mode 100644 .claude/commands/reflection.md create mode 100644 .claude/commands/smoke.md create mode 100644 .claude/commands/test.md diff --git a/.claude/commands/lint.md b/.claude/commands/lint.md new file mode 100644 index 0000000000..dc7ec82026 --- /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 0000000000..5368d98e52 --- /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 0000000000..10230b31e8 --- /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 0000000000..0ec397fc32 --- /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 -- GitLab From f28af530361d66db6ecccb16864a2420b65add6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= Date: Mon, 15 Dec 2025 11:23:23 +0000 Subject: [PATCH 4/9] Document Claude Code slash commands and simplify Testing section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add new "Claude Code Commands" section documenting /smoke, /lint, /test, /reflection - Document test shortcuts (unit, cli, tui, functional) - Simplify Testing section by referencing slash commands 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b59dfa3eb0..9c71840cdf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,6 +22,17 @@ 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 @@ -67,23 +78,10 @@ mypy clive/ tests/ ### Testing -```bash -# Run smoke test -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 - -# Run all tests in parallel (default process count set in .gitlab-ci.yml) -pytest -n 16 - -# Run unit tests only -pytest tests/unit/ - -# Run functional tests (CLI or TUI) -pytest tests/functional/cli/ -pytest tests/functional/tui/ - -# Run a single test file (example) -pytest tests/unit/test_date_utils.py +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 -- GitLab From 35484a0434c18e15b458d666c9e3e3854f2303a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= Date: Mon, 15 Dec 2025 13:03:47 +0000 Subject: [PATCH 5/9] Add useful GitLab CLI commands to CLAUDE.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added glab commands for common MR and pipeline operations. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 9c71840cdf..707610c792 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -283,6 +283,24 @@ TUI test patterns (`tests/functional/tui/`): - 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: -- GitLab From a6458636442fbe3c6391ac209fa88061ef00832f Mon Sep 17 00:00:00 2001 From: Marcin Sobczyk Date: Fri, 19 Dec 2025 10:35:29 +0000 Subject: [PATCH 6/9] Exclude artifacts when using generated testnet block_log --- .../clive_local_tools/testnet_block_log/prepared_data.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 647c5387c8..ad63e72fab 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: -- GitLab From bffac26b4d832f5e9a020910ba99154d1a59cb1f Mon Sep 17 00:00:00 2001 From: Marcin Sobczyk Date: Fri, 9 Jan 2026 13:02:36 +0000 Subject: [PATCH 7/9] Add memo encryption and decryption CLI commands Add core commands for encrypting and decrypting memos using Hive blockchain encryption. Includes: - EncryptMemo and DecryptMemo commands in core - EncryptMemoWithAccountNames for resolving account names to keys - CLI decrypt command (clive crypt decrypt) - --encrypt-memo option for transfer command Co-Authored-By: Claude Opus 4.5 --- .../__private/cli/commands/crypt/__init__.py | 1 + clive/__private/cli/commands/crypt/decrypt.py | 54 ++++++++++++ .../cli/commands/process/transfer.py | 44 +++++++++- .../parameters/argument_related_options.py | 2 + .../cli/common/parameters/options.py | 5 +- clive/__private/cli/crypt/__init__.py | 1 + clive/__private/cli/crypt/main.py | 36 ++++++++ clive/__private/cli/main.py | 2 + clive/__private/core/commands/commands.py | 72 ++++++++++++++++ clive/__private/core/commands/decrypt_memo.py | 61 +++++++++++++ clive/__private/core/commands/encrypt_memo.py | 56 ++++++++++++ .../encrypt_memo_with_account_names.py | 85 +++++++++++++++++++ pydoclint-errors-baseline.txt | 4 + 13 files changed, 421 insertions(+), 2 deletions(-) create mode 100644 clive/__private/cli/commands/crypt/__init__.py create mode 100644 clive/__private/cli/commands/crypt/decrypt.py create mode 100644 clive/__private/cli/crypt/__init__.py create mode 100644 clive/__private/cli/crypt/main.py create mode 100644 clive/__private/core/commands/decrypt_memo.py create mode 100644 clive/__private/core/commands/encrypt_memo.py create mode 100644 clive/__private/core/commands/encrypt_memo_with_account_names.py diff --git a/clive/__private/cli/commands/crypt/__init__.py b/clive/__private/cli/commands/crypt/__init__.py new file mode 100644 index 0000000000..9d48db4f9f --- /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 0000000000..909d17bdc7 --- /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 5302f5287e..536e8c739a 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 40537daa3e..64804a1386 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 c957e38ed1..f80ab0b9a8 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 0000000000..9d48db4f9f --- /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 0000000000..21dc3de245 --- /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 56675ccc77..623221d826 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 68135c41fa..8cbbd7ab4c 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 0000000000..8e2b6a80a4 --- /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 0000000000..3d48b52789 --- /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 0000000000..d330652bff --- /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/pydoclint-errors-baseline.txt b/pydoclint-errors-baseline.txt index cde6ffd639..262696036b 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]. -- GitLab From 934cb674396991f058486a1883127d7125132b7f Mon Sep 17 00:00:00 2001 From: Marcin Sobczyk Date: Fri, 9 Jan 2026 13:03:00 +0000 Subject: [PATCH 8/9] Update testnet block_log with encrypted memo transfer Regenerate block_log to include a transfer with encrypted memo for testing memo decryption functionality. Co-Authored-By: Claude Opus 4.5 --- .../block_log_with_config/.gitignore | 1 + .../alternate-chain-spec.json | 2 +- .../block_log_with_config/block_log_part.0001 | Bin 25867 -> 26636 bytes .../block_log_part.0001.artifacts | Bin 1808 -> 0 bytes .../block_log_with_config/config.ini | 2 +- .../testnet_block_log/constants.py | 3 +++ .../testnet_block_log/generate_block_log.py | 17 +++++++++++++++++ 7 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 tests/clive-local-tools/clive_local_tools/testnet_block_log/block_log_with_config/.gitignore delete mode 100644 tests/clive-local-tools/clive_local_tools/testnet_block_log/block_log_with_config/block_log_part.0001.artifacts 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 0000000000..5de0ee5124 --- /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 473cd2c7d8..9fc2b5134c 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 GIT binary patch delta 19933 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 delta 19121 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 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 GIT binary patch literal 0 HcmV?d00001 literal 1808 zcmeZdU|=vevouXLHBC-Ru{5SPxL50B^$Zn zf5Dw;%iiaFJ`rgQ5nmTRvE9`oy~uk;zv0okOZ)fOK*YJuezrKYt>l4Rvczxuu%P}Y zwh-}B<)Eak{O_#Nxps@lpWo>(ZVwU9opZ9S?sn1lti?NQeot#z?&|;%pBV7uS>Mis z&o}>P*eJ!-F}KtaB7XJwg^K)phqiscZ~VaG;OyrMoFL-o^c3^f-pY=N<-hAvnx__rDMEnigk?UTu z5(>U@HNQ;0hpKFLgNR!TBwTvc*KoY;beFh$?814|-67(k4mRicPemS;)9~hbx8C;1 z1rLb$0+Ii3ls@l}ocnL)@A%cx6CQyQ3shI?wV7;`^E-JG<99EJ`aJjj zQ)}Ncevn+8yGigS?;?I5h`92ZsbO<%zDtGu2;OxvYpuSzFGO5&&rb%EKnpg>6FI>v zSr;s~^Mi;tT~ymT>u{jc!5spLq3$XoA^s5Y&#FF4MZQ)!$3Cw5mr%P->N*7h%_P#{v zl1Ly#y->@t)D6s@@|#Zu-j#RQYuo}A4_Ntn{i78TYrc6uWGPJVU;G&=?)H1*mUO0* z^TN|x+4nB!eVY^nG3TK68F%*IYm%c}0(|*2J47x)#lJO9cMgd3S$A>v&sFQ&!lj&o zA?np$H~DF>I~@7Q(^7o*1?I_HWh?{YdMwu2)t6k^Ua zhV!}K&p5QnrkI?FSGIj}4=Qf;aAQkERde03ph>$7^U7;{!yxKcM}AqjOe{KR9*d^j zgcq*rC!ykLRT4iQUOL^t^klW-y@xCdUBe;j^Y(sEIbHr)!F}7So(DO1L=Hm59|b&~ zJkLs{;GJS)+Ziq&Er$q*`h&AA%wDWqUCW%@=HcST&$I_BK6BUcn;|jr&(bZN*JNid zn`;pXQJ=oD-u#D-C-ddD`u9)n<<)J0itBw1JIf+>YHjwiv&()hnsVD93Znk7WD)BI z-o9eqbq>E|dD1P{K*a-9vY428c^qTB+ZTVxFX5MohNy4bdUk6ZZ#@rBnPa}4?}_=- zq2k68KUN$lh_^VjVW0bKOA$Z87>N2KlN_~vZV0~KA|uvky7qHi4^&)g&3AeJAKN;d z=KN!k-1DoQGZvyg@0rt2QQyZuxu0+FUesQ;y%j1xC4*m#hTdOzN%|}np>;zyXGth-p!H%%W@MS>i0~3 zEmbYeUcz4WWntB$$W@P^;s;)ORjIY<^!6~up6U^JJ1-&;qTXe*<*6x}3kyuTpEIAm znVEbJDjraA?8?7eYxy(T?`B^2n)k~o38Fsp*CC1i$PQWEKBi|JH7AX?L&a}oP td 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) -- GitLab From 2b4b6290b4f042e029cfe50b680c31abdb3ed4ad Mon Sep 17 00:00:00 2001 From: Marcin Sobczyk Date: Fri, 9 Jan 2026 13:03:18 +0000 Subject: [PATCH 9/9] Add tests for memo encryption and decryption Add CLI tests covering: - Encrypting memos during transfer - Decrypting memos from account history - Error handling for invalid keys and memos Co-Authored-By: Claude Opus 4.5 --- .../clive_local_tools/cli/cli_tester.py | 3 + tests/functional/cli/encryption/__init__.py | 1 + .../cli/encryption/test_memo_encryption.py | 192 ++++++++++++++++++ 3 files changed, 196 insertions(+) create mode 100644 tests/functional/cli/encryption/__init__.py create mode 100644 tests/functional/cli/encryption/test_memo_encryption.py diff --git a/tests/clive-local-tools/clive_local_tools/cli/cli_tester.py b/tests/clive-local-tools/clive_local_tools/cli/cli_tester.py index 0efa119635..8ebbd799ab 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/functional/cli/encryption/__init__.py b/tests/functional/cli/encryption/__init__.py new file mode 100644 index 0000000000..9d48db4f9f --- /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 0000000000..54fa09eda1 --- /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) -- GitLab