mm-eth 0.5.9__tar.gz → 0.7.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_eth-0.7.0/PKG-INFO +8 -0
- {mm_eth-0.5.9 → mm_eth-0.7.0}/justfile +3 -1
- mm_eth-0.7.0/pyproject.toml +89 -0
- {mm_eth-0.5.9 → mm_eth-0.7.0}/src/mm_eth/abi.py +3 -5
- mm_eth-0.7.0/src/mm_eth/account.py +104 -0
- {mm_eth-0.5.9 → mm_eth-0.7.0}/src/mm_eth/anvil.py +18 -9
- mm_eth-0.7.0/src/mm_eth/cli/calcs.py +11 -0
- mm_eth-0.7.0/src/mm_eth/cli/cli.py +138 -0
- mm_eth-0.7.0/src/mm_eth/cli/cli_utils.py +57 -0
- mm_eth-0.7.0/src/mm_eth/cli/cmd/balance_cmd.py +47 -0
- mm_eth-0.7.0/src/mm_eth/cli/cmd/balances_cmd.py +118 -0
- mm_eth-0.7.0/src/mm_eth/cli/cmd/deploy_cmd.py +47 -0
- {mm_eth-0.5.9 → mm_eth-0.7.0}/src/mm_eth/cli/cmd/node_cmd.py +22 -16
- mm_eth-0.7.0/src/mm_eth/cli/cmd/solc_cmd.py +25 -0
- mm_eth-0.7.0/src/mm_eth/cli/cmd/transfer_cmd.py +418 -0
- {mm_eth-0.5.9 → mm_eth-0.7.0}/src/mm_eth/cli/cmd/wallet/mnemonic_cmd.py +2 -2
- mm_eth-0.7.0/src/mm_eth/cli/cmd/wallet/private_key_cmd.py +11 -0
- mm_eth-0.7.0/src/mm_eth/cli/rpc_helpers.py +50 -0
- mm_eth-0.7.0/src/mm_eth/cli/validators.py +45 -0
- mm_eth-0.7.0/src/mm_eth/converters.py +56 -0
- mm_eth-0.7.0/src/mm_eth/erc20.py +40 -0
- mm_eth-0.7.0/src/mm_eth/retry.py +153 -0
- mm_eth-0.7.0/src/mm_eth/rpc.py +278 -0
- mm_eth-0.7.0/src/mm_eth/solc.py +47 -0
- {mm_eth-0.5.9 → mm_eth-0.7.0}/src/mm_eth/tx.py +9 -11
- mm_eth-0.7.0/src/mm_eth/utils.py +24 -0
- mm_eth-0.7.0/tests/cli/cmd/test_balance_cmd.py +10 -0
- mm_eth-0.7.0/tests/cli/cmd/test_node_cmd.py +9 -0
- mm_eth-0.7.0/tests/cli/cmd/test_solc_cmd.py +12 -0
- mm_eth-0.7.0/tests/cli/cmd/wallet/__init__.py +0 -0
- mm_eth-0.7.0/tests/cli/cmd/wallet/test_mnemonic_cmd.py +9 -0
- mm_eth-0.7.0/tests/cli/cmd/wallet/test_private_key_cmd.py +12 -0
- {mm_eth-0.5.9 → mm_eth-0.7.0}/tests/conftest.py +71 -55
- {mm_eth-0.5.9 → mm_eth-0.7.0}/tests/test_account.py +5 -8
- mm_eth-0.7.0/tests/test_converters.py +23 -0
- mm_eth-0.7.0/tests/test_rpc.py +67 -0
- mm_eth-0.7.0/uv.lock +1407 -0
- mm_eth-0.5.9/PKG-INFO +0 -9
- mm_eth-0.5.9/pyproject.toml +0 -85
- mm_eth-0.5.9/src/mm_eth/account.py +0 -71
- mm_eth-0.5.9/src/mm_eth/cli/calcs.py +0 -27
- mm_eth-0.5.9/src/mm_eth/cli/cli.py +0 -241
- mm_eth-0.5.9/src/mm_eth/cli/cli_utils.py +0 -62
- mm_eth-0.5.9/src/mm_eth/cli/cmd/balance_cmd.py +0 -47
- mm_eth-0.5.9/src/mm_eth/cli/cmd/balances_cmd.py +0 -118
- mm_eth-0.5.9/src/mm_eth/cli/cmd/call_contract_cmd.py +0 -44
- mm_eth-0.5.9/src/mm_eth/cli/cmd/deploy_cmd.py +0 -44
- mm_eth-0.5.9/src/mm_eth/cli/cmd/encode_input_data_cmd.py +0 -10
- mm_eth-0.5.9/src/mm_eth/cli/cmd/example_cmd.py +0 -9
- mm_eth-0.5.9/src/mm_eth/cli/cmd/rpc_cmd.py +0 -78
- mm_eth-0.5.9/src/mm_eth/cli/cmd/solc_cmd.py +0 -24
- mm_eth-0.5.9/src/mm_eth/cli/cmd/token_cmd.py +0 -29
- mm_eth-0.5.9/src/mm_eth/cli/cmd/transfer_cmd.py +0 -336
- mm_eth-0.5.9/src/mm_eth/cli/cmd/tx_cmd.py +0 -16
- mm_eth-0.5.9/src/mm_eth/cli/cmd/vault_cmd.py +0 -19
- mm_eth-0.5.9/src/mm_eth/cli/cmd/wallet/private_key_cmd.py +0 -10
- mm_eth-0.5.9/src/mm_eth/cli/examples/balances.toml +0 -18
- mm_eth-0.5.9/src/mm_eth/cli/examples/call_contract.toml +0 -9
- mm_eth-0.5.9/src/mm_eth/cli/examples/transfer.toml +0 -46
- mm_eth-0.5.9/src/mm_eth/cli/print_helpers.py +0 -37
- mm_eth-0.5.9/src/mm_eth/cli/rpc_helpers.py +0 -133
- mm_eth-0.5.9/src/mm_eth/cli/validators.py +0 -48
- mm_eth-0.5.9/src/mm_eth/constants.py +0 -1
- mm_eth-0.5.9/src/mm_eth/ens.py +0 -23
- mm_eth-0.5.9/src/mm_eth/erc20.py +0 -237
- mm_eth-0.5.9/src/mm_eth/ethernodes.py +0 -34
- mm_eth-0.5.9/src/mm_eth/json_encoder.py +0 -15
- mm_eth-0.5.9/src/mm_eth/rpc.py +0 -475
- mm_eth-0.5.9/src/mm_eth/solc.py +0 -33
- mm_eth-0.5.9/src/mm_eth/utils.py +0 -230
- mm_eth-0.5.9/src/mm_eth/vault.py +0 -38
- mm_eth-0.5.9/tests/cli/cmd/test_balance_cmd.py +0 -11
- mm_eth-0.5.9/tests/cli/cmd/test_mnemonic_cmd.py +0 -10
- mm_eth-0.5.9/tests/cli/cmd/test_node_cmd.py +0 -10
- mm_eth-0.5.9/tests/cli/cmd/test_private_key_cmd.py +0 -8
- mm_eth-0.5.9/tests/cli/cmd/test_solc_cmd.py +0 -10
- mm_eth-0.5.9/tests/cli/test_calcs.py +0 -8
- mm_eth-0.5.9/tests/contracts/abi/ERC20.json +0 -72
- mm_eth-0.5.9/tests/test_ens.py +0 -13
- mm_eth-0.5.9/tests/test_ethernodes.py +0 -8
- mm_eth-0.5.9/tests/test_rpc.py +0 -103
- mm_eth-0.5.9/tests/test_utils.py +0 -46
- mm_eth-0.5.9/uv.lock +0 -1608
- {mm_eth-0.5.9 → mm_eth-0.7.0}/.gitignore +0 -0
- {mm_eth-0.5.9 → mm_eth-0.7.0}/README.md +0 -0
- {mm_eth-0.5.9 → mm_eth-0.7.0}/dict.dic +0 -0
- {mm_eth-0.5.9 → mm_eth-0.7.0}/src/mm_eth/__init__.py +0 -0
- {mm_eth-0.5.9 → mm_eth-0.7.0}/src/mm_eth/cli/__init__.py +0 -0
- {mm_eth-0.5.9 → mm_eth-0.7.0}/src/mm_eth/cli/cmd/__init__.py +0 -0
- {mm_eth-0.5.9 → mm_eth-0.7.0}/src/mm_eth/cli/cmd/wallet/__init__.py +0 -0
- {mm_eth-0.5.9 → mm_eth-0.7.0}/src/mm_eth/deploy.py +0 -0
- {mm_eth-0.5.9 → mm_eth-0.7.0}/src/mm_eth/py.typed +0 -0
- {mm_eth-0.5.9 → mm_eth-0.7.0}/tests/__init__.py +0 -0
- {mm_eth-0.5.9 → mm_eth-0.7.0}/tests/cli/__init__.py +0 -0
- {mm_eth-0.5.9 → mm_eth-0.7.0}/tests/cli/cmd/__init__.py +0 -0
- {mm_eth-0.5.9 → mm_eth-0.7.0}/tests/contracts/ERC20.sol +0 -0
- {mm_eth-0.5.9 → mm_eth-0.7.0}/tests/test_abi.py +0 -0
- {mm_eth-0.5.9 → mm_eth-0.7.0}/tests/test_tx.py +0 -0
mm_eth-0.7.0/PKG-INFO
ADDED
|
@@ -20,7 +20,9 @@ lint: format
|
|
|
20
20
|
uv run mypy src
|
|
21
21
|
|
|
22
22
|
audit:
|
|
23
|
-
uv
|
|
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,89 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "mm-eth"
|
|
3
|
+
version = "0.7.0"
|
|
4
|
+
description = ""
|
|
5
|
+
requires-python = ">=3.13"
|
|
6
|
+
dependencies = [
|
|
7
|
+
# "websocket-client~=1.8.0",
|
|
8
|
+
"web3~=7.12.0",
|
|
9
|
+
"typer~=0.16.0",
|
|
10
|
+
"mm-cryptocurrency>=0.4.7",
|
|
11
|
+
"mm-std>=0.5.3",
|
|
12
|
+
]
|
|
13
|
+
[project.scripts]
|
|
14
|
+
mm-eth = "mm_eth.cli.cli:app"
|
|
15
|
+
|
|
16
|
+
[build-system]
|
|
17
|
+
requires = ["hatchling"]
|
|
18
|
+
build-backend = "hatchling.build"
|
|
19
|
+
|
|
20
|
+
[tool.uv]
|
|
21
|
+
dev-dependencies = [
|
|
22
|
+
"pytest~=8.4.0",
|
|
23
|
+
"pytest-asyncio~=1.0.0",
|
|
24
|
+
"pytest-xdist~=3.7.0",
|
|
25
|
+
"ruff~=0.11.13",
|
|
26
|
+
"pip-audit~=2.9.0",
|
|
27
|
+
"bandit~=1.8.3",
|
|
28
|
+
"mypy~=1.16.0",
|
|
29
|
+
"python-dotenv>=1.1.0",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[tool.mypy]
|
|
33
|
+
python_version = "3.13"
|
|
34
|
+
mypy_path = "stubs"
|
|
35
|
+
warn_no_return = false
|
|
36
|
+
implicit_reexport = true
|
|
37
|
+
strict = true
|
|
38
|
+
enable_error_code = ["truthy-bool", "possibly-undefined"]
|
|
39
|
+
exclude = ["^tests/", "^tmp/"]
|
|
40
|
+
[[tool.mypy.overrides]]
|
|
41
|
+
module = ["rlp", "rlp.sedes", "ens.utils", "ens"]
|
|
42
|
+
ignore_missing_imports = true
|
|
43
|
+
|
|
44
|
+
[tool.ruff]
|
|
45
|
+
line-length = 130
|
|
46
|
+
target-version = "py313"
|
|
47
|
+
[tool.ruff.lint]
|
|
48
|
+
select = ["ALL"]
|
|
49
|
+
# fixable = ["F401"]
|
|
50
|
+
ignore = [
|
|
51
|
+
"TC", # flake8-type-checking, TYPE_CHECKING is dangerous, for example it doesn't work with pydantic
|
|
52
|
+
"A005", # flake8-builtins: stdlib-module-shadowing
|
|
53
|
+
"ERA001", # eradicate: commented-out-code
|
|
54
|
+
"PT", # flake8-pytest-style
|
|
55
|
+
"D", # pydocstyle
|
|
56
|
+
"FIX", # flake8-fixme
|
|
57
|
+
"PLR0911", # pylint: too-many-return-statements
|
|
58
|
+
"PLR0912", # pylint: too-many-branches
|
|
59
|
+
"PLR0913", # pylint: too-many-arguments
|
|
60
|
+
"PLR2004", # pylint: magic-value-comparison
|
|
61
|
+
"PLC0414", # pylint: useless-import-alias
|
|
62
|
+
"FBT", # flake8-boolean-trap
|
|
63
|
+
"EM", # flake8-errmsg
|
|
64
|
+
"TRY003", # tryceratops: raise-vanilla-args
|
|
65
|
+
"C901", # mccabe: complex-structure,
|
|
66
|
+
"BLE001", # flake8-blind-except
|
|
67
|
+
"S311", # bandit: suspicious-non-cryptographic-random-usage
|
|
68
|
+
"TD002", # flake8-todos: missing-todo-author
|
|
69
|
+
"TD003", # flake8-todos: missing-todo-link
|
|
70
|
+
"RET503", # flake8-return: implicit-return
|
|
71
|
+
"COM812", # it's used in ruff formatter
|
|
72
|
+
"ASYNC109", # flake8-async: async-function-with-timeout
|
|
73
|
+
"G004",
|
|
74
|
+
]
|
|
75
|
+
[tool.ruff.lint.pep8-naming]
|
|
76
|
+
classmethod-decorators = ["field_validator"]
|
|
77
|
+
[tool.ruff.lint.per-file-ignores]
|
|
78
|
+
"tests/*.py" = ["ANN", "S"]
|
|
79
|
+
[tool.ruff.format]
|
|
80
|
+
quote-style = "double"
|
|
81
|
+
indent-style = "space"
|
|
82
|
+
|
|
83
|
+
[tool.bandit]
|
|
84
|
+
exclude_dirs = ["tests"]
|
|
85
|
+
skips = ["B311"]
|
|
86
|
+
|
|
87
|
+
[tool.pytest.ini_options]
|
|
88
|
+
asyncio_mode = "auto"
|
|
89
|
+
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],
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
53
|
+
if await anvil.check():
|
|
54
|
+
return Result.ok(anvil)
|
|
54
55
|
port = get_free_local_port()
|
|
55
56
|
|
|
56
|
-
return
|
|
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_cryptocurrency
|
|
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_cryptocurrency.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_cryptocurrency.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_cryptocurrency import CryptocurrencyConfig
|
|
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(CryptocurrencyConfig):
|
|
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
|