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
@@ -0,0 +1,53 @@
package io.novafoundation.nova.common.utils
import java.math.BigInteger
private const val BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
/**
* Base58Check - the legacy encoding used by Bitcoin's P2PKH (`1...`) and P2SH (`3...`) addresses, as opposed to
* native SegWit's bech32. Needed only to recognize/decode EXTERNAL destination addresses for sending (many
* exchanges, including at least one confirmed live, only offer a P2SH withdrawal address with no way to pick a
* native SegWit one) - this app's OWN address (derivation, receiving, `bitcoinAddressToAccountId()`) remains
* native SegWit only, unaffected by this.
*/
object Base58Check {
class Decoded(val version: Int, val payload: ByteArray)
fun decode(address: String): Decoded {
val full = base58Decode(address)
require(full.size == 25) { "Base58Check payload must be 25 bytes, got ${full.size}" }
val versionAndPayload = full.copyOfRange(0, 21)
val checksum = full.copyOfRange(21, 25)
val expectedChecksum = versionAndPayload.sha256d().copyOfRange(0, 4)
require(checksum.contentEquals(expectedChecksum)) { "Base58Check checksum mismatch" }
return Decoded(version = versionAndPayload[0].toInt() and 0xff, payload = versionAndPayload.copyOfRange(1, 21))
}
private fun base58Decode(input: String): ByteArray {
require(input.isNotEmpty()) { "Base58 input must not be empty" }
var value = BigInteger.ZERO
val base = BigInteger.valueOf(58)
for (c in input) {
val digit = BASE58_ALPHABET.indexOf(c)
require(digit >= 0) { "Invalid base58 character: $c" }
value = value.multiply(base).add(BigInteger.valueOf(digit.toLong()))
}
val bytes = value.toByteArray().let {
// BigInteger.toByteArray() may prepend a 0x00 sign-disambiguation byte - strip it, it is not part of the data.
if (it.size > 1 && it[0] == 0.toByte()) it.copyOfRange(1, it.size) else it
}
// Each leading '1' character encodes a leading zero byte that BigInteger's conversion would otherwise drop.
val leadingZeros = input.takeWhile { it == '1' }.length
return ByteArray(leadingZeros) + bytes
}
}
@@ -0,0 +1,60 @@
package io.novafoundation.nova.common.utils
private const val P2PKH_VERSION = 0x00
private const val P2SH_VERSION = 0x05
/**
* Any Bitcoin address this wallet can SEND to - a strict superset of what it can derive/receive as its own
* address (native SegWit only, see `BitcoinAddress.kt`). Needed because a real exchange withdrawal address was
* confirmed live to be P2SH (`3...`), which this app's original native-SegWit-only `isValidBitcoinAddress()`
* rejected outright, blocking the send with a misleading "QR can't be decoded" error (the QR was fine - a bare
* P2SH address - the address TYPE just wasn't recognized as a valid destination at all).
*
* Deliberately kept separate from [bitcoinAddressToAccountId]/[AccountId] - that function's 20-byte output feeds
* generic multi-chain code (`Chain.accountIdOf`) that assumes every account id is THIS wallet's own P2WPKH
* shape. Silently reusing it for a P2SH/P2PKH recipient would produce a 20-byte hash with no type tag, and
* downstream code would wrap it in the wrong (P2WPKH) scriptPubKey - sending funds to an address that doesn't
* match what was actually asked for. [BitcoinDestination] carries its type through to [toScriptPubKey] instead.
*/
sealed class BitcoinDestination {
data class NativeSegwit(val witnessProgram: ByteArray) : BitcoinDestination()
data class P2sh(val scriptHash: ByteArray) : BitcoinDestination()
data class P2pkh(val pubKeyHash: ByteArray) : BitcoinDestination()
}
fun String.decodeBitcoinDestination(): BitcoinDestination {
runCatching {
val decoded = SegwitAddress.decode("bc", this)
if (decoded.witnessVersion == 0 && decoded.witnessProgram.size == 20) {
return BitcoinDestination.NativeSegwit(decoded.witnessProgram)
}
}
val decoded = Base58Check.decode(this)
return when (decoded.version) {
P2PKH_VERSION -> BitcoinDestination.P2pkh(decoded.payload)
P2SH_VERSION -> BitcoinDestination.P2sh(decoded.payload)
else -> error("Unsupported Bitcoin address version byte: ${decoded.version}")
}
}
fun String.isValidBitcoinDestinationAddress(): Boolean = runCatching { decodeBitcoinDestination() }.isSuccess
/**
* @return the scriptPubKey a transaction output must use to actually pay this destination - P2SH/P2PKH have
* different script shapes from this wallet's own P2WPKH (see [toP2wpkhScriptPubKey]), despite all three being a
* "20-byte hash wrapped in a short script."
*/
fun BitcoinDestination.toScriptPubKey(): ByteArray = when (this) {
is BitcoinDestination.NativeSegwit -> byteArrayOf(0x00, 0x14) + witnessProgram
// OP_HASH160 <20-byte-push> OP_EQUAL
is BitcoinDestination.P2sh -> byteArrayOf(0xa9.toByte(), 0x14) + scriptHash + byteArrayOf(0x87.toByte())
// OP_DUP OP_HASH160 <20-byte-push> OP_EQUALVERIFY OP_CHECKSIG
is BitcoinDestination.P2pkh -> byteArrayOf(0x76, 0xa9.toByte(), 0x14) + pubKeyHash + byteArrayOf(0x88.toByte(), 0xac.toByte())
}