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.
- mm_balance-0.5.1/PKG-INFO +10 -0
- mm_balance-0.5.1/README.md +122 -0
- mm_balance-0.5.1/dict.dic +10 -0
- {mm_balance-0.1.18 → mm_balance-0.5.1}/justfile +4 -2
- mm_balance-0.5.1/pyproject.toml +75 -0
- mm_balance-0.5.1/src/mm_balance/balance_fetcher.py +77 -0
- mm_balance-0.5.1/src/mm_balance/cli.py +73 -0
- mm_balance-0.5.1/src/mm_balance/command_runner.py +75 -0
- mm_balance-0.5.1/src/mm_balance/config/example.toml +83 -0
- mm_balance-0.5.1/src/mm_balance/config.py +131 -0
- {mm_balance-0.1.18 → mm_balance-0.5.1}/src/mm_balance/constants.py +7 -3
- mm_balance-0.5.1/src/mm_balance/diff.py +170 -0
- {mm_balance-0.1.18 → mm_balance-0.5.1}/src/mm_balance/output/formats/json_format.py +9 -7
- {mm_balance-0.1.18 → mm_balance-0.5.1}/src/mm_balance/output/formats/table_format.py +33 -31
- {mm_balance-0.1.18 → mm_balance-0.5.1}/src/mm_balance/output/utils.py +3 -1
- {mm_balance-0.1.18 → mm_balance-0.5.1}/src/mm_balance/price.py +11 -9
- {mm_balance-0.1.18 → mm_balance-0.5.1}/src/mm_balance/result.py +22 -10
- mm_balance-0.5.1/src/mm_balance/rpc.py +183 -0
- {mm_balance-0.1.18 → mm_balance-0.5.1}/src/mm_balance/token_decimals.py +19 -19
- mm_balance-0.5.1/src/mm_balance/utils.py +30 -0
- mm_balance-0.5.1/uv.lock +1901 -0
- mm_balance-0.1.18/PKG-INFO +0 -9
- mm_balance-0.1.18/README.md +0 -14
- mm_balance-0.1.18/pyproject.toml +0 -72
- mm_balance-0.1.18/src/mm_balance/cli.py +0 -85
- mm_balance-0.1.18/src/mm_balance/config/example.yml +0 -74
- mm_balance-0.1.18/src/mm_balance/config.py +0 -155
- mm_balance-0.1.18/src/mm_balance/rpc/aptos.py +0 -23
- mm_balance-0.1.18/src/mm_balance/rpc/btc.py +0 -16
- mm_balance-0.1.18/src/mm_balance/rpc/evm.py +0 -27
- mm_balance-0.1.18/src/mm_balance/rpc/solana.py +0 -26
- mm_balance-0.1.18/src/mm_balance/utils.py +0 -10
- mm_balance-0.1.18/src/mm_balance/workers.py +0 -85
- mm_balance-0.1.18/tests/__init__.py +0 -0
- mm_balance-0.1.18/uv.lock +0 -2029
- {mm_balance-0.1.18 → mm_balance-0.5.1}/.gitignore +0 -0
- {mm_balance-0.1.18 → mm_balance-0.5.1}/src/mm_balance/__init__.py +0 -0
- {mm_balance-0.1.18 → mm_balance-0.5.1}/src/mm_balance/output/__init__.py +0 -0
- {mm_balance-0.1.18 → mm_balance-0.5.1}/src/mm_balance/output/formats/__init__.py +0 -0
- {mm_balance-0.1.18/src/mm_balance/rpc → mm_balance-0.5.1/tests}/__init__.py +0 -0
- {mm_balance-0.1.18 → mm_balance-0.5.1}/tests/conftest.py +0 -0
- {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
|
+
```
|
|
@@ -20,8 +20,10 @@ lint: format
|
|
|
20
20
|
uv run mypy src
|
|
21
21
|
|
|
22
22
|
audit:
|
|
23
|
-
uv
|
|
24
|
-
uv run
|
|
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)
|