pyrxd 0.2.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.
- pyrxd/__init__.py +82 -0
- pyrxd/aes_cbc.py +30 -0
- pyrxd/base58.py +78 -0
- pyrxd/btc_wallet/__init__.py +28 -0
- pyrxd/btc_wallet/keys.py +334 -0
- pyrxd/btc_wallet/payment.py +267 -0
- pyrxd/btc_wallet/validate.py +57 -0
- pyrxd/constants.py +341 -0
- pyrxd/crypto/__init__.py +0 -0
- pyrxd/curve.py +100 -0
- pyrxd/fee_model.py +16 -0
- pyrxd/fee_models/__init__.py +4 -0
- pyrxd/fee_models/satoshis_per_kilobyte.py +67 -0
- pyrxd/glyph/__init__.py +82 -0
- pyrxd/glyph/builder.py +908 -0
- pyrxd/glyph/creator.py +133 -0
- pyrxd/glyph/dmint.py +1093 -0
- pyrxd/glyph/ft.py +325 -0
- pyrxd/glyph/inspector.py +116 -0
- pyrxd/glyph/payload.py +282 -0
- pyrxd/glyph/scanner.py +179 -0
- pyrxd/glyph/script.py +215 -0
- pyrxd/glyph/types.py +437 -0
- pyrxd/gravity/__init__.py +58 -0
- pyrxd/gravity/artifacts/__init__.py +0 -0
- pyrxd/gravity/artifacts/maker_covenant_6x12_p2wpkh.artifact.json +100 -0
- pyrxd/gravity/artifacts/maker_covenant_flat_12x10_11_12_13_14_p2wpkh.artifact.json +119 -0
- pyrxd/gravity/artifacts/maker_covenant_flat_12x20_sentinel_all.artifact.json +123 -0
- pyrxd/gravity/artifacts/maker_covenant_flat_6x10_11_12_13_14_p2wpkh.artifact.json +95 -0
- pyrxd/gravity/artifacts/maker_covenant_flat_6x12_p2wpkh.artifact.json +91 -0
- pyrxd/gravity/artifacts/maker_covenant_flat_6x13_p2wpkh.artifact.json +95 -0
- pyrxd/gravity/artifacts/maker_covenant_trade.artifact.json +95 -0
- pyrxd/gravity/artifacts/maker_covenant_unified_p2wpkh.artifact.json +119 -0
- pyrxd/gravity/artifacts/maker_offer.artifact.json +52 -0
- pyrxd/gravity/codehash.py +67 -0
- pyrxd/gravity/covenant.py +476 -0
- pyrxd/gravity/maker.py +449 -0
- pyrxd/gravity/trade.py +521 -0
- pyrxd/gravity/transactions.py +820 -0
- pyrxd/gravity/types.py +151 -0
- pyrxd/hash.py +37 -0
- pyrxd/hd/__init__.py +47 -0
- pyrxd/hd/bip32.py +336 -0
- pyrxd/hd/bip39.py +110 -0
- pyrxd/hd/bip44.py +107 -0
- pyrxd/hd/wallet.py +475 -0
- pyrxd/hd/wordlist/chinese_simplified.txt +2048 -0
- pyrxd/hd/wordlist/english.txt +2048 -0
- pyrxd/keys.py +421 -0
- pyrxd/merkle_path.py +341 -0
- pyrxd/network/__init__.py +27 -0
- pyrxd/network/bitcoin.py +868 -0
- pyrxd/network/chaintracker.py +89 -0
- pyrxd/network/electrumx.py +662 -0
- pyrxd/py.typed +0 -0
- pyrxd/script/__init__.py +23 -0
- pyrxd/script/script.py +180 -0
- pyrxd/script/type.py +272 -0
- pyrxd/script/unlocking_template.py +15 -0
- pyrxd/security/__init__.py +56 -0
- pyrxd/security/errors.py +122 -0
- pyrxd/security/rng.py +64 -0
- pyrxd/security/secrets.py +266 -0
- pyrxd/security/types.py +297 -0
- pyrxd/spv/__init__.py +40 -0
- pyrxd/spv/chain.py +70 -0
- pyrxd/spv/merkle.py +153 -0
- pyrxd/spv/payment.py +131 -0
- pyrxd/spv/pow.py +83 -0
- pyrxd/spv/proof.py +214 -0
- pyrxd/spv/witness.py +127 -0
- pyrxd/transaction/__init__.py +13 -0
- pyrxd/transaction/transaction.py +432 -0
- pyrxd/transaction/transaction_input.py +87 -0
- pyrxd/transaction/transaction_output.py +50 -0
- pyrxd/transaction/transaction_preimage.py +238 -0
- pyrxd/utils.py +612 -0
- pyrxd/wallet.py +330 -0
- pyrxd-0.2.0.dist-info/METADATA +201 -0
- pyrxd-0.2.0.dist-info/RECORD +83 -0
- pyrxd-0.2.0.dist-info/WHEEL +4 -0
- pyrxd-0.2.0.dist-info/licenses/LICENSE +202 -0
- pyrxd-0.2.0.dist-info/licenses/NOTICE +11 -0
pyrxd/__init__.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""pyrxd — Python SDK for the Radiant (RXD) blockchain.
|
|
2
|
+
|
|
3
|
+
Provides transaction building, HD wallet, Glyph token protocol (NFT/FT/dMint),
|
|
4
|
+
Gravity cross-chain atomic swaps, SPV verification, and ElectrumX networking.
|
|
5
|
+
|
|
6
|
+
Quickstart::
|
|
7
|
+
|
|
8
|
+
from pyrxd import GlyphBuilder, GlyphMetadata, GlyphProtocol
|
|
9
|
+
from pyrxd import RxdSdkError, ValidationError
|
|
10
|
+
|
|
11
|
+
Subpackages:
|
|
12
|
+
pyrxd.glyph — Glyph token protocol (NFT, FT, dMint, mutable, V2)
|
|
13
|
+
pyrxd.gravity — BTC↔RXD atomic swaps (Gravity protocol)
|
|
14
|
+
pyrxd.security — Typed secrets, error hierarchy, secure RNG
|
|
15
|
+
pyrxd.hd — BIP-32/39/44 HD wallet
|
|
16
|
+
pyrxd.network — ElectrumX client, BTC data sources
|
|
17
|
+
pyrxd.spv — SPV chain/payment verification
|
|
18
|
+
pyrxd.transaction — Transaction building and serialization
|
|
19
|
+
pyrxd.script — Script types and evaluation
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from pyrxd.glyph import (
|
|
24
|
+
GlyphBuilder,
|
|
25
|
+
GlyphInspector,
|
|
26
|
+
GlyphItem,
|
|
27
|
+
GlyphMetadata,
|
|
28
|
+
GlyphProtocol,
|
|
29
|
+
GlyphRef,
|
|
30
|
+
GlyphScanner,
|
|
31
|
+
)
|
|
32
|
+
from pyrxd.gravity import ActiveOffer, GravityMakerSession, GravityOfferParams, GravityTrade
|
|
33
|
+
from pyrxd.hd.bip32 import Xprv, Xpub, ckd, bip32_derive_xprv_from_mnemonic, bip32_derive_xkeys_from_xkey
|
|
34
|
+
from pyrxd.hd.bip39 import mnemonic_from_entropy, seed_from_mnemonic
|
|
35
|
+
from pyrxd.hd.bip44 import bip44_derive_xprv_from_mnemonic
|
|
36
|
+
from pyrxd.hd.wallet import AddressRecord, HdWallet
|
|
37
|
+
from pyrxd.keys import PrivateKey
|
|
38
|
+
from pyrxd.network.electrumx import UtxoRecord, script_hash_for_address
|
|
39
|
+
from pyrxd.security import (
|
|
40
|
+
RxdSdkError,
|
|
41
|
+
ValidationError,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
__version__ = "0.2.0"
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"__version__",
|
|
48
|
+
# Glyph
|
|
49
|
+
"GlyphBuilder",
|
|
50
|
+
"GlyphInspector",
|
|
51
|
+
"GlyphItem",
|
|
52
|
+
"GlyphMetadata",
|
|
53
|
+
"GlyphProtocol",
|
|
54
|
+
"GlyphRef",
|
|
55
|
+
"GlyphScanner",
|
|
56
|
+
# Gravity
|
|
57
|
+
"GravityTrade",
|
|
58
|
+
"GravityMakerSession",
|
|
59
|
+
"GravityOfferParams",
|
|
60
|
+
"ActiveOffer",
|
|
61
|
+
# HD wallet — BIP-32
|
|
62
|
+
"Xprv",
|
|
63
|
+
"Xpub",
|
|
64
|
+
"ckd",
|
|
65
|
+
"bip32_derive_xprv_from_mnemonic",
|
|
66
|
+
"bip32_derive_xkeys_from_xkey",
|
|
67
|
+
# HD wallet — BIP-39
|
|
68
|
+
"mnemonic_from_entropy",
|
|
69
|
+
"seed_from_mnemonic",
|
|
70
|
+
# HD wallet — BIP-44
|
|
71
|
+
"AddressRecord",
|
|
72
|
+
"bip44_derive_xprv_from_mnemonic",
|
|
73
|
+
"HdWallet",
|
|
74
|
+
# Keys
|
|
75
|
+
"PrivateKey",
|
|
76
|
+
# Network utilities
|
|
77
|
+
"UtxoRecord",
|
|
78
|
+
"script_hash_for_address",
|
|
79
|
+
# Errors
|
|
80
|
+
"RxdSdkError",
|
|
81
|
+
"ValidationError",
|
|
82
|
+
]
|
pyrxd/aes_cbc.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from Cryptodome.Cipher import AES
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class InvalidPadding(Exception):
|
|
5
|
+
pass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def append_pkcs7_padding(message: bytes) -> bytes:
|
|
9
|
+
pad = 16 - (len(message) % 16)
|
|
10
|
+
return message + bytes([pad]) * pad
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def strip_pkcs7_padding(message: bytes) -> bytes:
|
|
14
|
+
if len(message) % 16 != 0 or len(message) == 0:
|
|
15
|
+
raise InvalidPadding("invalid length")
|
|
16
|
+
pad = message[-1]
|
|
17
|
+
if not 1 <= pad <= 16:
|
|
18
|
+
raise InvalidPadding("invalid padding byte (out of range)")
|
|
19
|
+
for i in message[-pad:]:
|
|
20
|
+
if i != pad:
|
|
21
|
+
raise InvalidPadding("invalid padding byte (inconsistent)")
|
|
22
|
+
return message[0:-pad]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def aes_encrypt_with_iv(key: bytes, iv: bytes, message: bytes) -> bytes:
|
|
26
|
+
return AES.new(key, AES.MODE_CBC, iv).encrypt(append_pkcs7_padding(message))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def aes_decrypt_with_iv(key: bytes, iv: bytes, message: bytes) -> bytes:
|
|
30
|
+
return strip_pkcs7_padding(AES.new(key, AES.MODE_CBC, iv).decrypt(message))
|
pyrxd/base58.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from .hash import hash256
|
|
2
|
+
|
|
3
|
+
BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _checksum(payload: bytes) -> bytes:
|
|
7
|
+
return hash256(payload)[:4]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def b58_encode(payload: bytes) -> str:
|
|
11
|
+
pad = 0
|
|
12
|
+
for byte in payload:
|
|
13
|
+
if byte == 0:
|
|
14
|
+
pad += 1
|
|
15
|
+
else:
|
|
16
|
+
break
|
|
17
|
+
prefix = "1" * pad
|
|
18
|
+
num = int.from_bytes(payload, "big")
|
|
19
|
+
result = ""
|
|
20
|
+
while num > 0:
|
|
21
|
+
num, remaining = divmod(num, 58)
|
|
22
|
+
result = BASE58_ALPHABET[remaining] + result
|
|
23
|
+
return prefix + result
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def base58check_encode(payload: bytes) -> str:
|
|
27
|
+
return b58_encode(payload + _checksum(payload))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def to_base58check(payload: bytes, prefix: bytes) -> str:
|
|
31
|
+
"""
|
|
32
|
+
Converts a binary array into a base58check string with a checksum
|
|
33
|
+
:param payload: The binary array to convert to base58check
|
|
34
|
+
:param prefix: The prefix to add to the binary
|
|
35
|
+
:return: The base58check string representation
|
|
36
|
+
"""
|
|
37
|
+
return base58check_encode(prefix + payload)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def from_base58check(encoded: str, prefix_len: int = 1) -> (bytes, bytes):
|
|
41
|
+
"""
|
|
42
|
+
Converts a base58check string into payload and prefix
|
|
43
|
+
:param encoded: The base58check string to convert
|
|
44
|
+
:param prefix_len: The byte length of the prefix
|
|
45
|
+
:return: A tuple containing the prefix and the payload
|
|
46
|
+
"""
|
|
47
|
+
payload = base58check_decode(encoded)
|
|
48
|
+
return payload[:prefix_len], payload[prefix_len:]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def b58_decode(encoded: str) -> bytes:
|
|
52
|
+
pad = 0
|
|
53
|
+
for char in encoded:
|
|
54
|
+
if char == "1":
|
|
55
|
+
pad += 1
|
|
56
|
+
else:
|
|
57
|
+
break
|
|
58
|
+
prefix = b"\x00" * pad
|
|
59
|
+
num = 0
|
|
60
|
+
try:
|
|
61
|
+
for char in encoded:
|
|
62
|
+
num *= 58
|
|
63
|
+
num += BASE58_ALPHABET.index(char)
|
|
64
|
+
except Exception:
|
|
65
|
+
raise ValueError(f"invalid base58 encoded {encoded}")
|
|
66
|
+
# if num is 0 then (0).to_bytes will return b''
|
|
67
|
+
return prefix + num.to_bytes((num.bit_length() + 7) // 8, "big")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def base58check_decode(encoded: str) -> bytes:
|
|
71
|
+
decoded = b58_decode(encoded)
|
|
72
|
+
payload = decoded[:-4]
|
|
73
|
+
decoded_checksum = decoded[-4:]
|
|
74
|
+
hash_checksum = _checksum(payload)
|
|
75
|
+
if decoded_checksum != hash_checksum:
|
|
76
|
+
_msg = f"unmatched base58 checksum, expect {decoded_checksum.hex()} but actually {hash_checksum.hex()}"
|
|
77
|
+
raise ValueError(_msg)
|
|
78
|
+
return payload
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Bitcoin wallet tooling for the Gravity Taker.
|
|
2
|
+
|
|
3
|
+
Public API
|
|
4
|
+
----------
|
|
5
|
+
BtcKeypair — keypair with all 4 address formats
|
|
6
|
+
BtcUtxo — UTXO descriptor
|
|
7
|
+
BtcPaymentTx — signed transaction result
|
|
8
|
+
generate_keypair — generate a fresh keypair from CSPRNG
|
|
9
|
+
keypair_from_wif — load keypair from WIF (testing/recovery)
|
|
10
|
+
build_payment_tx — build+sign a 1-input segwit-v0 payment tx
|
|
11
|
+
validate_btc_address — validate a mainnet Bitcoin address string
|
|
12
|
+
validate_satoshis — validate a satoshi amount
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from .keys import BtcKeypair, generate_keypair, keypair_from_wif
|
|
16
|
+
from .payment import BtcPaymentTx, BtcUtxo, build_payment_tx
|
|
17
|
+
from .validate import validate_btc_address, validate_satoshis
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"BtcKeypair",
|
|
21
|
+
"BtcPaymentTx",
|
|
22
|
+
"BtcUtxo",
|
|
23
|
+
"build_payment_tx",
|
|
24
|
+
"generate_keypair",
|
|
25
|
+
"keypair_from_wif",
|
|
26
|
+
"validate_btc_address",
|
|
27
|
+
"validate_satoshis",
|
|
28
|
+
]
|
pyrxd/btc_wallet/keys.py
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"""Bitcoin keypair generation for the Gravity Taker.
|
|
2
|
+
|
|
3
|
+
Supports all 4 address formats required by the Gravity multi-type covenant:
|
|
4
|
+
P2PKH, P2WPKH, P2SH-P2WPKH, P2TR.
|
|
5
|
+
|
|
6
|
+
Networks
|
|
7
|
+
--------
|
|
8
|
+
The ``network`` parameter selects the bech32 HRP and the base58 version bytes
|
|
9
|
+
used for address and WIF serialization:
|
|
10
|
+
|
|
11
|
+
* ``"bc"`` — Bitcoin mainnet (default). bech32 HRP ``bc``, P2PKH version 0x00,
|
|
12
|
+
P2SH version 0x05, WIF version 0x80.
|
|
13
|
+
* ``"tb"`` — Bitcoin testnet3 / signet. bech32 HRP ``tb``, P2PKH version 0x6F,
|
|
14
|
+
P2SH version 0xC4, WIF version 0xEF.
|
|
15
|
+
* ``"bcrt"`` — Bitcoin regtest. bech32 HRP ``bcrt``, base58 versions + WIF
|
|
16
|
+
match testnet.
|
|
17
|
+
|
|
18
|
+
Any other HRP string is accepted (treated as mainnet-equivalent base58
|
|
19
|
+
versions) so that custom / local networks can be used without a code change.
|
|
20
|
+
|
|
21
|
+
Design rules
|
|
22
|
+
------------
|
|
23
|
+
* Private key is stored in PrivateKeyMaterial — never leaks in repr/str.
|
|
24
|
+
* Uses secure_scalar_mod_n() for key generation (CSPRNG + rejection sampling).
|
|
25
|
+
* No assert in src/ — all invariants use explicit raises.
|
|
26
|
+
* unsafe_wif() is named 'unsafe' for grep-in-code-review visibility.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import hashlib
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
|
|
34
|
+
from pyrxd.security.errors import KeyMaterialError, ValidationError
|
|
35
|
+
from pyrxd.security.rng import secure_scalar_mod_n
|
|
36
|
+
from pyrxd.security.secrets import PrivateKeyMaterial
|
|
37
|
+
|
|
38
|
+
__all__ = ["BtcKeypair", "generate_keypair", "keypair_from_wif"]
|
|
39
|
+
|
|
40
|
+
# secp256k1 curve order
|
|
41
|
+
_N = int("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141", 16)
|
|
42
|
+
|
|
43
|
+
# bech32 / bech32m character set
|
|
44
|
+
_BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
|
45
|
+
|
|
46
|
+
# bech32 GF polynomial constants (BIP173)
|
|
47
|
+
_BECH32_GENERATOR = [0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3]
|
|
48
|
+
|
|
49
|
+
# Checksum constants: bech32 uses 1, bech32m uses 0x2BC830A3 (BIP350)
|
|
50
|
+
_BECH32_CONST = 1
|
|
51
|
+
_BECH32M_CONST = 0x2BC830A3
|
|
52
|
+
|
|
53
|
+
# Base58 version bytes + WIF prefix per network.
|
|
54
|
+
# Testnet values per https://en.bitcoin.it/wiki/List_of_address_prefixes.
|
|
55
|
+
# Mainnet: P2PKH 0x00, P2SH 0x05, WIF 0x80
|
|
56
|
+
# Testnet/signet/regtest: P2PKH 0x6F, P2SH 0xC4, WIF 0xEF
|
|
57
|
+
_MAINNET_P2PKH = 0x00
|
|
58
|
+
_MAINNET_P2SH = 0x05
|
|
59
|
+
_MAINNET_WIF = 0x80
|
|
60
|
+
_TESTNET_P2PKH = 0x6F
|
|
61
|
+
_TESTNET_P2SH = 0xC4
|
|
62
|
+
_TESTNET_WIF = 0xEF
|
|
63
|
+
|
|
64
|
+
# Known HRPs for which we use testnet-style base58 version bytes.
|
|
65
|
+
_TESTNET_HRPS = frozenset({"tb", "bcrt"})
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _version_bytes_for(network: str) -> tuple[int, int, int]:
|
|
69
|
+
"""Return (p2pkh_version, p2sh_version, wif_version) for ``network``.
|
|
70
|
+
|
|
71
|
+
Unknown HRPs fall back to mainnet versions; bech32 output is still produced
|
|
72
|
+
with the supplied HRP. This keeps custom/local HRPs usable without a code
|
|
73
|
+
change while giving correct values for the three well-known networks.
|
|
74
|
+
"""
|
|
75
|
+
if network in _TESTNET_HRPS:
|
|
76
|
+
return _TESTNET_P2PKH, _TESTNET_P2SH, _TESTNET_WIF
|
|
77
|
+
return _MAINNET_P2PKH, _MAINNET_P2SH, _MAINNET_WIF
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class BtcKeypair:
|
|
82
|
+
"""A Bitcoin keypair with addresses in all 4 Gravity-supported formats.
|
|
83
|
+
|
|
84
|
+
Private key is stored as PrivateKeyMaterial (never logs/repr leaks).
|
|
85
|
+
|
|
86
|
+
Attributes:
|
|
87
|
+
network: bech32 HRP (``"bc"`` mainnet, ``"tb"`` testnet/signet,
|
|
88
|
+
``"bcrt"`` regtest, or any custom HRP). Defaults to ``"bc"``.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
_privkey: PrivateKeyMaterial # private — use with care
|
|
92
|
+
pubkey_bytes: bytes # 33-byte compressed pubkey
|
|
93
|
+
|
|
94
|
+
# Per-format address info
|
|
95
|
+
p2pkh_address: str
|
|
96
|
+
p2wpkh_address: str
|
|
97
|
+
p2sh_p2wpkh_address: str
|
|
98
|
+
p2tr_address: str
|
|
99
|
+
|
|
100
|
+
# 20-byte hashes (used for P2PKH, P2WPKH, P2SH-P2WPKH covenant params)
|
|
101
|
+
pkh: bytes # RIPEMD160(SHA256(pubkey)) — 20 bytes
|
|
102
|
+
p2sh_hash: bytes # RIPEMD160(SHA256(P2WPKH_redeem)) — 20 bytes
|
|
103
|
+
|
|
104
|
+
# 32-byte x-only tweaked output key (used for P2TR covenant param)
|
|
105
|
+
p2tr_output_key: bytes # 32 bytes
|
|
106
|
+
|
|
107
|
+
# Network / HRP used for all address + WIF serialization.
|
|
108
|
+
network: str = "bc"
|
|
109
|
+
|
|
110
|
+
def __repr__(self) -> str:
|
|
111
|
+
return f"BtcKeypair(p2wpkh={self.p2wpkh_address!r})"
|
|
112
|
+
|
|
113
|
+
def unsafe_wif(self) -> str:
|
|
114
|
+
"""Export WIF. Named 'unsafe' to be visible in code review.
|
|
115
|
+
|
|
116
|
+
Uses the WIF version byte for ``self.network`` (0x80 for mainnet,
|
|
117
|
+
0xEF for testnet/signet/regtest).
|
|
118
|
+
"""
|
|
119
|
+
raw = self._privkey.unsafe_raw_bytes()
|
|
120
|
+
_, _, wif_version = _version_bytes_for(self.network)
|
|
121
|
+
# WIF: version + privkey(32) + 0x01 (compressed) + checksum(4)
|
|
122
|
+
payload = bytes([wif_version]) + raw + b"\x01"
|
|
123
|
+
checksum = hashlib.sha256(hashlib.sha256(payload).digest()).digest()[:4]
|
|
124
|
+
return _base58check_encode(payload + checksum)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def generate_keypair(network: str = "bc") -> BtcKeypair:
|
|
128
|
+
"""Generate a fresh Bitcoin keypair using CSPRNG.
|
|
129
|
+
|
|
130
|
+
Uses secure_scalar_mod_n() for the private key — explicit range check,
|
|
131
|
+
rejection sampling, never Math.random().
|
|
132
|
+
Matches JS btc_wallet.js::generateKeypair() audit-hardened version.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
network: bech32 HRP for address serialization. ``"bc"`` (default) for
|
|
136
|
+
mainnet, ``"tb"`` for testnet/signet, ``"bcrt"`` for regtest, or
|
|
137
|
+
any custom HRP.
|
|
138
|
+
"""
|
|
139
|
+
import coincurve # noqa: PLC0415
|
|
140
|
+
|
|
141
|
+
privkey_material = secure_scalar_mod_n()
|
|
142
|
+
raw = privkey_material.unsafe_raw_bytes()
|
|
143
|
+
privkey_obj = coincurve.PrivateKey(raw)
|
|
144
|
+
pubkey_bytes = privkey_obj.public_key.format(compressed=True) # 33 bytes
|
|
145
|
+
return _build_keypair(privkey_material, pubkey_bytes, network=network)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def keypair_from_wif(wif: str, network: str = "bc") -> BtcKeypair:
|
|
149
|
+
"""Load keypair from WIF string (for testing/recovery).
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
wif: WIF-encoded private key.
|
|
153
|
+
network: bech32 HRP for address serialization (see ``generate_keypair``).
|
|
154
|
+
Note: this controls OUTPUT address/WIF encoding only; the input WIF
|
|
155
|
+
is decoded regardless of its version byte.
|
|
156
|
+
"""
|
|
157
|
+
import coincurve # noqa: PLC0415
|
|
158
|
+
|
|
159
|
+
privkey_material = PrivateKeyMaterial.from_wif(wif)
|
|
160
|
+
raw = privkey_material.unsafe_raw_bytes()
|
|
161
|
+
privkey_obj = coincurve.PrivateKey(raw)
|
|
162
|
+
pubkey_bytes = privkey_obj.public_key.format(compressed=True)
|
|
163
|
+
return _build_keypair(privkey_material, pubkey_bytes, network=network)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _build_keypair(
|
|
167
|
+
privkey_material: PrivateKeyMaterial,
|
|
168
|
+
pubkey_bytes: bytes,
|
|
169
|
+
network: str = "bc",
|
|
170
|
+
) -> BtcKeypair:
|
|
171
|
+
"""Internal: build BtcKeypair from validated privkey + pubkey bytes."""
|
|
172
|
+
if len(pubkey_bytes) != 33:
|
|
173
|
+
raise KeyMaterialError("pubkey_bytes must be 33 bytes (compressed)")
|
|
174
|
+
if not isinstance(network, str) or not network:
|
|
175
|
+
raise ValidationError("network must be a non-empty string")
|
|
176
|
+
|
|
177
|
+
p2pkh_version, p2sh_version, _wif_version = _version_bytes_for(network)
|
|
178
|
+
|
|
179
|
+
# PKH = RIPEMD160(SHA256(pubkey))
|
|
180
|
+
pkh = _hash160(pubkey_bytes)
|
|
181
|
+
|
|
182
|
+
# P2SH-P2WPKH redeem script: OP_0 <20-byte pkh>
|
|
183
|
+
p2wpkh_redeem = b"\x00\x14" + pkh
|
|
184
|
+
p2sh_hash = _hash160(p2wpkh_redeem)
|
|
185
|
+
|
|
186
|
+
# P2TR: BIP341 key-path-only tweak on x-only pubkey
|
|
187
|
+
x_only = pubkey_bytes[1:] # drop parity byte
|
|
188
|
+
p2tr_output_key = _taproot_tweak(x_only)
|
|
189
|
+
|
|
190
|
+
return BtcKeypair(
|
|
191
|
+
_privkey=privkey_material,
|
|
192
|
+
pubkey_bytes=pubkey_bytes,
|
|
193
|
+
pkh=pkh,
|
|
194
|
+
p2sh_hash=p2sh_hash,
|
|
195
|
+
p2tr_output_key=p2tr_output_key,
|
|
196
|
+
p2pkh_address=_p2pkh_address(pkh, p2pkh_version),
|
|
197
|
+
p2wpkh_address=_p2wpkh_address(pkh, network),
|
|
198
|
+
p2sh_p2wpkh_address=_p2sh_address(p2sh_hash, p2sh_version),
|
|
199
|
+
p2tr_address=_p2tr_address(p2tr_output_key, network),
|
|
200
|
+
network=network,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _hash160(data: bytes) -> bytes:
|
|
205
|
+
"""RIPEMD160(SHA256(data)) — Bitcoin's hash160."""
|
|
206
|
+
return hashlib.new("ripemd160", hashlib.sha256(data).digest()).digest()
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _taproot_tweak(x_only_pubkey: bytes) -> bytes:
|
|
210
|
+
"""BIP341 key-path tweak: tagged hash then add tweak*G to pubkey.
|
|
211
|
+
|
|
212
|
+
tagged hash = SHA256(SHA256('TapTweak') || SHA256('TapTweak') || x_only_pubkey)
|
|
213
|
+
"""
|
|
214
|
+
import coincurve # noqa: PLC0415
|
|
215
|
+
|
|
216
|
+
tag = b"TapTweak"
|
|
217
|
+
tag_hash = hashlib.sha256(tag).digest()
|
|
218
|
+
tweak = hashlib.sha256(tag_hash + tag_hash + x_only_pubkey).digest()
|
|
219
|
+
|
|
220
|
+
# Reconstruct compressed pubkey (assume even parity = 0x02 prefix)
|
|
221
|
+
compressed = b"\x02" + x_only_pubkey
|
|
222
|
+
pub = coincurve.PublicKey(compressed)
|
|
223
|
+
# tweaked = pubkey + tweak*G; raises on scalar overflow (prob ~2^-128)
|
|
224
|
+
tweaked = pub.add(tweak)
|
|
225
|
+
tweaked_bytes = tweaked.format(compressed=True)
|
|
226
|
+
return tweaked_bytes[1:] # x-only (32 bytes)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ---------------------------------------------------------------------------
|
|
230
|
+
# Address encoding helpers
|
|
231
|
+
# ---------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _base58check_encode(payload_with_checksum: bytes) -> str:
|
|
235
|
+
"""Base58 encode a payload that already has its 4-byte checksum appended."""
|
|
236
|
+
_B58 = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
|
|
237
|
+
data = payload_with_checksum
|
|
238
|
+
# Count leading zero bytes
|
|
239
|
+
pad = 0
|
|
240
|
+
for b in data:
|
|
241
|
+
if b == 0:
|
|
242
|
+
pad += 1
|
|
243
|
+
else:
|
|
244
|
+
break
|
|
245
|
+
num = int.from_bytes(data, "big")
|
|
246
|
+
result = ""
|
|
247
|
+
while num > 0:
|
|
248
|
+
num, rem = divmod(num, 58)
|
|
249
|
+
result = chr(_B58[rem]) + result
|
|
250
|
+
return "1" * pad + result
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _p2pkh_address(pkh: bytes, version: int = _MAINNET_P2PKH) -> str:
|
|
254
|
+
"""Base58Check P2PKH for the given version byte (mainnet 0x00, testnet 0x6F)."""
|
|
255
|
+
payload = bytes([version]) + pkh
|
|
256
|
+
checksum = hashlib.sha256(hashlib.sha256(payload).digest()).digest()[:4]
|
|
257
|
+
return _base58check_encode(payload + checksum)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _p2wpkh_address(pkh: bytes, hrp: str = "bc") -> str:
|
|
261
|
+
"""P2WPKH bech32 for the given HRP (bc=mainnet, tb=testnet, bcrt=regtest)."""
|
|
262
|
+
return _bech32_encode(hrp, 0, pkh)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _p2sh_address(script_hash: bytes, version: int = _MAINNET_P2SH) -> str:
|
|
266
|
+
"""Base58Check P2SH for the given version byte (mainnet 0x05, testnet 0xC4)."""
|
|
267
|
+
payload = bytes([version]) + script_hash
|
|
268
|
+
checksum = hashlib.sha256(hashlib.sha256(payload).digest()).digest()[:4]
|
|
269
|
+
return _base58check_encode(payload + checksum)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _p2tr_address(output_key_32: bytes, hrp: str = "bc") -> str:
|
|
273
|
+
"""P2TR bech32m for the given HRP (bc=mainnet, tb=testnet, bcrt=regtest)."""
|
|
274
|
+
return _bech32_encode(hrp, 1, output_key_32) # witness version 1 = bech32m
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# ---------------------------------------------------------------------------
|
|
278
|
+
# BIP173/BIP350 bech32/bech32m encode
|
|
279
|
+
# ---------------------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _bech32_polymod(values: list[int]) -> int:
|
|
283
|
+
"""Compute the bech32 checksum polynomial."""
|
|
284
|
+
c = 1
|
|
285
|
+
for v in values:
|
|
286
|
+
c0 = c >> 25
|
|
287
|
+
c = ((c & 0x1FFFFFF) << 5) ^ v
|
|
288
|
+
for i, gen in enumerate(_BECH32_GENERATOR):
|
|
289
|
+
if (c0 >> i) & 1:
|
|
290
|
+
c ^= gen
|
|
291
|
+
return c
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _bech32_hrp_expand(hrp: str) -> list[int]:
|
|
295
|
+
"""Expand the HRP into values for checksum computation."""
|
|
296
|
+
return [ord(x) >> 5 for x in hrp] + [0] + [ord(x) & 31 for x in hrp]
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _bech32_create_checksum(hrp: str, data: list[int], spec: int) -> list[int]:
|
|
300
|
+
"""Compute the 6-character checksum for a bech32/bech32m string."""
|
|
301
|
+
values = _bech32_hrp_expand(hrp) + data
|
|
302
|
+
polymod = _bech32_polymod(values + [0, 0, 0, 0, 0, 0]) ^ spec
|
|
303
|
+
return [(polymod >> (5 * (5 - i))) & 31 for i in range(6)]
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _convertbits(data: bytes, frombits: int, tobits: int) -> list[int]:
|
|
307
|
+
"""Convert between bit groupings (e.g. 8-bit bytes to 5-bit groups)."""
|
|
308
|
+
acc = 0
|
|
309
|
+
bits = 0
|
|
310
|
+
ret = []
|
|
311
|
+
maxv = (1 << tobits) - 1
|
|
312
|
+
for value in data:
|
|
313
|
+
acc = ((acc << frombits) | value) & 0x3FFFFFFF
|
|
314
|
+
bits += frombits
|
|
315
|
+
while bits >= tobits:
|
|
316
|
+
bits -= tobits
|
|
317
|
+
ret.append((acc >> bits) & maxv)
|
|
318
|
+
if bits:
|
|
319
|
+
ret.append((acc << (tobits - bits)) & maxv)
|
|
320
|
+
return ret
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _bech32_encode(hrp: str, witness_version: int, witness_program: bytes) -> str:
|
|
324
|
+
"""Encode a Bitcoin native SegWit address.
|
|
325
|
+
|
|
326
|
+
Uses bech32 for witness version 0 (BIP173) and bech32m for version 1+
|
|
327
|
+
(BIP350). On mainnet the encoded address starts with ``bc1q`` (v0) or
|
|
328
|
+
``bc1p`` (v1); on testnet ``tb1q`` / ``tb1p``.
|
|
329
|
+
"""
|
|
330
|
+
spec = _BECH32M_CONST if witness_version > 0 else _BECH32_CONST
|
|
331
|
+
data = _convertbits(witness_program, 8, 5)
|
|
332
|
+
combined = [witness_version] + data
|
|
333
|
+
checksum = _bech32_create_checksum(hrp, combined, spec)
|
|
334
|
+
return hrp + "1" + "".join(_BECH32_CHARSET[d] for d in combined + checksum)
|