Привет! Сегодня я расскажу о своём опыте написания простого Android-приложения для отправки биткоинов с существующего кошелька, отображения его баланса и списка транзакций. Кажется, чего уж проще? Да, но есть нюансы. О них и поговорим.
Дисклеймер
Эта статья носит просветительский характер и не призывает никого ни к каким операциям с криптовалютой. Автор настаивает на необходимости подчиняться актуальным законам в сфере регулирования цифровых валют и активов.
0. Чего ожидать
Начинающие энтузиасты криптовалют могут (как и я) столкнуться с массой неочевидных и сложных для понимания вещей. В этой статье я хочу поделиться своим опытом хождения по граблям и облегчить жизнь тем, кто будет позже разбираться в этой теме.
Вас ждёт погружение в мир блокчейн-транзакций: от создания кошельков и работы с UTXO до подписания транзакций и их отправки в сеть. Это не только отличный способ глубже понять принципы работы биткоина, но и возможность научиться писать собственные Android-приложения для работы с криптовалютами.
Уточню, что код приложения работает с тестовой сетью Signet. Это второе поколение “песочницы” биткоина, которая в точности повторяет “продакшн”. С тем лишь отличием, что токены в ней не представляют никакой ценности. Однако при желании, вы можете с лёгкостью переключиться на использование настоящей Bitcoin-сети, изменив в коде всего один параметр.
Сперва я опишу процесс создания и пополнения кошельков, затем мы обсудим принципы работы сети Bitcoin, а потом перейдём непосредственно к коду. Если у вас уже есть кошелёк в Signet-сети, можете сразу переходить к пункту 4. А если вы уже знаете, как и что работает, можете переходить на пункт 7.
1. Создаём
Итак, давайте заведём себе кошелёк. Удобнее всего это делать в популярном приложении Electrum, запустив его с флагом --signet
. После запуска создаём кошелёк first_wallet
.
Далее оставьте все настройки по умолчанию:
Standard wallet — Create a new seed — Encrypt wallet file.
Важно: запишите на бумагу 12 слов предложенной seed-последовательности и пароль от кошелька.
Теперь создадим ещё один кошелёк — second_wallet
, на который мы будем переводить криптовалюту для тестирования отправки.
2. Пополняем
После того, как кошельки созданы, переведём немного криптовалюты на first_wallet
. Идём на специальный сервис и просим себе 0.01 BTC. Обычно процесс перевода занимает 5 — 10 минут, но может понадобиться несколько попыток.
В Signet управление добычей блоков осуществляется группой подписантов, которые обрабатывают каждый новый блок. Это позволяет поддерживать стабильность сети и предотвращает хаос, характерный для Testnet (ранней версии тестовой сети Bitcoin), где добыча блоков не контролируется. Однако в связи с этой особенностью сильно разжиться токенами не получится.
3. Смотрим внутрь кошелька
В Electrum перейдите на вкладку “Addresses”. Если её у вас нет, нажмите пункт меню View — Addresses. Созданный кошелёк содержит 30 адресов, по которым можно перевести деньги и сдачу. Когда вы переводите криптовалюту через приложения вроде Electrum, кошелёк выбирает один из заранее сгенерированных адресов для отправки сдачи, следуя порядку их создания. Сделано это для повышения анонимности, а нам это добавляет некоторые неудобства, которые мы обсудим ниже.
4. Экспортируем адреса и ключи
В Electrum перейдём в Wallet — Private keys — Export и сохраним список кошельков вместе с их приватными ключами. Скопируем адреса (без приватных ключей) в файл addresses.txt, который позже будем использовать в приложении.
Для первого адреса из нашего списка скопируем приватный ключ в файл private_key.txt.
Коснёмся двух важных моментов.
Во-первых, если вы будете использовать настоящую сеть Bitcoin, вам нужно как следует подумать, где хранить приватный ключ своего кошелька. Вы не можете отозвать или перевыпустить приватный ключ, равно как и удалить/деактивировать кошелёк. Поэтому любой, кто получит доступ к этому ключу, сможет необратимо вывести ваши деньги.
Во-вторых, если вы используете HD (Hierarchical Deterministic) кошелёк, как раз такой, как мы создали в пункте 1, вы всегда можете сгенерировать новые адреса из своей seed-фразы. В идеальном мире нам следует использовать новый адрес для каждой новой транзакции. Но в нашем приложении мы для простоты будем использовать только один.
5. Дизайним UI
Для приложения мы будем использовать простой дизайн с двумя экранами: экраном транзакций и экраном отправки валюты.
Экран транзакции отображает текущий подтверждённый баланс, кнопку навигации на экран отправки Bitcoin, кнопку копирования текущего кошелька в буфер обмена и список транзакций. Список содержит последние 25 подтверждённых операций. По клику на любой из них переводим пользователя в браузер, где на сайте mempool.space он может посмотреть подробную информацию по выбранной транзакции.
На экране отправки также выводим текущий подтверждённый баланс, поле ввода суммы перевода и адреса назначения. Если с введёнными данными всё в порядке, по нажатию на кнопку Send показываем диалог с id созданной транзакции. По нажатию на id тоже отправляем пользователя в браузер. Если сервер ответил ошибкой, показываем её текст в диалоге.
6. Анализируем
Мы будем работать с API mempool.space. Оно позволяет получать все необходимые данные из сети Signet, а также отправлять запросы на создание новых транзакций.
Все используемые нами методы требуют указания адреса кошелька. Нам нужен любой receiving-кошелёк из списка, сохранённого в addresses.txt. Будем использовать первый.
6.1 Баланс списка
Вычислим баланс адреса на основе ответа от метода address. Этот метод возвращает следующие основные параметры:
chain_stats: статистика транзакций, связанных с этим адресом, которые уже подтверждены в блокчейне.
Поля:
-
funded_txo_count
: Количество UTXO (непотраченных выходов), которые были получены этим адресом.UTXO (Unspent Transaction Output) — это непотраченные выходы предыдущих транзакций, которые могут быть использованы для создания новых. По сути, UTXO — это “монеты”, которые находятся на адресе и ещё не были использованы.
Важно помнить, что когда мы отправляем биткоины, мы тратим UTXO полностью. Если UTXO превышает сумму перевода, остаток возвращается на наш адрес в виде нового UTXO (это “сдача”).
-
funded_txo_sum
: сумма всех непотраченных средств, полученных этим адресом (общая сумма всех UTXO). -
spent_txo_count
: это количество UTXO, которые были потрачены этим адресом. -
spent_txo_sum
: Сумма всех потраченных UTXO в сатоши для данного адреса. -
tx_count
: Общее количество транзакций, связанных с этим адресом (включая как входящие, так и исходящие транзакции). -
mempool_stats: статистика транзакций, связанных с этим адресом, которые находятся в mempool, но ещё не подтверждены в блокчейне.
mempool — это буферное хранилище на каждой ноде сети для неподтверждённых транзакций, где они находятся до тех пор, пока не будут включены в блок и подтверждены.
Для нашего приложения мы будем вычислять текущий баланс по формуле funded_txo_sum — spent_txo_sum, основываясь на подтверждённых операциях.
Переведём 0.12345 mBTC (0.00012345 BTC) на наш second_wallet
при помощи Electrum. Иногда мы будем использовать для сумм ещё и сатоши (sat) — минимальные денежные единицы сети Bitcoin. 1 BTC = 100.000.000 sat.
Операции с биткоинами выполняются с задержкой, которая зависит от величины комиссии, которую вы оставляете майнерам. Кроме того, нода может отклонить ваш запрос, если он пытается потратить те же UTXO, что и предыдущий неподтверждённый запрос с вашего адреса (double-spending). Поэтому для тестирования надо запастись терпением — задержки в подтверждении транзакций являются нормальной частью работы сети.
6.2 Список транзакций
Хорошо, с балансом разобрались. Теперь отобразим список транзакций.
Транзакция в Bitcoin-сети содержит входы, с которых мы хотим потратить средства, и выходы, куда мы хотим их перевести. Выходы содержат как сумму перевода, так и «сдачу», которую мы возвращаем сами себе. Разница между суммой всех входов и всех выходов является комиссией майнерам, которые подтверждают эту транзакцию.
Можно представить себе, что на входе у нас 2 банкноты: 100 и 50 рублей. А на выходе — плата продавцу за товар, который стоит 130 рублей, и 15 рублей сдачи, которые мы возвращаем обратно себе в кошелёк. Разница между входами (150 рублей) и суммой отправленных средств (130 рублей и 15 рублей сдачи) составляет 5 рублей, и это комиссия, которую мы платим посреднику за подтверждение покупки.
Итак, время запросить транзакции тут и посмотреть внутрь ответа.
Основные параметры транзакций, которые возвращает API по запросу:
-
txid
(Transaction ID): это уникальный хэш для идентификации в блокчейне. -
vin
(входы транзакции): список входов транзакции. Входы представляют собой источники средств — это предыдущие UTXO. Вход содержит следующие важные поля:-
txid
: идентификатор предыдущей транзакции, откуда берутся средства. -
vout
: индекс выходного элемента предыдущей транзакции (указывающий на конкретный выход, используемый в качестве текущего входа). В нём содержатся поля:-
prevout.scriptpubkey_address
— адрес, на который были отправлены средства в предыдущей транзакции, иprevout.value
— её сумма -
witness
: это подпись и публичный ключ, которые подтверждают право расходования UTXO.
-
-
-
vout
(выходы транзакции): список выходов транзакции. Каждый выход представляет собой отправку средств на определённый адрес. Тут тоже используются поляscriptpubkey_address
иvalue
. -
fee
: комиссия за транзакцию в сатоши. Эта сумма платится майнерам за включение транзакции в блок.Транзакции с нулевой комиссией могут быть обработаны, но с крайне низкой вероятностью, так как майнеры предпочитают транзакции с комиссией.
Комиссию правильно рассчитывать на основе размера транзакции в байтах и текущей ситуации в сети, но в коде мы ограничимся фиксированной комиссией в 250 сатоши для упрощения демонстрации.
-
status.confirmed
: булево значение, указывающее, была ли транзакция подтверждена (включена в блок).
Таким образом, вход транзакции содержит ссылку на предыдущий выход, создавая цепочку, которая является основой работы блокчейна.
6.3 Перевод криптовалюты
Вот мы и подошли к самому интересному. Для перевода криптовалюты нам нужно сформировать транзакцию, наподобие той, что мы разобрали выше. Она должна включать сумму перевода, сдачу (если она необходима), а также комиссию, выплачиваемую майнерам.
Каждая транзакция должна быть подписана. Подпись — это доказательство того, что мы обладаем приватным ключом, соответствующим адресу, с которого мы отправляем средства. Этот процесс включает использование алгоритма ECDSA (Elliptic Curve Digital Signature Algorithm). Подпись может быть проверена с помощью публичного ключа, известного сети.
После того, как транзакция подписана, её необходимо перевести в HEX-строку (это двоичный формат данных, представленный в шестнадцатеричном виде) и передать по сети.
Отправить строку можно через API, например, сделав запрос на этот адрес. Как только транзакция попадает в сеть, она будет отправлена в mempool и будет ждать включения в блок майнерами.
7. Кодим!
Напишем приложение на Compose c использованием архитектуры MVVM + Repository. Я предполагаю, что читатель уже знаком с Android и писал UI на Compose с использованием указанной архитектуры, поэтому не буду затрагивать эти вопросы.
Для упрощения работы с криптографией и внутренними проверками, мы будем использовать библиотеку bitcoinj. Это довольно популярное решение для работы с Bitcoin на языке Java. Для доступа к сети Signet необходимо использовать версию bitcoinj не ниже 0.17-beta1.
Полный код проекта можно посмотреть на моём Github. Для того, чтобы всё заработало, нужно положить файлы, созданные в пункте 4, по пути app/src/main/assets/
, чтобы код имел к ним доступ.
Далее я остановлюсь на некоторых методах BitcoinWalletViewModel
, в которых заключена основная бизнес-логика приложения.
7.1 Готовим данные для элемента списка транзакций
fun getTransactionDisplayData(transaction: TransactionDTO, ownAddresses: Set<String>): TransactionDisplayData { // выясняем, есть ли наш адрес в списке in. // Если да, то это операция расхода. val isOutgoing = transaction.vIn.any { input -> input.prevOut.scriptPublicKeyAddress in ownAddresses } // есть ли кто-то ещё кроме нас в списке out val hasOutputToOthers = transaction.vOut.any { out -> out.scriptPublicKeyAddress !in ownAddresses } // определяем, что это поступление средств к нам val isIncoming = !isOutgoing && transaction.vOut.any { out -> out.scriptPublicKeyAddress in ownAddresses } // определяем тип транзакции val transactionType: TransactionType = when { isOutgoing && hasOutputToOthers -> TransactionType.EXPENSE isIncoming -> TransactionType.INCOME isOutgoing && !hasOutputToOthers -> TransactionType.SELF_TRANSFER else -> TransactionType.UNKNOWN } val amount: Long = when (transactionType) { // если тратим, то нужно прибавить к отображаемой сумме величину комиссии TransactionType.EXPENSE -> transaction.vOut .filter { it.scriptPublicKeyAddress !in ownAddresses } .sumOf { it.value } + transaction.fee // если получаем, просто выводим сумму трансфера TransactionType.INCOME -> transaction.vOut .filter { it.scriptPublicKeyAddress in ownAddresses } .sumOf { it.value } else -> 0L } val amountInmBtc = amount / 100_000.0 val transactionAddressText = when (transactionType) { // для поступлений ищем кошелёк, с которого переведены деньги TransactionType.INCOME -> { val senderAddress = transaction.vIn.firstOrNull { input -> input.prevOut.scriptPublicKeyAddress !in ownAddresses }?.prevOut?.scriptPublicKeyAddress if (senderAddress != null) "From: ${getShortAddress(senderAddress)}" else null } // для расхода - кошелёк, на который они переведены TransactionType.EXPENSE -> { val receiverAddress = transaction.vOut.firstOrNull { out -> out.scriptPublicKeyAddress !in ownAddresses }?.scriptPublicKeyAddress if (receiverAddress != null) "To: ${getShortAddress(receiverAddress)}" else null } else -> null } // и возвращаем то, что получили return TransactionDisplayData( transactionType = transactionType, amountInmBtc = amountInmBtc, transactionAddressText = transactionAddressText ) }
7.2 Готовим HEX транзакции
Сама процедура подготовки транзакции довольно громоздкая, поэтому я разбил её на несколько отдельных функций. Общий алгоритм такой:
-
Запросить список всех транзакций
-
найти UTXO c подходящим балансом. Баланс должен быть больше суммы платежа + комиссия + “пыль” (минимальная сумма платежа и остатка на счёте)
-
создать объект Transaction
-
добавить в него выходы транзакции (указать адреса и суммы расходов)
-
добавить входы (UTXO, с суммы которого будет осуществлён перевод). Последовательность действий должна быть именно такая. Попытка добавить вход до выходов приведёт к ошибке времени выполнения.
-
Подписать все входы
-
Получить HEX-представление транзакции
Для выполнения шага 2 используется следующий код:
private fun findSuitableUtxo(transactions: List<TransactionDTO>, amount: Long): Utxo? { for (tx in transactions) { // нас интересуют только подтверждённые транзакции if (tx.status.confirmed) { tx.vOut.forEachIndexed { index, vout -> // помимо сумм платежа и комиссии учитываем “пыль” if (vout.value >= (amount + feeAmount + dustThreshold)) { // проверяем, что этот выход не был использован как вход (UTXO) val isUsed = transactions.any { transaction -> transaction.vIn.any { vin -> vin.txId == tx.txId && vin.vOut == index } } // если все проверки пройдены, возвращаем этот UTXO if (!isUsed) { return Utxo(tx.txId, index.toLong(), vout.value) } } } } } return null }
Шаги 3 — 7 реализованы так:
private fun prepareTransaction(params: TransactionParams): String { Context.propagate(Context()) // Базовые настройки сети и используемого ключа val scriptType = ScriptType.P2WPKH val network = BitcoinNetwork.SIGNET // Подготовка ключа val cleanKey = params.privateKey.substringAfter(':') val key = DumpedPrivateKey.fromBase58(network, cleanKey).key // получаем адрес платежа val addressParser = AddressParser.getDefault() val toAddress = addressParser.parseAddress(params.destinationAddress) // сумма платежа val sendAmount = Coin.valueOf(params.amount) // Сумма, доступная для расходования в UTXO val totalInput = Coin.valueOf(params.utxo.value) // Комиссия майнерам. val fee = Coin.valueOf(params.feeAmount) // проверяем, хватает ли нам денег if (totalInput.subtract(sendAmount) < fee) { throw IllegalArgumentException("Not enough funds to send transaction with fee") } // Шаг 3: создаём транзакцию val transaction = Transaction() // Шаг 4: Добавляем выход - адрес получателя и сумму transaction.addOutput(sendAmount, toAddress) // Считаем сдачу (если она есть) val change = totalInput.subtract(sendAmount).subtract(fee) if (change.isPositive) { // Важно: необходимо отправить сдачу обратно на кошелёк отправителя transaction.addOutput(change, key.toAddress(scriptType, network)) } // Добавляем UTXO как вход val utxo = Sha256Hash.wrap(params.utxo.txId) val outPoint = TransactionOutPoint(params.utxo.vOutIndex, utxo) val input = TransactionInput(transaction, byteArrayOf(), outPoint, Coin.valueOf(params.utxo.value)) // Шаг 5: Добавляем вход transaction.addInput(input) // Готовим scriptPubKey для подписи val scriptCode = ScriptBuilder.createP2PKHOutputScript(key.pubKeyHash) // Подписываем входы for (i in 0 until transaction.inputs.size) { val txIn = transaction.getInput(i.toLong()) val signature = transaction.calculateWitnessSignature( i, key, scriptCode, Coin.valueOf(params.utxo.value), Transaction.SigHash.ALL, false ) // Шаг 6: Подписываем входы txIn.witness = TransactionWitness.of(listOf(signature.encodeToBitcoin(), key.pubKey)) } // Шаг 7: Получаем HEX транзакции для последующей отправки. return transaction.serialize().toHexString()
Далее можно отправлять полученный HEX и показывать результат.
8. Заключение
Теперь, когда мы прошли путь от создания кошелька до отправки и просмотра биткоин-транзакций, у вас есть небольшое, но функциональное приложение для работы с криптовалютой на Android. Надеюсь, этот практический опыт помог вам не только лучше понять основы биткоин-транзакций и взаимодействия с блокчейном, но и вдохновил на дальнейшие эксперименты в этой увлекательной сфере.
Возможно, для кого-то это станет первым шагом в захватывающий мир децентрализованных финансов (DeFi) и технологий, основанных на блокчейне.
В любом случае, буду рад Pull Request’ам и обратной связи!🙂
9. Полезные ссылки
Github с кодом проекта: https://github.com/pristalovpavel/BitcoinWallet
Подробно про сеть Signet: https://en.bitcoin.it/wiki/Signet
Официальная документация bitcoinj: https://bitcoinj.org/
Если интересно посмотреть, что же там получилось в HEX транзакции: https://live.blockcypher.com/btc/decodetx/
Просмотр блоков сети Signet https://explorer.bc-2.jp/
ссылка на оригинал статьи https://habr.com/ru/articles/846616/
Добавить комментарий