From 6239526d43a77805de0d32446404fc5a3919b284 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Sun, 12 Jul 2026 15:51:37 -0700 Subject: [PATCH] feat: Bitcoin transaction construction, signing, and sending RealBitcoinTransactionService builds and signs native SegWit transactions client-side (mempool.space only exposes UTXOs/fee-rate/broadcast, not construction, unlike TronGrid): greedy largest-first UTXO selection over confirmed UTXOs, inputs*68 + outputs*31 + 11 vsize heuristic for fees (matching pezkuwi-exchange's proven approach), RBF-signaled inputs, and a 546-sat dust threshold that folds sub-dust change into the fee rather than creating an uneconomical output. Each input gets its own BIP143 sighash, signed via the existing raw-hash signing primitive (NovaSigner.signRaw with skipMessageHashing) already used for Ethereum/Tron, then DER-encoded with low-S normalization - no new signing call path was added, only the DER encoding step. Also fixes multiChainEncryptionFor/getMetaAccountKeypair to recognize Bitcoin accounts: previously neither function had any Bitcoin (or Tron) branch, so signing would have silently used the wrong keypair. Threads a bitcoin flag through mapMetaAccountSecretsToKeypair/DerivationPath and AccountSecrets.keypair(chain)/derivationPath(chain) the same way ethereum already is. --- .../common/data/secrets/v2/SecretStoreV2.kt | 15 +- .../feature_account_api/data/model/Fee.kt | 12 ++ .../data/secrets/SecretStoreExt.kt | 4 +- .../data/signer/secrets/SecretsSigner.kt | 9 +- .../assets/tranfers/TransactionExecution.kt | 2 + .../data/network/bitcoin/BitcoinApi.kt | 39 ++++ .../network/bitcoin/RetrofitBitcoinApi.kt | 16 ++ .../bitcoin/model/BitcoinUtxoResponse.kt | 22 +++ .../transaction/BitcoinTransactionService.kt | 36 ++++ .../RealBitcoinTransactionService.kt | 186 ++++++++++++++++++ .../BitcoinNativeAssetTransfers.kt | 87 ++++++++ .../di/modules/BitcoinAssetsModule.kt | 38 +++- 12 files changed, 451 insertions(+), 15 deletions(-) create mode 100644 feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/bitcoin/model/BitcoinUtxoResponse.kt create mode 100644 feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/bitcoin/transaction/BitcoinTransactionService.kt create mode 100644 feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/bitcoin/transaction/RealBitcoinTransactionService.kt create mode 100644 feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/bitcoinNative/BitcoinNativeAssetTransfers.kt diff --git a/common/src/main/java/io/novafoundation/nova/common/data/secrets/v2/SecretStoreV2.kt b/common/src/main/java/io/novafoundation/nova/common/data/secrets/v2/SecretStoreV2.kt index b62f364a..2b6916eb 100644 --- a/common/src/main/java/io/novafoundation/nova/common/data/secrets/v2/SecretStoreV2.kt +++ b/common/src/main/java/io/novafoundation/nova/common/data/secrets/v2/SecretStoreV2.kt @@ -156,17 +156,21 @@ val AccountSecrets.isChainAccountSecrets suspend fun SecretStoreV2.getMetaAccountKeypair( metaId: Long, isEthereum: Boolean, + isBitcoin: Boolean = false, ): Keypair = withContext(Dispatchers.Default) { val secrets = getMetaAccountSecrets(metaId) ?: noMetaSecrets(metaId) - mapMetaAccountSecretsToKeypair(secrets, isEthereum) + mapMetaAccountSecretsToKeypair(secrets, isEthereum, isBitcoin) } fun mapMetaAccountSecretsToKeypair( secrets: EncodableStruct, ethereum: Boolean, + bitcoin: Boolean = false, ): Keypair { - val keypairStruct = if (ethereum) { + val keypairStruct = if (bitcoin) { + secrets[MetaAccountSecrets.BitcoinKeypair] ?: noBitcoinSecret() + } else if (ethereum) { secrets[MetaAccountSecrets.EthereumKeypair] ?: noEthereumSecret() } else { secrets[MetaAccountSecrets.SubstrateKeypair] @@ -178,8 +182,11 @@ fun mapMetaAccountSecretsToKeypair( fun mapMetaAccountSecretsToDerivationPath( secrets: EncodableStruct, ethereum: Boolean, + bitcoin: Boolean = false, ): String? { - return if (ethereum) { + return if (bitcoin) { + secrets[MetaAccountSecrets.BitcoinDerivationPath] + } else if (ethereum) { secrets[MetaAccountSecrets.EthereumDerivationPath] } else { secrets[MetaAccountSecrets.SubstrateDerivationPath] @@ -198,6 +205,8 @@ private fun noChainSecrets(metaId: Long, accountId: ByteArray): Nothing { private fun noEthereumSecret(): Nothing = error("No ethereum keypair found") +private fun noBitcoinSecret(): Nothing = error("No bitcoin keypair found") + fun mapKeypairStructToKeypair(struct: EncodableStruct): Keypair { return Keypair( publicKey = struct[KeyPairSchema.PublicKey], diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/model/Fee.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/model/Fee.kt index d683e7fa..2bedc7a7 100644 --- a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/model/Fee.kt +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/model/Fee.kt @@ -63,6 +63,18 @@ class SubstrateFee( override val asset: Chain.Asset ) : Fee +/** + * Fee for a Bitcoin transaction, denominated in sats: `feeRate (sat/vB) * estimated vsize` - see + * `RealBitcoinTransactionService` for how both are derived. The network only ever collects what miners include + * in a block, which is exactly this amount (unlike account-model chains, a Bitcoin fee is not a cap/estimate + * that gets partially refunded - it is the literal difference between input and output values). + */ +class BitcoinFee( + override val amount: BigInteger, + override val submissionOrigin: SubmissionOrigin, + override val asset: Chain.Asset +) : Fee + class SubstrateFeeBase( override val amount: BigInteger, override val asset: Chain.Asset diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/secrets/SecretStoreExt.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/secrets/SecretStoreExt.kt index 5f172b4d..c132e8b0 100644 --- a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/secrets/SecretStoreExt.kt +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/secrets/SecretStoreExt.kt @@ -25,14 +25,14 @@ suspend fun SecretStoreV2.getAccountSecrets( fun AccountSecrets.keypair(chain: Chain): Keypair { return fold( - left = { mapMetaAccountSecretsToKeypair(it, ethereum = chain.isEthereumBased) }, + left = { mapMetaAccountSecretsToKeypair(it, ethereum = chain.isEthereumBased, bitcoin = chain.isBitcoinBased) }, right = { mapChainAccountSecretsToKeypair(it) } ) } fun AccountSecrets.derivationPath(chain: Chain): String? { return fold( - left = { mapMetaAccountSecretsToDerivationPath(it, ethereum = chain.isEthereumBased) }, + left = { mapMetaAccountSecretsToDerivationPath(it, ethereum = chain.isEthereumBased, bitcoin = chain.isBitcoinBased) }, right = { it[ChainAccountSecrets.DerivationPath] } ) } diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/secrets/SecretsSigner.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/secrets/SecretsSigner.kt index 2336b9ef..74264155 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/secrets/SecretsSigner.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/secrets/SecretsSigner.kt @@ -146,7 +146,8 @@ class SecretsSigner( return secretStoreV2.getKeypair( metaAccount = metaAccount, accountId = accountId, - isEthereumBased = multiChainEncryption is MultiChainEncryption.Ethereum + isEthereumBased = multiChainEncryption is MultiChainEncryption.Ethereum, + isBitcoinBased = metaAccount.bitcoinAddress?.contentEquals(accountId) == true ) } @@ -159,11 +160,12 @@ class SecretsSigner( private suspend fun SecretStoreV2.getKeypair( metaAccount: MetaAccount, accountId: AccountId, - isEthereumBased: Boolean + isEthereumBased: Boolean, + isBitcoinBased: Boolean = false, ) = if (hasChainSecrets(metaAccount.id, accountId)) { getChainAccountKeypair(metaAccount.id, accountId) } else { - getMetaAccountKeypair(metaAccount.id, isEthereumBased) + getMetaAccountKeypair(metaAccount.id, isEthereumBased, isBitcoinBased) } /** @@ -173,6 +175,7 @@ class SecretsSigner( return when { substrateAccountId.contentEquals(accountId) -> substrateCryptoType?.let(MultiChainEncryption.Companion::substrateFrom) ethereumAccountId().contentEquals(accountId) -> MultiChainEncryption.Ethereum + bitcoinAddress?.contentEquals(accountId) == true -> MultiChainEncryption.Ethereum else -> { val chainAccount = chainAccounts.values.firstOrNull { it.accountId.contentEquals(accountId) } ?: return null val cryptoType = chainAccount.cryptoType ?: return null diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/tranfers/TransactionExecution.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/tranfers/TransactionExecution.kt index b4ec88b6..e1e5d3dc 100644 --- a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/tranfers/TransactionExecution.kt +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/tranfers/TransactionExecution.kt @@ -8,4 +8,6 @@ sealed interface TransactionExecution { class Ethereum(val ethereumTransactionExecution: EthereumTransactionExecution) : TransactionExecution class Substrate(val extrinsicExecutionResult: ExtrinsicExecutionResult) : TransactionExecution + + class Bitcoin(val hash: String) : TransactionExecution } diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/bitcoin/BitcoinApi.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/bitcoin/BitcoinApi.kt index 8504babd..f7f79b2f 100644 --- a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/bitcoin/BitcoinApi.kt +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/bitcoin/BitcoinApi.kt @@ -5,9 +5,25 @@ import kotlinx.coroutines.delay import retrofit2.HttpException import java.math.BigInteger +/** A single unspent output, as needed to select inputs and build a transaction. */ +data class BitcoinUtxo( + val txid: String, + val vout: Int, + val valueSat: Long, + val confirmed: Boolean, +) + interface BitcoinApi { suspend fun fetchNativeBalance(baseUrl: String, address: String): Balance + + suspend fun fetchUtxos(baseUrl: String, address: String): List + + /** sat/vB, mempool.space's ~30-minute-confirmation estimate - a balanced default, neither cheapest nor fastest. */ + suspend fun fetchRecommendedFeeRateSatPerVbyte(baseUrl: String): Long + + /** @param rawTxHex fully signed raw transaction, hex-encoded. @return the broadcast transaction's txid. */ + suspend fun broadcastTransaction(baseUrl: String, rawTxHex: String): String } class RealBitcoinApi( @@ -24,6 +40,29 @@ class RealBitcoinApi( return (funded - spent).toBigInteger().coerceAtLeast(BigInteger.ZERO) } + override suspend fun fetchUtxos(baseUrl: String, address: String): List { + val response = retryOn429 { retrofitApi.getUtxos("${baseUrl.trimEnd('/')}/address/$address/utxo") } + + return response.map { utxo -> + BitcoinUtxo( + txid = utxo.txid, + vout = utxo.vout, + valueSat = utxo.value, + confirmed = utxo.status?.confirmed == true + ) + } + } + + override suspend fun fetchRecommendedFeeRateSatPerVbyte(baseUrl: String): Long { + val fees = retryOn429 { retrofitApi.getRecommendedFees("${baseUrl.trimEnd('/')}/v1/fees/recommended") } + + return fees.halfHourFee ?: fees.hourFee ?: fees.fastestFee ?: 1L + } + + override suspend fun broadcastTransaction(baseUrl: String, rawTxHex: String): String { + return retryOn429 { retrofitApi.broadcastTransaction("${baseUrl.trimEnd('/')}/tx", rawTxHex) }.trim() + } + private fun addressUrl(baseUrl: String, address: String): String { return "${baseUrl.trimEnd('/')}/address/$address" } diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/bitcoin/RetrofitBitcoinApi.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/bitcoin/RetrofitBitcoinApi.kt index a0d6af41..35853a28 100644 --- a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/bitcoin/RetrofitBitcoinApi.kt +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/bitcoin/RetrofitBitcoinApi.kt @@ -2,8 +2,12 @@ package io.novafoundation.nova.feature_wallet_impl.data.network.bitcoin import io.novafoundation.nova.common.data.network.UserAgent import io.novafoundation.nova.feature_wallet_impl.data.network.bitcoin.model.BitcoinAddressResponse +import io.novafoundation.nova.feature_wallet_impl.data.network.bitcoin.model.BitcoinFeeEstimateResponse +import io.novafoundation.nova.feature_wallet_impl.data.network.bitcoin.model.BitcoinUtxoResponse +import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.Headers +import retrofit2.http.POST import retrofit2.http.Url interface RetrofitBitcoinApi { @@ -11,4 +15,16 @@ interface RetrofitBitcoinApi { @GET @Headers(UserAgent.NOVA) suspend fun getAddress(@Url url: String): BitcoinAddressResponse + + @GET + @Headers(UserAgent.NOVA) + suspend fun getUtxos(@Url url: String): List + + @GET + @Headers(UserAgent.NOVA) + suspend fun getRecommendedFees(@Url url: String): BitcoinFeeEstimateResponse + + @POST + @Headers(UserAgent.NOVA) + suspend fun broadcastTransaction(@Url url: String, @Body rawTxHex: String): String } diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/bitcoin/model/BitcoinUtxoResponse.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/bitcoin/model/BitcoinUtxoResponse.kt new file mode 100644 index 00000000..0468e700 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/bitcoin/model/BitcoinUtxoResponse.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.bitcoin.model + +/** Response shape of mempool.space's `GET /address/{address}/utxo`. */ +class BitcoinUtxoResponse( + val txid: String, + val vout: Int, + val value: Long, + val status: BitcoinUtxoStatus? = null, +) + +class BitcoinUtxoStatus( + val confirmed: Boolean = false, +) + +/** Response shape of mempool.space's `GET /v1/fees/recommended`, all values in sat/vB. */ +class BitcoinFeeEstimateResponse( + val fastestFee: Long? = null, + val halfHourFee: Long? = null, + val hourFee: Long? = null, + val economyFee: Long? = null, + val minimumFee: Long? = null, +) diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/bitcoin/transaction/BitcoinTransactionService.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/bitcoin/transaction/BitcoinTransactionService.kt new file mode 100644 index 00000000..8f4fb41b --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/bitcoin/transaction/BitcoinTransactionService.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.bitcoin.transaction + +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.TransactionExecution +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId +import java.math.BigInteger + +/** + * Mirrors [io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction.TronTransactionService]'s + * shape (calculateFee/transact/transactAndAwaitExecution over a sending origin), but for Bitcoin. See + * `RealBitcoinTransactionService` for the UTXO-model construction/signing details, which differ substantially + * from Tron's account-model approach. + */ +interface BitcoinTransactionService { + + suspend fun calculateFee(chain: Chain, origin: TransactionOrigin, recipient: AccountId, amountSat: BigInteger): Fee + + suspend fun transact( + chain: Chain, + origin: TransactionOrigin, + recipient: AccountId, + presetFee: Fee?, + amountSat: BigInteger + ): Result + + suspend fun transactAndAwaitExecution( + chain: Chain, + origin: TransactionOrigin, + recipient: AccountId, + presetFee: Fee?, + amountSat: BigInteger + ): Result +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/bitcoin/transaction/RealBitcoinTransactionService.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/bitcoin/transaction/RealBitcoinTransactionService.kt new file mode 100644 index 00000000..afebd1a0 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/bitcoin/transaction/RealBitcoinTransactionService.kt @@ -0,0 +1,186 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.bitcoin.transaction + +import io.novafoundation.nova.common.utils.BitcoinInput +import io.novafoundation.nova.common.utils.BitcoinOutput +import io.novafoundation.nova.common.utils.BitcoinTransaction +import io.novafoundation.nova.common.utils.DerSignature +import io.novafoundation.nova.common.utils.toBitcoinAddress +import io.novafoundation.nova.common.utils.toEcdsaSignatureData +import io.novafoundation.nova.common.utils.toP2wpkhScriptPubKey +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission +import io.novafoundation.nova.feature_account_api.data.extrinsic.SubmissionOrigin +import io.novafoundation.nova.feature_account_api.data.model.BitcoinFee +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_account_api.data.signer.CallExecutionType +import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider +import io.novafoundation.nova.feature_account_api.data.signer.SubmissionHierarchy +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.requireMetaAccountFor +import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdIn +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.TransactionExecution +import io.novafoundation.nova.feature_wallet_impl.data.network.bitcoin.BitcoinApi +import io.novafoundation.nova.feature_wallet_impl.data.network.bitcoin.BitcoinUtxo +import io.novafoundation.nova.runtime.ext.commissionAsset +import io.novafoundation.nova.runtime.ext.requireMempoolSpaceBaseUrl +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignerPayloadRaw +import java.math.BigInteger + +/** mempool.space's own dust threshold for a P2WPKH output - see `RealBitcoinTransactionService`'s doc for how this is used. */ +private const val DUST_LIMIT_SAT = 546L + +private const val TX_VERSION = 2 +private const val LOCKTIME = 0 + +private data class UtxoSelection( + val selectedUtxos: List, + val changeSat: Long, + val feeSat: Long, +) + +/** + * Builds, signs and broadcasts native SegWit (P2WPKH) Bitcoin transactions - UTXO selection and raw construction + * happen entirely client-side (no server-assisted "createtransaction" the way TronGrid offers - mempool.space + * only exposes UTXOs/fee-rate/broadcast, not transaction construction), using the hand-rolled protocol + * primitives in [BitcoinTransaction]/[DerSignature]/`BitcoinAddress.kt` verified against BIP143/BIP173. + * + * ## UTXO selection + * Greedy largest-first over CONFIRMED UTXOs only (unconfirmed outputs are skipped - spending them risks the + * whole transaction unraveling if the parent is replaced/dropped), matching the exchange's proven approach. + * If the leftover after amount+fee would be below Bitcoin's dust threshold ([DUST_LIMIT_SAT]), no change output + * is created and the leftover is folded into the fee instead of producing an uneconomical-to-spend output. + * + * ## Fee + * `feeRate (sat/vB, from mempool.space's ~30-minute estimate) * estimated vsize` (the same + * `inputs*68 + outputs*31 + 11` heuristic already proven in production by `pezkuwi-exchange/wallet-service`). + * + * ## Signing + * Each input gets its own BIP143 sighash (unlike Tron/Ethereum's single whole-transaction hash) - computed by + * [BitcoinTransaction.bip143Sighash], signed via the same [io.novafoundation.nova.feature_account_api.data.signer.NovaSigner.signRaw] + * primitive Tron/Ethereum already use (`skipMessageHashing = true`, since the sighash is already the final + * digest to sign), then DER-encoded with low-S normalization via [DerSignature] - Bitcoin's one departure from + * Tron/Ethereum's fixed compact r+s+v format. No new signing call path was added. + * + * ## Broadcast + * `POST /tx` with the fully serialized signed transaction, hex-encoded, as a raw text body. + */ +class RealBitcoinTransactionService( + private val accountRepository: AccountRepository, + private val signerProvider: SignerProvider, + private val bitcoinApi: BitcoinApi, +) : BitcoinTransactionService { + + override suspend fun calculateFee(chain: Chain, origin: TransactionOrigin, recipient: AccountId, amountSat: BigInteger): Fee { + val submittingMetaAccount = accountRepository.requireMetaAccountFor(origin, chain.id) + val ownerAccountId = submittingMetaAccount.requireAccountIdIn(chain) + val baseUrl = chain.requireMempoolSpaceBaseUrl() + + val utxos = confirmedUtxos(baseUrl, ownerAccountId) + val feeRate = bitcoinApi.fetchRecommendedFeeRateSatPerVbyte(baseUrl) + val selection = selectUtxos(utxos, amountSat.toLong().coerceAtLeast(0), feeRate) + + val feeSat = selection?.feeSat ?: 0L + + return BitcoinFee(feeSat.toBigInteger(), SubmissionOrigin.singleOrigin(ownerAccountId), chain.commissionAsset) + } + + override suspend fun transact( + chain: Chain, + origin: TransactionOrigin, + recipient: AccountId, + presetFee: Fee?, + amountSat: BigInteger + ): Result = runCatching { + val submittingMetaAccount = accountRepository.requireMetaAccountFor(origin, chain.id) + val ownerAccountId = submittingMetaAccount.requireAccountIdIn(chain) + val ownerPublicKey = requireNotNull(submittingMetaAccount.bitcoinPublicKey) { + "No bitcoin public key found for meta account ${submittingMetaAccount.id}" + } + val baseUrl = chain.requireMempoolSpaceBaseUrl() + val amountSatLong = amountSat.toLong() + + val utxos = confirmedUtxos(baseUrl, ownerAccountId) + val feeRate = bitcoinApi.fetchRecommendedFeeRateSatPerVbyte(baseUrl) + val selection = selectUtxos(utxos, amountSatLong, feeRate) + ?: error("Insufficient confirmed UTXOs to cover amount + fee") + + val inputs = selection.selectedUtxos.map { utxo -> + BitcoinInput(txid = utxo.txid.fromHex(), vout = utxo.vout, valueSat = utxo.valueSat) + } + + val outputs = buildList { + add(BitcoinOutput(valueSat = amountSatLong, scriptPubKey = recipient.toP2wpkhScriptPubKey())) + if (selection.changeSat > 0) { + add(BitcoinOutput(valueSat = selection.changeSat, scriptPubKey = ownerAccountId.toP2wpkhScriptPubKey())) + } + } + + val signer = signerProvider.rootSignerFor(submittingMetaAccount) + + val witnesses = inputs.indices.map { index -> + val sighash = BitcoinTransaction.bip143Sighash(TX_VERSION, inputs, outputs, index, ownerAccountId, LOCKTIME) + val signedRaw = signer.signRaw(SignerPayloadRaw(message = sighash, accountId = ownerAccountId, skipMessageHashing = true)) + val signature = signedRaw.toEcdsaSignatureData() + + DerSignature.encode(signature.r, signature.s) to ownerPublicKey + } + + val signedTxBytes = BitcoinTransaction.serializeSigned(TX_VERSION, inputs, outputs, witnesses, LOCKTIME) + val txid = bitcoinApi.broadcastTransaction(baseUrl, signedTxBytes.toHexString(withPrefix = false)) + + ExtrinsicSubmission( + hash = txid, + submissionOrigin = SubmissionOrigin.singleOrigin(ownerAccountId), + callExecutionType = CallExecutionType.IMMEDIATE, + submissionHierarchy = SubmissionHierarchy(submittingMetaAccount, CallExecutionType.IMMEDIATE) + ) + } + + override suspend fun transactAndAwaitExecution( + chain: Chain, + origin: TransactionOrigin, + recipient: AccountId, + presetFee: Fee?, + amountSat: BigInteger + ): Result { + // Broadcast acceptance is already a strong signal (same posture as Tron/EVM) - this sits outside the + // primary send flow's critical path, which only ever calls transact(). + return transact(chain, origin, recipient, presetFee, amountSat).map { TransactionExecution.Bitcoin(it.hash) } + } + + private suspend fun confirmedUtxos(baseUrl: String, ownerAccountId: AccountId): List { + val address = ownerAccountId.toBitcoinAddress() + + return bitcoinApi.fetchUtxos(baseUrl, address).filter { it.confirmed } + } + + private fun selectUtxos(utxos: List, amountSat: Long, feeRateSatPerVbyte: Long): UtxoSelection? { + val sorted = utxos.sortedByDescending { it.valueSat } + val selected = mutableListOf() + var total = 0L + + for (utxo in sorted) { + selected += utxo + total += utxo.valueSat + + val vsizeWithChange = BitcoinTransaction.estimateVsize(selected.size, outputCount = 2) + val feeWithChange = feeRateSatPerVbyte * vsizeWithChange + if (total < amountSat + feeWithChange) continue + + val changeSat = total - amountSat - feeWithChange + + return if (changeSat >= DUST_LIMIT_SAT) { + UtxoSelection(selected.toList(), changeSat = changeSat, feeSat = feeWithChange) + } else { + // Folding a sub-dust leftover into the fee instead of creating an uneconomical-to-spend output. + UtxoSelection(selected.toList(), changeSat = 0, feeSat = total - amountSat) + } + } + + return null + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/bitcoinNative/BitcoinNativeAssetTransfers.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/bitcoinNative/BitcoinNativeAssetTransfers.kt new file mode 100644 index 00000000..595fe60a --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/bitcoinNative/BitcoinNativeAssetTransfers.kt @@ -0,0 +1,87 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.bitcoinNative + +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.intoOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfer +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfers +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.TransactionExecution +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.WeightedAssetTransfer +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.amountInPlanks +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.model.TransferParsedFromCall +import io.novafoundation.nova.feature_wallet_impl.data.network.bitcoin.transaction.BitcoinTransactionService +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.checkForFeeChanges +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.positiveAmount +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.recipientIsNotSystemAccount +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.sufficientBalanceInUsedAsset +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.sufficientTransferableBalanceToPayOriginFee +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.validAddress +import io.novafoundation.nova.feature_wallet_impl.domain.validaiton.recipientCanAcceptTransfer +import io.novafoundation.nova.runtime.ext.accountIdOrDefault +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import kotlinx.coroutines.CoroutineScope + +/** + * Native BTC transfer. No RBF fee-bumping or replace-by-fee UI is implemented here (out of scope for this + * send-only phase) - [BitcoinTransactionService] signals RBF (BIP125) on every input regardless, so a stuck + * transaction remains bumpable from a compatible wallet even without in-app support. + */ +class BitcoinNativeAssetTransfers( + private val bitcoinTransactionService: BitcoinTransactionService, + private val assetSourceRegistry: AssetSourceRegistry, +) : AssetTransfers { + + override fun getValidationSystem(coroutineScope: CoroutineScope) = ValidationSystem { + validAddress() + recipientIsNotSystemAccount() + + positiveAmount() + + sufficientBalanceInUsedAsset() + sufficientTransferableBalanceToPayOriginFee() + + recipientCanAcceptTransfer(assetSourceRegistry) + + checkForFeeChanges(assetSourceRegistry, coroutineScope) + } + + override suspend fun calculateFee(transfer: AssetTransfer, coroutineScope: CoroutineScope): Fee { + return bitcoinTransactionService.calculateFee( + chain = transfer.originChain, + origin = transfer.sender.intoOrigin(), + recipient = transfer.originChain.accountIdOrDefault(transfer.recipient), + amountSat = transfer.amountInPlanks + ) + } + + override suspend fun performTransfer(transfer: WeightedAssetTransfer, coroutineScope: CoroutineScope): Result { + return bitcoinTransactionService.transact( + chain = transfer.originChain, + origin = transfer.sender.intoOrigin(), + recipient = transfer.originChain.accountIdOrDefault(transfer.recipient), + presetFee = transfer.fee.submissionFee, + amountSat = transfer.amountInPlanks + ) + } + + override suspend fun performTransferAndAwaitExecution(transfer: WeightedAssetTransfer, coroutineScope: CoroutineScope): Result { + return bitcoinTransactionService.transactAndAwaitExecution( + chain = transfer.originChain, + origin = transfer.sender.intoOrigin(), + recipient = transfer.originChain.accountIdOrDefault(transfer.recipient), + presetFee = transfer.fee.submissionFee, + amountSat = transfer.amountInPlanks + ) + } + + override suspend fun areTransfersEnabled(chainAsset: Chain.Asset): Boolean { + return true + } + + override suspend fun parseTransfer(call: GenericCall.Instance, chain: Chain): TransferParsedFromCall? { + return null + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/BitcoinAssetsModule.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/BitcoinAssetsModule.kt index 9c1ff2bf..4e024b8f 100644 --- a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/BitcoinAssetsModule.kt +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/BitcoinAssetsModule.kt @@ -4,26 +4,31 @@ import dagger.Module import dagger.Provides import io.novafoundation.nova.common.data.network.NetworkApiCreator import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository import io.novafoundation.nova.feature_wallet_api.data.cache.AssetCache import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSource +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry import io.novafoundation.nova.feature_wallet_impl.data.network.bitcoin.BitcoinApi import io.novafoundation.nova.feature_wallet_impl.data.network.bitcoin.RealBitcoinApi import io.novafoundation.nova.feature_wallet_impl.data.network.bitcoin.RetrofitBitcoinApi +import io.novafoundation.nova.feature_wallet_impl.data.network.bitcoin.transaction.BitcoinTransactionService +import io.novafoundation.nova.feature_wallet_impl.data.network.bitcoin.transaction.RealBitcoinTransactionService import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.StaticAssetSource import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.bitcoinNative.BitcoinNativeAssetBalance import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.UnsupportedAssetHistory -import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.UnsupportedAssetTransfers +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.bitcoinNative.BitcoinNativeAssetTransfers import javax.inject.Qualifier @Qualifier annotation class BitcoinNativeAssets /** - * Bitcoin support - Phase 4, read-only. + * Bitcoin support: `balance` (REST polling against mempool.space) and `transfers` (client-side UTXO selection, + * BIP143 signing, raw broadcast - see `RealBitcoinTransactionService` for the full construction/signing notes). * - * Only `balance` is implemented for real; `transfers`/`history` reuse the same `Unsupported*` stubs the rest of - * the app uses for asset types with no send/history support yet (see `TronAssetsModule` for the identical - * Phase-1 precedent this mirrors). Send support is separate, later work. + * `history` remains unsupported (out of scope for this phase, same as the rest of the app's `Unsupported*` + * stubs used for asset types without history support - see `TronAssetsModule` for the identical precedent). */ @Module class BitcoinAssetsModule { @@ -38,19 +43,38 @@ class BitcoinAssetsModule { @FeatureScope fun provideBitcoinApi(retrofitBitcoinApi: RetrofitBitcoinApi): BitcoinApi = RealBitcoinApi(retrofitBitcoinApi) + @Provides + @FeatureScope + fun provideBitcoinTransactionService( + accountRepository: AccountRepository, + signerProvider: SignerProvider, + bitcoinApi: BitcoinApi, + ): BitcoinTransactionService = RealBitcoinTransactionService( + accountRepository = accountRepository, + signerProvider = signerProvider, + bitcoinApi = bitcoinApi + ) + @Provides @FeatureScope fun provideBitcoinNativeBalance(assetCache: AssetCache, bitcoinApi: BitcoinApi) = BitcoinNativeAssetBalance(assetCache, bitcoinApi) + @Provides + @FeatureScope + fun provideBitcoinNativeAssetTransfers( + bitcoinTransactionService: BitcoinTransactionService, + assetSourceRegistry: AssetSourceRegistry, + ) = BitcoinNativeAssetTransfers(bitcoinTransactionService, assetSourceRegistry) + @Provides @BitcoinNativeAssets @FeatureScope fun provideBitcoinNativeAssetSource( bitcoinNativeAssetBalance: BitcoinNativeAssetBalance, - unsupportedAssetTransfers: UnsupportedAssetTransfers, + bitcoinNativeAssetTransfers: BitcoinNativeAssetTransfers, unsupportedAssetHistory: UnsupportedAssetHistory, ): AssetSource = StaticAssetSource( - transfers = unsupportedAssetTransfers, + transfers = bitcoinNativeAssetTransfers, balance = bitcoinNativeAssetBalance, history = unsupportedAssetHistory )