mm-btc 0.0.5__tar.gz → 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. mm_btc-0.1.0/.env.example +1 -0
  2. mm_btc-0.1.0/.gitignore +15 -0
  3. mm_btc-0.1.0/PKG-INFO +9 -0
  4. mm_btc-0.1.0/README.txt +9 -0
  5. mm_btc-0.1.0/justfile +33 -0
  6. {mm_btc-0.0.5 → mm_btc-0.1.0}/pyproject.toml +19 -23
  7. mm_btc-0.1.0/src/mm_btc/blockstream.py +110 -0
  8. {mm_btc-0.0.5 → mm_btc-0.1.0}/src/mm_btc/cli/cli.py +20 -1
  9. mm_btc-0.1.0/src/mm_btc/cli/cmd/address_cmd.py +10 -0
  10. mm_btc-0.1.0/src/mm_btc/cli/cmd/create_tx_cmd.py +27 -0
  11. mm_btc-0.1.0/src/mm_btc/cli/cmd/decode_tx_cmd.py +8 -0
  12. mm_btc-0.1.0/src/mm_btc/cli/cmd/utxo_cmd.py +11 -0
  13. mm_btc-0.1.0/src/mm_btc/tx.py +5 -0
  14. mm_btc-0.1.0/tests/__init__.py +0 -0
  15. mm_btc-0.1.0/tests/cli/cmd/test_mnemonic_cmd.py +22 -0
  16. mm_btc-0.1.0/tests/conftest.py +34 -0
  17. mm_btc-0.1.0/tests/test_blockstream.py +28 -0
  18. mm_btc-0.1.0/uv.lock +1235 -0
  19. mm_btc-0.0.5/PKG-INFO +0 -21
  20. mm_btc-0.0.5/README.txt +0 -1
  21. mm_btc-0.0.5/setup.cfg +0 -4
  22. mm_btc-0.0.5/src/mm_btc/blockstream.py +0 -63
  23. mm_btc-0.0.5/src/mm_btc/cli/cmd/address_cmd.py +0 -10
  24. mm_btc-0.0.5/src/mm_btc.egg-info/PKG-INFO +0 -21
  25. mm_btc-0.0.5/src/mm_btc.egg-info/SOURCES.txt +0 -20
  26. mm_btc-0.0.5/src/mm_btc.egg-info/dependency_links.txt +0 -1
  27. mm_btc-0.0.5/src/mm_btc.egg-info/entry_points.txt +0 -2
  28. mm_btc-0.0.5/src/mm_btc.egg-info/requires.txt +0 -18
  29. mm_btc-0.0.5/src/mm_btc.egg-info/top_level.txt +0 -1
  30. mm_btc-0.0.5/tests/test_blockstream.py +0 -24
  31. {mm_btc-0.0.5 → mm_btc-0.1.0}/src/mm_btc/__init__.py +0 -0
  32. {mm_btc-0.0.5 → mm_btc-0.1.0}/src/mm_btc/cli/__init__.py +0 -0
  33. {mm_btc-0.0.5 → mm_btc-0.1.0}/src/mm_btc/cli/cli_utils.py +0 -0
  34. {mm_btc-0.0.5 → mm_btc-0.1.0}/src/mm_btc/cli/cmd/__init__.py +0 -0
  35. {mm_btc-0.0.5 → mm_btc-0.1.0}/src/mm_btc/cli/cmd/mnemonic_cmd.py +0 -0
  36. {mm_btc-0.0.5 → mm_btc-0.1.0}/src/mm_btc/py.typed +0 -0
  37. {mm_btc-0.0.5 → mm_btc-0.1.0}/src/mm_btc/wallet.py +0 -0
  38. {mm_btc-0.0.5 → mm_btc-0.1.0}/tests/test_wallet.py +0 -0
@@ -0,0 +1 @@
1
+ PROXIES_URL=https://site.com/api/proxies
@@ -0,0 +1,15 @@
1
+ .idea
2
+ .venv
3
+ .env
4
+ .coverage
5
+ /htmlcov
6
+ __pycache__
7
+ *.egg-info
8
+ pip-wheel-metadata
9
+ .pytest_cache
10
+ .mypy_cache
11
+ .ruff_cache
12
+ /dist
13
+ /build
14
+ /tmp
15
+ .DS_Store
mm_btc-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.3
2
+ Name: mm-btc
3
+ Version: 0.1.0
4
+ Requires-Python: >=3.12
5
+ Requires-Dist: bitcoinlib~=0.6.15
6
+ Requires-Dist: bit~=0.8.0
7
+ Requires-Dist: hdwallet~=2.2.1
8
+ Requires-Dist: mm-std~=0.1.2
9
+ Requires-Dist: typer~=0.12.3
@@ -0,0 +1,9 @@
1
+ mm-btc
2
+
3
+
4
+ Links:
5
+ - https://en.bitcoin.it/wiki/Transaction
6
+ - https://en.bitcoin.it/wiki/List_of_address_prefixes
7
+ - https://github.com/bitcoin/bips/blob/master/README.mediawiki
8
+
9
+ - https://github.com/Blockstream/esplora/blob/master/API.md
mm_btc-0.1.0/justfile ADDED
@@ -0,0 +1,33 @@
1
+ set dotenv-load
2
+ version := `uv run python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])'`
3
+
4
+
5
+ clean:
6
+ rm -rf .pytest_cache .mypy_cache .ruff_cache .coverage dist build src/*.egg-info
7
+
8
+ build: clean lint audit test
9
+ uvx --from build pyproject-build --installer uv
10
+
11
+ format:
12
+ uv run ruff check --select I --fix src tests
13
+ uv run ruff format src tests
14
+
15
+ test:
16
+ uv run coverage run -m pytest -n auto tests
17
+
18
+ lint: format
19
+ uv run ruff check src tests
20
+ uv run mypy src
21
+
22
+ audit:
23
+ uv run pip-audit
24
+ uv run bandit -r -c "pyproject.toml" src
25
+
26
+ publish: build
27
+ git diff-index --quiet HEAD
28
+ uvx twine upload dist/**
29
+ git tag -a 'v{{version}}' -m 'v{{version}}'
30
+ git push origin v{{version}}
31
+
32
+ sync:
33
+ uv sync
@@ -1,37 +1,35 @@
1
1
  [project]
2
2
  name = "mm-btc"
3
- version = "0.0.5"
3
+ version = "0.1.0"
4
4
  description = ""
5
5
  dependencies = [
6
- "mm-std~=0.1.0",
6
+ "mm-std~=0.1.2",
7
7
  "hdwallet~=2.2.1",
8
+ "bit~=0.8.0",
9
+ "bitcoinlib~=0.6.15",
8
10
  "typer~=0.12.3"
9
11
  ]
10
12
  requires-python = ">=3.12"
11
- [project.optional-dependencies]
12
- dev = [
13
- "build~=1.2.1",
14
- "twine~=5.1.0",
15
- "pytest~=8.3.2",
13
+ [project.scripts]
14
+ mm-btc = "mm_btc.cli.cli:app"
15
+
16
+ [build-system]
17
+ requires = ["hatchling"]
18
+ build-backend = "hatchling.build"
19
+
20
+ [tool.uv]
21
+ dev-dependencies = [
22
+ "pytest~=8.3.3",
16
23
  "pytest-xdist~=3.6.1",
17
- "pytest-httpserver~=1.0.8",
24
+ "pytest-httpserver~=1.1.0",
18
25
  "coverage~=7.6.0",
19
- "ruff~=0.5.2",
26
+ "ruff~=0.6.4",
20
27
  "pip-audit~=2.7.0",
21
28
  "bandit~=1.7.7",
22
- "mypy~=1.11.0",
23
- "types-python-dateutil~=2.9.0",
24
- "types-requests~=2.32.0.20240523",
29
+ "mypy~=1.11.2",
30
+ "types-python-dateutil~=2.9.0.20240906",
25
31
  "types-PyYAML~=6.0.12.12",
26
32
  ]
27
- [project.scripts]
28
- mm-btc = "mm_btc.cli.cli:app"
29
-
30
-
31
- [build-system]
32
- requires = ["setuptools"]
33
- build-backend = "setuptools.build_meta"
34
-
35
33
 
36
34
  [tool.mypy]
37
35
  python_version = "3.12"
@@ -39,10 +37,9 @@ warn_no_return = false
39
37
  strict = true
40
38
  exclude = ["^tests/", "^tmp/"]
41
39
  [[tool.mypy.overrides]]
42
- module = ["hdwallet.*", "hdwallet.symbols.*", "telebot.util.*"]
40
+ module = ["hdwallet.*", "hdwallet.symbols.*", "bit.*", "bitcoinlib.transactions.*"]
43
41
  ignore_missing_imports = true
44
42
 
45
-
46
43
  [tool.ruff]
47
44
  line-length = 130
48
45
  target-version = "py312"
@@ -68,7 +65,6 @@ lint.ignore = [
68
65
  quote-style = "double"
69
66
  indent-style = "space"
70
67
 
71
-
72
68
  [tool.bandit]
73
69
  exclude_dirs = ["tests"]
74
70
  skips = ["B311"]
@@ -0,0 +1,110 @@
1
+ from collections.abc import Sequence
2
+ from typing import TypeAlias
3
+
4
+ from mm_std import Err, HResponse, Ok, Result, hr
5
+ from mm_std.random_ import random_str_choice
6
+ from pydantic import BaseModel
7
+
8
+ MAINNET_BASE_URL = "https://blockstream.info/api"
9
+ TESTNET_BASE_URL = "https://blockstream.info/testnet/api"
10
+
11
+ ERROR_INVALID_ADDRESS = "INVALID_ADDRESS"
12
+ ERROR_INVALID_NETWORK = "INVALID_NETWORK"
13
+
14
+ Proxy: TypeAlias = str | Sequence[str] | None
15
+
16
+
17
+ class Mempool(BaseModel):
18
+ count: int
19
+ vsize: int
20
+ total_fee: int
21
+ fee_histogram: list[tuple[float, int]]
22
+
23
+
24
+ class Address(BaseModel):
25
+ class ChainStats(BaseModel):
26
+ funded_txo_count: int
27
+ funded_txo_sum: int
28
+ spent_txo_count: int
29
+ spent_txo_sum: int
30
+ tx_count: int
31
+
32
+ class MempoolStats(BaseModel):
33
+ funded_txo_count: int
34
+ funded_txo_sum: int
35
+ spent_txo_count: int
36
+ spent_txo_sum: int
37
+ tx_count: int
38
+
39
+ chain_stats: ChainStats
40
+ mempool_stats: MempoolStats
41
+
42
+
43
+ class Utxo(BaseModel):
44
+ class Status(BaseModel):
45
+ confirmed: bool
46
+ block_height: int
47
+ block_hash: str
48
+ block_time: int
49
+
50
+ txid: str
51
+ vout: int
52
+ status: Status
53
+ value: int
54
+
55
+
56
+ class BlockstreamClient:
57
+ def __init__(self, testnet: bool = False, timeout: int = 10, proxies: Proxy = None, attempts: int = 1):
58
+ self.testnet = testnet
59
+ self.timeout = timeout
60
+ self.proxies = proxies
61
+ self.attempts = attempts
62
+ self.base_url = TESTNET_BASE_URL if testnet else MAINNET_BASE_URL
63
+
64
+ def get_address(self, address: str) -> Result[Address]:
65
+ result: Result[Address] = Err("not started yet")
66
+ data = None
67
+ for _ in range(self.attempts):
68
+ try:
69
+ res = self._request(f"/address/{address}")
70
+ data = res.to_dict()
71
+ if res.code == 400 and (
72
+ "invalid bitcoin address" in res.body.lower() or "bech32 segwit decoding error" in res.body.lower()
73
+ ):
74
+ return Err(ERROR_INVALID_ADDRESS, data=data)
75
+ elif res.code == 400 and "invalid network" in res.body.lower():
76
+ return Err(ERROR_INVALID_NETWORK, data=data)
77
+ return Ok(Address(**res.json), data=data)
78
+ except Exception as err:
79
+ result = Err(err, data=data)
80
+ return result
81
+
82
+ def get_confirmed_balance(self, address: str) -> Result[int]:
83
+ return self.get_address(address).and_then(lambda a: Ok(a.chain_stats.funded_txo_sum - a.chain_stats.spent_txo_sum))
84
+
85
+ def get_utxo(self, address: str) -> Result[list[Utxo]]:
86
+ result: Result[list[Utxo]] = Err("not started yet")
87
+ data = None
88
+ for _ in range(self.attempts):
89
+ try:
90
+ res = self._request(f"/address/{address}/utxo")
91
+ data = res.to_dict()
92
+ return Ok([Utxo(**out) for out in res.json], data=data)
93
+ except Exception as err:
94
+ result = Err(err, data=data)
95
+ return result
96
+
97
+ def get_mempool(self) -> Result[Mempool]:
98
+ result: Result[Mempool] = Err("not started yet")
99
+ data = None
100
+ for _ in range(self.attempts):
101
+ try:
102
+ res = self._request("/mempool")
103
+ data = res.to_dict()
104
+ return Ok(Mempool(**res.json), data=data)
105
+ except Exception as err:
106
+ result = Err(err, data=data)
107
+ return result
108
+
109
+ def _request(self, url: str) -> HResponse:
110
+ return hr(f"{self.base_url}{url}", timeout=self.timeout, proxy=random_str_choice(self.proxies))
@@ -1,3 +1,4 @@
1
+ from pathlib import Path
1
2
  from typing import Annotated
2
3
 
3
4
  import typer
@@ -5,7 +6,7 @@ import typer.core
5
6
  from mm_std import print_plain
6
7
 
7
8
  from mm_btc.cli import cli_utils
8
- from mm_btc.cli.cmd import address_cmd, mnemonic_cmd
9
+ from mm_btc.cli.cmd import address_cmd, create_tx_cmd, decode_tx_cmd, mnemonic_cmd, utxo_cmd
9
10
 
10
11
  app = typer.Typer(no_args_is_help=True, pretty_exceptions_enable=False, add_completion=False)
11
12
 
@@ -42,6 +43,24 @@ def address_command(address: str) -> None:
42
43
  address_cmd.run(address)
43
44
 
44
45
 
46
+ @app.command("create-tx")
47
+ def create_tx_command(config_path: Annotated[Path, typer.Argument(exists=True)]) -> None:
48
+ """Create a transaction"""
49
+ create_tx_cmd.run(config_path)
50
+
51
+
52
+ @app.command("decode-tx")
53
+ def decode_tx_command(tx_hex: str, testnet: Annotated[bool, typer.Option("--testnet", "-t")] = False) -> None:
54
+ """Decode a transaction"""
55
+ decode_tx_cmd.run(tx_hex, testnet)
56
+
57
+
58
+ @app.command("utxo")
59
+ def utxo_command(address: str) -> None:
60
+ """Get UTXOs from Blockstream API"""
61
+ utxo_cmd.run(address)
62
+
63
+
45
64
  def version_callback(value: bool) -> None:
46
65
  if value:
47
66
  print_plain(f"mm-btc: v{cli_utils.get_version()}")
@@ -0,0 +1,10 @@
1
+ from mm_std import print_json
2
+
3
+ from mm_btc.blockstream import BlockstreamClient
4
+ from mm_btc.wallet import is_testnet_address
5
+
6
+
7
+ def run(address: str) -> None:
8
+ client = BlockstreamClient(testnet=is_testnet_address(address))
9
+ res = client.get_address(address)
10
+ print_json(res)
@@ -0,0 +1,27 @@
1
+ from pathlib import Path
2
+
3
+ from bit import PrivateKey, PrivateKeyTestnet
4
+ from mm_std import BaseConfig, print_console
5
+
6
+ from mm_btc.wallet import is_testnet_address
7
+
8
+
9
+ class Config(BaseConfig):
10
+ class Output(BaseConfig):
11
+ address: str
12
+ amount: int
13
+
14
+ from_address: str
15
+ private: str
16
+ outputs: list[Output]
17
+
18
+
19
+ def run(config_path: Path) -> None:
20
+ config = Config.read_config(config_path)
21
+ testnet = is_testnet_address(config.from_address)
22
+ key = PrivateKeyTestnet(config.private) if testnet else PrivateKey(config.private)
23
+
24
+ outputs = [(o.address, o.amount, "satoshi") for o in config.outputs]
25
+
26
+ tx = key.create_transaction(outputs)
27
+ print_console(tx)
@@ -0,0 +1,8 @@
1
+ from mm_std import print_json
2
+
3
+ from mm_btc.tx import decode_tx
4
+
5
+
6
+ def run(tx_hex: str, testnet: bool = False) -> None:
7
+ res = decode_tx(tx_hex, testnet)
8
+ print_json(res)
@@ -0,0 +1,11 @@
1
+ from mm_std import print_json
2
+
3
+ from mm_btc.blockstream import BlockstreamClient
4
+ from mm_btc.wallet import is_testnet_address
5
+
6
+
7
+ def run(address: str) -> None:
8
+ client = BlockstreamClient(testnet=is_testnet_address(address))
9
+ res = client.get_utxo(address)
10
+
11
+ print_json(res.ok_or_err())
@@ -0,0 +1,5 @@
1
+ from bitcoinlib.transactions import Transaction
2
+
3
+
4
+ def decode_tx(tx_hex: str, testnet: bool = False) -> dict[str, object]:
5
+ return Transaction.parse(tx_hex, network="testnet" if testnet else "mainnet").as_dict() # type: ignore[no-any-return]
File without changes
@@ -0,0 +1,22 @@
1
+ import pytest
2
+ from mm_std import run_command
3
+
4
+ from mm_btc.cli.cmd.mnemonic_cmd import get_derivation_path_prefix
5
+
6
+
7
+ def test_get_derivation_path_prefix():
8
+ assert get_derivation_path_prefix("m/11'/0'/0'/0", testnet=True) == "m/11'/0'/0'/0"
9
+ assert get_derivation_path_prefix("bip44", False) == "m/44'/0'/0'/0"
10
+ assert get_derivation_path_prefix("bip44", True) == "m/44'/1'/0'/0"
11
+ assert get_derivation_path_prefix("bip84", False) == "m/84'/0'/0'/0"
12
+ assert get_derivation_path_prefix("bip84", True) == "m/84'/1'/0'/0"
13
+
14
+ with pytest.raises(ValueError):
15
+ get_derivation_path_prefix("bip", True)
16
+
17
+
18
+ def test_mnemonic_cmd(mnemonic, passphrase):
19
+ cmd = f"mm-btc mnemonic -m '{mnemonic}' --passphrase '{passphrase}'"
20
+ res = run_command(cmd)
21
+ assert res.code == 0
22
+ assert "m/44'/0'/0'/0/7 1AJNF13C4xVvduE3D9pdtdDNFbL5Bm7T3u L3QF5FHUtX2a1ucGgVfrdhdvLHBSxsRQoGsv7tyY8P5Jt7UV9LZv"
@@ -0,0 +1,34 @@
1
+ import pytest
2
+ from mm_std import get_dotenv, hr
3
+
4
+
5
+ @pytest.fixture()
6
+ def mnemonic() -> str:
7
+ return "hub blur cliff taste afraid master game milk nest change blame code"
8
+
9
+
10
+ @pytest.fixture
11
+ def passphrase() -> str:
12
+ return "my-secret"
13
+
14
+
15
+ @pytest.fixture()
16
+ def mainnet_bip44_address_0() -> str:
17
+ return "1Men3kiujJH7H5NXyKpFtWWtni1cTfSk48"
18
+
19
+
20
+ @pytest.fixture
21
+ def binance_address() -> str:
22
+ return "34xp4vRoCGJym3xR7yCVPFHoCNxv4Twseo"
23
+
24
+
25
+ @pytest.fixture
26
+ def proxies() -> list[str]:
27
+ proxies_url = get_dotenv("PROXIES_URL")
28
+ if proxies_url:
29
+ res = hr(proxies_url)
30
+ try:
31
+ return res.json["proxies"]
32
+ except KeyError:
33
+ pass
34
+ return []
@@ -0,0 +1,28 @@
1
+ from mm_btc import blockstream
2
+ from mm_btc.blockstream import BlockstreamClient
3
+
4
+
5
+ def test_get_address(binance_address, proxies):
6
+ client = BlockstreamClient(proxies=proxies, timeout=5, attempts=5)
7
+
8
+ # non-empty address
9
+ res = client.get_address(binance_address)
10
+ assert res.unwrap().chain_stats.tx_count > 800
11
+
12
+ # empty address
13
+ res = client.get_address("bc1pa48c294qk7yd7sc8y0wxydc3a2frv5j83e65rvm48v3ej098s5zs8kvh6d")
14
+ assert res.unwrap().chain_stats.tx_count == 0
15
+
16
+ # invalid address
17
+ res = client.get_address("bc1pa48c294qk7yd7sc8y0wxydc3a2frv5j83e65rvm48v3ej098s5zs8kvh5d")
18
+ assert res.unwrap_err() == blockstream.ERROR_INVALID_ADDRESS
19
+
20
+ # invalid network
21
+ res = client.get_address("mqkwWDWdgXhYunfoKvQfYyydwB5vdma3cK")
22
+ assert res.unwrap_err() == blockstream.ERROR_INVALID_NETWORK
23
+
24
+
25
+ def test_get_confirmed_balance(binance_address, proxies):
26
+ client = BlockstreamClient(proxies=proxies)
27
+ res = client.get_confirmed_balance(binance_address)
28
+ assert res.unwrap() > 0