feat: support sending BTC to P2SH/P2PKH destination addresses

Confirmed live via logcat: the real "QR can't be decoded" error was
neither a QR format issue (the earlier BIP21 fix, while correct, wasn't
the cause here) nor a scan failure - the raw QR content was a bare P2SH
address (3...), which this wallet's native-SegWit-only address validation
rejected outright. A real exchange (OKX) withdrawal address turned out to
be P2SH-only with no way to request native SegWit instead.

Adds Base58Check decoding and BitcoinDestinationAddress.kt (P2WPKH/P2SH/
P2PKH, each producing the correct scriptPubKey shape - these differ from
each other, not just cosmetically). This wallet's OWN address/derivation
stays native-SegWit-only and untouched (bitcoinAddressToAccountId is
unaffected) - only the SEND path now recognizes wider destination types.

BitcoinTransactionService/RealBitcoinTransactionService now take the raw
recipient address string instead of a pre-resolved AccountId - converting
to AccountId this early would have discarded which script shape the
destination actually needs, silently building a P2WPKH output for a P2SH
recipient (a real fund-misdirection risk, not just a validation gap).

Test vectors: Satoshi's genesis P2PKH address, a well-known P2SH vanity
address, and the real OKX withdrawal address from this session's live QR
scan, all cross-verified against an independent Python implementation
before writing the Kotlin equivalent.

Reverts the temporary QrDiag logging added to find this - no longer needed.
This commit is contained in:
2026-07-13 05:54:37 -07:00
parent ff19c7c4ae
commit d4faa64c19
8 changed files with 243 additions and 23 deletions
@@ -5,7 +5,6 @@ import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmis
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
/**
@@ -13,15 +12,21 @@ import java.math.BigInteger
* 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.
*
* [recipientAddress] is the raw destination address string, not an [io.novasama.substrate_sdk_android.runtime.AccountId] -
* unlike every other chain's transfer path, a Bitcoin destination can legitimately be a P2SH/P2PKH address (a
* real exchange withdrawal address was confirmed to be P2SH-only, with no way to request native SegWit instead),
* which needs its own distinct scriptPubKey shape. Converting to a generic 20-byte AccountId this early would
* discard which shape it needs to be - see `BitcoinDestinationAddress.kt`.
*/
interface BitcoinTransactionService {
suspend fun calculateFee(chain: Chain, origin: TransactionOrigin, recipient: AccountId, amountSat: BigInteger): Fee
suspend fun calculateFee(chain: Chain, origin: TransactionOrigin, recipientAddress: String, amountSat: BigInteger): Fee
suspend fun transact(
chain: Chain,
origin: TransactionOrigin,
recipient: AccountId,
recipientAddress: String,
presetFee: Fee?,
amountSat: BigInteger
): Result<ExtrinsicSubmission>
@@ -29,7 +34,7 @@ interface BitcoinTransactionService {
suspend fun transactAndAwaitExecution(
chain: Chain,
origin: TransactionOrigin,
recipient: AccountId,
recipientAddress: String,
presetFee: Fee?,
amountSat: BigInteger
): Result<TransactionExecution>
@@ -4,9 +4,11 @@ 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.decodeBitcoinDestination
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.common.utils.toScriptPubKey
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
@@ -43,10 +45,12 @@ private data class UtxoSelection(
)
/**
* 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.
* Builds, signs and broadcasts Bitcoin transactions from this wallet's own native SegWit (P2WPKH) address - the
* recipient output, however, can be P2WPKH, P2SH or P2PKH (see `BitcoinDestinationAddress.kt`; a real exchange
* withdrawal address was confirmed live to be P2SH-only). 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
@@ -74,7 +78,7 @@ class RealBitcoinTransactionService(
private val bitcoinApi: BitcoinApi,
) : BitcoinTransactionService {
override suspend fun calculateFee(chain: Chain, origin: TransactionOrigin, recipient: AccountId, amountSat: BigInteger): Fee {
override suspend fun calculateFee(chain: Chain, origin: TransactionOrigin, recipientAddress: String, amountSat: BigInteger): Fee {
val submittingMetaAccount = accountRepository.requireMetaAccountFor(origin, chain.id)
val ownerAccountId = submittingMetaAccount.requireAccountIdIn(chain)
val baseUrl = chain.requireMempoolSpaceBaseUrl()
@@ -91,7 +95,7 @@ class RealBitcoinTransactionService(
override suspend fun transact(
chain: Chain,
origin: TransactionOrigin,
recipient: AccountId,
recipientAddress: String,
presetFee: Fee?,
amountSat: BigInteger
): Result<ExtrinsicSubmission> = runCatching {
@@ -102,6 +106,7 @@ class RealBitcoinTransactionService(
}
val baseUrl = chain.requireMempoolSpaceBaseUrl()
val amountSatLong = amountSat.toLong()
val recipientScriptPubKey = recipientAddress.decodeBitcoinDestination().toScriptPubKey()
val utxos = confirmedUtxos(baseUrl, ownerAccountId)
val feeRate = bitcoinApi.fetchRecommendedFeeRateSatPerVbyte(baseUrl)
@@ -113,7 +118,7 @@ class RealBitcoinTransactionService(
}
val outputs = buildList {
add(BitcoinOutput(valueSat = amountSatLong, scriptPubKey = recipient.toP2wpkhScriptPubKey()))
add(BitcoinOutput(valueSat = amountSatLong, scriptPubKey = recipientScriptPubKey))
if (selection.changeSat > 0) {
add(BitcoinOutput(valueSat = selection.changeSat, scriptPubKey = ownerAccountId.toP2wpkhScriptPubKey()))
}
@@ -143,13 +148,13 @@ class RealBitcoinTransactionService(
override suspend fun transactAndAwaitExecution(
chain: Chain,
origin: TransactionOrigin,
recipient: AccountId,
recipientAddress: String,
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) }
return transact(chain, origin, recipientAddress, presetFee, amountSat).map { TransactionExecution.Bitcoin(it.hash) }
}
private suspend fun confirmedUtxos(baseUrl: String, ownerAccountId: AccountId): List<BitcoinUtxo> {
@@ -19,7 +19,6 @@ import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets
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
@@ -52,7 +51,7 @@ class BitcoinNativeAssetTransfers(
return bitcoinTransactionService.calculateFee(
chain = transfer.originChain,
origin = transfer.sender.intoOrigin(),
recipient = transfer.originChain.accountIdOrDefault(transfer.recipient),
recipientAddress = transfer.recipient,
amountSat = transfer.amountInPlanks
)
}
@@ -61,7 +60,7 @@ class BitcoinNativeAssetTransfers(
return bitcoinTransactionService.transact(
chain = transfer.originChain,
origin = transfer.sender.intoOrigin(),
recipient = transfer.originChain.accountIdOrDefault(transfer.recipient),
recipientAddress = transfer.recipient,
presetFee = transfer.fee.submissionFee,
amountSat = transfer.amountInPlanks
)
@@ -71,7 +70,7 @@ class BitcoinNativeAssetTransfers(
return bitcoinTransactionService.transactAndAwaitExecution(
chain = transfer.originChain,
origin = transfer.sender.intoOrigin(),
recipient = transfer.originChain.accountIdOrDefault(transfer.recipient),
recipientAddress = transfer.recipient,
presetFee = transfer.fee.submissionFee,
amountSat = transfer.amountInPlanks
)