Шлём биткоины с Android

Kate

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

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 он может посмотреть подробную информацию по выбранной транзакции.

2806caf91b692441561c947ba0149945.png

На экране отправки также выводим текущий подтверждённый баланс, поле ввода суммы перевода и адреса назначения. Если с введёнными данными всё в порядке, по нажатию на кнопку Send показываем диалог с id созданной транзакции. По нажатию на id тоже отправляем пользователя в браузер. Если сервер ответил ошибкой, показываем её текст в диалоге.

3062a13802fd8e74af66fee380646c34.png

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) и технологий, основанных на блокчейне.

 
Сверху