mm-eth 0.5.9__tar.gz → 0.7.1__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 (98) hide show
  1. mm_eth-0.7.1/PKG-INFO +7 -0
  2. {mm_eth-0.5.9 → mm_eth-0.7.1}/justfile +3 -1
  3. mm_eth-0.7.1/pyproject.toml +87 -0
  4. {mm_eth-0.5.9 → mm_eth-0.7.1}/src/mm_eth/abi.py +3 -5
  5. mm_eth-0.7.1/src/mm_eth/account.py +104 -0
  6. {mm_eth-0.5.9 → mm_eth-0.7.1}/src/mm_eth/anvil.py +18 -9
  7. mm_eth-0.7.1/src/mm_eth/cli/calcs.py +11 -0
  8. mm_eth-0.7.1/src/mm_eth/cli/cli.py +138 -0
  9. mm_eth-0.7.1/src/mm_eth/cli/cli_utils.py +57 -0
  10. mm_eth-0.7.1/src/mm_eth/cli/cmd/balance_cmd.py +47 -0
  11. mm_eth-0.7.1/src/mm_eth/cli/cmd/balances_cmd.py +118 -0
  12. mm_eth-0.7.1/src/mm_eth/cli/cmd/deploy_cmd.py +47 -0
  13. {mm_eth-0.5.9 → mm_eth-0.7.1}/src/mm_eth/cli/cmd/node_cmd.py +22 -16
  14. mm_eth-0.7.1/src/mm_eth/cli/cmd/solc_cmd.py +25 -0
  15. mm_eth-0.7.1/src/mm_eth/cli/cmd/transfer_cmd.py +417 -0
  16. {mm_eth-0.5.9 → mm_eth-0.7.1}/src/mm_eth/cli/cmd/wallet/mnemonic_cmd.py +2 -2
  17. mm_eth-0.7.1/src/mm_eth/cli/cmd/wallet/private_key_cmd.py +11 -0
  18. mm_eth-0.7.1/src/mm_eth/cli/rpc_helpers.py +50 -0
  19. mm_eth-0.7.1/src/mm_eth/cli/validators.py +45 -0
  20. mm_eth-0.7.1/src/mm_eth/converters.py +56 -0
  21. mm_eth-0.7.1/src/mm_eth/erc20.py +40 -0
  22. mm_eth-0.7.1/src/mm_eth/retry.py +153 -0
  23. mm_eth-0.7.1/src/mm_eth/rpc.py +278 -0
  24. mm_eth-0.7.1/src/mm_eth/solc.py +47 -0
  25. {mm_eth-0.5.9 → mm_eth-0.7.1}/src/mm_eth/tx.py +9 -11
  26. mm_eth-0.7.1/src/mm_eth/utils.py +24 -0
  27. mm_eth-0.7.1/tests/cli/cmd/test_balance_cmd.py +10 -0
  28. mm_eth-0.7.1/tests/cli/cmd/test_node_cmd.py +9 -0
  29. mm_eth-0.7.1/tests/cli/cmd/test_solc_cmd.py +12 -0
  30. mm_eth-0.7.1/tests/cli/cmd/wallet/__init__.py +0 -0
  31. mm_eth-0.7.1/tests/cli/cmd/wallet/test_mnemonic_cmd.py +9 -0
  32. mm_eth-0.7.1/tests/cli/cmd/wallet/test_private_key_cmd.py +12 -0
  33. {mm_eth-0.5.9 → mm_eth-0.7.1}/tests/conftest.py +71 -55
  34. {mm_eth-0.5.9 → mm_eth-0.7.1}/tests/test_account.py +5 -8
  35. mm_eth-0.7.1/tests/test_converters.py +23 -0
  36. mm_eth-0.7.1/tests/test_rpc.py +67 -0
  37. mm_eth-0.7.1/uv.lock +1407 -0
  38. mm_eth-0.5.9/PKG-INFO +0 -9
  39. mm_eth-0.5.9/pyproject.toml +0 -85
  40. mm_eth-0.5.9/src/mm_eth/account.py +0 -71
  41. mm_eth-0.5.9/src/mm_eth/cli/calcs.py +0 -27
  42. mm_eth-0.5.9/src/mm_eth/cli/cli.py +0 -241
  43. mm_eth-0.5.9/src/mm_eth/cli/cli_utils.py +0 -62
  44. mm_eth-0.5.9/src/mm_eth/cli/cmd/balance_cmd.py +0 -47
  45. mm_eth-0.5.9/src/mm_eth/cli/cmd/balances_cmd.py +0 -118
  46. mm_eth-0.5.9/src/mm_eth/cli/cmd/call_contract_cmd.py +0 -44
  47. mm_eth-0.5.9/src/mm_eth/cli/cmd/deploy_cmd.py +0 -44
  48. mm_eth-0.5.9/src/mm_eth/cli/cmd/encode_input_data_cmd.py +0 -10
  49. mm_eth-0.5.9/src/mm_eth/cli/cmd/example_cmd.py +0 -9
  50. mm_eth-0.5.9/src/mm_eth/cli/cmd/rpc_cmd.py +0 -78
  51. mm_eth-0.5.9/src/mm_eth/cli/cmd/solc_cmd.py +0 -24
  52. mm_eth-0.5.9/src/mm_eth/cli/cmd/token_cmd.py +0 -29
  53. mm_eth-0.5.9/src/mm_eth/cli/cmd/transfer_cmd.py +0 -336
  54. mm_eth-0.5.9/src/mm_eth/cli/cmd/tx_cmd.py +0 -16
  55. mm_eth-0.5.9/src/mm_eth/cli/cmd/vault_cmd.py +0 -19
  56. mm_eth-0.5.9/src/mm_eth/cli/cmd/wallet/private_key_cmd.py +0 -10
  57. mm_eth-0.5.9/src/mm_eth/cli/examples/balances.toml +0 -18
  58. mm_eth-0.5.9/src/mm_eth/cli/examples/call_contract.toml +0 -9
  59. mm_eth-0.5.9/src/mm_eth/cli/examples/transfer.toml +0 -46
  60. mm_eth-0.5.9/src/mm_eth/cli/print_helpers.py +0 -37
  61. mm_eth-0.5.9/src/mm_eth/cli/rpc_helpers.py +0 -133
  62. mm_eth-0.5.9/src/mm_eth/cli/validators.py +0 -48
  63. mm_eth-0.5.9/src/mm_eth/constants.py +0 -1
  64. mm_eth-0.5.9/src/mm_eth/ens.py +0 -23
  65. mm_eth-0.5.9/src/mm_eth/erc20.py +0 -237
  66. mm_eth-0.5.9/src/mm_eth/ethernodes.py +0 -34
  67. mm_eth-0.5.9/src/mm_eth/json_encoder.py +0 -15
  68. mm_eth-0.5.9/src/mm_eth/rpc.py +0 -475
  69. mm_eth-0.5.9/src/mm_eth/solc.py +0 -33
  70. mm_eth-0.5.9/src/mm_eth/utils.py +0 -230
  71. mm_eth-0.5.9/src/mm_eth/vault.py +0 -38
  72. mm_eth-0.5.9/tests/cli/cmd/test_balance_cmd.py +0 -11
  73. mm_eth-0.5.9/tests/cli/cmd/test_mnemonic_cmd.py +0 -10
  74. mm_eth-0.5.9/tests/cli/cmd/test_node_cmd.py +0 -10
  75. mm_eth-0.5.9/tests/cli/cmd/test_private_key_cmd.py +0 -8
  76. mm_eth-0.5.9/tests/cli/cmd/test_solc_cmd.py +0 -10
  77. mm_eth-0.5.9/tests/cli/test_calcs.py +0 -8
  78. mm_eth-0.5.9/tests/contracts/abi/ERC20.json +0 -72
  79. mm_eth-0.5.9/tests/test_ens.py +0 -13
  80. mm_eth-0.5.9/tests/test_ethernodes.py +0 -8
  81. mm_eth-0.5.9/tests/test_rpc.py +0 -103
  82. mm_eth-0.5.9/tests/test_utils.py +0 -46
  83. mm_eth-0.5.9/uv.lock +0 -1608
  84. {mm_eth-0.5.9 → mm_eth-0.7.1}/.gitignore +0 -0
  85. {mm_eth-0.5.9 → mm_eth-0.7.1}/README.md +0 -0
  86. {mm_eth-0.5.9 → mm_eth-0.7.1}/dict.dic +0 -0
  87. {mm_eth-0.5.9 → mm_eth-0.7.1}/src/mm_eth/__init__.py +0 -0
  88. {mm_eth-0.5.9 → mm_eth-0.7.1}/src/mm_eth/cli/__init__.py +0 -0
  89. {mm_eth-0.5.9 → mm_eth-0.7.1}/src/mm_eth/cli/cmd/__init__.py +0 -0
  90. {mm_eth-0.5.9 → mm_eth-0.7.1}/src/mm_eth/cli/cmd/wallet/__init__.py +0 -0
  91. {mm_eth-0.5.9 → mm_eth-0.7.1}/src/mm_eth/deploy.py +0 -0
  92. {mm_eth-0.5.9 → mm_eth-0.7.1}/src/mm_eth/py.typed +0 -0
  93. {mm_eth-0.5.9 → mm_eth-0.7.1}/tests/__init__.py +0 -0
  94. {mm_eth-0.5.9 → mm_eth-0.7.1}/tests/cli/__init__.py +0 -0
  95. {mm_eth-0.5.9 → mm_eth-0.7.1}/tests/cli/cmd/__init__.py +0 -0
  96. {mm_eth-0.5.9 → mm_eth-0.7.1}/tests/contracts/ERC20.sol +0 -0
  97. {mm_eth-0.5.9 → mm_eth-0.7.1}/tests/test_abi.py +0 -0
  98. {mm_eth-0.5.9 → mm_eth-0.7.1}/tests/test_tx.py +0 -0
mm_eth-0.7.1/PKG-INFO ADDED
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: mm-eth
3
+ Version: 0.7.1
4
+ Requires-Python: >=3.13
5
+ Requires-Dist: mm-web3~=0.5.1
6
+ Requires-Dist: typer~=0.16.0
7
+ Requires-Dist: web3~=7.12.0
@@ -20,7 +20,9 @@ lint: format
20
20
  uv run mypy src
21
21
 
22
22
  audit:
23
- uv run pip-audit
23
+ uv export --no-dev --all-extras --format requirements-txt --no-emit-project > requirements.txt
24
+ uv run pip-audit -r requirements.txt --disable-pip
25
+ rm requirements.txt
24
26
  uv run bandit -r -c "pyproject.toml" src
25
27
 
26
28
  publish: build
@@ -0,0 +1,87 @@
1
+ [project]
2
+ name = "mm-eth"
3
+ version = "0.7.1"
4
+ description = ""
5
+ requires-python = ">=3.13"
6
+ dependencies = [
7
+ "mm-web3~=0.5.1",
8
+ "web3~=7.12.0",
9
+ "typer~=0.16.0",
10
+ ]
11
+ [project.scripts]
12
+ mm-eth = "mm_eth.cli.cli:app"
13
+
14
+ [build-system]
15
+ requires = ["hatchling"]
16
+ build-backend = "hatchling.build"
17
+
18
+ [tool.uv]
19
+ dev-dependencies = [
20
+ "pytest~=8.4.0",
21
+ "pytest-asyncio~=1.0.0",
22
+ "pytest-xdist~=3.7.0",
23
+ "ruff~=0.11.13",
24
+ "pip-audit~=2.9.0",
25
+ "bandit~=1.8.3",
26
+ "mypy~=1.16.0",
27
+ "python-dotenv>=1.1.0",
28
+ ]
29
+
30
+ [tool.mypy]
31
+ python_version = "3.13"
32
+ mypy_path = "stubs"
33
+ warn_no_return = false
34
+ implicit_reexport = true
35
+ strict = true
36
+ enable_error_code = ["truthy-bool", "possibly-undefined"]
37
+ exclude = ["^tests/", "^tmp/"]
38
+ [[tool.mypy.overrides]]
39
+ module = ["rlp", "rlp.sedes", "ens.utils", "ens"]
40
+ ignore_missing_imports = true
41
+
42
+ [tool.ruff]
43
+ line-length = 130
44
+ target-version = "py313"
45
+ [tool.ruff.lint]
46
+ select = ["ALL"]
47
+ # fixable = ["F401"]
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
+ "D", # pydocstyle
54
+ "FIX", # flake8-fixme
55
+ "PLR0911", # pylint: too-many-return-statements
56
+ "PLR0912", # pylint: too-many-branches
57
+ "PLR0913", # pylint: too-many-arguments
58
+ "PLR2004", # pylint: magic-value-comparison
59
+ "PLC0414", # pylint: useless-import-alias
60
+ "FBT", # flake8-boolean-trap
61
+ "EM", # flake8-errmsg
62
+ "TRY003", # tryceratops: raise-vanilla-args
63
+ "C901", # mccabe: complex-structure,
64
+ "BLE001", # flake8-blind-except
65
+ "S311", # bandit: suspicious-non-cryptographic-random-usage
66
+ "TD002", # flake8-todos: missing-todo-author
67
+ "TD003", # flake8-todos: missing-todo-link
68
+ "RET503", # flake8-return: implicit-return
69
+ "COM812", # it's used in ruff formatter
70
+ "ASYNC109", # flake8-async: async-function-with-timeout
71
+ "G004",
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"
@@ -11,8 +11,6 @@ from pydantic import BaseModel
11
11
  from web3 import Web3
12
12
  from web3.auto import w3
13
13
 
14
- from mm_eth.utils import hex_to_bytes
15
-
16
14
 
17
15
  @dataclass
18
16
  class NameTypeValue:
@@ -74,7 +72,7 @@ def encode_function_input_by_abi(abi: ABI | ABIFunction, fn_name: str, args: lis
74
72
  # if abi is contract_abi, get function_abi
75
73
  if isinstance(abi, Sequence):
76
74
  abi = get_function_abi(abi, fn_name)
77
- abi = cast(ABIFunction, abi)
75
+ # abi = cast(ABIFunction, abi)
78
76
 
79
77
  # need update all address values to checkSum version
80
78
  processed_args = []
@@ -99,7 +97,7 @@ def encode_function_input_by_signature(func_signature: str, args: list[Any]) ->
99
97
  func_abi: ABIFunction = {
100
98
  "name": func_name,
101
99
  "type": "function",
102
- "inputs": [{"type": t} for t in arg_types], # type: ignore[typeddict-item]
100
+ "inputs": [{"type": t} for t in arg_types],
103
101
  }
104
102
  return encode_function_input_by_abi(func_abi, func_name, args)
105
103
 
@@ -110,7 +108,7 @@ def encode_function_signature(func_name_with_types: str) -> HexStr:
110
108
 
111
109
 
112
110
  def decode_data(types: list[str], data: str) -> tuple[Any, ...]:
113
- return eth_abi.decode(types, hex_to_bytes(data))
111
+ return eth_abi.decode(types, eth_utils.to_bytes(hexstr=HexStr(data)))
114
112
 
115
113
 
116
114
  def encode_data(types: list[str], args: list[Any]) -> str:
@@ -0,0 +1,104 @@
1
+ from dataclasses import dataclass
2
+
3
+ import eth_utils
4
+ from eth_account import Account
5
+ from eth_account.hdaccount import Mnemonic
6
+ from eth_account.signers.local import LocalAccount
7
+ from eth_account.types import Language
8
+ from eth_keys import KeyAPI
9
+ from mm_result import Result
10
+
11
+ Account.enable_unaudited_hdwallet_features()
12
+
13
+ key_api = KeyAPI()
14
+
15
+ # Default derivation path template for Ethereum HD wallets
16
+ DEFAULT_DERIVATION_PATH = "m/44'/60'/0'/0/{i}"
17
+
18
+
19
+ @dataclass
20
+ class DerivedAccount:
21
+ """Represents an account derived from a mnemonic phrase."""
22
+
23
+ index: int
24
+ path: str
25
+ address: str
26
+ private_key: str
27
+
28
+
29
+ def generate_mnemonic(num_words: int = 24) -> str:
30
+ """
31
+ Generates a BIP39 mnemonic phrase in English.
32
+
33
+ Args:
34
+ num_words (int): Number of words in the mnemonic (12, 15, 18, 21, or 24).
35
+
36
+ Returns:
37
+ str: Generated mnemonic phrase.
38
+ """
39
+ mnemonic = Mnemonic(Language.ENGLISH)
40
+ return mnemonic.generate(num_words=num_words)
41
+
42
+
43
+ def derive_accounts(mnemonic: str, passphrase: str, derivation_path: str, limit: int) -> list[DerivedAccount]:
44
+ """
45
+ Derives multiple Ethereum accounts from a given mnemonic phrase.
46
+
47
+ Args:
48
+ mnemonic (str): BIP39 mnemonic phrase.
49
+ passphrase (str): Optional BIP39 passphrase.
50
+ derivation_path (str): Path template with '{i}' as index placeholder.
51
+ limit (int): Number of accounts to derive.
52
+
53
+ Raises:
54
+ ValueError: If derivation_path does not contain '{i}'.
55
+
56
+ Returns:
57
+ list[DerivedAccount]: List of derived Ethereum accounts.
58
+ """
59
+ if "{i}" not in derivation_path:
60
+ raise ValueError("derivation_path must contain {i}, for example: " + DEFAULT_DERIVATION_PATH)
61
+
62
+ result: list[DerivedAccount] = []
63
+ for i in range(limit):
64
+ path = derivation_path.replace("{i}", str(i))
65
+ acc = Account.from_mnemonic(mnemonic, passphrase, path)
66
+ private_key = acc.key.to_0x_hex().lower()
67
+ result.append(DerivedAccount(i, path, acc.address, private_key))
68
+ return result
69
+
70
+
71
+ def private_to_address(private_key: str, lower: bool = False) -> Result[str]:
72
+ """
73
+ Converts a private key to its corresponding Ethereum address.
74
+
75
+ Args:
76
+ private_key (str): Hex-encoded private key.
77
+ lower (bool): Whether to return address in lowercase.
78
+
79
+ Returns:
80
+ Result[str]: Ok(address) or Err(exception) on failure.
81
+ """
82
+ try:
83
+ acc: LocalAccount = Account.from_key(private_key)
84
+ address = acc.address.lower() if lower else acc.address
85
+ return Result.ok(address)
86
+ except Exception as e:
87
+ return Result.err(e)
88
+
89
+
90
+ def is_private_key(private_key: str) -> bool:
91
+ """
92
+ Checks if a given hex string is a valid Ethereum private key.
93
+
94
+ Args:
95
+ private_key (str): Hex-encoded private key.
96
+
97
+ Returns:
98
+ bool: True if valid, False otherwise.
99
+ """
100
+ try:
101
+ key_api.PrivateKey(eth_utils.decode_hex(private_key)).public_key.to_address()
102
+ return True # noqa: TRY300
103
+ except Exception:
104
+ return False
@@ -1,10 +1,11 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import socket
3
4
  import time
4
5
  from subprocess import Popen # nosec
6
+ from typing import cast
5
7
 
6
- from mm_std import Err, Ok, Result
7
- from mm_std.net import get_free_local_port
8
+ from mm_result import Result
8
9
 
9
10
  from mm_eth import account, rpc
10
11
 
@@ -25,16 +26,16 @@ class Anvil:
25
26
  if self.process:
26
27
  self.process.kill()
27
28
 
28
- def check(self) -> bool:
29
- res = rpc.eth_chain_id(self.rpc_url)
30
- return isinstance(res, Ok) and res.ok == self.chain_id
29
+ async def check(self) -> bool:
30
+ res = await rpc.eth_chain_id(self.rpc_url)
31
+ return res.is_ok() and res.unwrap() == self.chain_id
31
32
 
32
33
  @property
33
34
  def rpc_url(self) -> str:
34
35
  return f"http://localhost:{self.port}"
35
36
 
36
37
  @classmethod
37
- def launch(
38
+ async def launch(
38
39
  cls,
39
40
  chain_id: int = 31337,
40
41
  port: int | None = None,
@@ -49,8 +50,16 @@ class Anvil:
49
50
  port = get_free_local_port()
50
51
  anvil = Anvil(chain_id=chain_id, port=port, mnemonic=mnemonic)
51
52
  anvil.start_process()
52
- if anvil.check():
53
- return Ok(anvil)
53
+ if await anvil.check():
54
+ return Result.ok(anvil)
54
55
  port = get_free_local_port()
55
56
 
56
- return Err("can't lauch anvil")
57
+ return Result.err("can't launch anvil")
58
+
59
+
60
+ def get_free_local_port() -> int:
61
+ sock = socket.socket()
62
+ sock.bind(("", 0))
63
+ port = sock.getsockname()[1]
64
+ sock.close()
65
+ return cast(int, port)
@@ -0,0 +1,11 @@
1
+ import mm_web3
2
+
3
+ from mm_eth.cli.validators import SUFFIX_DECIMALS
4
+
5
+
6
+ def calc_eth_expression(expression: str, variables: dict[str, int] | None = None) -> int:
7
+ return mm_web3.calc_expression_with_vars(expression, variables, unit_decimals=SUFFIX_DECIMALS)
8
+
9
+
10
+ def calc_token_expression(expression: str, token_decimals: int, variables: dict[str, int] | None = None) -> int:
11
+ return mm_web3.calc_expression_with_vars(expression, variables, unit_decimals={"t": token_decimals})
@@ -0,0 +1,138 @@
1
+ import asyncio
2
+ import importlib.metadata
3
+ from pathlib import Path
4
+ from typing import Annotated
5
+
6
+ import mm_print
7
+ import typer
8
+
9
+ from mm_eth.account import DEFAULT_DERIVATION_PATH
10
+ from mm_eth.cli.cli_utils import PrintFormat
11
+ from mm_eth.cli.cmd import balance_cmd, balances_cmd, deploy_cmd, node_cmd, solc_cmd, transfer_cmd
12
+ from mm_eth.cli.cmd.balances_cmd import BalancesCmdParams
13
+ from mm_eth.cli.cmd.deploy_cmd import DeployCmdParams
14
+ from mm_eth.cli.cmd.transfer_cmd import TransferCmdParams
15
+ from mm_eth.cli.cmd.wallet import mnemonic_cmd, private_key_cmd
16
+
17
+ app = typer.Typer(no_args_is_help=True, pretty_exceptions_enable=False, add_completion=False)
18
+
19
+ wallet_app = typer.Typer(no_args_is_help=True, help="Wallet commands: generate mnemonic, private to address")
20
+ app.add_typer(wallet_app, name="wallet")
21
+ app.add_typer(wallet_app, name="w", hidden=True)
22
+
23
+
24
+ @wallet_app.command(name="mnemonic", help="Generate eth accounts based on a mnemonic")
25
+ def mnemonic_command( # nosec
26
+ mnemonic: Annotated[str, typer.Option("--mnemonic", "-m")] = "",
27
+ passphrase: Annotated[str, typer.Option("--passphrase", "-p")] = "",
28
+ print_path: bool = typer.Option(False, "--print_path"),
29
+ derivation_path: Annotated[str, typer.Option("--path")] = DEFAULT_DERIVATION_PATH,
30
+ words: int = typer.Option(12, "--words", "-w", help="Number of mnemonic words"),
31
+ limit: int = typer.Option(10, "--limit", "-l"),
32
+ save_file: str = typer.Option("", "--save", "-s", help="Save private keys to a file"),
33
+ ) -> None:
34
+ mnemonic_cmd.run(
35
+ mnemonic,
36
+ passphrase=passphrase,
37
+ print_path=print_path,
38
+ limit=limit,
39
+ words=words,
40
+ derivation_path=derivation_path,
41
+ save_file=save_file,
42
+ )
43
+
44
+
45
+ @wallet_app.command(name="private-key", help="Print an address for a private key")
46
+ def private_key_command(private_key: str) -> None:
47
+ private_key_cmd.run(private_key)
48
+
49
+
50
+ @app.command(name="node", help="Check RPC url")
51
+ def node_command(
52
+ urls: Annotated[list[str], typer.Argument()],
53
+ proxy: Annotated[str | None, typer.Option("--proxy", "-p", help="Proxy")] = None,
54
+ print_format: Annotated[PrintFormat, typer.Option("--format", "-f", help="Print format")] = PrintFormat.TABLE,
55
+ ) -> None:
56
+ asyncio.run(node_cmd.run(urls, proxy, print_format))
57
+
58
+
59
+ @app.command(name="balance", help="Gen account balance")
60
+ def balance_command(
61
+ wallet_address: Annotated[str, typer.Argument()],
62
+ token_address: Annotated[str | None, typer.Option("--token", "-t")] = None,
63
+ rpc_url: Annotated[str, typer.Option("--url", "-u", envvar="MM_ETH_RPC_URL")] = "", # nosec
64
+ wei: bool = typer.Option(False, "--wei", "-w", help="Print balances in wei units"),
65
+ print_format: Annotated[PrintFormat, typer.Option("--format", "-f", help="Print format")] = PrintFormat.PLAIN,
66
+ ) -> None:
67
+ asyncio.run(balance_cmd.run(rpc_url, wallet_address, token_address, wei, print_format))
68
+
69
+
70
+ @app.command(name="balances", help="Print base and ERC20 token balances")
71
+ def balances_command(
72
+ config_path: Path,
73
+ print_config: bool = typer.Option(False, "--config", "-c", help="Print config and exit"),
74
+ nonce: bool = typer.Option(False, "--nonce", "-n", help="Print nonce also"),
75
+ wei: bool = typer.Option(False, "--wei", "-w", help="Show balances in WEI"),
76
+ ) -> None:
77
+ asyncio.run(
78
+ balances_cmd.run(BalancesCmdParams(config_path=config_path, print_config=print_config, wei=wei, show_nonce=nonce))
79
+ )
80
+
81
+
82
+ @app.command(name="solc", help="Compile a solidity file")
83
+ def solc_command(
84
+ contract_path: Path,
85
+ tmp_dir: Path = Path("/tmp"), # noqa: S108 # nosec
86
+ print_format: Annotated[PrintFormat, typer.Option("--format", "-f", help="Print format")] = PrintFormat.PLAIN,
87
+ ) -> None:
88
+ solc_cmd.run(contract_path, tmp_dir, print_format)
89
+
90
+
91
+ @app.command(name="deploy", help="Deploy a smart contract onchain")
92
+ def deploy_command(
93
+ config_path: Path,
94
+ print_config: bool = typer.Option(False, "--config", "-c", help="Print config and exit"),
95
+ ) -> None:
96
+ asyncio.run(deploy_cmd.run(DeployCmdParams(config_path=config_path, print_config=print_config)))
97
+
98
+
99
+ @app.command(
100
+ name="transfer", help="Transfers ETH or ERC20 tokens, supporting multiple routes, delays, and expression-based values"
101
+ )
102
+ def transfer_command(
103
+ config_path: Path,
104
+ print_balances: bool = typer.Option(False, "--balances", "-b", help="Print balances and exit"),
105
+ print_transfers: bool = typer.Option(False, "--transfers", "-t", help="Print transfers (from, to, value) and exit"),
106
+ print_config: bool = typer.Option(False, "--config", "-c", help="Print config and exit"),
107
+ emulate: bool = typer.Option(False, "--emulate", "-e", help="Emulate transaction posting"),
108
+ skip_receipt: bool = typer.Option(False, "--skip-receipt", help="Don't wait for a tx receipt"),
109
+ debug: bool = typer.Option(False, "--debug", "-d", help="Print debug info"),
110
+ ) -> None:
111
+ asyncio.run(
112
+ transfer_cmd.run(
113
+ TransferCmdParams(
114
+ config_path=config_path,
115
+ print_balances=print_balances,
116
+ print_transfers=print_transfers,
117
+ print_config=print_config,
118
+ debug=debug,
119
+ skip_receipt=skip_receipt,
120
+ emulate=emulate,
121
+ )
122
+ )
123
+ )
124
+
125
+
126
+ def version_callback(value: bool) -> None:
127
+ if value:
128
+ mm_print.plain(f"mm-eth: {importlib.metadata.version('mm-eth')}")
129
+ raise typer.Exit
130
+
131
+
132
+ @app.callback()
133
+ def main(_version: bool = typer.Option(None, "--version", callback=version_callback, is_eager=True)) -> None:
134
+ pass
135
+
136
+
137
+ if __name__ == "__main_":
138
+ app()
@@ -0,0 +1,57 @@
1
+ import importlib.metadata
2
+ from enum import Enum, unique
3
+ from pathlib import Path
4
+
5
+ import mm_print
6
+ from pydantic import BaseModel
7
+ from rich.table import Table
8
+
9
+ from mm_eth import rpc
10
+
11
+
12
+ @unique
13
+ class PrintFormat(str, Enum):
14
+ PLAIN = "plain"
15
+ TABLE = "table"
16
+ JSON = "json"
17
+
18
+
19
+ def public_rpc_url(url: str | None) -> str:
20
+ if not url or url == "1":
21
+ return "https://ethereum-rpc.publicnode.com"
22
+ if url.startswith(("http://", "https://", "ws://", "wss://")):
23
+ return url
24
+
25
+ match url.lower():
26
+ case "mainnet" | "1":
27
+ return "https://ethereum-rpc.publicnode.com"
28
+ case "sepolia" | "11155111":
29
+ return "https://ethereum-sepolia-rpc.publicnode.com"
30
+ case "opbnb" | "204":
31
+ return "https://opbnb-mainnet-rpc.bnbchain.org"
32
+ case "base" | "8453":
33
+ return "https://mainnet.base.org"
34
+ case "base-sepolia" | "84532":
35
+ return "https://sepolia.base.org"
36
+ case _:
37
+ return url
38
+
39
+
40
+ class BaseConfigParams(BaseModel):
41
+ config_path: Path
42
+ print_config: bool
43
+
44
+
45
+ async def check_nodes_for_chain_id(nodes: list[str], chain_id: int) -> None:
46
+ for node in nodes:
47
+ res = (await rpc.eth_chain_id(node)).unwrap("can't get chain_id")
48
+ if res != chain_id:
49
+ mm_print.fatal(f"node {node} has a wrong chain_id: {res}")
50
+
51
+
52
+ def add_table_raw(table: Table, *row: object) -> None:
53
+ table.add_row(*[str(cell) for cell in row])
54
+
55
+
56
+ def get_version() -> str:
57
+ return importlib.metadata.version("mm-eth")
@@ -0,0 +1,47 @@
1
+ import eth_utils
2
+ import mm_print
3
+
4
+ from mm_eth import converters, rpc
5
+ from mm_eth.cli import cli_utils
6
+ from mm_eth.cli.cli import PrintFormat
7
+
8
+
9
+ async def run(rpc_url: str, wallet_address: str, token_address: str | None, wei: bool, print_format: PrintFormat) -> None:
10
+ result: dict[str, object] = {}
11
+ rpc_url = cli_utils.public_rpc_url(rpc_url)
12
+
13
+ # nonce
14
+ result["nonce"] = (await rpc.eth_get_transaction_count(rpc_url, wallet_address)).value_or_error()
15
+ if print_format == PrintFormat.PLAIN:
16
+ mm_print.plain(f"nonce: {result['nonce']}")
17
+
18
+ # eth balance
19
+ result["eth_balance"] = (
20
+ (await rpc.eth_get_balance(rpc_url, wallet_address))
21
+ .map(lambda value: value if wei else eth_utils.from_wei(value, "ether"))
22
+ .value_or_error()
23
+ )
24
+ if print_format == PrintFormat.PLAIN:
25
+ mm_print.plain(f"eth_balance: {result['eth_balance']}")
26
+
27
+ if token_address:
28
+ # token decimal
29
+ result["token_decimal"] = (await rpc.erc20_decimals(rpc_url, token_address)).value_or_error()
30
+ if print_format == PrintFormat.PLAIN:
31
+ mm_print.plain(f"token_decimal: {result['token_decimal']}")
32
+
33
+ # token symbol
34
+ result["token_symbol"] = (await rpc.erc20_symbol(rpc_url, token_address)).value_or_error()
35
+ if print_format == PrintFormat.PLAIN:
36
+ mm_print.plain(f"token_symbol: {result['token_symbol']}")
37
+
38
+ # token balance
39
+ result["token_balance"] = (await rpc.erc20_balance(rpc_url, token_address, wallet_address)).value_or_error()
40
+ if isinstance(result["token_balance"], int) and not wei and isinstance(result["token_decimal"], int):
41
+ result["token_balance"] = converters.from_wei(result["token_balance"], "t", decimals=result["token_decimal"])
42
+
43
+ if print_format == PrintFormat.PLAIN:
44
+ mm_print.plain(f"token_balance: {result['token_balance']}")
45
+
46
+ if print_format == PrintFormat.JSON:
47
+ mm_print.json(data=result)
@@ -0,0 +1,118 @@
1
+ from dataclasses import dataclass
2
+ from typing import Annotated
3
+
4
+ import mm_print
5
+ from mm_web3 import Web3CliConfig
6
+ from pydantic import BeforeValidator
7
+ from rich.live import Live
8
+ from rich.table import Table
9
+
10
+ from mm_eth import converters, retry
11
+ from mm_eth.cli.cli_utils import BaseConfigParams
12
+ from mm_eth.cli.validators import Validators
13
+
14
+
15
+ class Config(Web3CliConfig):
16
+ addresses: Annotated[list[str], BeforeValidator(Validators.eth_addresses(unique=True))]
17
+ tokens: Annotated[list[str], BeforeValidator(Validators.eth_addresses(unique=True))]
18
+ nodes: Annotated[list[str], BeforeValidator(Validators.nodes())]
19
+ round_ndigits: int = 5
20
+
21
+
22
+ @dataclass
23
+ class Token:
24
+ address: str
25
+ decimals: int
26
+ symbol: str
27
+
28
+
29
+ class BalancesCmdParams(BaseConfigParams):
30
+ wei: bool
31
+ show_nonce: bool
32
+
33
+
34
+ async def run(params: BalancesCmdParams) -> None:
35
+ config = Config.read_toml_config_or_exit(params.config_path)
36
+ if params.print_config:
37
+ config.print_and_exit()
38
+
39
+ tokens = await _get_tokens_info(config)
40
+
41
+ table = Table(title="balances")
42
+ table.add_column("address")
43
+ if params.show_nonce:
44
+ table.add_column("nonce")
45
+ table.add_column("wei" if params.wei else "eth")
46
+ for t in tokens:
47
+ table.add_column(t.symbol)
48
+
49
+ base_sum = 0
50
+ token_sum: dict[str, int] = {t.address: 0 for t in tokens}
51
+ with Live(table, refresh_per_second=0.5):
52
+ for address in config.addresses:
53
+ row = [address]
54
+ if params.show_nonce:
55
+ nonce = await retry.eth_get_transaction_count(5, config.nodes, None, address=address)
56
+ row.append(str(nonce.value_or_error()))
57
+
58
+ base_balance_res = await retry.eth_get_balance(5, config.nodes, None, address=address)
59
+ if base_balance_res.is_ok():
60
+ balance = base_balance_res.unwrap()
61
+ base_sum += balance
62
+ if params.wei:
63
+ row.append(str(balance))
64
+ else:
65
+ row.append(str(converters.from_wei(balance, "eth", round_ndigits=config.round_ndigits)))
66
+ else:
67
+ row.append(base_balance_res.unwrap_err())
68
+
69
+ for t in tokens:
70
+ token_balance_res = await retry.erc20_balance(5, config.nodes, None, token=t.address, wallet=address)
71
+ if token_balance_res.is_ok():
72
+ token_balance = token_balance_res.unwrap()
73
+ token_sum[t.address] += token_balance
74
+ if params.wei:
75
+ row.append(str(token_balance))
76
+ else:
77
+ row.append(
78
+ str(converters.from_wei(token_balance, "t", round_ndigits=config.round_ndigits, decimals=t.decimals))
79
+ )
80
+ else:
81
+ row.append(token_balance_res.unwrap_err())
82
+
83
+ table.add_row(*row)
84
+
85
+ sum_row = ["sum"]
86
+ if params.show_nonce:
87
+ sum_row.append("")
88
+ if params.wei:
89
+ sum_row.append(str(base_sum))
90
+ sum_row.extend([str(token_sum[t.address]) for t in tokens])
91
+ else:
92
+ sum_row.append(str(converters.from_wei(base_sum, "eth", round_ndigits=config.round_ndigits)))
93
+ sum_row.extend(
94
+ [
95
+ str(converters.from_wei(token_sum[t.address], "t", round_ndigits=config.round_ndigits, decimals=t.decimals))
96
+ for t in tokens
97
+ ]
98
+ )
99
+
100
+ table.add_row(*sum_row)
101
+
102
+
103
+ async def _get_tokens_info(config: Config) -> list[Token]:
104
+ result: list[Token] = []
105
+ for address in config.tokens:
106
+ decimals_res = await retry.erc20_decimals(5, config.nodes, None, token=address)
107
+ if decimals_res.is_err():
108
+ mm_print.fatal(f"can't get token {address} decimals: {decimals_res.unwrap_err()}")
109
+ decimal = decimals_res.unwrap()
110
+
111
+ symbols_res = await retry.erc20_symbol(5, config.nodes, None, token=address)
112
+ if symbols_res.is_err():
113
+ mm_print.fatal(f"can't get token {address} symbol: {symbols_res.unwrap_err()}")
114
+ symbol = symbols_res.unwrap()
115
+
116
+ result.append(Token(address=address, decimals=decimal, symbol=symbol))
117
+
118
+ return result