Шлём биткоины с Android (и смотрим транзакции)

от автора

Привет! Сегодня я расскажу о своём опыте написания простого Android-приложения  для отправки биткоинов с существующего кошелька, отображения его баланса и списка транзакций. Кажется, чего уж проще? Да, но есть нюансы. О них и поговорим.

Дисклеймер

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

0. Чего ожидать

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

Вас ждёт погружение в мир блокчейн-транзакций: от создания кошельков и работы с UTXO до подписания транзакций и их отправки в сеть. Это не только отличный способ глубже понять принципы работы биткоина, но и возможность научиться писать собственные Android-приложения для работы с криптовалютами.

Уточню, что код приложения работает с тестовой сетью Signet. Это второе поколение “песочницы” биткоина, которая в точности повторяет “продакшн”. С тем лишь отличием, что токены в ней не представляют никакой ценности. Однако при желании, вы можете с лёгкостью переключиться на использование настоящей Bitcoin-сети, изменив в коде всего один параметр.

Сперва я опишу процесс создания и пополнения кошельков, затем мы обсудим принципы работы сети Bitcoin, а потом перейдём непосредственно к коду. Если у вас уже есть кошелёк в Signet-сети, можете сразу переходить к пункту 4. А если вы уже знаете, как и что работает, можете переходить на пункт 7.

1. Создаём

Итак, давайте заведём себе кошелёк. Удобнее всего это делать в популярном приложении Electrum, запустив его с флагом --signet. После запуска создаём кошелёк first_wallet

Простой и понятный интерфейс позволяет быстро создать кошелёк

Простой и понятный интерфейс позволяет быстро создать кошелёк

Далее оставьте все настройки по умолчанию: 

Standard walletCreate a new seedEncrypt wallet file.

Важно: запишите на бумагу 12 слов предложенной seed-последовательности и пароль от кошелька.

Теперь создадим ещё один кошелёк — second_wallet, на который мы будем переводить криптовалюту для тестирования отправки.

2. Пополняем

После того, как кошельки созданы, переведём немного криптовалюты на first_wallet. Идём на специальный сервис и просим себе 0.01 BTC. Обычно процесс перевода занимает 5 — 10 минут, но может понадобиться несколько попыток.

В Signet управление добычей блоков осуществляется группой подписантов, которые обрабатывают каждый новый блок. Это позволяет поддерживать стабильность сети и предотвращает хаос, характерный для Testnet (ранней версии тестовой сети Bitcoin), где добыча блоков не контролируется. Однако в связи с этой особенностью сильно разжиться токенами не получится.

3. Смотрим внутрь кошелька

В Electrum перейдите на вкладку “Addresses”. Если её у вас нет, нажмите пункт меню ViewAddresses. Созданный кошелёк содержит 30 адресов, по которым можно перевести деньги и сдачу. Когда вы переводите криптовалюту через приложения вроде Electrum, кошелёк выбирает один из заранее сгенерированных адресов для отправки сдачи, следуя порядку их создания. Сделано это для повышения анонимности, а нам это добавляет некоторые неудобства, которые мы обсудим ниже.

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

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

4. Экспортируем адреса и ключи

В Electrum перейдём в WalletPrivate keysExport и сохраним список кошельков вместе с их приватными ключами. Скопируем адреса (без приватных ключей) в файл 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/


ссылка на оригинал статьи https://habr.com/ru/articles/846616/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *