Привет! Сегодня я расскажу о своём опыте написания простого 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/
Комментарии (4)
csl
01.10.2024 06:17Касательно скринов (может у вас нет уязвимости): проблема Алгоритма сервиса пикселизации картинок
anil Автор
01.10.2024 06:17+1Спасибо за информацию! Очень интересно было почитать!
На самом деле, я не столько стремился скрыть номера кошельков в тестовой сети, сколько хотел не отвлекать внимание читателя на длиннющие строки :-) Лично я в хешах обычно сравниваю только несколько первых и несколько последних символов, поэтому в таком виде мне просто легче воспринимать информацию.
csl
Можете добавить ещё в хабы "Платёжные системы" и "Финансы в IT" ?
anil Автор
Спасибо, добавил!