Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
c1c0239
Add PSBT (BIP-174) support
JAGADISHSUNILPEDNEKAR Mar 8, 2025
45e90d9
Merge branch 'master' into add-psbt-support
JAGADISHSUNILPEDNEKAR Mar 8, 2025
0b60d97
Address maintainer feedback: clean init.py, revert block.py changes, …
JAGADISHSUNILPEDNEKAR Mar 18, 2025
8e22601
Address PR feedback: Clean up code, revert block.py, add testing docs…
JAGADISHSUNILPEDNEKAR Mar 18, 2025
f9ef8f8
Remove unnecessary files as requested
JAGADISHSUNILPEDNEKAR Mar 19, 2025
0a6cc39
Focus PR on PSBT implementation only, revert unrelated changes
JAGADISHSUNILPEDNEKAR Mar 19, 2025
f1ba68a
Focus PR on PSBT implementation only, reverted unrelated changes
JAGADISHSUNILPEDNEKAR Mar 19, 2025
eb02f2f
Focus PR on PSBT implementation only, reverted many more unrelated c…
JAGADISHSUNILPEDNEKAR Mar 19, 2025
6d1e3ef
Simplify PR to focus on PSBT: remove unnecessary files, revert testin…
JAGADISHSUNILPEDNEKAR Mar 19, 2025
2d2e9b3
Focus PR on PSBT implementation only: keep only necessary changes for…
JAGADISHSUNILPEDNEKAR Mar 19, 2025
9c7e180
Fix test issues with transaction serialization for PSBT implementation
JAGADISHSUNILPEDNEKAR Mar 20, 2025
34b7ea3
Remove non-PSBT related files from PR
JAGADISHSUNILPEDNEKAR Mar 20, 2025
1da9864
Remove remaining non-PSBT files and make PSBT implementation self-con…
JAGADISHSUNILPEDNEKAR Mar 20, 2025
3065398
Removed extended test files and verified all tests pass
JAGADISHSUNILPEDNEKAR Mar 22, 2025
2586a01
Kept the codebase clean
JAGADISHSUNILPEDNEKAR Mar 24, 2025
76fb5f2
Removed pycache files
JAGADISHSUNILPEDNEKAR Mar 24, 2025
5fb3579
Created a file cleantree.sh to show the project root without the byt…
JAGADISHSUNILPEDNEKAR Mar 24, 2025
8750a7e
Removed TEST_SCRIPT.PY
JAGADISHSUNILPEDNEKAR Mar 24, 2025
7684e8c
Added PSBT documenation and modified Readme
JAGADISHSUNILPEDNEKAR Mar 25, 2025
993cdd9
Made improvements such as added actual cryptographic operations
JAGADISHSUNILPEDNEKAR Mar 26, 2025
55773ef
Made some improvements to the code
JAGADISHSUNILPEDNEKAR Mar 27, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 44 additions & 10 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,12 @@ parts/
sdist/
var/
wheels/
.idea/
*.egg-info/
.installed.cfg
*.egg
MANIFEST

# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec

Expand All @@ -39,6 +36,7 @@ pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
Expand All @@ -52,32 +50,39 @@ coverage.xml
*.mo
*.pot

# Django stuff:
# Django stuff
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal

# Flask stuff:
# Flask stuff
instance/
.webassets-cache

# Scrapy stuff:
# Scrapy stuff
.scrapy

# Sphinx documentation
docs/_build/
docs/_generated/

# PyBuilder
target/

# Jupyter Notebook
.ipynb_checkpoints

# IPython
profile_default/
ipython_config.py

# pyenv
.python-version

# celery beat schedule file
# celery
celerybeat-schedule
celerybeat.pid

# SageMath parsed files
*.sage.py
Expand All @@ -104,9 +109,38 @@ venv.bak/

# mypy
.mypy_cache/
.dmypy.json
dmypy.json

# visual studio code
.vscode
### IDE/Editors ###
# Visual Studio Code
.vscode/
*.code-workspace

# vim
# JetBrains IDEs
.idea/
*.iml
*.iws
*.ipr
.idea_modules/

# Vim
*.swp
*.swo
.*.sw*

### macOS ###
# General
.DS_Store
.AppleDouble
.LSOverride
._*

### Bitcoin specific ###
# Block data
*.dat
*.blk

# Wallet files
wallet.dat
*.wallet
22 changes: 21 additions & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -129,11 +129,31 @@ https://github.com/karask/python-bitcoin-utils/blob/master/examples/send_to_p2tr
Spend taproot from script path (has three alternative script path spends - A, B and C)
https://github.com/karask/python-bitcoin-utils/blob/master/examples/spend_p2tr_three_scripts_by_script_path.py - single input, single output, spend script path B.

Partially Signed Bitcoin Transactions (PSBT)
--------------------------------------------

The library now supports BIP-174 Partially Signed Bitcoin Transactions (PSBT), which enables secure, flexible transaction construction and signing across multiple devices or parties.

Creating a PSBT
https://github.com/karask/python-bitcoin-utils/blob/master/examples/create_psbt.py - creates a PSBT from an unsigned transaction and adds UTXO information.

Signing a PSBT
https://github.com/karask/python-bitcoin-utils/blob/master/examples/sign_psbt.py - signs a PSBT with a private key.

Combining PSBTs
https://github.com/karask/python-bitcoin-utils/blob/master/examples/combine_psbt.py - combines PSBTs signed by different parties.

Finalizing and Extracting a Transaction
https://github.com/karask/python-bitcoin-utils/blob/master/examples/finalize_psbt.py - finalizes a PSBT and extracts the final transaction.

Multisignature Wallet with PSBT
https://github.com/karask/python-bitcoin-utils/blob/master/examples/psbt_multisig_wallet.py - demonstrates a complete multisignature workflow using PSBTs.

Other
-----

Use NodeProxy to make calls to a Bitcoin node
https://github.com/karask/python-bitcoin-utils/blob/master/examples/node_proxy.py - make Bitcoin command-line interface calls programmatically (NodeProxy wraps jsonrpc-requests library)


Please explore the codebase or the API documentation (BitcoinUtilities.pdf) for supported functionality and other options.
Please explore the codebase or the API documentation (BitcoinUtilities.pdf) for supported functionality and other options.
143 changes: 138 additions & 5 deletions bitcoinutils/keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,117 @@ def sign_message(self, message: str, compressed: bool = True) -> Optional[str]:

return None

def sign(self, message, k=None):
"""Signs a message with the private key (deterministically using RFC 6979)

Parameters
----------
message : bytes or str
The message to sign
k : int, optional
Optional nonce value for testing or custom ECDSA signature generation

Returns
-------
bytes
The DER-encoded signature
"""
# Convert message to bytes if it's not already
if not isinstance(message, bytes):
if isinstance(message, str):
message = message.encode('utf-8')
else:
message = bytes(message)

# Hash the message if it's not already a 32-byte hash
if len(message) != 32:
message_digest = hashlib.sha256(message).digest()
else:
message_digest = message

# Deterministic k generation following RFC 6979
if k is None:
# Get private key as bytes
private_key_bytes = self.to_bytes()

# Initialize RFC 6979 variables
import hmac
v = b'\x01' * 32
k_hmac = b'\x00' * 32

# Initial k calculation
k_hmac = hmac.new(k_hmac, v + b'\x00' + private_key_bytes + message_digest, hashlib.sha256).digest()
v = hmac.new(k_hmac, v, hashlib.sha256).digest()
k_hmac = hmac.new(k_hmac, v + b'\x01' + private_key_bytes + message_digest, hashlib.sha256).digest()
v = hmac.new(k_hmac, v, hashlib.sha256).digest()

# Generate k value until we find one in the valid range
while True:
v = hmac.new(k_hmac, v, hashlib.sha256).digest()
k_int = int.from_bytes(v, byteorder='big')

if 1 <= k_int < Secp256k1Params._order:
# Valid k found
k = k_int
break

# Try again with a different v
k_hmac = hmac.new(k_hmac, v + b'\x00', hashlib.sha256).digest()
v = hmac.new(k_hmac, v, hashlib.sha256).digest()

# Sign the message with custom or deterministic k
signature = self.key.sign_digest(
message_digest,
sigencode=sigencode_der,
k=k
)

# Ensure Low S value for standardness (BIP 62)
# Extract R and S from the DER signature
r_pos = 4 # Position after DER header and R length
r_len = signature[3]
s_pos = r_pos + r_len + 2 # Position of S value
s_len = signature[r_pos + r_len + 1]
s_value = int.from_bytes(signature[s_pos:s_pos+s_len], byteorder='big')

# Check if S is greater than half the curve order
half_order = Secp256k1Params._order // 2
if s_value > half_order:
# Convert to low S value
s_value = Secp256k1Params._order - s_value
s_bytes = s_value.to_bytes(32, byteorder='big')

# Remove any leading zeros to match DER encoding rules
while s_bytes[0] == 0 and len(s_bytes) > 1:
s_bytes = s_bytes[1:]

# If high bit is set, prepend a zero byte
if s_bytes[0] & 0x80:
s_bytes = b'\x00' + s_bytes

# Reconstruct the signature with the new S value
new_s_len = len(s_bytes)

# Calculate total length for DER encoding
total_len = r_len + new_s_len + 4
if total_len > 255:
total_len = 255 # Limit to maximum DER length

# Construct new signature
new_sig = bytearray()
new_sig.append(0x30) # DER sequence
new_sig.append(total_len - 2) # Sequence length
new_sig.append(0x02) # Integer type
new_sig.append(r_len) # R length
new_sig.extend(signature[r_pos:r_pos+r_len]) # R value
new_sig.append(0x02) # Integer type
new_sig.append(new_s_len) # S length
new_sig.extend(s_bytes) # S value

signature = bytes(new_sig)

return signature

def sign_input(
self, tx: Transaction, txin_index: int, script: Script, sighash=SIGHASH_ALL
):
Expand Down Expand Up @@ -831,10 +942,11 @@ def to_hash160(self, compressed: bool = True) -> str:

def get_address(self, compressed: bool = True) -> P2pkhAddress:
"""Returns the corresponding P2PKH Address (default compressed)"""

hash160 = self._to_hash160(compressed)
addr_string_hex = b_to_h(hash160)
return P2pkhAddress(hash160=addr_string_hex)

# Directly create the address using from_hash160 class method
return P2pkhAddress.from_hash160(addr_string_hex)

def get_segwit_address(self) -> P2wpkhAddress:
"""Returns the corresponding P2WPKH address
Expand Down Expand Up @@ -1086,9 +1198,24 @@ class P2pkhAddress(Address):
"""

def __init__(
self, address: Optional[str] = None, hash160: Optional[str] = None
self,
address: Optional[str] = None,
hash160: Optional[str] = None,
script: Optional[Script] = None,
) -> None:
super().__init__(address=address, hash160=hash160)
# Call the parent class initializer with all the expected parameters
super().__init__(address=address, hash160=hash160, script=script)

@classmethod
def from_hash160(cls, hash160: str) -> 'P2pkhAddress':
"""Creates a P2pkhAddress from a hash160 hex string"""
return cls(hash160=hash160)

# Added for PSBT support
@classmethod
def from_public_key(cls, pubkey):
"""Backward compatibility method to create P2pkhAddress from public key."""
return pubkey.get_address()

def to_script_pub_key(self) -> Script:
"""Returns the scriptPubKey (P2PKH) that corresponds to this address"""
Expand Down Expand Up @@ -1323,6 +1450,12 @@ def get_type(self) -> str:
"""Returns the type of address"""
return self.version

# Added for PSBT support
@classmethod
def from_public_key(cls, pubkey):
"""Backward compatibility method to create P2wpkhAddress from public key."""
return pubkey.get_segwit_address()


class P2wshAddress(SegwitAddress):
"""Encapsulates a P2WSH address.
Expand Down Expand Up @@ -1409,4 +1542,4 @@ def main():


if __name__ == "__main__":
main()
main()
Loading