mm-eth 0.3.0__py3-none-any.whl → 0.4.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/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
@@ -10,9 +10,9 @@ from .cmd import (
10
10
  balance_cmd,
11
11
  balances_cmd,
12
12
  call_contract_cmd,
13
- config_example_cmd,
14
13
  deploy_cmd,
15
14
  encode_input_data_cmd,
15
+ example_cmd,
16
16
  mnemonic_cmd,
17
17
  node_cmd,
18
18
  private_key_cmd,
@@ -20,8 +20,7 @@ from .cmd import (
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,7 @@ 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
34
32
 
35
33
  app = typer.Typer(no_args_is_help=True, pretty_exceptions_enable=False, add_completion=False)
36
34
 
@@ -40,8 +38,7 @@ app.add_typer(wallet_app, name="w", hidden=True)
40
38
 
41
39
 
42
40
  class ConfigExample(str, Enum):
43
- TRANSFER_ETH = "transfer-eth"
44
- TRANSFER_ERC20 = "transfer-erc20"
41
+ TRANSFER = "transfer"
45
42
  BALANCES = "balances"
46
43
  CALL_CONTRACT = "call-contract"
47
44
 
@@ -141,43 +138,24 @@ def tx_command(
141
138
  tx_cmd.run(rpc_url, tx_hash, get_receipt)
142
139
 
143
140
 
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(
141
+ @app.command(
142
+ name="transfer", help="Transfers ETH or ERC20 tokens, supporting multiple routes, delays, and expression-based values"
143
+ )
144
+ def transfer_command(
167
145
  config_path: Path,
168
146
  print_balances: bool = typer.Option(False, "--balances", "-b", help="Print balances and exit"),
169
147
  print_config: bool = typer.Option(False, "--config", "-c", help="Print config and exit"),
170
148
  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"),
149
+ skip_receipt: bool = typer.Option(False, "--skip-receipt", help="Don't wait for a tx receipt"),
172
150
  debug: bool = typer.Option(False, "--debug", "-d", help="Print debug info"),
173
151
  ) -> None:
174
- transfer_erc20_cmd.run(
175
- TransferErc20CmdParams(
152
+ transfer_cmd.run(
153
+ TransferCmdParams(
176
154
  config_path=config_path,
177
155
  print_balances=print_balances,
178
- print_config_and_exit=print_config,
156
+ print_config=print_config,
179
157
  debug=debug,
180
- no_receipt=no_receipt,
158
+ skip_receipt=skip_receipt,
181
159
  emulate=emulate,
182
160
  )
183
161
  )
@@ -196,7 +174,7 @@ def send_contract_command(
196
174
  SendContractCmdParams(
197
175
  config_path=config_path,
198
176
  print_balances=print_balances,
199
- print_config_and_exit=print_config,
177
+ print_config=print_config,
200
178
  debug=debug,
201
179
  no_receipt=no_receipt,
202
180
  emulate=emulate,
@@ -211,7 +189,7 @@ def balances_command(
211
189
  nonce: bool = typer.Option(False, "--nonce", "-n", help="Print nonce also"),
212
190
  wei: bool = typer.Option(False, "--wei", "-w", help="Show balances in WEI"),
213
191
  ) -> None:
214
- balances_cmd.run(BalancesCmdParams(config_path=config_path, print_config_and_exit=print_config, wei=wei, show_nonce=nonce))
192
+ balances_cmd.run(BalancesCmdParams(config_path=config_path, print_config=print_config, wei=wei, show_nonce=nonce))
215
193
 
216
194
 
217
195
  @app.command(name="call-contract", help="Call a method on a contract")
@@ -219,7 +197,7 @@ def call_contract_command(
219
197
  config_path: Path,
220
198
  print_config: bool = typer.Option(False, "--config", "-c", help="Print config and exit"),
221
199
  ) -> None:
222
- call_contract_cmd.run(CallContractCmdParams(config_path=config_path, print_config_and_exit=print_config))
200
+ call_contract_cmd.run(CallContractCmdParams(config_path=config_path, print_config=print_config))
223
201
 
224
202
 
225
203
  @app.command(name="deploy", help="Deploy a smart contract onchain")
@@ -227,12 +205,12 @@ def deploy_command(
227
205
  config_path: Path,
228
206
  print_config: bool = typer.Option(False, "--config", "-c", help="Print config and exit"),
229
207
  ) -> None:
230
- deploy_cmd.run(DeployCmdParams(config_path=config_path, print_config_and_exit=print_config))
208
+ deploy_cmd.run(DeployCmdParams(config_path=config_path, print_config=print_config))
231
209
 
232
210
 
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)
211
+ @app.command(name="example", help="Displays an example configuration for a command")
212
+ def example_command(command: Annotated[ConfigExample, typer.Argument()]) -> None:
213
+ example_cmd.run(command)
236
214
 
237
215
 
238
216
  @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)
@@ -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
@@ -18,11 +18,15 @@ class Validators(ConfigValidators):
18
18
  return ConfigValidators.valid_calc_int_expression(var_name, {"t": 6})
19
19
 
20
20
  @staticmethod
21
- def eth_routes() -> Callable[[str | None], list[TxRoute]]:
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
+
24
+ @staticmethod
25
+ def eth_routes() -> Callable[[str], list[TxRoute]]:
22
26
  return ConfigValidators.routes(is_address, to_lower=True)
23
27
 
24
28
  @staticmethod
25
- def eth_private_keys() -> Callable[[str | list[str] | None], AddressToPrivate]:
29
+ def eth_private_keys() -> Callable[[str], AddressToPrivate]:
26
30
  return ConfigValidators.private_keys(address_from_private)
27
31
 
28
32
  @staticmethod
@@ -30,7 +34,7 @@ class Validators(ConfigValidators):
30
34
  return ConfigValidators.address(is_address, to_lower=True)
31
35
 
32
36
  @staticmethod
33
- def eth_addresses(unique: bool) -> Callable[[str | list[str] | None], list[str]]:
37
+ def eth_addresses(unique: bool) -> Callable[[str], list[str]]:
34
38
  return ConfigValidators.addresses(unique, to_lower=True, is_address=is_address)
35
39
 
36
40
 
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mm-eth
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Requires-Python: >=3.12
5
- Requires-Dist: mm-crypto-utils>=0.1.2
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
@@ -15,35 +15,33 @@ 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=SdfOq4hSI2V8rgbSG0LTXqicE8dmIdLDBfSstSPhDfA,9019
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=UV7d53cZZ4vRuRWNfBbw7RpQl8pADyhG2WV7KgmYMCg,1520
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
30
+ mm_eth/cli/cmd/example_cmd.py,sha256=o4NTll3fjmspbKjZ0sHGDRHTZ1RcFNHZDi9Ka0VNoDQ,264
31
31
  mm_eth/cli/cmd/mnemonic_cmd.py,sha256=Mb0H0inSNCa93GEPxGYQi7BnPe11mnLjnspfe7h54I4,972
32
32
  mm_eth/cli/cmd/node_cmd.py,sha256=Ae5yPxxnNiHw3tZcojS7KwNLM4gEfLhsTfhZp_86rqU,1956
33
33
  mm_eth/cli/cmd/private_key_cmd.py,sha256=Fv_2OLog1h32pIP7PJITwl_pHdy3BXvaDRcXZsxY1xo,241
34
34
  mm_eth/cli/cmd/rpc_cmd.py,sha256=02q82YqgbPezfEBmV_QBCIeNReE7ktkPych8Xr9ann8,2186
35
- mm_eth/cli/cmd/send_contract_cmd.py,sha256=h4Ue2gU0SqMoqQk9L8gSOJyzGUgzNLk2fXkEwsTlgQE,7325
35
+ mm_eth/cli/cmd/send_contract_cmd.py,sha256=JX3L1Dz4CBySfa2Pf87E8grauPu0U6xIyyOus81285Y,7316
36
36
  mm_eth/cli/cmd/solc_cmd.py,sha256=tBpeMdPfGs2iQIMaIJAAhMh1a3KyXHwyninfXPVpsgs,677
37
37
  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=WeGX6kHc0x9kTTYuchp80DNWhq-i8vMCuR5JRq8kbVQ,6982
38
+ mm_eth/cli/cmd/transfer_cmd.py,sha256=nC1Jiqe6zDJQvK9xVhB8mCbkEB1ZgMZmA393tDnZ1Dw,12176
40
39
  mm_eth/cli/cmd/tx_cmd.py,sha256=PIenXYTT60Z2fqsivpzybCLI2Z_tlcz-asm3B0JLHgI,517
41
40
  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.0.dist-info/METADATA,sha256=8-_f0wEkpdt0pA8P7kYg2Es1bCjykJUxzaGn553IXEY,207
47
- mm_eth-0.3.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
48
- mm_eth-0.3.0.dist-info/entry_points.txt,sha256=aGhpsozl8NIrkuUcX5fSgURCcDhr3ShUdeTSIrJq4oc,46
49
- mm_eth-0.3.0.dist-info/RECORD,,
41
+ mm_eth/cli/examples/balances.toml,sha256=i_ALpiEcf8-0TFiUg1cgJhxxfHYeBl9x0b3tnUWjswU,421
42
+ mm_eth/cli/examples/call_contract.toml,sha256=ZQWK-409V_vLIZ2bsRD5RCWPPzShPz2KJTTRQY4YaGw,248
43
+ mm_eth/cli/examples/transfer.toml,sha256=HLVpkCNakZFE8qdFdDUoBPk7ZBdfEmk9TLZtooRsGAc,1198
44
+ mm_eth-0.4.0.dist-info/METADATA,sha256=WjvTV6bG2379Zk0cJkuMX6xhAlVSi_YeqVhS4IyBFfI,207
45
+ mm_eth-0.4.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
46
+ mm_eth-0.4.0.dist-info/entry_points.txt,sha256=aGhpsozl8NIrkuUcX5fSgURCcDhr3ShUdeTSIrJq4oc,46
47
+ mm_eth-0.4.0.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, BeforeValidator(Validators.log_file())] = None
36
- log_info: Annotated[Path | None, BeforeValidator(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