mirror of
https://github.com/pezkuwichain/pezkuwi-wallet-android.git
synced 2026-07-22 09:05:48 +00:00
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:
+12
-3
@@ -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],
|
||||
|
||||
+12
@@ -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
|
||||
|
||||
+2
-2
@@ -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] }
|
||||
)
|
||||
}
|
||||
|
||||
+6
-3
@@ -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
|
||||
|
||||
+2
@@ -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
|
||||
}
|
||||
|
||||
+39
@@ -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"
|
||||
}
|
||||
|
||||
+16
@@ -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
|
||||
}
|
||||
|
||||
+22
@@ -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,
|
||||
)
|
||||
+36
@@ -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>
|
||||
}
|
||||
+186
@@ -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
|
||||
}
|
||||
}
|
||||
+87
@@ -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
|
||||
}
|
||||
}
|
||||
+31
-7
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user