Привет! Сегодня я расскажу о своём опыте написания простого 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, кошелёк выбирает один из заранее сгенерированных адресов для отправки сдачи, следуя порядку их создания. Сделано это для повышения анонимности, а нам это добавляет некоторые неудобства, которые мы обсудим ниже.

Все используемые нами адреса в сети Signet начинаются с "tb1q"
Все используемые нами адреса в сети Signet начинаются с "tb1q"

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. Вход содержит следующие важные поля:

    1. txid: идентификатор предыдущей транзакции, откуда берутся средства.

    2. vout: индекс выходного элемента предыдущей транзакции (указывающий на конкретный выход, используемый в качестве текущего входа). В нём содержатся поля:

      • prevout.scriptpubkey_address - адрес, на который были отправлены средства в предыдущей транзакции, и prevout.value - её сумма

      • witness: это подпись и публичный ключ, которые подтверждают право расходования UTXO.

  • vout (выходы транзакции): список выходов транзакции. Каждый выход представляет собой отправку средств на определённый адрес. Тут тоже используются поля scriptpubkey_address и value.

  • fee: комиссия за транзакцию в сатоши. Эта сумма платится майнерам за включение транзакции в блок.

    Транзакции с нулевой комиссией могут быть обработаны, но с крайне низкой вероятностью, так как майнеры предпочитают транзакции с комиссией.

    Комиссию правильно рассчитывать на основе размера транзакции в байтах и текущей ситуации в сети, но в коде мы ограничимся фиксированной комиссией в 250 сатоши для упрощения демонстрации.

  • status.confirmed: булево значение, указывающее, была ли транзакция подтверждена (включена в блок).

Таким образом, вход транзакции содержит ссылку на предыдущий выход, создавая цепочку, которая является основой работы блокчейна.

Красивое отображение транзакции и полную информацию по ней можно так же увидеть на mempool.space
Красивое отображение транзакции и полную информацию по ней можно так же увидеть на mempool.space

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 транзакции

Сама процедура подготовки транзакции довольно громоздкая, поэтому я разбил её на несколько отдельных функций. Общий алгоритм такой:

  1. Запросить список всех транзакций

  2. найти UTXO c подходящим балансом. Баланс должен быть больше суммы платежа + комиссия + “пыль” (минимальная сумма платежа и остатка на счёте)

  3. создать объект Transaction 

  4. добавить в него выходы транзакции (указать адреса и суммы расходов)

  5. добавить входы (UTXO, с суммы которого будет осуществлён перевод). Последовательность действий должна быть именно такая. Попытка добавить вход до выходов приведёт к ошибке времени выполнения.

  6. Подписать все входы

  7. Получить 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 и показывать результат.

2850 сатоши отправлены!
2850 сатоши отправлены!

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)


  1. csl
    01.10.2024 06:17

    Можете добавить ещё в хабы "Платёжные системы" и "Финансы в IT" ?


    1. anil Автор
      01.10.2024 06:17
      +1

      Спасибо, добавил!


  1. csl
    01.10.2024 06:17

    Касательно скринов (может у вас нет уязвимости): проблема Алгоритма сервиса пикселизации картинок


    1. anil Автор
      01.10.2024 06:17
      +1

      Спасибо за информацию! Очень интересно было почитать!

      На самом деле, я не столько стремился скрыть номера кошельков в тестовой сети, сколько хотел не отвлекать внимание читателя на длиннющие строки :-) Лично я в хешах обычно сравниваю только несколько первых и несколько последних символов, поэтому в таком виде мне просто легче воспринимать информацию.