mm-eth 0.7.3__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_eth/__init__.py +1 -0
- mm_eth/abi.py +17 -2
- mm_eth/account.py +6 -43
- mm_eth/anvil.py +11 -0
- mm_eth/cli/__init__.py +1 -0
- mm_eth/cli/calcs.py +4 -0
- mm_eth/cli/cli.py +14 -3
- mm_eth/cli/cli_utils.py +21 -4
- mm_eth/cli/cmd/__init__.py +1 -0
- mm_eth/cli/cmd/balance_cmd.py +10 -7
- mm_eth/cli/cmd/balances_cmd.py +13 -4
- mm_eth/cli/cmd/deploy_cmd.py +9 -2
- mm_eth/cli/cmd/node_cmd.py +14 -2
- mm_eth/cli/cmd/solc_cmd.py +11 -7
- mm_eth/cli/cmd/transfer_cmd.py +25 -1
- mm_eth/cli/cmd/wallet/__init__.py +1 -0
- mm_eth/cli/cmd/wallet/mnemonic_cmd.py +5 -2
- mm_eth/cli/cmd/wallet/private_key_cmd.py +7 -3
- mm_eth/cli/rpc_helpers.py +6 -0
- mm_eth/cli/validators.py +12 -0
- mm_eth/converters.py +4 -0
- mm_eth/deploy.py +4 -0
- mm_eth/erc20.py +4 -0
- mm_eth/retry.py +17 -0
- mm_eth/rpc.py +32 -9
- mm_eth/solc.py +6 -1
- mm_eth/tx.py +13 -0
- mm_eth/utils.py +4 -0
- mm_eth-0.8.0.dist-info/METADATA +7 -0
- mm_eth-0.8.0.dist-info/RECORD +33 -0
- {mm_eth-0.7.3.dist-info → mm_eth-0.8.0.dist-info}/WHEEL +1 -1
- mm_eth-0.7.3.dist-info/METADATA +0 -7
- mm_eth-0.7.3.dist-info/RECORD +0 -33
- {mm_eth-0.7.3.dist-info → mm_eth-0.8.0.dist-info}/entry_points.txt +0 -0
mm_eth/__init__.py
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Python library for interacting with EVM blockchains."""
|
mm_eth/abi.py
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
"""ABI encoding, decoding, and function signature utilities."""
|
|
2
|
+
|
|
1
3
|
import string
|
|
2
4
|
from collections.abc import Sequence
|
|
3
5
|
from dataclasses import dataclass
|
|
@@ -13,16 +15,21 @@ from web3.auto import w3
|
|
|
13
15
|
|
|
14
16
|
@dataclass
|
|
15
17
|
class NameTypeValue:
|
|
18
|
+
"""A named, typed ABI parameter value."""
|
|
19
|
+
|
|
16
20
|
name: str
|
|
17
21
|
type: str
|
|
18
22
|
value: Any
|
|
19
23
|
|
|
20
24
|
|
|
21
25
|
class FunctionInput(BaseModel):
|
|
26
|
+
"""Decoded function input with ABI metadata and parameter values."""
|
|
27
|
+
|
|
22
28
|
function_abi: ABIFunction
|
|
23
29
|
params: dict[str, Any]
|
|
24
30
|
|
|
25
31
|
def decode_params_bytes(self) -> dict[str, Any]:
|
|
32
|
+
"""Convert bytes parameters to human-readable hex or text strings."""
|
|
26
33
|
result: dict[str, Any] = {}
|
|
27
34
|
for k, v in self.params.items():
|
|
28
35
|
if isinstance(v, bytes):
|
|
@@ -36,10 +43,12 @@ class FunctionInput(BaseModel):
|
|
|
36
43
|
return result
|
|
37
44
|
|
|
38
45
|
def function_signature(self) -> str:
|
|
46
|
+
"""Return the function signature string, e.g. 'transfer(to,value)'."""
|
|
39
47
|
inputs = [i["name"] for i in self.function_abi["inputs"]]
|
|
40
48
|
return self.function_abi["name"] + f"({','.join(inputs)})"
|
|
41
49
|
|
|
42
50
|
def to_list(self, decode_bytes: bool = False) -> list[NameTypeValue]:
|
|
51
|
+
"""Convert parameters to a list of NameTypeValue, optionally decoding bytes."""
|
|
43
52
|
result = []
|
|
44
53
|
for param in self.function_abi["inputs"]:
|
|
45
54
|
name = param["name"]
|
|
@@ -55,12 +64,14 @@ class FunctionInput(BaseModel):
|
|
|
55
64
|
|
|
56
65
|
|
|
57
66
|
def decode_function_input(contract_abi: ABI, tx_input: str) -> FunctionInput:
|
|
67
|
+
"""Decode a transaction input hex string using the contract ABI."""
|
|
58
68
|
contract = w3.eth.contract(abi=contract_abi)
|
|
59
69
|
func, params = contract.decode_function_input(HexStr(tx_input))
|
|
60
70
|
return FunctionInput(function_abi=func.abi, params=params)
|
|
61
71
|
|
|
62
72
|
|
|
63
73
|
def get_function_abi(contr_abi: ABI, fn_name: str) -> ABIFunction:
|
|
74
|
+
"""Find and return the ABI entry for a function by name."""
|
|
64
75
|
abi = next((x for x in contr_abi if x.get("name", None) == fn_name and x.get("type", None) == "function"), None)
|
|
65
76
|
if not abi:
|
|
66
77
|
raise ValueError("can't find abi for function: " + fn_name)
|
|
@@ -68,6 +79,7 @@ def get_function_abi(contr_abi: ABI, fn_name: str) -> ABIFunction:
|
|
|
68
79
|
|
|
69
80
|
|
|
70
81
|
def encode_function_input_by_abi(abi: ABI | ABIFunction, fn_name: str, args: list[Any]) -> HexStr:
|
|
82
|
+
"""Encode function call data using a contract ABI or function ABI."""
|
|
71
83
|
# if abi is contract_abi, get function_abi
|
|
72
84
|
if isinstance(abi, Sequence):
|
|
73
85
|
abi = get_function_abi(abi, fn_name)
|
|
@@ -85,6 +97,7 @@ def encode_function_input_by_abi(abi: ABI | ABIFunction, fn_name: str, args: lis
|
|
|
85
97
|
|
|
86
98
|
|
|
87
99
|
def encode_function_input_by_signature(func_signature: str, args: list[Any]) -> HexStr:
|
|
100
|
+
"""Encode function call data from a signature string like 'func1(uint256,address)'."""
|
|
88
101
|
if not func_signature.endswith(")"):
|
|
89
102
|
raise ValueError(f"wrong func_signature={func_signature}. example: func1(uint256,address)")
|
|
90
103
|
func_signature = func_signature.removesuffix(")")
|
|
@@ -102,20 +115,22 @@ def encode_function_input_by_signature(func_signature: str, args: list[Any]) ->
|
|
|
102
115
|
|
|
103
116
|
|
|
104
117
|
def encode_function_signature(func_name_with_types: str) -> HexStr:
|
|
105
|
-
"""
|
|
118
|
+
"""Encode a 4-byte function selector from a function signature like 'transfer(address,uint256)'."""
|
|
106
119
|
return HexStr(eth_utils.to_hex(Web3.keccak(text=func_name_with_types))[0:10])
|
|
107
120
|
|
|
108
121
|
|
|
109
122
|
def decode_data(types: list[str], data: str) -> tuple[Any, ...]:
|
|
123
|
+
"""Decode ABI-encoded hex data into a tuple of values."""
|
|
110
124
|
return eth_abi.decode(types, eth_utils.to_bytes(hexstr=HexStr(data)))
|
|
111
125
|
|
|
112
126
|
|
|
113
127
|
def encode_data(types: list[str], args: list[Any]) -> str:
|
|
128
|
+
"""ABI-encodes values into a hex string."""
|
|
114
129
|
return eth_utils.to_hex(eth_abi.encode(types, args))
|
|
115
130
|
|
|
116
131
|
|
|
117
132
|
def parse_function_signatures(contract_abi: ABI) -> dict[str, str]:
|
|
118
|
-
"""
|
|
133
|
+
"""Return dict, key: function_name_and_types, value: 4bytes signature."""
|
|
119
134
|
result: dict[str, str] = {}
|
|
120
135
|
for item in contract_abi:
|
|
121
136
|
if item.get("type", None) == "function":
|
mm_eth/account.py
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
"""Ethereum account generation, derivation, and key utilities."""
|
|
2
|
+
|
|
1
3
|
from dataclasses import dataclass
|
|
2
4
|
|
|
3
5
|
import eth_utils
|
|
@@ -27,35 +29,13 @@ class DerivedAccount:
|
|
|
27
29
|
|
|
28
30
|
|
|
29
31
|
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
|
-
"""
|
|
32
|
+
"""Generate a BIP39 mnemonic phrase in English."""
|
|
39
33
|
mnemonic = Mnemonic(Language.ENGLISH)
|
|
40
34
|
return mnemonic.generate(num_words=num_words)
|
|
41
35
|
|
|
42
36
|
|
|
43
37
|
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
|
-
"""
|
|
38
|
+
"""Derive multiple Ethereum accounts from a mnemonic phrase."""
|
|
59
39
|
if "{i}" not in derivation_path:
|
|
60
40
|
raise ValueError("derivation_path must contain {i}, for example: " + DEFAULT_DERIVATION_PATH)
|
|
61
41
|
|
|
@@ -69,16 +49,7 @@ def derive_accounts(mnemonic: str, passphrase: str, derivation_path: str, limit:
|
|
|
69
49
|
|
|
70
50
|
|
|
71
51
|
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
|
-
"""
|
|
52
|
+
"""Convert a private key to its corresponding Ethereum address."""
|
|
82
53
|
try:
|
|
83
54
|
acc: LocalAccount = Account.from_key(private_key)
|
|
84
55
|
address = acc.address.lower() if lower else acc.address
|
|
@@ -88,15 +59,7 @@ def private_to_address(private_key: str, lower: bool = False) -> Result[str]:
|
|
|
88
59
|
|
|
89
60
|
|
|
90
61
|
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
|
-
"""
|
|
62
|
+
"""Check if a hex string is a valid Ethereum private key."""
|
|
100
63
|
try:
|
|
101
64
|
key_api.PrivateKey(eth_utils.decode_hex(private_key)).public_key.to_address()
|
|
102
65
|
return True # noqa: TRY300
|
mm_eth/anvil.py
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
"""Anvil (local Ethereum node) process management."""
|
|
2
|
+
|
|
1
3
|
from __future__ import annotations
|
|
2
4
|
|
|
3
5
|
import socket
|
|
@@ -11,27 +13,34 @@ from mm_eth import account, rpc
|
|
|
11
13
|
|
|
12
14
|
|
|
13
15
|
class Anvil:
|
|
16
|
+
"""Manages an Anvil local Ethereum node process."""
|
|
17
|
+
|
|
14
18
|
def __init__(self, *, chain_id: int, port: int, mnemonic: str) -> None:
|
|
19
|
+
"""Initialize Anvil configuration without starting the process."""
|
|
15
20
|
self.chain_id = chain_id
|
|
16
21
|
self.port = port
|
|
17
22
|
self.mnemonic = mnemonic
|
|
18
23
|
self.process: Popen | None = None # type: ignore[type-arg]
|
|
19
24
|
|
|
20
25
|
def start_process(self) -> None:
|
|
26
|
+
"""Start the Anvil subprocess."""
|
|
21
27
|
cmd = f"anvil -m '{self.mnemonic}' -p {self.port} --chain-id {self.chain_id}"
|
|
22
28
|
self.process = Popen(cmd, shell=True) # noqa: S602 # nosec
|
|
23
29
|
time.sleep(3)
|
|
24
30
|
|
|
25
31
|
def stop(self) -> None:
|
|
32
|
+
"""Kill the Anvil subprocess if running."""
|
|
26
33
|
if self.process:
|
|
27
34
|
self.process.kill()
|
|
28
35
|
|
|
29
36
|
async def check(self) -> bool:
|
|
37
|
+
"""Verify the Anvil node is running and has the expected chain ID."""
|
|
30
38
|
res = await rpc.eth_chain_id(self.rpc_url)
|
|
31
39
|
return res.is_ok() and res.unwrap() == self.chain_id
|
|
32
40
|
|
|
33
41
|
@property
|
|
34
42
|
def rpc_url(self) -> str:
|
|
43
|
+
"""Return the local HTTP RPC URL for this Anvil instance."""
|
|
35
44
|
return f"http://localhost:{self.port}"
|
|
36
45
|
|
|
37
46
|
@classmethod
|
|
@@ -42,6 +51,7 @@ class Anvil:
|
|
|
42
51
|
mnemonic: str = "",
|
|
43
52
|
attempts: int = 3,
|
|
44
53
|
) -> Result[Anvil]:
|
|
54
|
+
"""Launch an Anvil instance, retrying on failure up to the given number of attempts."""
|
|
45
55
|
if not mnemonic:
|
|
46
56
|
mnemonic = account.generate_mnemonic()
|
|
47
57
|
|
|
@@ -58,6 +68,7 @@ class Anvil:
|
|
|
58
68
|
|
|
59
69
|
|
|
60
70
|
def get_free_local_port() -> int:
|
|
71
|
+
"""Find and return an available local TCP port."""
|
|
61
72
|
sock = socket.socket()
|
|
62
73
|
sock.bind(("", 0))
|
|
63
74
|
port = sock.getsockname()[1]
|
mm_eth/cli/__init__.py
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI interface for mm-eth."""
|
mm_eth/cli/calcs.py
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
|
+
"""Expression calculators for ETH and token values."""
|
|
2
|
+
|
|
1
3
|
import mm_web3
|
|
2
4
|
|
|
3
5
|
from mm_eth.cli.validators import SUFFIX_DECIMALS
|
|
4
6
|
|
|
5
7
|
|
|
6
8
|
def calc_eth_expression(expression: str, variables: dict[str, int] | None = None) -> int:
|
|
9
|
+
"""Evaluate an expression with ETH unit suffixes (eth, gwei, ether) to wei."""
|
|
7
10
|
return mm_web3.calc_expression_with_vars(expression, variables, unit_decimals=SUFFIX_DECIMALS)
|
|
8
11
|
|
|
9
12
|
|
|
10
13
|
def calc_token_expression(expression: str, token_decimals: int, variables: dict[str, int] | None = None) -> int:
|
|
14
|
+
"""Evaluate an expression with token unit suffix (t) to the smallest token unit."""
|
|
11
15
|
return mm_web3.calc_expression_with_vars(expression, variables, unit_decimals={"t": token_decimals})
|
mm_eth/cli/cli.py
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
"""Main CLI application and command definitions."""
|
|
2
|
+
|
|
1
3
|
import asyncio
|
|
2
4
|
import importlib.metadata
|
|
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_eth.account import DEFAULT_DERIVATION_PATH
|
|
10
12
|
from mm_eth.cli.cli_utils import PrintFormat
|
|
@@ -31,6 +33,7 @@ def mnemonic_command( # nosec
|
|
|
31
33
|
limit: int = typer.Option(10, "--limit", "-l"),
|
|
32
34
|
save_file: str = typer.Option("", "--save", "-s", help="Save private keys to a file"),
|
|
33
35
|
) -> None:
|
|
36
|
+
"""Generate or derive ETH accounts from a mnemonic phrase."""
|
|
34
37
|
mnemonic_cmd.run(
|
|
35
38
|
mnemonic,
|
|
36
39
|
passphrase=passphrase,
|
|
@@ -44,6 +47,7 @@ def mnemonic_command( # nosec
|
|
|
44
47
|
|
|
45
48
|
@wallet_app.command(name="private-key", help="Print an address for a private key")
|
|
46
49
|
def private_key_command(private_key: str) -> None:
|
|
50
|
+
"""Print the address for a given private key."""
|
|
47
51
|
private_key_cmd.run(private_key)
|
|
48
52
|
|
|
49
53
|
|
|
@@ -53,6 +57,7 @@ def node_command(
|
|
|
53
57
|
proxy: Annotated[str | None, typer.Option("--proxy", "-p", help="Proxy")] = None,
|
|
54
58
|
print_format: Annotated[PrintFormat, typer.Option("--format", "-f", help="Print format")] = PrintFormat.TABLE,
|
|
55
59
|
) -> None:
|
|
60
|
+
"""Check RPC node URLs and display chain info."""
|
|
56
61
|
asyncio.run(node_cmd.run(urls, proxy, print_format))
|
|
57
62
|
|
|
58
63
|
|
|
@@ -64,6 +69,7 @@ def balance_command(
|
|
|
64
69
|
wei: bool = typer.Option(False, "--wei", "-w", help="Print balances in wei units"),
|
|
65
70
|
print_format: Annotated[PrintFormat, typer.Option("--format", "-f", help="Print format")] = PrintFormat.PLAIN,
|
|
66
71
|
) -> None:
|
|
72
|
+
"""Print ETH and optional ERC-20 token balance for an address."""
|
|
67
73
|
asyncio.run(balance_cmd.run(rpc_url, wallet_address, token_address, wei, print_format))
|
|
68
74
|
|
|
69
75
|
|
|
@@ -74,6 +80,7 @@ def balances_command(
|
|
|
74
80
|
nonce: bool = typer.Option(False, "--nonce", "-n", help="Print nonce also"),
|
|
75
81
|
wei: bool = typer.Option(False, "--wei", "-w", help="Show balances in WEI"),
|
|
76
82
|
) -> None:
|
|
83
|
+
"""Print base and ERC-20 token balances for multiple addresses."""
|
|
77
84
|
asyncio.run(
|
|
78
85
|
balances_cmd.run(BalancesCmdParams(config_path=config_path, print_config=print_config, wei=wei, show_nonce=nonce))
|
|
79
86
|
)
|
|
@@ -85,6 +92,7 @@ def solc_command(
|
|
|
85
92
|
tmp_dir: Path = Path("/tmp"), # noqa: S108 # nosec
|
|
86
93
|
print_format: Annotated[PrintFormat, typer.Option("--format", "-f", help="Print format")] = PrintFormat.PLAIN,
|
|
87
94
|
) -> None:
|
|
95
|
+
"""Compile a Solidity file and print the ABI and bytecode."""
|
|
88
96
|
solc_cmd.run(contract_path, tmp_dir, print_format)
|
|
89
97
|
|
|
90
98
|
|
|
@@ -93,6 +101,7 @@ def deploy_command(
|
|
|
93
101
|
config_path: Path,
|
|
94
102
|
print_config: bool = typer.Option(False, "--config", "-c", help="Print config and exit"),
|
|
95
103
|
) -> None:
|
|
104
|
+
"""Deploy a smart contract onchain from a config file."""
|
|
96
105
|
asyncio.run(deploy_cmd.run(DeployCmdParams(config_path=config_path, print_config=print_config)))
|
|
97
106
|
|
|
98
107
|
|
|
@@ -108,6 +117,7 @@ def transfer_command(
|
|
|
108
117
|
skip_receipt: bool = typer.Option(False, "--skip-receipt", help="Don't wait for a tx receipt"),
|
|
109
118
|
debug: bool = typer.Option(False, "--debug", "-d", help="Print debug info"),
|
|
110
119
|
) -> None:
|
|
120
|
+
"""Transfer ETH or ERC-20 tokens based on a config file."""
|
|
111
121
|
asyncio.run(
|
|
112
122
|
transfer_cmd.run(
|
|
113
123
|
TransferCmdParams(
|
|
@@ -124,14 +134,15 @@ def transfer_command(
|
|
|
124
134
|
|
|
125
135
|
|
|
126
136
|
def version_callback(value: bool) -> None:
|
|
137
|
+
"""Print the version and exit when --version is passed."""
|
|
127
138
|
if value:
|
|
128
|
-
|
|
139
|
+
print_plain(f"mm-eth: {importlib.metadata.version('mm-eth')}")
|
|
129
140
|
raise typer.Exit
|
|
130
141
|
|
|
131
142
|
|
|
132
143
|
@app.callback()
|
|
133
144
|
def main(_version: bool = typer.Option(None, "--version", callback=version_callback, is_eager=True)) -> None:
|
|
134
|
-
|
|
145
|
+
"""CLI entry point for mm-eth."""
|
|
135
146
|
|
|
136
147
|
|
|
137
148
|
if __name__ == "__main_":
|
mm_eth/cli/cli_utils.py
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
"""Shared CLI utilities, config base classes, and output helpers."""
|
|
2
|
+
|
|
1
3
|
import importlib.metadata
|
|
2
|
-
from enum import
|
|
4
|
+
from enum import StrEnum, unique
|
|
3
5
|
from pathlib import Path
|
|
6
|
+
from typing import NoReturn
|
|
4
7
|
|
|
5
|
-
import
|
|
8
|
+
import typer
|
|
6
9
|
from pydantic import BaseModel
|
|
7
10
|
from rich.table import Table
|
|
8
11
|
|
|
@@ -10,13 +13,16 @@ from mm_eth import rpc
|
|
|
10
13
|
|
|
11
14
|
|
|
12
15
|
@unique
|
|
13
|
-
class PrintFormat(
|
|
16
|
+
class PrintFormat(StrEnum):
|
|
17
|
+
"""Output format for CLI commands."""
|
|
18
|
+
|
|
14
19
|
PLAIN = "plain"
|
|
15
20
|
TABLE = "table"
|
|
16
21
|
JSON = "json"
|
|
17
22
|
|
|
18
23
|
|
|
19
24
|
def public_rpc_url(url: str | None) -> str:
|
|
25
|
+
"""Resolve a network name or alias to a public RPC URL."""
|
|
20
26
|
if not url or url == "1":
|
|
21
27
|
return "https://ethereum-rpc.publicnode.com"
|
|
22
28
|
if url.startswith(("http://", "https://", "ws://", "wss://")):
|
|
@@ -38,20 +44,31 @@ def public_rpc_url(url: str | None) -> str:
|
|
|
38
44
|
|
|
39
45
|
|
|
40
46
|
class BaseConfigParams(BaseModel):
|
|
47
|
+
"""Base parameters shared by CLI commands that read a config file."""
|
|
48
|
+
|
|
41
49
|
config_path: Path
|
|
42
50
|
print_config: bool
|
|
43
51
|
|
|
44
52
|
|
|
45
53
|
async def check_nodes_for_chain_id(nodes: list[str], chain_id: int) -> None:
|
|
54
|
+
"""Validate that all nodes return the expected chain ID, exiting on mismatch."""
|
|
46
55
|
for node in nodes:
|
|
47
56
|
res = (await rpc.eth_chain_id(node)).unwrap("can't get chain_id")
|
|
48
57
|
if res != chain_id:
|
|
49
|
-
|
|
58
|
+
fatal(f"node {node} has a wrong chain_id: {res}")
|
|
50
59
|
|
|
51
60
|
|
|
52
61
|
def add_table_raw(table: Table, *row: object) -> None:
|
|
62
|
+
"""Add a row to a Rich table, converting all values to strings."""
|
|
53
63
|
table.add_row(*[str(cell) for cell in row])
|
|
54
64
|
|
|
55
65
|
|
|
56
66
|
def get_version() -> str:
|
|
67
|
+
"""Return the installed mm-eth package version."""
|
|
57
68
|
return importlib.metadata.version("mm-eth")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def fatal(message: str) -> NoReturn:
|
|
72
|
+
"""Print an error message and exit with code 1."""
|
|
73
|
+
typer.echo(message)
|
|
74
|
+
raise typer.Exit(1)
|
mm_eth/cli/cmd/__init__.py
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI command implementations."""
|
mm_eth/cli/cmd/balance_cmd.py
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
"""CLI command: print account balance."""
|
|
2
|
+
|
|
1
3
|
import eth_utils
|
|
2
|
-
import
|
|
4
|
+
from mm_print import print_json, print_plain
|
|
3
5
|
|
|
4
6
|
from mm_eth import converters, rpc
|
|
5
7
|
from mm_eth.cli import cli_utils
|
|
@@ -7,13 +9,14 @@ from mm_eth.cli.cli import PrintFormat
|
|
|
7
9
|
|
|
8
10
|
|
|
9
11
|
async def run(rpc_url: str, wallet_address: str, token_address: str | None, wei: bool, print_format: PrintFormat) -> None:
|
|
12
|
+
"""Fetch and print ETH and optional ERC-20 token balance for an address."""
|
|
10
13
|
result: dict[str, object] = {}
|
|
11
14
|
rpc_url = cli_utils.public_rpc_url(rpc_url)
|
|
12
15
|
|
|
13
16
|
# nonce
|
|
14
17
|
result["nonce"] = (await rpc.eth_get_transaction_count(rpc_url, wallet_address)).value_or_error()
|
|
15
18
|
if print_format == PrintFormat.PLAIN:
|
|
16
|
-
|
|
19
|
+
print_plain(f"nonce: {result['nonce']}")
|
|
17
20
|
|
|
18
21
|
# eth balance
|
|
19
22
|
result["eth_balance"] = (
|
|
@@ -22,18 +25,18 @@ async def run(rpc_url: str, wallet_address: str, token_address: str | None, wei:
|
|
|
22
25
|
.value_or_error()
|
|
23
26
|
)
|
|
24
27
|
if print_format == PrintFormat.PLAIN:
|
|
25
|
-
|
|
28
|
+
print_plain(f"eth_balance: {result['eth_balance']}")
|
|
26
29
|
|
|
27
30
|
if token_address:
|
|
28
31
|
# token decimal
|
|
29
32
|
result["token_decimal"] = (await rpc.erc20_decimals(rpc_url, token_address)).value_or_error()
|
|
30
33
|
if print_format == PrintFormat.PLAIN:
|
|
31
|
-
|
|
34
|
+
print_plain(f"token_decimal: {result['token_decimal']}")
|
|
32
35
|
|
|
33
36
|
# token symbol
|
|
34
37
|
result["token_symbol"] = (await rpc.erc20_symbol(rpc_url, token_address)).value_or_error()
|
|
35
38
|
if print_format == PrintFormat.PLAIN:
|
|
36
|
-
|
|
39
|
+
print_plain(f"token_symbol: {result['token_symbol']}")
|
|
37
40
|
|
|
38
41
|
# token balance
|
|
39
42
|
result["token_balance"] = (await rpc.erc20_balance(rpc_url, token_address, wallet_address)).value_or_error()
|
|
@@ -41,7 +44,7 @@ async def run(rpc_url: str, wallet_address: str, token_address: str | None, wei:
|
|
|
41
44
|
result["token_balance"] = converters.from_wei(result["token_balance"], "t", decimals=result["token_decimal"])
|
|
42
45
|
|
|
43
46
|
if print_format == PrintFormat.PLAIN:
|
|
44
|
-
|
|
47
|
+
print_plain(f"token_balance: {result['token_balance']}")
|
|
45
48
|
|
|
46
49
|
if print_format == PrintFormat.JSON:
|
|
47
|
-
|
|
50
|
+
print_json(data=result)
|
mm_eth/cli/cmd/balances_cmd.py
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
|
+
"""CLI command: print balances for multiple addresses and tokens."""
|
|
2
|
+
|
|
1
3
|
from dataclasses import dataclass
|
|
2
4
|
from typing import Annotated
|
|
3
5
|
|
|
4
|
-
import mm_print
|
|
5
6
|
from mm_web3 import Web3CliConfig
|
|
6
7
|
from pydantic import BeforeValidator
|
|
7
8
|
from rich.live import Live
|
|
8
9
|
from rich.table import Table
|
|
9
10
|
|
|
10
11
|
from mm_eth import converters, retry
|
|
11
|
-
from mm_eth.cli.cli_utils import BaseConfigParams
|
|
12
|
+
from mm_eth.cli.cli_utils import BaseConfigParams, fatal
|
|
12
13
|
from mm_eth.cli.validators import Validators
|
|
13
14
|
|
|
14
15
|
|
|
15
16
|
class Config(Web3CliConfig):
|
|
17
|
+
"""Configuration for the balances command."""
|
|
18
|
+
|
|
16
19
|
addresses: Annotated[list[str], BeforeValidator(Validators.eth_addresses(unique=True))]
|
|
17
20
|
tokens: Annotated[list[str], BeforeValidator(Validators.eth_addresses(unique=True))]
|
|
18
21
|
nodes: Annotated[list[str], BeforeValidator(Validators.nodes())]
|
|
@@ -21,17 +24,22 @@ class Config(Web3CliConfig):
|
|
|
21
24
|
|
|
22
25
|
@dataclass
|
|
23
26
|
class Token:
|
|
27
|
+
"""Resolved ERC-20 token metadata."""
|
|
28
|
+
|
|
24
29
|
address: str
|
|
25
30
|
decimals: int
|
|
26
31
|
symbol: str
|
|
27
32
|
|
|
28
33
|
|
|
29
34
|
class BalancesCmdParams(BaseConfigParams):
|
|
35
|
+
"""Parameters for the balances command."""
|
|
36
|
+
|
|
30
37
|
wei: bool
|
|
31
38
|
show_nonce: bool
|
|
32
39
|
|
|
33
40
|
|
|
34
41
|
async def run(params: BalancesCmdParams) -> None:
|
|
42
|
+
"""Read config, fetch balances for all addresses/tokens, and display a table."""
|
|
35
43
|
config = Config.read_toml_config_or_exit(params.config_path)
|
|
36
44
|
if params.print_config:
|
|
37
45
|
config.print_and_exit()
|
|
@@ -101,16 +109,17 @@ async def run(params: BalancesCmdParams) -> None:
|
|
|
101
109
|
|
|
102
110
|
|
|
103
111
|
async def _get_tokens_info(config: Config) -> list[Token]:
|
|
112
|
+
"""Fetch decimals and symbol for each configured token address."""
|
|
104
113
|
result: list[Token] = []
|
|
105
114
|
for address in config.tokens:
|
|
106
115
|
decimals_res = await retry.erc20_decimals(5, config.nodes, None, token=address)
|
|
107
116
|
if decimals_res.is_err():
|
|
108
|
-
|
|
117
|
+
fatal(f"can't get token {address} decimals: {decimals_res.unwrap_err()}")
|
|
109
118
|
decimal = decimals_res.unwrap()
|
|
110
119
|
|
|
111
120
|
symbols_res = await retry.erc20_symbol(5, config.nodes, None, token=address)
|
|
112
121
|
if symbols_res.is_err():
|
|
113
|
-
|
|
122
|
+
fatal(f"can't get token {address} symbol: {symbols_res.unwrap_err()}")
|
|
114
123
|
symbol = symbols_res.unwrap()
|
|
115
124
|
|
|
116
125
|
result.append(Token(address=address, decimals=decimal, symbol=symbol))
|
mm_eth/cli/cmd/deploy_cmd.py
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
"""CLI command: deploy a smart contract."""
|
|
2
|
+
|
|
1
3
|
from typing import cast
|
|
2
4
|
|
|
3
|
-
import mm_print
|
|
4
5
|
import tomlkit
|
|
6
|
+
from mm_print import print_json
|
|
5
7
|
from mm_web3 import Web3CliConfig
|
|
6
8
|
from pydantic import StrictStr
|
|
7
9
|
|
|
@@ -10,6 +12,8 @@ from mm_eth.cli.cli_utils import BaseConfigParams
|
|
|
10
12
|
|
|
11
13
|
|
|
12
14
|
class Config(Web3CliConfig):
|
|
15
|
+
"""Configuration for the deploy command."""
|
|
16
|
+
|
|
13
17
|
private_key: StrictStr
|
|
14
18
|
nonce: int | None = None
|
|
15
19
|
gas: int
|
|
@@ -24,10 +28,13 @@ class Config(Web3CliConfig):
|
|
|
24
28
|
|
|
25
29
|
|
|
26
30
|
class DeployCmdParams(BaseConfigParams):
|
|
31
|
+
"""Parameters for the deploy command."""
|
|
32
|
+
|
|
27
33
|
broadcast: bool = False
|
|
28
34
|
|
|
29
35
|
|
|
30
36
|
async def run(cli_params: DeployCmdParams) -> None:
|
|
37
|
+
"""Read deploy config, build contract data, and print the deployment payload."""
|
|
31
38
|
config = Config.read_toml_config_or_exit(cli_params.config_path)
|
|
32
39
|
if cli_params.print_config:
|
|
33
40
|
config.print_and_exit({"private_key"})
|
|
@@ -44,4 +51,4 @@ async def run(cli_params: DeployCmdParams) -> None:
|
|
|
44
51
|
)
|
|
45
52
|
|
|
46
53
|
res = deploy.get_deploy_contract_data(config.contract_bin, constructor_types, constructor_values)
|
|
47
|
-
|
|
54
|
+
print_json(res)
|
mm_eth/cli/cmd/node_cmd.py
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
"""CLI command: check RPC node status."""
|
|
2
|
+
|
|
1
3
|
from decimal import Decimal
|
|
2
4
|
|
|
3
5
|
import eth_utils
|
|
4
|
-
import mm_print
|
|
5
6
|
import pydash
|
|
7
|
+
from mm_print import print_json
|
|
6
8
|
from pydantic import BaseModel
|
|
7
9
|
from rich.live import Live
|
|
8
10
|
from rich.table import Table
|
|
@@ -12,6 +14,8 @@ from mm_eth.cli.cli import PrintFormat
|
|
|
12
14
|
|
|
13
15
|
|
|
14
16
|
class NodeInfo(BaseModel):
|
|
17
|
+
"""RPC node status information."""
|
|
18
|
+
|
|
15
19
|
url: str
|
|
16
20
|
chain_id: int | str
|
|
17
21
|
chain_name: str
|
|
@@ -19,11 +23,15 @@ class NodeInfo(BaseModel):
|
|
|
19
23
|
base_fee: str | int | Decimal
|
|
20
24
|
|
|
21
25
|
def table_row(self) -> list[object]:
|
|
26
|
+
"""Return the node info fields as a list for table rendering."""
|
|
22
27
|
return [self.url, self.chain_id, self.chain_name, self.block_number, self.base_fee]
|
|
23
28
|
|
|
24
29
|
|
|
25
30
|
class LiveTable:
|
|
31
|
+
"""Wrapper around Rich's Live table for incremental row display."""
|
|
32
|
+
|
|
26
33
|
def __init__(self, table: Table, ignore: bool = False) -> None:
|
|
34
|
+
"""Initialize the live table, optionally disabling live output."""
|
|
27
35
|
self.ignore = ignore
|
|
28
36
|
if ignore:
|
|
29
37
|
return
|
|
@@ -32,18 +40,21 @@ class LiveTable:
|
|
|
32
40
|
self.live.start()
|
|
33
41
|
|
|
34
42
|
def add_row(self, *args: object) -> None:
|
|
43
|
+
"""Add a row to the table and refresh the live display."""
|
|
35
44
|
if self.ignore:
|
|
36
45
|
return
|
|
37
46
|
self.table.add_row(*(str(a) for a in args))
|
|
38
47
|
self.live.refresh()
|
|
39
48
|
|
|
40
49
|
def stop(self) -> None:
|
|
50
|
+
"""Stop the live display."""
|
|
41
51
|
if self.ignore:
|
|
42
52
|
return
|
|
43
53
|
self.live.stop()
|
|
44
54
|
|
|
45
55
|
|
|
46
56
|
async def run(urls: list[str], proxy: str | None, print_format: PrintFormat) -> None:
|
|
57
|
+
"""Query each RPC URL and display chain ID, block number, and base fee."""
|
|
47
58
|
urls = pydash.uniq(urls)
|
|
48
59
|
result = []
|
|
49
60
|
live_table = LiveTable(
|
|
@@ -58,7 +69,7 @@ async def run(urls: list[str], proxy: str | None, print_format: PrintFormat) ->
|
|
|
58
69
|
live_table.stop()
|
|
59
70
|
|
|
60
71
|
if print_format == PrintFormat.JSON:
|
|
61
|
-
|
|
72
|
+
print_json(data=result)
|
|
62
73
|
# print_json(data=result)
|
|
63
74
|
# table = Table(*["url", "chain_id", "chain_name", "block_number", "base_fee"], title="nodes")
|
|
64
75
|
|
|
@@ -68,6 +79,7 @@ async def run(urls: list[str], proxy: str | None, print_format: PrintFormat) ->
|
|
|
68
79
|
|
|
69
80
|
|
|
70
81
|
async def _get_node_info(url: str, proxy: str | None) -> NodeInfo:
|
|
82
|
+
"""Fetch chain ID, block number, and base fee from a single RPC node."""
|
|
71
83
|
chain_id_res = await rpc.eth_chain_id(url, proxy=proxy)
|
|
72
84
|
chain_id = chain_id_res.value_or_error()
|
|
73
85
|
chain_name = ""
|