Введение.
В данной статье будет показано, как написать свой простейший холодный кошелёк для монеты Dash на языке Python.Почему я решил написать подобный софт?
Достаточно давно я видел тему на форуме, где обсуждали безопасные кошельки, и один из пользователей предлагал устроить конкурс на создание своего кошелька. Конкурс такой, конечно, вряд ли будет, но идею я запомнил, а также то, что людям это, возможно, будет интересно.Ссылка на тему — Безопасный криптокошелек | xss.pro (ex DaMaGeLaB)
P.S. Кошелёк будет максимально простым, без удобного интерфейса, но основной функционал он будет выполнять.
Какой функционал будет в кошельке?
- Создание кошелька с авто-генерацией мнемонической фразы
- Создание кошелька на основе вашей мнемонической фразы
- Хранение всех данных кошелька в виде файла на компьютере
- Шифрование данных кошелька внутри файла
- Создание и отправка транзакций с авто-расчётом минимальной комиссии
- Отображение общего баланса всех адресов и отображение баланса для конкретного адреса
- Выбор подключения к собственной ноде
Какие данные будут храниться в кошельке?
- Мнемоническая фраза
- 25 адресов для приёма монет
- 25 адресов для сдачи от транзакций
- Приватные ключи от всех адресов
Как будут отправляться транзакции в сеть?
Для создания и отправки транзакций будут использоваться RPC-команды к ноде. Ноду можно будет выбрать самостоятельно, то есть свою собственную или любую бесплатную из открытого доступа — решать вам.В чем цель статьи?
Мне кажется, идея написать собственный холодный кошелек достаточно интересна, и, возможно, у кого-то было желание реализовать подобное, но это показалось слишком сложной задачей. Целью статьи является показать, что написать подобный софт не так уж и сложно, если речь идет о базовых функциях.P.S. Более интересные вещи, например, миксер, реализовать будет так же несложно, если у вас есть собственная нода и в качестве монеты вы используете Dash, так как в нем по умолчанию есть возможность миксации. Потребуется лишь отправлять RPC-команды на ноду, и она сделает все за вас.
Подготовка проекта.
Данную статью я хочу сделать максимально простой в освоении, даже для тех, кто не особо знаком с программированием. Поэтому инструкции будут достаточно подробными и, возможно, в какой-то мере избыточными для тех, кто прекрасно знает Python.Установка Python и IDE.
Первое, что нужно сделать, — это, конечно же, установить сам Python версии 3.12.Скачать его можно по этой ссылке - Python Release Python 3.12.0 | Python.org
В качестве IDE для разработки был выбран PyCharm Community.
Скачать его можно по этой ссылке - https://www.jetbrains.com/pycharm/download/
Подготовка файлов проекта.
Теперь создадим папку с проектом и заранее подготовленными файлами.В главной папке проекта должно быть 3 файла и две папки:
Файлы:
- main.py (Весь бэкенд проекта)
- config.json (В нем будут храниться некоторые настройки приложения)
- node_settings.txt (Список адресов к нодам, на которые будут отправляться RPC команды)
Папки:
- templates (В ней будут находиться HTML файлы веб-интерфейса кошелька)
- wallets (В ней будут храниться зашифрованные файлы кошельков)
Файлы в папке templates:
- index.html (Начальная страница, на которой будет находиться выбор: создать кошелек или импортировать)
- create_wallet.html (Страница создания кошелька, на ней будет находиться выбор: генерировать мнемоническую фразу или использовать свою)
- auth.html (Страница авторизации в кошельке, на ней будут поля для выбора кошелька и ввода пароля к нему)
- enter_mnemonic.html (Страница для ввода мнемонической фразы при создании кошелька)
- enter_password.html (Страница для указания названия кошелька и назначения ему пароля при создании)
- profile.html (Страница с интерфейсом управления кошельком, то есть отображение адресов, выбор ноды, создание транзакции и т.д.)
- transaction_result.html (Страница, на которой будет отображаться информация о созданной транзакции)
Настройка проекта в PyCharm.
Когда все файлы созданы, в главной папке проекта нажмите ПКМ и выберите "Open Folder as PyCharm".После этого весь проект откроется в IDE, и теперь нужно будет настроить venv (виртуальная среда).
В venv будут храниться все библиотеки, используемые проектом. Можно обойтись и без него, но в таком случае все библиотеки будут храниться глубоко в папке appdata, а не в папке с проектом.
После настройки venv сразу же установим все необходимые библиотеки.
Код:
asn1crypto==1.5.1
base58==2.1.1
bip-utils==2.9.3
bitcoinlib==0.7.2
blinker==1.9.0
cbor2==5.6.5
certifi==2025.1.31
cffi==1.17.1
charset-normalizer==3.4.1
click==8.1.8
coincurve==20.0.0
colorama==0.4.6
crcmod==1.7
cryptography==44.0.0
ecdsa==0.19.0
ed25519-blake2b==1.4.1
Flask==3.1.0
Flask-SQLAlchemy==3.1.1
greenlet==3.1.1
idna==3.10
itsdangerous==2.2.0
Jinja2==3.1.5
MarkupSafe==3.0.2
numpy==2.2.2
py-sr25519-bindings==0.2.1
pycparser==2.22
pycryptodome==3.21.0
pycryptodomex==3.21.0
PyNaCl==1.5.0
requests==2.32.3
six==1.17.0
SQLAlchemy==2.0.38
typing_extensions==4.12.2
urllib3==2.3.0
Werkzeug==3.1.3
Для библиотеки bitcoinlib потребуется также установить Microsoft C++ Build Tools. - Microsoft C++ Build Tools - Visual Studio
Начало написания проекта.
Теперь приступим к написанию самого кода.Конфиг файл.
Первым делом будет показан конфигурационный файл:
JSON:
{
"depth": 25,
"wallets_dir": "wallets",
"node_file": "node_settings.txt"
}
- depth (Количество генерируемых адресов и приватных ключей: для принятия монет и для сдачи)
- wallets_dir (Директория, в которой хранятся файлы кошельков)
- node_file (Текстовый файл со списком адресов к нодам)
Импорты.
Теперь нужно рассмотреть бэкенд-часть в файле main.py.Первым делом пропишем импорты всех библиотек.
Python:
import json
import os
import base58
import hashlib
import base64
from flask import Flask, render_template, request, redirect, url_for, flash, jsonify, session
from bip_utils import (
Bip39MnemonicGenerator, Bip39SeedGenerator, Bip44, Bip44Coins, Bip44Changes
)
from Cryptodome.Cipher import AES
from Cryptodome.Random import get_random_bytes
from Cryptodome.Protocol.KDF import scrypt
import requests
Инициализация Flask и конфига.
Затем инициализируем Flask-приложение (Flask будет использоваться для веб-интерфейса) и получим данные из конфига.
Python:
app = Flask(__name__)
app.secret_key = os.urandom(24)
# Открытие и загрузка данных из конфига в переменные
with open("config.json", "r") as f:
CONFIG = json.load(f)
depth = CONFIG['depth']
node_file = CONFIG['node_file']
wallets_dir = CONFIG['wallets_dir']
if __name__ == '__main__':
app.run(debug=True)
Создание кошелька с сгенерированной мнемонической фразой.
Первое, что будет показано, — это создание кошелька на основе сгенерированной мнемонической фразы.Рассмотрим веб-страницу, где будет выбор для создания кошелька на основе сгенерированной мнемонической фразы (create_wallet.html):
HTML:
<!DOCTYPE html>
<html>
<head>
<title>Создание кошелька</title>
</head>
<body>
<h1>Выберите способ создания:</h1>
<form method="post" action="/generate"><!--Отправка POST запроса на маршрут /generate-->
<button type="submit">Сгенерировать мнемонику</button>
</form>
<form action="/enter_mnemonic"> <!--Вызов маршрута /enter_mnemonic-->
<button type="submit">Использовать свою мнемонику</button>
</form>
</body>
</html>
Рассмотрим же этот маршрут в Python коде:
Python:
@app.route('/generate', methods=['POST'])
def generate_mnemonic():
return render_template(
'enter_password.html',
mnemonic=Bip39MnemonicGenerator().FromWordsNumber(12).ToStr()
)
enter_password.html:
HTML:
<!DOCTYPE html>
<html>
<head>
<title>Защита паролем</title>
</head>
<body>
<h1>Установите пароль для шифрования</h1>
<form method="post" action="/encrypt">
<input type="hidden" name="mnemonic" value="{{ mnemonic }}">
<label for="wallet_name">Имя файла кошелька:</label>
<input type="text" name="wallet_name" id="wallet_name" value="{{ wallet_name }}" required><br>
<input type="password" name="password" placeholder="Пароль" required><br>
<button type="submit">Сохранить кошелек</button>
</form>
</body>
</html>
Основная функция создания файла кошелька.
Маршрут /encrypt:
Python:
@app.route('/encrypt', methods=['POST'])
def encrypt_wallet():
try:
mnemonic = request.form['mnemonic']
password = request.form['password']
filename = validate_filename(request.form['wallet_name'])
filepath = os.path.join(wallets_dir, filename)
if os.path.exists(filepath):
raise ValueError("Файл уже существует")
wallets = DashWalletManager.generate_wallet(mnemonic)
# Сбор всех данных в одну переменную, чтобы потом зашифровать все эти данные
data = f"Mnemonic: {mnemonic}\n\n=== Receiving ===\n"
data += "\n".join(f"Address: {a}\nPrivate: {p}\n" for a, p in wallets['receiving'])
data += "\n=== Change ===\n"
data += "\n".join(f"Address: {a}\nPrivate: {p}\n" for a, p in wallets['change'])
with open(filepath, 'w', encoding='utf-8') as f:
f.write(CryptoUtils.encrypt(data, password))
flash(f"Кошелек {filename} успешно создан", "success")
# При успешном создании кошелька вызывается маршрут auth
return redirect(url_for('auth'))
except Exception as e:
return redirect(url_for('enter_password')) # Перенаправляем на обработку GET
wallets = DashWalletManager.generate_wallet(mnemonic)Затем адреса, ключи и мнемоническая фраза записываются в TXT файл и шифруются с помощью другой функции.
with open(filepath, 'w', encoding='utf-8') as f: f.write(CryptoUtils.encrypt(data, password))При успешном выполнении функции происходит редирект на маршрут auth.
Функция генерации адресов и приватных ключей:
Python:
def generate_wallet(mnemonic: str) -> dict:
# В данном коде генерируются адреса и приватные ключи
wallets = {'receiving': [], 'change': []}
try:
# Получение seed на основе мнемонической фразы
seed = Bip39SeedGenerator(mnemonic).Generate()
# Получение мастер ключа
master = Bip44.FromSeed(seed, Bip44Coins.DASH)
def generate_addresses(change_type: Bip44Changes) -> list:
return [
(
derived.PublicKey().ToAddress(),
DashWalletManager.private_to_wif(derived.PrivateKey().Raw().ToHex())
)
for idx in range(depth)
for derived in [master.Purpose().Coin().Account(0).Change(change_type).AddressIndex(idx)]
]
wallets['receiving'] = generate_addresses(Bip44Changes.CHAIN_EXT)
wallets['change'] = generate_addresses(Bip44Changes.CHAIN_INT)
except Exception as e:
print(f"Ошибка генерации кошелька: {e}")
return wallets
- wallets = {'receiving': [], 'change': []} — это создание словаря с двумя ключами, в одном из которых будут приватные ключи и адреса для принятия монет, а в другом — для сдачи.
- seed = Bip39SeedGenerator(mnemonic).Generate() нужен для генерации seed из мнемонической фразы (seed — байтовое представление мнемонической фразы).
- master = Bip44.FromSeed(seed, Bip44Coins.DASH) — получение мастер-ключа (мастер-ключ — это главный ключ мнемонической фразы, из которого генерируются адреса всех типов и приватные ключи).
Генерация адресов и приватных ключей.
Приватные ключи конвертируются в формат WIF при вызове функции private_to_wif.
Python:
def generate_addresses(change_type: Bip44Changes) -> list:
return [
(
derived.PublicKey().ToAddress(),
DashWalletManager.private_to_wif(derived.PrivateKey().Raw().ToHex())
)
for idx in range(depth)
for derived in [master.Purpose().Coin().Account(0).Change(change_type).AddressIndex(idx)]
]
Перебирает числа от 0 до 24:
for idx in range(depth)P.S. depth — это значение, указанное в конфиге, именно столько адресов будет создано.
Деривационный путь, в котором указывается значение из idx, показанное выше.
[master.Purpose().Coin().Account(0).Change(change_type).AddressIndex(idx)] ]wallets['receiving'] = generate_addresses(Bip44Changes.CHAIN_EXT)wallets['change'] = generate_addresses(Bip44Changes.CHAIN_INT)Вызов функции, показанной выше, для генерации адресов и приватных ключей типа для принятия монет и типа для сдачи. Затем запись полученных данных в соответствующие ключи внутри словаря, созданного в начале функции generate_wallet.
Функция конвертации приватного ключа в формат WIF:
Python:
def private_to_wif(private_key_hex: str) -> str:
raw_key = bytes.fromhex(private_key_hex)
key_suffix = raw_key + b'\x01'
prefixed = b'\xcc' + key_suffix
first_hash = hashlib.sha256(prefixed).digest()
checksum = hashlib.sha256(first_hash).digest()[:4]
return base58.b58encode(prefixed + checksum).decode()
- Конвертация приватного ключа из hex в байты.
- В конец конвертированного в байты ключа добавляется префикс \x01, обозначающий, что ключ будет компрессированным.
- Добавление префикса в начале ключа. Префикс означает, что ключ будет для монеты Dash.
- Хеширование и повторное хеширование первого хеша.
- Формирование полного payload путем добавления 4 байтов от хеша в конец ключа.
- Кодирование в Base58.
- Конвертация в строку.
- Возвращение ответа с готовым ключом в функцию, которая вызвала private_to_wif.
Шифрование данных кошелька.
Функция encrypt, в которой будут шифроваться полученные данные по типу адресов и приватных ключей:
Python:
def encrypt(data: str, password: str) -> str:
# Для шифрования используется AES GCM, точно так же как и в большинстве холодных кошельков
salt = get_random_bytes(16)
key = scrypt(password.encode(), salt, key_len=32, N=2 ** 20, r=8, p=1)
nonce = get_random_bytes(12)
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
ciphertext, tag = cipher.encrypt_and_digest(data.encode())
return base64.b64encode(salt + nonce + ciphertext + tag).decode('utf-8')
После записи зашифрованных данных в файл кошелька в функции encrypt_wallet происходит редирект на маршрут auth.
Python:
@app.route('/auth')
def auth():
return render_template(
'auth.html',
wallet_files=[f for f in os.listdir(wallets_dir) if f.endswith('.txt')]
)
Авторизация в кошельке.
Страница auth.html:
HTML:
<!DOCTYPE html>
<html>
<head>
<title>Авторизация</title>
</head>
<body>
<h1>Импорт кошелька</h1>
<form method="post" action="/login">
<label for="wallet_file">Выберите файл кошелька:</label>
<select name="wallet_file" id="wallet_file" required>
{% for file in wallet_files %}
<option value="{{ file }}">{{ file }}</option>
{% endfor %}
</select><br>
<input type="password" name="password" placeholder="Пароль" required><br>
<button type="submit">Открыть кошелек</button>
</form>
</body>
</html>
Функция авторизации в кошельке:
Python:
@app.route('/login', methods=['POST'])
def login():
try:
# Берет данные из формы
filename = request.form['wallet_file']
password = request.form['password']
filepath = os.path.join(wallets_dir, filename)
if not os.path.exists(filepath):
raise FileNotFoundError("Файл кошелька не найден")
# Открытие файла кошелька и его расшифровка с помощью функции decrypt в классе CryptoUtils
with open(filepath, 'r', encoding='utf-8') as f:
data = CryptoUtils.decrypt(f.read(), password)
parts = data.split('\n')
wallets = {'receiving': [], 'change': []}
current_section = None
# Разбитие данных из файла на переменные
for line in parts[2:]:
line = line.strip()
if line == '=== Receiving ===':
current_section = 'receiving'
elif line == '=== Change ===':
current_section = 'change'
elif line.startswith('Address:'):
addr = line.split(': ')[1]
elif line.startswith('Private:'):
priv = line.split(': ')[1]
if current_section:
wallets[current_section].append((addr, priv))
return render_template(
'profile.html',
mnemonic=parts[0].split(': ')[1],
receiving=wallets['receiving'],
change=wallets['change'],
nodes=load_nodes()
)
except Exception as e:
flash(str(e), "error")
return redirect(url_for('auth'))
with open(filepath, 'r', encoding='utf-8') as f: data = CryptoUtils.decrypt(f.read(), password)В ней происходит открытие файла кошелька и его расшифровка с помощью функции decrypt в классе CryptoUtils.
Функция расшифровки:
Python:
def decrypt(encrypted_data: str, password: str) -> str:
encrypted_bytes = base64.b64decode(encrypted_data)
salt = encrypted_bytes[:16]
nonce = encrypted_bytes[16:28]
ciphertext = encrypted_bytes[28:-16]
tag = encrypted_bytes[-16:]
key = scrypt(password.encode(), salt, key_len=32, N=2 ** 20, r=8, p=1)
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
try:
return cipher.decrypt_and_verify(ciphertext, tag).decode('utf-8')
except ValueError:
raise ValueError("Неверный пароль или поврежденные данные")
После расшифровки чистые данные передаются обратно в функцию login, где разбиваются на ключи внутри словаря, который в дальнейшем передается на страницу profile.html.
Панель управления кошельком.
Страница profile.html:
HTML:
<!DOCTYPE html>
<html>
<head>
<title>Профиль кошелька</title>
</head>
<body>
<h1>Ваш кошелек Dash</h1>
<div class="mnemonic">
<h3>Мнемоническая фраза:</h3>
<p>{{ mnemonic }}</p>
</div>
<div class="section">
<h2>Адреса для получения средств</h2>
<table>
<tr><th>Адрес</th><th>Приватный ключ (WIF)</th><th>Баланс</th></tr>
{% for addr, priv in receiving %}
<tr><td>{{ addr }}</td><td>{{ priv }}</td><td id="balance-{{ addr }}">Загрузка...</td></tr>
{% endfor %}
</table>
</div>
<div class="section">
<h2>Адреса для сдачи</h2>
<table>
<tr><th>Адрес</th><th>Приватный ключ (WIF)</th><th>Баланс</th></tr>
{% for addr, priv in change %}
<tr><td>{{ addr }}</td><td>{{ priv }}</td><td id="balance-{{ addr }}">Загрузка...</td></tr>
{% endfor %}
</table>
</div>
<div class="section">
<h2>Общий баланс: <span id="total-balance">Загрузка...</span> Dash</h2>
</div>
<div class="section">
<h2>Настройки ноды Dash</h2>
<form id="node-settings" action="/save_node_settings" method="post" onsubmit="saveNodeSettings(event)">
<div class="form-group">
<label for="node-address">Адрес ноды:</label>
<input type="text" id="node-address" name="node-address" placeholder="http://127.0.0.1:9998" required>
</div>
<div class="form-group checkbox">
<input type="checkbox" id="auth-required" name="auth-required" onchange="toggleAuthFields()">
<label for="auth-required">Требуется аутентификация</label>
</div>
<div class="form-group" id="username-group">
<label for="node-username">Логин:</label>
<input type="text" id="node-username" name="node-username" placeholder="Логин">
</div>
<div class="form-group" id="password-group">
<label for="node-password">Пароль:</label>
<input type="password" id="node-password" name="node-password" placeholder="Пароль">
</div>
<button type="submit">Сохранить настройки</button>
</form>
<div class="node-list">
<h3>Выберите ноду:</h3>
<form id="select-node-form" onsubmit="selectNode(event)">
<select id="selected-node" name="selected-node" required>
{% for node in nodes %}
<option value="{{ node }}">{{ node }}</option>
{% endfor %}
</select>
<button type="submit">Использовать ноду</button>
</form>
</div>
</div>
<div class="section">
<h2>Создать транзакцию</h2>
<form id="send-form" onsubmit="sendTransaction(event)">
<div class="form-group">
<label for="from-address">Отправитель:</label>
<select id="from-address" required>
{% for addr, priv in receiving + change %}
<option value="{{ addr }}|{{ priv }}">{{ addr }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="to-address">Получатель:</label>
<input type="text" id="to-address" placeholder="Адрес получателя" required>
</div>
<div class="form-group">
<label for="change-address">Адрес для сдачи:</label>
<select id="change-address" required>
{% for addr, priv in change %}
<option value="{{ addr }}|{{ priv }}">{{ addr }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="amount">Сумма (DASH):</label>
<input type="number" step="0.00000001" id="amount" required>
</div>
<button type="submit">Отправить</button>
</form>
</div>
<script>
function sendTransaction(event) {
event.preventDefault();
const formData = {
from: document.getElementById('from-address').value,
to: document.getElementById('to-address').value,
change: document.getElementById('change-address').value,
amount: parseFloat(document.getElementById('amount').value)
};
fetch('/send_transaction', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(formData)
})
.then(response => response.json())
.then(data => {
if(data.success) {
window.location.href = `/transaction_result?txid=${data.txid}`;
} else {
alert(`Ошибка: ${data.error}`);
}
})
.catch(error => console.error('Error:', error));
}
function toggleAuthFields() {
const authRequired = document.getElementById('auth-required').checked;
document.getElementById('username-group').style.display = authRequired ? 'block' : 'none';
document.getElementById('password-group').style.display = authRequired ? 'block' : 'none';
}
document.addEventListener('DOMContentLoaded', () => {
toggleAuthFields();
const addresses = [];
document.querySelectorAll('table tr td:first-child').forEach(td => {
const address = td.innerText.trim();
if (address) addresses.push(address);
});
if (addresses.length > 0) {
fetch('/check_balances', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ addresses: addresses })
})
.then(response => response.json())
.then(data => {
console.log('Balances response:', data);
let totalBalance = 0;
for (const [addr, balance] of Object.entries(data)) {
const balanceElement = document.getElementById(`balance-${addr}`);
if (typeof balance === 'number') {
const dashBalance = balance.toFixed(8);
balanceElement.textContent = `${dashBalance} DASH`;
totalBalance += balance;
} else {
balanceElement.textContent = 'Ошибка запроса';
}
}
document.getElementById('total-balance').textContent =
`${totalBalance.toFixed(8)} DASH`;
})
.catch(error => {
console.error("Ошибка получения баланса:", error);
document.querySelectorAll('[id^="balance-"]').forEach(el => {
el.textContent = 'Ошибка подключения';
});
});
}
});
function saveNodeSettings(event) {
event.preventDefault();
const form = event.target;
fetch(form.action, {
method: 'POST',
body: new FormData(form)
}).then(response => {
if (response.ok) {
alert("Настройки ноды успешно сохранены");
location.reload();
}
});
}
function selectNode(event) {
event.preventDefault();
const form = event.target;
fetch('/select_node', {
method: 'POST',
body: new FormData(form)
}).then(response => {
if (response.ok) {
alert("Нода успешно выбрана");
}
});
}
</script>
</body>
</html>
HTML-код, думаю, должен быть понятен, поэтому разберем JS-код.
Добавление адресов к RPC ноды.
Первой на очереди будет функция для отображения полей ввода логина и пароля к ноде. Это нужно для того, чтобы при добавлении нового адреса к ноде можно было указать данные для авторизации (или не указывать).
JavaScript:
function toggleAuthFields() {
const authRequired = document.getElementById('auth-required').checked;
document.getElementById('username-group').style.display = authRequired ? 'block' : 'none';
document.getElementById('password-group').style.display = authRequired ? 'block' : 'none';
}
<input type="checkbox" id="auth-required" name="auth-required" onchange="toggleAuthFields()">Если чекбокс включен, значит, нужно отображать поля для ввода логина и пароля.
Python:
<div class="form-group" id="username-group">
<label for="node-username">Логин:</label>
<input type="text" id="node-username" name="node-username" placeholder="Логин">
</div>
<div class="form-group" id="password-group">
<label for="node-password">Пароль:</label>
<input type="password" id="node-password" name="node-password" placeholder="Пароль">
</div>
При нажатии на кнопку "Сохранить настройки" срабатывает эта функция:
JavaScript:
function saveNodeSettings(event) {
event.preventDefault();
const form = event.target;
fetch(form.action, {
method: 'POST',
body: new FormData(form)
}).then(response => {
if (response.ok) {
alert("Настройки ноды успешно сохранены");
location.reload();
}
});
}
Она отправляет данные из всех полей POST-запросом на маршрут, указанный в form (/save_node_settings).
HTML:
<form id="node-settings" action="/save_node_settings" method="post" onsubmit="saveNodeSettings(event)">
<div class="form-group">
<label for="node-address">Адрес ноды:</label>
<input type="text" id="node-address" name="node-address" placeholder="http://127.0.0.1:9998" required>
</div>
<div class="form-group checkbox">
<input type="checkbox" id="auth-required" name="auth-required" onchange="toggleAuthFields()">
<label for="auth-required">Требуется аутентификация</label>
</div>
<div class="form-group" id="username-group">
<label for="node-username">Логин:</label>
<input type="text" id="node-username" name="node-username" placeholder="Логин">
</div>
<div class="form-group" id="password-group">
<label for="node-password">Пароль:</label>
<input type="password" id="node-password" name="node-password" placeholder="Пароль">
</div>
<button type="submit">Сохранить настройки</button>
</form>
save_node_settings:
Python:
@app.route('/save_node_settings', methods=['POST'])
def save_node_settings():
try:
node_str = request.form['node-address']
if request.form.get('auth-required') == 'on':
node_str += f"|{request.form['node-username']}:{request.form['node-password']}"
with open(node_file, 'a', encoding='utf-8') as f:
f.write(node_str + '\n')
flash("Настройки ноды сохранены", "success")
return '', 204
except Exception as e:
return jsonify({'error': str(e)}), 500
Выбор адреса ноды.
Теперь рассмотрим функцию выбора конкретного адреса для доступа к ноде:
JavaScript:
function selectNode(event) {
event.preventDefault();
const form = event.target;
fetch('/select_node', {
method: 'POST',
body: new FormData(form)
}).then(response => {
if (response.ok) {
alert("Нода успешно выбрана");
}
});
}
HTML:
<form id="select-node-form" onsubmit="selectNode(event)">
<select id="selected-node" name="selected-node" required>
{% for node in nodes %}
<option value="{{ node }}">{{ node }}</option>
{% endfor %}
</select>
<button type="submit">Использовать ноду</button>
</form>
Маршрут /select_node:
Python:
@app.route('/select_node', methods=['POST'])
def select_node():
# Из запроса с сайта берется нода и записывается в сессию в ключ selected_node
session['selected_node'] = request.form['selected-node']
flash(f"Выбрана нода: {session['selected_node']}", "success")
return '', 204
Автозапуск функционала при открытии страницы.
Теперь рассмотрим код, который выполняется именно при загрузке страницы профиля:
JavaScript:
document.addEventListener('DOMContentLoaded', () => {
toggleAuthFields();
const addresses = [];
document.querySelectorAll('table tr td:first-child').forEach(td => {
const address = td.innerText.trim();
if (address) addresses.push(address);
});
if (addresses.length > 0) {
fetch('/check_balances', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ addresses: addresses })
})
.then(response => response.json())
.then(data => {
console.log('Balances response:', data);
let totalBalance = 0;
for (const [addr, balance] of Object.entries(data)) {
const balanceElement = document.getElementById(`balance-${addr}`);
if (typeof balance === 'number') {
const dashBalance = balance.toFixed(8);
balanceElement.textContent = `${dashBalance} DASH`;
totalBalance += balance;
} else {
balanceElement.textContent = 'Ошибка запроса';
}
}
document.getElementById('total-balance').textContent =
`${totalBalance.toFixed(8)} DASH`;
})
.catch(error => {
console.error("Ошибка получения баланса:", error);
document.querySelectorAll('[id^="balance-"]').forEach(el => {
el.textContent = 'Ошибка подключения';
});
});
}
});
Самым первым выполняется вызов функции для проверки чекбокса:
toggleAuthFields();Проверка баланса.
Затем собираем все адреса из таблицы на странице и добавляем их в массив:
JavaScript:
const addresses = [];
document.querySelectorAll('table tr td:first-child').forEach(td => {
const address = td.innerText.trim();
if (address) addresses.push(address);
});
HTML:
<div class="section">
<h2>Адреса для получения средств</h2>
<table>
<tr><th>Адрес</th><th>Приватный ключ (WIF)</th><th>Баланс</th></tr>
{% for addr, priv in receiving %}
<tr><td>{{ addr }}</td><td>{{ priv }}</td><td id="balance-{{ addr }}">Загрузка...</td></tr>
{% endfor %}
</table>
</div>
<div class="section">
<h2>Адреса для сдачи</h2>
<table>
<tr><th>Адрес</th><th>Приватный ключ (WIF)</th><th>Баланс</th></tr>
{% for addr, priv in change %}
<tr><td>{{ addr }}</td><td>{{ priv }}</td><td id="balance-{{ addr }}">Загрузка...</td></tr>
{% endfor %}
</table>
</div>
После этого все адреса из массива отправляются на сервер на маршрут /check_balances для проверки баланса.
JavaScript:
if (addresses.length > 0) {
fetch('/check_balances', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ addresses: addresses })
})
.then(response => response.json())
.then(data => {
console.log('Balances response:', data);
let totalBalance = 0;
for (const [addr, balance] of Object.entries(data)) {
const balanceElement = document.getElementById(`balance-${addr}`);
if (typeof balance === 'number') {
const dashBalance = balance.toFixed(8);
balanceElement.textContent = `${dashBalance} DASH`;
totalBalance += balance;
} else {
balanceElement.textContent = 'Ошибка запроса';
}
}
document.getElementById('total-balance').textContent =
`${totalBalance.toFixed(8)} DASH`;
})
.catch(error => {
console.error("Ошибка получения баланса:", error);
document.querySelectorAll('[id^="balance-"]').forEach(el => {
el.textContent = 'Ошибка подключения';
});
});
Обработка ответа от сервера:
.then(response => response.json()).then(data =>Обработка полученных балансов, округление до 8 знаков:
JavaScript:
for (const [addr, balance] of Object.entries(data)) {
const balanceElement = document.getElementById(`balance-${addr}`);
if (typeof balance === 'number') {
const dashBalance = balance.toFixed(8);
balanceElement.textContent = `${dashBalance} DASH`;
totalBalance += balance;
} else {
balanceElement.textContent = 'Ошибка запроса';
}
}
Обновление общего баланса со всех адресов:
JavaScript:
document.getElementById('total-balance').textContent =
`${totalBalance.toFixed(8)} DASH`;
HTML:
<div class="section">
<h2>Общий баланс: <span id="total-balance">Загрузка...</span> Dash</h2>
</div>
Функция для проверки балансов (check_balances):
Python:
@app.route('/check_balances', methods=['POST'])
def check_balances():
try:
data = request.get_json()
addresses = data.get('addresses', [])
selected_node = session.get('selected_node', '127.0.0.1')
results = {}
for addr in addresses:
# Отправка тела запроса в функцию для отправки запросов
response = NodeManager.send_rpc_command(
selected_node,
'getaddressbalance',
[{"addresses": [addr]}]
)
if response.get('error'):
results[addr] = response['error']
else:
balance = response.get('result', {}).get('balance', 0) / 1e8
results[addr] = balance
return jsonify(results)
except Exception as e:
return jsonify({'error': str(e)}), 500
Функция для отправки запросов на ноду:
Python:
def send_rpc_command(node_str: str, method: str, params: list) -> dict:
url, login, password = NodeManager.parse_node(node_str)
# отправляемые данные
payload = {
"jsonrpc": "1.0",
"id": "curltest",
"method": method,
"params": params
}
try:
# Отправка POST запроса
response = requests.post(
url,
json=payload,
headers={'Content-Type': 'application/json'},
auth=(login, password) if login and password else None,
timeout=10
)
print (response.json()) # Принт сделан для дебага
return response.json() if response.status_code == 200 else {"error": "Ошибка запроса"}
except Exception as e:
return {"error": str(e)}
Создание транзакций.
Теперь рассмотрим создание транзакций и для начала разберём, какие данные нужны для её создания:- Адрес, откуда отправлять монеты
- Адрес, куда отправлять монеты
- Адрес для сдачи
- Сумма отправки
Именно для этих данных на странице профиля есть поля ввода и селекторы для выбора адресов, откуда отправлять и куда отправлять сдачу, если она будет.
HTML:
<form id="send-form" onsubmit="sendTransaction(event)">
<div class="form-group">
<label for="from-address">Отправитель:</label>
<select id="from-address" required>
{% for addr, priv in receiving + change %}
<option value="{{ addr }}|{{ priv }}">{{ addr }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="to-address">Получатель:</label>
<input type="text" id="to-address" placeholder="Адрес получателя" required>
</div>
<div class="form-group">
<label for="change-address">Адрес для сдачи:</label>
<select id="change-address" required>
{% for addr, priv in change %}
<option value="{{ addr }}|{{ priv }}">{{ addr }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="amount">Сумма (DASH):</label>
<input type="number" step="0.00000001" id="amount" required>
</div>
<button type="submit">Отправить</button>
Рассмотрим JS код, работающий с данными полями.
JavaScript:
function sendTransaction(event) {
event.preventDefault();
const formData = {
from: document.getElementById('from-address').value,
to: document.getElementById('to-address').value,
change: document.getElementById('change-address').value,
amount: parseFloat(document.getElementById('amount').value)
};
fetch('/send_transaction', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(formData)
})
.then(response => response.json())
.then(data => {
if(data.success) {
window.location.href = `/transaction_result?txid=${data.txid}`;
} else {
alert(`Ошибка: ${data.error}`);
}
})
.catch(error => console.error('Error:', error));
}
Маршрут /send_transaction:
Для начала рассмотрим, из чего будет состоять функция создания и отправки транзакции:- Получение всех входов кошелька.
- Перебор всех входов и запись их в объект входов будущей транзакции, попутно считывая сумму с каждого из них и записывая в отдельную переменную.
- Запись полученных неистраченных транзакций адреса во входы.
- Расчет комиссии по формуле, показанной чуть позже.
- Проверка, чтобы комиссия была не меньше 227 сатоши (225 — это минимально возможная комиссия, но транзакции с одним входом и выходом весят меньше, поэтому нужно повышать комиссию больше 225).
- Проверка на остаток монет: если после расчета комиссии и суммы отправки остаются монеты, то добавлять выход на сдачу и пересчитывать комиссию.
- Проверка, чтобы итоговая сумма (перевод + комиссия) не была больше, чем общий баланс кошелька (считался при переборе входов).
- Проверка, чтобы если после вычета перевода + комиссии из общего баланса оставался остаток, то создавался выход для сдачи.
- Запись выходов.
- Создание транзакции, используя сформированные входы и выходы, с применением RPC-команды на ноде.
- Подписание транзакции, используя RPC-команду на ноде.
- Отправка подписанной транзакции в блокчейн, используя RPC-команду на ноде.
Как происходит авторасчет комиссии?
Комиссия рассчитывается методом считывания всех байтов в подписанной транзакции. Минимально выставляемая цена за байт — это один сатоши.Что находится в подписанной транзакции?
- Входы (неистраченные транзакции на кошельке).
- Выходы (как минимум один — это кошелек для вывода с суммой, если выводится весь баланс. Если остается сдача, то добавляется еще один выход с кошельком и суммой для сдачи).
Формула по расчету комиссии:
Общий размер комиссии = 10 + (количество входов × 148) + (количество выходов × 34).Для чего добавляется 10 байтов?
Добавленные 10 байтов — это версия + locktime. То есть, это означает версию формата транзакции и locktime, который определяет время или номер блока, с которого транзакция станет действительной для загрузки в блокчейн.О locktime:
- Если значение locktime меньше 500,000, оно интерпретируется как номер блока.
- Если больше — как метка времени (timestamp, Unix-время в секундах).
- 0 (по умолчанию): Транзакция может быть сразу обработана.
Что находится во входах?
- txid (хэш транзакции).
- vout (индекс конкретного выхода в предыдущей транзакции. В одной транзакции может быть несколько выходов (отправляли на несколько кошельков, и один из них — это тот, с которого сейчас выводим), поэтому нужно указать, какой из них используется).
- scriptSig (подпись, доказывающая право на владение монетой).
- sequence (параметр для блокировки транзакции, например, установить время или количество блоков, после которых можно тратить транзакцию. Если параметр не используется, то ставится 4294967295. Это означает, что транзакцию можно тратить сразу после поступления).
Что находится в выходах?
- value (сумма перевода).
- scriptPubKey (скрипт, который определяет условия, при которых средства можно будет потратить в будущем. Например, адрес, проверка публичного ключа и подписи).
Теперь перейдем к самому коду, в котором я указал комментарии для большей ясности.
Python:
@app.route('/send_transaction', methods=['POST'])
def send_transaction():
try:
data = request.get_json()
from_address, from_priv = data['from'].split('|')
to_address = data['to']
change_address = data['change'].split('|')[0] # Берем только адрес для сдачи
amount = int(float(data['amount']) * 1e8) # Конвертация в сатоши
# Получаем UTXO
utxo_response = NodeManager.send_rpc_command(
session.get('selected_node'),
'getaddressutxos',
[{"addresses": [from_address]}]
)
if utxo_response.get('error'):
return jsonify(success=False, error=utxo_response['error'])
utxos = utxo_response.get('result', [])
if not utxos:
return jsonify(success=False, error='Нет доступных UTXO')
# Собираем входы и считаем баланс
inputs = []
total_input = 0
for utxo in utxos:
inputs.append({
"txid": utxo['txid'],
"vout": utxo['outputIndex']
})
total_input += utxo['satoshis']
# Первоначальный расчет комиссии (1 выход)
output_count = 1
fee_rate = 10 + (len(inputs) * 148) + (output_count * 34)
fee_rate = max(fee_rate, 227) # Минимальная комиссия
# Расчет остатка
spend_change = total_input - amount - fee_rate
# Если есть сдача, добавляем выход и корректируем комиссию
if spend_change > 0:
fee_rate += 34 # +34 байта за дополнительный выход
output_count += 1
# Повторная проверка баланса с новой комиссией
if total_input < amount + fee_rate:
return jsonify(success=False, error='Недостаточно средств с учетом сдачи')
spend_change = total_input - amount - fee_rate
# Формируем выходы
outputs = [{to_address: round(amount / 1e8, 8)}]
if spend_change > 0:
outputs.append({change_address: round(spend_change / 1e8, 8)})
# Создаем raw транзакцию
raw_tx = NodeManager.send_rpc_command(
session['selected_node'],
'createrawtransaction',
[inputs, outputs]
)
if raw_tx.get('error'):
return jsonify(success=False, error=raw_tx['error'])
# Подписываем транзакцию ТОЛЬКО ключом отправителя
signed_tx = NodeManager.send_rpc_command(
session['selected_node'],
'signrawtransactionwithkey',
[raw_tx['result'], [from_priv]] # Только ключ отправителя
)
if not signed_tx.get('result', {}).get('complete'):
return jsonify(success=False, error='Ошибка подписи транзакции')
# Отправляем транзакцию
send_result = NodeManager.send_rpc_command(
session['selected_node'],
'sendrawtransaction',
[
signed_tx['result']['hex'], # Подписанная транзакция
0, # allowhighfees (0 = false)
False, # instantSend
False # bypasslimits
]
)
if send_result.get('error'):
return jsonify(success=False, error=send_result['error'])
return jsonify(success=True, txid=send_result['result'])
except Exception as e:
return jsonify(success=False, error=str(e))
Вся логика данной функции уже была описана выше, но стоит обратить внимание на параметры для отправки транзакции в сеть.
signed_tx['result']['hex'], # Подписанная транзакция 0, # allowhighfees (0 = false) False, # instantSend False # bypasslimits- 0 означает, что отправка транзакции с аномально большой комиссией запрещена.
- Первый false означает запрет использования instantSend.
- Второй false означает использование стандартных правил сети Dash, например, размер максимальной транзакции, минимальная комиссия, количество входов и выходов. Если выключить данный параметр, транзакция может не отправиться из-за нарушений каких-либо правил.
Что такое InstantSend?
InstantSend позволяет провести транзакцию почти мгновенно, но требует доступных мастернод, поэтому его использование было отключено.Что такое мастер нода?
Мастерноды были придуманы именно в Dash. Благодаря им можно проводить транзакции практически мгновенно.Мастерноды обрабатывают транзакции и проверяют, были ли уже истрачены используемые входы. Если несколько мастернод голосуют за то, что входы еще не были использованы, транзакция сразу же подтверждается.
На этом с функцией создания и отправки транзакции все.
Страница с результатами транзакции.
Если помните, после отправки транзакции результат отправки передается обратно на страницу. Если результат удачный, то хеш транзакции передается на маршрут /transaction_result. Его сейчас и рассмотрим.
Python:
@app.route('/transaction_result')
def transaction_result():
txid = request.args.get('txid')
error = request.args.get('error')
return render_template('transaction_result.html', txid=txid, error=error)
transaction_result.html:
HTML:
<!DOCTYPE html>
<html>
<head>
<title>Результат транзакции</title>
</head>
<body>
<h1>Результат выполнения транзакции</h1>
{% if txid %}
<p>Транзакция успешно отправлена!</p>
<p>TXID: <strong>{{ txid }}</strong></p>
{% else %}
<p>Ошибка при выполнении транзакции: {{ error }}</p>
{% endif %}
</body>
</html>
Создание кошелька без генерации мнемонической фразы.
Также еще не была рассмотрена логика создания кошелька с использованием своей мнемонической фразы, а не сгенерированной. Логика практически такая же, как и при генерации мнемоники, за исключением того, что вызывается маршрут, который не генерирует мнемоническую фразу и отправляет уже на другой маршрут для генерации адресов, а вызывается маршрут, открывающий страницу для ввода мнемонической фразы. После ввода мнемонической фразы уже происходит перенаправление на маршрут для генерации адресов.Маршрут /enter_mnemonic:
Python:
@app.route('/enter_mnemonic')
def enter_mnemonic():
return render_template('enter_mnemonic.html')
Страница ввода мнемонической фразы(enter_mnemonic):
HTML:
<!DOCTYPE html>
<html>
<head>
<title>Ввод мнемонической фразы</title>
</head>
<body>
<h1>Введите вашу мнемоническую фразу</h1>
<form method="post" action="/process_mnemonic">
<textarea name="mnemonic" rows="4" cols="50" required></textarea><br>
<button type="submit">Продолжить</button>
</form>
</body>
</html>
Маршрут для передачи мнемонической фразы на страницу enter_password:
На странице enter_password вводится пароль для будущего кошелька (используется в обоих случаях создания кошелька: как с сгенерированной мнемонической фразой, так и с уже готовой).
Python:
@app.route('/process_mnemonic', methods=['POST'])
def process_mnemonic():
return render_template(
'enter_password.html',
mnemonic=request.form['mnemonic'].strip()
)
Интерфейс.
На этом статья подходит к своему завершению, и вот какой интерфейс получился у холодного кошелька:
Вывод.
Код данного проекта получился достаточно грязным, по моему мнению. Это потому что я впервые попытался реализовать подобное и просто хотел понять, сложно ли создать собственный холодный кошелек или нет, поэтому данный проект был написан лишь для тестов, как первоначальная наработка. Тем не менее, было проведено несколько тестов, сделано несколько транзакций, и весь функционал полностью рабочий.Если кого-то заинтересовало написание полноценного, качественного холодного кошелька, я могу попробовать реализовать это, если вам будет интересно (пишите об этом в комментариях). Как обычно, все исходники из данного проекта и возможные его обновления будут на GitHub.
P.S. Также у меня есть идея написать аппаратный кошелек с использованием Arduino. Если вам это будет интересно, также пишите об этом.
Ссылка на GitHub репозиторий - https://github.com/overlordgamedev/Cold-Wallet-HooliWallet
Статья в виде документа - https://docs.google.com/document/d/1roZRsSl-uc7yS1mUEfOiYucW_aNXYgZcOUC_DeTGJNo
Сделано OverlordGameDev специально для форума xss.pro