mm-btc 0.5.6__tar.gz → 0.6.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.
- mm_btc-0.6.0/.claude/settings.local.json +10 -0
- mm_btc-0.6.0/CLAUDE.md +24 -0
- {mm_btc-0.5.6 → mm_btc-0.6.0}/PKG-INFO +3 -3
- {mm_btc-0.5.6 → mm_btc-0.6.0}/justfile +1 -0
- {mm_btc-0.5.6 → mm_btc-0.6.0}/pyproject.toml +8 -7
- mm_btc-0.6.0/src/mm_btc/__init__.py +1 -0
- {mm_btc-0.5.6 → mm_btc-0.6.0}/src/mm_btc/blockstream.py +27 -6
- mm_btc-0.6.0/src/mm_btc/cli/__init__.py +1 -0
- {mm_btc-0.5.6 → mm_btc-0.6.0}/src/mm_btc/cli/cli.py +8 -5
- mm_btc-0.6.0/src/mm_btc/cli/cmd/__init__.py +1 -0
- {mm_btc-0.5.6 → mm_btc-0.6.0}/src/mm_btc/cli/cmd/address_cmd.py +5 -2
- {mm_btc-0.5.6 → mm_btc-0.6.0}/src/mm_btc/cli/cmd/create_tx_cmd.py +9 -2
- mm_btc-0.6.0/src/mm_btc/cli/cmd/decode_tx_cmd.py +11 -0
- {mm_btc-0.5.6 → mm_btc-0.6.0}/src/mm_btc/cli/cmd/mnemonic_cmd.py +14 -6
- {mm_btc-0.5.6 → mm_btc-0.6.0}/src/mm_btc/cli/cmd/utxo_cmd.py +5 -2
- mm_btc-0.6.0/src/mm_btc/tx.py +8 -0
- {mm_btc-0.5.6 → mm_btc-0.6.0}/src/mm_btc/wallet.py +21 -5
- {mm_btc-0.5.6 → mm_btc-0.6.0}/uv.lock +76 -324
- mm_btc-0.5.6/CLAUDE.md +0 -13
- mm_btc-0.5.6/src/mm_btc/cli/cmd/decode_tx_cmd.py +0 -8
- mm_btc-0.5.6/src/mm_btc/tx.py +0 -5
- mm_btc-0.5.6/tests/cmd/__init__.py +0 -0
- {mm_btc-0.5.6 → mm_btc-0.6.0}/.env.example +0 -0
- {mm_btc-0.5.6 → mm_btc-0.6.0}/.gitignore +0 -0
- {mm_btc-0.5.6 → mm_btc-0.6.0}/README.md +0 -0
- {mm_btc-0.5.6 → mm_btc-0.6.0}/dict.dic +0 -0
- {mm_btc-0.5.6 → mm_btc-0.6.0}/src/mm_btc/py.typed +0 -0
- {mm_btc-0.5.6/src/mm_btc → mm_btc-0.6.0/tests}/__init__.py +0 -0
- {mm_btc-0.5.6 → mm_btc-0.6.0}/tests/conftest.py +0 -0
- {mm_btc-0.5.6/src/mm_btc/cli → mm_btc-0.6.0/tests/mm_btc}/__init__.py +0 -0
- {mm_btc-0.5.6/src/mm_btc/cli/cmd → mm_btc-0.6.0/tests/mm_btc/cli}/__init__.py +0 -0
- {mm_btc-0.5.6/tests → mm_btc-0.6.0/tests/mm_btc/cli/cmd}/__init__.py +0 -0
- {mm_btc-0.5.6/tests → mm_btc-0.6.0/tests/mm_btc/cli}/cmd/test_mnemonic_cmd.py +0 -0
- {mm_btc-0.5.6/tests → mm_btc-0.6.0/tests/mm_btc}/test_blockstream.py +0 -0
- {mm_btc-0.5.6/tests → mm_btc-0.6.0/tests/mm_btc}/test_wallet.py +0 -0
mm_btc-0.6.0/CLAUDE.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# AI Agent Start Guide
|
|
2
|
+
|
|
3
|
+
## Critical: Language
|
|
4
|
+
RESPOND IN ENGLISH. Always. No exceptions.
|
|
5
|
+
User's language does NOT determine your response language.
|
|
6
|
+
Only switch if user EXPLICITLY requests it (e.g., "respond in {language}").
|
|
7
|
+
Language switching applies ONLY to chat. All code, comments, commit messages, and files must ALWAYS be in English — no exceptions.
|
|
8
|
+
|
|
9
|
+
## Mandatory Rules (external)
|
|
10
|
+
These files are REQUIRED. Read them fully and follow all rules.
|
|
11
|
+
- `~/.claude/shared-rules/general.md`
|
|
12
|
+
- `~/.claude/shared-rules/python.md`
|
|
13
|
+
|
|
14
|
+
## Project Reading (context)
|
|
15
|
+
These files are REQUIRED for project understanding.
|
|
16
|
+
- `README.md`
|
|
17
|
+
|
|
18
|
+
## Preflight (mandatory)
|
|
19
|
+
Before your first response:
|
|
20
|
+
1. Read all files listed above.
|
|
21
|
+
2. Do not answer until all are read.
|
|
22
|
+
3. In your first reply, list every file you have read from this document.
|
|
23
|
+
|
|
24
|
+
Failure to follow this protocol is considered an error.
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mm-btc
|
|
3
|
-
Version: 0.
|
|
4
|
-
Requires-Python: >=3.
|
|
3
|
+
Version: 0.6.0
|
|
4
|
+
Requires-Python: >=3.14
|
|
5
5
|
Requires-Dist: bitcoinlib~=0.7.6
|
|
6
6
|
Requires-Dist: bit~=0.8.0
|
|
7
7
|
Requires-Dist: hdwallet~=3.6.1
|
|
8
|
-
Requires-Dist: mm-web3~=0.
|
|
8
|
+
Requires-Dist: mm-web3~=0.6.2
|
|
9
9
|
Requires-Dist: mnemonic~=0.21
|
|
10
10
|
Requires-Dist: typer~=0.21.1
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "mm-btc"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.6.0"
|
|
4
4
|
description = ""
|
|
5
|
-
requires-python = ">=3.
|
|
5
|
+
requires-python = ">=3.14"
|
|
6
6
|
dependencies = [
|
|
7
|
-
"mm-web3~=0.
|
|
7
|
+
"mm-web3~=0.6.2",
|
|
8
8
|
"hdwallet~=3.6.1",
|
|
9
9
|
"bit~=0.8.0",
|
|
10
10
|
"bitcoinlib~=0.7.6",
|
|
@@ -21,19 +21,20 @@ build-backend = "hatchling.build"
|
|
|
21
21
|
|
|
22
22
|
[dependency-groups]
|
|
23
23
|
dev = [
|
|
24
|
-
"bandit~=1.9.
|
|
24
|
+
"bandit~=1.9.3",
|
|
25
25
|
"mypy~=1.19.1",
|
|
26
26
|
"pip-audit~=2.10.0",
|
|
27
27
|
"pytest~=9.0.2",
|
|
28
28
|
"pytest-asyncio~=1.3.0",
|
|
29
29
|
"pytest-xdist~=3.8.0",
|
|
30
|
-
"ruff~=0.
|
|
30
|
+
"ruff~=0.15.0",
|
|
31
31
|
"python-dotenv~=1.2.0",
|
|
32
|
+
"ty~=0.0.14",
|
|
32
33
|
]
|
|
33
34
|
|
|
34
35
|
|
|
35
36
|
[tool.mypy]
|
|
36
|
-
python_version = "3.
|
|
37
|
+
python_version = "3.14"
|
|
37
38
|
warn_no_return = false
|
|
38
39
|
strict = true
|
|
39
40
|
exclude = ["^tests/", "^tmp/"]
|
|
@@ -48,7 +49,7 @@ ignore_missing_imports = true
|
|
|
48
49
|
|
|
49
50
|
[tool.ruff]
|
|
50
51
|
line-length = 130
|
|
51
|
-
target-version = "
|
|
52
|
+
target-version = "py314"
|
|
52
53
|
[tool.ruff.lint]
|
|
53
54
|
select = ["ALL"]
|
|
54
55
|
ignore = [
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""mm-btc: Bitcoin wallet, transaction, and blockchain utilities."""
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
"""Blockstream Esplora API client for querying Bitcoin blockchain data."""
|
|
2
|
+
|
|
1
3
|
from collections.abc import Sequence
|
|
2
4
|
|
|
3
5
|
from mm_http import HttpResponse, http_request
|
|
@@ -5,18 +7,19 @@ from mm_result import Result
|
|
|
5
7
|
from mm_web3 import random_proxy
|
|
6
8
|
from pydantic import BaseModel
|
|
7
9
|
|
|
10
|
+
# Blockstream API base URLs.
|
|
8
11
|
MAINNET_BASE_URL = "https://blockstream.info/api"
|
|
9
12
|
TESTNET_BASE_URL = "https://blockstream.info/testnet/api"
|
|
10
13
|
|
|
11
|
-
#
|
|
12
|
-
# ERROR_INVALID_NETWORK = "INVALID_NETWORK"
|
|
13
|
-
|
|
14
|
+
# Error constant for HTTP 400 responses.
|
|
14
15
|
ERROR_400_BAD_REQUEST = "400 Bad Request"
|
|
15
16
|
|
|
16
17
|
type Proxy = str | Sequence[str] | None
|
|
17
18
|
|
|
18
19
|
|
|
19
20
|
class Mempool(BaseModel):
|
|
21
|
+
"""Bitcoin mempool summary statistics."""
|
|
22
|
+
|
|
20
23
|
count: int
|
|
21
24
|
vsize: int
|
|
22
25
|
total_fee: int
|
|
@@ -24,7 +27,11 @@ class Mempool(BaseModel):
|
|
|
24
27
|
|
|
25
28
|
|
|
26
29
|
class Address(BaseModel):
|
|
30
|
+
"""Bitcoin address information with on-chain and mempool statistics."""
|
|
31
|
+
|
|
27
32
|
class ChainStats(BaseModel):
|
|
33
|
+
"""Confirmed on-chain transaction statistics for an address."""
|
|
34
|
+
|
|
28
35
|
funded_txo_count: int
|
|
29
36
|
funded_txo_sum: int
|
|
30
37
|
spent_txo_count: int
|
|
@@ -32,6 +39,8 @@ class Address(BaseModel):
|
|
|
32
39
|
tx_count: int
|
|
33
40
|
|
|
34
41
|
class MempoolStats(BaseModel):
|
|
42
|
+
"""Unconfirmed mempool transaction statistics for an address."""
|
|
43
|
+
|
|
35
44
|
funded_txo_count: int
|
|
36
45
|
funded_txo_sum: int
|
|
37
46
|
spent_txo_count: int
|
|
@@ -43,7 +52,11 @@ class Address(BaseModel):
|
|
|
43
52
|
|
|
44
53
|
|
|
45
54
|
class Utxo(BaseModel):
|
|
55
|
+
"""Unspent transaction output."""
|
|
56
|
+
|
|
46
57
|
class Status(BaseModel):
|
|
58
|
+
"""Confirmation status of a UTXO."""
|
|
59
|
+
|
|
47
60
|
confirmed: bool
|
|
48
61
|
block_height: int
|
|
49
62
|
block_hash: str
|
|
@@ -56,7 +69,10 @@ class Utxo(BaseModel):
|
|
|
56
69
|
|
|
57
70
|
|
|
58
71
|
class BlockstreamClient:
|
|
72
|
+
"""HTTP client for the Blockstream Esplora REST API."""
|
|
73
|
+
|
|
59
74
|
def __init__(self, testnet: bool = False, timeout: float = 5, proxies: Proxy = None, attempts: int = 1) -> None:
|
|
75
|
+
"""Initialize the client with network, timeout, proxy, and retry settings."""
|
|
60
76
|
self.testnet = testnet
|
|
61
77
|
self.timeout = timeout
|
|
62
78
|
self.proxies = proxies
|
|
@@ -64,41 +80,46 @@ class BlockstreamClient:
|
|
|
64
80
|
self.base_url = TESTNET_BASE_URL if testnet else MAINNET_BASE_URL
|
|
65
81
|
|
|
66
82
|
async def get_address(self, address: str) -> Result[Address]:
|
|
83
|
+
"""Fetch address information including chain and mempool stats."""
|
|
67
84
|
result: Result[Address] = Result.err("not started yet")
|
|
68
85
|
for _ in range(self.attempts):
|
|
69
86
|
res = await self._request(f"/address/{address}")
|
|
70
87
|
try:
|
|
71
88
|
if res.status_code == 400:
|
|
72
89
|
return res.to_result_err("400 Bad Request")
|
|
73
|
-
return res.to_result_ok(Address(**res.
|
|
90
|
+
return res.to_result_ok(Address(**res.json_body().unwrap()))
|
|
74
91
|
except Exception as e:
|
|
75
92
|
result = res.to_result_err(e)
|
|
76
93
|
return result
|
|
77
94
|
|
|
78
95
|
async def get_confirmed_balance(self, address: str) -> Result[int]:
|
|
96
|
+
"""Fetch the confirmed balance (funded minus spent) for an address."""
|
|
79
97
|
return (await self.get_address(address)).chain(
|
|
80
98
|
lambda a: Result.ok(a.chain_stats.funded_txo_sum - a.chain_stats.spent_txo_sum)
|
|
81
99
|
)
|
|
82
100
|
|
|
83
101
|
async def get_utxo(self, address: str) -> Result[list[Utxo]]:
|
|
102
|
+
"""Fetch the list of UTXOs for an address."""
|
|
84
103
|
result: Result[list[Utxo]] = Result.err("not started yet")
|
|
85
104
|
for _ in range(self.attempts):
|
|
86
105
|
res = await self._request(f"/address/{address}/utxo")
|
|
87
106
|
try:
|
|
88
|
-
return res.to_result_ok([Utxo(**out) for out in res.
|
|
107
|
+
return res.to_result_ok([Utxo(**out) for out in res.json_body().unwrap()])
|
|
89
108
|
except Exception as e:
|
|
90
109
|
result = res.to_result_err(e)
|
|
91
110
|
return result
|
|
92
111
|
|
|
93
112
|
async def get_mempool(self) -> Result[Mempool]:
|
|
113
|
+
"""Fetch current mempool summary statistics."""
|
|
94
114
|
result: Result[Mempool] = Result.err("not started yet")
|
|
95
115
|
for _ in range(self.attempts):
|
|
96
116
|
res = await self._request("/mempool")
|
|
97
117
|
try:
|
|
98
|
-
return res.to_result_ok(Mempool(**res.
|
|
118
|
+
return res.to_result_ok(Mempool(**res.json_body().unwrap()))
|
|
99
119
|
except Exception as e:
|
|
100
120
|
result = res.to_result_err(e)
|
|
101
121
|
return result
|
|
102
122
|
|
|
103
123
|
async def _request(self, url: str) -> HttpResponse:
|
|
124
|
+
"""Send an HTTP GET request to the Blockstream API."""
|
|
104
125
|
return await http_request(f"{self.base_url}{url}", timeout=self.timeout, proxy=random_proxy(self.proxies))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI package for mm-btc."""
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
"""CLI entry point for mm-btc commands."""
|
|
2
|
+
|
|
1
3
|
import asyncio
|
|
2
4
|
import importlib.metadata
|
|
3
5
|
from pathlib import Path
|
|
4
6
|
from typing import Annotated
|
|
5
7
|
|
|
6
|
-
import mm_print
|
|
7
8
|
import typer
|
|
9
|
+
from mm_print import print_plain
|
|
8
10
|
|
|
9
11
|
from mm_btc.wallet import AddressType
|
|
10
12
|
|
|
@@ -15,7 +17,7 @@ app = typer.Typer(no_args_is_help=True, pretty_exceptions_enable=False, add_comp
|
|
|
15
17
|
|
|
16
18
|
@app.command("mnemonic")
|
|
17
19
|
@app.command(name="m", hidden=True)
|
|
18
|
-
def mnemonic_command( # nosec B107:hardcoded_password_default
|
|
20
|
+
def mnemonic_command( # nosec B107:hardcoded_password_default — empty string is a safe default, actual passphrase is user-provided
|
|
19
21
|
mnemonic: Annotated[str, typer.Option("--mnemonic", "-m", help="")] = "",
|
|
20
22
|
passphrase: Annotated[str, typer.Option("--passphrase", "-p")] = "",
|
|
21
23
|
path: Annotated[str, typer.Option("--path", help="Derivation path. Examples: bip44, bip88, m/44'/0'/0'/0")] = "bip44",
|
|
@@ -66,15 +68,16 @@ def utxo_command(address: str) -> None:
|
|
|
66
68
|
|
|
67
69
|
|
|
68
70
|
def version_callback(value: bool) -> None:
|
|
71
|
+
"""Print the version and exit when --version flag is passed."""
|
|
69
72
|
if value:
|
|
70
|
-
|
|
73
|
+
print_plain(f"mm-btc: v{importlib.metadata.version('mm-btc')}")
|
|
71
74
|
raise typer.Exit
|
|
72
75
|
|
|
73
76
|
|
|
74
77
|
@app.callback()
|
|
75
78
|
def main(_version: bool = typer.Option(None, "--version", callback=version_callback, is_eager=True)) -> None:
|
|
76
|
-
|
|
79
|
+
"""mm-btc CLI application root callback."""
|
|
77
80
|
|
|
78
81
|
|
|
79
|
-
if __name__ == "
|
|
82
|
+
if __name__ == "__main__":
|
|
80
83
|
app()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI command handlers for mm-btc."""
|
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
|
|
1
|
+
"""CLI handler for the address command — fetches address info from Blockstream."""
|
|
2
|
+
|
|
3
|
+
from mm_print import print_json
|
|
2
4
|
|
|
3
5
|
from mm_btc.blockstream import BlockstreamClient
|
|
4
6
|
from mm_btc.wallet import is_testnet_address
|
|
5
7
|
|
|
6
8
|
|
|
7
9
|
async def run(address: str) -> None:
|
|
10
|
+
"""Fetch and print address information from the Blockstream API."""
|
|
8
11
|
client = BlockstreamClient(testnet=is_testnet_address(address))
|
|
9
12
|
res = await client.get_address(address)
|
|
10
|
-
|
|
13
|
+
print_json(res.value_or_error())
|
|
@@ -1,14 +1,20 @@
|
|
|
1
|
+
"""CLI handler for the create-tx command — builds a Bitcoin transaction from a TOML config."""
|
|
2
|
+
|
|
1
3
|
from pathlib import Path
|
|
2
4
|
|
|
3
|
-
import mm_print
|
|
4
5
|
from bit import PrivateKey, PrivateKeyTestnet
|
|
6
|
+
from mm_print import print_json
|
|
5
7
|
from mm_web3 import Web3CliConfig
|
|
6
8
|
|
|
7
9
|
from mm_btc.wallet import is_testnet_address
|
|
8
10
|
|
|
9
11
|
|
|
10
12
|
class Config(Web3CliConfig):
|
|
13
|
+
"""Transaction creation configuration loaded from a TOML file."""
|
|
14
|
+
|
|
11
15
|
class Output(Web3CliConfig):
|
|
16
|
+
"""Single transaction output with destination address and amount in satoshis."""
|
|
17
|
+
|
|
12
18
|
address: str
|
|
13
19
|
amount: int
|
|
14
20
|
|
|
@@ -18,6 +24,7 @@ class Config(Web3CliConfig):
|
|
|
18
24
|
|
|
19
25
|
|
|
20
26
|
def run(config_path: Path) -> None:
|
|
27
|
+
"""Build and print a signed Bitcoin transaction from the given config file."""
|
|
21
28
|
config = Config.read_toml_config_or_exit(config_path)
|
|
22
29
|
testnet = is_testnet_address(config.from_address)
|
|
23
30
|
key = PrivateKeyTestnet(config.private) if testnet else PrivateKey(config.private)
|
|
@@ -25,4 +32,4 @@ def run(config_path: Path) -> None:
|
|
|
25
32
|
outputs = [(o.address, o.amount, "satoshi") for o in config.outputs]
|
|
26
33
|
|
|
27
34
|
tx = key.create_transaction(outputs)
|
|
28
|
-
|
|
35
|
+
print_json(tx)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""CLI handler for the decode-tx command — decodes a raw transaction hex."""
|
|
2
|
+
|
|
3
|
+
from mm_print import print_json
|
|
4
|
+
|
|
5
|
+
from mm_btc.tx import decode_tx
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def run(tx_hex: str, testnet: bool = False) -> None:
|
|
9
|
+
"""Decode and print a raw Bitcoin transaction."""
|
|
10
|
+
res = decode_tx(tx_hex, testnet)
|
|
11
|
+
print_json(res)
|
|
@@ -1,18 +1,24 @@
|
|
|
1
|
+
"""CLI handler for the mnemonic command — generates and displays HD wallet keys."""
|
|
2
|
+
|
|
1
3
|
from dataclasses import dataclass
|
|
2
|
-
from enum import
|
|
4
|
+
from enum import StrEnum
|
|
3
5
|
|
|
4
|
-
import
|
|
6
|
+
from mm_print import print_plain
|
|
5
7
|
|
|
6
8
|
from mm_btc.wallet import AddressType, derive_accounts, generate_mnemonic
|
|
7
9
|
|
|
8
10
|
|
|
9
|
-
class PrivateType(
|
|
11
|
+
class PrivateType(StrEnum):
|
|
12
|
+
"""Output format for private keys."""
|
|
13
|
+
|
|
10
14
|
hex = "hex"
|
|
11
15
|
wif = "wif"
|
|
12
16
|
|
|
13
17
|
|
|
14
18
|
@dataclass
|
|
15
19
|
class Args:
|
|
20
|
+
"""Arguments for the mnemonic command."""
|
|
21
|
+
|
|
16
22
|
mnemonic: str
|
|
17
23
|
passphrase: str
|
|
18
24
|
words: int
|
|
@@ -24,20 +30,22 @@ class Args:
|
|
|
24
30
|
|
|
25
31
|
|
|
26
32
|
def run(args: Args) -> None:
|
|
33
|
+
"""Execute the mnemonic command: generate or use a mnemonic and print derived accounts."""
|
|
27
34
|
mnemonic = args.mnemonic or generate_mnemonic()
|
|
28
35
|
passphrase = args.passphrase
|
|
29
36
|
path = get_derivation_path_prefix(args.path, args.testnet)
|
|
30
37
|
accounts = derive_accounts(mnemonic, passphrase, path, args.address_type, args.limit)
|
|
31
38
|
|
|
32
|
-
|
|
39
|
+
print_plain(f"{mnemonic}")
|
|
33
40
|
if passphrase:
|
|
34
|
-
|
|
41
|
+
print_plain(f"{passphrase}")
|
|
35
42
|
for acc in accounts:
|
|
36
43
|
private = acc.private if args.hex else acc.wif
|
|
37
|
-
|
|
44
|
+
print_plain(f"{acc.path} {acc.address} {private}")
|
|
38
45
|
|
|
39
46
|
|
|
40
47
|
def get_derivation_path_prefix(path: str, testnet: bool) -> str:
|
|
48
|
+
"""Resolve a path alias (bip44, bip84) or custom path to a full derivation path prefix."""
|
|
41
49
|
if path.startswith("m/"):
|
|
42
50
|
return path
|
|
43
51
|
coin = "1" if testnet else "0"
|
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
|
|
1
|
+
"""CLI handler for the utxo command — fetches UTXOs from Blockstream."""
|
|
2
|
+
|
|
3
|
+
from mm_print import print_json
|
|
2
4
|
|
|
3
5
|
from mm_btc.blockstream import BlockstreamClient
|
|
4
6
|
from mm_btc.wallet import is_testnet_address
|
|
5
7
|
|
|
6
8
|
|
|
7
9
|
async def run(address: str) -> None:
|
|
10
|
+
"""Fetch and print UTXOs for an address from the Blockstream API."""
|
|
8
11
|
client = BlockstreamClient(testnet=is_testnet_address(address))
|
|
9
12
|
res = await client.get_utxo(address)
|
|
10
|
-
|
|
13
|
+
print_json(res.value_or_error())
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Bitcoin transaction decoding utilities."""
|
|
2
|
+
|
|
3
|
+
from bitcoinlib.transactions import Transaction
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def decode_tx(tx_hex: str, testnet: bool = False) -> dict[str, object]:
|
|
7
|
+
"""Decode a raw Bitcoin transaction hex string into a dictionary."""
|
|
8
|
+
return Transaction.parse(tx_hex, network="testnet" if testnet else "mainnet").as_dict() # type: ignore[no-any-return] # ty: ignore[unused-ignore-comment] # bitcoinlib has no type stubs, as_dict() returns Any
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
"""Bitcoin wallet operations: mnemonic generation, HD key derivation, and address utilities."""
|
|
2
|
+
|
|
1
3
|
from dataclasses import dataclass
|
|
2
|
-
from enum import
|
|
4
|
+
from enum import StrEnum, unique
|
|
3
5
|
|
|
4
6
|
from hdwallet import HDWallet
|
|
5
7
|
from hdwallet.cryptocurrencies import Bitcoin
|
|
@@ -8,6 +10,7 @@ from hdwallet.hds import BIP32HD
|
|
|
8
10
|
from hdwallet.mnemonics import BIP39Mnemonic
|
|
9
11
|
from mnemonic import Mnemonic
|
|
10
12
|
|
|
13
|
+
# BIP44/BIP84 standard derivation path prefixes for mainnet and testnet.
|
|
11
14
|
BIP44_MAINNET_PATH = "m/44'/0'/0'/0"
|
|
12
15
|
BIP44_TESTNET_PATH = "m/44'/1'/0'/0"
|
|
13
16
|
BIP84_MAINNET_PATH = "m/84'/0'/0'/0"
|
|
@@ -16,6 +19,8 @@ BIP84_TESTNET_PATH = "m/84'/1'/0'/0"
|
|
|
16
19
|
|
|
17
20
|
@dataclass
|
|
18
21
|
class Account:
|
|
22
|
+
"""Derived Bitcoin account with address, private key, WIF, and derivation path."""
|
|
23
|
+
|
|
19
24
|
address: str
|
|
20
25
|
private: str
|
|
21
26
|
wif: str
|
|
@@ -23,7 +28,9 @@ class Account:
|
|
|
23
28
|
|
|
24
29
|
|
|
25
30
|
@unique
|
|
26
|
-
class AddressType(
|
|
31
|
+
class AddressType(StrEnum):
|
|
32
|
+
"""Supported Bitcoin address types."""
|
|
33
|
+
|
|
27
34
|
P2PKH = "P2PKH" # Pay to Public Key Hash
|
|
28
35
|
P2SH = "P2SH" # Pay to Script Hash
|
|
29
36
|
P2TR = "P2TR" # Taproot
|
|
@@ -34,10 +41,12 @@ class AddressType(str, Enum):
|
|
|
34
41
|
|
|
35
42
|
|
|
36
43
|
def generate_mnemonic(language: str = "english", words: int = 12) -> str:
|
|
37
|
-
|
|
44
|
+
"""Generate a BIP39 mnemonic phrase."""
|
|
45
|
+
return Mnemonic(language).generate(strength=mnemonic_words_to_strength(words))
|
|
38
46
|
|
|
39
47
|
|
|
40
48
|
def derive_accounts(mnemonic: str, passphrase: str, path_prefix: str, address_type: AddressType, limit: int) -> list[Account]:
|
|
49
|
+
"""Derive multiple Bitcoin accounts from a mnemonic using HD wallet derivation."""
|
|
41
50
|
coin = Bitcoin
|
|
42
51
|
if path_prefix.startswith(("m/84'/1'", "m/44'/1'")):
|
|
43
52
|
network = coin.NETWORKS.TESTNET
|
|
@@ -56,13 +65,19 @@ def derive_accounts(mnemonic: str, passphrase: str, path_prefix: str, address_ty
|
|
|
56
65
|
wallet.clean_derivation()
|
|
57
66
|
path = f"{path_prefix}/{index_path}"
|
|
58
67
|
w = wallet.from_derivation(derivation=CustomDerivation(path=path))
|
|
59
|
-
|
|
68
|
+
address = w.address(address_type)
|
|
69
|
+
private = w.private_key()
|
|
70
|
+
wif = w.wif()
|
|
71
|
+
if address is None or private is None or wif is None:
|
|
72
|
+
raise ValueError(f"Failed to derive wallet data for path: {path}")
|
|
73
|
+
accounts.append(Account(address=address, private=private, wif=wif, path=path))
|
|
60
74
|
w.clean_derivation()
|
|
61
75
|
|
|
62
76
|
return accounts
|
|
63
77
|
|
|
64
78
|
|
|
65
|
-
def
|
|
79
|
+
def mnemonic_words_to_strength(words: int) -> int:
|
|
80
|
+
"""Convert mnemonic word count to BIP39 entropy strength in bits."""
|
|
66
81
|
if words == 12:
|
|
67
82
|
return 128
|
|
68
83
|
if words == 15:
|
|
@@ -78,4 +93,5 @@ def mnemonic_words_to_strenght(words: int) -> int:
|
|
|
78
93
|
|
|
79
94
|
|
|
80
95
|
def is_testnet_address(address: str) -> bool:
|
|
96
|
+
"""Check if a Bitcoin address belongs to testnet based on its prefix."""
|
|
81
97
|
return address.startswith(("m", "n", "tb1"))
|