mm-balance 0.1.18__tar.gz → 0.5.1__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 (42) hide show
  1. mm_balance-0.5.1/PKG-INFO +10 -0
  2. mm_balance-0.5.1/README.md +122 -0
  3. mm_balance-0.5.1/dict.dic +10 -0
  4. {mm_balance-0.1.18 → mm_balance-0.5.1}/justfile +4 -2
  5. mm_balance-0.5.1/pyproject.toml +75 -0
  6. mm_balance-0.5.1/src/mm_balance/balance_fetcher.py +77 -0
  7. mm_balance-0.5.1/src/mm_balance/cli.py +73 -0
  8. mm_balance-0.5.1/src/mm_balance/command_runner.py +75 -0
  9. mm_balance-0.5.1/src/mm_balance/config/example.toml +83 -0
  10. mm_balance-0.5.1/src/mm_balance/config.py +131 -0
  11. {mm_balance-0.1.18 → mm_balance-0.5.1}/src/mm_balance/constants.py +7 -3
  12. mm_balance-0.5.1/src/mm_balance/diff.py +170 -0
  13. {mm_balance-0.1.18 → mm_balance-0.5.1}/src/mm_balance/output/formats/json_format.py +9 -7
  14. {mm_balance-0.1.18 → mm_balance-0.5.1}/src/mm_balance/output/formats/table_format.py +33 -31
  15. {mm_balance-0.1.18 → mm_balance-0.5.1}/src/mm_balance/output/utils.py +3 -1
  16. {mm_balance-0.1.18 → mm_balance-0.5.1}/src/mm_balance/price.py +11 -9
  17. {mm_balance-0.1.18 → mm_balance-0.5.1}/src/mm_balance/result.py +22 -10
  18. mm_balance-0.5.1/src/mm_balance/rpc.py +183 -0
  19. {mm_balance-0.1.18 → mm_balance-0.5.1}/src/mm_balance/token_decimals.py +19 -19
  20. mm_balance-0.5.1/src/mm_balance/utils.py +30 -0
  21. mm_balance-0.5.1/uv.lock +1901 -0
  22. mm_balance-0.1.18/PKG-INFO +0 -9
  23. mm_balance-0.1.18/README.md +0 -14
  24. mm_balance-0.1.18/pyproject.toml +0 -72
  25. mm_balance-0.1.18/src/mm_balance/cli.py +0 -85
  26. mm_balance-0.1.18/src/mm_balance/config/example.yml +0 -74
  27. mm_balance-0.1.18/src/mm_balance/config.py +0 -155
  28. mm_balance-0.1.18/src/mm_balance/rpc/aptos.py +0 -23
  29. mm_balance-0.1.18/src/mm_balance/rpc/btc.py +0 -16
  30. mm_balance-0.1.18/src/mm_balance/rpc/evm.py +0 -27
  31. mm_balance-0.1.18/src/mm_balance/rpc/solana.py +0 -26
  32. mm_balance-0.1.18/src/mm_balance/utils.py +0 -10
  33. mm_balance-0.1.18/src/mm_balance/workers.py +0 -85
  34. mm_balance-0.1.18/tests/__init__.py +0 -0
  35. mm_balance-0.1.18/uv.lock +0 -2029
  36. {mm_balance-0.1.18 → mm_balance-0.5.1}/.gitignore +0 -0
  37. {mm_balance-0.1.18 → mm_balance-0.5.1}/src/mm_balance/__init__.py +0 -0
  38. {mm_balance-0.1.18 → mm_balance-0.5.1}/src/mm_balance/output/__init__.py +0 -0
  39. {mm_balance-0.1.18 → mm_balance-0.5.1}/src/mm_balance/output/formats/__init__.py +0 -0
  40. {mm_balance-0.1.18/src/mm_balance/rpc → mm_balance-0.5.1/tests}/__init__.py +0 -0
  41. {mm_balance-0.1.18 → mm_balance-0.5.1}/tests/conftest.py +0 -0
  42. {mm_balance-0.1.18 → mm_balance-0.5.1}/tests/test_dummy.py +0 -0
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: mm-balance
3
+ Version: 0.5.1
4
+ Requires-Python: >=3.13
5
+ Requires-Dist: deepdiff==8.5.0
6
+ Requires-Dist: mm-apt==0.4.2
7
+ Requires-Dist: mm-btc==0.5.3
8
+ Requires-Dist: mm-concurrency~=0.1.0
9
+ Requires-Dist: mm-eth==0.7.1
10
+ Requires-Dist: mm-sol==0.7.1
@@ -0,0 +1,122 @@
1
+ # mm-balance
2
+
3
+ A multi-cryptocurrency balance checker that allows you to track balances across multiple networks, wallets, and tokens.
4
+
5
+ ## Features
6
+
7
+ - Support for multiple networks: Bitcoin, Ethereum, Solana, Aptos, Arbitrum, Optimism
8
+ - Check balances of native tokens and custom tokens
9
+ - Fetch current prices from CoinGecko
10
+ - Group addresses for easier management
11
+ - Compare balances between two points in time
12
+ - Multiple output formats (table and JSON)
13
+ - Proxy support
14
+
15
+ ## Installation
16
+
17
+ ### Ubuntu
18
+
19
+ ```shell
20
+ sudo apt update && sudo apt-get install build-essential libgmp3-dev python3-dev -y
21
+ sudo curl -LsSf https://astral.sh/uv/install.sh | sh
22
+ source $HOME/.local/bin/env
23
+ uv tool install mm-balance
24
+ ```
25
+
26
+ ### macOS
27
+
28
+ ```shell
29
+ brew install gmp
30
+ curl -LsSf https://astral.sh/uv/install.sh | sh
31
+ source ~/.zshrc # or appropriate shell config
32
+ uv tool install mm-balance
33
+ ```
34
+
35
+ ### Windows (via WSL)
36
+
37
+ ```shell
38
+ sudo apt update && sudo apt-get install build-essential libgmp3-dev python3-dev -y
39
+ curl -LsSf https://astral.sh/uv/install.sh | sh
40
+ source $HOME/.bashrc
41
+ uv tool install mm-balance
42
+ ```
43
+
44
+ ## Usage
45
+
46
+ Create a configuration file in TOML format, then run:
47
+
48
+ ```shell
49
+ mm-balance your_config.toml
50
+ ```
51
+
52
+ ### Command Line Options
53
+
54
+ ```
55
+ Options:
56
+ -f, --format [TABLE|JSON] Print format
57
+ -s, --skip-empty Skip empty balances
58
+ -d, --debug Print debug info
59
+ -c, --config Print config and exit
60
+ --price / --no-price Print prices
61
+ --save-balances PATH Save balances file
62
+ --diff-from-balances PATH Diff from balances file
63
+ --example Print a config example
64
+ --networks Print supported networks
65
+ --version Show version and exit
66
+ --help Show this message and exit
67
+ ```
68
+
69
+ ## Configuration
70
+
71
+ Create a TOML file with the following structure:
72
+
73
+ ```toml
74
+ [[coins]]
75
+ ticker = "BTC"
76
+ network = "bitcoin"
77
+ addresses = [
78
+ "bc1qgdjqv0av3q56jvd82tkdjpy7gdp9ut8tlqmgrpmv24sq90ecnvqqjwvw97",
79
+ "34xp4vRoCGJym3xR7yCVPFHoCNxv4Twseo"
80
+ ]
81
+
82
+ [[coins]]
83
+ ticker = "ETH"
84
+ network = "ethereum"
85
+ comment = "exchange_wallets"
86
+ addresses = "group: exchange_wallets"
87
+
88
+ [[addresses]]
89
+ name = "exchange_wallets"
90
+ addresses = [
91
+ "0xf977814e90da44bfa03b6295a0616a897441acec",
92
+ "0x47ac0fb4f2d84898e4d9e7b4dab3c24507a6d503"
93
+ ]
94
+
95
+ [settings]
96
+ round_ndigits = 4
97
+ price = true
98
+ skip_empty = false
99
+ print_format = "table" # table, json
100
+ ```
101
+
102
+ For a full configuration example, run:
103
+
104
+ ```shell
105
+ mm-balance --example
106
+ ```
107
+
108
+ ## Supported Networks
109
+
110
+ Current supported networks include:
111
+ - bitcoin
112
+ - ethereum
113
+ - solana
114
+ - aptos
115
+ - arbitrum-one
116
+ - op-mainnet
117
+
118
+ To see the complete list, run:
119
+
120
+ ```shell
121
+ mm-balance --networks
122
+ ```
@@ -0,0 +1,10 @@
1
+ ndigits
2
+ fnumber
3
+ stablecoin
4
+ stablecoins
5
+ usdc
6
+ arbitrum
7
+ nosec
8
+ aptos
9
+ solana
10
+ blockstream
@@ -20,8 +20,10 @@ lint: format
20
20
  uv run mypy src
21
21
 
22
22
  audit:
23
- uv run pip-audit --ignore-vuln GHSA-f9vj-2wh5-fj8j --ignore-vuln GHSA-q34m-jh98-gwm2
24
- uv run bandit -r -c "pyproject.toml" src
23
+ uv export --no-dev --all-extras --format requirements-txt --no-emit-project > requirements.txt
24
+ uv run pip-audit -r requirements.txt --disable-pip
25
+ rm requirements.txt
26
+ uv run bandit --silent --recursive --configfile "pyproject.toml" src
25
27
 
26
28
  publish: build
27
29
  git diff-index --quiet HEAD
@@ -0,0 +1,75 @@
1
+ [project]
2
+ name = "mm-balance"
3
+ version = "0.5.1"
4
+ description = ""
5
+ requires-python = ">=3.13"
6
+ dependencies = [
7
+ "mm-concurrency~=0.1.0",
8
+ "mm-apt==0.4.2",
9
+ "mm-btc==0.5.3",
10
+ "mm-eth==0.7.1",
11
+ "mm-sol==0.7.1",
12
+ "deepdiff==8.5.0",
13
+ ]
14
+ [project.scripts]
15
+ mm-balance = "mm_balance.cli:app"
16
+
17
+ [build-system]
18
+ requires = ["hatchling"]
19
+ build-backend = "hatchling.build"
20
+
21
+ [tool.uv]
22
+ dev-dependencies = [
23
+ "pytest~=8.4.0",
24
+ "pytest-xdist~=3.7.0",
25
+ "ruff~=0.11.13",
26
+ "pip-audit~=2.9.0",
27
+ "bandit~=1.8.3",
28
+ "mypy~=1.16.0",
29
+ ]
30
+
31
+ [tool.mypy]
32
+ python_version = "3.13"
33
+ warn_no_return = false
34
+ strict = true
35
+ exclude = ["^tests/", "^tmp/"]
36
+
37
+ [tool.ruff]
38
+ line-length = 130
39
+ target-version = "py313"
40
+ [tool.ruff.lint]
41
+ select = ["ALL"]
42
+ ignore = [
43
+ "TC", # flake8-type-checking, TYPE_CHECKING is dangerous, for example it doesn't work with pydantic
44
+ "A005", # flake8-builtins: stdlib-module-shadowing
45
+ "ERA001", # eradicate: commented-out-code
46
+ "PT", # flake8-pytest-style
47
+ "D", # pydocstyle
48
+ "FIX", # flake8-fixme
49
+ "PLR0911", # pylint: too-many-return-statements
50
+ "PLR0912", # pylint: too-many-branches
51
+ "PLR0913", # pylint: too-many-arguments
52
+ "PLR2004", # pylint: magic-value-comparison
53
+ "PLC0414", # pylint: useless-import-alias
54
+ "FBT", # flake8-boolean-trap
55
+ "EM", # flake8-errmsg
56
+ "TRY003", # tryceratops: raise-vanilla-args
57
+ "C901", # mccabe: complex-structure,
58
+ "BLE001", # flake8-blind-except
59
+ "S311", # bandit: suspicious-non-cryptographic-random-usage
60
+ "TD002", # flake8-todos: missing-todo-author
61
+ "TD003", # flake8-todos: missing-todo-link
62
+ "RET503", # flake8-return: implicit-return
63
+ "COM812", # it's used in ruff formatter
64
+ ]
65
+ [tool.ruff.lint.pep8-naming]
66
+ classmethod-decorators = ["field_validator"]
67
+ [tool.ruff.lint.per-file-ignores]
68
+ "tests/*.py" = ["ANN", "S"]
69
+ [tool.ruff.format]
70
+ quote-style = "double"
71
+ indent-style = "space"
72
+
73
+ [tool.bandit]
74
+ exclude_dirs = ["tests"]
75
+ skips = ["B311"]
@@ -0,0 +1,77 @@
1
+ from dataclasses import dataclass
2
+ from decimal import Decimal
3
+
4
+ from mm_concurrency import AsyncTaskRunner
5
+ from mm_result import Result
6
+ from rich.progress import TaskID
7
+
8
+ from mm_balance import rpc
9
+ from mm_balance.config import Config
10
+ from mm_balance.constants import Network
11
+ from mm_balance.output import utils
12
+ from mm_balance.token_decimals import TokenDecimals
13
+ from mm_balance.utils import PrintFormat
14
+
15
+
16
+ @dataclass
17
+ class Task:
18
+ group_index: int
19
+ wallet_address: str
20
+ token_address: str | None
21
+ balance: Result[Decimal] | None = None
22
+
23
+
24
+ class BalanceFetcher:
25
+ def __init__(self, config: Config, token_decimals: TokenDecimals) -> None:
26
+ self.config = config
27
+ self.token_decimals = token_decimals
28
+ self.tasks: dict[Network, list[Task]] = {network: [] for network in config.networks()}
29
+ self.progress_bar = utils.create_progress_bar(config.settings.print_format is not PrintFormat.TABLE)
30
+ self.progress_bar_task: dict[Network, TaskID] = {}
31
+
32
+ for idx, group in enumerate(config.groups):
33
+ task_list = [Task(group_index=idx, wallet_address=a, token_address=group.token) for a in group.addresses]
34
+ self.tasks[group.network].extend(task_list)
35
+
36
+ for network in config.networks():
37
+ if self.tasks[network]:
38
+ self.progress_bar_task[network] = utils.create_progress_task(self.progress_bar, network, len(self.tasks[network]))
39
+
40
+ async def process(self) -> None:
41
+ with self.progress_bar:
42
+ runner = AsyncTaskRunner(max_concurrent_tasks=10)
43
+ for network in self.config.networks():
44
+ runner.add(f"process_{network}", self._process_network(network))
45
+ await runner.run()
46
+
47
+ def get_group_tasks(self, group_index: int, network: Network) -> list[Task]:
48
+ return [b for b in self.tasks[network] if b.group_index == group_index]
49
+
50
+ def get_errors(self) -> list[Task]:
51
+ result = []
52
+ for network in self.tasks:
53
+ result.extend([task for task in self.tasks[network] if task.balance is not None and task.balance.is_err()])
54
+ return result
55
+
56
+ async def _process_network(self, network: Network) -> None:
57
+ runner = AsyncTaskRunner(max_concurrent_tasks=self.config.workers[network])
58
+ for idx, task in enumerate(self.tasks[network]):
59
+ runner.add(str(idx), self._get_balance(network, task.wallet_address, task.token_address))
60
+ res = await runner.run()
61
+
62
+ # TODO: print job.exceptions if present
63
+ for idx, _task in enumerate(self.tasks[network]):
64
+ self.tasks[network][idx].balance = res.results.get(str(idx))
65
+
66
+ async def _get_balance(self, network: Network, wallet_address: str, token_address: str | None) -> Result[Decimal]:
67
+ res = await rpc.get_balance(
68
+ network=network,
69
+ nodes=self.config.nodes[network],
70
+ proxies=self.config.settings.proxies,
71
+ wallet_address=wallet_address,
72
+ token_address=token_address,
73
+ token_decimals=self.token_decimals[network][token_address],
74
+ ndigits=self.config.settings.round_ndigits,
75
+ )
76
+ self.progress_bar.update(self.progress_bar_task[network], advance=1)
77
+ return res
@@ -0,0 +1,73 @@
1
+ import asyncio
2
+ import importlib.metadata
3
+ import pkgutil
4
+ from pathlib import Path
5
+ from typing import Annotated
6
+
7
+ import mm_print
8
+ import typer
9
+
10
+ from mm_balance import command_runner
11
+ from mm_balance.command_runner import CommandParameters
12
+ from mm_balance.constants import NETWORKS
13
+ from mm_balance.utils import PrintFormat
14
+
15
+ app = typer.Typer(no_args_is_help=True, pretty_exceptions_enable=False, add_completion=False)
16
+
17
+
18
+ def version_callback(value: bool) -> None:
19
+ if value:
20
+ typer.echo(f"mm-balance: v{importlib.metadata.version('mm-balance')}")
21
+ raise typer.Exit
22
+
23
+
24
+ def example_callback(value: bool) -> None:
25
+ if value:
26
+ data = pkgutil.get_data(__name__, "config/example.toml")
27
+ if data is None:
28
+ mm_print.fatal("Example config not found")
29
+ mm_print.toml(toml=data.decode("utf-8"))
30
+ raise typer.Exit
31
+
32
+
33
+ def networks_callback(value: bool) -> None:
34
+ if value:
35
+ for network in NETWORKS:
36
+ typer.echo(network)
37
+ raise typer.Exit
38
+
39
+
40
+ @app.command()
41
+ def cli(
42
+ config_path: Annotated[Path, typer.Argument()],
43
+ print_format: Annotated[PrintFormat | None, typer.Option("--format", "-f", help="Print format.")] = None,
44
+ skip_empty: Annotated[bool | None, typer.Option("--skip-empty", "-s", help="Skip empty balances.")] = None,
45
+ debug: Annotated[bool | None, typer.Option("--debug", "-d", help="Print debug info.")] = None,
46
+ print_config: Annotated[bool | None, typer.Option("--config", "-c", help="Print config and exit.")] = None,
47
+ price: Annotated[bool | None, typer.Option("--price/--no-price", help="Print prices.")] = None,
48
+ save_balances: Annotated[Path | None, typer.Option("--save-balances", help="Save balances file.")] = None,
49
+ diff_from_balances: Annotated[Path | None, typer.Option("--diff-from-balances", help="Diff from balances file.")] = None,
50
+ _example: Annotated[bool | None, typer.Option("--example", callback=example_callback, help="Print a config example.")] = None,
51
+ _networks: Annotated[
52
+ bool | None, typer.Option("--networks", callback=networks_callback, help="Print supported networks.")
53
+ ] = None,
54
+ _version: bool = typer.Option(None, "--version", callback=version_callback, is_eager=True),
55
+ ) -> None:
56
+ asyncio.run(
57
+ command_runner.run(
58
+ CommandParameters(
59
+ config_path=config_path,
60
+ print_format=print_format,
61
+ skip_empty=skip_empty,
62
+ debug=debug,
63
+ print_config=print_config,
64
+ price=price,
65
+ save_balances=save_balances,
66
+ diff_from_balances=diff_from_balances,
67
+ )
68
+ )
69
+ )
70
+
71
+
72
+ if __name__ == "__main__":
73
+ app()
@@ -0,0 +1,75 @@
1
+ import getpass
2
+ from pathlib import Path
3
+
4
+ import mm_print
5
+ from pydantic import BaseModel
6
+
7
+ from mm_balance.balance_fetcher import BalanceFetcher
8
+ from mm_balance.config import Config
9
+ from mm_balance.diff import BalancesDict, Diff
10
+ from mm_balance.output.formats import json_format, table_format
11
+ from mm_balance.price import Prices, get_prices
12
+ from mm_balance.result import create_balances_result
13
+ from mm_balance.token_decimals import get_token_decimals
14
+ from mm_balance.utils import PrintFormat
15
+
16
+
17
+ class CommandParameters(BaseModel):
18
+ config_path: Path
19
+ print_format: PrintFormat | None
20
+ skip_empty: bool | None
21
+ debug: bool | None
22
+ print_config: bool | None
23
+ price: bool | None
24
+ save_balances: Path | None
25
+ diff_from_balances: Path | None
26
+
27
+
28
+ async def run(params: CommandParameters) -> None:
29
+ zip_password = "" # nosec
30
+ if params.config_path.name.endswith(".zip"):
31
+ zip_password = getpass.getpass("zip password: ")
32
+ config = Config.read_toml_config_or_exit(params.config_path, zip_password=zip_password)
33
+ if params.print_config:
34
+ config.print_and_exit()
35
+
36
+ if params.print_format is not None:
37
+ config.settings.print_format = params.print_format
38
+ if params.debug is not None:
39
+ config.settings.print_debug = params.debug
40
+ if params.skip_empty is not None:
41
+ config.settings.skip_empty = params.skip_empty
42
+ if params.price is not None:
43
+ config.settings.price = params.price
44
+
45
+ if config.settings.print_debug and config.settings.print_format is PrintFormat.TABLE:
46
+ table_format.print_nodes(config)
47
+ table_format.print_proxy_count(config)
48
+
49
+ token_decimals = await get_token_decimals(config)
50
+ if config.settings.print_debug and config.settings.print_format is PrintFormat.TABLE:
51
+ table_format.print_token_decimals(token_decimals)
52
+
53
+ prices = await get_prices(config) if config.settings.price else Prices()
54
+ if config.settings.print_format is PrintFormat.TABLE:
55
+ table_format.print_prices(config, prices)
56
+
57
+ workers = BalanceFetcher(config, token_decimals)
58
+ await workers.process()
59
+
60
+ result = create_balances_result(config, prices, workers)
61
+ if config.settings.print_format is PrintFormat.TABLE:
62
+ table_format.print_result(config, result, workers)
63
+ elif config.settings.print_format is PrintFormat.JSON:
64
+ json_format.print_result(config, token_decimals, prices, workers, result)
65
+ else:
66
+ mm_print.fatal("Unsupported print format")
67
+
68
+ if params.save_balances:
69
+ BalancesDict.from_balances_result(result).save_to_path(params.save_balances)
70
+
71
+ if params.diff_from_balances:
72
+ old_balances = BalancesDict.from_file(params.diff_from_balances)
73
+ new_balances = BalancesDict.from_balances_result(result)
74
+ diff = Diff.calc(old_balances, new_balances)
75
+ diff.print(config.settings.print_format)
@@ -0,0 +1,83 @@
1
+ [[coins]]
2
+ ticker = "SOL"
3
+ network = "solana"
4
+ addresses = "2ojv9BAiHUrvsm9gxDe7fJSzbNZSJcxZvf8dqmWGHG8S" # binance
5
+
6
+ [[coins]]
7
+ ticker = "USDT"
8
+ network = "solana"
9
+ addresses = "2ojv9BAiHUrvsm9gxDe7fJSzbNZSJcxZvf8dqmWGHG8S" # binance
10
+
11
+ [[coins]]
12
+ ticker = "BTC"
13
+ network = "bitcoin"
14
+ comment = "coldwallets"
15
+ addresses = """
16
+ 34xp4vRoCGJym3xR7yCVPFHoCNxv4Twseo # binance
17
+ bc1qgdjqv0av3q56jvd82tkdjpy7gdp9ut8tlqmgrpmv24sq90ecnvqqjwvw97 # bitfinex
18
+ bc1ql49ydapnjafl5t2cp9zqpjwe6pdgmxy98859v2 # robinhood
19
+ """
20
+ share = 0.1
21
+
22
+ [[coins]]
23
+ ticker = "ETH"
24
+ network = "ethereum"
25
+ comment = "okx"
26
+ addresses = "group: okx_eth"
27
+
28
+ [[coins]]
29
+ ticker = "USDT"
30
+ network = "ethereum"
31
+ comment = "okx"
32
+ addresses = "group: okx_eth"
33
+
34
+ [[coins]]
35
+ ticker = "ETH"
36
+ network = "ethereum"
37
+ comment = "binance"
38
+ addresses = "group: binance_eth"
39
+
40
+ [[coins]]
41
+ ticker = "USDT"
42
+ network = "ethereum"
43
+ comment = "binance"
44
+ addresses = "group: binance_eth"
45
+
46
+ [[coins]]
47
+ ticker = "USDC"
48
+ network = "aptos"
49
+ comment = "swap.thala.apt"
50
+ token = "0xf22bede237a07e121b56d91a491eb7bcdfd1f5907926a9e58338f964a01b17fa::asset::USDC"
51
+ decimals = 6
52
+ addresses = "0x48271d39d0b05bd6efca2278f22277d6fcc375504f9839fd73f74ace240861af"
53
+
54
+
55
+ [[addresses]]
56
+ name = "okx_eth"
57
+ addresses = """
58
+ 0xf59869753f41db720127ceb8dbb8afaf89030de4
59
+ 0x65a0947ba5175359bb457d3b34491edf4cbf7997
60
+ 0xe9172daf64b05b26eb18f07ac8d6d723acb48f99
61
+ 0x4d19c0a5357bc48be0017095d3c871d9afc3f21d
62
+ """
63
+
64
+ [[addresses]]
65
+ name = "binance_eth"
66
+ addresses = """
67
+ 0xf977814e90da44bfa03b6295a0616a897441acec
68
+ 0x47ac0fb4f2d84898e4d9e7b4dab3c24507a6d503
69
+ """
70
+
71
+
72
+ [settings] # all records are optional
73
+ round_ndigits = 4
74
+ price = true
75
+ skip_empty = false
76
+ print_debug = false
77
+ print_format = "table" # table, json
78
+ format_number_separator = ","
79
+ proxies = """
80
+ # env_url: MM_BALANCE_PROXIES_URL
81
+ # socks5://usr:pass@site.com:1234
82
+ # http://site.com:1234
83
+ """
@@ -0,0 +1,131 @@
1
+ from __future__ import annotations
2
+
3
+ from decimal import Decimal
4
+ from pathlib import Path
5
+ from typing import Annotated, Self
6
+
7
+ import mm_print
8
+ import pydash
9
+ from mm_web3 import ConfigValidators, Web3CliConfig
10
+ from pydantic import BeforeValidator, Field, StringConstraints, model_validator
11
+
12
+ from mm_balance.constants import DEFAULT_NODES, TOKEN_ADDRESS, Network
13
+ from mm_balance.utils import PrintFormat
14
+
15
+
16
+ class Validators(ConfigValidators):
17
+ pass
18
+
19
+
20
+ class AssetGroup(Web3CliConfig):
21
+ """
22
+ Represents a group of cryptocurrency assets of the same type.
23
+
24
+ An asset group contains information about a specific cryptocurrency (token)
25
+ across multiple addresses/wallets.
26
+ """
27
+
28
+ comment: str = ""
29
+ ticker: Annotated[str, StringConstraints(to_upper=True)]
30
+ network: Network
31
+ token: str | None = None # Token address. If None, it's a native token
32
+ decimals: int | None = None
33
+ coingecko_id: str | None = None
34
+ addresses: Annotated[list[str], BeforeValidator(Validators.addresses(deduplicate=True))]
35
+ share: Decimal = Decimal(1)
36
+
37
+ @property
38
+ def name(self) -> str:
39
+ result = self.ticker
40
+ if self.comment:
41
+ result += " / " + self.comment
42
+ result += " / " + self.network
43
+ return result
44
+
45
+ @model_validator(mode="after")
46
+ def final_validator(self) -> Self:
47
+ if self.token is None:
48
+ self.token = detect_token_address(self.ticker, self.network)
49
+ if self.token is not None and self.network.is_evm_network():
50
+ self.token = self.token.lower()
51
+ return self
52
+
53
+ def process_addresses(self, address_groups: list[AddressCollection]) -> None:
54
+ result = []
55
+ for line in self.addresses:
56
+ if line.startswith("file:"):
57
+ path = Path(line.removeprefix("file:").strip()).expanduser()
58
+ if path.is_file():
59
+ result += path.read_text().strip().splitlines()
60
+ else:
61
+ mm_print.fatal(f"File with addresses not found: {path}")
62
+ elif line.startswith("group:"):
63
+ group_name = line.removeprefix("group:").strip()
64
+ address_group = next((ag for ag in address_groups if ag.name == group_name), None)
65
+ if address_group is None:
66
+ raise ValueError(f"Address group not found: {group_name}")
67
+ result += address_group.addresses
68
+ else:
69
+ result.append(line)
70
+ # TODO: check address is valid. There is network info in the group
71
+ if self.network.need_lowercase_address():
72
+ result = [address.lower() for address in result]
73
+ self.addresses = pydash.uniq(result)
74
+
75
+
76
+ class AddressCollection(Web3CliConfig):
77
+ name: str
78
+ addresses: Annotated[list[str], BeforeValidator(Validators.addresses(deduplicate=True))]
79
+
80
+
81
+ class Settings(Web3CliConfig):
82
+ proxies: Annotated[list[str], Field(default_factory=list), BeforeValidator(Validators.proxies())]
83
+ round_ndigits: int = 4
84
+ print_format: PrintFormat = PrintFormat.TABLE
85
+ price: bool = True
86
+ skip_empty: bool = False # don't print the address with an empty balance
87
+ print_debug: bool = False # print debug info: nodes, token_decimals
88
+ format_number_separator: str = "," # as thousands separators
89
+
90
+
91
+ class Config(Web3CliConfig):
92
+ groups: list[AssetGroup] = Field(alias="coins")
93
+ addresses: list[AddressCollection] = Field(default_factory=list)
94
+ nodes: dict[Network, list[str]] = Field(default_factory=dict)
95
+ workers: dict[Network, int] = Field(default_factory=dict)
96
+ settings: Settings = Field(default_factory=Settings) # type: ignore[arg-type]
97
+
98
+ def has_share(self) -> bool:
99
+ return any(g.share != Decimal(1) for g in self.groups)
100
+
101
+ def networks(self) -> list[Network]:
102
+ return pydash.uniq([group.network for group in self.groups])
103
+
104
+ @model_validator(mode="after")
105
+ def final_validator(self) -> Self:
106
+ # check all addresses has uniq name
107
+ address_group_names = [ag.name for ag in self.addresses]
108
+ non_uniq_names = [name for name in address_group_names if address_group_names.count(name) > 1]
109
+ if non_uniq_names:
110
+ raise ValueError("There are non-unique address group names: " + ", ".join(non_uniq_names))
111
+
112
+ # load addresses from address_group
113
+ for group in self.groups:
114
+ group.process_addresses(self.addresses)
115
+
116
+ # load default rpc nodes
117
+ for network in self.networks():
118
+ if network not in self.nodes:
119
+ self.nodes[network] = DEFAULT_NODES[network]
120
+
121
+ # load default workers
122
+ for network in self.networks():
123
+ if network not in self.workers:
124
+ self.workers[network] = 5
125
+
126
+ return self
127
+
128
+
129
+ def detect_token_address(ticker: str, network: Network) -> str | None:
130
+ if network in TOKEN_ADDRESS:
131
+ return TOKEN_ADDRESS[network].get(ticker)