diff --git a/docs/cli/index.md b/docs/cli/index.md index 40c6823bf..bcceabdf3 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -160,14 +160,14 @@ - [mint](#mint) - [Options](#options-25) - [--creator ](#--creator-) - - [-n, --name ](#-n---name--3) + - [--name ](#--name-) - [-u, --unit ](#-u---unit-) - [-t, --total ](#-t---total-) - [-d, --decimals ](#-d---decimals-) + - [--nft, --ft](#--nft---ft) - [-i, --image ](#-i---image-) - [-m, --metadata ](#-m---metadata-) - [--mutable, --immutable](#--mutable---immutable) - - [--nft, --ft](#--nft---ft) - [-n, --network ](#-n---network-) - [nfd-lookup](#nfd-lookup) - [Options](#options-26) @@ -1111,8 +1111,8 @@ algokit task mint [OPTIONS] **Required** Address or alias of the asset creator. -### -n, --name -**Required** Asset name. +### --name +Asset name. ### -u, --unit @@ -1127,6 +1127,11 @@ Total supply of the asset. Defaults to 1. Number of decimals. Defaults to 0. +### --nft, --ft +Whether the asset should be validated as NFT or FT. Refers to NFT by default and validates canonical +definitions of pure or fractional NFTs as per ARC3 standard. + + ### -i, --image **Required** Path to the asset image file to be uploaded to IPFS. @@ -1141,11 +1146,6 @@ For more details refer to [https://arc.algorand.foundation/ARCs/arc-0003#json-me Whether the asset should be mutable or immutable. Refers to ARC19 by default. -### --nft, --ft -Whether the asset should be validated as NFT or FT. Refers to NFT by default and validates canonical -definitions of pure or fractional NFTs as per ARC3 standard. - - ### -n, --network Network to use. Refers to localnet by default. diff --git a/src/algokit/cli/tasks/mint.py b/src/algokit/cli/tasks/mint.py index 7b3719513..0c101b8ad 100644 --- a/src/algokit/cli/tasks/mint.py +++ b/src/algokit/cli/tasks/mint.py @@ -1,8 +1,10 @@ +import json import logging import math from pathlib import Path import click +from algokit_utils import Account from algosdk.error import AlgodHTTPError from algosdk.util import algos_to_microalgos @@ -11,6 +13,7 @@ from algokit.cli.tasks.utils import ( get_account_with_private_key, load_algod_client, + run_callback_once, validate_balance, ) from algokit.core.tasks.ipfs import ( @@ -72,18 +75,37 @@ def _validate_unit_name(context: click.Context, param: click.Parameter, value: s ) -def _validate_asset_name(context: click.Context, param: click.Parameter, value: str) -> str: +def _get_and_validate_asset_name(context: click.Context, param: click.Parameter, value: str | None) -> str: """ Validate the asset name by checking if its byte length is less than or equal to a predefined maximum value. + If asset name has not been supplied in the metadata file or via an argument a prompt is displayed. Args: context (click.Context): The click context. param (click.Parameter): The click parameter. - value (str): The value of the parameter. + value (str|None): The value of the parameter. Returns: str: The value of the parameter if it passes the validation. """ + token_metadata_path = context.params.get("token_metadata_path") + token_name = None + + if token_metadata_path is not None: + with Path(token_metadata_path).open("r") as metadata_file: + data = json.load(metadata_file) + token_name = data.get("name") + + if value is None: + if token_name is None: + value = click.prompt("Provide the asset name", type=str) + else: + value = token_name + elif token_name is not None and token_name != value: + raise click.BadParameter("Token name in metadata JSON must match CLI argument providing token name!") + + if value is None: + raise click.BadParameter("Asset name cannot be None") if len(value.encode("utf-8")) <= MAX_ASSET_NAME_BYTE_LENGTH: return value @@ -93,6 +115,75 @@ def _validate_asset_name(context: click.Context, param: click.Parameter, value: ) +def _get_creator_account(_: click.Context, __: click.Parameter, value: str) -> Account: + """ + Validate the creator account by checking if it is a valid Algorand address. + + Args: + context (click.Context): The click context. + value (str): The value of the parameter. + + Returns: + Account: An account object with the address and private key. + """ + try: + return get_account_with_private_key(value) + except Exception as ex: + raise click.BadParameter(str(ex)) from ex + + +def _get_and_validate_decimals(context: click.Context, _: click.Parameter, value: int | None) -> int: + """ + Validate the number of decimal places for the token. + If decimals has not been supplied in the metadata file or via an argument a prompt is displayed. + + Args: + context (click.Context): The click context. + value (int|None): The value of the parameter. + + Returns: + int: The value of the parameter if it passes the validation. + """ + token_metadata_path = context.params.get("token_metadata_path") + token_decimals = None + if token_metadata_path is not None: + with Path(token_metadata_path).open("r") as metadata_file: + data = json.load(metadata_file) + token_decimals = data.get("decimals") + + if value is None: + if token_decimals is None: + decimals: int = click.prompt("Provide the asset decimals", type=int, default=0) + return decimals + return int(token_decimals) + else: + if token_decimals is not None and token_decimals != value: + raise click.BadParameter("The value for decimals in the metadata JSON must match the decimals argument.") + return value + + +def _validate_supply_for_nft(context: click.Context, _: click.Parameter, value: bool) -> bool: # noqa: FBT001 + """ + Validate the total supply and decimal places for NFTs. + + Args: + context (click.Context): The click context. + value (bool): The value of the parameter. + + Returns: + bool: The value of the parameter if it passes the validation. + """ + if value: + try: + total = context.params.get("total") + decimals = context.params.get("decimals") + if total is not None and decimals is not None: + _validate_supply(total, decimals) + except click.ClickException as ex: + raise ex + return value + + @click.command( name="mint", help="Mint new fungible or non-fungible assets on Algorand.", @@ -103,16 +194,17 @@ def _validate_asset_name(context: click.Context, param: click.Parameter, value: prompt="Provide the address or alias of the asset creator", help="Address or alias of the asset creator.", type=click.STRING, + callback=run_callback_once(_get_creator_account), + is_eager=True, ) @click.option( - "-n", "--name", "asset_name", type=click.STRING, - required=True, - callback=_validate_asset_name, - prompt="Provide the asset name", + required=False, + callback=_get_and_validate_asset_name, help="Asset name.", + is_eager=True, ) @click.option( "-u", @@ -120,9 +212,10 @@ def _validate_asset_name(context: click.Context, param: click.Parameter, value: "unit_name", type=click.STRING, required=True, - callback=_validate_unit_name, + callback=run_callback_once(_validate_unit_name), prompt="Provide the unit name", help="Unit name of the asset.", + is_eager=True, ) @click.option( "-t", @@ -132,15 +225,26 @@ def _validate_asset_name(context: click.Context, param: click.Parameter, value: default=1, prompt="Provide the total supply", help="Total supply of the asset. Defaults to 1.", + is_eager=True, ) @click.option( "-d", "--decimals", type=click.INT, required=False, - default=0, - prompt="Provide the number of decimals", + callback=_get_and_validate_decimals, help="Number of decimals. Defaults to 0.", + is_eager=True, # This option needs to be evaluated before nft option. +) +@click.option( + "--nft/--ft", + "non_fungible", + type=click.BOOL, + prompt="Validate asset as NFT? Checks values of `total` and `decimals` as per ARC3 if set to True.", + default=False, + callback=_validate_supply_for_nft, + help="""Whether the asset should be validated as NFT or FT. Refers to NFT by default and validates canonical + definitions of pure or fractional NFTs as per ARC3 standard.""", ) @click.option( "-i", @@ -169,15 +273,6 @@ def _validate_asset_name(context: click.Context, param: click.Parameter, value: default=False, help="Whether the asset should be mutable or immutable. Refers to `ARC19` by default.", ) -@click.option( - "--nft/--ft", - "non_fungible", - type=click.BOOL, - prompt="Validate asset as NFT? Checks values of `total` and `decimals` as per ARC3 if set to True.", - default=False, - help="""Whether the asset should be validated as NFT or FT. Refers to NFT by default and validates canonical - definitions of pure or fractional NFTs as per ARC3 standard.""", -) @click.option( "-n", "--network", @@ -189,7 +284,7 @@ def _validate_asset_name(context: click.Context, param: click.Parameter, value: ) def mint( # noqa: PLR0913 *, - creator: str, + creator: Account, asset_name: str, unit_name: str, total: int, @@ -197,14 +292,9 @@ def mint( # noqa: PLR0913 image_path: Path, token_metadata_path: Path | None, mutable: bool, - non_fungible: bool, network: AlgorandNetwork, + non_fungible: bool, # noqa: ARG001 ) -> None: - if non_fungible: - _validate_supply(total, decimals) - - creator_account = get_account_with_private_key(creator) - pinata_jwt = get_pinata_jwt() if not pinata_jwt: raise click.ClickException("You are not logged in! Please login using `algokit task ipfs login`.") @@ -212,26 +302,22 @@ def mint( # noqa: PLR0913 client = load_algod_client(network) validate_balance( client, - creator_account, + creator, 0, algos_to_microalgos(ASSET_MINTING_MBR), # type: ignore[no-untyped-call] ) - token_metadata = TokenMetadata.from_json_file(token_metadata_path) - if not token_metadata_path: - token_metadata.name = asset_name - token_metadata.decimals = decimals + token_metadata = TokenMetadata.from_json_file(token_metadata_path, asset_name, decimals) try: asset_id, txn_id = mint_token( client=client, jwt=pinata_jwt, - creator_account=creator_account, + creator_account=creator, + unit_name=unit_name, + total=total, token_metadata=token_metadata, image_path=image_path, - unit_name=unit_name, - asset_name=asset_name, mutable=mutable, - total=total, ) click.echo("\nSuccessfully minted the asset!") diff --git a/src/algokit/cli/tasks/utils.py b/src/algokit/cli/tasks/utils.py index f8a392031..65dbb33dc 100644 --- a/src/algokit/cli/tasks/utils.py +++ b/src/algokit/cli/tasks/utils.py @@ -4,6 +4,9 @@ import os import stat import sys +from collections.abc import Callable +from functools import wraps +from typing import Any import algosdk import algosdk.encoding @@ -297,3 +300,27 @@ def get_account_info(algod_client: algosdk.v2client.algod.AlgodClient, account_a account_info = algod_client.account_info(account_address) assert isinstance(account_info, dict) return account_info + + +def run_callback_once(callback: Callable) -> Callable: + """ + Click option callbacks run twice, first to validate the prompt input, + and then independently from that is used to validate the value passed to the option. + + In cases where the callback is expensive or has side effects(like prompting the user), + it's better to run it only once. + """ + + @wraps(callback) + def wrapper(context: click.Context, param: click.Parameter, value: Any) -> Any: # noqa: ANN401 + if context.obj is None: + context.obj = {} + + key = f"{param.name}_callback_result" + if key not in context.obj: + result = callback(context, param, value) + context.obj[key] = result + return result + return context.obj[key] + + return wrapper diff --git a/src/algokit/core/tasks/mint/mint.py b/src/algokit/core/tasks/mint/mint.py index 0509343d8..d30acd1cb 100644 --- a/src/algokit/core/tasks/mint/mint.py +++ b/src/algokit/core/tasks/mint/mint.py @@ -146,13 +146,11 @@ def mint_token( # noqa: PLR0913 client: algod.AlgodClient, jwt: str, creator_account: Account, - asset_name: str, unit_name: str, total: int, token_metadata: TokenMetadata, mutable: bool, image_path: pathlib.Path | None = None, - decimals: int | None = 0, ) -> tuple[int, str]: """ Mint new token on the Algorand blockchain. @@ -180,12 +178,6 @@ def mint_token( # noqa: PLR0913 ValueError: If the decimals in the metadata JSON does not match the provided decimals amount. """ - if not token_metadata.name or token_metadata.name != asset_name: - raise ValueError("Token name in metadata JSON must match CLI argument providing token name!") - - if token_metadata.decimals and token_metadata.decimals != decimals: - raise ValueError("Token metadata JSON and CLI arguments providing decimals amount must be equal!") - if image_path: token_metadata.image_integrity = _file_integrity(image_path) token_metadata.image_mimetype = _file_mimetype(image_path) @@ -205,11 +197,11 @@ def mint_token( # noqa: PLR0913 sp=client.suggested_params(), reserve=_reserve_address_from_cid(metadata_cid) if mutable else "", unit_name=unit_name, - asset_name=asset_name, + asset_name=token_metadata.name, url=_create_url_from_cid(metadata_cid) + "#arc3" if mutable else "ipfs://" + metadata_cid + "#arc3", manager=creator_account.address if mutable else "", total=total, - decimals=decimals, + decimals=token_metadata.decimals, ) logger.debug(f"Asset config params: {asset_config_params.to_json()}") diff --git a/src/algokit/core/tasks/mint/models.py b/src/algokit/core/tasks/mint/models.py index 4e7122e27..f81719d3e 100644 --- a/src/algokit/core/tasks/mint/models.py +++ b/src/algokit/core/tasks/mint/models.py @@ -28,10 +28,10 @@ class Localization: @dataclass class TokenMetadata: - name: str | None = None + name: str + decimals: int description: str | None = None properties: Properties | None = None - decimals: int | None = None image: str | None = None image_integrity: str | None = None image_mimetype: str | None = None @@ -74,13 +74,15 @@ def to_file_path(self) -> Path: raise ValueError(f"Failed to decode JSON from file {file_path}: {err}") from err @classmethod - def from_json_file(cls: type["TokenMetadata"], file_path: Path | None) -> "TokenMetadata": + def from_json_file(cls, file_path: Path | None, name: str, decimals: int = 0) -> "TokenMetadata": if not file_path: - return cls() + return cls(name=name, decimals=decimals) try: with file_path.open() as file: data = json.load(file) + data["name"] = name + data["decimals"] = decimals return cls(**data) except FileNotFoundError as err: raise ValueError(f"No such file or directory: '{file_path}'") from err @@ -101,7 +103,7 @@ class AssetConfigTxnParams: freeze: str | None = "" clawback: str | None = "" note: str | None = "" - decimals: int | None = 0 + decimals: int = 0 default_frozen: bool = False lease: str | None = "" rekey_to: str | None = "" diff --git a/tests/tasks/test_mint.py b/tests/tasks/test_mint.py index 6336be510..c4015f102 100644 --- a/tests/tasks/test_mint.py +++ b/tests/tasks/test_mint.py @@ -1,7 +1,10 @@ import json import re +from pathlib import Path +import click import pytest +from algokit.cli.tasks.mint import _get_and_validate_asset_name, _get_and_validate_decimals from algokit.core.tasks.wallet import WALLET_ALIASES_KEYRING_USERNAME from algosdk.mnemonic import from_private_key from approvaltests.namer import NamerFactory @@ -80,6 +83,65 @@ def test_mint_token_successful( verify(result.output, options=NamerFactory.with_parameters(account_type, is_mutable, network)) +@pytest.mark.parametrize("decimals", ["decimals_given_params", "no_decimals_given"]) +def test_mint_token_successful_on_decimals( + *, + mocker: MockerFixture, + tmp_path_factory: pytest.TempPathFactory, + mock_keyring: dict[str, str | int], + decimals: str, +) -> None: + # Arrange + cwd = tmp_path_factory.mktemp("cwd") + if decimals == "no_decimals_given": + include_decimals_argument = False + prompt_input = "2" + elif decimals == "decimals_given_params": + include_decimals_argument = True + prompt_input = None + + account = "my_alias" + mock_keyring[account] = json.dumps( + {"alias": account, "address": DUMMY_ACCOUNT.address, "private_key": DUMMY_ACCOUNT.private_key} + ) + mock_keyring[WALLET_ALIASES_KEYRING_USERNAME] = json.dumps([account]) + + (cwd / "image.png").touch() + + mocker.patch( + "algokit.core.tasks.mint.mint.upload_to_pinata", + side_effect=[ + "bafkreifax6dswcxk4us2am3jxhd3swxew32oreaxzol7dnnqzhieepqg2y", + "bafkreiftmc4on252dnckhv7jdqnhkxjkpvlrekpevjwm3gjszygxkus5oe", + ], + ) + mocker.patch("algokit.core.tasks.mint.mint.wait_for_confirmation", return_value={"asset-index": 123}) + mocker.patch( + "algokit.cli.tasks.mint.get_pinata_jwt", + return_value="dummy_key", + ) + mocker.patch( + "algokit.cli.tasks.mint.validate_balance", + ) + algod_mock = mocker.MagicMock() + algod_mock.send_transaction.return_value = "dummy_tx_id" + algod_mock.suggested_params.return_value = DUMMY_SUGGESTED_PARAMS + mocker.patch("algokit.cli.tasks.mint.load_algod_client", return_value=algod_mock) + + # Act + result = invoke( + f"""task mint --creator {account} --name test --unit tst --total 100 + {'--decimals 2 ' if include_decimals_argument else ''} + --image image.png -n localnet --mutable --nft""", + input=prompt_input, + cwd=cwd, + ) + + # Assert + assert result.exit_code == 0 + verify(result.output, options=NamerFactory.with_parameters(decimals)) + + def test_mint_token_pure_fractional_nft_ft_validation( tmp_path_factory: pytest.TempPathFactory, ) -> None: @@ -166,18 +228,10 @@ def test_mint_token_no_pinata_jwt_error( verify(result.output) -def test_mint_token_acfg_token_metadata_mismatch( - mocker: MockerFixture, +def test_mint_token_acfg_token_metadata_mismatch_on_name( tmp_path_factory: pytest.TempPathFactory, ) -> None: - # Arrange - is_mutable = True cwd = tmp_path_factory.mktemp("cwd") - account = "" - prompt_input = None - account = DUMMY_ACCOUNT.address - prompt_input = from_private_key(DUMMY_ACCOUNT.private_key) # type: ignore[no-untyped-call] - (cwd / "image.png").touch() (cwd / "metadata.json").write_text( """ { @@ -194,25 +248,43 @@ def test_mint_token_acfg_token_metadata_mismatch( } """ ) + context = click.Context(click.Command("mint")) + context.params["token_metadata_path"] = Path(cwd / "metadata.json") + param = click.Option(["--name"]) + name = "test" - mocker.patch( - "algokit.cli.tasks.mint.get_pinata_jwt", - return_value="dummy_key", - ) - mocker.patch( - "algokit.cli.tasks.mint.validate_balance", - ) - algod_mock = mocker.MagicMock() - mocker.patch("algokit.cli.tasks.mint.load_algod_client", return_value=algod_mock) + with pytest.raises( + click.BadParameter, match="Token name in metadata JSON must match CLI argument providing token name!" + ): + _get_and_validate_asset_name(context, param, name) - # Act - result = invoke( - f"""task mint --creator {account} --name test --unit tst --total 1 --decimals 0 - --image image.png --metadata metadata.json -n localnet --{'mutable' if is_mutable else "immutable"} --nft""", - input=prompt_input, - cwd=cwd, + +def test_mint_token_acfg_token_metadata_mismatch_on_decimals( + tmp_path_factory: pytest.TempPathFactory, +) -> None: + cwd = tmp_path_factory.mktemp("cwd") + (cwd / "metadata.json").write_text( + """ + { + "name": "test2", + "decimals": 2, + "description": "Stars", + "properties": { + "author": "Al", + "traits": { + "position": "center", + "colors": 4 + } + } + } + """ ) + context = click.Context(click.Command("mint")) + context.params["token_metadata_path"] = Path(cwd / "metadata.json") + param = click.Option(["--decimals"]) + decimals = 0 - # Assert - assert result.exit_code == 1 - verify(result.output) + with pytest.raises( + click.BadParameter, match="The value for decimals in the metadata JSON must match the decimals argument" + ): + _get_and_validate_decimals(context, param, decimals) diff --git a/tests/tasks/test_mint.test_mint_token_successful_on_decimals.decimals_given_params.approved.txt b/tests/tasks/test_mint.test_mint_token_successful_on_decimals.decimals_given_params.approved.txt new file mode 100644 index 000000000..2d84885d9 --- /dev/null +++ b/tests/tasks/test_mint.test_mint_token_successful_on_decimals.decimals_given_params.approved.txt @@ -0,0 +1,25 @@ +Uploading image to pinata... +Image uploaded to pinata: ipfs://bafkreifax6dswcxk4us2am3jxhd3swxew32oreaxzol7dnnqzhieepqg2y +Uploading metadata to pinata... +Metadata uploaded to pinata: bafkreiftmc4on252dnckhv7jdqnhkxjkpvlrekpevjwm3gjszygxkus5oe +DEBUG: Asset config params: { + "sender": "MW5E55FG7OHV7B2YB5JGFL6ONZSP7ABCMM77CIDGOT2GSBJEYUBOF3UYKA", + "unit_name": "tst", + "asset_name": "test", + "url": "template-ipfs://{ipfscid:1:raw:reserve:sha2-256}#arc3", + "manager": "MW5E55FG7OHV7B2YB5JGFL6ONZSP7ABCMM77CIDGOT2GSBJEYUBOF3UYKA", + "reserve": "WNQLRZXLXINUJI6X5EOBU5K5FJ6VOERJ4SVGZTMZGLHA25KSLVY55WGVYQ", + "total": 100, + "freeze": "", + "clawback": "", + "note": "", + "decimals": 2, + "default_frozen": false, + "lease": "", + "rekey_to": "", + "strict_empty_address_check": false +} + +Successfully minted the asset! +Browse your asset at: https://explore.algokit.io/localnet/asset/123 +Check transaction status at: https://explore.algokit.io/localnet/transaction/dummy_tx_id diff --git a/tests/tasks/test_mint.test_mint_token_successful_on_decimals.no_decimals_given.approved.txt b/tests/tasks/test_mint.test_mint_token_successful_on_decimals.no_decimals_given.approved.txt new file mode 100644 index 000000000..b2ee8eb6c --- /dev/null +++ b/tests/tasks/test_mint.test_mint_token_successful_on_decimals.no_decimals_given.approved.txt @@ -0,0 +1,26 @@ +Provide the asset decimals [0]: 2 +Uploading image to pinata... +Image uploaded to pinata: ipfs://bafkreifax6dswcxk4us2am3jxhd3swxew32oreaxzol7dnnqzhieepqg2y +Uploading metadata to pinata... +Metadata uploaded to pinata: bafkreiftmc4on252dnckhv7jdqnhkxjkpvlrekpevjwm3gjszygxkus5oe +DEBUG: Asset config params: { + "sender": "MW5E55FG7OHV7B2YB5JGFL6ONZSP7ABCMM77CIDGOT2GSBJEYUBOF3UYKA", + "unit_name": "tst", + "asset_name": "test", + "url": "template-ipfs://{ipfscid:1:raw:reserve:sha2-256}#arc3", + "manager": "MW5E55FG7OHV7B2YB5JGFL6ONZSP7ABCMM77CIDGOT2GSBJEYUBOF3UYKA", + "reserve": "WNQLRZXLXINUJI6X5EOBU5K5FJ6VOERJ4SVGZTMZGLHA25KSLVY55WGVYQ", + "total": 100, + "freeze": "", + "clawback": "", + "note": "", + "decimals": 2, + "default_frozen": false, + "lease": "", + "rekey_to": "", + "strict_empty_address_check": false +} + +Successfully minted the asset! +Browse your asset at: https://explore.algokit.io/localnet/asset/123 +Check transaction status at: https://explore.algokit.io/localnet/transaction/dummy_tx_id