mm-balance 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.
@@ -1,13 +1,13 @@
1
1
  from dataclasses import dataclass
2
2
  from decimal import Decimal
3
3
 
4
- from mm_std import ConcurrentTasks, PrintFormat, Result
4
+ from mm_std import AsyncTaskRunner, PrintFormat, Result
5
5
  from rich.progress import TaskID
6
6
 
7
+ from mm_balance import rpc
7
8
  from mm_balance.config import Config
8
- from mm_balance.constants import NETWORK_APTOS, NETWORK_BITCOIN, NETWORK_SOLANA, Network
9
+ from mm_balance.constants import Network
9
10
  from mm_balance.output import utils
10
- from mm_balance.rpc import aptos, btc, evm, solana
11
11
  from mm_balance.token_decimals import TokenDecimals
12
12
 
13
13
 
@@ -19,7 +19,7 @@ class Task:
19
19
  balance: Result[Decimal] | None = None
20
20
 
21
21
 
22
- class Workers:
22
+ class BalanceFetcher:
23
23
  def __init__(self, config: Config, token_decimals: TokenDecimals) -> None:
24
24
  self.config = config
25
25
  self.token_decimals = token_decimals
@@ -35,15 +35,14 @@ class Workers:
35
35
  if self.tasks[network]:
36
36
  self.progress_bar_task[network] = utils.create_progress_task(self.progress_bar, network, len(self.tasks[network]))
37
37
 
38
- def process(self) -> None:
38
+ async def process(self) -> None:
39
39
  with self.progress_bar:
40
- job = ConcurrentTasks(max_workers=10)
40
+ runner = AsyncTaskRunner(max_concurrent_tasks=10)
41
41
  for network in self.config.networks():
42
- job.add_task(network, self._process_network, args=(network,))
43
- job.execute()
42
+ runner.add_task(f"process_{network}", self._process_network(network))
43
+ await runner.run()
44
44
 
45
45
  def get_group_tasks(self, group_index: int, network: Network) -> list[Task]:
46
- # TODO: can we get network by group_index?
47
46
  return [b for b in self.tasks[network] if b.group_index == group_index]
48
47
 
49
48
  def get_errors(self) -> list[Task]:
@@ -52,31 +51,25 @@ class Workers:
52
51
  result.extend([task for task in self.tasks[network] if task.balance is not None and task.balance.is_err()])
53
52
  return result
54
53
 
55
- def _process_network(self, network: Network) -> None:
56
- job = ConcurrentTasks(max_workers=self.config.workers[network])
54
+ async def _process_network(self, network: Network) -> None:
55
+ runner = AsyncTaskRunner(max_concurrent_tasks=self.config.workers[network])
57
56
  for idx, task in enumerate(self.tasks[network]):
58
- job.add_task(str(idx), self._get_balance, args=(network, task.wallet_address, task.token_address))
59
- job.execute()
57
+ runner.add_task(str(idx), self._get_balance(network, task.wallet_address, task.token_address))
58
+ res = await runner.run()
59
+
60
60
  # TODO: print job.exceptions if present
61
61
  for idx, _task in enumerate(self.tasks[network]):
62
- self.tasks[network][idx].balance = job.result.get(str(idx)) # type: ignore[assignment]
63
-
64
- def _get_balance(self, network: Network, wallet_address: str, token_address: str | None) -> Result[Decimal]:
65
- nodes = self.config.nodes[network]
66
- round_ndigits = self.config.settings.round_ndigits
67
- proxies = self.config.settings.proxies
68
- token_decimals = self.token_decimals[network][token_address]
69
-
70
- if network.is_evm_network():
71
- res = evm.get_balance(nodes, wallet_address, token_address, token_decimals, proxies, round_ndigits)
72
- elif network == NETWORK_BITCOIN:
73
- res = btc.get_balance(wallet_address, proxies, round_ndigits)
74
- elif network == NETWORK_APTOS:
75
- res = aptos.get_balance(nodes, wallet_address, token_address, token_decimals, proxies, round_ndigits)
76
- elif network == NETWORK_SOLANA:
77
- res = solana.get_balance(nodes, wallet_address, token_address, token_decimals, proxies, round_ndigits)
78
- else:
79
- raise ValueError(f"Unsupported network: {network}")
62
+ self.tasks[network][idx].balance = res.results.get(str(idx))
80
63
 
64
+ async def _get_balance(self, network: Network, wallet_address: str, token_address: str | None) -> Result[Decimal]:
65
+ res = await rpc.get_balance(
66
+ network=network,
67
+ nodes=self.config.nodes[network],
68
+ proxies=self.config.settings.proxies,
69
+ wallet_address=wallet_address,
70
+ token_address=token_address,
71
+ token_decimals=self.token_decimals[network][token_address],
72
+ ndigits=self.config.settings.round_ndigits,
73
+ )
81
74
  self.progress_bar.update(self.progress_bar_task[network], advance=1)
82
75
  return res
mm_balance/cli.py CHANGED
@@ -1,4 +1,4 @@
1
- import getpass
1
+ import asyncio
2
2
  import importlib.metadata
3
3
  import pkgutil
4
4
  from pathlib import Path
@@ -7,14 +7,9 @@ from typing import Annotated
7
7
  import typer
8
8
  from mm_std import PrintFormat, fatal, pretty_print_toml
9
9
 
10
- from mm_balance.config import Config
10
+ from mm_balance import command_runner
11
+ from mm_balance.command_runner import CommandParameters
11
12
  from mm_balance.constants import NETWORKS
12
- from mm_balance.diff import BalancesDict, Diff
13
- from mm_balance.output.formats import json_format, table_format
14
- from mm_balance.price import Prices, get_prices
15
- from mm_balance.result import create_balances_result
16
- from mm_balance.token_decimals import get_token_decimals
17
- from mm_balance.workers import Workers
18
13
 
19
14
  app = typer.Typer(no_args_is_help=True, pretty_exceptions_enable=False, add_completion=False)
20
15
 
@@ -49,63 +44,28 @@ def cli(
49
44
  debug: Annotated[bool | None, typer.Option("--debug", "-d", help="Print debug info.")] = None,
50
45
  print_config: Annotated[bool | None, typer.Option("--config", "-c", help="Print config and exit.")] = None,
51
46
  price: Annotated[bool | None, typer.Option("--price/--no-price", help="Print prices.")] = None,
52
- save_balances_file: Annotated[Path | None, typer.Option("--save-balances-file", help="Save balances file.")] = None,
53
- diff_from_balances_file: Annotated[
54
- Path | None, typer.Option("--diff-from-balances-file", help="Diff from balances file.")
55
- ] = None,
47
+ save_balances: Annotated[Path | None, typer.Option("--save-balances", help="Save balances file.")] = None,
48
+ diff_from_balances: Annotated[Path | None, typer.Option("--diff-from-balances", help="Diff from balances file.")] = None,
56
49
  _example: Annotated[bool | None, typer.Option("--example", callback=example_callback, help="Print a config example.")] = None,
57
50
  _networks: Annotated[
58
51
  bool | None, typer.Option("--networks", callback=networks_callback, help="Print supported networks.")
59
52
  ] = None,
60
53
  _version: bool = typer.Option(None, "--version", callback=version_callback, is_eager=True),
61
54
  ) -> None:
62
- zip_password = "" # nosec
63
- if config_path.name.endswith(".zip"):
64
- zip_password = getpass.getpass("zip password")
65
- config = Config.read_toml_config_or_exit(config_path, zip_password=zip_password)
66
- if print_config:
67
- config.print_and_exit()
68
-
69
- if print_format is not None:
70
- config.settings.print_format = print_format
71
- if debug is not None:
72
- config.settings.print_debug = debug
73
- if skip_empty is not None:
74
- config.settings.skip_empty = skip_empty
75
- if price is not None:
76
- config.settings.price = price
77
-
78
- if config.settings.print_debug and config.settings.print_format is PrintFormat.TABLE:
79
- table_format.print_nodes(config)
80
- table_format.print_proxy_count(config)
81
-
82
- token_decimals = get_token_decimals(config)
83
- if config.settings.print_debug and config.settings.print_format is PrintFormat.TABLE:
84
- table_format.print_token_decimals(token_decimals)
85
-
86
- prices = get_prices(config) if config.settings.price else Prices()
87
- if config.settings.print_format is PrintFormat.TABLE:
88
- table_format.print_prices(config, prices)
89
-
90
- workers = Workers(config, token_decimals)
91
- workers.process()
92
-
93
- result = create_balances_result(config, prices, workers)
94
- if config.settings.print_format is PrintFormat.TABLE:
95
- table_format.print_result(config, result, workers)
96
- elif config.settings.print_format is PrintFormat.JSON:
97
- json_format.print_result(config, token_decimals, prices, workers, result)
98
- else:
99
- fatal("Unsupported print format")
100
-
101
- if save_balances_file:
102
- BalancesDict.from_balances_result(result).save_to_path(save_balances_file)
103
-
104
- if diff_from_balances_file:
105
- old_balances = BalancesDict.from_file(diff_from_balances_file)
106
- new_balances = BalancesDict.from_balances_result(result)
107
- diff = Diff.calc(old_balances, new_balances)
108
- diff.print(config.settings.print_format)
55
+ asyncio.run(
56
+ command_runner.run(
57
+ CommandParameters(
58
+ config_path=config_path,
59
+ print_format=print_format,
60
+ skip_empty=skip_empty,
61
+ debug=debug,
62
+ print_config=print_config,
63
+ price=price,
64
+ save_balances=save_balances,
65
+ diff_from_balances=diff_from_balances,
66
+ )
67
+ )
68
+ )
109
69
 
110
70
 
111
71
  if __name__ == "__main__":
@@ -0,0 +1,74 @@
1
+ import getpass
2
+ from pathlib import Path
3
+
4
+ from mm_std import PrintFormat, fatal
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
+
15
+
16
+ class CommandParameters(BaseModel):
17
+ config_path: Path
18
+ print_format: PrintFormat | None
19
+ skip_empty: bool | None
20
+ debug: bool | None
21
+ print_config: bool | None
22
+ price: bool | None
23
+ save_balances: Path | None
24
+ diff_from_balances: Path | None
25
+
26
+
27
+ async def run(params: CommandParameters) -> None:
28
+ zip_password = "" # nosec
29
+ if params.config_path.name.endswith(".zip"):
30
+ zip_password = getpass.getpass("zip password")
31
+ config = Config.read_toml_config_or_exit(params.config_path, zip_password=zip_password)
32
+ if params.print_config:
33
+ config.print_and_exit()
34
+
35
+ if params.print_format is not None:
36
+ config.settings.print_format = params.print_format
37
+ if params.debug is not None:
38
+ config.settings.print_debug = params.debug
39
+ if params.skip_empty is not None:
40
+ config.settings.skip_empty = params.skip_empty
41
+ if params.price is not None:
42
+ config.settings.price = params.price
43
+
44
+ if config.settings.print_debug and config.settings.print_format is PrintFormat.TABLE:
45
+ table_format.print_nodes(config)
46
+ table_format.print_proxy_count(config)
47
+
48
+ token_decimals = await get_token_decimals(config)
49
+ if config.settings.print_debug and config.settings.print_format is PrintFormat.TABLE:
50
+ table_format.print_token_decimals(token_decimals)
51
+
52
+ prices = await get_prices(config) if config.settings.price else Prices()
53
+ if config.settings.print_format is PrintFormat.TABLE:
54
+ table_format.print_prices(config, prices)
55
+
56
+ workers = BalanceFetcher(config, token_decimals)
57
+ await workers.process()
58
+
59
+ result = create_balances_result(config, prices, workers)
60
+ if config.settings.print_format is PrintFormat.TABLE:
61
+ table_format.print_result(config, result, workers)
62
+ elif config.settings.print_format is PrintFormat.JSON:
63
+ json_format.print_result(config, token_decimals, prices, workers, result)
64
+ else:
65
+ fatal("Unsupported print format")
66
+
67
+ if params.save_balances:
68
+ BalancesDict.from_balances_result(result).save_to_path(params.save_balances)
69
+
70
+ if params.diff_from_balances:
71
+ old_balances = BalancesDict.from_file(params.diff_from_balances)
72
+ new_balances = BalancesDict.from_balances_result(result)
73
+ diff = Diff.calc(old_balances, new_balances)
74
+ diff.print(config.settings.print_format)
mm_balance/config.py CHANGED
@@ -16,11 +16,18 @@ class Validators(ConfigValidators):
16
16
  pass
17
17
 
18
18
 
19
- class Group(BaseConfig):
19
+ class AssetGroup(BaseConfig):
20
+ """
21
+ Represents a group of cryptocurrency assets of the same type.
22
+
23
+ An asset group contains information about a specific cryptocurrency (token)
24
+ across multiple addresses/wallets.
25
+ """
26
+
20
27
  comment: str = ""
21
28
  ticker: Annotated[str, StringConstraints(to_upper=True)]
22
29
  network: Network
23
- token: str | None = None # Token address. If None, it's a native token, for example ETH
30
+ token: str | None = None # Token address. If None, it's a native token
24
31
  decimals: int | None = None
25
32
  coingecko_id: str | None = None
26
33
  addresses: Annotated[list[str], BeforeValidator(Validators.addresses(unique=True))]
@@ -42,7 +49,7 @@ class Group(BaseConfig):
42
49
  self.token = self.token.lower()
43
50
  return self
44
51
 
45
- def process_addresses(self, address_groups: list[AddressGroup]) -> None:
52
+ def process_addresses(self, address_groups: list[AddressCollection]) -> None:
46
53
  result = []
47
54
  for line in self.addresses:
48
55
  if line.startswith("file:"):
@@ -60,10 +67,12 @@ class Group(BaseConfig):
60
67
  else:
61
68
  result.append(line)
62
69
  # TODO: check address is valid. There is network info in the group
70
+ if self.network.need_lowercase_address():
71
+ result = [address.lower() for address in result]
63
72
  self.addresses = pydash.uniq(result)
64
73
 
65
74
 
66
- class AddressGroup(BaseConfig):
75
+ class AddressCollection(BaseConfig):
67
76
  name: str
68
77
  addresses: Annotated[list[str], BeforeValidator(ConfigValidators.addresses(unique=True))]
69
78
 
@@ -79,8 +88,8 @@ class Settings(BaseConfig):
79
88
 
80
89
 
81
90
  class Config(BaseConfig):
82
- groups: list[Group] = Field(alias="coins")
83
- addresses: list[AddressGroup] = Field(default_factory=list)
91
+ groups: list[AssetGroup] = Field(alias="coins")
92
+ addresses: list[AddressCollection] = Field(default_factory=list)
84
93
  nodes: dict[Network, list[str]] = Field(default_factory=dict)
85
94
  workers: dict[Network, int] = Field(default_factory=dict)
86
95
  settings: Settings = Field(default_factory=Settings) # type: ignore[arg-type]
mm_balance/constants.py CHANGED
@@ -12,7 +12,10 @@ class Network(str):
12
12
  __slots__ = ()
13
13
 
14
14
  def is_evm_network(self) -> bool:
15
- return self in [NETWORK_ETHEREUM, NETWORK_ARBITRUM_ONE, NETWORK_OP_MAINNET] or self.startswith("evm-")
15
+ return self in EVM_NETWORKS or self.startswith("evm-")
16
+
17
+ def need_lowercase_address(self) -> bool:
18
+ return self.is_evm_network()
16
19
 
17
20
  @classmethod
18
21
  def __get_pydantic_core_schema__(cls, _source_type: object, handler: GetCoreSchemaHandler) -> CoreSchema:
@@ -26,6 +29,7 @@ NETWORK_ETHEREUM = Network("ethereum")
26
29
  NETWORK_SOLANA = Network("solana")
27
30
  NETWORK_OP_MAINNET = Network("op-mainnet")
28
31
  NETWORKS = [NETWORK_APTOS, NETWORK_ARBITRUM_ONE, NETWORK_BITCOIN, NETWORK_ETHEREUM, NETWORK_SOLANA, NETWORK_OP_MAINNET]
32
+ EVM_NETWORKS = [NETWORK_ETHEREUM, NETWORK_ARBITRUM_ONE, NETWORK_OP_MAINNET]
29
33
 
30
34
 
31
35
  TOKEN_ADDRESS: dict[Network, dict[str, str]] = {
@@ -1,13 +1,15 @@
1
1
  from mm_std import print_json
2
2
 
3
+ from mm_balance.balance_fetcher import BalanceFetcher
3
4
  from mm_balance.config import Config
4
5
  from mm_balance.price import Prices
5
6
  from mm_balance.result import BalancesResult
6
7
  from mm_balance.token_decimals import TokenDecimals
7
- from mm_balance.workers import Workers
8
8
 
9
9
 
10
- def print_result(config: Config, token_decimals: TokenDecimals, prices: Prices, workers: Workers, result: BalancesResult) -> None:
10
+ def print_result(
11
+ config: Config, token_decimals: TokenDecimals, prices: Prices, workers: BalanceFetcher, result: BalancesResult
12
+ ) -> None:
11
13
  data: dict[str, object] = {}
12
14
  if config.settings.print_debug:
13
15
  data["nodes"] = config.nodes
@@ -2,12 +2,12 @@ from decimal import Decimal
2
2
 
3
3
  from mm_std import print_table
4
4
 
5
+ from mm_balance.balance_fetcher import BalanceFetcher
5
6
  from mm_balance.config import Config
6
7
  from mm_balance.output.utils import format_number
7
8
  from mm_balance.price import Prices
8
9
  from mm_balance.result import BalancesResult, GroupResult, Total
9
10
  from mm_balance.token_decimals import TokenDecimals
10
- from mm_balance.workers import Workers
11
11
 
12
12
 
13
13
  def print_nodes(config: Config) -> None:
@@ -38,7 +38,7 @@ def print_prices(config: Config, prices: Prices) -> None:
38
38
  print_table("Prices", ["coin", "usd"], rows)
39
39
 
40
40
 
41
- def print_result(config: Config, result: BalancesResult, workers: Workers) -> None:
41
+ def print_result(config: Config, result: BalancesResult, workers: BalanceFetcher) -> None:
42
42
  for group in result.groups:
43
43
  _print_group(config, group)
44
44
 
@@ -49,14 +49,14 @@ def print_result(config: Config, result: BalancesResult, workers: Workers) -> No
49
49
  _print_errors(config, workers)
50
50
 
51
51
 
52
- def _print_errors(config: Config, workers: Workers) -> None:
52
+ def _print_errors(config: Config, workers: BalanceFetcher) -> None:
53
53
  error_tasks = workers.get_errors()
54
54
  if not error_tasks:
55
55
  return
56
56
  rows = []
57
57
  for task in error_tasks:
58
58
  group = config.groups[task.group_index]
59
- rows.append([group.ticker + " / " + group.network, task.wallet_address, task.balance.err]) # type: ignore[union-attr]
59
+ rows.append([group.ticker + " / " + group.network, task.wallet_address, task.balance.error]) # type: ignore[union-attr]
60
60
  print_table("Errors", ["coin", "address", "error"], rows)
61
61
 
62
62
 
mm_balance/price.py CHANGED
@@ -2,10 +2,9 @@ from collections import defaultdict
2
2
  from decimal import Decimal
3
3
 
4
4
  import pydash
5
- from mm_std import hr
6
- from mm_std.random_ import random_str_choice
5
+ from mm_std import http_request, random_str_choice
7
6
 
8
- from mm_balance.config import Config, Group
7
+ from mm_balance.config import AssetGroup, Config
9
8
  from mm_balance.constants import RETRIES_COINGECKO_PRICES, TICKER_TO_COINGECKO_ID
10
9
 
11
10
 
@@ -18,7 +17,7 @@ class Prices(defaultdict[str, Decimal]):
18
17
  """
19
18
 
20
19
 
21
- def get_prices(config: Config) -> Prices:
20
+ async def get_prices(config: Config) -> Prices:
22
21
  result = Prices()
23
22
 
24
23
  coingecko_map: dict[str, str] = {} # ticker -> coingecko_id
@@ -30,19 +29,21 @@ def get_prices(config: Config) -> Prices:
30
29
 
31
30
  url = f"https://api.coingecko.com/api/v3/simple/price?ids={','.join(coingecko_map.values())}&vs_currencies=usd"
32
31
  for _ in range(RETRIES_COINGECKO_PRICES):
33
- res = hr(url, proxy=random_str_choice(config.settings.proxies))
34
- if res.code != 200:
32
+ res = await http_request(url, proxy=random_str_choice(config.settings.proxies))
33
+ if res.status_code != 200:
35
34
  continue
36
35
 
36
+ json_body = res.parse_json_body() or {}
37
+
37
38
  for ticker, coingecko_id in coingecko_map.items():
38
- if coingecko_id in res.json:
39
- result[ticker] = Decimal(str(pydash.get(res.json, f"{coingecko_id}.usd")))
39
+ if coingecko_id in json_body:
40
+ result[ticker] = Decimal(str(pydash.get(json_body, f"{coingecko_id}.usd")))
40
41
  break
41
42
 
42
43
  return result
43
44
 
44
45
 
45
- def get_coingecko_id(group: Group) -> str | None:
46
+ def get_coingecko_id(group: AssetGroup) -> str | None:
46
47
  if group.coingecko_id:
47
48
  return group.coingecko_id
48
49
  return TICKER_TO_COINGECKO_ID.get(group.ticker)
mm_balance/result.py CHANGED
@@ -2,13 +2,11 @@ from collections import defaultdict
2
2
  from dataclasses import dataclass
3
3
  from decimal import Decimal
4
4
 
5
- from mm_std import Ok
6
-
7
- from mm_balance.config import Config, Group
5
+ from mm_balance.balance_fetcher import BalanceFetcher, Task
6
+ from mm_balance.config import AssetGroup, Config
8
7
  from mm_balance.constants import USD_STABLECOINS, Network
9
8
  from mm_balance.price import Prices
10
9
  from mm_balance.utils import round_decimal
11
- from mm_balance.workers import Task, Workers
12
10
 
13
11
 
14
12
  @dataclass
@@ -52,7 +50,7 @@ class BalancesResult:
52
50
  total_share: Total
53
51
 
54
52
 
55
- def create_balances_result(config: Config, prices: Prices, workers: Workers) -> BalancesResult:
53
+ def create_balances_result(config: Config, prices: Prices, workers: BalanceFetcher) -> BalancesResult:
56
54
  groups = []
57
55
  for group_index, group in enumerate(config.groups):
58
56
  tasks = workers.get_group_tasks(group_index, group.network)
@@ -63,19 +61,6 @@ def create_balances_result(config: Config, prices: Prices, workers: Workers) ->
63
61
  return BalancesResult(groups=groups, total=total, total_share=total_share)
64
62
 
65
63
 
66
- # def save_balances_file(result: BalancesResult, balances_file: Path) -> None:
67
- # data = {}
68
- # for group in result.groups:
69
- # if group.network not in data:
70
- # data[group.network] = {}
71
- # if group.ticker not in data[group.network]:
72
- # data[group.network][group.ticker] = {}
73
- # for address in group.addresses:
74
- # if isinstance(address.balance, Balance):
75
- # data[group.network][group.ticker][address.address] = float(address.balance.balance)
76
- # json.dump(data, balances_file.open("w"), indent=2)
77
-
78
-
79
64
  def _create_total(use_share: bool, groups: list[GroupResult]) -> Total:
80
65
  coin_balances: dict[str, Decimal] = defaultdict(Decimal) # ticker -> balance
81
66
  coin_usd_values: dict[str, Decimal] = defaultdict(Decimal) # ticker -> usd value
@@ -108,7 +93,7 @@ def _create_total(use_share: bool, groups: list[GroupResult]) -> Total:
108
93
  )
109
94
 
110
95
 
111
- def _create_group_result(config: Config, group: Group, tasks: list[Task], prices: Prices) -> GroupResult:
96
+ def _create_group_result(config: Config, group: AssetGroup, tasks: list[Task], prices: Prices) -> GroupResult:
112
97
  addresses = []
113
98
  balance_sum = Decimal(0)
114
99
  usd_sum = Decimal(0)
@@ -116,8 +101,8 @@ def _create_group_result(config: Config, group: Group, tasks: list[Task], prices
116
101
  balance: Balance | str
117
102
  if task.balance is None:
118
103
  balance = "balance is None! Something went wrong."
119
- elif isinstance(task.balance, Ok):
120
- coin_value = task.balance.ok
104
+ elif task.balance.is_ok():
105
+ coin_value = task.balance.unwrap()
121
106
  usd_value = Decimal(0)
122
107
  if group.ticker in prices:
123
108
  usd_value = round_decimal(coin_value * prices[group.ticker], config.settings.round_ndigits)
@@ -125,7 +110,7 @@ def _create_group_result(config: Config, group: Group, tasks: list[Task], prices
125
110
  balance_sum += balance.balance
126
111
  usd_sum += balance.usd_value
127
112
  else:
128
- balance = task.balance.err
113
+ balance = task.balance.unwrap_error()
129
114
  addresses.append(AddressBalance(address=task.wallet_address, balance=balance))
130
115
 
131
116
  balance_sum_share = balance_sum * group.share
@@ -142,3 +127,16 @@ def _create_group_result(config: Config, group: Group, tasks: list[Task], prices
142
127
  balance_sum_share=balance_sum_share,
143
128
  usd_sum_share=usd_sum_share,
144
129
  )
130
+
131
+
132
+ # def save_balances_file(result: BalancesResult, balances_file: Path) -> None:
133
+ # data = {}
134
+ # for group in result.groups:
135
+ # if group.network not in data:
136
+ # data[group.network] = {}
137
+ # if group.ticker not in data[group.network]:
138
+ # data[group.network][group.ticker] = {}
139
+ # for address in group.addresses:
140
+ # if isinstance(address.balance, Balance):
141
+ # data[group.network][group.ticker][address.address] = float(address.balance.balance)
142
+ # json.dump(data, balances_file.open("w"), indent=2)
mm_balance/rpc.py ADDED
@@ -0,0 +1,183 @@
1
+ from decimal import Decimal
2
+
3
+ from mm_apt import retry as apt_retry
4
+ from mm_btc.blockstream import BlockstreamClient
5
+ from mm_crypto_utils import Nodes, Proxies
6
+ from mm_eth import retry as eth_retry
7
+ from mm_sol import retry as sol_retry
8
+ from mm_std import Result
9
+
10
+ from mm_balance.constants import (
11
+ NETWORK_APTOS,
12
+ NETWORK_BITCOIN,
13
+ NETWORK_SOLANA,
14
+ RETRIES_BALANCE,
15
+ RETRIES_DECIMALS,
16
+ TIMEOUT_BALANCE,
17
+ TIMEOUT_DECIMALS,
18
+ Network,
19
+ )
20
+ from mm_balance.utils import scale_and_round
21
+
22
+
23
+ async def get_balance(
24
+ *,
25
+ network: Network,
26
+ nodes: Nodes,
27
+ proxies: Proxies,
28
+ wallet_address: str,
29
+ token_address: str | None,
30
+ token_decimals: int,
31
+ ndigits: int,
32
+ ) -> Result[Decimal]:
33
+ """
34
+ Fetch balance for a wallet on specified network.
35
+
36
+ This function retrieves the balance of a wallet address on a given network.
37
+ It supports multiple networks including EVM-compatible chains (Ethereum,
38
+ Arbitrum, etc.), Bitcoin, Aptos, and Solana. For EVM networks and Solana,
39
+ it can fetch both native coin and token balances.
40
+
41
+ Args:
42
+ network: The blockchain network to query
43
+ nodes: RPC nodes to use for the request
44
+ proxies: Proxy configuration for the request
45
+ wallet_address: The address of the wallet to check
46
+ token_address: The address of the token (None for native coin)
47
+ token_decimals: Number of decimal places for the token
48
+ ndigits: Number of digits to round the result to
49
+
50
+ Returns:
51
+ Result containing the balance as a Decimal on success, or an error message
52
+ """
53
+ if network.is_evm_network():
54
+ return await _get_evm_balance(
55
+ nodes=nodes,
56
+ proxies=proxies,
57
+ wallet_address=wallet_address,
58
+ token_address=token_address,
59
+ token_decimals=token_decimals,
60
+ ndigits=ndigits,
61
+ )
62
+ if network == NETWORK_BITCOIN:
63
+ return await _get_bitcoin_balance(
64
+ proxies=proxies,
65
+ wallet_address=wallet_address,
66
+ token_decimals=token_decimals,
67
+ ndigits=ndigits,
68
+ )
69
+ if network == NETWORK_APTOS:
70
+ return await _get_aptos_balance(
71
+ nodes=nodes,
72
+ proxies=proxies,
73
+ wallet_address=wallet_address,
74
+ token_address=token_address,
75
+ token_decimals=token_decimals,
76
+ ndigits=ndigits,
77
+ )
78
+ if network == NETWORK_SOLANA:
79
+ return await _get_solana_balance(
80
+ nodes=nodes,
81
+ proxies=proxies,
82
+ wallet_address=wallet_address,
83
+ token_address=token_address,
84
+ token_decimals=token_decimals,
85
+ ndigits=ndigits,
86
+ )
87
+ return Result.err("Unsupported network")
88
+
89
+
90
+ async def _get_evm_balance(
91
+ *,
92
+ nodes: Nodes,
93
+ proxies: Proxies,
94
+ wallet_address: str,
95
+ token_address: str | None,
96
+ token_decimals: int,
97
+ ndigits: int,
98
+ ) -> Result[Decimal]:
99
+ """Fetch balance for EVM-compatible networks."""
100
+ if token_address is None:
101
+ res = await eth_retry.eth_get_balance(RETRIES_BALANCE, nodes, proxies, address=wallet_address, timeout=TIMEOUT_BALANCE)
102
+ else:
103
+ res = await eth_retry.erc20_balance(
104
+ RETRIES_BALANCE, nodes, proxies, token=token_address, wallet=wallet_address, timeout=TIMEOUT_BALANCE
105
+ )
106
+ return res.map(lambda value: scale_and_round(value, token_decimals, ndigits))
107
+
108
+
109
+ async def _get_bitcoin_balance(
110
+ *,
111
+ proxies: Proxies,
112
+ wallet_address: str,
113
+ token_decimals: int,
114
+ ndigits: int,
115
+ ) -> Result[Decimal]:
116
+ """Fetch balance for Bitcoin network."""
117
+ res = await BlockstreamClient(proxies=proxies, attempts=RETRIES_BALANCE).get_confirmed_balance(wallet_address)
118
+ return res.map(lambda value: scale_and_round(value, token_decimals, ndigits))
119
+
120
+
121
+ async def _get_aptos_balance(
122
+ *,
123
+ nodes: Nodes,
124
+ proxies: Proxies,
125
+ wallet_address: str,
126
+ token_address: str | None,
127
+ token_decimals: int,
128
+ ndigits: int,
129
+ ) -> Result[Decimal]:
130
+ """Fetch balance for Aptos network."""
131
+ actual_token_address = token_address if token_address is not None else "0x1::aptos_coin::AptosCoin"
132
+ res = await apt_retry.get_balance(
133
+ RETRIES_BALANCE, nodes, proxies, account=wallet_address, coin_type=actual_token_address, timeout=TIMEOUT_BALANCE
134
+ )
135
+ return res.map(lambda value: scale_and_round(value, token_decimals, ndigits))
136
+
137
+
138
+ async def _get_solana_balance(
139
+ *,
140
+ nodes: Nodes,
141
+ proxies: Proxies,
142
+ wallet_address: str,
143
+ token_address: str | None,
144
+ token_decimals: int,
145
+ ndigits: int,
146
+ ) -> Result[Decimal]:
147
+ """Fetch balance for Solana network."""
148
+ if token_address is None:
149
+ res = await sol_retry.get_sol_balance(RETRIES_BALANCE, nodes, proxies, address=wallet_address, timeout=TIMEOUT_BALANCE)
150
+ else:
151
+ res = await sol_retry.get_token_balance(
152
+ RETRIES_BALANCE, nodes, proxies, owner=wallet_address, token=token_address, timeout=TIMEOUT_BALANCE
153
+ )
154
+ return res.map(lambda value: scale_and_round(value, token_decimals, ndigits))
155
+
156
+
157
+ async def get_token_decimals(
158
+ *,
159
+ network: Network,
160
+ nodes: Nodes,
161
+ proxies: Proxies,
162
+ token_address: str,
163
+ ) -> Result[int]:
164
+ """
165
+ Fetch the number of decimal places for a token.
166
+
167
+ This function retrieves the decimal precision for a token on a given network.
168
+ Currently supports EVM-compatible networks and Solana.
169
+
170
+ Args:
171
+ network: The blockchain network the token exists on
172
+ nodes: RPC nodes to use for the request
173
+ proxies: Proxy configuration for the request
174
+ token_address: The address of the token
175
+
176
+ Returns:
177
+ Result containing the number of decimals as an integer on success, or an error message
178
+ """
179
+ if network.is_evm_network():
180
+ return await eth_retry.erc20_decimals(RETRIES_DECIMALS, nodes, proxies, token=token_address, timeout=TIMEOUT_DECIMALS)
181
+ if network == NETWORK_SOLANA:
182
+ return await sol_retry.get_token_decimals(RETRIES_DECIMALS, nodes, proxies, token=token_address, timeout=TIMEOUT_DECIMALS)
183
+ return Result.err("Unsupported network")
@@ -1,8 +1,8 @@
1
- from mm_std import Err, fatal
1
+ from mm_std import fatal
2
2
 
3
+ from mm_balance import rpc
3
4
  from mm_balance.config import Config
4
5
  from mm_balance.constants import NETWORK_APTOS, NETWORK_BITCOIN, NETWORK_SOLANA, Network
5
- from mm_balance.rpc import evm, solana
6
6
 
7
7
 
8
8
  class TokenDecimals(dict[Network, dict[str | None, int]]): # {network: {None: 18}} -- None is for native token, ex. ETH
@@ -12,9 +12,8 @@ class TokenDecimals(dict[Network, dict[str | None, int]]): # {network: {None: 1
12
12
  self[network] = {}
13
13
 
14
14
 
15
- def get_token_decimals(config: Config) -> TokenDecimals:
15
+ async def get_token_decimals(config: Config) -> TokenDecimals:
16
16
  result = TokenDecimals(config.networks())
17
- proxies = config.settings.proxies
18
17
 
19
18
  for group in config.groups:
20
19
  # token_decimals is already known
@@ -39,15 +38,12 @@ def get_token_decimals(config: Config) -> TokenDecimals:
39
38
  if group.token in result[group.network]:
40
39
  continue # don't request for a token_decimals twice
41
40
 
42
- nodes = config.nodes[group.network]
43
- if group.network.is_evm_network():
44
- res = evm.get_token_decimals(nodes, group.token, proxies)
45
- elif group.network == NETWORK_SOLANA:
46
- res = solana.get_token_decimals(nodes, group.token, proxies)
47
- else:
48
- fatal(f"unsupported network: {group.network}. Cant get token decimals for {group.token}")
49
- if isinstance(res, Err):
50
- fatal(f"can't get decimals for token {group.ticker} / {group.token}, error={res.err}")
51
- result[group.network][group.token] = res.ok
41
+ res = await rpc.get_token_decimals(
42
+ network=group.network, nodes=config.nodes[group.network], proxies=config.settings.proxies, token_address=group.token
43
+ )
44
+
45
+ if res.is_err():
46
+ fatal(f"can't get decimals for token {group.ticker} / {group.token}, error={res.unwrap_error()}")
47
+ result[group.network][group.token] = res.unwrap()
52
48
 
53
49
  return result
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: mm-balance
3
+ Version: 0.4.0
4
+ Requires-Python: >=3.12
5
+ Requires-Dist: deepdiff==8.4.2
6
+ Requires-Dist: mm-apt==0.3.4
7
+ Requires-Dist: mm-btc==0.4.2
8
+ Requires-Dist: mm-eth==0.6.1
9
+ Requires-Dist: mm-sol==0.6.2
10
+ Requires-Dist: typer==0.15.2
@@ -0,0 +1,22 @@
1
+ mm_balance/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ mm_balance/balance_fetcher.py,sha256=HUYoZkBdRSPb3AQ5MANOf1H9PQEyizaftXM4oCwp0Bs,3192
3
+ mm_balance/cli.py,sha256=0ZD0fEhzEgrBrXyxUVfuMikrcqJ5w9LSJST4U8DlSIE,2699
4
+ mm_balance/command_runner.py,sha256=4rWzQfGEznTNTm_tfVlp46FGlpUD0Wd7zFr0UyL1aws,2845
5
+ mm_balance/config.py,sha256=yVB-46HR7saXSsGKbHoxsu6cRMUVTSiuoEPSpSSiOv0,4886
6
+ mm_balance/constants.py,sha256=K1qOTHZNmv3_AsLAWzrRG5XCDBMOhVr1NLnL1_2bC08,2446
7
+ mm_balance/diff.py,sha256=GPRbykty2TIBBM8jpYXOV9Itjyd_mz0BTUsQ8KX7cNo,7099
8
+ mm_balance/price.py,sha256=QVnd0PTn-1ylVLJu230zVpGv5Bs8MgBkantU0pq2osE,1568
9
+ mm_balance/result.py,sha256=yx691T0E6FAe-NSiFMnf1KHBgbCAZPBBQ9xLgKs4_1U,5175
10
+ mm_balance/rpc.py,sha256=-alZDHjpcxna3fHDu_qDGCRMBupFU9x2NeOLGoUQM7U,6121
11
+ mm_balance/token_decimals.py,sha256=di4-LVkXmqAFGc9vrXF_Kq1Oqx3YHWZhavjXrXmigC0,1953
12
+ mm_balance/utils.py,sha256=_UMX3TV350Sr222tAnxGUf0R5McpwloNTQC-U-xiuHc,636
13
+ mm_balance/config/example.toml,sha256=f3Jr40ziOCv_Txf-BysS89c9r7uS-IYHuvwbQ-iftUs,1802
14
+ mm_balance/output/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
+ mm_balance/output/utils.py,sha256=zyN9igdaXGY_vKfc-3dJ13mH1T7JDke3AeB4MY_3AsA,842
16
+ mm_balance/output/formats/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
+ mm_balance/output/formats/json_format.py,sha256=ki0AK7Azft5SF9v14_8X_QT-NtyNWqw0MlQmxtV-VH8,921
18
+ mm_balance/output/formats/table_format.py,sha256=aVf34CK0oVyzm5_mYWnV5BG7eFUah4BYE6DJD1-8bRk,4782
19
+ mm_balance-0.4.0.dist-info/METADATA,sha256=IBj1ayGa0HlsY9yLV-qL5eSdmLGeCN5k74n_xh9sf7k,254
20
+ mm_balance-0.4.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
21
+ mm_balance-0.4.0.dist-info/entry_points.txt,sha256=rSnP0ZW1a3ACNwTWM7T53CmOycKbzhG43m2_wseENng,50
22
+ mm_balance-0.4.0.dist-info/RECORD,,
File without changes
mm_balance/rpc/aptos.py DELETED
@@ -1,23 +0,0 @@
1
- from decimal import Decimal
2
-
3
- from mm_aptos import balance
4
- from mm_std import Result
5
-
6
- from mm_balance.constants import RETRIES_BALANCE, TIMEOUT_BALANCE
7
-
8
-
9
- def get_balance(
10
- nodes: list[str], wallet: str, token: str | None, decimals: int, proxies: list[str], round_ndigits: int
11
- ) -> Result[Decimal]:
12
- if token is None:
13
- token = "0x1::aptos_coin::AptosCoin" # noqa: S105 # nosec
14
- return balance.get_decimal_balance_with_retries(
15
- RETRIES_BALANCE,
16
- nodes,
17
- wallet,
18
- token,
19
- decimals=decimals,
20
- timeout=TIMEOUT_BALANCE,
21
- proxies=proxies,
22
- round_ndigits=round_ndigits,
23
- )
mm_balance/rpc/btc.py DELETED
@@ -1,15 +0,0 @@
1
- from decimal import Decimal
2
-
3
- from mm_btc.blockstream import BlockstreamClient
4
- from mm_std import Ok, Result
5
-
6
- from mm_balance.constants import RETRIES_BALANCE
7
- from mm_balance.utils import scale_and_round
8
-
9
-
10
- def get_balance(address: str, proxies: list[str], round_ndigits: int) -> Result[Decimal]:
11
- return (
12
- BlockstreamClient(proxies=proxies, attempts=RETRIES_BALANCE)
13
- .get_confirmed_balance(address)
14
- .and_then(lambda b: Ok(scale_and_round(b, 8, round_ndigits)))
15
- )
mm_balance/rpc/evm.py DELETED
@@ -1,28 +0,0 @@
1
- from decimal import Decimal
2
-
3
- from mm_eth import erc20, rpc
4
- from mm_std import Ok, Result
5
-
6
- from mm_balance.constants import RETRIES_BALANCE, RETRIES_DECIMALS, TIMEOUT_BALANCE, TIMEOUT_DECIMALS
7
- from mm_balance.utils import scale_and_round
8
-
9
-
10
- def get_balance(
11
- nodes: list[str], wallet: str, token: str | None, decimals: int, proxies: list[str], round_ndigits: int
12
- ) -> Result[Decimal]:
13
- if token is not None:
14
- res = erc20.get_balance(
15
- nodes,
16
- token,
17
- wallet,
18
- proxies=proxies,
19
- attempts=RETRIES_BALANCE,
20
- timeout=TIMEOUT_BALANCE,
21
- )
22
- else:
23
- res = rpc.eth_get_balance(nodes, wallet, proxies=proxies, attempts=RETRIES_BALANCE, timeout=TIMEOUT_BALANCE)
24
- return res.and_then(lambda b: Ok(scale_and_round(b, decimals, round_ndigits)))
25
-
26
-
27
- def get_token_decimals(nodes: list[str], token_address: str, proxies: list[str]) -> Result[int]:
28
- return erc20.get_decimals(nodes, token_address, timeout=TIMEOUT_DECIMALS, proxies=proxies, attempts=RETRIES_DECIMALS)
mm_balance/rpc/solana.py DELETED
@@ -1,27 +0,0 @@
1
- from decimal import Decimal
2
-
3
- from mm_sol import balance, token
4
- from mm_std import Ok, Result
5
-
6
- from mm_balance.constants import RETRIES_BALANCE, RETRIES_DECIMALS, TIMEOUT_BALANCE, TIMEOUT_DECIMALS
7
- from mm_balance.utils import scale_and_round
8
-
9
-
10
- def get_balance(
11
- nodes: list[str], wallet: str, token: str | None, decimals: int, proxies: list[str], round_ndigits: int
12
- ) -> Result[Decimal]:
13
- if token is None:
14
- res = balance.get_sol_balance_with_retries(
15
- nodes, wallet, retries=RETRIES_BALANCE, timeout=TIMEOUT_BALANCE, proxies=proxies
16
- )
17
- else:
18
- res = balance.get_token_balance_with_retries(
19
- nodes, wallet, token, retries=RETRIES_BALANCE, timeout=TIMEOUT_BALANCE, proxies=proxies
20
- )
21
- return res.and_then(lambda b: Ok(scale_and_round(b, decimals, round_ndigits)))
22
-
23
-
24
- def get_token_decimals(nodes: list[str], token_address: str, proxies: list[str]) -> Result[int]:
25
- return token.get_decimals_with_retries(
26
- nodes, token_address, retries=RETRIES_DECIMALS, timeout=TIMEOUT_DECIMALS, proxies=proxies
27
- )
@@ -1,10 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: mm-balance
3
- Version: 0.3.0
4
- Requires-Python: >=3.12
5
- Requires-Dist: deepdiff==8.2.0
6
- Requires-Dist: mm-aptos==0.2.0
7
- Requires-Dist: mm-btc==0.3.0
8
- Requires-Dist: mm-eth==0.5.3
9
- Requires-Dist: mm-sol==0.5.1
10
- Requires-Dist: typer==0.15.1
@@ -1,25 +0,0 @@
1
- mm_balance/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- mm_balance/cli.py,sha256=nRDVejN-dnr1iRezBxNk4z0oLa59tiwzIVhgeOGzvbo,4498
3
- mm_balance/config.py,sha256=gywHNWEQL1xeG778cuca5aBoXElLIfoGle3xxW6m_0U,4564
4
- mm_balance/constants.py,sha256=kqG2zuwv0l-PzDHIrMJVQpfQWiXjr2DsqGPcKmqNJLo,2334
5
- mm_balance/diff.py,sha256=GPRbykty2TIBBM8jpYXOV9Itjyd_mz0BTUsQ8KX7cNo,7099
6
- mm_balance/price.py,sha256=DzvcQngS6wgi_4YWoXxGvOuOkwJvUbN0KI8DeIxbB5A,1494
7
- mm_balance/result.py,sha256=-Ebq07JMLcQAmRs82cA6aYMbsT1qbZSyAOixr9K_wbg,5157
8
- mm_balance/token_decimals.py,sha256=N3YppB2F3J_OuNkawpAHLVD-4MRCoVjBI01_OoRg5sY,2201
9
- mm_balance/utils.py,sha256=_UMX3TV350Sr222tAnxGUf0R5McpwloNTQC-U-xiuHc,636
10
- mm_balance/workers.py,sha256=eg0Ve1xVu3Kd_thfVmPsp6tEdJsYYvs1ipXiu5rKItY,3758
11
- mm_balance/config/example.toml,sha256=f3Jr40ziOCv_Txf-BysS89c9r7uS-IYHuvwbQ-iftUs,1802
12
- mm_balance/output/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
- mm_balance/output/utils.py,sha256=zyN9igdaXGY_vKfc-3dJ13mH1T7JDke3AeB4MY_3AsA,842
14
- mm_balance/output/formats/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
- mm_balance/output/formats/json_format.py,sha256=japDhWBVaZ9Znwrhi6BLsUEyn2zyNVxPFYhH7SiILb8,893
16
- mm_balance/output/formats/table_format.py,sha256=QtWq6ETIccfBTw-9SPypeZrX2zfZwpoCFi_Qe_qOLAA,4751
17
- mm_balance/rpc/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
18
- mm_balance/rpc/aptos.py,sha256=1JCYCqDim4tk1axXscaAJRXPd4J6vV1ABFbwMbPgrL0,641
19
- mm_balance/rpc/btc.py,sha256=wBMxUjbqdQipVsTFVkj4tk7loErA2czLVfvG8vjFLeE,493
20
- mm_balance/rpc/evm.py,sha256=LaU2csGL-VlQauCiTX_WFnstvTyZLMP5gDw2LyV53m8,1048
21
- mm_balance/rpc/solana.py,sha256=10rJ4eEr9sfEfhXx-X2R7bdJ5dL7bVMwHHfJ4R3QR7U,1071
22
- mm_balance-0.3.0.dist-info/METADATA,sha256=Owy1HtXoCk7RhiqeEwijv80KKLuDBHbgQdHOrjAxvEs,256
23
- mm_balance-0.3.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
24
- mm_balance-0.3.0.dist-info/entry_points.txt,sha256=rSnP0ZW1a3ACNwTWM7T53CmOycKbzhG43m2_wseENng,50
25
- mm_balance-0.3.0.dist-info/RECORD,,