mm-eth 0.3.1__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. {mm_eth-0.3.1 → mm_eth-0.4.0}/PKG-INFO +3 -3
  2. {mm_eth-0.3.1 → mm_eth-0.4.0}/pyproject.toml +6 -6
  3. mm_eth-0.4.0/src/mm_eth/cli/calcs.py +27 -0
  4. {mm_eth-0.3.1 → mm_eth-0.4.0}/src/mm_eth/cli/cli.py +20 -42
  5. mm_eth-0.4.0/src/mm_eth/cli/cli_utils.py +60 -0
  6. {mm_eth-0.3.1 → mm_eth-0.4.0}/src/mm_eth/cli/cmd/balances_cmd.py +1 -1
  7. {mm_eth-0.3.1 → mm_eth-0.4.0}/src/mm_eth/cli/cmd/call_contract_cmd.py +1 -1
  8. {mm_eth-0.3.1 → mm_eth-0.4.0}/src/mm_eth/cli/cmd/deploy_cmd.py +1 -1
  9. mm_eth-0.3.1/src/mm_eth/cli/cmd/config_example_cmd.py → mm_eth-0.4.0/src/mm_eth/cli/cmd/example_cmd.py +1 -1
  10. {mm_eth-0.3.1 → mm_eth-0.4.0}/src/mm_eth/cli/cmd/send_contract_cmd.py +1 -1
  11. mm_eth-0.4.0/src/mm_eth/cli/cmd/transfer_cmd.py +311 -0
  12. {mm_eth-0.3.1/src/mm_eth/cli/config_examples → mm_eth-0.4.0/src/mm_eth/cli/examples}/balances.toml +3 -0
  13. {mm_eth-0.3.1/src/mm_eth/cli/config_examples → mm_eth-0.4.0/src/mm_eth/cli/examples}/call_contract.toml +4 -0
  14. mm_eth-0.3.1/src/mm_eth/cli/config_examples/transfer_erc20.toml → mm_eth-0.4.0/src/mm_eth/cli/examples/transfer.toml +15 -3
  15. {mm_eth-0.3.1 → mm_eth-0.4.0}/src/mm_eth/cli/validators.py +4 -0
  16. {mm_eth-0.3.1 → mm_eth-0.4.0}/uv.lock +47 -47
  17. mm_eth-0.3.1/src/mm_eth/cli/calcs.py +0 -93
  18. mm_eth-0.3.1/src/mm_eth/cli/cli_utils.py +0 -32
  19. mm_eth-0.3.1/src/mm_eth/cli/cmd/transfer_erc20_cmd.py +0 -200
  20. mm_eth-0.3.1/src/mm_eth/cli/cmd/transfer_eth_cmd.py +0 -182
  21. mm_eth-0.3.1/src/mm_eth/cli/config_examples/transfer_eth.toml +0 -26
  22. {mm_eth-0.3.1 → mm_eth-0.4.0}/.gitignore +0 -0
  23. {mm_eth-0.3.1 → mm_eth-0.4.0}/README.txt +0 -0
  24. {mm_eth-0.3.1 → mm_eth-0.4.0}/dict.dic +0 -0
  25. {mm_eth-0.3.1 → mm_eth-0.4.0}/justfile +0 -0
  26. {mm_eth-0.3.1 → mm_eth-0.4.0}/src/mm_eth/__init__.py +0 -0
  27. {mm_eth-0.3.1 → mm_eth-0.4.0}/src/mm_eth/abi.py +0 -0
  28. {mm_eth-0.3.1 → mm_eth-0.4.0}/src/mm_eth/account.py +0 -0
  29. {mm_eth-0.3.1 → mm_eth-0.4.0}/src/mm_eth/anvil.py +0 -0
  30. {mm_eth-0.3.1 → mm_eth-0.4.0}/src/mm_eth/cli/__init__.py +0 -0
  31. {mm_eth-0.3.1 → mm_eth-0.4.0}/src/mm_eth/cli/cmd/__init__.py +0 -0
  32. {mm_eth-0.3.1 → mm_eth-0.4.0}/src/mm_eth/cli/cmd/balance_cmd.py +0 -0
  33. {mm_eth-0.3.1 → mm_eth-0.4.0}/src/mm_eth/cli/cmd/encode_input_data_cmd.py +0 -0
  34. {mm_eth-0.3.1 → mm_eth-0.4.0}/src/mm_eth/cli/cmd/mnemonic_cmd.py +0 -0
  35. {mm_eth-0.3.1 → mm_eth-0.4.0}/src/mm_eth/cli/cmd/node_cmd.py +0 -0
  36. {mm_eth-0.3.1 → mm_eth-0.4.0}/src/mm_eth/cli/cmd/private_key_cmd.py +0 -0
  37. {mm_eth-0.3.1 → mm_eth-0.4.0}/src/mm_eth/cli/cmd/rpc_cmd.py +0 -0
  38. {mm_eth-0.3.1 → mm_eth-0.4.0}/src/mm_eth/cli/cmd/solc_cmd.py +0 -0
  39. {mm_eth-0.3.1 → mm_eth-0.4.0}/src/mm_eth/cli/cmd/token_cmd.py +0 -0
  40. {mm_eth-0.3.1 → mm_eth-0.4.0}/src/mm_eth/cli/cmd/tx_cmd.py +0 -0
  41. {mm_eth-0.3.1 → mm_eth-0.4.0}/src/mm_eth/cli/cmd/vault_cmd.py +0 -0
  42. {mm_eth-0.3.1 → mm_eth-0.4.0}/src/mm_eth/cli/print_helpers.py +0 -0
  43. {mm_eth-0.3.1 → mm_eth-0.4.0}/src/mm_eth/cli/rpc_helpers.py +0 -0
  44. {mm_eth-0.3.1 → mm_eth-0.4.0}/src/mm_eth/constants.py +0 -0
  45. {mm_eth-0.3.1 → mm_eth-0.4.0}/src/mm_eth/deploy.py +0 -0
  46. {mm_eth-0.3.1 → mm_eth-0.4.0}/src/mm_eth/ens.py +0 -0
  47. {mm_eth-0.3.1 → mm_eth-0.4.0}/src/mm_eth/erc20.py +0 -0
  48. {mm_eth-0.3.1 → mm_eth-0.4.0}/src/mm_eth/ethernodes.py +0 -0
  49. {mm_eth-0.3.1 → mm_eth-0.4.0}/src/mm_eth/json_encoder.py +0 -0
  50. {mm_eth-0.3.1 → mm_eth-0.4.0}/src/mm_eth/py.typed +0 -0
  51. {mm_eth-0.3.1 → mm_eth-0.4.0}/src/mm_eth/rpc.py +0 -0
  52. {mm_eth-0.3.1 → mm_eth-0.4.0}/src/mm_eth/solc.py +0 -0
  53. {mm_eth-0.3.1 → mm_eth-0.4.0}/src/mm_eth/tx.py +0 -0
  54. {mm_eth-0.3.1 → mm_eth-0.4.0}/src/mm_eth/utils.py +0 -0
  55. {mm_eth-0.3.1 → mm_eth-0.4.0}/src/mm_eth/vault.py +0 -0
  56. {mm_eth-0.3.1 → mm_eth-0.4.0}/tests/__init__.py +0 -0
  57. {mm_eth-0.3.1 → mm_eth-0.4.0}/tests/cli/__init__.py +0 -0
  58. {mm_eth-0.3.1 → mm_eth-0.4.0}/tests/cli/cmd/__init__.py +0 -0
  59. {mm_eth-0.3.1 → mm_eth-0.4.0}/tests/cli/cmd/test_balance_cmd.py +0 -0
  60. {mm_eth-0.3.1 → mm_eth-0.4.0}/tests/cli/cmd/test_mnemonic_cmd.py +0 -0
  61. {mm_eth-0.3.1 → mm_eth-0.4.0}/tests/cli/cmd/test_node_cmd.py +0 -0
  62. {mm_eth-0.3.1 → mm_eth-0.4.0}/tests/cli/cmd/test_private_key_cmd.py +0 -0
  63. {mm_eth-0.3.1 → mm_eth-0.4.0}/tests/cli/cmd/test_solc_cmd.py +0 -0
  64. {mm_eth-0.3.1 → mm_eth-0.4.0}/tests/cli/test_calcs.py +0 -0
  65. {mm_eth-0.3.1 → mm_eth-0.4.0}/tests/conftest.py +0 -0
  66. {mm_eth-0.3.1 → mm_eth-0.4.0}/tests/contracts/ERC20.sol +0 -0
  67. {mm_eth-0.3.1 → mm_eth-0.4.0}/tests/contracts/abi/ERC20.json +0 -0
  68. {mm_eth-0.3.1 → mm_eth-0.4.0}/tests/test_abi.py +0 -0
  69. {mm_eth-0.3.1 → mm_eth-0.4.0}/tests/test_account.py +0 -0
  70. {mm_eth-0.3.1 → mm_eth-0.4.0}/tests/test_ethernodes.py +0 -0
  71. {mm_eth-0.3.1 → mm_eth-0.4.0}/tests/test_rpc.py +0 -0
  72. {mm_eth-0.3.1 → mm_eth-0.4.0}/tests/test_tx.py +0 -0
  73. {mm_eth-0.3.1 → mm_eth-0.4.0}/tests/test_utils.py +0 -0
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mm-eth
3
- Version: 0.3.1
3
+ Version: 0.4.0
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,12 +1,12 @@
1
1
  [project]
2
2
  name = "mm-eth"
3
- version = "0.3.1"
3
+ version = "0.4.0"
4
4
  description = ""
5
5
  requires-python = ">=3.12"
6
6
  dependencies = [
7
- "mm-crypto-utils>=0.1.4",
7
+ "mm-crypto-utils>=0.1.5",
8
8
  "websocket-client~=1.8.0",
9
- "web3~=7.7.0",
9
+ "web3~=7.8.0",
10
10
  "typer>=0.15.1",
11
11
  ]
12
12
  [project.scripts]
@@ -20,11 +20,11 @@ build-backend = "hatchling.build"
20
20
  dev-dependencies = [
21
21
  "pytest~=8.3.4",
22
22
  "pytest-xdist~=3.6.1",
23
- "ruff~=0.9.4",
23
+ "ruff~=0.9.5",
24
24
  "pip-audit~=2.7.3",
25
25
  "bandit~=1.8.2",
26
- "mypy~=1.14.1",
27
- "types-PyYAML~=6.0.12.20241230",
26
+ "mypy~=1.15.0",
27
+ "types-pyyaml>=6.0.12.20241230",
28
28
  ]
29
29
 
30
30
  [tool.mypy]
@@ -0,0 +1,27 @@
1
+ import random
2
+
3
+ import mm_crypto_utils
4
+ from mm_crypto_utils import VarInt
5
+
6
+ from mm_eth.constants import SUFFIX_DECIMALS
7
+
8
+
9
+ def calc_eth_expression(expression: str, var: VarInt | None = None) -> int:
10
+ return mm_crypto_utils.calc_int_expression(expression, var=var, suffix_decimals=SUFFIX_DECIMALS)
11
+
12
+
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})
15
+
16
+
17
+ def calc_function_args(value: str) -> str:
18
+ while True:
19
+ if "random(" not in value:
20
+ return value
21
+ start_index = value.index("random(")
22
+ stop_index = value.index(")", start_index)
23
+ random_range = [int(v.strip()) for v in value[start_index + 7 : stop_index].split(",")]
24
+ if len(random_range) != 2:
25
+ raise ValueError("wrong random(from,to) template")
26
+ rand_value = str(random.randint(random_range[0], random_range[1]))
27
+ value = value[0:start_index] + rand_value + value[stop_index + 1 :]
@@ -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")
@@ -0,0 +1,60 @@
1
+ import importlib.metadata
2
+ import time
3
+ from pathlib import Path
4
+ from typing import Literal
5
+
6
+ from mm_crypto_utils import Nodes, Proxies
7
+ from mm_std import BaseConfig, print_json
8
+ from pydantic import BaseModel
9
+
10
+ from mm_eth import rpc
11
+
12
+
13
+ def get_version() -> str:
14
+ return importlib.metadata.version("mm-eth")
15
+
16
+
17
+ def public_rpc_url(url: str | None) -> str:
18
+ if not url or url == "1":
19
+ return "https://ethereum.publicnode.com"
20
+ if url.startswith(("http://", "https://", "ws://", "wss://")):
21
+ return url
22
+
23
+ match url.lower():
24
+ case "mainnet" | "1":
25
+ return "https://ethereum.publicnode.com"
26
+ case "opbnb" | "204":
27
+ return "https://opbnb-mainnet-rpc.bnbchain.org"
28
+ case "base" | "8453":
29
+ return "https://mainnet.base.org"
30
+ case "base-sepolia" | "84532":
31
+ return "https://sepolia.base.org"
32
+ case _:
33
+ return url
34
+
35
+
36
+ class BaseConfigParams(BaseModel):
37
+ config_path: Path
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"