mm-sol 0.7.4__tar.gz → 0.8.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. mm_sol-0.8.0/.claude/settings.local.json +7 -0
  2. mm_sol-0.8.0/CLAUDE.md +24 -0
  3. {mm_sol-0.7.4 → mm_sol-0.8.0}/PKG-INFO +3 -3
  4. mm_sol-0.8.0/pyproject.toml +87 -0
  5. mm_sol-0.8.0/src/mm_sol/__init__.py +1 -0
  6. {mm_sol-0.7.4 → mm_sol-0.8.0}/src/mm_sol/account.py +19 -0
  7. mm_sol-0.8.0/src/mm_sol/cli/__init__.py +1 -0
  8. {mm_sol-0.7.4 → mm_sol-0.8.0}/src/mm_sol/cli/calcs.py +6 -0
  9. {mm_sol-0.7.4 → mm_sol-0.8.0}/src/mm_sol/cli/cli.py +19 -5
  10. {mm_sol-0.7.4 → mm_sol-0.8.0}/src/mm_sol/cli/cli_utils.py +18 -2
  11. mm_sol-0.8.0/src/mm_sol/cli/cmd/__init__.py +1 -0
  12. {mm_sol-0.7.4 → mm_sol-0.8.0}/src/mm_sol/cli/cmd/balance_cmd.py +11 -3
  13. {mm_sol-0.7.4 → mm_sol-0.8.0}/src/mm_sol/cli/cmd/balances_cmd.py +13 -6
  14. mm_sol-0.8.0/src/mm_sol/cli/cmd/example_cmd.py +11 -0
  15. {mm_sol-0.7.4 → mm_sol-0.8.0}/src/mm_sol/cli/cmd/node_cmd.py +5 -2
  16. {mm_sol-0.7.4 → mm_sol-0.8.0}/src/mm_sol/cli/cmd/transfer_cmd.py +25 -8
  17. mm_sol-0.8.0/src/mm_sol/cli/cmd/wallet/__init__.py +1 -0
  18. {mm_sol-0.7.4 → mm_sol-0.8.0}/src/mm_sol/cli/cmd/wallet/keypair_cmd.py +5 -2
  19. {mm_sol-0.7.4 → mm_sol-0.8.0}/src/mm_sol/cli/cmd/wallet/mnemonic_cmd.py +5 -2
  20. {mm_sol-0.7.4 → mm_sol-0.8.0}/src/mm_sol/cli/validators.py +11 -0
  21. mm_sol-0.8.0/src/mm_sol/constants.py +4 -0
  22. {mm_sol-0.7.4 → mm_sol-0.8.0}/src/mm_sol/converters.py +6 -0
  23. {mm_sol-0.7.4 → mm_sol-0.8.0}/src/mm_sol/retry.py +7 -0
  24. {mm_sol-0.7.4 → mm_sol-0.8.0}/src/mm_sol/rpc.py +8 -2
  25. {mm_sol-0.7.4 → mm_sol-0.8.0}/src/mm_sol/rpc_sync.py +35 -3
  26. {mm_sol-0.7.4 → mm_sol-0.8.0}/src/mm_sol/spl_token.py +4 -0
  27. {mm_sol-0.7.4 → mm_sol-0.8.0}/src/mm_sol/transfer.py +8 -1
  28. {mm_sol-0.7.4 → mm_sol-0.8.0}/src/mm_sol/utils.py +5 -0
  29. mm_sol-0.8.0/tests/__init__.py +1 -0
  30. mm_sol-0.8.0/tests/cli/__init__.py +1 -0
  31. mm_sol-0.8.0/tests/cli/cmd/__init__.py +1 -0
  32. mm_sol-0.8.0/tests/cli/cmd/wallet/__init__.py +1 -0
  33. {mm_sol-0.7.4 → mm_sol-0.8.0}/tests/cli/cmd/wallet/test_keypair_cmd.py +3 -0
  34. {mm_sol-0.7.4 → mm_sol-0.8.0}/tests/cli/cmd/wallet/test_mnemonic_cmd.py +4 -0
  35. {mm_sol-0.7.4 → mm_sol-0.8.0}/tests/conftest.py +17 -0
  36. {mm_sol-0.7.4 → mm_sol-0.8.0}/tests/test_account.py +11 -0
  37. {mm_sol-0.7.4 → mm_sol-0.8.0}/tests/test_client.py +3 -0
  38. {mm_sol-0.7.4 → mm_sol-0.8.0}/tests/test_converters.py +5 -0
  39. {mm_sol-0.7.4 → mm_sol-0.8.0}/tests/test_rpc.py +3 -0
  40. {mm_sol-0.7.4 → mm_sol-0.8.0}/tests/test_rpc_sync.py +11 -0
  41. {mm_sol-0.7.4 → mm_sol-0.8.0}/tests/test_spl_token.py +5 -0
  42. {mm_sol-0.7.4 → mm_sol-0.8.0}/uv.lock +50 -307
  43. mm_sol-0.7.4/pyproject.toml +0 -86
  44. mm_sol-0.7.4/src/mm_sol/__init__.py +0 -0
  45. mm_sol-0.7.4/src/mm_sol/cli/__init__.py +0 -0
  46. mm_sol-0.7.4/src/mm_sol/cli/cmd/__init__.py +0 -0
  47. mm_sol-0.7.4/src/mm_sol/cli/cmd/example_cmd.py +0 -8
  48. mm_sol-0.7.4/src/mm_sol/cli/cmd/wallet/__init__.py +0 -0
  49. mm_sol-0.7.4/src/mm_sol/constants.py +0 -1
  50. mm_sol-0.7.4/tests/__init__.py +0 -0
  51. mm_sol-0.7.4/tests/cli/__init__.py +0 -0
  52. mm_sol-0.7.4/tests/cli/cmd/__init__.py +0 -0
  53. mm_sol-0.7.4/tests/cli/cmd/wallet/__init__.py +0 -0
  54. {mm_sol-0.7.4 → mm_sol-0.8.0}/.env.example +0 -0
  55. {mm_sol-0.7.4 → mm_sol-0.8.0}/.gitignore +0 -0
  56. {mm_sol-0.7.4 → mm_sol-0.8.0}/.pre-commit-config.yaml +0 -0
  57. {mm_sol-0.7.4 → mm_sol-0.8.0}/README.md +0 -0
  58. {mm_sol-0.7.4 → mm_sol-0.8.0}/dict.dic +0 -0
  59. {mm_sol-0.7.4 → mm_sol-0.8.0}/justfile +0 -0
  60. {mm_sol-0.7.4 → mm_sol-0.8.0}/src/mm_sol/cli/examples/balances.toml +0 -0
  61. {mm_sol-0.7.4 → mm_sol-0.8.0}/src/mm_sol/cli/examples/transfer.toml +0 -0
  62. {mm_sol-0.7.4 → mm_sol-0.8.0}/src/mm_sol/py.typed +0 -0
@@ -0,0 +1,7 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(just lint:*)"
5
+ ]
6
+ }
7
+ }
mm_sol-0.8.0/CLAUDE.md ADDED
@@ -0,0 +1,24 @@
1
+ # AI Agent Start Guide
2
+
3
+ ## Critical: Language
4
+ RESPOND IN ENGLISH. Always. No exceptions.
5
+ User's language does NOT determine your response language.
6
+ Only switch if user EXPLICITLY requests it (e.g., "respond in {language}").
7
+ Language switching applies ONLY to chat. All code, comments, commit messages, and files must ALWAYS be in English — no exceptions.
8
+
9
+ ## Mandatory Rules (external)
10
+ These files are REQUIRED. Read them fully and follow all rules.
11
+ - `~/.claude/shared-rules/general.md`
12
+ - `~/.claude/shared-rules/python.md`
13
+
14
+ ## Project Reading (context)
15
+ These files are REQUIRED for project understanding.
16
+ - `README.md`
17
+
18
+ ## Preflight (mandatory)
19
+ Before your first response:
20
+ 1. Read all files listed above.
21
+ 2. Do not answer until all are read.
22
+ 3. In your first reply, list every file you have read from this document.
23
+
24
+ Failure to follow this protocol is considered an error.
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mm-sol
3
- Version: 0.7.4
4
- Requires-Python: >=3.13
3
+ Version: 0.8.0
4
+ Requires-Python: >=3.14
5
5
  Requires-Dist: base58~=2.1.1
6
6
  Requires-Dist: jinja2~=3.1.6
7
- Requires-Dist: mm-web3~=0.5.6
7
+ Requires-Dist: mm-web3~=0.6.2
8
8
  Requires-Dist: mnemonic==0.21
9
9
  Requires-Dist: solana~=0.36.11
10
10
  Requires-Dist: typer~=0.21.1
@@ -0,0 +1,87 @@
1
+ [project]
2
+ name = "mm-sol"
3
+ version = "0.8.0"
4
+ description = ""
5
+ requires-python = ">=3.14"
6
+ dependencies = [
7
+ "mm-web3~=0.6.2",
8
+ "solana~=0.36.11",
9
+ "base58~=2.1.1",
10
+ "mnemonic==0.21",
11
+ "typer~=0.21.1",
12
+ "jinja2~=3.1.6",
13
+ # "socksio>=1.0.0",
14
+ ]
15
+ [project.scripts]
16
+ mm-sol = "mm_sol.cli.cli:app"
17
+
18
+ [build-system]
19
+ requires = ["hatchling"]
20
+ build-backend = "hatchling.build"
21
+
22
+ [dependency-groups]
23
+ dev = [
24
+ "bandit~=1.9.3",
25
+ "mypy~=1.19.1",
26
+ "pip-audit~=2.10.0",
27
+ "pre-commit~=4.5.1",
28
+ "pytest~=9.0.2",
29
+ "pytest-asyncio~=1.3.0",
30
+ "pytest-xdist~=3.8.0",
31
+ "ruff~=0.15.0",
32
+ "python-dotenv~=1.2.0",
33
+ ]
34
+
35
+ [tool.mypy]
36
+ python_version = "3.14"
37
+ mypy_path = "stubs"
38
+ warn_no_return = false
39
+ implicit_reexport = true
40
+ strict = true
41
+ exclude = ["^tests/", "^tmp/"]
42
+
43
+ [tool.ruff]
44
+ line-length = 130
45
+ target-version = "py314"
46
+ [tool.ruff.lint]
47
+ select = ["ALL"]
48
+ ignore = [
49
+ "TC", # flake8-type-checking, TYPE_CHECKING is dangerous, for example it doesn't work with pydantic
50
+ "A005", # flake8-builtins: stdlib-module-shadowing
51
+ "ERA001", # eradicate: commented-out-code
52
+ "PT", # flake8-pytest-style
53
+ "FIX", # flake8-fixme
54
+ "PLR0911", # pylint: too-many-return-statements
55
+ "PLR0912", # pylint: too-many-branches
56
+ "PLR0913", # pylint: too-many-arguments
57
+ "PLR2004", # pylint: magic-value-comparison
58
+ "PLC0414", # pylint: useless-import-alias
59
+ "FBT", # flake8-boolean-trap
60
+ "EM", # flake8-errmsg
61
+ "TRY003", # tryceratops: raise-vanilla-args
62
+ "C901", # mccabe: complex-structure,
63
+ "BLE001", # flake8-blind-except
64
+ "S311", # bandit: suspicious-non-cryptographic-random-usage
65
+ "TD002", # flake8-todos: missing-todo-author
66
+ "TD003", # flake8-todos: missing-todo-link
67
+ "RET503", # flake8-return: implicit-return
68
+ "COM812", # it's used in ruff formatter
69
+ "ASYNC109", # flake8-async: async-function-with-timeout
70
+ "D203", # pydocstyle: one-blank-line-before-class (conflicts with D211)
71
+ "D213", # pydocstyle: multi-line-summary-second-line (conflicts with D212)
72
+ ]
73
+ [tool.ruff.lint.pep8-naming]
74
+ classmethod-decorators = ["field_validator"]
75
+ [tool.ruff.lint.per-file-ignores]
76
+ "tests/*.py" = ["ANN", "S"]
77
+ [tool.ruff.format]
78
+ quote-style = "double"
79
+ indent-style = "space"
80
+
81
+ [tool.bandit]
82
+ exclude_dirs = ["tests"]
83
+ skips = ["B311"]
84
+
85
+ [tool.pytest.ini_options]
86
+ asyncio_mode = "auto"
87
+ asyncio_default_fixture_loop_scope = "function"
@@ -0,0 +1 @@
1
+ """Library for interacting with Solana blockchain."""
@@ -1,3 +1,5 @@
1
+ """Solana account management: key generation, derivation, and validation."""
2
+
1
3
  import contextlib
2
4
  from dataclasses import dataclass
3
5
 
@@ -8,10 +10,15 @@ from solders.keypair import Keypair
8
10
  from solders.pubkey import Pubkey
9
11
 
10
12
  PHANTOM_DERIVATION_PATH = "m/44'/501'/{i}'/0'"
13
+ """Default Phantom wallet derivation path template."""
14
+
11
15
  WORD_STRENGTH = {12: 128, 15: 160, 18: 192, 21: 224, 24: 256}
16
+ """Mapping of mnemonic word count to entropy bits."""
12
17
 
13
18
 
14
19
  class NewAccount(BaseModel):
20
+ """Newly generated Solana account with public and private keys."""
21
+
15
22
  public_key: str
16
23
  private_key_base58: str
17
24
  private_key_arr: list[int]
@@ -19,6 +26,8 @@ class NewAccount(BaseModel):
19
26
 
20
27
  @dataclass
21
28
  class DerivedAccount:
29
+ """Account derived from a mnemonic at a specific derivation path."""
30
+
22
31
  index: int
23
32
  path: str
24
33
  address: str
@@ -26,6 +35,7 @@ class DerivedAccount:
26
35
 
27
36
 
28
37
  def generate_mnemonic(num_words: int = 24) -> str:
38
+ """Generate a BIP39 mnemonic phrase with the specified number of words."""
29
39
  if num_words not in WORD_STRENGTH:
30
40
  raise ValueError(f"num_words must be one of {list(WORD_STRENGTH.keys())}")
31
41
  mnemonic = Mnemonic("english")
@@ -33,6 +43,7 @@ def generate_mnemonic(num_words: int = 24) -> str:
33
43
 
34
44
 
35
45
  def derive_accounts(mnemonic: str, passphrase: str, derivation_path: str, limit: int) -> list[DerivedAccount]:
46
+ """Derive multiple accounts from a mnemonic using the given derivation path template."""
36
47
  if "{i}" not in derivation_path:
37
48
  raise ValueError("derivation_path must contain {i}, for example: m/44'/501'/{i}'/0'")
38
49
 
@@ -54,6 +65,7 @@ def derive_accounts(mnemonic: str, passphrase: str, derivation_path: str, limit:
54
65
 
55
66
 
56
67
  def generate_account() -> NewAccount:
68
+ """Generate a new random Solana keypair and return it as a NewAccount."""
57
69
  keypair = Keypair()
58
70
  public_key = str(keypair.pubkey())
59
71
  private_key_base58 = base58.b58encode(bytes(keypair.to_bytes())).decode("utf-8")
@@ -62,6 +74,7 @@ def generate_account() -> NewAccount:
62
74
 
63
75
 
64
76
  def get_keypair(private_key: str | list[int]) -> Keypair:
77
+ """Create a Keypair from a base58 string, JSON array string, or integer list."""
65
78
  if isinstance(private_key, str):
66
79
  if "[" in private_key:
67
80
  private_key_ = [int(x) for x in private_key.replace("[", "").replace("]", "").split(",")]
@@ -73,12 +86,14 @@ def get_keypair(private_key: str | list[int]) -> Keypair:
73
86
 
74
87
 
75
88
  def check_private_key(public_key: str | Pubkey, private_key: str | list[int]) -> bool:
89
+ """Check whether a private key corresponds to the given public key."""
76
90
  if isinstance(public_key, str):
77
91
  public_key = Pubkey.from_string(public_key)
78
92
  return get_keypair(private_key).pubkey() == public_key
79
93
 
80
94
 
81
95
  def get_public_key(private_key: str) -> str:
96
+ """Derive the public key address from a private key string."""
82
97
  if "[" in private_key:
83
98
  private_key_ = [int(x) for x in private_key.replace("[", "").replace("]", "").split(",")]
84
99
  else:
@@ -87,20 +102,24 @@ def get_public_key(private_key: str) -> str:
87
102
 
88
103
 
89
104
  def get_private_key_base58(private_key: str) -> str:
105
+ """Convert a private key to base58 encoding."""
90
106
  keypair = get_keypair(private_key)
91
107
  return base58.b58encode(bytes(keypair.to_bytes())).decode("utf-8")
92
108
 
93
109
 
94
110
  def get_private_key_arr(private_key: str) -> list[int]:
111
+ """Convert a private key to a list of byte integers."""
95
112
  keypair = get_keypair(private_key)
96
113
  return list(x for x in keypair.to_bytes()) # noqa: C400
97
114
 
98
115
 
99
116
  def get_private_key_arr_str(private_key: str) -> str:
117
+ """Convert a private key to a JSON-style array string."""
100
118
  return f"[{','.join(str(x) for x in get_private_key_arr(private_key))}]"
101
119
 
102
120
 
103
121
  def is_address(pubkey: str) -> bool:
122
+ """Check whether a string is a valid Solana address."""
104
123
  with contextlib.suppress(Exception):
105
124
  Pubkey.from_string(pubkey)
106
125
  return True
@@ -0,0 +1 @@
1
+ """CLI interface for mm-sol."""
@@ -1,3 +1,5 @@
1
+ """Value expression calculators for SOL and token amounts with optional balance variables."""
2
+
1
3
  from mm_result import Result
2
4
  from mm_web3 import Nodes, Proxies
3
5
  from mm_web3.calcs import calc_expression_with_vars
@@ -7,16 +9,19 @@ from mm_sol.constants import UNIT_DECIMALS
7
9
 
8
10
 
9
11
  def calc_sol_expression(expression: str, variables: dict[str, int] | None = None) -> int:
12
+ """Evaluate a SOL expression string into lamports."""
10
13
  return calc_expression_with_vars(expression, variables, unit_decimals=UNIT_DECIMALS)
11
14
 
12
15
 
13
16
  def calc_token_expression(expression: str, token_decimals: int, variables: dict[str, int] | None = None) -> int:
17
+ """Evaluate a token expression string into smallest units."""
14
18
  return calc_expression_with_vars(expression, variables, unit_decimals={"t": token_decimals})
15
19
 
16
20
 
17
21
  async def calc_sol_value_for_address(
18
22
  *, nodes: Nodes, value_expression: str, address: str, proxies: Proxies, fee: int
19
23
  ) -> Result[int]:
24
+ """Calculate SOL value in lamports for an address, resolving 'balance' variable if used."""
20
25
  value_expression = value_expression.lower()
21
26
  variables: dict[str, int] | None = None
22
27
  if "balance" in value_expression:
@@ -34,6 +39,7 @@ async def calc_sol_value_for_address(
34
39
  async def calc_token_value_for_address(
35
40
  *, nodes: Nodes, value_expression: str, owner: str, token: str, token_decimals: int, proxies: Proxies
36
41
  ) -> Result[int]:
42
+ """Calculate token value in smallest units for an address, resolving 'balance' variable if used."""
37
43
  variables: dict[str, int] | None = None
38
44
  value_expression = value_expression.lower()
39
45
  if "balance" in value_expression:
@@ -1,10 +1,12 @@
1
+ """Main CLI entry point and command definitions for mm-sol."""
2
+
1
3
  import asyncio
2
- from enum import Enum
4
+ from enum import StrEnum
3
5
  from pathlib import Path
4
6
  from typing import Annotated
5
7
 
6
- import mm_print
7
8
  import typer
9
+ from mm_print import print_plain
8
10
 
9
11
  from mm_sol.account import PHANTOM_DERIVATION_PATH
10
12
 
@@ -13,8 +15,10 @@ from .cmd import balance_cmd, balances_cmd, example_cmd, node_cmd, transfer_cmd
13
15
  from .cmd.transfer_cmd import TransferCmdParams
14
16
  from .cmd.wallet import keypair_cmd, mnemonic_cmd
15
17
 
18
+ """Main CLI application."""
16
19
  app = typer.Typer(no_args_is_help=True, pretty_exceptions_enable=False, add_completion=False)
17
20
 
21
+ """Wallet subcommand group."""
18
22
  wallet_app = typer.Typer(
19
23
  no_args_is_help=True, help="Wallet-related commands: generate new accounts, derive addresses from private keys, and more"
20
24
  )
@@ -23,23 +27,27 @@ app.add_typer(wallet_app, name="w", hidden=True)
23
27
 
24
28
 
25
29
  def version_callback(value: bool) -> None:
30
+ """Print version and exit when --version is passed."""
26
31
  if value:
27
- mm_print.plain(f"mm-sol: {cli_utils.get_version()}")
32
+ print_plain(f"mm-sol: {cli_utils.get_version()}")
28
33
  raise typer.Exit
29
34
 
30
35
 
31
36
  @app.callback()
32
37
  def main(_version: bool = typer.Option(None, "--version", callback=version_callback, is_eager=True)) -> None:
33
- pass
38
+ """Solana CLI tool."""
39
+
34
40
 
41
+ class ConfigExample(StrEnum):
42
+ """Available example configuration names."""
35
43
 
36
- class ConfigExample(str, Enum):
37
44
  balances = "balances"
38
45
  transfer = "transfer"
39
46
 
40
47
 
41
48
  @app.command(name="example", help="Displays an example configuration for a command")
42
49
  def example_command(command: Annotated[ConfigExample, typer.Argument()]) -> None:
50
+ """Display an example configuration for the given command."""
43
51
  example_cmd.run(command.value)
44
52
 
45
53
 
@@ -51,6 +59,7 @@ def balance_command(
51
59
  proxies_url: Annotated[str, typer.Option("--proxies-url", envvar="MM_SOL_PROXIES_URL")] = "", # nosec
52
60
  lamport: bool = typer.Option(False, "--lamport", "-l", help="Print balances in lamports"),
53
61
  ) -> None:
62
+ """Fetch and print SOL and optional token balance for an account."""
54
63
  asyncio.run(balance_cmd.run(rpc_url, wallet_address, token_address, lamport, proxies_url))
55
64
 
56
65
 
@@ -58,6 +67,7 @@ def balance_command(
58
67
  def balances_command(
59
68
  config_path: Path, print_config: Annotated[bool, typer.Option("--config", "-c", help="Print config and exit")] = False
60
69
  ) -> None:
70
+ """Display SOL and token balances for multiple accounts from a config file."""
61
71
  asyncio.run(balances_cmd.run(config_path, print_config))
62
72
 
63
73
 
@@ -72,6 +82,7 @@ def transfer_command(
72
82
  no_confirmation: bool = typer.Option(False, "--no-confirmation", "-nc", help="Do not wait for confirmation"),
73
83
  debug: bool = typer.Option(False, "--debug", "-d", help="Print debug info"),
74
84
  ) -> None:
85
+ """Execute SOL or SPL token transfers based on a config file."""
75
86
  asyncio.run(
76
87
  transfer_cmd.run(
77
88
  TransferCmdParams(
@@ -93,6 +104,7 @@ def node_command(
93
104
  urls: Annotated[list[str], typer.Argument()],
94
105
  proxy: Annotated[str | None, typer.Option("--proxy", "-p", help="Proxy")] = None,
95
106
  ) -> None:
107
+ """Check RPC node availability by fetching block height."""
96
108
  asyncio.run(node_cmd.run(urls, proxy))
97
109
 
98
110
 
@@ -105,11 +117,13 @@ def wallet_mnemonic_command( # nosec
105
117
  words: int = typer.Option(12, "--words", "-w", help="Number of mnemonic words"),
106
118
  limit: int = typer.Option(5, "--limit", "-l"),
107
119
  ) -> None:
120
+ """Generate or derive accounts from a mnemonic phrase."""
108
121
  mnemonic_cmd.run(mnemonic, passphrase, words, derivation_path, limit)
109
122
 
110
123
 
111
124
  @wallet_app.command(name="keypair", help="Print public, private_base58, private_arr by a private key")
112
125
  def keypair_command(private_key: str) -> None:
126
+ """Print keypair details from a private key."""
113
127
  keypair_cmd.run(private_key)
114
128
 
115
129
 
@@ -1,9 +1,13 @@
1
+ """Shared CLI utilities: config printing, RPC URL resolution, and helpers."""
2
+
1
3
  import importlib.metadata
2
4
  import time
3
5
  from pathlib import Path
6
+ from typing import NoReturn
4
7
 
5
- import mm_print
8
+ import typer
6
9
  from loguru import logger
10
+ from mm_print import print_json
7
11
  from mm_web3 import Nodes, Proxies, Web3CliConfig, random_node, random_proxy
8
12
  from pydantic import BaseModel
9
13
  from solders.signature import Signature
@@ -12,23 +16,28 @@ from mm_sol.utils import get_client
12
16
 
13
17
 
14
18
  def get_version() -> str:
19
+ """Return the installed mm-sol package version."""
15
20
  return importlib.metadata.version("mm-sol")
16
21
 
17
22
 
18
23
  class BaseConfigParams(BaseModel):
24
+ """Base parameters shared by CLI commands that read a config file."""
25
+
19
26
  config_path: Path
20
27
  print_config_and_exit: bool
21
28
 
22
29
 
23
30
  def print_config(config: Web3CliConfig, exclude: set[str] | None = None, count: set[str] | None = None) -> None:
31
+ """Print a config as JSON, optionally replacing list fields with their counts."""
24
32
  data = config.model_dump(exclude=exclude)
25
33
  if count:
26
34
  for k in count:
27
35
  data[k] = len(data[k])
28
- mm_print.json(data)
36
+ print_json(data)
29
37
 
30
38
 
31
39
  def public_rpc_url(url: str | None) -> str:
40
+ """Resolve a shorthand network name (mainnet/testnet/devnet) to its full RPC URL."""
32
41
  if not url:
33
42
  return "https://api.mainnet-beta.solana.com"
34
43
 
@@ -44,6 +53,7 @@ def public_rpc_url(url: str | None) -> str:
44
53
 
45
54
 
46
55
  def wait_confirmation(nodes: Nodes, proxies: Proxies, signature: Signature, log_prefix: str) -> bool:
56
+ """Poll for transaction confirmation, returning True if confirmed within 30 seconds."""
47
57
  count = 0
48
58
  while True:
49
59
  try:
@@ -60,3 +70,9 @@ def wait_confirmation(nodes: Nodes, proxies: Proxies, signature: Signature, log_
60
70
  if count > 30:
61
71
  logger.error(f"{log_prefix}: can't get confirmation, timeout")
62
72
  return False
73
+
74
+
75
+ def fatal(message: str) -> NoReturn:
76
+ """Print an error message and exit with code 1."""
77
+ typer.echo(message)
78
+ raise typer.Exit(1)
@@ -0,0 +1 @@
1
+ """CLI command implementations."""
@@ -1,6 +1,8 @@
1
+ """Single account balance query command."""
2
+
1
3
  from decimal import Decimal
2
4
 
3
- import mm_print
5
+ from mm_print import print_json
4
6
  from mm_web3 import fetch_proxies
5
7
  from pydantic import BaseModel, Field
6
8
 
@@ -10,6 +12,8 @@ from mm_sol.cli import cli_utils
10
12
 
11
13
 
12
14
  class HumanReadableBalanceResult(BaseModel):
15
+ """Balance result with SOL and token values in human-readable decimals."""
16
+
13
17
  sol_balance: Decimal | None
14
18
  token_balance: Decimal | None
15
19
  token_decimals: int | None
@@ -17,12 +21,15 @@ class HumanReadableBalanceResult(BaseModel):
17
21
 
18
22
 
19
23
  class BalanceResult(BaseModel):
24
+ """Balance result with SOL and token values in smallest units."""
25
+
20
26
  sol_balance: int | None = None
21
27
  token_balance: int | None = None
22
28
  token_decimals: int | None = None
23
29
  errors: list[str] = Field(default_factory=list)
24
30
 
25
31
  def to_human_readable(self) -> HumanReadableBalanceResult:
32
+ """Convert balances from smallest units to human-readable decimals."""
26
33
  sol_balance = Decimal(self.sol_balance) / 10**9 if self.sol_balance is not None else None
27
34
  token_balance = None
28
35
  if self.token_balance is not None and self.token_decimals is not None:
@@ -39,6 +46,7 @@ async def run(
39
46
  lamport: bool,
40
47
  proxies_url: str | None,
41
48
  ) -> None:
49
+ """Fetch and print SOL and optional token balance for a single account."""
42
50
  result = BalanceResult()
43
51
 
44
52
  rpc_url = cli_utils.public_rpc_url(rpc_url)
@@ -68,6 +76,6 @@ async def run(
68
76
  result.errors.append("token_decimals: " + decimals_res.unwrap_err())
69
77
 
70
78
  if lamport:
71
- mm_print.json(result)
79
+ print_json(result)
72
80
  else:
73
- mm_print.json(result.to_human_readable())
81
+ print_json(result.to_human_readable())
@@ -1,18 +1,23 @@
1
+ """Multi-account balances query command."""
2
+
1
3
  import random
2
4
  from decimal import Decimal
3
5
  from pathlib import Path
4
6
  from typing import Annotated, Any
5
7
 
6
- import mm_print
8
+ from mm_print import print_json
7
9
  from mm_web3 import ConfigValidators, Web3CliConfig
8
10
  from pydantic import BeforeValidator, Field
9
11
 
10
12
  import mm_sol.retry
11
13
  from mm_sol import converters, retry
14
+ from mm_sol.cli.cli_utils import fatal
12
15
  from mm_sol.cli.validators import Validators
13
16
 
14
17
 
15
18
  class Config(Web3CliConfig):
19
+ """Configuration for the balances command."""
20
+
16
21
  accounts: Annotated[list[str], BeforeValidator(Validators.sol_addresses(unique=True))]
17
22
  tokens: Annotated[list[str], BeforeValidator(Validators.sol_addresses(unique=True))]
18
23
  nodes: Annotated[list[str], BeforeValidator(ConfigValidators.nodes())]
@@ -20,10 +25,12 @@ class Config(Web3CliConfig):
20
25
 
21
26
  @property
22
27
  def random_node(self) -> str:
28
+ """Return a randomly selected RPC node URL."""
23
29
  return random.choice(self.nodes)
24
30
 
25
31
 
26
32
  async def run(config_path: Path, print_config: bool) -> None:
33
+ """Fetch and print SOL and token balances for all configured accounts."""
27
34
  config = Config.read_toml_config_or_exit(config_path)
28
35
  if print_config:
29
36
  config.print_and_exit()
@@ -35,19 +42,20 @@ async def run(config_path: Path, print_config: bool) -> None:
35
42
  for token_address in config.tokens:
36
43
  res = await mm_sol.retry.get_token_decimals(3, config.nodes, config.proxies, token=token_address)
37
44
  if res.is_err():
38
- mm_print.exit_with_error(f"Failed to get decimals for token {token_address}: {res.unwrap_err()}")
45
+ fatal(f"Failed to get decimals for token {token_address}: {res.unwrap_err()}")
39
46
 
40
47
  token_decimals = res.unwrap()
41
48
  result[token_address] = await _get_token_balances(token_address, token_decimals, config.accounts, config)
42
49
  result[token_address + "_decimals"] = token_decimals
43
50
  result[token_address + "_sum"] = sum([v for v in result[token_address].values() if v is not None])
44
51
 
45
- mm_print.json(result)
52
+ print_json(result)
46
53
 
47
54
 
48
55
  async def _get_token_balances(
49
56
  token_address: str, token_decimals: int, accounts: list[str], config: Config
50
57
  ) -> dict[str, Decimal | None]:
58
+ """Fetch token balances for all accounts, returning a dict of address to balance."""
51
59
  result: dict[str, Decimal | None] = {}
52
60
  for account in accounts:
53
61
  result[account] = (
@@ -60,12 +68,11 @@ async def _get_token_balances(
60
68
 
61
69
 
62
70
  async def _get_sol_balances(accounts: list[str], config: Config) -> dict[str, Decimal | None]:
71
+ """Fetch SOL balances for all accounts, returning a dict of address to balance."""
63
72
  result = {}
64
73
  for account in accounts:
65
74
  result[account] = (
66
- (await retry.get_sol_balance(3, config.nodes, config.proxies, address=account))
67
- .map(lambda v: converters.lamports_to_sol(v))
68
- .value
75
+ (await retry.get_sol_balance(3, config.nodes, config.proxies, address=account)).map(converters.lamports_to_sol).value
69
76
  )
70
77
 
71
78
  return result
@@ -0,0 +1,11 @@
1
+ """Example config display command."""
2
+
3
+ from pathlib import Path
4
+
5
+ from mm_print import print_toml
6
+
7
+
8
+ def run(module: str) -> None:
9
+ """Print the example TOML configuration for the given command module."""
10
+ example_file = Path(Path(__file__).parent.absolute(), "../examples", f"{module}.toml")
11
+ print_toml(example_file.read_text())
@@ -1,11 +1,14 @@
1
- import mm_print
1
+ """RPC node health check command."""
2
+
3
+ from mm_print import print_json
2
4
 
3
5
  from mm_sol import rpc
4
6
  from mm_sol.cli import cli_utils
5
7
 
6
8
 
7
9
  async def run(urls: list[str], proxy: str | None) -> None:
10
+ """Check each RPC URL by fetching block height and print results."""
8
11
  result = {}
9
12
  for url in [cli_utils.public_rpc_url(u) for u in urls]:
10
13
  result[url] = (await rpc.get_block_height(url, proxy=proxy, timeout=10)).value_or_error()
11
- mm_print.json(data=result)
14
+ print_json(data=result)