commit 0085ae35f9284a072a61f6c1e6c53192d0af990e Author: Maksim Harbacheuski Date: Tue Aug 12 11:18:39 2025 +0300 Init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..666b8fb --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.idea/ +venv/ +wallets/ \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c62fce9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +bit==0.8.0 +pycryptodome==3.23.0 +Requests==2.32.4 +rich==14.1.0 diff --git a/wallet.py b/wallet.py new file mode 100644 index 0000000..c6752fb --- /dev/null +++ b/wallet.py @@ -0,0 +1,233 @@ +import os +import json +import getpass +import gc +from pathlib import Path +from Crypto.Cipher import AES +from Crypto.Protocol.KDF import PBKDF2 +from Crypto.Random import get_random_bytes +from rich import print +from rich.prompt import Prompt +from bit import Key +import requests + +WALLETS_DIR = "wallets" +API_BASE_DEFAULT = "https://blockstream.info/api" + +if not os.path.exists(WALLETS_DIR): + os.makedirs(WALLETS_DIR) + +def encrypt_wallet(secret, password): + salt = get_random_bytes(16) + key = PBKDF2(password, salt, dkLen=32) + cipher = AES.new(key, AES.MODE_GCM) + ciphertext, tag = cipher.encrypt_and_digest(secret.encode()) + return { + 'ciphertext': ciphertext.hex(), + 'salt': salt.hex(), + 'nonce': cipher.nonce.hex(), + 'tag': tag.hex() + } + +def decrypt_wallet(data, password): + salt = bytes.fromhex(data['salt']) + key = PBKDF2(password, salt, dkLen=32) + cipher = AES.new(key, AES.MODE_GCM, nonce=bytes.fromhex(data['nonce'])) + return cipher.decrypt_and_verify(bytes.fromhex(data['ciphertext']), bytes.fromhex(data['tag'])).decode() + + +api_base = API_BASE_DEFAULT +selected_wallet = None + +def connect_to_network(): + global api_base + url = Prompt.ask("[bold cyan]Введите REST-URL узла/провайдера (Blockstream REST по умолчанию)[/]", default=API_BASE_DEFAULT) + + try: + r = requests.get(f"{url.rstrip('/')}/blocks/tip/height", timeout=6) + if r.status_code == 200: + api_base = url.rstrip('/') + print("[green1]Подключено! Используется API:[/]", api_base) + else: + print(f"[red1]Ошибка соединения: HTTP {r.status_code}[/]") + except Exception as e: + print(f"[red1]Ошибка подключения: {e}[/]") + +def create_wallet(): + k = Key() + save_wallet(k) + show_private_key = Prompt.ask("[bold cyan]Показать приватный ключ (WIF)?[/]", choices=["y","n"], default="n") + if show_private_key == 'y': + print(f"[orange1]{k.to_wif()}[/]") + +def import_wallet(): + print("[bold cyan]Введите приватный ключ (WIF): [/]", end='') + wif = getpass.getpass(prompt="") + try: + k = Key(wif) + save_wallet(k) + except Exception as e: + print(f"[red1]Не удалось импортировать ключ: {e}[/]") + +def save_wallet(key_obj): + print("[bold cyan]Введите пароль для шифрования кошелька: [/]", end='') + password = getpass.getpass(prompt="") + encrypted = encrypt_wallet(key_obj.to_wif(), password) + filename = os.path.join(WALLETS_DIR, f"{key_obj.address}.json") + with open(filename, 'w') as f: + json.dump({"address": key_obj.address, "data": encrypted}, f) + print(f"[green1]Кошелек сохранен: {key_obj.address}[/]") + +def select_wallet(): + global selected_wallet + files = os.listdir(WALLETS_DIR) + if not files: + print("[red1]Нет доступных кошельков.[/]") + return + for i, fname in enumerate(files): + print(f"[orange1][{i+1}] {Path(fname).stem}[/]") + try: + choice = int(Prompt.ask("[bold cyan]Выберите номер кошелька[/]")) + except: + print("[red1]Неверный номер.[/]") + return + with open(os.path.join(WALLETS_DIR, files[choice-1])) as f: + selected_wallet = json.load(f) + +def check_password(): + return get_private_key() is not None + +def get_private_key(): + if not selected_wallet: + print("[red1]Кошелек не выбран.[/]") + return None + print("[bold cyan]Введите пароль от кошелька: [/]", end='') + password = getpass.getpass(prompt="") + try: + wif = decrypt_wallet(selected_wallet['data'], password) + del password + return wif + except Exception: + print("[red1]Неверный пароль![/]") + return None + +def delete_wallet(): + global selected_wallet + while not check_password(): + pass + address = Prompt.ask("[red1]При удалении кошелька доступ к средствам будет потерян, введите адрес кошелька для подтверждения операции[/]") + if address != selected_wallet['address']: + print("[red1]Операция отменена[/]") + return + file_path = os.path.join(WALLETS_DIR, selected_wallet['address'] + '.json') + if os.path.exists(file_path): + os.remove(file_path) + selected_wallet = None + print(f"[green1]Кошелек удален[/]") + +def get_balance(): + if not selected_wallet: + print("[red1]Кошелек не выбран.[/]") + return + addr = selected_wallet['address'] + try: + r = requests.get(f"{api_base}/address/{addr}/utxo", timeout=8) + if r.status_code != 200: + print(f"[red1]Ошибка получения UTXO: HTTP {r.status_code}[/]") + return + utxos = r.json() + balance_sats = sum([u['value'] for u in utxos]) + + print(f"[orange1]Баланс: {balance_sats / 1e8} BTC ({balance_sats} сатоши)[/]") + except Exception as e: + print(f"[red1]Ошибка при получении баланса: {e}[/]") + +def send_transaction(): + if not selected_wallet: + print("[red1]Кошелек не выбран.[/]") + return + to = Prompt.ask("[bold cyan]Введите адрес получателя[/]") + amount = float(Prompt.ask("[bold cyan]Введите сумму в BTC[/]")) + fee_per_byte = int(Prompt.ask("[bold cyan]Введите комиссию (sat/byte)[/]", default="10")) + + estimated_size = int(Prompt.ask("[bold cyan]Оценочный размер транзакции в байтах[/]", default="200")) + fee_sats = fee_per_byte * estimated_size + + print(f"[yellow]Ориентировочная комиссия: {fee_sats} сатоши ({fee_sats/1e8} BTC)[/]") + confirm = Prompt.ask("[bold cyan]Отправить транзакцию?[/]", choices=["y","n"], default="n") + if confirm != 'y': + print("[red1]Операция отменена[/]") + return + + wif = None + while not wif: + wif = get_private_key() + + try: + key = Key(wif) + outputs = [(to, amount, 'btc')] + # bit.Key.send автоматически выбирает UTXO и подписывает. + # Параметр fee в send ожидает целую сумму в сатоши (absolute fee) + tx_hash = key.send(outputs, fee=fee_sats) + print(f"[green1]Транзакция отправлена! TXID: {tx_hash}[/]") + except Exception as e: + print(f"[red1]Ошибка при отправке: {e}[/]") + finally: + del wif + gc.collect() + +def main(): + menu = """ + [bold blue]Меню:[/] + a. Подключение к сети + c. Создание кошелька + w. Выбор кошелька + b. Просмотр баланса + s. Отправка средств + i. Импорт существующего кошелька (WIF) + d. Удаление кошелька + q. Выход + """ + print(menu) + while True: + try: + choice = Prompt.ask("[slate_blue1]Выберите действие[/]").lower() + if choice == 'a': + connect_to_network() + elif choice == 'c': + create_wallet() + elif choice == 'w': + select_wallet() + elif choice == 'b': + if selected_wallet: + get_balance() + else: + print("[red1]Необходимо выбрать кошелек[/]") + elif choice == 's': + if selected_wallet: + send_transaction() + else: + print("[red1]Необходимо выбрать кошелек[/]") + elif choice == 'i': + import_wallet() + elif choice == 'd': + if not selected_wallet: + select_wallet() + if selected_wallet: + delete_wallet() + else: + print("[red1]Необходимо выбрать кошелек[/]") + elif choice == 'q': + break + elif choice == "clear": + if os.name == 'nt': + _ = os.system('cls') + else: + _ = os.system('clear') + print(menu) + except KeyboardInterrupt: + print() + continue + +if __name__ == "__main__": + main()