mm-sol 0.7.4__py3-none-any.whl → 0.8.0__py3-none-any.whl

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/__init__.py CHANGED
@@ -0,0 +1 @@
1
+ """Library for interacting with Solana blockchain."""
mm_sol/account.py CHANGED
@@ -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
mm_sol/cli/__init__.py CHANGED
@@ -0,0 +1 @@
1
+ """CLI interface for mm-sol."""
mm_sol/cli/calcs.py CHANGED
@@ -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:
mm_sol/cli/cli.py CHANGED
@@ -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
 
mm_sol/cli/cli_utils.py CHANGED
@@ -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
@@ -1,8 +1,11 @@
1
+ """Example config display command."""
2
+
1
3
  from pathlib import Path
2
4
 
3
- import mm_print
5
+ from mm_print import print_toml
4
6
 
5
7
 
6
8
  def run(module: str) -> None:
9
+ """Print the example TOML configuration for the given command module."""
7
10
  example_file = Path(Path(__file__).parent.absolute(), "../examples", f"{module}.toml")
8
- mm_print.toml(example_file.read_text())
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)
@@ -1,11 +1,12 @@
1
+ """SOL and SPL token transfer command with multi-route support."""
2
+
1
3
  import asyncio
2
4
  import sys
3
5
  from pathlib import Path
4
6
  from typing import Annotated
5
7
 
6
- import mm_print
7
8
  from loguru import logger
8
- from mm_std import utc_now
9
+ from mm_std import utc
9
10
  from mm_web3 import Web3CliConfig
10
11
  from mm_web3.account import PrivateKeyMap
11
12
  from mm_web3.calcs import calc_decimal_expression
@@ -20,12 +21,14 @@ from solders.signature import Signature
20
21
  import mm_sol.retry
21
22
  from mm_sol import retry
22
23
  from mm_sol.cli import calcs, cli_utils
23
- from mm_sol.cli.cli_utils import BaseConfigParams
24
+ from mm_sol.cli.cli_utils import BaseConfigParams, fatal
24
25
  from mm_sol.cli.validators import Validators
25
26
  from mm_sol.converters import lamports_to_sol, to_token
26
27
 
27
28
 
28
29
  class Config(Web3CliConfig):
30
+ """Transfer command configuration with routes, keys, and value expressions."""
31
+
29
32
  nodes: Annotated[list[str], BeforeValidator(Validators.nodes())]
30
33
  transfers: Annotated[list[Transfer], BeforeValidator(Validators.sol_transfers())]
31
34
  private_keys: Annotated[PrivateKeyMap, BeforeValidator(Validators.sol_private_keys())]
@@ -41,10 +44,12 @@ class Config(Web3CliConfig):
41
44
 
42
45
  @property
43
46
  def from_addresses(self) -> list[str]:
47
+ """Return the list of sender addresses from all transfer routes."""
44
48
  return [r.from_address for r in self.transfers]
45
49
 
46
50
  @model_validator(mode="after") # type: ignore[misc]
47
- async def final_validator(self) -> "Config":
51
+ async def final_validator(self) -> Config:
52
+ """Validate private keys, transfer values, and fetch token decimals if needed."""
48
53
  if not self.private_keys.contains_all_addresses(self.from_addresses):
49
54
  raise ValueError("private keys are not set for all addresses")
50
55
 
@@ -69,13 +74,15 @@ class Config(Web3CliConfig):
69
74
  if self.token:
70
75
  res = await mm_sol.retry.get_token_decimals(3, self.nodes, self.proxies, token=self.token)
71
76
  if res.is_err():
72
- mm_print.exit_with_error(f"can't get decimals for token={self.token}, error={res.unwrap_err()}")
77
+ fatal(f"can't get decimals for token={self.token}, error={res.unwrap_err()}")
73
78
  self.token_decimals = res.unwrap()
74
79
 
75
80
  return self
76
81
 
77
82
 
78
83
  class TransferCmdParams(BaseConfigParams):
84
+ """CLI parameters for the transfer command."""
85
+
79
86
  print_balances: bool
80
87
  print_transfers: bool
81
88
  debug: bool
@@ -85,6 +92,7 @@ class TransferCmdParams(BaseConfigParams):
85
92
 
86
93
 
87
94
  async def run(cmd_params: TransferCmdParams) -> None:
95
+ """Execute the transfer command: print config/balances/transfers or run transfers."""
88
96
  config = await Config.read_toml_config_or_exit_async(cmd_params.config_path)
89
97
 
90
98
  if cmd_params.print_config_and_exit:
@@ -103,8 +111,9 @@ async def run(cmd_params: TransferCmdParams) -> None:
103
111
 
104
112
 
105
113
  async def _run_transfers(config: Config, cmd_params: TransferCmdParams) -> None:
114
+ """Execute all configured transfer routes sequentially with optional delays."""
106
115
  init_loguru(cmd_params.debug, config.log_debug, config.log_info)
107
- logger.info(f"transfer {cmd_params.config_path}: started at {utc_now()} UTC")
116
+ logger.info(f"transfer {cmd_params.config_path}: started at {utc()} UTC")
108
117
  logger.debug(f"config={config.model_dump(exclude={'private_keys'}) | {'version': cli_utils.get_version()}}")
109
118
  for i, route in enumerate(config.transfers):
110
119
  await _transfer(route, config, cmd_params)
@@ -113,10 +122,11 @@ async def _run_transfers(config: Config, cmd_params: TransferCmdParams) -> None:
113
122
  logger.info(f"delay {delay_value} seconds")
114
123
  if not cmd_params.emulate:
115
124
  await asyncio.sleep(float(delay_value))
116
- logger.info(f"transfer {cmd_params.config_path}: finished at {utc_now()} UTC")
125
+ logger.info(f"transfer {cmd_params.config_path}: finished at {utc()} UTC")
117
126
 
118
127
 
119
128
  async def _calc_value(transfer: Transfer, config: Config, transfer_sol_fee: int) -> int | None:
129
+ """Calculate the transfer value in smallest units, resolving balance-based expressions."""
120
130
  if config.token:
121
131
  value_res = await calcs.calc_token_value_for_address(
122
132
  nodes=config.nodes,
@@ -142,7 +152,7 @@ async def _calc_value(transfer: Transfer, config: Config, transfer_sol_fee: int)
142
152
 
143
153
 
144
154
  def _check_value_min_limit(transfer: Transfer, value: int, config: Config) -> bool:
145
- """Returns False if the transfer should be skipped."""
155
+ """Return False if the transfer value is below the configured minimum limit."""
146
156
  if config.value_min_limit:
147
157
  if config.token:
148
158
  value_min_limit = calcs.calc_token_expression(config.value_min_limit, config.token_decimals)
@@ -154,12 +164,14 @@ def _check_value_min_limit(transfer: Transfer, value: int, config: Config) -> bo
154
164
 
155
165
 
156
166
  def _value_with_suffix(value: int, config: Config) -> str:
167
+ """Format a value with its unit suffix (sol or t)."""
157
168
  if config.token:
158
169
  return f"{to_token(value, decimals=config.token_decimals, ndigits=config.round_ndigits)}t"
159
170
  return f"{lamports_to_sol(value, config.round_ndigits)}sol"
160
171
 
161
172
 
162
173
  async def _send_tx(transfer: Transfer, value: int, config: Config) -> Signature | None:
174
+ """Submit a SOL or token transfer transaction with retries."""
163
175
  logger.debug(f"{transfer.log_prefix}: value={_value_with_suffix(value, config)}")
164
176
  if config.token:
165
177
  res = await retry.transfer_token(
@@ -191,6 +203,7 @@ async def _send_tx(transfer: Transfer, value: int, config: Config) -> Signature
191
203
 
192
204
 
193
205
  async def _transfer(transfer: Transfer, config: Config, cmd_params: TransferCmdParams) -> None:
206
+ """Execute a single transfer route: calculate value, check limits, send, and confirm."""
194
207
  transfer_sol_fee = 5000
195
208
 
196
209
  value = await _calc_value(transfer, config, transfer_sol_fee)
@@ -218,6 +231,7 @@ async def _transfer(transfer: Transfer, config: Config, cmd_params: TransferCmdP
218
231
 
219
232
 
220
233
  def _print_transfers(config: Config) -> None:
234
+ """Print a table of all configured transfer routes."""
221
235
  table = Table("n", "from_address", "to_address", "value", title="transfers")
222
236
  for count, transfer in enumerate(config.transfers, start=1):
223
237
  table.add_row(str(count), transfer.from_address, transfer.to_address, transfer.value)
@@ -226,6 +240,7 @@ def _print_transfers(config: Config) -> None:
226
240
 
227
241
 
228
242
  async def _print_balances(config: Config) -> None:
243
+ """Print a live-updating table of SOL and token balances for all transfer routes."""
229
244
  if config.token:
230
245
  headers = ["n", "from_address", "sol", "t", "to_address", "sol", "t"]
231
246
  else:
@@ -259,11 +274,13 @@ async def _print_balances(config: Config) -> None:
259
274
 
260
275
 
261
276
  async def _get_sol_balance_str(address: str, config: Config) -> str:
277
+ """Fetch SOL balance and return it as a formatted string."""
262
278
  res = await retry.get_sol_balance(5, config.nodes, config.proxies, address=address)
263
279
  return res.map(lambda ok: str(lamports_to_sol(ok, config.round_ndigits))).value_or_error()
264
280
 
265
281
 
266
282
  async def _get_token_balance_str(address: str, config: Config) -> str:
283
+ """Fetch token balance and return it as a formatted string."""
267
284
  if not config.token:
268
285
  raise ValueError("token is not set")
269
286
  res = await mm_sol.retry.get_token_balance(5, config.nodes, config.proxies, owner=address, token=config.token)
@@ -0,0 +1 @@
1
+ """Wallet-related CLI commands."""
@@ -1,6 +1,8 @@
1
+ """Keypair details display command."""
2
+
1
3
  from pathlib import Path
2
4
 
3
- import mm_print
5
+ from mm_print import print_json
4
6
 
5
7
  from mm_sol.account import (
6
8
  get_private_key_arr_str,
@@ -10,10 +12,11 @@ from mm_sol.account import (
10
12
 
11
13
 
12
14
  def run(private_key: str) -> None:
15
+ """Print public key, base58 private key, and array private key from a private key input."""
13
16
  if (file := Path(private_key)).is_file():
14
17
  private_key = file.read_text()
15
18
 
16
19
  public = get_public_key(private_key)
17
20
  private_base58 = get_private_key_base58(private_key)
18
21
  private_arr = get_private_key_arr_str(private_key)
19
- mm_print.json({"public": public, "private_base58": private_base58, "private_arr": private_arr})
22
+ print_json({"public": public, "private_base58": private_base58, "private_arr": private_arr})
@@ -1,12 +1,15 @@
1
+ """Mnemonic generation and account derivation command."""
2
+
1
3
  from dataclasses import asdict
2
4
  from typing import Any
3
5
 
4
- import mm_print
6
+ from mm_print import print_json
5
7
 
6
8
  from mm_sol.account import derive_accounts, generate_mnemonic
7
9
 
8
10
 
9
11
  def run(mnemonic: str, passphrase: str, words: int, derivation_path: str, limit: int) -> None: # nosec
12
+ """Generate or use a mnemonic to derive accounts and print results."""
10
13
  result: dict[str, Any] = {}
11
14
  if not mnemonic:
12
15
  mnemonic = generate_mnemonic(words)
@@ -16,4 +19,4 @@ def run(mnemonic: str, passphrase: str, words: int, derivation_path: str, limit:
16
19
  accounts = derive_accounts(mnemonic=mnemonic, passphrase=passphrase, derivation_path=derivation_path, limit=limit)
17
20
 
18
21
  result["accounts"] = [asdict(acc) for acc in accounts]
19
- mm_print.json(result)
22
+ print_json(result)
mm_sol/cli/validators.py CHANGED
@@ -1,3 +1,5 @@
1
+ """Solana-specific config validators for CLI commands."""
2
+
1
3
  from collections.abc import Callable
2
4
 
3
5
  from mm_web3 import ConfigValidators
@@ -9,30 +11,39 @@ from mm_sol.constants import UNIT_DECIMALS
9
11
 
10
12
 
11
13
  class Validators(ConfigValidators):
14
+ """Pydantic validators for Solana addresses, private keys, and value expressions."""
15
+
12
16
  @staticmethod
13
17
  def sol_address() -> Callable[[str], str]:
18
+ """Return a validator for a single Solana address."""
14
19
  return ConfigValidators.address(is_address)
15
20
 
16
21
  @staticmethod
17
22
  def sol_addresses(unique: bool) -> Callable[[str], list[str]]:
23
+ """Return a validator for a list of Solana addresses."""
18
24
  return ConfigValidators.addresses(unique, is_address=is_address)
19
25
 
20
26
  @staticmethod
21
27
  def sol_transfers() -> Callable[[str], list[Transfer]]:
28
+ """Return a validator for transfer routes with Solana addresses."""
22
29
  return ConfigValidators.transfers(is_address)
23
30
 
24
31
  @staticmethod
25
32
  def sol_private_keys() -> Callable[[str], PrivateKeyMap]:
33
+ """Return a validator for private key mappings."""
26
34
  return ConfigValidators.private_keys(get_public_key)
27
35
 
28
36
  @staticmethod
29
37
  def valid_sol_expression(var_name: str | None = None) -> Callable[[str], str]:
38
+ """Return a validator for SOL value expressions."""
30
39
  return ConfigValidators.expression_with_vars(var_name, UNIT_DECIMALS)
31
40
 
32
41
  @staticmethod
33
42
  def valid_token_expression(var_name: str | None = None) -> Callable[[str], str]:
43
+ """Return a validator for token value expressions with 6 decimal places."""
34
44
  return ConfigValidators.expression_with_vars(var_name, {"t": 6})
35
45
 
36
46
  @staticmethod
37
47
  def valid_sol_or_token_expression(var_name: str | None = None) -> Callable[[str], str]:
48
+ """Return a validator accepting both SOL and token value expressions."""
38
49
  return ConfigValidators.expression_with_vars(var_name, UNIT_DECIMALS | {"t": 6})
mm_sol/constants.py CHANGED
@@ -1 +1,4 @@
1
+ """Solana-related constants."""
2
+
1
3
  UNIT_DECIMALS = {"sol": 9}
4
+ """Mapping of unit name to decimal places."""
mm_sol/converters.py CHANGED
@@ -1,23 +1,29 @@
1
+ """Conversion utilities between SOL, lamports, and token smallest units."""
2
+
1
3
  from decimal import Decimal
2
4
 
3
5
 
4
6
  def lamports_to_sol(lamports: int, ndigits: int = 4) -> Decimal:
7
+ """Convert lamports to SOL with the specified decimal precision."""
5
8
  if lamports == 0:
6
9
  return Decimal(0)
7
10
  return Decimal(str(round(lamports / 10**9, ndigits=ndigits)))
8
11
 
9
12
 
10
13
  def to_token(smallest_unit_value: int, decimals: int, ndigits: int = 4) -> Decimal:
14
+ """Convert a token's smallest unit value to a human-readable Decimal."""
11
15
  if smallest_unit_value == 0:
12
16
  return Decimal(0)
13
17
  return Decimal(str(round(smallest_unit_value / 10**decimals, ndigits=ndigits)))
14
18
 
15
19
 
16
20
  def sol_to_lamports(sol: Decimal) -> int:
21
+ """Convert SOL amount to lamports."""
17
22
  return int(sol * 10**9)
18
23
 
19
24
 
20
25
  def to_lamports(value: str | int | Decimal, decimals: int | None = None) -> int:
26
+ """Parse a value into lamports. Supports raw int, Decimal, or string with 'sol'/'t' suffix."""
21
27
  if isinstance(value, int):
22
28
  return value
23
29
  if isinstance(value, Decimal):
mm_sol/retry.py CHANGED
@@ -1,3 +1,5 @@
1
+ """Retry wrappers for Solana RPC calls with node and proxy rotation."""
2
+
1
3
  from mm_result import Result
2
4
  from mm_web3 import Nodes, Proxies, retry_with_node_and_proxy
3
5
  from solders.solders import Pubkey, Signature
@@ -6,6 +8,7 @@ from mm_sol import rpc, spl_token, transfer
6
8
 
7
9
 
8
10
  async def get_sol_balance(retries: int, nodes: Nodes, proxies: Proxies, *, address: str, timeout: float = 5) -> Result[int]:
11
+ """Fetch SOL balance in lamports with retries across nodes and proxies."""
9
12
  return await retry_with_node_and_proxy(
10
13
  retries,
11
14
  nodes,
@@ -24,6 +27,7 @@ async def get_token_balance(
24
27
  token_account: str | None = None,
25
28
  timeout: float = 5,
26
29
  ) -> Result[int]:
30
+ """Fetch SPL token balance with retries across nodes and proxies."""
27
31
  return await retry_with_node_and_proxy(
28
32
  retries,
29
33
  nodes,
@@ -53,6 +57,7 @@ async def transfer_token(
53
57
  timeout: float = 10,
54
58
  create_token_account_if_not_exists: bool = True,
55
59
  ) -> Result[Signature]:
60
+ """Transfer SPL tokens with retries across nodes and proxies."""
56
61
  return await retry_with_node_and_proxy(
57
62
  retries,
58
63
  nodes,
@@ -83,6 +88,7 @@ async def transfer_sol(
83
88
  lamports: int,
84
89
  timeout: float = 10,
85
90
  ) -> Result[Signature]:
91
+ """Transfer SOL with retries across nodes and proxies."""
86
92
  return await retry_with_node_and_proxy(
87
93
  retries,
88
94
  nodes,
@@ -100,6 +106,7 @@ async def transfer_sol(
100
106
 
101
107
 
102
108
  async def get_token_decimals(retries: int, nodes: Nodes, proxies: Proxies, *, token: str, timeout: float = 5) -> Result[int]:
109
+ """Fetch token decimals with retries across nodes and proxies."""
103
110
  return await retry_with_node_and_proxy(
104
111
  retries,
105
112
  nodes,
mm_sol/rpc.py CHANGED
@@ -1,3 +1,5 @@
1
+ """Async Solana JSON-RPC client with HTTP and WebSocket support."""
2
+
1
3
  import json
2
4
  from collections.abc import Sequence
3
5
  from typing import Any
@@ -15,6 +17,7 @@ async def rpc_call(
15
17
  proxy: str | None,
16
18
  id_: int = 1,
17
19
  ) -> Result[Any]:
20
+ """Send a JSON-RPC request to a Solana node via HTTP or WebSocket."""
18
21
  data = {"jsonrpc": "2.0", "method": method, "params": params, "id": id_}
19
22
  if node.startswith("http"):
20
23
  return await _http_call(node, data, timeout, proxy)
@@ -22,11 +25,12 @@ async def rpc_call(
22
25
 
23
26
 
24
27
  async def _http_call(node: str, data: dict[str, object], timeout: float, proxy: str | None) -> Result[Any]:
28
+ """Execute an RPC call over HTTP."""
25
29
  res = await http_request(node, method="POST", proxy=proxy, timeout=timeout, json=data)
26
30
  if res.is_err():
27
31
  return res.to_result_err()
28
32
  try:
29
- parsed_body = res.parse_json()
33
+ parsed_body = res.json_body().unwrap("invalid_json")
30
34
  err = parsed_body.get("error", {}).get("message", "")
31
35
  if err:
32
36
  return res.to_result_err(f"service_error: {err}")
@@ -38,6 +42,7 @@ async def _http_call(node: str, data: dict[str, object], timeout: float, proxy:
38
42
 
39
43
 
40
44
  async def _ws_call(node: str, data: dict[str, object], timeout: float) -> Result[Any]:
45
+ """Execute an RPC call over WebSocket."""
41
46
  response = None
42
47
  try:
43
48
  async with websockets.connect(node, open_timeout=timeout) as ws:
@@ -57,11 +62,12 @@ async def _ws_call(node: str, data: dict[str, object], timeout: float) -> Result
57
62
 
58
63
 
59
64
  async def get_block_height(node: str, timeout: float = 5, proxy: str | None = None) -> Result[int]:
65
+ """Return the current block height."""
60
66
  return await rpc_call(node=node, method="getBlockHeight", params=[], timeout=timeout, proxy=proxy)
61
67
 
62
68
 
63
69
  async def get_balance(node: str, address: str, timeout: float = 5, proxy: str | None = None) -> Result[int]:
64
- """Returns balance in lamports"""
70
+ """Return balance in lamports."""
65
71
  return (await rpc_call(node=node, method="getBalance", params=[address], timeout=timeout, proxy=proxy)).map(
66
72
  lambda r: r["value"]
67
73
  )
mm_sol/rpc_sync.py CHANGED
@@ -1,3 +1,5 @@
1
+ """Synchronous Solana JSON-RPC client and response models."""
2
+
1
3
  from typing import Any
2
4
 
3
5
  import pydash
@@ -6,10 +8,15 @@ from mm_result import Result
6
8
  from pydantic import BaseModel, ConfigDict, Field
7
9
 
8
10
  DEFAULT_MAINNET_RPC = "https://api.mainnet-beta.solana.com"
11
+ """Default Solana mainnet RPC endpoint."""
12
+
9
13
  DEFAULT_TESTNET_RPC = "https://api.testnet.solana.com"
14
+ """Default Solana testnet RPC endpoint."""
10
15
 
11
16
 
12
17
  class EpochInfo(BaseModel):
18
+ """Solana epoch information from getEpochInfo RPC call."""
19
+
13
20
  model_config = ConfigDict(populate_by_name=True)
14
21
 
15
22
  epoch: int
@@ -21,10 +28,13 @@ class EpochInfo(BaseModel):
21
28
 
22
29
  @property
23
30
  def progress(self) -> float:
31
+ """Return epoch progress as a percentage."""
24
32
  return round(self.slot_index / self.slots_in_epoch * 100, 2)
25
33
 
26
34
 
27
35
  class ClusterNode(BaseModel):
36
+ """A node in the Solana cluster from getClusterNodes."""
37
+
28
38
  pubkey: str
29
39
  version: str | None
30
40
  gossip: str | None
@@ -32,7 +42,11 @@ class ClusterNode(BaseModel):
32
42
 
33
43
 
34
44
  class VoteAccount(BaseModel):
45
+ """Validator vote account with stake and credit info."""
46
+
35
47
  class EpochCredits(BaseModel):
48
+ """Credits earned by a validator in a single epoch."""
49
+
36
50
  epoch: int
37
51
  credits: int
38
52
  previous_credits: int
@@ -49,7 +63,11 @@ class VoteAccount(BaseModel):
49
63
 
50
64
 
51
65
  class BlockProduction(BaseModel):
66
+ """Block production statistics for a slot range."""
67
+
52
68
  class Leader(BaseModel):
69
+ """Per-leader block production stats."""
70
+
53
71
  address: str
54
72
  produced: int
55
73
  skipped: int
@@ -61,14 +79,18 @@ class BlockProduction(BaseModel):
61
79
 
62
80
  @property
63
81
  def total_produced(self) -> int:
82
+ """Return total blocks produced across all leaders."""
64
83
  return sum(leader.produced for leader in self.leaders)
65
84
 
66
85
  @property
67
86
  def total_skipped(self) -> int:
87
+ """Return total blocks skipped across all leaders."""
68
88
  return sum(leader.skipped for leader in self.leaders)
69
89
 
70
90
 
71
91
  class StakeActivation(BaseModel):
92
+ """Stake activation status for a stake account."""
93
+
72
94
  state: str
73
95
  active: int
74
96
  inactive: int
@@ -83,6 +105,7 @@ def rpc_call(
83
105
  timeout: float = 5,
84
106
  proxy: str | None = None,
85
107
  ) -> Result[Any]:
108
+ """Send a synchronous JSON-RPC request to a Solana node."""
86
109
  data = {"jsonrpc": "2.0", "method": method, "params": params, "id": id_}
87
110
  if node.startswith("http"):
88
111
  return _http_call(node, data, timeout, proxy)
@@ -90,12 +113,13 @@ def rpc_call(
90
113
 
91
114
 
92
115
  def _http_call(node: str, data: dict[str, object], timeout: float, proxy: str | None) -> Result[Any]:
116
+ """Execute a synchronous RPC call over HTTP."""
93
117
  res = http_request_sync(node, method="POST", proxy=proxy, timeout=timeout, json=data)
94
118
  try:
95
119
  if res.is_err():
96
120
  return res.to_result_err()
97
121
 
98
- json_body = res.parse_json()
122
+ json_body = res.json_body().unwrap("invalid_json")
99
123
  err = pydash.get(json_body, "error.message")
100
124
  if err:
101
125
  return res.to_result_err(f"service_error: {err}")
@@ -108,35 +132,40 @@ def _http_call(node: str, data: dict[str, object], timeout: float, proxy: str |
108
132
 
109
133
 
110
134
  def get_balance(node: str, address: str, timeout: float = 5, proxy: str | None = None) -> Result[int]:
111
- """Returns balance in lamports"""
135
+ """Return balance in lamports."""
112
136
  return rpc_call(node=node, method="getBalance", params=[address], timeout=timeout, proxy=proxy).map(lambda r: r["value"])
113
137
 
114
138
 
115
139
  def get_block_height(node: str, timeout: float = 5, proxy: str | None = None) -> Result[int]:
116
- """Returns balance in lamports"""
140
+ """Return the current block height."""
117
141
  return rpc_call(node=node, method="getBlockHeight", params=[], timeout=timeout, proxy=proxy)
118
142
 
119
143
 
120
144
  def get_slot(node: str, timeout: float = 5, proxy: str | None = None) -> Result[int]:
145
+ """Return the current slot number."""
121
146
  return rpc_call(node=node, method="getSlot", params=[], timeout=timeout, proxy=proxy)
122
147
 
123
148
 
124
149
  def get_epoch_info(node: str, epoch: int | None = None, timeout: float = 5, proxy: str | None = None) -> Result[EpochInfo]:
150
+ """Return epoch information, optionally for a specific epoch."""
125
151
  params = [epoch] if epoch else []
126
152
  return rpc_call(node=node, method="getEpochInfo", params=params, timeout=timeout, proxy=proxy).map(lambda r: EpochInfo(**r))
127
153
 
128
154
 
129
155
  def get_health(node: str, timeout: float = 5, proxy: str | None = None) -> Result[bool]:
156
+ """Check whether the node is healthy."""
130
157
  return rpc_call(node=node, method="getHealth", params=[], timeout=timeout, proxy=proxy).map(lambda r: r == "ok")
131
158
 
132
159
 
133
160
  def get_cluster_nodes(node: str, timeout: float = 5, proxy: str | None = None) -> Result[list[ClusterNode]]:
161
+ """Return the list of cluster nodes."""
134
162
  return rpc_call(node=node, method="getClusterNodes", timeout=timeout, proxy=proxy, params=[]).map(
135
163
  lambda r: [ClusterNode(**n) for n in r],
136
164
  )
137
165
 
138
166
 
139
167
  def get_vote_accounts(node: str, timeout: float = 5, proxy: str | None = None) -> Result[list[VoteAccount]]:
168
+ """Return current and delinquent vote accounts."""
140
169
  res = rpc_call(node=node, method="getVoteAccounts", timeout=timeout, proxy=proxy, params=[])
141
170
  if res.is_err():
142
171
  return res
@@ -186,6 +215,7 @@ def get_leader_scheduler(
186
215
  timeout: float = 5,
187
216
  proxy: str | None = None,
188
217
  ) -> Result[dict[str, list[int]]]:
218
+ """Return the leader schedule, optionally for a specific slot."""
189
219
  return rpc_call(
190
220
  node=node,
191
221
  method="getLeaderSchedule",
@@ -196,6 +226,7 @@ def get_leader_scheduler(
196
226
 
197
227
 
198
228
  def get_stake_activation(node: str, address: str, timeout: float = 60, proxy: str | None = None) -> Result[StakeActivation]:
229
+ """Return stake activation status for a stake account address."""
199
230
  return rpc_call(node=node, method="getStakeActivation", timeout=timeout, proxy=proxy, params=[address]).map(
200
231
  lambda ok: StakeActivation(**ok),
201
232
  )
@@ -209,6 +240,7 @@ def get_transaction(
209
240
  timeout: float = 5,
210
241
  proxy: str | None = None,
211
242
  ) -> Result[dict[str, object] | None]:
243
+ """Return transaction details for a given signature."""
212
244
  if max_supported_transaction_version is not None:
213
245
  params = [signature, {"maxSupportedTransactionVersion": max_supported_transaction_version, "encoding": encoding}]
214
246
  else:
mm_sol/spl_token.py CHANGED
@@ -1,3 +1,5 @@
1
+ """SPL token balance and metadata queries via Solana RPC."""
2
+
1
3
  from mm_result import Result
2
4
  from solana.exceptions import SolanaRpcException
3
5
  from solana.rpc.core import RPCException
@@ -14,6 +16,7 @@ async def get_balance(
14
16
  timeout: float = 5,
15
17
  proxy: str | None = None,
16
18
  ) -> Result[int]:
19
+ """Return the token balance in smallest units for the owner's associated token account."""
17
20
  response = None
18
21
  try:
19
22
  client = get_async_client(node, proxy=proxy, timeout=timeout)
@@ -38,6 +41,7 @@ async def get_balance(
38
41
 
39
42
 
40
43
  async def get_decimals(node: str, token: str, timeout: float = 5, proxy: str | None = None) -> Result[int]:
44
+ """Return the number of decimals for a token mint."""
41
45
  response = None
42
46
  try:
43
47
  client = get_async_client(node, proxy=proxy, timeout=timeout)
mm_sol/transfer.py CHANGED
@@ -1,3 +1,5 @@
1
+ """SOL and SPL token transfer operations."""
2
+
1
3
  import pydash
2
4
  from mm_result import Result
3
5
  from pydantic import BaseModel
@@ -27,6 +29,7 @@ async def transfer_token(
27
29
  timeout: float = 10,
28
30
  create_token_account_if_not_exists: bool = True,
29
31
  ) -> Result[Signature]:
32
+ """Transfer SPL tokens, optionally creating the recipient's token account."""
30
33
  # TODO: try/except this function!!!
31
34
  acc = get_keypair(private_key)
32
35
  if not check_private_key(from_address, private_key):
@@ -74,6 +77,7 @@ async def transfer_sol(
74
77
  proxy: str | None = None,
75
78
  timeout: float = 10,
76
79
  ) -> Result[Signature]:
80
+ """Transfer SOL from one account to another."""
77
81
  acc = get_keypair(private_key)
78
82
  if not check_private_key(from_address, private_key):
79
83
  return Result.err("invalid_private_key")
@@ -93,12 +97,15 @@ async def transfer_sol(
93
97
 
94
98
 
95
99
  class SolTransferInfo(BaseModel):
100
+ """Parsed SOL transfer from a transaction's instructions."""
101
+
96
102
  source: str
97
103
  destination: str
98
104
  lamports: int
99
105
 
100
106
 
101
107
  def find_sol_transfers(node: str, tx_signature: str) -> Result[list[SolTransferInfo]]:
108
+ """Parse SOL transfer instructions from a transaction signature."""
102
109
  res = rpc_sync.get_transaction(node, tx_signature, encoding="jsonParsed")
103
110
  if res.is_err():
104
111
  return res # type: ignore[return-value]
@@ -115,4 +122,4 @@ def find_sol_transfers(node: str, tx_signature: str) -> Result[list[SolTransferI
115
122
  result.append(SolTransferInfo(source=source, destination=destination, lamports=lamports))
116
123
  return res.with_value(result)
117
124
  except Exception as e:
118
- return Result.err(e, res.extra)
125
+ return Result.err(e, res.context)
mm_sol/utils.py CHANGED
@@ -1,3 +1,5 @@
1
+ """Utility functions for creating Solana RPC clients and converting pubkeys."""
2
+
1
3
  from solana.rpc.api import Client
2
4
  from solana.rpc.async_api import AsyncClient
3
5
  from solana.rpc.commitment import Commitment
@@ -11,6 +13,7 @@ def get_client(
11
13
  proxy: str | None = None,
12
14
  timeout: float = 10,
13
15
  ) -> Client:
16
+ """Create a synchronous Solana RPC client."""
14
17
  return Client(endpoint, commitment=commitment, extra_headers=extra_headers, timeout=timeout, proxy=proxy)
15
18
 
16
19
 
@@ -21,10 +24,12 @@ def get_async_client(
21
24
  proxy: str | None = None,
22
25
  timeout: float = 10,
23
26
  ) -> AsyncClient:
27
+ """Create an asynchronous Solana RPC client."""
24
28
  return AsyncClient(endpoint, commitment=commitment, extra_headers=extra_headers, timeout=timeout, proxy=proxy)
25
29
 
26
30
 
27
31
  def pubkey(value: str | Pubkey) -> Pubkey:
32
+ """Convert a string or Pubkey to a Pubkey instance."""
28
33
  if isinstance(value, Pubkey):
29
34
  return value
30
35
  return Pubkey.from_string(value)
@@ -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,31 @@
1
+ mm_sol/__init__.py,sha256=fUeHKoD3cV4rOVWUrfNuugxIe71FcaWQkhnoyvtZrZg,54
2
+ mm_sol/account.py,sha256=ytwiAzxvrjNryFaCDfIAuMMchluxm8gmNTX0w09HBVE,4538
3
+ mm_sol/constants.py,sha256=2Lbzqi6hA_2edJ-AYgJszPGH2r9tOyXNWlabqE19uUM,106
4
+ mm_sol/converters.py,sha256=NhgSEYdkz63Ph7G52vCCoSTlqD4ncO4xRtK7eXT_EQE,1797
5
+ mm_sol/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ mm_sol/retry.py,sha256=RILvfBtpHjDwacrX2aCdtSkAvLB9Cn59SoQa9Dv3VQE,3315
7
+ mm_sol/rpc.py,sha256=o3PEx4at5JCZ4qKkZTMDozr4kt1ZKeGPBKeJxk2owFk,2768
8
+ mm_sol/rpc_sync.py,sha256=XVs6vA1o5Y1I1MvMWv0ePDBcwVdj7rMWX7QJULfEB8Y,8531
9
+ mm_sol/spl_token.py,sha256=yw-tIH-wbfrTf7WdjMr39ZbAO4Ip-T0wTd8YBP3K-0w,2159
10
+ mm_sol/transfer.py,sha256=7roDEqE6qyUZCsw4J4MBMZwBlEVPTnls0vX5AupFEB0,4733
11
+ mm_sol/utils.py,sha256=ZeTESNwYEcMfGl523WwnMwzWIGT-SEh5WsAz62kEIE8,1165
12
+ mm_sol/cli/__init__.py,sha256=AoiQ74zt-QmDnw1wLOfMW5HLNtZrj6Ut9DExNQ0CLDA,32
13
+ mm_sol/cli/calcs.py,sha256=jb6AnmXQDm3fgYxOhjmN5E6caCdBGkpm0IiN7WwWL00,2184
14
+ mm_sol/cli/cli.py,sha256=-y2uvJAS__4pSK9z61R42pWcuGPBjV8dcb5z7SGbHdI,5457
15
+ mm_sol/cli/cli_utils.py,sha256=UdRN-NTvxyJjWS3c2dzznK9TCg69ptP_3V3acgy3jfA,2471
16
+ mm_sol/cli/validators.py,sha256=cMANe5jbMrw9Bb2PrCvciaKmEjWpYEykdice29x9f1s,2037
17
+ mm_sol/cli/cmd/__init__.py,sha256=GYdoNv80KS2p7bCThuoRbhiLmIZW6ddPqPEFEx567nQ,35
18
+ mm_sol/cli/cmd/balance_cmd.py,sha256=mEfpcL4LD3do9mwIwSXg-qz2gZ7YT32zYhDLSXx5cpw,2816
19
+ mm_sol/cli/cmd/balances_cmd.py,sha256=MnZZNc6cSAneaDyTZ9BxZjUlUgRg1TG84RyGzd9HwCg,3072
20
+ mm_sol/cli/cmd/example_cmd.py,sha256=8DM8Zbuj8FMzzd04ywfALXfzpYxvUy3sS-r5oj7vz3M,338
21
+ mm_sol/cli/cmd/node_cmd.py,sha256=DukE_9VuoPEWxlberIRwZ9kUhXgbQv1n0xGsl-cc8kI,463
22
+ mm_sol/cli/cmd/transfer_cmd.py,sha256=A1bfeUvFvU16SFfWt7i0FfswIhA-PH5fld4oHOQAI1w,12143
23
+ mm_sol/cli/cmd/wallet/__init__.py,sha256=et8EPGCXsVukSYnO29E1Wn_O0_Sf8FKxc-09UuFL_gk,35
24
+ mm_sol/cli/cmd/wallet/keypair_cmd.py,sha256=XsToFLoQjxbK4q9-UZZtLa7U7ZGBDS7-HMrWSyufhUA,676
25
+ mm_sol/cli/cmd/wallet/mnemonic_cmd.py,sha256=XzCpmpaiKgrButhEmPe6Ww51Pmml_Q727654dU7i6Wo,784
26
+ mm_sol/cli/examples/balances.toml,sha256=UxX5FXKhH1lwjniiOQW-hZP0C731W96o5-1V1yqg0cQ,350
27
+ mm_sol/cli/examples/transfer.toml,sha256=-7inhWntAHKYqr0spmRYyNflUrYWscv_xMoWwytS61E,1623
28
+ mm_sol-0.8.0.dist-info/METADATA,sha256=t6wCPNR7ZD5kVFn4xV_Pbb4r2jMSj5GTmdQM06f8q6o,252
29
+ mm_sol-0.8.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
30
+ mm_sol-0.8.0.dist-info/entry_points.txt,sha256=MrYnosumy9nsITSAw5TiR3WXDwsdoF0YvUIlZ38TLLs,46
31
+ mm_sol-0.8.0.dist-info/RECORD,,
@@ -1,31 +0,0 @@
1
- mm_sol/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- mm_sol/account.py,sha256=cVcxRQBuV_Gfm2WgQIwaYuAQijeIJqDDxLC22PN0XSs,3493
3
- mm_sol/constants.py,sha256=9XKF8Z2dyY6L82_Bm0_naSQp5U0mIaOeqbZNBj25D2E,27
4
- mm_sol/converters.py,sha256=rBxe3SIADZS8hG7TYl4FgjmvKH-ykaTmNbnWWQDiFZ4,1430
5
- mm_sol/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
- mm_sol/retry.py,sha256=IjaKvIe13_kRkwUxEpgNaZ0MNAW9bSOW5H33BL8GNA4,2889
7
- mm_sol/rpc.py,sha256=TFgU6H5la7ctEgPyQ0CW8SxipCycx8FFCu1baR6F_mM,2473
8
- mm_sol/rpc_sync.py,sha256=ryS6ZuPrrZsfQUuizVHbgcr-IX_jS9hF4J4B1ya2FAU,7190
9
- mm_sol/spl_token.py,sha256=Bd5zXhlVhFd5n3WwunRoeM_yYo-0f1hNR7_1zww81A8,1944
10
- mm_sol/transfer.py,sha256=kfNGRjF4aNSLAyBdm0jHyCXENFTMwib4pqIhKRJGCCQ,4413
11
- mm_sol/utils.py,sha256=oD06NsMSMhN6lqsM6mSgLTtiKwA1uAsen9WR82ofRTE,923
12
- mm_sol/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
- mm_sol/cli/calcs.py,sha256=lgXF5SJkwOEY2AjygBO9CxP-LpyvOWB7j5HQn6p-1tU,1765
14
- mm_sol/cli/cli.py,sha256=TxAAQ-QX9j9mG6d6el9SLYxF3QeIeWal7AdW_GTzPZQ,4723
15
- mm_sol/cli/cli_utils.py,sha256=1_ikUjwHkxJleNwhVLaI_-6m4JyCKZFH2WgvsZ5EGMk,1799
16
- mm_sol/cli/validators.py,sha256=DnK2C68MgceqQ6Zs90WZ6Co0LYMiNmhxZILX-2GKhZM,1401
17
- mm_sol/cli/cmd/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
18
- mm_sol/cli/cmd/balance_cmd.py,sha256=6h7ryybMyegy8EsV1limWo2gcYL9_dczK9JRiHxHLzg,2452
19
- mm_sol/cli/cmd/balances_cmd.py,sha256=u8dSexKVVExC-5U-8vPUDFUBF2r-j-vsSmmOBM-Tsqc,2673
20
- mm_sol/cli/cmd/example_cmd.py,sha256=HFEjAPxIPAss3u--i3mLpvVttCkmY1Ps4aEfNLPp8bw,209
21
- mm_sol/cli/cmd/node_cmd.py,sha256=octbG9IwCUKyaFgGrPf_2MJlzHKncMQhGlyi-1rqw-E,339
22
- mm_sol/cli/cmd/transfer_cmd.py,sha256=6q5LbWWl6AvQI2u4kh3qMQpZndPkdturTn_1wgGflNo,11023
23
- mm_sol/cli/cmd/wallet/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
24
- mm_sol/cli/cmd/wallet/keypair_cmd.py,sha256=HndbANn2XbuHWxD-8ggKEGUK1OT1tjiVTtR5G6F8XFw,527
25
- mm_sol/cli/cmd/wallet/mnemonic_cmd.py,sha256=xT0wzBtj9IeJHvrzst0jFjXP4pj_k17JsKX_PBNjIO4,637
26
- mm_sol/cli/examples/balances.toml,sha256=UxX5FXKhH1lwjniiOQW-hZP0C731W96o5-1V1yqg0cQ,350
27
- mm_sol/cli/examples/transfer.toml,sha256=-7inhWntAHKYqr0spmRYyNflUrYWscv_xMoWwytS61E,1623
28
- mm_sol-0.7.4.dist-info/METADATA,sha256=qZbfGj2Wz16APhjeC5GVH2bxpllHii6GUT5BU9fzl1g,252
29
- mm_sol-0.7.4.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
30
- mm_sol-0.7.4.dist-info/entry_points.txt,sha256=MrYnosumy9nsITSAw5TiR3WXDwsdoF0YvUIlZ38TLLs,46
31
- mm_sol-0.7.4.dist-info/RECORD,,
File without changes