diff --git a/docs/cli/index.md b/docs/cli/index.md index aad5b52b..9a5902d2 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -80,20 +80,28 @@ - [status](#status) - [stop](#stop) - [task](#task) - - [wallet](#wallet) + - [vanity-address](#vanity-address) - [Options](#options-13) + - [-m, --match ](#-m---match-) + - [-o, --output ](#-o---output--1) + - [-a, --alias ](#-a---alias-) + - [-f, --output-file ](#-f---output-file-) + - [Arguments](#arguments-5) + - [KEYWORD](#keyword) + - [wallet](#wallet) + - [Options](#options-14) - [-a, --address ](#-a---address-) - [-m, --mnemonic](#-m---mnemonic) - [-f, --force](#-f---force) - - [Arguments](#arguments-5) - - [ALIAS_NAME](#alias_name) - [Arguments](#arguments-6) + - [ALIAS_NAME](#alias_name) + - [Arguments](#arguments-7) - [ALIAS](#alias) - - [Options](#options-14) + - [Options](#options-15) - [-f, --force](#-f---force-1) - - [Arguments](#arguments-7) + - [Arguments](#arguments-8) - [ALIAS](#alias-1) - - [Options](#options-15) + - [Options](#options-16) - [-f, --force](#-f---force-2) # algokit @@ -542,6 +550,53 @@ Collection of useful tasks to help you develop on Algorand. algokit task [OPTIONS] COMMAND [ARGS]... ``` +### vanity-address + +Generate a vanity Algorand address. Your KEYWORD can only include letters A - Z and numbers 2 - 7. +Keeping your KEYWORD under 5 characters will usually result in faster generation. +Note: The longer the KEYWORD, the longer it may take to generate a matching address. +Please be patient if you choose a long keyword. + +```shell +algokit task vanity-address [OPTIONS] KEYWORD +``` + +### Options + + +### -m, --match +Location where the keyword will be included. Default is start. + + +* **Options** + + start | anywhere | end + + + +### -o, --output +How the output will be presented. + + +* **Options** + + stdout | alias | file + + + +### -a, --alias +Alias for the address. Required if output is “alias”. + + +### -f, --output-file +File to dump the output. Required if output is “file”. + +### Arguments + + +### KEYWORD +Required argument + ### wallet Create short aliases for your addresses and accounts on AlgoKit CLI. diff --git a/docs/features/tasks.md b/docs/features/tasks.md new file mode 100644 index 00000000..56f0a54f --- /dev/null +++ b/docs/features/tasks.md @@ -0,0 +1,8 @@ +# AlgoKit Tasks + +AlgoKit Tasks are a collection of handy tasks that can be used to perform various operations on Algorand blockchain. + +## Features + +- [Wallet Aliasing](./tasks/wallet.md) - Manage your Algorand addresses and accounts effortlessly with the AlgoKit Wallet feature. This feature allows you to create short aliases for your addresses and accounts on AlgoKit CLI. +- [Vanity Address Generation](./tasks/vanity.md) - Generate vanity addresses for your Algorand accounts with the AlgoKit Vanity feature. This feature allows you to generate Algorand addresses with a custom prefix of your choice. diff --git a/docs/features/tasks/README.md b/docs/features/tasks/README.md deleted file mode 100644 index e69de29b..00000000 diff --git a/docs/features/tasks/vanity_address.md b/docs/features/tasks/vanity_address.md new file mode 100644 index 00000000..8937c716 --- /dev/null +++ b/docs/features/tasks/vanity_address.md @@ -0,0 +1,43 @@ +# AlgoKit Task Vanity Address + +The AlgoKit Vanity Address feature allows you to generate a vanity Algorand address. A vanity address is an address that contains a specific keyword in it. The keyword can only include uppercase letters A-Z and numbers 2-7. The longer the keyword, the longer it may take to generate a matching address. + +## Usage + +Available commands and possible usage as follows: + +```bash +$ ~ algokit task wallet +Usage: algokit task vanity-address [OPTIONS] KEYWORD + + Generate a vanity Algorand address. Your KEYWORD can only include letters A - Z and numbers 2 - 7. Keeping your + KEYWORD under 5 characters will usually result in faster generation. Note: The longer the KEYWORD, the longer it may + take to generate a matching address. Please be patient if you choose a long keyword. + +Options: + -m, --match [start|anywhere|end] Location where the keyword will be included. Default is start. + -o, --output [stdout|alias|file] How the output will be presented. + -a, --alias TEXT Alias for the address. Required if output is "alias". + -f, --output-file PATH File to dump the output. Required if output is "file". + -h, --help Show this message and exit. +``` + +## Examples + +Generate a vanity address with the keyword "ALGO" at the start of the address with default output to `stdout`: + +```bash +$ ~ algokit task vanity-address ALGO +``` + +Generate a vanity address with the keyword "ALGO" at the start of the address with output to a file: + +```bash +$ ~ algokit task vanity-address ALGO -o file -f vanity-address.txt +``` + +Generate a vanity address with the keyword "ALGO" anywhere in the address with output to a file: + +```bash +$ ~ algokit task vanity-address ALGO -m anywhere -o file -f vanity-address.txt +``` diff --git a/src/algokit/cli/task.py b/src/algokit/cli/task.py index 8a32f76b..114e1f04 100644 --- a/src/algokit/cli/task.py +++ b/src/algokit/cli/task.py @@ -2,6 +2,7 @@ import click +from algokit.cli.tasks.vanity_address import vanity_address from algokit.cli.tasks.wallet import wallet logger = logging.getLogger(__name__) @@ -13,3 +14,4 @@ def task_group() -> None: task_group.add_command(wallet) +task_group.add_command(vanity_address) diff --git a/src/algokit/cli/tasks/vanity_address.py b/src/algokit/cli/tasks/vanity_address.py new file mode 100644 index 00000000..38ef4969 --- /dev/null +++ b/src/algokit/cli/tasks/vanity_address.py @@ -0,0 +1,89 @@ +import json +import logging +import re +from pathlib import Path + +import click + +from algokit.core.tasks.vanity_address import MatchType, generate_vanity_address + +logger = logging.getLogger(__name__) + + +def validate_inputs( + keyword: str, + output: str, + alias: str | None, + output_file: str | None, +) -> None: + if not re.match("^[A-Z2-7]+$", keyword): + raise click.ClickException("Invalid KEYWORD. Allowed: uppercase letters A-Z and numbers 2-7.") + if output == "alias" and not alias: + raise click.ClickException( + "Please provide an alias using the '--alias' option when the output is set to 'alias'." + ) + if output == "file" and not output_file: + raise click.ClickException( + "Please provide an output filename using the '--output-file' option when the output is set to 'file'." + ) + + +@click.command( + name="vanity-address", + help="""Generate a vanity Algorand address. Your KEYWORD can only include letters A - Z and numbers 2 - 7. + Keeping your KEYWORD under 5 characters will usually result in faster generation. + Note: The longer the KEYWORD, the longer it may take to generate a matching address. + Please be patient if you choose a long keyword. + """, +) +@click.argument("keyword") +@click.option( + "--match", + "-m", + default=MatchType.START.value, + type=click.Choice([e.value for e in MatchType]), + help="Location where the keyword will be included. Default is start.", +) +@click.option( + "--output", + "-o", + required=False, + default="stdout", + type=click.Choice(["stdout", "alias", "file"]), + help="How the output will be presented.", +) +@click.option("--alias", "-a", required=False, help='Alias for the address. Required if output is "alias".') +@click.option( + "--output-file", + "-f", + required=False, + type=click.Path(), + help='File to dump the output. Required if output is "file".', +) +def vanity_address( + keyword: str, match: MatchType, output: str, alias: str | None = None, output_file: str | None = None +) -> None: + match = MatchType(match) # Force cast since click does not yet support enums as types + validate_inputs(keyword, output, alias, output_file) + + result_data = generate_vanity_address(keyword, match) + + if output == "stdout": + logger.warning( + "WARNING: Your mnemonic is displayed on the console. " + "Ensure its security by keeping it confidential." + "Consider clearing your terminal history after noting down the token.\n" + ) + click.echo(result_data) + + elif output == "alias" and alias is not None: + add_to_wallet(alias, result_data) + elif output == "file" and output_file is not None: + output_path = Path(output_file) + with output_path.open("w") as f: + json.dump(result_data, f, indent=4) + click.echo(f"Output written to {output_path.absolute()}") + + +def add_to_wallet(alias: str, output_data: dict) -> None: + logger.info(f"Adding {output_data} to wallet {alias}") diff --git a/src/algokit/core/tasks/vanity_address.py b/src/algokit/core/tasks/vanity_address.py new file mode 100644 index 00000000..f8fd5e8a --- /dev/null +++ b/src/algokit/core/tasks/vanity_address.py @@ -0,0 +1,106 @@ +import logging +import time +from enum import Enum +from multiprocessing import Manager, Pool, cpu_count +from multiprocessing.managers import DictProxy +from multiprocessing.synchronize import Event as EventClass +from timeit import default_timer as timer + +import algosdk +from algosdk import mnemonic + +logger = logging.getLogger(__name__) + +PROGRESS_REFRESH_INTERVAL_SECONDS = 5 + + +class MatchType(Enum): + START = "start" + ANYWHERE = "anywhere" + END = "end" + + +def _log_progress(shared_data: DictProxy, stop_event: EventClass, start_time: float) -> None: + """Logs progress of address matching at regular intervals.""" + last_log_time = start_time + + while not stop_event.is_set(): + total_count = sum(count.value for count in shared_data["counts"]) + if timer() - last_log_time >= PROGRESS_REFRESH_INTERVAL_SECONDS: + elapsed_time = timer() - start_time + logger.info( + f"Still searching for a match. Iterated over {total_count} addresses in {elapsed_time:.2f} seconds." + ) + last_log_time = timer() + time.sleep(1) + + +def _search_for_matching_address( + worker_id: int, keyword: str, match: MatchType, stop_event: EventClass, shared_data: DictProxy +) -> None: + """ + Searches for a matching address based on the specified keyword and matching criteria. + + Args: + keyword (str): The keyword to search for in the address. + match (MatchType): The matching criteria for the keyword. It can be "start" to match addresses that start with + the keyword, "anywhere" to match addresses that contain the keyword anywhere, + or "end" to match addresses that end with the keyword. + lock (LockBase): A multiprocessing lock object to synchronize access to the shared data. + stop_event (EventClass): A multiprocessing event object to stop the search when a match is found. + shared_data (DictProxy): A multiprocessing dictionary to share data between processes. + """ + + while not stop_event.is_set(): + private_key, address = algosdk.account.generate_account() # type: ignore[no-untyped-call] + generated_mnemonic = mnemonic.from_private_key(private_key) # type: ignore[no-untyped-call] + + match_conditions = { + MatchType.START: address.startswith(keyword), + MatchType.ANYWHERE: keyword in address, + MatchType.END: address.endswith(keyword), + } + + shared_data["counts"][worker_id].value += 1 + + if match_conditions.get(match, False): + stop_event.set() + shared_data.update({"mnemonic": generated_mnemonic, "address": address}) + return + + +def generate_vanity_address(keyword: str, match: MatchType) -> dict[str, str]: + """ + Generate a vanity address in the Algorand blockchain. + + Args: + keyword (str): The keyword to search for in the address. + match (MatchType): The matching criteria for the keyword. It can be "start" to match addresses that start with + the keyword, "anywhere" to match addresses that contain the keyword anywhere, + or "end" to match addresses that end with the keyword. + + Returns: + dict[str, str]: A dictionary containing the generated mnemonic and address + that match the specified keyword and matching criteria. + """ + + manager = Manager() + num_processes = cpu_count() + shared_data = manager.dict() + shared_data["counts"] = [manager.Value("i", 0) for _ in range(num_processes - 1)] + stop_event = manager.Event() + + start_time: float = timer() + with Pool(processes=num_processes) as pool: + for worker_id in range(num_processes - 1): + pool.apply_async(_search_for_matching_address, (worker_id, keyword, match, stop_event, shared_data)) + + # Start the logger process + pool.apply_async(_log_progress, (shared_data, stop_event, start_time)) + + pool.close() + pool.join() + + logger.debug(f"Vanity address generation time: {timer() - start_time:.2f} seconds") + + return {key: str(shared_data[key]) for key in ["mnemonic", "address"] if key in shared_data} diff --git a/tests/tasks/test_vanity_address.py b/tests/tasks/test_vanity_address.py new file mode 100644 index 00000000..c4ebc021 --- /dev/null +++ b/tests/tasks/test_vanity_address.py @@ -0,0 +1,74 @@ +import re +from pathlib import Path + +import pytest + +from tests.utils.approvals import verify +from tests.utils.click_invoker import invoke + + +def test_vanity_address_no_options() -> None: + result = invoke("task vanity-address") + + assert result.exit_code != 0 + verify(result.output) + + +def test_vanity_address_invalid_keyword() -> None: + result = invoke("task vanity-address test") + + assert result.exit_code != 0 + verify(result.output) + + +def test_vanity_address_invalid_input_on_file() -> None: + result = invoke("task vanity-address TEST -o file") + + assert result.exit_code != 0 + verify(result.output) + + +def test_vanity_address_invalid_input_on_alias() -> None: + result = invoke("task vanity-address TEST -o alias") + + assert result.exit_code != 0 + verify(result.output) + + +def test_vanity_address_on_default() -> None: + result = invoke("task vanity-address A") + + assert result.exit_code == 0 + match = re.search(r"'address': '([^']+)'", result.output) + if match: + address = match.group(1) + assert address.startswith("A") + + +def test_vanity_address_on_anywhere_match() -> None: + result = invoke("task vanity-address A -m anywhere") + + assert result.exit_code == 0 + match = re.search(r"'address': '([^']+)'", result.output) + if match: + address = match.group(1) + assert "A" in address + + +def test_vanity_address_on_file(tmp_path_factory: pytest.TempPathFactory) -> None: + cwd = tmp_path_factory.mktemp("cwd") + output_file_path = Path(cwd) / "output.txt" + + path = str(output_file_path.absolute()).replace("\\", r"\\") + result = invoke(f"task vanity-address A -o file -f {path}") + + assert result.exit_code == 0 + assert output_file_path.exists() + output = output_file_path.read_text() + + # Ensure output address starts with A + output_match = re.search(r'\"address\": "([^"]+)"', output) + + if output_match: + address = output_match.group(1) + assert address.startswith("A") diff --git a/tests/tasks/test_vanity_address.test_vanity_address_invalid_input_on_alias.approved.txt b/tests/tasks/test_vanity_address.test_vanity_address_invalid_input_on_alias.approved.txt new file mode 100644 index 00000000..e5302be4 --- /dev/null +++ b/tests/tasks/test_vanity_address.test_vanity_address_invalid_input_on_alias.approved.txt @@ -0,0 +1 @@ +Error: Please provide an alias using the '--alias' option when the output is set to 'alias'. diff --git a/tests/tasks/test_vanity_address.test_vanity_address_invalid_input_on_file.approved.txt b/tests/tasks/test_vanity_address.test_vanity_address_invalid_input_on_file.approved.txt new file mode 100644 index 00000000..e1ae1041 --- /dev/null +++ b/tests/tasks/test_vanity_address.test_vanity_address_invalid_input_on_file.approved.txt @@ -0,0 +1 @@ +Error: Please provide an output filename using the '--output-file' option when the output is set to 'file'. diff --git a/tests/tasks/test_vanity_address.test_vanity_address_invalid_keyword.approved.txt b/tests/tasks/test_vanity_address.test_vanity_address_invalid_keyword.approved.txt new file mode 100644 index 00000000..28242883 --- /dev/null +++ b/tests/tasks/test_vanity_address.test_vanity_address_invalid_keyword.approved.txt @@ -0,0 +1 @@ +Error: Invalid KEYWORD. Allowed: uppercase letters A-Z and numbers 2-7. diff --git a/tests/tasks/test_vanity_address.test_vanity_address_no_options.approved.txt b/tests/tasks/test_vanity_address.test_vanity_address_no_options.approved.txt new file mode 100644 index 00000000..28f42763 --- /dev/null +++ b/tests/tasks/test_vanity_address.test_vanity_address_no_options.approved.txt @@ -0,0 +1,4 @@ +Usage: algokit task vanity-address [OPTIONS] KEYWORD +Try 'algokit task vanity-address -h' for help. + +Error: Missing argument 'KEYWORD'.