mm-eth 0.3.1__py3-none-any.whl → 0.4.1__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/account.py CHANGED
@@ -13,9 +13,12 @@ Account.enable_unaudited_hdwallet_features()
13
13
 
14
14
  key_api = KeyAPI()
15
15
 
16
+ DEFAULT_DERIVATION_PATH = "m/44'/60'/0'/0/{i}"
17
+
16
18
 
17
19
  @dataclass
18
- class NewAccount:
20
+ class DerivedAccount:
21
+ index: int
19
22
  path: str
20
23
  address: str
21
24
  private_key: str
@@ -30,18 +33,15 @@ def generate_mnemonic(num_words: int = 24) -> str:
30
33
  return mnemonic.generate(num_words=num_words)
31
34
 
32
35
 
33
- def generate_accounts( # nosec
34
- mnemonic: str,
35
- passphrase: str = "",
36
- path_prefix: str = "m/44'/60'/0'/0",
37
- limit: int = 12,
38
- ) -> list[NewAccount]:
39
- result: list[NewAccount] = []
36
+ def derive_accounts(mnemonic: str, passphrase: str, derivation_path: str, limit: int) -> list[DerivedAccount]:
37
+ if "{i}" not in derivation_path:
38
+ raise ValueError("derivation_path must contain {i}, for example: " + DEFAULT_DERIVATION_PATH)
39
+ result: list[DerivedAccount] = []
40
40
  for i in range(limit):
41
- path = f"{path_prefix}/{i}"
42
- acc = Account.from_mnemonic(mnemonic=mnemonic, account_path=path, passphrase=passphrase)
41
+ path = derivation_path.replace("{i}", str(i))
42
+ acc = Account.from_mnemonic(mnemonic, passphrase, path)
43
43
  private_key = acc.key.to_0x_hex().lower()
44
- result.append(NewAccount(path, acc.address, private_key))
44
+ result.append(DerivedAccount(i, path, acc.address, private_key))
45
45
  return result
46
46
 
47
47
 
mm_eth/cli/calcs.py CHANGED
@@ -10,58 +10,8 @@ def calc_eth_expression(expression: str, var: VarInt | None = None) -> int:
10
10
  return mm_crypto_utils.calc_int_expression(expression, var=var, suffix_decimals=SUFFIX_DECIMALS)
11
11
 
12
12
 
13
- # def calc_var_value(value: str, *, var_name: str = "var", var_value: int | None = None, decimals: int | None = None) -> int:
14
- # if not isinstance(value, str):
15
- # raise TypeError(f"value is not str: {value}")
16
- # try:
17
- # var_name = var_name.lower()
18
- # result = 0
19
- # for token in split_on_plus_minus_tokens(value.lower()):
20
- # operator = token[0]
21
- # item = token[1:]
22
- # if item.isdigit():
23
- # item_value = int(item)
24
- # elif item.endswith("eth"):
25
- # item = item.removesuffix("eth")
26
- # item_value = int(Decimal(item) * 10**18)
27
- # elif item.endswith("ether"):
28
- # item = item.removesuffix("ether")
29
- # item_value = int(Decimal(item) * 10**18)
30
- # elif item.endswith("gwei"):
31
- # item = item.removesuffix("gwei")
32
- # item_value = int(Decimal(item) * 10**9)
33
- # elif item.endswith("t"):
34
- # if decimals is None:
35
- # raise ValueError("t without decimals")
36
- # item = item.removesuffix("t")
37
- # item_value = int(Decimal(item) * 10**decimals)
38
- # elif item.endswith(var_name):
39
- # if var_value is None:
40
- # raise ValueError("var_value is not set")
41
- # item = item.removesuffix(var_name)
42
- # k = Decimal(item) if item else Decimal(1)
43
- # item_value = int(k * var_value)
44
- # elif item.startswith("random(") and item.endswith(")"):
45
- # item = item.lstrip("random(").rstrip(")")
46
- # arr = item.split(",")
47
- # if len(arr) != 2:
48
- # raise ValueError(f"wrong value, random part: {value}")
49
- # from_value = to_wei(arr[0], decimals=decimals)
50
- # to_value = to_wei(arr[1], decimals=decimals)
51
- # if from_value > to_value:
52
- # raise ValueError(f"wrong value, random part: {value}")
53
- # item_value = random.randint(from_value, to_value)
54
- # else:
55
- # raise ValueError(f"wrong value: {value}")
56
- #
57
- # if operator == "+":
58
- # result += item_value
59
- # if operator == "-":
60
- # result -= item_value
61
- #
62
- # return result
63
- # except Exception as err:
64
- # raise ValueError(f"illegal ee: {value}, error={err}") from err
13
+ def calc_token_expression(expression: str, token_decimals: int, var: VarInt | None = None) -> int:
14
+ return mm_crypto_utils.calc_int_expression(expression, var=var, suffix_decimals={"t": token_decimals})
65
15
 
66
16
 
67
17
  def calc_function_args(value: str) -> str:
@@ -75,19 +25,3 @@ def calc_function_args(value: str) -> str:
75
25
  raise ValueError("wrong random(from,to) template")
76
26
  rand_value = str(random.randint(random_range[0], random_range[1]))
77
27
  value = value[0:start_index] + rand_value + value[stop_index + 1 :]
78
-
79
-
80
- # def is_value_less_min_limit(
81
- # value_min_limit: str | None,
82
- # value: int,
83
- # value_unit: str,
84
- # decimals: int | None = None,
85
- # log_prefix: str | None = None,
86
- # ) -> bool:
87
- # if value_min_limit is None:
88
- # return False
89
- # if value < calc_eth_expression(value_min_limit):
90
- # prefix = get_log_prefix(log_prefix)
91
- # logger.info("{}value is less min limit, value={}", prefix, from_wei_str(value, value_unit, decimals=decimals))
92
- # return True
93
- # return False
mm_eth/cli/cli.py CHANGED
@@ -5,23 +5,22 @@ from typing import Annotated
5
5
  import typer
6
6
  from mm_std import PrintFormat, print_plain
7
7
 
8
+ from mm_eth.account import DEFAULT_DERIVATION_PATH
9
+
8
10
  from . import cli_utils
9
11
  from .cmd import (
10
12
  balance_cmd,
11
13
  balances_cmd,
12
14
  call_contract_cmd,
13
- config_example_cmd,
14
15
  deploy_cmd,
15
16
  encode_input_data_cmd,
16
- mnemonic_cmd,
17
+ example_cmd,
17
18
  node_cmd,
18
- private_key_cmd,
19
19
  rpc_cmd,
20
20
  send_contract_cmd,
21
21
  solc_cmd,
22
22
  token_cmd,
23
- transfer_erc20_cmd,
24
- transfer_eth_cmd,
23
+ transfer_cmd,
25
24
  tx_cmd,
26
25
  vault_cmd,
27
26
  )
@@ -29,8 +28,8 @@ from .cmd.balances_cmd import BalancesCmdParams
29
28
  from .cmd.call_contract_cmd import CallContractCmdParams
30
29
  from .cmd.deploy_cmd import DeployCmdParams
31
30
  from .cmd.send_contract_cmd import SendContractCmdParams
32
- from .cmd.transfer_erc20_cmd import TransferErc20CmdParams
33
- from .cmd.transfer_eth_cmd import TransferEthCmdParams
31
+ from .cmd.transfer_cmd import TransferCmdParams
32
+ from .cmd.wallet import mnemonic_cmd, private_key_cmd
34
33
 
35
34
  app = typer.Typer(no_args_is_help=True, pretty_exceptions_enable=False, add_completion=False)
36
35
 
@@ -40,8 +39,7 @@ app.add_typer(wallet_app, name="w", hidden=True)
40
39
 
41
40
 
42
41
  class ConfigExample(str, Enum):
43
- TRANSFER_ETH = "transfer-eth"
44
- TRANSFER_ERC20 = "transfer-erc20"
42
+ TRANSFER = "transfer"
45
43
  BALANCES = "balances"
46
44
  CALL_CONTRACT = "call-contract"
47
45
 
@@ -77,9 +75,10 @@ def node_command(
77
75
  @wallet_app.command(name="mnemonic", help="Generate eth accounts based on a mnemonic")
78
76
  def mnemonic_command( # nosec
79
77
  mnemonic: Annotated[str, typer.Option("--mnemonic", "-m")] = "",
80
- passphrase: Annotated[str, typer.Option("--passphrase", "-pass")] = "",
78
+ passphrase: Annotated[str, typer.Option("--passphrase", "-p")] = "",
81
79
  print_path: bool = typer.Option(False, "--print_path"),
82
- path_prefix: Annotated[str, typer.Option("--path")] = "m/44'/60'/0'/0",
80
+ derivation_path: Annotated[str, typer.Option("--path")] = DEFAULT_DERIVATION_PATH,
81
+ words: int = typer.Option(12, "--words", "-w", help="Number of mnemonic words"),
83
82
  limit: int = typer.Option(10, "--limit", "-l"),
84
83
  save_file: str = typer.Option("", "--save", "-s", help="Save private keys to a file"),
85
84
  ) -> None:
@@ -88,7 +87,8 @@ def mnemonic_command( # nosec
88
87
  passphrase=passphrase,
89
88
  print_path=print_path,
90
89
  limit=limit,
91
- path_prefix=path_prefix,
90
+ words=words,
91
+ derivation_path=derivation_path,
92
92
  save_file=save_file,
93
93
  )
94
94
 
@@ -141,43 +141,24 @@ def tx_command(
141
141
  tx_cmd.run(rpc_url, tx_hash, get_receipt)
142
142
 
143
143
 
144
- @app.command(name="transfer-eth", help="Transfer eth / base token from one or many accounts")
145
- def transfer_eth_command(
146
- config_path: Path,
147
- print_balances: bool = typer.Option(False, "--balances", "-b", help="Print balances and exit"),
148
- print_config: bool = typer.Option(False, "--config", "-c", help="Print config and exit"),
149
- emulate: bool = typer.Option(False, "--emulate", "-e", help="Emulate transaction posting"),
150
- no_receipt: bool = typer.Option(False, "--no-receipt", "-nr", help="Don't wait for a tx receipt"),
151
- debug: bool = typer.Option(False, "--debug", "-d", help="Print debug info"),
152
- ) -> None:
153
- transfer_eth_cmd.run(
154
- TransferEthCmdParams(
155
- config_path=config_path,
156
- print_balances=print_balances,
157
- print_config_and_exit=print_config,
158
- debug=debug,
159
- no_receipt=no_receipt,
160
- emulate=emulate,
161
- )
162
- )
163
-
164
-
165
- @app.command(name="transfer-erc20", help="Transfer ERC20 token from one or many accounts")
166
- def transfer_erc20_command(
144
+ @app.command(
145
+ name="transfer", help="Transfers ETH or ERC20 tokens, supporting multiple routes, delays, and expression-based values"
146
+ )
147
+ def transfer_command(
167
148
  config_path: Path,
168
149
  print_balances: bool = typer.Option(False, "--balances", "-b", help="Print balances and exit"),
169
150
  print_config: bool = typer.Option(False, "--config", "-c", help="Print config and exit"),
170
151
  emulate: bool = typer.Option(False, "--emulate", "-e", help="Emulate transaction posting"),
171
- no_receipt: bool = typer.Option(False, "--no-receipt", "-nr", help="Don't wait for a tx receipt"),
152
+ skip_receipt: bool = typer.Option(False, "--skip-receipt", help="Don't wait for a tx receipt"),
172
153
  debug: bool = typer.Option(False, "--debug", "-d", help="Print debug info"),
173
154
  ) -> None:
174
- transfer_erc20_cmd.run(
175
- TransferErc20CmdParams(
155
+ transfer_cmd.run(
156
+ TransferCmdParams(
176
157
  config_path=config_path,
177
158
  print_balances=print_balances,
178
- print_config_and_exit=print_config,
159
+ print_config=print_config,
179
160
  debug=debug,
180
- no_receipt=no_receipt,
161
+ skip_receipt=skip_receipt,
181
162
  emulate=emulate,
182
163
  )
183
164
  )
@@ -196,7 +177,7 @@ def send_contract_command(
196
177
  SendContractCmdParams(
197
178
  config_path=config_path,
198
179
  print_balances=print_balances,
199
- print_config_and_exit=print_config,
180
+ print_config=print_config,
200
181
  debug=debug,
201
182
  no_receipt=no_receipt,
202
183
  emulate=emulate,
@@ -211,7 +192,7 @@ def balances_command(
211
192
  nonce: bool = typer.Option(False, "--nonce", "-n", help="Print nonce also"),
212
193
  wei: bool = typer.Option(False, "--wei", "-w", help="Show balances in WEI"),
213
194
  ) -> None:
214
- balances_cmd.run(BalancesCmdParams(config_path=config_path, print_config_and_exit=print_config, wei=wei, show_nonce=nonce))
195
+ balances_cmd.run(BalancesCmdParams(config_path=config_path, print_config=print_config, wei=wei, show_nonce=nonce))
215
196
 
216
197
 
217
198
  @app.command(name="call-contract", help="Call a method on a contract")
@@ -219,7 +200,7 @@ def call_contract_command(
219
200
  config_path: Path,
220
201
  print_config: bool = typer.Option(False, "--config", "-c", help="Print config and exit"),
221
202
  ) -> None:
222
- call_contract_cmd.run(CallContractCmdParams(config_path=config_path, print_config_and_exit=print_config))
203
+ call_contract_cmd.run(CallContractCmdParams(config_path=config_path, print_config=print_config))
223
204
 
224
205
 
225
206
  @app.command(name="deploy", help="Deploy a smart contract onchain")
@@ -227,12 +208,12 @@ def deploy_command(
227
208
  config_path: Path,
228
209
  print_config: bool = typer.Option(False, "--config", "-c", help="Print config and exit"),
229
210
  ) -> None:
230
- deploy_cmd.run(DeployCmdParams(config_path=config_path, print_config_and_exit=print_config))
211
+ deploy_cmd.run(DeployCmdParams(config_path=config_path, print_config=print_config))
231
212
 
232
213
 
233
- @app.command(name="config-example", help="Print an example of config for a command")
234
- def config_example_command(command: Annotated[ConfigExample, typer.Argument()]) -> None:
235
- config_example_cmd.run(command)
214
+ @app.command(name="example", help="Displays an example configuration for a command")
215
+ def example_command(command: Annotated[ConfigExample, typer.Argument()]) -> None:
216
+ example_cmd.run(command)
236
217
 
237
218
 
238
219
  @app.command(name="encode-input-data", help="Encode input data by a function signature")
mm_eth/cli/cli_utils.py CHANGED
@@ -1,8 +1,14 @@
1
1
  import importlib.metadata
2
+ import time
2
3
  from pathlib import Path
4
+ from typing import Literal
3
5
 
6
+ from mm_crypto_utils import Nodes, Proxies
7
+ from mm_std import BaseConfig, print_json
4
8
  from pydantic import BaseModel
5
9
 
10
+ from mm_eth import rpc
11
+
6
12
 
7
13
  def get_version() -> str:
8
14
  return importlib.metadata.version("mm-eth")
@@ -29,4 +35,26 @@ def public_rpc_url(url: str | None) -> str:
29
35
 
30
36
  class BaseConfigParams(BaseModel):
31
37
  config_path: Path
32
- print_config_and_exit: bool
38
+ print_config: bool
39
+
40
+
41
+ def print_config(config: BaseConfig, exclude: set[str] | None = None, count: set[str] | None = None) -> None:
42
+ data = config.model_dump(exclude=exclude)
43
+ if count:
44
+ for k in count:
45
+ data[k] = len(data[k])
46
+ print_json(data)
47
+
48
+
49
+ def wait_tx_status(nodes: Nodes, proxies: Proxies, tx_hash: str, timeout: int) -> Literal["OK", "FAIL", "TIMEOUT"]:
50
+ started_at = time.perf_counter()
51
+ count = 0
52
+ while True:
53
+ res = rpc.get_tx_status(nodes, tx_hash, proxies=proxies, attempts=5)
54
+ if res.is_ok():
55
+ return "OK" if res.ok == 1 else "FAIL"
56
+
57
+ time.sleep(1)
58
+ count += 1
59
+ if time.perf_counter() - started_at > timeout:
60
+ return "TIMEOUT"
@@ -33,7 +33,7 @@ class BalancesCmdParams(BaseConfigParams):
33
33
 
34
34
  def run(params: BalancesCmdParams) -> None:
35
35
  config = Config.read_toml_config_or_exit(params.config_path)
36
- if params.print_config_and_exit:
36
+ if params.print_config:
37
37
  config.print_and_exit()
38
38
 
39
39
  tokens = _get_tokens_info(config)
@@ -22,7 +22,7 @@ class CallContractCmdParams(BaseConfigParams):
22
22
 
23
23
  def run(cli_params: CallContractCmdParams) -> None:
24
24
  config = Config.read_toml_config_or_exit(cli_params.config_path)
25
- if cli_params.print_config_and_exit:
25
+ if cli_params.print_config:
26
26
  config.print_and_exit()
27
27
 
28
28
  input_data = abi.encode_function_input_by_signature(
@@ -27,7 +27,7 @@ class DeployCmdParams(BaseConfigParams):
27
27
 
28
28
  def run(cli_params: DeployCmdParams) -> None:
29
29
  config = Config.read_toml_config_or_exit(cli_params.config_path)
30
- if cli_params.print_config_and_exit:
30
+ if cli_params.print_config:
31
31
  config.print_and_exit({"private_key"})
32
32
 
33
33
  constructor_types = yaml.full_load(config.constructor_types)
@@ -5,5 +5,5 @@ from mm_std import print_plain
5
5
 
6
6
  def run(command: str) -> None:
7
7
  command = command.replace("-", "_")
8
- example_file = Path(Path(__file__).parent.absolute(), "../config_examples", f"{command}.toml")
8
+ example_file = Path(Path(__file__).parent.absolute(), "../examples", f"{command}.toml")
9
9
  print_plain(example_file.read_text())
@@ -65,7 +65,7 @@ class SendContractCmdParams(BaseConfigParams):
65
65
  # noinspection DuplicatedCode
66
66
  def run(cli_params: SendContractCmdParams) -> None:
67
67
  config = Config.read_toml_config_or_exit(cli_params.config_path)
68
- if cli_params.print_config_and_exit:
68
+ if cli_params.print_config:
69
69
  config.print_and_exit({"private_key"})
70
70
 
71
71
  mm_crypto_utils.init_logger(cli_params.debug, config.log_debug, config.log_info)
@@ -0,0 +1,311 @@
1
+ import sys
2
+ import time
3
+ from pathlib import Path
4
+ from typing import Annotated, Self
5
+
6
+ import mm_crypto_utils
7
+ from loguru import logger
8
+ from mm_crypto_utils import AddressToPrivate, TxRoute
9
+ from mm_std import BaseConfig, Err, fatal, utc_now
10
+ from pydantic import AfterValidator, BeforeValidator, Field, model_validator
11
+ from rich.live import Live
12
+ from rich.table import Table
13
+
14
+ from mm_eth import erc20, rpc
15
+ from mm_eth.cli import calcs, cli_utils, rpc_helpers
16
+ from mm_eth.cli.calcs import calc_eth_expression
17
+ from mm_eth.cli.cli_utils import BaseConfigParams
18
+ from mm_eth.cli.validators import Validators
19
+ from mm_eth.tx import sign_tx
20
+ from mm_eth.utils import from_wei_str
21
+
22
+
23
+ class Config(BaseConfig):
24
+ nodes: Annotated[list[str], BeforeValidator(Validators.nodes())]
25
+ chain_id: int
26
+ routes: Annotated[list[TxRoute], BeforeValidator(Validators.eth_routes())]
27
+ private_keys: Annotated[AddressToPrivate, BeforeValidator(Validators.eth_private_keys())]
28
+ token: Annotated[str | None, AfterValidator(Validators.eth_address())] = None # if None, then eth transfer
29
+ token_decimals: int = -1
30
+ max_fee: Annotated[str, AfterValidator(Validators.valid_eth_expression("base_fee"))]
31
+ priority_fee: Annotated[str, AfterValidator(Validators.valid_eth_expression())]
32
+ max_fee_limit: Annotated[str | None, AfterValidator(Validators.valid_eth_expression())] = None
33
+ value: Annotated[str, AfterValidator(Validators.valid_eth_or_token_expression("balance"))]
34
+ value_min_limit: Annotated[str | None, AfterValidator(Validators.valid_eth_or_token_expression())] = None
35
+ gas: Annotated[str, AfterValidator(Validators.valid_eth_expression("estimate"))]
36
+ delay: Annotated[str | None, AfterValidator(Validators.valid_calc_decimal_value())] = None # in seconds
37
+ round_ndigits: int = 5
38
+ proxies: Annotated[list[str], Field(default_factory=list), BeforeValidator(Validators.proxies())]
39
+ wait_tx_timeout: int = 120
40
+ log_debug: Annotated[Path | None, BeforeValidator(Validators.log_file())] = None
41
+ log_info: Annotated[Path | None, BeforeValidator(Validators.log_file())] = None
42
+
43
+ @property
44
+ def from_addresses(self) -> list[str]:
45
+ return [r.from_address for r in self.routes]
46
+
47
+ @model_validator(mode="after")
48
+ def final_validator(self) -> Self:
49
+ if not self.private_keys.contains_all_addresses(self.from_addresses):
50
+ raise ValueError("private keys are not set for all addresses")
51
+
52
+ if self.token:
53
+ Validators.valid_token_expression("balance")(self.value)
54
+ if self.value_min_limit:
55
+ Validators.valid_token_expression()(self.value_min_limit)
56
+ else:
57
+ Validators.valid_eth_expression("balance")(self.value)
58
+ if self.value_min_limit:
59
+ Validators.valid_eth_expression()(self.value_min_limit)
60
+
61
+ if self.token:
62
+ res = erc20.get_decimals(self.nodes, self.token, proxies=self.proxies, attempts=5)
63
+ if isinstance(res, Err):
64
+ fatal(f"can't get token decimals: {res.err}")
65
+ self.token_decimals = res.ok
66
+
67
+ return self
68
+
69
+
70
+ class TransferCmdParams(BaseConfigParams):
71
+ print_balances: bool
72
+ debug: bool
73
+ skip_receipt: bool
74
+ emulate: bool
75
+
76
+
77
+ def run(cmd_params: TransferCmdParams) -> None:
78
+ config = Config.read_toml_config_or_exit(cmd_params.config_path)
79
+ if cmd_params.print_config:
80
+ cli_utils.print_config(config, exclude={"private_keys"}, count=None if cmd_params.debug else {"proxies"})
81
+ sys.exit(0)
82
+
83
+ rpc_helpers.check_nodes_for_chain_id(config.nodes, config.chain_id)
84
+
85
+ if cmd_params.print_balances:
86
+ _print_balances(config)
87
+ sys.exit(0)
88
+
89
+ _run_transfers(config, cmd_params)
90
+
91
+
92
+ def _run_transfers(config: Config, cmd_params: TransferCmdParams) -> None:
93
+ mm_crypto_utils.init_logger(cmd_params.debug, config.log_debug, config.log_info)
94
+ logger.info(f"transfer {cmd_params.config_path}: started at {utc_now()} UTC")
95
+ logger.debug(f"config={config.model_dump(exclude={'private_keys'}) | {'version': cli_utils.get_version()}}")
96
+ for i, route in enumerate(config.routes):
97
+ _transfer(route, config, cmd_params)
98
+ if config.delay is not None and i < len(config.routes) - 1:
99
+ delay_value = mm_crypto_utils.calc_decimal_value(config.delay)
100
+ logger.info(f"delay {delay_value} seconds")
101
+ if not cmd_params.emulate:
102
+ time.sleep(float(delay_value))
103
+ logger.info(f"finished at {utc_now()} UTC")
104
+
105
+
106
+ def _transfer(route: TxRoute, config: Config, cmd_params: TransferCmdParams) -> None:
107
+ nonce = rpc_helpers.get_nonce(config.nodes, route.from_address, route.log_prefix)
108
+ if nonce is None:
109
+ return
110
+
111
+ max_fee = rpc_helpers.calc_max_fee(config.nodes, config.max_fee, route.log_prefix)
112
+ if max_fee is None:
113
+ return
114
+
115
+ if rpc_helpers.is_max_fee_limit_exceeded(max_fee, config.max_fee_limit, route.log_prefix):
116
+ return
117
+
118
+ gas = _calc_gas(route, config)
119
+ if gas is None:
120
+ return
121
+
122
+ value = _calc_value(route, max_fee=max_fee, gas=gas, config=config)
123
+ if value is None:
124
+ return
125
+
126
+ if not _check_value_min_limit(route, value, config):
127
+ return
128
+
129
+ priority_fee = calc_eth_expression(config.priority_fee)
130
+
131
+ # emulate?
132
+ if cmd_params.emulate:
133
+ msg = f"{route.log_prefix}: emulate,"
134
+ msg += f" value={_value_with_suffix(value, config)},"
135
+ msg += f" max_fee={from_wei_str(max_fee, 'gwei', config.round_ndigits)},"
136
+ msg += f" priority_fee={from_wei_str(priority_fee, 'gwei', config.round_ndigits)},"
137
+ msg += f" gas={gas}"
138
+ logger.info(msg)
139
+ return
140
+
141
+ tx_hash = _send_tx(route=route, nonce=nonce, max_fee=max_fee, priority_fee=priority_fee, gas=gas, value=value, config=config)
142
+ if tx_hash is None:
143
+ return
144
+
145
+ status = "UNKNOWN"
146
+ if not cmd_params.skip_receipt:
147
+ logger.debug(f"{route.log_prefix}: waiting for receipt, tx_hash={tx_hash}")
148
+ status = cli_utils.wait_tx_status(config.nodes, config.proxies, tx_hash, config.wait_tx_timeout)
149
+
150
+ logger.info(f"{route.log_prefix}: tx_hash={tx_hash}, value={_value_with_suffix(value, config)}, status={status}")
151
+
152
+
153
+ def _calc_value(route: TxRoute, max_fee: int, gas: int, config: Config) -> int | None:
154
+ if config.token:
155
+ return rpc_helpers.calc_erc20_value_for_address(
156
+ nodes=config.nodes,
157
+ value_expression=config.value,
158
+ wallet_address=route.from_address,
159
+ token_address=config.token,
160
+ decimals=config.token_decimals,
161
+ log_prefix=route.log_prefix,
162
+ )
163
+ return rpc_helpers.calc_eth_value_for_address(
164
+ nodes=config.nodes,
165
+ value_expression=config.value,
166
+ address=route.from_address,
167
+ gas=gas,
168
+ max_fee=max_fee,
169
+ log_prefix=route.log_prefix,
170
+ )
171
+
172
+
173
+ def _check_value_min_limit(route: TxRoute, value: int, config: Config) -> bool:
174
+ """Returns False if the transfer should be skipped."""
175
+ if config.value_min_limit:
176
+ if config.token:
177
+ value_min_limit = calcs.calc_token_expression(config.value_min_limit, config.token_decimals)
178
+ else:
179
+ value_min_limit = calcs.calc_eth_expression(config.value_min_limit)
180
+ if value < value_min_limit:
181
+ logger.info(f"{route.log_prefix}: value<value_min_limit, value={_value_with_suffix(value, config)}")
182
+ return True
183
+
184
+
185
+ def _send_tx(*, route: TxRoute, nonce: int, max_fee: int, priority_fee: int, gas: int, value: int, config: Config) -> str | None:
186
+ debug_tx_params = {
187
+ "nonce": nonce,
188
+ "max_fee": max_fee,
189
+ "priority_fee": priority_fee,
190
+ "gas": gas,
191
+ "value": value,
192
+ "to": route.to_address,
193
+ "chain_id": config.chain_id,
194
+ }
195
+ logger.debug(f"{route.log_prefix}: tx_params={debug_tx_params}")
196
+
197
+ if config.token:
198
+ signed_tx = erc20.sign_transfer_tx(
199
+ nonce=nonce,
200
+ max_fee_per_gas=max_fee,
201
+ max_priority_fee_per_gas=priority_fee,
202
+ gas_limit=gas,
203
+ private_key=config.private_keys[route.from_address],
204
+ chain_id=config.chain_id,
205
+ value=value,
206
+ token_address=config.token,
207
+ recipient_address=route.to_address,
208
+ )
209
+ else:
210
+ signed_tx = sign_tx(
211
+ nonce=nonce,
212
+ max_fee_per_gas=max_fee,
213
+ max_priority_fee_per_gas=priority_fee,
214
+ gas=gas,
215
+ private_key=config.private_keys[route.from_address],
216
+ chain_id=config.chain_id,
217
+ value=value,
218
+ to=route.to_address,
219
+ )
220
+ res = rpc.eth_send_raw_transaction(config.nodes, signed_tx.raw_tx, attempts=5)
221
+ if isinstance(res, Err):
222
+ logger.info(f"{route.log_prefix}: tx error {res.err}")
223
+ return None
224
+ return res.ok
225
+
226
+
227
+ def _calc_gas(route: TxRoute, config: Config) -> int | None:
228
+ if config.token:
229
+ return rpc_helpers.calc_gas(
230
+ nodes=config.nodes,
231
+ gas_expression=config.gas,
232
+ from_address=route.from_address,
233
+ to_address=config.token,
234
+ data=erc20.encode_transfer_input_data(route.to_address, 1234),
235
+ log_prefix=route.log_prefix,
236
+ )
237
+ return rpc_helpers.calc_gas(
238
+ nodes=config.nodes,
239
+ gas_expression=config.gas,
240
+ from_address=route.from_address,
241
+ to_address=route.to_address,
242
+ value=123,
243
+ log_prefix=route.log_prefix,
244
+ )
245
+
246
+
247
+ def _print_balances(config: Config) -> None:
248
+ if config.token:
249
+ headers = ["n", "from_address", "nonce", "eth", "t", "to_address", "nonce", "eth", "t"]
250
+ else:
251
+ headers = ["n", "from_address", "nonce", "eth", "to_address", "nonce", "eth"]
252
+ table = Table(*headers, title="balances")
253
+ with Live(table, refresh_per_second=0.5):
254
+ for count, route in enumerate(config.routes):
255
+ from_nonce = _get_nonce_str(route.from_address, config)
256
+ to_nonce = _get_nonce_str(route.to_address, config)
257
+
258
+ from_eth_balance = _get_eth_balance_str(route.from_address, config)
259
+ to_eth_balance = _get_eth_balance_str(route.to_address, config)
260
+
261
+ from_token_balance = _get_token_balance_str(route.from_address, config) if config.token else ""
262
+ to_token_balance = _get_token_balance_str(route.to_address, config) if config.token else ""
263
+
264
+ if config.token:
265
+ table.add_row(
266
+ str(count),
267
+ route.from_address,
268
+ from_nonce,
269
+ from_eth_balance,
270
+ from_token_balance,
271
+ route.to_address,
272
+ to_nonce,
273
+ to_eth_balance,
274
+ to_token_balance,
275
+ )
276
+ else:
277
+ table.add_row(
278
+ str(count),
279
+ route.from_address,
280
+ from_nonce,
281
+ from_eth_balance,
282
+ route.to_address,
283
+ to_nonce,
284
+ to_eth_balance,
285
+ )
286
+
287
+
288
+ def _get_nonce_str(address: str, config: Config) -> str:
289
+ return str(rpc.eth_get_transaction_count(config.nodes, address, proxies=config.proxies, attempts=5).ok_or_err())
290
+
291
+
292
+ def _get_eth_balance_str(address: str, config: Config) -> str:
293
+ return rpc.eth_get_balance(config.nodes, address, proxies=config.proxies, attempts=5).map_or_else(
294
+ lambda err: err,
295
+ lambda ok: from_wei_str(ok, "eth", config.round_ndigits),
296
+ )
297
+
298
+
299
+ def _get_token_balance_str(address: str, config: Config) -> str:
300
+ if not config.token:
301
+ raise ValueError("token is not set")
302
+ return erc20.get_balance(config.nodes, config.token, address, proxies=config.proxies, attempts=5).map_or_else(
303
+ lambda err: err,
304
+ lambda ok: from_wei_str(ok, "t", decimals=config.token_decimals, round_ndigits=config.round_ndigits),
305
+ )
306
+
307
+
308
+ def _value_with_suffix(value: int, config: Config) -> str:
309
+ if config.token:
310
+ return from_wei_str(value, "t", config.round_ndigits, decimals=config.token_decimals)
311
+ return from_wei_str(value, "eth", config.round_ndigits)
File without changes
@@ -3,18 +3,18 @@ from typing import Any
3
3
 
4
4
  from mm_std import print_json
5
5
 
6
- from mm_eth.account import generate_accounts, generate_mnemonic
6
+ from mm_eth.account import derive_accounts, generate_mnemonic
7
7
 
8
8
 
9
- def run(mnemonic: str, passphrase: str, limit: int, print_path: bool, path_prefix: str, save_file: str) -> None: # nosec
9
+ def run(mnemonic: str, passphrase: str, words: int, derivation_path: str, limit: int, print_path: bool, save_file: str) -> None: # nosec
10
10
  result: dict[str, Any] = {}
11
11
  if not mnemonic:
12
- mnemonic = generate_mnemonic()
12
+ mnemonic = generate_mnemonic(num_words=words)
13
13
  result["mnemonic"] = mnemonic
14
14
  if passphrase:
15
15
  result["passphrase"] = passphrase
16
16
  result["accounts"] = []
17
- for acc in generate_accounts(mnemonic=mnemonic, passphrase=passphrase, limit=limit, path_prefix=path_prefix):
17
+ for acc in derive_accounts(mnemonic=mnemonic, passphrase=passphrase, limit=limit, derivation_path=derivation_path):
18
18
  new_account = {"address": acc.address, "private": acc.private_key}
19
19
  if print_path:
20
20
  new_account["path"] = acc.path
@@ -3,13 +3,16 @@ addresses = """
3
3
  0x58487485c3858109f5A37e42546FE87473f79a4b
4
4
  0x97C77B548aE0d4925F5C201220fC6d8996424309
5
5
  """
6
+
6
7
  tokens = """
7
8
  0x7EdF3b8579c21A8820b4C0B8352541c1CE04045f # USDT
8
9
  0x6a55fe4884DE7E1d904BdC47A3BA092240ae9B39 # USDC
9
10
  """
11
+
10
12
  nodes = """
11
13
  https://arb1.arbitrum.io/rpc
12
14
  https://rpc.arb1.arbitrum.gateway.fm
13
15
  https://arbitrum-one.publicnode.com
14
16
  """
17
+
15
18
  round_ndigits = 3
@@ -1,5 +1,9 @@
1
1
  contract_address = "0xBa985cad26658EB00eA42aCc7516aed52e7a8AcC"
2
+
2
3
  function_signature = "balanceOf(address)"
4
+
3
5
  function_args = "['0x83aC43147BA5dAA5abc4ccEA84F2B8000bA82f26']"
6
+
4
7
  outputs_types = "uint256" # optional
8
+
5
9
  node = "https://rpc.eth.gateway.fm"
@@ -2,25 +2,37 @@ routes = """
2
2
  0x10fd602Bff689e64D4720D1DCCCD3494f1f16623 0x58487485c3858109f5A37e42546FE87473f79a4b
3
3
  0x97C77B548aE0d4925F5C201220fC6d8996424309 0x7EdF3b8579c21A8820b4C0B8352541c1CE04045f # can comment here
4
4
  """
5
+
5
6
  private_keys = """
6
7
  0x7bb5b9c0ba991275f84b796b4d25fd3a8d7320911f50fade85410e7a2b000632
7
8
  0xb7e0b671e176b04ceb0897a698d34771bfe9acf29273dc52a141be6e97145a00
8
9
  file: /path/to/other_private_keys.txt
9
10
  """
10
- token = "0x60631C856303731BE4deb81C0303F80B652aA5b4" # USDC
11
- decimals = 6
11
+
12
+ token = "0x60631C856303731BE4deb81C0303F80B652aA5b4" # If not specified, it ETH transfers
13
+
12
14
  max_fee = "1.2base_fee + 1gwei + random(1,200)" # supported var_name=base_fee
15
+
13
16
  max_fee_limit = "10.1gwei - random(1, 10)" # optional
17
+
14
18
  priority_fee = "1gwei + random(1, 12)"
19
+
15
20
  gas = "estimate + random(100, 200) - 19" # supported var_name=estimate
16
- value = "0.5balance - random(1.5t, 3t) + 11t" # supported var_name=balance
21
+
22
+ value = "0.5balance - random(1.5t, 3t) + 11t" # supported var_name=balance. For ERC20 token use 't' suffix.
23
+
17
24
  value_min_limit = "0.5t + random(1, 2) - 7"
18
25
 
19
26
  delay = "random(1.123, 10) + 1" # secs, optional
27
+
20
28
  log_debug = "/path/to/file_debug.log" # optional
29
+
21
30
  log_info = "/path/to/file_info.log" # optional
31
+
22
32
  round_ndigits = 6 # optional, default=5
33
+
23
34
  chain_id = 421613
35
+
24
36
  nodes = """
25
37
  https://arbitrum-goerli.publicnode.com
26
38
  https://rpc.goerli.arbitrum.gateway.fm
mm_eth/cli/validators.py CHANGED
@@ -17,6 +17,10 @@ class Validators(ConfigValidators):
17
17
  def valid_token_expression(var_name: str | None = None) -> Callable[[str], str]:
18
18
  return ConfigValidators.valid_calc_int_expression(var_name, {"t": 6})
19
19
 
20
+ @staticmethod
21
+ def valid_eth_or_token_expression(var_name: str | None = None) -> Callable[[str], str]:
22
+ return ConfigValidators.valid_calc_int_expression(var_name, SUFFIX_DECIMALS | {"t": 6})
23
+
20
24
  @staticmethod
21
25
  def eth_routes() -> Callable[[str], list[TxRoute]]:
22
26
  return ConfigValidators.routes(is_address, to_lower=True)
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mm-eth
3
- Version: 0.3.1
3
+ Version: 0.4.1
4
4
  Requires-Python: >=3.12
5
- Requires-Dist: mm-crypto-utils>=0.1.4
5
+ Requires-Dist: mm-crypto-utils>=0.1.5
6
6
  Requires-Dist: typer>=0.15.1
7
- Requires-Dist: web3~=7.7.0
7
+ Requires-Dist: web3~=7.8.0
8
8
  Requires-Dist: websocket-client~=1.8.0
@@ -1,6 +1,6 @@
1
1
  mm_eth/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
2
  mm_eth/abi.py,sha256=Qf-QOsR9QexyQM9XWKNeTMkRarIL3XQJbaDbJ8ifMrw,4856
3
- mm_eth/account.py,sha256=VjyWOYX5K9ZgdJsHp_I3m-lJ5g9FDo3OJ3knqUPs_TM,1924
3
+ mm_eth/account.py,sha256=k0MNMatBe0zo1iKZiB_Tq6zyEIo22IncD6ewNUJp3dY,2075
4
4
  mm_eth/anvil.py,sha256=98RCfI7dEpxFBTV6UErYvubWVP3n0ctUFn1--4kZ84U,1603
5
5
  mm_eth/constants.py,sha256=Cy_G-IleBH4gAZ4ok8AGHHlqmdW0ZM7ZldyVpzAfWLs,54
6
6
  mm_eth/deploy.py,sha256=SB3ruY808_5UnG8kHR34uVP66P3zOWZu0ImKD7UUv2s,691
@@ -15,35 +15,34 @@ mm_eth/tx.py,sha256=efSoMCoWkenbGdHo1_LX66_Edz1HvED5-J_i3wrHwMw,4051
15
15
  mm_eth/utils.py,sha256=sSxt9GZEntZlT0RU8ht9Qon875HPhpd-1JjgqUBEfVo,7405
16
16
  mm_eth/vault.py,sha256=h8NyiOQh5YFskh1lZA3KyvnJUnxl9769ME2ChplG0CM,1477
17
17
  mm_eth/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
18
- mm_eth/cli/calcs.py,sha256=iHPofMxttXGiJ6h_TqGduzrvfxqQdgYbTHxMWI9u8MA,3845
19
- mm_eth/cli/cli.py,sha256=1c16hY4SIL2NqCnY5SUBPCSIk3AoBn-kPLs9oZsgB1g,10112
20
- mm_eth/cli/cli_utils.py,sha256=j09pcGYJ6MfPV-DOjICTB4G8hE2vqb5od19_T2HbGbk,858
18
+ mm_eth/cli/calcs.py,sha256=cLFTYNAN-I53tUiSg-zFfVr2afjIZPftDDjHj16FBz0,1068
19
+ mm_eth/cli/cli.py,sha256=0UYDtNrCXvJqs836tPxsQHs7yTKjvAGL3qgWiXVhnDU,9208
20
+ mm_eth/cli/cli_utils.py,sha256=yBIS3dGid75zyxw8crPOQHA4p3Krk5BoA2g01turKmQ,1712
21
21
  mm_eth/cli/print_helpers.py,sha256=yOiOFjTKloumwf07AqNIHQswUo8t0yuT9bpeGBGl60Q,1470
22
22
  mm_eth/cli/rpc_helpers.py,sha256=FMV-QVNM3v9X8H_-DP0hjNRqmm7KOnfzkw9bP17Qbz0,4499
23
- mm_eth/cli/validators.py,sha256=77OFPsl6TGdcy-TKElDa5vumBhaVlgSUiAtkJ2C4RaU,1475
23
+ mm_eth/cli/validators.py,sha256=whnuA4CFkKbcNcbl4e2EwktTSTDGFBTA798E9F_3EM8,1682
24
24
  mm_eth/cli/cmd/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
25
25
  mm_eth/cli/cmd/balance_cmd.py,sha256=gkwUa8VGe1wXH0GDsit3-1NmRiijojaNLygi5zNcYSY,2110
26
- mm_eth/cli/cmd/balances_cmd.py,sha256=X3dzzVlphu9L4YPvm--Iev48zqOHlqtckxV8g1tNbwM,4206
27
- mm_eth/cli/cmd/call_contract_cmd.py,sha256=nsOIu8JIZNSZwXi-z9TmjRrzhkupyf9zlf8N65l3HZc,1273
28
- mm_eth/cli/cmd/config_example_cmd.py,sha256=hXsG6S6yZXrr9thu3Uc-RQyl7MCaKayX1v9Ix9m7t50,271
29
- mm_eth/cli/cmd/deploy_cmd.py,sha256=H5p0K4p3wX9MGFHg-h__dYUw-X5ttNTEDMPCy_T5eHQ,1281
26
+ mm_eth/cli/cmd/balances_cmd.py,sha256=4UiWSNH9OCnkvcMGPTygEss8119do-rfG7QtsNMfWZs,4197
27
+ mm_eth/cli/cmd/call_contract_cmd.py,sha256=RbBPvyUEQ45hQINYDKkx1yWhPygdymVKlRL26xI31uk,1264
28
+ mm_eth/cli/cmd/deploy_cmd.py,sha256=0oBp_RZw_DIEtBFRc6QKdAw5oouwwpDIdeXvuyP9xdU,1272
30
29
  mm_eth/cli/cmd/encode_input_data_cmd.py,sha256=9UQ1MKPEFQJ8j_COsP3KGKhwOf9tT3feBezI8vvxTlw,267
31
- mm_eth/cli/cmd/mnemonic_cmd.py,sha256=Mb0H0inSNCa93GEPxGYQi7BnPe11mnLjnspfe7h54I4,972
30
+ mm_eth/cli/cmd/example_cmd.py,sha256=o4NTll3fjmspbKjZ0sHGDRHTZ1RcFNHZDi9Ka0VNoDQ,264
32
31
  mm_eth/cli/cmd/node_cmd.py,sha256=Ae5yPxxnNiHw3tZcojS7KwNLM4gEfLhsTfhZp_86rqU,1956
33
- mm_eth/cli/cmd/private_key_cmd.py,sha256=Fv_2OLog1h32pIP7PJITwl_pHdy3BXvaDRcXZsxY1xo,241
34
32
  mm_eth/cli/cmd/rpc_cmd.py,sha256=02q82YqgbPezfEBmV_QBCIeNReE7ktkPych8Xr9ann8,2186
35
- mm_eth/cli/cmd/send_contract_cmd.py,sha256=h4Ue2gU0SqMoqQk9L8gSOJyzGUgzNLk2fXkEwsTlgQE,7325
33
+ mm_eth/cli/cmd/send_contract_cmd.py,sha256=JX3L1Dz4CBySfa2Pf87E8grauPu0U6xIyyOus81285Y,7316
36
34
  mm_eth/cli/cmd/solc_cmd.py,sha256=tBpeMdPfGs2iQIMaIJAAhMh1a3KyXHwyninfXPVpsgs,677
37
35
  mm_eth/cli/cmd/token_cmd.py,sha256=4y6ZQpLOJ33_iNuKpm9tZXh4RntWhmPUcizgaNNBzaw,1102
38
- mm_eth/cli/cmd/transfer_erc20_cmd.py,sha256=F9lUE4X_i4-yo4pdhHA3CWi1joHvtbUxyFUT4ibc574,7870
39
- mm_eth/cli/cmd/transfer_eth_cmd.py,sha256=egYx3yG29CCzKeZRzY5QmqSHQrypdHMKJ32rHdQpsyg,6980
36
+ mm_eth/cli/cmd/transfer_cmd.py,sha256=nC1Jiqe6zDJQvK9xVhB8mCbkEB1ZgMZmA393tDnZ1Dw,12176
40
37
  mm_eth/cli/cmd/tx_cmd.py,sha256=PIenXYTT60Z2fqsivpzybCLI2Z_tlcz-asm3B0JLHgI,517
41
38
  mm_eth/cli/cmd/vault_cmd.py,sha256=MOM1CILIaaqown1I-Fgo22ckqIMLtFt8t2D3fWNp798,606
42
- mm_eth/cli/config_examples/balances.toml,sha256=Wb3y_-Rh5kqjEDX-jvRZhzwq_rVGv4zLKy7PGe1jysY,418
43
- mm_eth/cli/config_examples/call_contract.toml,sha256=vq6VtOlRYGWfLRpPXNkNcdQpndW8bJJRDBbrTr5rSt8,244
44
- mm_eth/cli/config_examples/transfer_erc20.toml,sha256=3bnLZbl49HlG0BFa4JLp0HQswNbr72BNBTa-niWsRGA,1135
45
- mm_eth/cli/config_examples/transfer_eth.toml,sha256=-eQEU9tK_4To_PDG39vvIZNkZhgh0_EZQJVjbUHJlyg,1164
46
- mm_eth-0.3.1.dist-info/METADATA,sha256=-sztCRTmBAWQ5dtkrzD167ZAwgM1WvZZr_Z2XBaKNqU,207
47
- mm_eth-0.3.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
48
- mm_eth-0.3.1.dist-info/entry_points.txt,sha256=aGhpsozl8NIrkuUcX5fSgURCcDhr3ShUdeTSIrJq4oc,46
49
- mm_eth-0.3.1.dist-info/RECORD,,
39
+ mm_eth/cli/cmd/wallet/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
40
+ mm_eth/cli/cmd/wallet/mnemonic_cmd.py,sha256=xE-5Ux9BdYsTZYBy0dMn9jupGhW4ced-AgYscy_wU_4,1007
41
+ mm_eth/cli/cmd/wallet/private_key_cmd.py,sha256=Fv_2OLog1h32pIP7PJITwl_pHdy3BXvaDRcXZsxY1xo,241
42
+ mm_eth/cli/examples/balances.toml,sha256=i_ALpiEcf8-0TFiUg1cgJhxxfHYeBl9x0b3tnUWjswU,421
43
+ mm_eth/cli/examples/call_contract.toml,sha256=ZQWK-409V_vLIZ2bsRD5RCWPPzShPz2KJTTRQY4YaGw,248
44
+ mm_eth/cli/examples/transfer.toml,sha256=HLVpkCNakZFE8qdFdDUoBPk7ZBdfEmk9TLZtooRsGAc,1198
45
+ mm_eth-0.4.1.dist-info/METADATA,sha256=aMEeaP5awNBZ0ZhRIxmhTl1jennR_TAdETFamw4F9HU,207
46
+ mm_eth-0.4.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
47
+ mm_eth-0.4.1.dist-info/entry_points.txt,sha256=aGhpsozl8NIrkuUcX5fSgURCcDhr3ShUdeTSIrJq4oc,46
48
+ mm_eth-0.4.1.dist-info/RECORD,,
@@ -1,200 +0,0 @@
1
- import sys
2
- import time
3
- from pathlib import Path
4
- from typing import Annotated, Self
5
-
6
- import mm_crypto_utils
7
- from loguru import logger
8
- from mm_crypto_utils import AddressToPrivate, TxRoute
9
- from mm_std import BaseConfig, Err, Ok, fatal, utc_now
10
- from pydantic import AfterValidator, BeforeValidator, model_validator
11
-
12
- from mm_eth import erc20, rpc
13
- from mm_eth.cli import cli_utils, print_helpers, rpc_helpers
14
- from mm_eth.cli.calcs import calc_eth_expression
15
- from mm_eth.cli.cli_utils import BaseConfigParams
16
- from mm_eth.cli.validators import Validators
17
- from mm_eth.utils import from_wei_str
18
-
19
-
20
- # noinspection DuplicatedCode
21
- class Config(BaseConfig):
22
- nodes: Annotated[list[str], BeforeValidator(Validators.nodes())]
23
- chain_id: int
24
- routes: Annotated[list[TxRoute], BeforeValidator(Validators.eth_routes())]
25
- private_keys: Annotated[AddressToPrivate, BeforeValidator(Validators.eth_private_keys())]
26
- token: Annotated[str, AfterValidator(Validators.eth_address())]
27
- decimals: int
28
- max_fee: Annotated[str, AfterValidator(Validators.valid_eth_expression("base_fee"))]
29
- priority_fee: Annotated[str, AfterValidator(Validators.valid_eth_expression())]
30
- max_fee_limit: Annotated[str | None, AfterValidator(Validators.valid_eth_expression())] = None
31
- value: Annotated[str, AfterValidator(Validators.valid_token_expression("balance"))]
32
- value_min_limit: Annotated[str | None, AfterValidator(Validators.valid_token_expression())] = None
33
- gas: Annotated[str, AfterValidator(Validators.valid_eth_expression("estimate"))]
34
- delay: Annotated[str | None, AfterValidator(Validators.valid_calc_decimal_value())] = None # in seconds
35
- round_ndigits: int = 5
36
- log_debug: Annotated[Path | None, BeforeValidator(Validators.log_file())] = None
37
- log_info: Annotated[Path | None, BeforeValidator(Validators.log_file())] = None
38
-
39
- @property
40
- def from_addresses(self) -> list[str]:
41
- return [r.from_address for r in self.routes]
42
-
43
- @model_validator(mode="after")
44
- def final_validator(self) -> Self:
45
- if not self.private_keys.contains_all_addresses(self.from_addresses):
46
- raise ValueError("private keys are not set for all addresses")
47
-
48
- return self
49
-
50
-
51
- class TransferErc20CmdParams(BaseConfigParams):
52
- print_balances: bool
53
- debug: bool
54
- no_receipt: bool
55
- emulate: bool
56
-
57
-
58
- # noinspection DuplicatedCode
59
- def run(cli_params: TransferErc20CmdParams) -> None:
60
- config = Config.read_toml_config_or_exit(cli_params.config_path)
61
- if cli_params.print_config_and_exit:
62
- config.print_and_exit({"private_keys"})
63
-
64
- mm_crypto_utils.init_logger(cli_params.debug, config.log_debug, config.log_info)
65
- rpc_helpers.check_nodes_for_chain_id(config.nodes, config.chain_id)
66
-
67
- # check decimals
68
- res = erc20.get_decimals(config.nodes[0], config.token)
69
- if isinstance(res, Err):
70
- fatal(f"can't get token decimals: {res.err}")
71
- if res.ok != config.decimals:
72
- fatal(f"config.decimals is wrong: {config.decimals} != {res.ok}")
73
-
74
- if cli_params.print_balances:
75
- print_helpers.print_balances(
76
- config.nodes,
77
- config.from_addresses,
78
- token_address=config.token,
79
- token_decimals=config.decimals,
80
- round_ndigits=config.round_ndigits,
81
- )
82
- sys.exit(0)
83
-
84
- return _run_transfers(config, cli_params)
85
-
86
-
87
- # noinspection DuplicatedCode
88
- def _run_transfers(config: Config, cli_params: TransferErc20CmdParams) -> None:
89
- logger.info(f"started at {utc_now()} UTC")
90
- logger.debug(f"config={config.model_dump(exclude={'private_keys'}) | {'version': cli_utils.get_version()}}")
91
- for i, route in enumerate(config.routes):
92
- _transfer(route, config, cli_params)
93
- if not cli_params.emulate and config.delay is not None and i < len(config.routes) - 1:
94
- delay_value = mm_crypto_utils.calc_decimal_value(config.delay)
95
- logger.debug(f"delay {delay_value} seconds")
96
- time.sleep(float(delay_value))
97
- logger.info(f"finished at {utc_now()} UTC")
98
-
99
-
100
- # noinspection DuplicatedCode
101
- def _transfer(route: TxRoute, config: Config, cli_params: TransferErc20CmdParams) -> None:
102
- log_prefix = f"{route.from_address}->{route.to_address}"
103
- # get nonce
104
- nonce = rpc_helpers.get_nonce(config.nodes, route.from_address, log_prefix)
105
- if nonce is None:
106
- return
107
-
108
- # get max_fee
109
- max_fee = rpc_helpers.calc_max_fee(config.nodes, config.max_fee, log_prefix)
110
- if max_fee is None:
111
- return
112
-
113
- # check max_fee_limit
114
- if rpc_helpers.is_max_fee_limit_exceeded(max_fee, config.max_fee_limit, log_prefix):
115
- return
116
-
117
- # get gas
118
- gas = rpc_helpers.calc_gas(
119
- nodes=config.nodes,
120
- gas_expression=config.gas,
121
- from_address=route.from_address,
122
- to_address=config.token,
123
- data=erc20.encode_transfer_input_data(route.to_address, 1234),
124
- log_prefix=log_prefix,
125
- )
126
- if gas is None:
127
- return
128
-
129
- # get value
130
- value = rpc_helpers.calc_erc20_value_for_address(
131
- nodes=config.nodes,
132
- value_expression=config.value,
133
- wallet_address=route.from_address,
134
- token_address=config.token,
135
- decimals=config.decimals,
136
- log_prefix=log_prefix,
137
- )
138
- if value is None:
139
- return
140
-
141
- # value_min_limit
142
- if config.value_min_limit is not None:
143
- value_min_limit = mm_crypto_utils.calc_int_expression(config.value_min_limit, suffix_decimals={"t": config.decimals})
144
- if value < value_min_limit:
145
- value_str = from_wei_str(value, "t", config.round_ndigits, decimals=config.decimals)
146
- logger.info(f"{log_prefix}value<value_min_limit, value={value_str}")
147
- return
148
-
149
- priority_fee = calc_eth_expression(config.priority_fee)
150
-
151
- # emulate?
152
- if cli_params.emulate:
153
- msg = f"{log_prefix}: emulate,"
154
- msg += f" value={from_wei_str(value, 't', decimals=config.decimals, round_ndigits=config.round_ndigits)},"
155
- msg += f" max_fee={from_wei_str(max_fee, 'gwei', config.round_ndigits)},"
156
- msg += f" priority_fee={from_wei_str(priority_fee, 'gwei', config.round_ndigits)},"
157
- msg += f" gas={gas}"
158
- logger.info(msg)
159
- return
160
-
161
- debug_tx_params = {
162
- "nonce": nonce,
163
- "max_fee": max_fee,
164
- "priority_fee": priority_fee,
165
- "gas": gas,
166
- "value": value,
167
- "to": route.to_address,
168
- "chain_id": config.chain_id,
169
- }
170
- logger.debug(f"{log_prefix}: tx_params={debug_tx_params}")
171
- signed_tx = erc20.sign_transfer_tx(
172
- nonce=nonce,
173
- max_fee_per_gas=max_fee,
174
- max_priority_fee_per_gas=priority_fee,
175
- gas_limit=gas,
176
- private_key=config.private_keys[route.from_address],
177
- chain_id=config.chain_id,
178
- value=value,
179
- token_address=config.token,
180
- recipient_address=route.to_address,
181
- )
182
- res = rpc.eth_send_raw_transaction(config.nodes, signed_tx.raw_tx, attempts=5)
183
- if isinstance(res, Err):
184
- logger.info(f"{log_prefix}: send_error: {res.err}")
185
- return
186
- tx_hash = res.ok
187
-
188
- if cli_params.no_receipt:
189
- msg = f"{log_prefix}: tx_hash={tx_hash}, value={from_wei_str(value, 't', decimals=config.decimals, round_ndigits=config.round_ndigits)}" # noqa: E501
190
- logger.info(msg)
191
- else:
192
- logger.debug(f"{log_prefix}: tx_hash={tx_hash}, wait receipt")
193
- while True: # TODO: infinite loop if receipt_res is err
194
- receipt_res = rpc.get_tx_status(config.nodes, tx_hash)
195
- if isinstance(receipt_res, Ok):
196
- status = "OK" if receipt_res.ok == 1 else "FAIL"
197
- msg = f"{log_prefix}: tx_hash={tx_hash}, value={from_wei_str(value, 't', decimals=config.decimals, round_ndigits=config.round_ndigits)}, status={status}" # noqa: E501
198
- logger.info(msg)
199
- break
200
- time.sleep(1)
@@ -1,182 +0,0 @@
1
- import sys
2
- import time
3
- from pathlib import Path
4
- from typing import Annotated, Self
5
-
6
- import mm_crypto_utils
7
- from loguru import logger
8
- from mm_crypto_utils import AddressToPrivate, TxRoute
9
- from mm_std import BaseConfig, Err, Ok, utc_now
10
- from pydantic import AfterValidator, BeforeValidator, model_validator
11
-
12
- from mm_eth import rpc
13
- from mm_eth.cli import cli_utils, print_helpers, rpc_helpers
14
- from mm_eth.cli.calcs import calc_eth_expression
15
- from mm_eth.cli.cli_utils import BaseConfigParams
16
- from mm_eth.cli.validators import Validators as Validators
17
- from mm_eth.tx import sign_tx
18
- from mm_eth.utils import from_wei_str
19
-
20
-
21
- # noinspection DuplicatedCode
22
- class Config(BaseConfig):
23
- nodes: Annotated[list[str], BeforeValidator(Validators.nodes())]
24
- chain_id: int
25
- routes: Annotated[list[TxRoute], BeforeValidator(Validators.eth_routes())]
26
- private_keys: Annotated[AddressToPrivate, BeforeValidator(Validators.eth_private_keys())]
27
- max_fee: Annotated[str, AfterValidator(Validators.valid_eth_expression("base_fee"))]
28
- priority_fee: Annotated[str, AfterValidator(Validators.valid_eth_expression())]
29
- max_fee_limit: Annotated[str | None, AfterValidator(Validators.valid_eth_expression())] = None
30
- value: Annotated[str, AfterValidator(Validators.valid_eth_expression("balance"))]
31
- value_min_limit: Annotated[str | None, AfterValidator(Validators.valid_eth_expression())] = None
32
- gas: Annotated[str, AfterValidator(Validators.valid_eth_expression("estimate"))]
33
- delay: Annotated[str | None, AfterValidator(Validators.valid_calc_decimal_value())] = None # in seconds
34
- round_ndigits: int = 5
35
- log_debug: Annotated[Path | None, AfterValidator(Validators.log_file())] = None
36
- log_info: Annotated[Path | None, AfterValidator(Validators.log_file())] = None
37
-
38
- @property
39
- def from_addresses(self) -> list[str]:
40
- return [r.from_address for r in self.routes]
41
-
42
- @model_validator(mode="after")
43
- def final_validator(self) -> Self:
44
- if not self.private_keys.contains_all_addresses(self.from_addresses):
45
- raise ValueError("private keys are not set for all addresses")
46
-
47
- return self
48
-
49
-
50
- class TransferEthCmdParams(BaseConfigParams):
51
- print_balances: bool
52
- debug: bool
53
- no_receipt: bool
54
- emulate: bool
55
-
56
-
57
- def run(cli_params: TransferEthCmdParams) -> None:
58
- config = Config.read_toml_config_or_exit(cli_params.config_path)
59
- if cli_params.print_config_and_exit:
60
- config.print_and_exit({"private_keys"})
61
-
62
- mm_crypto_utils.init_logger(cli_params.debug, config.log_debug, config.log_info)
63
- rpc_helpers.check_nodes_for_chain_id(config.nodes, config.chain_id)
64
-
65
- if cli_params.print_balances:
66
- print_helpers.print_balances(config.nodes, config.from_addresses, round_ndigits=config.round_ndigits)
67
- sys.exit(0)
68
-
69
- return _run_transfers(config, cli_params)
70
-
71
-
72
- # noinspection DuplicatedCode
73
- def _run_transfers(config: Config, cli_params: TransferEthCmdParams) -> None:
74
- logger.info(f"started at {utc_now()} UTC")
75
- logger.debug(f"config={config.model_dump(exclude={'private_keys'}) | {'version': cli_utils.get_version()}}")
76
- for i, route in enumerate(config.routes):
77
- _transfer(route, config, cli_params)
78
- if not cli_params.emulate and config.delay is not None and i < len(config.routes) - 1:
79
- delay_value = mm_crypto_utils.calc_decimal_value(config.delay)
80
- logger.debug(f"delay {delay_value} seconds")
81
- time.sleep(float(delay_value))
82
- logger.info(f"finished at {utc_now()} UTC")
83
-
84
-
85
- # noinspection DuplicatedCode
86
- def _transfer(route: TxRoute, config: Config, cli_params: TransferEthCmdParams) -> None:
87
- log_prefix = f"{route.from_address}->{route.to_address}"
88
- # get nonce
89
- nonce = rpc_helpers.get_nonce(config.nodes, route.from_address, log_prefix)
90
- if nonce is None:
91
- return
92
-
93
- # get max_fee
94
- max_fee = rpc_helpers.calc_max_fee(config.nodes, config.max_fee, log_prefix)
95
- if max_fee is None:
96
- return
97
-
98
- # check max_fee_limit
99
- if rpc_helpers.is_max_fee_limit_exceeded(max_fee, config.max_fee_limit, log_prefix):
100
- return
101
-
102
- # get gas
103
- gas = rpc_helpers.calc_gas(
104
- nodes=config.nodes,
105
- gas_expression=config.gas,
106
- from_address=route.from_address,
107
- to_address=route.to_address,
108
- value=123,
109
- log_prefix=log_prefix,
110
- )
111
- if gas is None:
112
- return
113
-
114
- # get value
115
- value = rpc_helpers.calc_eth_value_for_address(
116
- nodes=config.nodes,
117
- value_expression=config.value,
118
- address=route.from_address,
119
- gas=gas,
120
- max_fee=max_fee,
121
- log_prefix=log_prefix,
122
- )
123
- if value is None:
124
- return
125
-
126
- # value_min_limit
127
- if config.value_min_limit is not None:
128
- value_min_limit = calc_eth_expression(config.value_min_limit)
129
- if value < value_min_limit:
130
- logger.info(f"{log_prefix}value<value_min_limit, value={from_wei_str(value, 'eth', config.round_ndigits)}")
131
- return
132
-
133
- priority_fee = calc_eth_expression(config.priority_fee)
134
-
135
- # emulate?
136
- if cli_params.emulate:
137
- msg = f"{log_prefix}: emulate, value={from_wei_str(value, 'eth', config.round_ndigits)},"
138
- msg += f" max_fee={from_wei_str(max_fee, 'gwei', config.round_ndigits)},"
139
- msg += f" priority_fee={from_wei_str(priority_fee, 'gwei', config.round_ndigits)},"
140
- msg += f" gas={gas}"
141
- logger.info(msg)
142
- return
143
-
144
- debug_tx_params = {
145
- "nonce": nonce,
146
- "max_fee": max_fee,
147
- "priority_fee": priority_fee,
148
- "gas": gas,
149
- "value": value,
150
- "to": route.to_address,
151
- "chain_id": config.chain_id,
152
- }
153
- logger.debug(f"{log_prefix}: tx_params={debug_tx_params}")
154
- signed_tx = sign_tx(
155
- nonce=nonce,
156
- max_fee_per_gas=max_fee,
157
- max_priority_fee_per_gas=priority_fee,
158
- gas=gas,
159
- private_key=config.private_keys[route.from_address],
160
- chain_id=config.chain_id,
161
- value=value,
162
- to=route.to_address,
163
- )
164
- res = rpc.eth_send_raw_transaction(config.nodes, signed_tx.raw_tx, attempts=5)
165
- if isinstance(res, Err):
166
- logger.info(f"{log_prefix}: send_error: {res.err}")
167
- return
168
- tx_hash = res.ok
169
-
170
- if cli_params.no_receipt:
171
- msg = f"{log_prefix}: tx_hash={tx_hash}, value={from_wei_str(value, 'ether', round_ndigits=config.round_ndigits)}"
172
- logger.info(msg)
173
- else:
174
- logger.debug(f"{log_prefix}: tx_hash={tx_hash}, wait receipt")
175
- while True: # TODO: infinite loop if receipt_res is err
176
- receipt_res = rpc.get_tx_status(config.nodes, tx_hash)
177
- if isinstance(receipt_res, Ok):
178
- status = "OK" if receipt_res.ok == 1 else "FAIL"
179
- msg = f"{log_prefix}: tx_hash={tx_hash}, value={from_wei_str(value, 'ether', round_ndigits=config.round_ndigits)}, status={status}" # noqa: E501
180
- logger.info(msg)
181
- break
182
- time.sleep(1)
@@ -1,26 +0,0 @@
1
- routes = """
2
- 0x10fd602Bff689e64D4720D1DCCCD3494f1f16623 0x58487485c3858109f5A37e42546FE87473f79a4b
3
- 0x97C77B548aE0d4925F5C201220fC6d8996424309 0x7EdF3b8579c21A8820b4C0B8352541c1CE04045f # can comment here
4
- file: /path/from_addresses.txt /path/to_addresses.txt
5
- """
6
- private_keys = """
7
- # 0x7bb5b9c0ba991275f84b796b4d25fd3a8d7320911f50fade85410e7a2b000632
8
- 0xb7e0b671e176b04ceb0897a698d34771bfe9acf29273dc52a141be6e97145a00
9
- file: /path/to/private_keys.txt
10
- """
11
- max_fee = "1.2base + 1gwei + random(1, 200)" # supported var_name=base
12
- max_fee_limit = "10.1gwei - random(1, 10)" # optional
13
- priority_fee = "1gwei + random(1, 12)"
14
- gas = "estimate + random(100, 200) - 19" # supported var_name=estimate
15
- value = "balance - random(0.002eth, 0.0025eth) + 11gwei" # supported var_name=balance. If 'balance' is used, value=calc(value) - gas*max_fee_per_gas
16
- value_min_limit = "0.001eth + random(1, 2) - 7gwei"
17
-
18
- delay = "random(1.123,10) + 1" # secs
19
- log_debug = "/path/to/file_debug.log" # optional
20
- log_info = "/path/to/file_info.log" # optional
21
- round_ndigits = 6
22
- chain_id = 421613
23
- nodes = """
24
- https://arbitrum-goerli.publicnode.com
25
- https://rpc.goerli.arbitrum.gateway.fm
26
- """
File without changes