diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/Bech32.kt b/common/src/main/java/io/novafoundation/nova/common/utils/Bech32.kt new file mode 100644 index 00000000..307a78f4 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/Bech32.kt @@ -0,0 +1,164 @@ +package io.novafoundation.nova.common.utils + +/** + * Bech32 (BIP173) / Bech32m (BIP350) codec, hand-implemented since no such library is currently on this + * project's classpath (same rationale as [Base58]/[Base58Check] for Tron - this is a deterministic text + * encoding, not a secret-dependent cryptographic primitive). + * + * Direct port of the reference algorithm in BIP173/BIP350's `segwit_addr.py`. Cross-checked against BIP173's + * and BIP350's official test vectors - see [Bech32Test]. + */ +object Bech32 { + + private const val CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" + private const val BECH32_CONST = 1L + private const val BECH32M_CONST = 0x2bc830a3L + + enum class Encoding(val const: Long) { + BECH32(BECH32_CONST), + BECH32M(BECH32M_CONST) + } + + data class Decoded(val hrp: String, val values: IntArray, val encoding: Encoding) + + private fun polymod(values: IntArray): Long { + val gen = longArrayOf(0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3) + var chk = 1L + for (v in values) { + val b = (chk ushr 25) + chk = (chk and 0x1ffffff) shl 5 xor v.toLong() + for (i in 0 until 5) { + if ((b ushr i) and 1L == 1L) { + chk = chk xor gen[i] + } + } + } + return chk + } + + private fun hrpExpand(hrp: String): IntArray { + val lower = hrp.map { (it.code ushr 5) } + val upper = hrp.map { (it.code and 31) } + return (lower + listOf(0) + upper).toIntArray() + } + + private fun createChecksum(hrp: String, data: IntArray, encoding: Encoding): IntArray { + val values = hrpExpand(hrp) + data + IntArray(6) + val mod = polymod(values) xor encoding.const + return IntArray(6) { i -> ((mod ushr (5 * (5 - i))) and 31).toInt() } + } + + fun encode(hrp: String, data: IntArray, encoding: Encoding): String { + val checksum = createChecksum(hrp, data, encoding) + val combined = data + checksum + return hrp + "1" + combined.map { CHARSET[it] }.joinToString("") + } + + fun decode(input: String): Decoded { + require(input.length in 8..90) { "Bech32 string has invalid length: ${input.length}" } + require(input == input.lowercase() || input == input.uppercase()) { "Bech32 string is mixed case: $input" } + + val lower = input.lowercase() + val separatorIndex = lower.lastIndexOf('1') + require(separatorIndex >= 1) { "Bech32 string is missing separator '1': $input" } + require(separatorIndex + 7 <= lower.length) { "Bech32 data part too short: $input" } + + val hrp = lower.substring(0, separatorIndex) + val dataPart = lower.substring(separatorIndex + 1) + + val values = IntArray(dataPart.length) + for ((i, c) in dataPart.withIndex()) { + val v = CHARSET.indexOf(c) + require(v >= 0) { "Invalid Bech32 character: '$c' in $input" } + values[i] = v + } + + val checksumValue = polymod(hrpExpand(hrp) + values) + val encoding = when (checksumValue) { + BECH32_CONST -> Encoding.BECH32 + BECH32M_CONST -> Encoding.BECH32M + else -> throw IllegalArgumentException("Invalid Bech32/Bech32m checksum: $input") + } + + return Decoded(hrp, values.copyOfRange(0, values.size - 6), encoding) + } + + /** + * Regroups bits between arbitrary group sizes (e.g. 8-bit bytes <-> 5-bit Bech32 words). Direct port of + * BIP173's `convertbits`. + */ + fun convertBits(data: IntArray, fromBits: Int, toBits: Int, pad: Boolean): IntArray? { + var acc = 0 + var bits = 0 + val ret = mutableListOf() + val maxV = (1 shl toBits) - 1 + val maxAcc = (1 shl (fromBits + toBits - 1)) - 1 + + for (value in data) { + if (value < 0 || (value ushr fromBits) != 0) return null + + acc = ((acc shl fromBits) or value) and maxAcc + bits += fromBits + while (bits >= toBits) { + bits -= toBits + ret.add((acc ushr bits) and maxV) + } + } + + if (pad) { + if (bits > 0) ret.add((acc shl (toBits - bits)) and maxV) + } else if (bits >= fromBits || ((acc shl (toBits - bits)) and maxV) != 0) { + return null + } + + return ret.toIntArray() + } +} + +/** + * Segwit address encoding/decoding (BIP173 witness v0, BIP350 witness v1+/Bech32m) on top of the raw [Bech32] + * codec above. Only witness version 0 (P2WPKH/P2WSH) is actually used by this app today (native SegWit only - + * Taproot/witness v1 is explicitly out of scope for now), but decode handles both since a user could paste any + * valid segwit address. + */ +object SegwitAddress { + + fun encode(hrp: String, witnessVersion: Int, witnessProgram: ByteArray): String { + require(witnessVersion in 0..16) { "Invalid witness version: $witnessVersion" } + require(witnessProgram.size in 2..40) { "Invalid witness program length: ${witnessProgram.size}" } + + val programWords = Bech32.convertBits(witnessProgram.map { it.toInt() and 0xff }.toIntArray(), 8, 5, true) + ?: throw IllegalArgumentException("Failed to convert witness program to 5-bit words") + + val encoding = if (witnessVersion == 0) Bech32.Encoding.BECH32 else Bech32.Encoding.BECH32M + + return Bech32.encode(hrp, intArrayOf(witnessVersion) + programWords, encoding) + } + + data class Decoded(val witnessVersion: Int, val witnessProgram: ByteArray) + + fun decode(expectedHrp: String, address: String): Decoded { + val (hrp, values, encoding) = Bech32.decode(address) + require(hrp == expectedHrp) { "Unexpected HRP: expected $expectedHrp, got $hrp" } + require(values.isNotEmpty()) { "Empty Bech32 data part: $address" } + + val witnessVersion = values[0] + val expectedEncoding = if (witnessVersion == 0) Bech32.Encoding.BECH32 else Bech32.Encoding.BECH32M + require(encoding == expectedEncoding) { + "Witness version $witnessVersion requires ${expectedEncoding.name} but address used ${encoding.name}: $address" + } + + val programWords = values.copyOfRange(1, values.size) + val programBytes = Bech32.convertBits(programWords, 5, 8, false) + ?: throw IllegalArgumentException("Invalid witness program padding: $address") + + require(programBytes.size in 2..40) { "Invalid witness program length: $address" } + if (witnessVersion == 0) { + require(programBytes.size == 20 || programBytes.size == 32) { + "Witness v0 program must be 20 (P2WPKH) or 32 (P2WSH) bytes: $address" + } + } + + return Decoded(witnessVersion, ByteArray(programBytes.size) { programBytes[it].toByte() }) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/BitcoinAddress.kt b/common/src/main/java/io/novafoundation/nova/common/utils/BitcoinAddress.kt new file mode 100644 index 00000000..a4855ec2 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/BitcoinAddress.kt @@ -0,0 +1,56 @@ +package io.novafoundation.nova.common.utils + +import io.novasama.substrate_sdk_android.runtime.AccountId +import org.bouncycastle.jcajce.provider.digest.RIPEMD160 + +/** + * Native SegWit (P2WPKH) Bitcoin address support. Only witness v0 P2WPKH (`bc1q...`) is implemented - Taproot + * and legacy/P2SH-SegWit are explicitly out of scope for this phase (see the BTC integration plan). + * + * Bitcoin's "account id" here is the 20-byte HASH160 of the compressed secp256k1 public key - unlike + * Tron/Ethereum (which both derive their account id via keccak256 of the *uncompressed* pubkey), so this is + * NOT interchangeable with [tronPublicKeyToAccountId]/`asEthereumPublicKey().toAccountId()` despite all three + * using the same underlying secp256k1 keypair machinery. + */ +private const val BITCOIN_MAINNET_HRP = "bc" + +/** RIPEMD160(SHA256(x)) - the "HASH160" function used throughout Bitcoin for pubkey hashes and script hashes. */ +fun ByteArray.hash160(): ByteArray { + val ripemd160 = RIPEMD160.Digest() + return ripemd160.digest(this.sha256()) +} + +/** + * @param compressedPublicKey a 33-byte compressed secp256k1 public key (0x02/0x03 prefix + 32-byte x-coordinate). + */ +fun ByteArray.bitcoinPublicKeyToAccountId(): AccountId { + require(size == 33) { "Bitcoin native SegWit requires a compressed (33-byte) public key, got $size bytes" } + + return hash160() +} + +/** P2WPKH scriptPubKey: `OP_0 <20-byte-push> `, i.e. `0x00 0x14 <20 bytes>`. */ +fun AccountId.toP2wpkhScriptPubKey(): ByteArray { + require(size == 20) { "Bitcoin account id (HASH160) must be 20 bytes, got $size" } + + return byteArrayOf(0x00, 0x14) + this +} + +fun AccountId.toBitcoinAddress(): String { + require(size == 20) { "Bitcoin account id (HASH160) must be 20 bytes, got $size" } + + return SegwitAddress.encode(BITCOIN_MAINNET_HRP, witnessVersion = 0, witnessProgram = this) +} + +fun String.bitcoinAddressToAccountId(): AccountId { + val decoded = SegwitAddress.decode(BITCOIN_MAINNET_HRP, this) + require(decoded.witnessVersion == 0 && decoded.witnessProgram.size == 20) { + "Not a native SegWit P2WPKH address: $this" + } + + return decoded.witnessProgram +} + +fun String.isValidBitcoinAddress(): Boolean = runCatching { bitcoinAddressToAccountId() }.isSuccess + +fun emptyBitcoinAccountId() = ByteArray(20) { 1 } diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/BitcoinTransaction.kt b/common/src/main/java/io/novafoundation/nova/common/utils/BitcoinTransaction.kt new file mode 100644 index 00000000..96c97d72 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/BitcoinTransaction.kt @@ -0,0 +1,170 @@ +package io.novafoundation.nova.common.utils + +import java.io.ByteArrayOutputStream + +/** SHA256(SHA256(x)) - Bitcoin's standard "double SHA256", used for both txids and the BIP143 sighash. */ +fun ByteArray.sha256d(): ByteArray = sha256().sha256() + +/** Bitcoin's variable-length integer ("CompactSize") encoding, used throughout raw transaction serialization. */ +fun Long.toBitcoinVarInt(): ByteArray { + require(this >= 0) { "VarInt cannot encode a negative value: $this" } + val out = ByteArrayOutputStream() + when { + this < 0xfd -> out.write(toInt()) + this <= 0xffff -> { + out.write(0xfd) + out.write(toInt() and 0xff) + out.write((toInt() ushr 8) and 0xff) + } + this <= 0xffffffffL -> { + out.write(0xfe) + for (i in 0..3) out.write(((this ushr (8 * i)) and 0xff).toInt()) + } + else -> { + out.write(0xff) + for (i in 0..7) out.write(((this ushr (8 * i)) and 0xff).toInt()) + } + } + return out.toByteArray() +} + +private fun Int.toLeBytes(byteCount: Int): ByteArray = ByteArray(byteCount) { i -> ((this ushr (8 * i)) and 0xff).toByte() } + +private fun Long.toLeBytes(byteCount: Int): ByteArray = ByteArray(byteCount) { i -> ((this ushr (8 * i)) and 0xff).toByte() } + +/** + * A single UTXO being spent, in the form needed to build and sign a transaction. + * + * @param txid the previous transaction's id in standard (RPC/explorer-display) byte order - this class reverses + * it internally to the on-wire/internal order raw transactions actually use (see [reversedTxid]). + */ +data class BitcoinInput( + val txid: ByteArray, + val vout: Int, + val valueSat: Long, + val sequence: Long = 0xfffffffdL, // RBF-signaling (BIP125), matching the exchange's proven, already-live choice +) { + init { + require(txid.size == 32) { "txid must be 32 bytes, got ${txid.size}" } + } + + fun reversedTxid(): ByteArray = txid.reversedArray() +} + +data class BitcoinOutput( + val valueSat: Long, + val scriptPubKey: ByteArray, +) + +/** + * Builds and signs native SegWit (P2WPKH-only) Bitcoin transactions using BIP143 sighashes - hand-implemented + * since no Bitcoin transaction library (bitcoinj/PSBT/etc.) is on this project's classpath (same rationale as + * [Bech32]/[DerSignature]). Verified byte-for-byte against BIP143's official "Native P2WPKH" worked example, + * including the fully serialized signed transaction - see [BitcoinTransactionTest]. + */ +object BitcoinTransaction { + + private const val SIGHASH_ALL = 1 + + /** P2PKH-shaped "scriptCode" BIP143 requires for a P2WPKH input - see BIP143's "Specification" section. */ + private fun p2wpkhScriptCode(accountId: ByteArray): ByteArray { + require(accountId.size == 20) + val script = byteArrayOf(0x76.toByte(), 0xa9.toByte(), 0x14) + accountId + byteArrayOf(0x88.toByte(), 0xac.toByte()) + return 25L.toBitcoinVarInt() + script + } + + private fun serializeOutpoint(input: BitcoinInput): ByteArray = input.reversedTxid() + input.vout.toLeBytes(4) + + private fun hashPrevouts(inputs: List): ByteArray = + inputs.fold(ByteArray(0)) { acc, input -> acc + serializeOutpoint(input) }.sha256d() + + private fun hashSequence(inputs: List): ByteArray = + inputs.fold(ByteArray(0)) { acc, input -> acc + input.sequence.toLeBytes(4) }.sha256d() + + private fun serializeOutput(output: BitcoinOutput): ByteArray = + output.valueSat.toLeBytes(8) + output.scriptPubKey.size.toLong().toBitcoinVarInt() + output.scriptPubKey + + private fun hashOutputs(outputs: List): ByteArray = + outputs.fold(ByteArray(0)) { acc, output -> acc + serializeOutput(output) }.sha256d() + + /** + * BIP143 sighash preimage + double-SHA256 for signing [inputIndex], which must be a P2WPKH input whose + * pubkey hashes to [signingAccountId]. Always uses SIGHASH_ALL, no ANYONECANPAY/NONE/SINGLE - this app never + * constructs those. + */ + fun bip143Sighash( + version: Int, + inputs: List, + outputs: List, + inputIndex: Int, + signingAccountId: ByteArray, + locktime: Int, + ): ByteArray { + val input = inputs[inputIndex] + + val preimage = version.toLeBytes(4) + + hashPrevouts(inputs) + + hashSequence(inputs) + + serializeOutpoint(input) + + p2wpkhScriptCode(signingAccountId) + + input.valueSat.toLeBytes(8) + + input.sequence.toLeBytes(4) + + hashOutputs(outputs) + + locktime.toLeBytes(4) + + SIGHASH_ALL.toLeBytes(4) + + return preimage.sha256d() + } + + /** + * @param witnesses one (derSignatureWithoutSighashByte, compressedPublicKey) pair per input, in input order - + * every input in this app's transactions is a P2WPKH input from this wallet's own single address, so every + * witness has exactly 2 items (signature, pubkey), never a bare key-path/script-path Taproot witness or a + * multisig-style stack. + */ + fun serializeSigned( + version: Int, + inputs: List, + outputs: List, + witnesses: List>, + locktime: Int, + ): ByteArray { + require(witnesses.size == inputs.size) { "Need exactly one witness per input" } + + val out = ByteArrayOutputStream() + out.write(version.toLeBytes(4)) + out.write(0x00) // segwit marker + out.write(0x01) // segwit flag + out.write(inputs.size.toLong().toBitcoinVarInt()) + for (input in inputs) { + out.write(input.reversedTxid()) + out.write(input.vout.toLeBytes(4)) + out.write(0L.toBitcoinVarInt()) // scriptSig: empty for a native SegWit input + out.write(input.sequence.toLeBytes(4)) + } + out.write(outputs.size.toLong().toBitcoinVarInt()) + for (output in outputs) { + out.write(serializeOutput(output)) + } + for ((derSignature, publicKey) in witnesses) { + out.write(2L.toBitcoinVarInt()) // 2 witness items: signature, pubkey + val sigWithHashType = derSignature + byteArrayOf(SIGHASH_ALL.toByte()) + out.write(sigWithHashType.size.toLong().toBitcoinVarInt()) + out.write(sigWithHashType) + out.write(publicKey.size.toLong().toBitcoinVarInt()) + out.write(publicKey) + } + out.write(locktime.toLeBytes(4)) + + return out.toByteArray() + } + + /** + * Estimated virtual size in vbytes for fee purposes - the same hardcoded heuristic already proven in + * production by `pezkuwi-exchange/wallet-service` (`inputs*68 + outputs*31 + 11`), reused here rather than + * computing an exact post-signing weight (which would require knowing final DER signature lengths ahead of + * time - low-S-normalized DER signatures are 70-72 bytes almost always, making this heuristic accurate to + * within a few vbytes in practice). + */ + fun estimateVsize(inputCount: Int, outputCount: Int): Long = inputCount * 68L + outputCount * 31L + 11L +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/DerSignature.kt b/common/src/main/java/io/novafoundation/nova/common/utils/DerSignature.kt new file mode 100644 index 00000000..643ae5d2 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/DerSignature.kt @@ -0,0 +1,62 @@ +package io.novafoundation.nova.common.utils + +import java.math.BigInteger + +/** + * DER-encodes a raw secp256k1 ECDSA (r, s) signature the way Bitcoin's script/witness format requires it - + * unlike Tron/Ethereum, which both use a fixed-size compact r(32)+s(32)+v(1) format (see + * [io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction.RealTronTransactionService]'s + * doc-comment for that format), Bitcoin signatures are a variable-length ASN.1 DER `SEQUENCE(INTEGER r, INTEGER + * s)`. + * + * `r`/`s` are taken as raw big-endian unsigned 32-byte values - exactly what + * [io.novasama.substrate_sdk_android]'s `SignatureWrapper.Ecdsa` (reached via `SignedRaw.toEcdsaSignatureData()`) + * already exposes for Ethereum-style signing, which this app already uses. No new signing call path is needed + * for Bitcoin: only this pure, standalone encoding step is new. + */ +object DerSignature { + + // secp256k1 curve order n, and n/2 - Bitcoin Core's standardness rules (BIP62) reject a signature whose `s` + // is greater than n/2 ("high-S"); wallets are expected to always produce the "low-S" of the two equally + // valid (r, s) and (r, n-s) signatures for a given message, or relay nodes/miners may refuse the transaction. + private val CURVE_ORDER = BigInteger("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141", 16) + private val HALF_CURVE_ORDER = CURVE_ORDER.shiftRight(1) + + /** + * @param r raw big-endian unsigned 32-byte value + * @param s raw big-endian unsigned 32-byte value (will be normalized to low-S if not already) + * @return DER-encoded `SEQUENCE(INTEGER r, INTEGER s)`, WITHOUT the trailing sighash-type byte (the caller + * appends that when assembling the witness/scriptSig, since it is not part of the DER signature itself). + */ + fun encode(r: ByteArray, s: ByteArray): ByteArray { + val rInt = BigInteger(1, r) + var sInt = BigInteger(1, s) + + if (sInt > HALF_CURVE_ORDER) { + sInt = CURVE_ORDER.subtract(sInt) + } + + val rEncoded = encodeInteger(rInt) + val sEncoded = encodeInteger(sInt) + + val sequenceBody = rEncoded + sEncoded + + return byteArrayOf(0x30, sequenceBody.size.toDerLength()) + sequenceBody + } + + /** + * ASN.1 DER INTEGER: tag(0x02) + length + minimal big-endian two's-complement bytes. [BigInteger.toByteArray] + * already produces minimal big-endian two's-complement (including the leading 0x00 disambiguation byte when + * the high bit of the first byte would otherwise be set, which would make it read as negative) - since + * `rInt`/`sInt` are always non-negative here, its output is exactly the DER INTEGER content we need. + */ + private fun encodeInteger(value: BigInteger): ByteArray { + val bytes = value.toByteArray() + return byteArrayOf(0x02, bytes.size.toDerLength()) + bytes + } + + private fun Int.toDerLength(): Byte { + require(this in 0..127) { "DER length $this requires long-form encoding, not expected for a 32-byte ECDSA signature" } + return toByte() + } +} diff --git a/common/src/test/java/io/novafoundation/nova/common/utils/Bech32Test.kt b/common/src/test/java/io/novafoundation/nova/common/utils/Bech32Test.kt new file mode 100644 index 00000000..a5b46eb2 --- /dev/null +++ b/common/src/test/java/io/novafoundation/nova/common/utils/Bech32Test.kt @@ -0,0 +1,179 @@ +package io.novafoundation.nova.common.utils + +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novasama.substrate_sdk_android.extensions.toHexString +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * All test vectors below are quoted verbatim from the official BIPs (fetched directly from + * https://github.com/bitcoin/bips at implementation time), not invented for this test: + * - BIP173 (https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki) for Bech32 checksum vectors. + * - BIP350 (https://github.com/bitcoin/bips/blob/master/bip-0350.mediawiki) for Bech32m checksum vectors + * and the current (BIP350-superseding-BIP173) segwit address <-> scriptPubKey vectors - BIP173's own + * witness-v1+ vectors used plain Bech32 (since Bech32m didn't exist yet) and are now considered INVALID; + * only BIP173's witness-v0 vectors still apply unchanged under BIP350. + */ +class Bech32Test { + + @Test + fun `valid Bech32 checksums should decode without throwing`() { + val validBech32 = listOf( + "A12UEL5L", + "a12uel5l", + "an83characterlonghumanreadablepartthatcontainsthenumber1andtheexcludedcharactersbio1tt5tgs", + "abcdef1qpzry9x8gf2tvdw0s3jn54khce6mua7lmqqqxw", + "11qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqc8247j", + "split1checkupstagehandshakeupstreamerranterredcaperred2y9e3w", + "?1ezyfcl" + ) + + for (address in validBech32) { + val decoded = Bech32.decode(address) + assertEquals("$address should decode as Bech32 (not Bech32m)", Bech32.Encoding.BECH32, decoded.encoding) + } + } + + @Test + fun `valid Bech32m checksums should decode without throwing`() { + val validBech32m = listOf( + "A1LQFN3A", + "a1lqfn3a", + "an83characterlonghumanreadablepartthatcontainsthetheexcludedcharactersbioandnumber11sg7hg6", + "abcdef1l7aum6echk45nj3s0wdvt2fg8x9yrzpqzd3ryx", + "11llllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllludsr8", + "split1checkupstagehandshakeupstreamerranterredcaperredlc445v", + "?1v759aa" + ) + + for (address in validBech32m) { + val decoded = Bech32.decode(address) + assertEquals("$address should decode as Bech32m (not Bech32)", Bech32.Encoding.BECH32M, decoded.encoding) + } + } + + @Test + fun `mixed case Bech32 string should be rejected`() { + assertThrows(IllegalArgumentException::class.java) { + Bech32.decode("tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sL5k7") + } + } + + // --- Segwit address <-> scriptPubKey (BIP350's updated table) --- + + private fun expectedWitnessVersionAndProgram(scriptPubKeyHex: String): Pair { + val script = scriptPubKeyHex.fromHex() + val versionByte = script[0].toInt() and 0xff + val witnessVersion = if (versionByte == 0) 0 else versionByte - 0x50 + val programLength = script[1].toInt() and 0xff + val program = script.copyOfRange(2, 2 + programLength) + return witnessVersion to program + } + + @Test + fun `known mainnet P2WPKH address should decode to the documented scriptPubKey`() { + val address = "BC1QW508D6QEJXTDG4Y5R3ZARVARY0C5XW7KV8F3T4" + val (expectedVersion, expectedProgram) = expectedWitnessVersionAndProgram("0014751e76e8199196d454941c45d1b3a323f1433bd6") + + val decoded = SegwitAddress.decode("bc", address) + + assertEquals(expectedVersion, decoded.witnessVersion) + assertTrue(decoded.witnessProgram.contentEquals(expectedProgram)) + } + + @Test + fun `known testnet P2WSH address should decode to the documented scriptPubKey`() { + val address = "tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sl5k7" + val (expectedVersion, expectedProgram) = + expectedWitnessVersionAndProgram("00201863143c14c5166804bd19203356da136c985678cd4d27a1b8c6329604903262") + + val decoded = SegwitAddress.decode("tb", address) + + assertEquals(expectedVersion, decoded.witnessVersion) + assertTrue(decoded.witnessProgram.contentEquals(expectedProgram)) + } + + @Test + fun `known testnet P2WPKH address (all-zero-ish program) should decode correctly`() { + val address = "tb1qqqqqp399et2xygdj5xreqhjjvcmzhxw4aywxecjdzew6hylgvsesrxh6hy" + val (expectedVersion, expectedProgram) = + expectedWitnessVersionAndProgram("0020000000c4a5cad46221b2a187905e5266362b99d5e91c6ce24d165dab93e86433") + + val decoded = SegwitAddress.decode("tb", address) + + assertEquals(expectedVersion, decoded.witnessVersion) + assertTrue(decoded.witnessProgram.contentEquals(expectedProgram)) + } + + @Test + fun `known witness v1 taproot-style address (Bech32m) should decode correctly`() { + val address = "bc1pw508d6qejxtdg4y5r3zarvary0c5xw7kw508d6qejxtdg4y5r3zarvary0c5xw7kt5nd6y" + val (expectedVersion, expectedProgram) = expectedWitnessVersionAndProgram( + "5128751e76e8199196d454941c45d1b3a323f1433bd6751e76e8199196d454941c45d1b3a323f1433bd6" + ) + + val decoded = SegwitAddress.decode("bc", address) + + assertEquals(1, expectedVersion) + assertEquals(expectedVersion, decoded.witnessVersion) + assertTrue(decoded.witnessProgram.contentEquals(expectedProgram)) + } + + @Test + fun `P2WPKH encode should reproduce the exact known mainnet address (lowercase)`() { + val (_, program) = expectedWitnessVersionAndProgram("0014751e76e8199196d454941c45d1b3a323f1433bd6") + + val encoded = SegwitAddress.encode("bc", witnessVersion = 0, witnessProgram = program) + + assertEquals("BC1QW508D6QEJXTDG4Y5R3ZARVARY0C5XW7KV8F3T4".lowercase(), encoded) + } + + @Test + fun `encode-decode should round trip for a fresh 20-byte P2WPKH program`() { + val program = "0011223344556677889900112233445566778899".fromHex() + assertEquals(20, program.size) + + val address = SegwitAddress.encode("bc", witnessVersion = 0, witnessProgram = program) + val decoded = SegwitAddress.decode("bc", address) + + assertEquals(0, decoded.witnessVersion) + assertTrue(decoded.witnessProgram.contentEquals(program)) + } + + @Test + fun `invalid checksum should be rejected`() { + assertThrows(IllegalArgumentException::class.java) { + SegwitAddress.decode("bc", "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t5") + } + } + + @Test + fun `wrong hrp should be rejected`() { + assertThrows(IllegalArgumentException::class.java) { + SegwitAddress.decode("bc", "tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sl5k7") + } + } + + @Test + fun `invalid program length should be rejected`() { + assertThrows(IllegalArgumentException::class.java) { + SegwitAddress.decode("bc", "bc1rw5uspcuh") + } + } + + @Test + fun `witness v0 with wrong program length per BIP141 should be rejected`() { + assertThrows(IllegalArgumentException::class.java) { + SegwitAddress.decode("bc", "BC1QR508D6QEJXTDG4Y5R3ZARVARYV98GJ9P") + } + } + + @Test + fun `witness v0 encoded with Bech32m instead of Bech32 should be rejected (BIP350)`() { + assertThrows(IllegalArgumentException::class.java) { + SegwitAddress.decode("bc", "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kemeawh") + } + } +} diff --git a/common/src/test/java/io/novafoundation/nova/common/utils/BitcoinAddressTest.kt b/common/src/test/java/io/novafoundation/nova/common/utils/BitcoinAddressTest.kt new file mode 100644 index 00000000..ce52a97a --- /dev/null +++ b/common/src/test/java/io/novafoundation/nova/common/utils/BitcoinAddressTest.kt @@ -0,0 +1,98 @@ +package io.novafoundation.nova.common.utils + +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novasama.substrate_sdk_android.extensions.toHexString +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * [knownAccountId]/[knownAddress] is BIP173/BIP350's official segwit address test vector + * (BC1QW508D6QEJXTDG4Y5R3ZARVARY0C5XW7KV8F3T4 <-> scriptPubKey 0014751e76e8199196d454941c45d1b3a323f1433bd6, + * fetched directly from https://github.com/bitcoin/bips at implementation time) - an independently-verifiable, + * real-world test vector, not invented for this test. [knownPublicKey] is BIP143's official Native P2WPKH + * example pubkey, whose HASH160 is independently confirmed (via Bech32AddressTest and BitcoinTransactionTest) + * to equal a *different* known account id - used here only to test [hash160]/[bitcoinPublicKeyToAccountId] in + * isolation from address encoding. + */ +class BitcoinAddressTest { + + private val knownAccountId = "751e76e8199196d454941c45d1b3a323f1433bd6".fromHex() + private val knownAddress = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4" + + private val knownPublicKey = "025476c2e83188368da1ff3e292e7acafcdb3566bb0ad253f62fc70f07aeee6357".fromHex() + private val knownPublicKeyAccountId = "1d0f172a0ecb48aee1be1f2687d2963ae33f71a1".fromHex() + + @Test + fun `accountId to address should produce the known BIP173 address`() { + assertEquals(knownAddress, knownAccountId.toBitcoinAddress()) + } + + @Test + fun `address to accountId should decode the known BIP173 address back to the known bytes`() { + assertTrue(knownAddress.bitcoinAddressToAccountId().contentEquals(knownAccountId)) + } + + @Test + fun `accountId to address and back should round trip`() { + val decodedBack = knownAccountId.toBitcoinAddress().bitcoinAddressToAccountId() + assertTrue(decodedBack.contentEquals(knownAccountId)) + } + + @Test + fun `compressed public key to accountId should match the known BIP143 hash160`() { + assertTrue(knownPublicKey.bitcoinPublicKeyToAccountId().contentEquals(knownPublicKeyAccountId)) + } + + @Test + fun `uncompressed (65-byte) public key should be rejected`() { + val uncompressed = ByteArray(65) + try { + uncompressed.bitcoinPublicKeyToAccountId() + org.junit.Assert.fail("Expected an IllegalArgumentException for a non-33-byte public key") + } catch (e: IllegalArgumentException) { + // expected + } + } + + @Test + fun `toP2wpkhScriptCode should produce OP_0 push-20 the account id`() { + val scriptPubKey = knownAccountId.toP2wpkhScriptPubKey() + + assertEquals("0014751e76e8199196d454941c45d1b3a323f1433bd6", scriptPubKey.toHexString(withPrefix = false)) + } + + @Test + fun `isValidBitcoinAddress should accept the known good address`() { + assertTrue(knownAddress.isValidBitcoinAddress()) + } + + @Test + fun `isValidBitcoinAddress should reject a corrupted checksum`() { + val corrupted = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t5" + assertFalse(corrupted.isValidBitcoinAddress()) + } + + @Test + fun `isValidBitcoinAddress should reject a testnet address`() { + assertFalse("tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sl5k7".isValidBitcoinAddress()) + } + + @Test + fun `isValidBitcoinAddress should reject a Tron address`() { + assertFalse("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t".isValidBitcoinAddress()) + } + + @Test + fun `isValidBitcoinAddress should reject a P2WSH (32-byte program) address as not P2WPKH`() { + assertFalse("bc1pw508d6qejxtdg4y5r3zarvary0c5xw7kw508d6qejxtdg4y5r3zarvary0c5xw7kt5nd6y".isValidBitcoinAddress()) + } + + @Test + fun `hash160 known test vector should match independently-verified value`() { + // hash160("hello") independently cross-checked via Python's hashlib (ripemd160(sha256(b"hello"))) at + // implementation time - a different library from this project's BouncyCastle, not just self-consistency. + assertEquals("b6a9c8c230722b7c748331a8b450f05566dc7d0f", "hello".toByteArray().hash160().toHexString(withPrefix = false)) + } +} diff --git a/common/src/test/java/io/novafoundation/nova/common/utils/BitcoinTransactionTest.kt b/common/src/test/java/io/novafoundation/nova/common/utils/BitcoinTransactionTest.kt new file mode 100644 index 00000000..6e2c4d27 --- /dev/null +++ b/common/src/test/java/io/novafoundation/nova/common/utils/BitcoinTransactionTest.kt @@ -0,0 +1,89 @@ +package io.novafoundation.nova.common.utils + +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novasama.substrate_sdk_android.extensions.toHexString +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Verified byte-for-byte against BIP143's official "Native P2WPKH" worked example + * (https://github.com/bitcoin/bips/blob/master/bip-0143.mediawiki, fetched directly at implementation time) - + * every intermediate value below (hashPrevouts/hashSequence/hashOutputs/preimage/sighash/signed tx) is quoted + * verbatim from that document, not invented for this test. + * + * The two inputs' txids are given here in the conventional *display* order (reversed from on-wire order) - the + * same order a REST API like mempool.space returns - to exercise [BitcoinInput.reversedTxid]'s reversal for + * real, rather than pre-reversing them and bypassing that logic. + */ +class BitcoinTransactionTest { + + // BIP143 doc's wire-order txids, reversed here once (by hand, offline) to get the display-order string a + // real API would hand back - see this test class's doc comment. + private val input0Txid = "9f96ade4b41d5433f4eda31e1738ec2b36f6e7d1420d94a6af99801a88f7f7ff".fromHex() + private val input1Txid = "8ac60eb9575db5b2d987e29f301b5b819ea83a5c6579d282d189cc04b8e151ef".fromHex() + + private val input0 = BitcoinInput(txid = input0Txid, vout = 0, valueSat = 625_000_000L, sequence = 0xeeffffffL) + private val input1 = BitcoinInput(txid = input1Txid, vout = 1, valueSat = 600_000_000L, sequence = 0xffffffffL) + + private val output0 = BitcoinOutput( + valueSat = 112_340_000L, + scriptPubKey = "76a9148280b37df378db99f66f85c95a783a76ac7a6d5988ac".fromHex() + ) + private val output1 = BitcoinOutput( + valueSat = 223_450_000L, + scriptPubKey = "76a9143bde42dbee7e4dbe6a21b2d50ce2f0167faa815988ac".fromHex() + ) + + private val signingAccountId = "1d0f172a0ecb48aee1be1f2687d2963ae33f71a1".fromHex() + private val publicKey = "025476c2e83188368da1ff3e292e7acafcdb3566bb0ad253f62fc70f07aeee6357".fromHex() + + @Test + fun `hash160 of the known public key should match the known account id`() { + assertEquals(signingAccountId.toHexString(withPrefix = false), publicKey.hash160().toHexString(withPrefix = false)) + } + + @Test + fun `bip143Sighash should match the known sighash for signing input 1`() { + val sighash = BitcoinTransaction.bip143Sighash( + version = 1, + inputs = listOf(input0, input1), + outputs = listOf(output0, output1), + inputIndex = 1, + signingAccountId = signingAccountId, + locktime = 0x11, + ) + + assertEquals("c37af31116d1b27caf68aae9e3ac82f1477929014d5b917657d0eb49478cb670", sighash.toHexString(withPrefix = false)) + } + + @Test + fun `serializeSigned should produce the exact expected bytes for a single-input all-P2WPKH transaction`() { + // Hand-verified (not from a BIP143 vector, since BIP143's own worked example mixes a legacy P2PK input + // with the P2WPKH one - this app only ever builds all-P2WPKH transactions since it only ever spends + // from its own single P2WPKH address). Expected hex was independently computed byte-by-byte from this + // function's own documented format (version/marker/flag/varints/witness) rather than copied from here. + val txidWire = "0a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f9".fromHex() + val txidDisplay = txidWire.reversedArray() // what a mempool.space-style API would actually return + + val input = BitcoinInput(txid = txidDisplay, vout = 3, valueSat = 100_000L, sequence = 0xfffffffdL) + val output = BitcoinOutput(valueSat = 90_000L, scriptPubKey = "0014".fromHex() + "2222222222222222222222222222222222222222".fromHex()) + val derSignature = "3006020101020101".fromHex() + val dummyPubKey = "0246e14bb0d93c0d64c265dd6b0eeeba6b9bd94aa88ce74aa302cf1cb8fdff9b6a".fromHex() + + val signed = BitcoinTransaction.serializeSigned( + version = 2, + inputs = listOf(input), + outputs = listOf(output), + witnesses = listOf(derSignature to dummyPubKey), + locktime = 0, + ) + + val expected = "020000000001010a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90300000000fdffffff01905f01000000000016001422222222222222222222222222222222222222220209300602010102010101210246e14bb0d93c0d64c265dd6b0eeeba6b9bd94aa88ce74aa302cf1cb8fdff9b6a00000000" + assertEquals(expected, signed.toHexString(withPrefix = false)) + } + + @Test + fun `estimateVsize should match the exchange's proven heuristic formula`() { + assertEquals(1 * 68L + 2 * 31L + 11L, BitcoinTransaction.estimateVsize(inputCount = 1, outputCount = 2)) + } +} diff --git a/common/src/test/java/io/novafoundation/nova/common/utils/DerSignatureTest.kt b/common/src/test/java/io/novafoundation/nova/common/utils/DerSignatureTest.kt new file mode 100644 index 00000000..d502f3e0 --- /dev/null +++ b/common/src/test/java/io/novafoundation/nova/common/utils/DerSignatureTest.kt @@ -0,0 +1,72 @@ +package io.novafoundation.nova.common.utils + +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novasama.substrate_sdk_android.extensions.toHexString +import org.junit.Assert.assertEquals +import org.junit.Test +import java.math.BigInteger + +class DerSignatureTest { + + private val curveOrder = BigInteger("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141", 16) + + private fun ByteArray.pad32() = ByteArray(32 - size) + this + + @Test + fun `small r and high-bit s should each get a leading zero byte per DER minimal-integer rule`() { + // r = 1 (0x01, high bit clear -> no padding needed), s = 128 (0x80, high bit set -> needs 0x00 prefix + // so it isn't misread as a negative two's-complement integer). Manually verified expected DER bytes. + val r = BigInteger.valueOf(1).toByteArray().pad32() + val s = BigInteger.valueOf(128).toByteArray().pad32() // 128 < half-curve-order, so no low-S flip happens + + val der = DerSignature.encode(r, s) + + assertEquals("30070201010202" + "0080", der.toHexString(withPrefix = false)) + } + + @Test + fun `high-S signature should be normalized to low-S per BIP62`() { + val r = BigInteger.valueOf(42).toByteArray().pad32() + val highS = curveOrder.subtract(BigInteger.ONE) // curveOrder - 1: definitely > halfCurveOrder + val s = highS.toByteArray().let { if (it.size > 32) it.copyOfRange(it.size - 32, it.size) else it }.pad32() + + val der = DerSignature.encode(r, s) + + // Expect the DER-encoded s to equal curveOrder - highS == 1, not the original high-S value. + val expectedNormalizedS = curveOrder.subtract(highS) + assertEquals(BigInteger.ONE, expectedNormalizedS) + + // Extract the s component back out of the DER bytes to check it against the expected normalized value. + val rLen = der[3].toInt() + val sTagIndex = 4 + rLen + val sLen = der[sTagIndex + 1].toInt() + val sBytes = der.copyOfRange(sTagIndex + 2, sTagIndex + 2 + sLen) + assertEquals(expectedNormalizedS, BigInteger(1, sBytes)) + } + + @Test + fun `already-low-S signature should be left unchanged`() { + val r = BigInteger.valueOf(7).toByteArray().pad32() + val lowS = BigInteger.valueOf(12345) + val s = lowS.toByteArray().pad32() + + val der = DerSignature.encode(r, s) + + val rLen = der[3].toInt() + val sTagIndex = 4 + rLen + val sLen = der[sTagIndex + 1].toInt() + val sBytes = der.copyOfRange(sTagIndex + 2, sTagIndex + 2 + sLen) + assertEquals(lowS, BigInteger(1, sBytes)) + } + + @Test + fun `DER output should start with SEQUENCE tag and correct overall length`() { + val r = "0011223344556677889900112233445566778899aabbccddeeff0011223344".fromHex() + val s = "1122334455667788990011223344556677889900112233445566778899aabb".fromHex() + + val der = DerSignature.encode(r, s) + + assertEquals(0x30.toByte(), der[0]) + assertEquals(der.size - 2, der[1].toInt()) + } +}