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.
This commit is contained in:
2026-07-12 15:51:37 -07:00
parent 1830024ed0
commit 6239526d43
12 changed files with 451 additions and 15 deletions
@@ -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<MetaAccountSecrets>,
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<MetaAccountSecrets>,
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<KeyPairSchema>): Keypair {
return Keypair(
publicKey = struct[KeyPairSchema.PublicKey],
@@ -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
@@ -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] }
)
}
@@ -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
@@ -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
}
@@ -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<BitcoinUtxo>
/** 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<BitcoinUtxo> {
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"
}
@@ -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<BitcoinUtxoResponse>
@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
}
@@ -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,
)
@@ -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<ExtrinsicSubmission>
suspend fun transactAndAwaitExecution(
chain: Chain,
origin: TransactionOrigin,
recipient: AccountId,
presetFee: Fee?,
amountSat: BigInteger
): Result<TransactionExecution>
}
@@ -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<BitcoinUtxo>,
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<ExtrinsicSubmission> = 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<TransactionExecution> {
// 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<BitcoinUtxo> {
val address = ownerAccountId.toBitcoinAddress()
return bitcoinApi.fetchUtxos(baseUrl, address).filter { it.confirmed }
}
private fun selectUtxos(utxos: List<BitcoinUtxo>, amountSat: Long, feeRateSatPerVbyte: Long): UtxoSelection? {
val sorted = utxos.sortedByDescending { it.valueSat }
val selected = mutableListOf<BitcoinUtxo>()
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
}
}
@@ -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<ExtrinsicSubmission> {
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<TransactionExecution> {
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
}
}
@@ -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
)