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.
- mm_sol-0.8.0/.claude/settings.local.json +7 -0
- mm_sol-0.8.0/CLAUDE.md +24 -0
- {mm_sol-0.7.4 → mm_sol-0.8.0}/PKG-INFO +3 -3
- mm_sol-0.8.0/pyproject.toml +87 -0
- mm_sol-0.8.0/src/mm_sol/__init__.py +1 -0
- {mm_sol-0.7.4 → mm_sol-0.8.0}/src/mm_sol/account.py +19 -0
- mm_sol-0.8.0/src/mm_sol/cli/__init__.py +1 -0
- {mm_sol-0.7.4 → mm_sol-0.8.0}/src/mm_sol/cli/calcs.py +6 -0
- {mm_sol-0.7.4 → mm_sol-0.8.0}/src/mm_sol/cli/cli.py +19 -5
- {mm_sol-0.7.4 → mm_sol-0.8.0}/src/mm_sol/cli/cli_utils.py +18 -2
- mm_sol-0.8.0/src/mm_sol/cli/cmd/__init__.py +1 -0
- {mm_sol-0.7.4 → mm_sol-0.8.0}/src/mm_sol/cli/cmd/balance_cmd.py +11 -3
- {mm_sol-0.7.4 → mm_sol-0.8.0}/src/mm_sol/cli/cmd/balances_cmd.py +13 -6
- mm_sol-0.8.0/src/mm_sol/cli/cmd/example_cmd.py +11 -0
- {mm_sol-0.7.4 → mm_sol-0.8.0}/src/mm_sol/cli/cmd/node_cmd.py +5 -2
- {mm_sol-0.7.4 → mm_sol-0.8.0}/src/mm_sol/cli/cmd/transfer_cmd.py +25 -8
- mm_sol-0.8.0/src/mm_sol/cli/cmd/wallet/__init__.py +1 -0
- {mm_sol-0.7.4 → mm_sol-0.8.0}/src/mm_sol/cli/cmd/wallet/keypair_cmd.py +5 -2
- {mm_sol-0.7.4 → mm_sol-0.8.0}/src/mm_sol/cli/cmd/wallet/mnemonic_cmd.py +5 -2
- {mm_sol-0.7.4 → mm_sol-0.8.0}/src/mm_sol/cli/validators.py +11 -0
- mm_sol-0.8.0/src/mm_sol/constants.py +4 -0
- {mm_sol-0.7.4 → mm_sol-0.8.0}/src/mm_sol/converters.py +6 -0
- {mm_sol-0.7.4 → mm_sol-0.8.0}/src/mm_sol/retry.py +7 -0
- {mm_sol-0.7.4 → mm_sol-0.8.0}/src/mm_sol/rpc.py +8 -2
- {mm_sol-0.7.4 → mm_sol-0.8.0}/src/mm_sol/rpc_sync.py +35 -3
- {mm_sol-0.7.4 → mm_sol-0.8.0}/src/mm_sol/spl_token.py +4 -0
- {mm_sol-0.7.4 → mm_sol-0.8.0}/src/mm_sol/transfer.py +8 -1
- {mm_sol-0.7.4 → mm_sol-0.8.0}/src/mm_sol/utils.py +5 -0
- mm_sol-0.8.0/tests/__init__.py +1 -0
- mm_sol-0.8.0/tests/cli/__init__.py +1 -0
- mm_sol-0.8.0/tests/cli/cmd/__init__.py +1 -0
- mm_sol-0.8.0/tests/cli/cmd/wallet/__init__.py +1 -0
- {mm_sol-0.7.4 → mm_sol-0.8.0}/tests/cli/cmd/wallet/test_keypair_cmd.py +3 -0
- {mm_sol-0.7.4 → mm_sol-0.8.0}/tests/cli/cmd/wallet/test_mnemonic_cmd.py +4 -0
- {mm_sol-0.7.4 → mm_sol-0.8.0}/tests/conftest.py +17 -0
- {mm_sol-0.7.4 → mm_sol-0.8.0}/tests/test_account.py +11 -0
- {mm_sol-0.7.4 → mm_sol-0.8.0}/tests/test_client.py +3 -0
- {mm_sol-0.7.4 → mm_sol-0.8.0}/tests/test_converters.py +5 -0
- {mm_sol-0.7.4 → mm_sol-0.8.0}/tests/test_rpc.py +3 -0
- {mm_sol-0.7.4 → mm_sol-0.8.0}/tests/test_rpc_sync.py +11 -0
- {mm_sol-0.7.4 → mm_sol-0.8.0}/tests/test_spl_token.py +5 -0
- {mm_sol-0.7.4 → mm_sol-0.8.0}/uv.lock +50 -307
- mm_sol-0.7.4/pyproject.toml +0 -86
- mm_sol-0.7.4/src/mm_sol/__init__.py +0 -0
- mm_sol-0.7.4/src/mm_sol/cli/__init__.py +0 -0
- mm_sol-0.7.4/src/mm_sol/cli/cmd/__init__.py +0 -0
- mm_sol-0.7.4/src/mm_sol/cli/cmd/example_cmd.py +0 -8
- mm_sol-0.7.4/src/mm_sol/cli/cmd/wallet/__init__.py +0 -0
- mm_sol-0.7.4/src/mm_sol/constants.py +0 -1
- mm_sol-0.7.4/tests/__init__.py +0 -0
- mm_sol-0.7.4/tests/cli/__init__.py +0 -0
- mm_sol-0.7.4/tests/cli/cmd/__init__.py +0 -0
- mm_sol-0.7.4/tests/cli/cmd/wallet/__init__.py +0 -0
- {mm_sol-0.7.4 → mm_sol-0.8.0}/.env.example +0 -0
- {mm_sol-0.7.4 → mm_sol-0.8.0}/.gitignore +0 -0
- {mm_sol-0.7.4 → mm_sol-0.8.0}/.pre-commit-config.yaml +0 -0
- {mm_sol-0.7.4 → mm_sol-0.8.0}/README.md +0 -0
- {mm_sol-0.7.4 → mm_sol-0.8.0}/dict.dic +0 -0
- {mm_sol-0.7.4 → mm_sol-0.8.0}/justfile +0 -0
- {mm_sol-0.7.4 → mm_sol-0.8.0}/src/mm_sol/cli/examples/balances.toml +0 -0
- {mm_sol-0.7.4 → mm_sol-0.8.0}/src/mm_sol/cli/examples/transfer.toml +0 -0
- {mm_sol-0.7.4 → mm_sol-0.8.0}/src/mm_sol/py.typed +0 -0
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.
|
|
4
|
-
Requires-Python: >=3.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
79
|
+
print_json(result)
|
|
72
80
|
else:
|
|
73
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
14
|
+
print_json(data=result)
|