Merge branch 'feature/btc-native-send' into fix/multisig-first-signer-deep-link

# Conflicts:
#	feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgeFragment.kt
#	feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgeViewModel.kt
#	feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/di/BridgeModule.kt
This commit is contained in:
2026-07-14 07:36:14 -07:00
116 changed files with 5425 additions and 455 deletions
@@ -28,6 +28,9 @@ object MetaAccountSecrets : Schema<MetaAccountSecrets>() {
val TronKeypair by schema(KeyPairSchema).optional()
val TronDerivationPath by string().optional()
val BitcoinKeypair by schema(KeyPairSchema).optional()
val BitcoinDerivationPath by string().optional()
}
object ChainAccountSecrets : Schema<ChainAccountSecrets>() {
@@ -47,6 +50,8 @@ fun MetaAccountSecrets(
ethereumDerivationPath: String? = null,
tronKeypair: Keypair? = null,
tronDerivationPath: String? = null,
bitcoinKeypair: Keypair? = null,
bitcoinDerivationPath: String? = null,
): EncodableStruct<MetaAccountSecrets> = MetaAccountSecrets { secrets ->
secrets[Entropy] = entropy
secrets[SubstrateSeed] = substrateSeed
@@ -75,6 +80,15 @@ fun MetaAccountSecrets(
}
}
secrets[TronDerivationPath] = tronDerivationPath
secrets[BitcoinKeypair] = bitcoinKeypair?.let {
KeyPairSchema { keypair ->
keypair[PublicKey] = it.publicKey
keypair[PrivateKey] = it.privateKey
keypair[Nonce] = null // bitcoin uses secp256k1 (like ethereum/tron), so nonce is always null
}
}
secrets[BitcoinDerivationPath] = bitcoinDerivationPath
}
fun ChainAccountSecrets(
@@ -103,6 +117,9 @@ val EncodableStruct<MetaAccountSecrets>.ethereumDerivationPath
val EncodableStruct<MetaAccountSecrets>.tronDerivationPath
get() = get(MetaAccountSecrets.TronDerivationPath)
val EncodableStruct<MetaAccountSecrets>.bitcoinDerivationPath
get() = get(MetaAccountSecrets.BitcoinDerivationPath)
val EncodableStruct<MetaAccountSecrets>.entropy
get() = get(MetaAccountSecrets.Entropy)
@@ -118,6 +135,9 @@ val EncodableStruct<MetaAccountSecrets>.ethereumKeypair
val EncodableStruct<MetaAccountSecrets>.tronKeypair
get() = get(MetaAccountSecrets.TronKeypair)
val EncodableStruct<MetaAccountSecrets>.bitcoinKeypair
get() = get(MetaAccountSecrets.BitcoinKeypair)
val EncodableStruct<ChainAccountSecrets>.derivationPath
get() = get(ChainAccountSecrets.DerivationPath)
@@ -156,20 +156,28 @@ val AccountSecrets.isChainAccountSecrets
suspend fun SecretStoreV2.getMetaAccountKeypair(
metaId: Long,
isEthereum: Boolean,
isTron: Boolean = false,
isBitcoin: Boolean = false,
): Keypair = withContext(Dispatchers.Default) {
val secrets = getMetaAccountSecrets(metaId) ?: noMetaSecrets(metaId)
mapMetaAccountSecretsToKeypair(secrets, isEthereum)
mapMetaAccountSecretsToKeypair(secrets, isEthereum, isTron, isBitcoin)
}
fun mapMetaAccountSecretsToKeypair(
secrets: EncodableStruct<MetaAccountSecrets>,
ethereum: Boolean,
tron: Boolean = false,
bitcoin: Boolean = false,
): Keypair {
val keypairStruct = if (ethereum) {
secrets[MetaAccountSecrets.EthereumKeypair] ?: noEthereumSecret()
} else {
secrets[MetaAccountSecrets.SubstrateKeypair]
// Tron and Bitcoin both reuse Ethereum's secp256k1 curve but derive their own keypair under a different
// SLIP-44 path - each must be checked before `ethereum`, not folded into it, or that account would get
// signed with the wrong (Ethereum) private key.
val keypairStruct = when {
tron -> secrets[MetaAccountSecrets.TronKeypair] ?: noTronSecret()
bitcoin -> secrets[MetaAccountSecrets.BitcoinKeypair] ?: noBitcoinSecret()
ethereum -> secrets[MetaAccountSecrets.EthereumKeypair] ?: noEthereumSecret()
else -> secrets[MetaAccountSecrets.SubstrateKeypair]
}
return mapKeypairStructToKeypair(keypairStruct)
@@ -178,8 +186,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 +209,10 @@ 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")
private fun noTronSecret(): Nothing = error("No tron keypair found")
fun mapKeypairStructToKeypair(struct: EncodableStruct<KeyPairSchema>): Keypair {
return Keypair(
publicKey = struct[KeyPairSchema.PublicKey],
@@ -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<Int>()
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() })
}
}
@@ -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> <hash160>`, 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 }
@@ -0,0 +1,79 @@
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)
}
}
// Base58Check itself is chain-agnostic (see TronAddress.kt) - a Bitcoin legacy address is 1 version byte +
// 20-byte hash, same shape as Tron's own address, just a different version byte and no fixed prefix meaning.
val decoded = Base58Check.decode(this)
require(decoded.size == 21) { "Not a valid Bitcoin legacy address: $this" }
val version = decoded[0].toInt() and 0xff
val hash = decoded.copyOfRange(1, decoded.size)
return when (version) {
P2PKH_VERSION -> BitcoinDestination.P2pkh(hash)
P2SH_VERSION -> BitcoinDestination.P2sh(hash)
else -> error("Unsupported Bitcoin address version byte: $version")
}
}
fun String.isValidBitcoinDestinationAddress(): Boolean = runCatching { decodeBitcoinDestination() }.isSuccess
/**
* The raw 20-byte hash underlying any destination type, with its type tag dropped - safe ONLY for consumers
* that treat it as an opaque identicon/display seed (e.g. `Chain.accountIdOf` and, downstream, address icon
* generation) and never feed it back into [toScriptPubKey] or re-encode it as an address. Real transaction
* construction must keep using [decodeBitcoinDestination]/[toScriptPubKey] directly, which keep the type.
*/
val BitcoinDestination.hash: ByteArray
get() = when (this) {
is BitcoinDestination.NativeSegwit -> witnessProgram
is BitcoinDestination.P2sh -> scriptHash
is BitcoinDestination.P2pkh -> pubKeyHash
}
/**
* @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())
}
@@ -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<BitcoinInput>): ByteArray =
inputs.fold(ByteArray(0)) { acc, input -> acc + serializeOutpoint(input) }.sha256d()
private fun hashSequence(inputs: List<BitcoinInput>): 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<BitcoinOutput>): 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<BitcoinInput>,
outputs: List<BitcoinOutput>,
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<BitcoinInput>,
outputs: List<BitcoinOutput>,
witnesses: List<Pair<ByteArray, ByteArray>>,
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
}
@@ -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()
}
}
@@ -2,6 +2,7 @@ package io.novafoundation.nova.common.utils
import io.novasama.substrate_sdk_android.extensions.asEthereumPublicKey
import io.novasama.substrate_sdk_android.extensions.toAccountId
import io.novasama.substrate_sdk_android.extensions.toHexString
import io.novasama.substrate_sdk_android.runtime.AccountId
import java.math.BigInteger
@@ -113,3 +114,21 @@ fun String.tronAddressToAccountId(): AccountId {
fun String.isValidTronAddress(): Boolean = runCatching { tronAddressToAccountId() }.isSuccess
fun emptyTronAccountId() = ByteArray(20) { 1 }
/**
* Hex form of a Tron address (`0x41` prefix byte ++ accountId, hex-encoded, no `0x` prefix), e.g.
* `41a614f803b6fd780986a42c78ec9c7f77e6ded13c`. This is the format TronGrid's `/wallet/` transaction
* construction/broadcast endpoints expect when called with `"visible": false` (as opposed to the human-facing
* Base58Check form used by the `/v1/accounts/{address}` balance endpoint and by [toTronAddress]).
*/
fun AccountId.toTronHexAddress(): String {
require(size == 20) { "Tron account id must be 20 bytes, got $size" }
return byteArrayOf(TRON_ADDRESS_PREFIX_BYTE).toHexString(withPrefix = false) + toHexString(withPrefix = false)
}
/**
* Converts a human-facing Base58Check Tron address (e.g. a TRC20 `contractAddress` from chain config) directly
* into the hex form described in [toTronHexAddress].
*/
fun String.tronAddressToHexAddress(): String = tronAddressToAccountId().toTronHexAddress()
+2
View File
@@ -355,6 +355,8 @@
<!-- Bridge Screen -->
<string name="bridge_title">DOT ↔ HEZ Bridge</string>
<string name="bridge_pair_dot_hez">HEZ ⇄ DOT</string>
<string name="bridge_pair_usdt">USDT ⇄ USDT.p</string>
<string name="bridge_you_send">You send</string>
<string name="bridge_you_receive">You receive (estimated)</string>
<string name="bridge_exchange_rate">Exchange rate</string>
@@ -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<Int, ByteArray> {
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")
}
}
}
@@ -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))
}
}
@@ -0,0 +1,99 @@
package io.novafoundation.nova.common.utils
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
/**
* Test vectors:
* - [genesisP2pkhAddress] is Satoshi's genesis coinbase address - real, independently-verifiable, its hash160
* cross-checked via Python's hashlib/base58 at implementation time.
* - [okxWithdrawalAddress] is a real address confirmed live: an OKX BTC withdrawal QR, whose bare (non-BIP21)
* content this app's original native-SegWit-only address validation rejected outright, producing a misleading
* "QR can't be decoded" error - the actual bug this file's code fixes.
*/
class BitcoinDestinationAddressTest {
private val genesisP2pkhAddress = "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"
private val genesisP2pkhHash160 = "62e907b15cbf27d5425399ebf6f0fb50ebb88f18"
private val piVanityP2shAddress = "3P14159f73E4gFr7JterCCQh9QjiTjiZrG"
private val okxWithdrawalAddress = "3QBsCZAv5hsZSrDpTcQYEqd82TdA8Qr3g9"
private val okxWithdrawalHash160 = "f6c78c3a049bfa34ed05b22ca9515414cdcb46d1"
private val nativeSegwitAddress = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"
private val nativeSegwitAccountId = "751e76e8199196d454941c45d1b3a323f1433bd6"
@Test
fun `should decode a known P2PKH address to the correct hash160`() {
val destination = genesisP2pkhAddress.decodeBitcoinDestination()
assertTrue(destination is BitcoinDestination.P2pkh)
assertEquals(genesisP2pkhHash160, (destination as BitcoinDestination.P2pkh).pubKeyHash.toHexString(withPrefix = false))
}
@Test
fun `should decode a known P2SH address to a P2sh destination`() {
val destination = piVanityP2shAddress.decodeBitcoinDestination()
assertTrue(destination is BitcoinDestination.P2sh)
}
@Test
fun `should decode the real OKX withdrawal address to the correct P2SH hash160`() {
val destination = okxWithdrawalAddress.decodeBitcoinDestination()
assertTrue(destination is BitcoinDestination.P2sh)
assertEquals(okxWithdrawalHash160, (destination as BitcoinDestination.P2sh).scriptHash.toHexString(withPrefix = false))
}
@Test
fun `should still decode a native segwit address as NativeSegwit`() {
val destination = nativeSegwitAddress.decodeBitcoinDestination()
assertTrue(destination is BitcoinDestination.NativeSegwit)
assertEquals(nativeSegwitAccountId, (destination as BitcoinDestination.NativeSegwit).witnessProgram.toHexString(withPrefix = false))
}
@Test
fun `isValidBitcoinDestinationAddress should accept P2PKH, P2SH and native segwit`() {
assertTrue(genesisP2pkhAddress.isValidBitcoinDestinationAddress())
assertTrue(piVanityP2shAddress.isValidBitcoinDestinationAddress())
assertTrue(okxWithdrawalAddress.isValidBitcoinDestinationAddress())
assertTrue(nativeSegwitAddress.isValidBitcoinDestinationAddress())
}
@Test
fun `isValidBitcoinDestinationAddress should reject a corrupted checksum`() {
assertFalse("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNb".isValidBitcoinDestinationAddress())
}
@Test
fun `isValidBitcoinDestinationAddress should reject a Tron address`() {
assertFalse("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t".isValidBitcoinDestinationAddress())
}
@Test
fun `P2SH destination should produce OP_HASH160 push-20 OP_EQUAL scriptPubKey`() {
val destination = okxWithdrawalAddress.decodeBitcoinDestination()
assertEquals("a914${okxWithdrawalHash160}87", destination.toScriptPubKey().toHexString(withPrefix = false))
}
@Test
fun `P2PKH destination should produce OP_DUP OP_HASH160 push-20 OP_EQUALVERIFY OP_CHECKSIG scriptPubKey`() {
val destination = genesisP2pkhAddress.decodeBitcoinDestination()
assertEquals("76a914${genesisP2pkhHash160}88ac", destination.toScriptPubKey().toHexString(withPrefix = false))
}
@Test
fun `NativeSegwit destination should produce OP_0 push-20 scriptPubKey`() {
val destination = nativeSegwitAddress.decodeBitcoinDestination()
assertEquals("0014$nativeSegwitAccountId", destination.toScriptPubKey().toHexString(withPrefix = false))
}
}
@@ -0,0 +1,92 @@
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()
// sequence is the *value* that gets LE-encoded, NOT the wire bytes - the doc shows input 0's on-wire
// sequence bytes as "eeffffff", which as a little-endian integer is 0xffffffee, not 0xeeffffff (a
// transcription of this exact off-by-reversal was caught by a failing CI run before this fix).
private val input0 = BitcoinInput(txid = input0Txid, vout = 0, valueSat = 625_000_000L, sequence = 0xffffffeeL)
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))
}
}
@@ -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())
}
}
@@ -44,6 +44,18 @@ class TronAddressTest {
assertTrue(decodedBack.contentEquals(accountId))
}
@Test
fun `toTronHexAddress should produce the known hex form`() {
val accountId = knownTronAddressHex.fromHex().copyOfRange(1, 21)
assertEquals(knownTronAddressHex, accountId.toTronHexAddress())
}
@Test
fun `tronAddressToHexAddress should produce the known hex form directly from a Base58 address`() {
assertEquals(knownTronAddressHex, knownTronAddress.tronAddressToHexAddress())
}
@Test
fun `isValidTronAddress should accept known good address`() {
assertTrue(knownTronAddress.isValidTronAddress())