feat: add checksum and signature validation to Bolt11Decoder
This commit is contained in:
@@ -20,7 +20,6 @@ object Bolt11Decoder {
|
|||||||
|
|
||||||
private val KNOWN_PREFIXES = listOf("lnbcrt", "lntbs", "lntb", "lnbc")
|
private val KNOWN_PREFIXES = listOf("lnbcrt", "lntbs", "lntb", "lnbc")
|
||||||
|
|
||||||
// ── NEW: map prefix → bech32 address HRP for fallback address encoding ───
|
|
||||||
private val PREFIX_TO_ADDRESS_HRP = mapOf(
|
private val PREFIX_TO_ADDRESS_HRP = mapOf(
|
||||||
"lnbc" to "bc",
|
"lnbc" to "bc",
|
||||||
"lntb" to "tb",
|
"lntb" to "tb",
|
||||||
@@ -29,23 +28,22 @@ object Bolt11Decoder {
|
|||||||
)
|
)
|
||||||
|
|
||||||
private val MULTIPLIER_MSAT: Map<Char, Long> = mapOf(
|
private val MULTIPLIER_MSAT: Map<Char, Long> = mapOf(
|
||||||
'm' to 100_000_000L, // milli-BTC = 10^8 msat
|
'm' to 100_000_000L,
|
||||||
'u' to 100_000L, // micro-BTC = 10^5 msat
|
'u' to 100_000L,
|
||||||
'n' to 100L, // nano-BTC = 10^2 msat
|
'n' to 100L,
|
||||||
'p' to 1L // pico-BTC = 10^-1 msat (must be multiple of 10)
|
'p' to 1L
|
||||||
)
|
)
|
||||||
|
|
||||||
private const val TAG_PAYMENT_HASH = 1
|
private const val TAG_PAYMENT_HASH = 1
|
||||||
private const val TAG_DESCRIPTION = 13
|
private const val TAG_DESCRIPTION = 13
|
||||||
private const val TAG_PAYEE_PUBKEY = 19
|
private const val TAG_PAYEE_PUBKEY = 19
|
||||||
private const val TAG_ROUTE_HINTS = 3
|
private const val TAG_ROUTE_HINTS = 3
|
||||||
private const val TAG_EXPIRY = 6 // ── NEW
|
private const val TAG_EXPIRY = 6
|
||||||
private const val TAG_FALLBACK_ADDR = 9 // ── NEW
|
private const val TAG_FALLBACK_ADDR = 9
|
||||||
|
|
||||||
private const val SIGNATURE_GROUPS = 104
|
private const val SIGNATURE_GROUPS = 104
|
||||||
private const val DEFAULT_EXPIRY_S = 3600L // ── NEW: BOLT11 default
|
private const val DEFAULT_EXPIRY_S = 3600L
|
||||||
|
|
||||||
// ── bech32 address encoding constants ────────────────────────────────────
|
|
||||||
private const val BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
private const val BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||||
private val BECH32_GEN = intArrayOf(
|
private val BECH32_GEN = intArrayOf(
|
||||||
0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3
|
0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3
|
||||||
@@ -94,8 +92,7 @@ object Bolt11Decoder {
|
|||||||
val amountSats = amountMsat / 1_000L
|
val amountSats = amountMsat / 1_000L
|
||||||
|
|
||||||
// ── 3. Decode bech32 → 5-bit groups ──────────────────────────────────
|
// ── 3. Decode bech32 → 5-bit groups ──────────────────────────────────
|
||||||
val payload = dataChars.dropLast(6)
|
val allGroups = dataChars.map { c ->
|
||||||
val groups = payload.map { c ->
|
|
||||||
val idx = CHARSET.indexOf(c)
|
val idx = CHARSET.indexOf(c)
|
||||||
if (idx < 0) {
|
if (idx < 0) {
|
||||||
Timber.w("Invalid bech32 character: '$c'")
|
Timber.w("Invalid bech32 character: '$c'")
|
||||||
@@ -104,15 +101,26 @@ object Bolt11Decoder {
|
|||||||
idx
|
idx
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── NEW: Validate bech32 checksum ─────────────────────────────────────
|
||||||
|
// The full bech32 string (including the 6-char checksum) must produce
|
||||||
|
// a polymod of exactly 1 when fed through the verifier.
|
||||||
|
if (!verifyBech32Checksum(hrp, allGroups)) {
|
||||||
|
Timber.w("Invalid bech32 checksum — invoice may be corrupted or tampered")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip the 6 checksum groups now that they are validated
|
||||||
|
val payload = allGroups.dropLast(6)
|
||||||
|
|
||||||
// ── 4. Split signature / signed data ──────────────────────────────────
|
// ── 4. Split signature / signed data ──────────────────────────────────
|
||||||
val sigGroups = groups.takeLast(SIGNATURE_GROUPS)
|
val sigGroups = payload.takeLast(SIGNATURE_GROUPS)
|
||||||
val signedGroups = groups.dropLast(SIGNATURE_GROUPS)
|
val signedGroups = payload.dropLast(SIGNATURE_GROUPS)
|
||||||
if (signedGroups.size < 7) {
|
if (signedGroups.size < 7) {
|
||||||
Timber.w("Data part too short for timestamp")
|
Timber.w("Data part too short for timestamp")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── NEW: decode 35-bit timestamp (7 groups × 5 bits) ─────────────────
|
// ── Decode 35-bit timestamp (7 groups × 5 bits) ───────────────────────
|
||||||
var timestampSecs = 0L
|
var timestampSecs = 0L
|
||||||
for (i in 0 until 7) {
|
for (i in 0 until 7) {
|
||||||
timestampSecs = (timestampSecs shl 5) or signedGroups[i].toLong()
|
timestampSecs = (timestampSecs shl 5) or signedGroups[i].toLong()
|
||||||
@@ -122,15 +130,14 @@ object Bolt11Decoder {
|
|||||||
val taggedGroups = signedGroups.drop(7)
|
val taggedGroups = signedGroups.drop(7)
|
||||||
|
|
||||||
// ── 5. Parse tagged fields ────────────────────────────────────────────
|
// ── 5. Parse tagged fields ────────────────────────────────────────────
|
||||||
var paymentHash: String? = null // ── NEW
|
var paymentHash: String? = null
|
||||||
var memo: String? = null
|
var memo: String? = null
|
||||||
var payee: String? = null
|
var payeeHex: String? = null // from 'n' field (if present)
|
||||||
var expirySecs: Long? = null // ── NEW (null = use default)
|
var expirySecs: Long? = null
|
||||||
var fallbackAddress: String? = null // ── NEW
|
var fallbackAddress: String? = null
|
||||||
val routeHintRoutes = mutableListOf<List<RouteHintHop>>()
|
val routeHintRoutes = mutableListOf<List<RouteHintHop>>()
|
||||||
|
|
||||||
// bech32 address HRP for this network (used for fallback address encoding)
|
val addrHrp = PREFIX_TO_ADDRESS_HRP[prefix] ?: "bc"
|
||||||
val addrHrp = PREFIX_TO_ADDRESS_HRP[prefix] ?: "bc" // ── NEW
|
|
||||||
|
|
||||||
var pos = 0
|
var pos = 0
|
||||||
while (pos + 2 < taggedGroups.size) {
|
while (pos + 2 < taggedGroups.size) {
|
||||||
@@ -148,9 +155,7 @@ object Bolt11Decoder {
|
|||||||
|
|
||||||
when (type) {
|
when (type) {
|
||||||
|
|
||||||
// ── NEW ───────────────────────────────────────────────────────
|
|
||||||
TAG_PAYMENT_HASH -> {
|
TAG_PAYMENT_HASH -> {
|
||||||
// Fixed: 52 groups × 5 = 260 bits → 32 bytes + 4 padding bits
|
|
||||||
if (fieldGroups.size != 52) {
|
if (fieldGroups.size != 52) {
|
||||||
Timber.w("Malformed 'p' field (len=${fieldGroups.size}, expected 52)")
|
Timber.w("Malformed 'p' field (len=${fieldGroups.size}, expected 52)")
|
||||||
continue
|
continue
|
||||||
@@ -170,23 +175,18 @@ object Bolt11Decoder {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
val bytes = convertBits(fieldGroups, 5, 8, false) ?: continue
|
val bytes = convertBits(fieldGroups, 5, 8, false) ?: continue
|
||||||
if (bytes.size == 33) payee = bytes.toByteArray().toHex()
|
if (bytes.size == 33) payeeHex = bytes.toByteArray().toHex()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── NEW ───────────────────────────────────────────────────────
|
|
||||||
TAG_EXPIRY -> {
|
TAG_EXPIRY -> {
|
||||||
// Variable-length big-endian integer in 5-bit groups
|
|
||||||
var secs = 0L
|
var secs = 0L
|
||||||
for (g in fieldGroups) secs = (secs shl 5) or g.toLong()
|
for (g in fieldGroups) secs = (secs shl 5) or g.toLong()
|
||||||
expirySecs = secs
|
expirySecs = secs
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── NEW ───────────────────────────────────────────────────────
|
|
||||||
TAG_FALLBACK_ADDR -> {
|
TAG_FALLBACK_ADDR -> {
|
||||||
fallbackAddress = decodeFallbackAddress(fieldGroups, addrHrp)
|
fallbackAddress = decodeFallbackAddress(fieldGroups, addrHrp)
|
||||||
if (fallbackAddress == null) {
|
if (fallbackAddress == null) Timber.w("Could not decode fallback address")
|
||||||
Timber.w("Could not decode fallback address")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
TAG_ROUTE_HINTS -> {
|
TAG_ROUTE_HINTS -> {
|
||||||
@@ -211,16 +211,26 @@ object Bolt11Decoder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── NEW: compute expiresAt ────────────────────────────────────────────
|
// ── Compute expiresAt ─────────────────────────────────────────────────
|
||||||
val expiresAt = createdAt.plusSeconds(expirySecs ?: DEFAULT_EXPIRY_S)
|
val expiresAt = createdAt.plusSeconds(expirySecs ?: DEFAULT_EXPIRY_S)
|
||||||
|
|
||||||
// ── 6. Recover payee from signature if 'n' field was absent ───────────
|
// ── NEW: Validate / recover payee from signature ───────────────────────
|
||||||
if (payee == null) {
|
//
|
||||||
payee = recoverPayee(hrp, signedGroups, sigGroups)
|
// BOLT11 §Signature:
|
||||||
if (payee != null) Timber.d("Recovered payee pubkey from signature")
|
// signed_data = hrp_bytes || convertBits(signedGroups, 5→8, pad=true)
|
||||||
|
// message = SHA-256(signed_data)
|
||||||
|
// signature = 64-byte compact ECDSA sig + 1-byte recovery id
|
||||||
|
//
|
||||||
|
// Case A – 'n' field present: VERIFY the signature against the known pubkey.
|
||||||
|
// Case B – 'n' field absent : RECOVER the pubkey from the signature.
|
||||||
|
// Either way a failure is fatal.
|
||||||
|
val payee: String? = verifyOrRecoverPayee(hrp, signedGroups, sigGroups, payeeHex)
|
||||||
|
?: run {
|
||||||
|
Timber.w("Signature validation failed — rejecting invoice")
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── NEW: paymentHash is required — fail loudly if absent ─────────────
|
// ── paymentHash is required ───────────────────────────────────────────
|
||||||
if (paymentHash == null) {
|
if (paymentHash == null) {
|
||||||
Timber.w("Invoice missing required 'p' (payment hash) field")
|
Timber.w("Invoice missing required 'p' (payment hash) field")
|
||||||
return null
|
return null
|
||||||
@@ -228,10 +238,12 @@ object Bolt11Decoder {
|
|||||||
|
|
||||||
val routeHints = routeHintRoutes.flatten()
|
val routeHints = routeHintRoutes.flatten()
|
||||||
|
|
||||||
Timber.d("Decoded locally — amount: ${amountSats}sat, memo: \"$memo\", " +
|
Timber.d(
|
||||||
|
"Decoded locally — amount: ${amountSats}sat, memo: \"$memo\", " +
|
||||||
"payee: $payee, routes: ${routeHintRoutes.size}, hops: ${routeHints.size}, " +
|
"payee: $payee, routes: ${routeHintRoutes.size}, hops: ${routeHints.size}, " +
|
||||||
"createdAt: $createdAt, expiresAt: $expiresAt, " +
|
"createdAt: $createdAt, expiresAt: $expiresAt, " +
|
||||||
"fallback: $fallbackAddress, paymentHash: $paymentHash")
|
"fallback: $fallbackAddress, paymentHash: $paymentHash"
|
||||||
|
)
|
||||||
|
|
||||||
return DecodedBolt11(
|
return DecodedBolt11(
|
||||||
amountSats = amountSats,
|
amountSats = amountSats,
|
||||||
@@ -245,14 +257,89 @@ object Bolt11Decoder {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── NEW: Bech32 checksum verification ─────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true iff the bech32 checksum over [hrp] + [data] (all groups,
|
||||||
|
* including the 6 checksum groups) is valid, i.e. polymod == 1.
|
||||||
|
*/
|
||||||
|
private fun verifyBech32Checksum(hrp: String, data: List<Int>): Boolean {
|
||||||
|
val values = bech32HrpExpand(hrp) + data
|
||||||
|
return bech32Polymod(values) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── NEW: Unified signature verify / recover ───────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the invoice signature.
|
||||||
|
*
|
||||||
|
* @param knownPubkeyHex hex-encoded compressed pubkey from the 'n' field,
|
||||||
|
* or null if the field was absent.
|
||||||
|
*
|
||||||
|
* - When [knownPubkeyHex] is non-null (Case A): performs an explicit
|
||||||
|
* ECDSA verify; returns the pubkey hex on success, null on failure.
|
||||||
|
* - When [knownPubkeyHex] is null (Case B): recovers the pubkey from the
|
||||||
|
* signature; returns the recovered pubkey hex, or null on failure.
|
||||||
|
*/
|
||||||
|
private fun verifyOrRecoverPayee(
|
||||||
|
hrp: String,
|
||||||
|
signedGroups: List<Int>,
|
||||||
|
sigGroups: List<Int>,
|
||||||
|
knownPubkeyHex: String?
|
||||||
|
): String? = runCatching {
|
||||||
|
// Build the message hash (identical for both cases)
|
||||||
|
val dataBytes = convertBits(signedGroups, 5, 8, true)?.toByteArray()
|
||||||
|
?: return null
|
||||||
|
val preimage = hrp.toByteArray(Charsets.US_ASCII) + dataBytes
|
||||||
|
val message = sha256(preimage)
|
||||||
|
|
||||||
|
// Decode the 65-byte signature (64 compact + 1 recovery id)
|
||||||
|
val sigBytes = convertBits(sigGroups, 5, 8, false)?.toByteArray()
|
||||||
|
?: return null
|
||||||
|
if (sigBytes.size != 65) {
|
||||||
|
Timber.w("Unexpected signature length: ${sigBytes.size}")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val compactSig = sigBytes.copyOfRange(0, 64)
|
||||||
|
val recid = sigBytes[64].toInt()
|
||||||
|
if (recid !in 0..3) {
|
||||||
|
Timber.w("Invalid recovery id: $recid")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (knownPubkeyHex != null) {
|
||||||
|
// ── Case A: explicit verify ───────────────────────────────────────
|
||||||
|
val pubkeyBytes = hexToByteArray(knownPubkeyHex)
|
||||||
|
|
||||||
|
// Secp256k1.ecdsaVerify expects a normalised DER or compact sig;
|
||||||
|
// the library's verifyCompact variant takes (sig64, msg32, pubkey).
|
||||||
|
val valid = Secp256k1.verify(compactSig, message, pubkeyBytes)
|
||||||
|
if (!valid) {
|
||||||
|
Timber.w("ECDSA signature verification failed for known pubkey")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
Timber.d("Signature verified against 'n' field pubkey")
|
||||||
|
knownPubkeyHex // return the already-known pubkey
|
||||||
|
} else {
|
||||||
|
// ── Case B: recovery ──────────────────────────────────────────────
|
||||||
|
val uncompressed = Secp256k1.ecdsaRecover(compactSig, message, recid)
|
||||||
|
val recovered = Secp256k1.pubKeyCompress(uncompressed).toHex()
|
||||||
|
Timber.d("Payee pubkey recovered from signature: $recovered")
|
||||||
|
recovered
|
||||||
|
}
|
||||||
|
}.getOrElse {
|
||||||
|
Timber.w("Signature processing failed: ${it.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
// ── Fallback address decoding ─────────────────────────────────────────────
|
// ── Fallback address decoding ─────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decodes a BOLT11 'f' (fallback address) tagged field.
|
* Decodes a BOLT11 'f' (fallback address) tagged field.
|
||||||
*
|
*
|
||||||
* The first 5-bit group is the address version/type:
|
* The first 5-bit group is the address version/type:
|
||||||
* 0 = P2PKH → 20-byte hash → Base58Check (version byte 0x00 mainnet / 0x6f testnet)
|
* 0 = P2PKH → 20-byte hash → Base58Check
|
||||||
* 1 = P2SH → 20-byte hash → Base58Check (version byte 0x05 mainnet / 0xc4 testnet)
|
* 1 = P2SH → 20-byte hash → Base58Check
|
||||||
* 17 = P2WPKH → 20-byte witness program → bech32 address (witness version 0)
|
* 17 = P2WPKH → 20-byte witness program → bech32 address (witness version 0)
|
||||||
* 18 = P2WSH → 32-byte witness program → bech32 address (witness version 0)
|
* 18 = P2WSH → 32-byte witness program → bech32 address (witness version 0)
|
||||||
*/
|
*/
|
||||||
@@ -263,29 +350,19 @@ object Bolt11Decoder {
|
|||||||
|
|
||||||
return when (version) {
|
return when (version) {
|
||||||
0 -> {
|
0 -> {
|
||||||
// P2PKH
|
|
||||||
val hash = convertBits(dataGroups, 5, 8, false) ?: return null
|
val hash = convertBits(dataGroups, 5, 8, false) ?: return null
|
||||||
if (hash.size != 20) return null
|
if (hash.size != 20) return null
|
||||||
val versionByte = if (addrHrp == "bc") 0x00 else 0x6f
|
val versionByte = if (addrHrp == "bc") 0x00 else 0x6f
|
||||||
encodeBase58Check(versionByte, hash.toByteArray())
|
encodeBase58Check(versionByte, hash.toByteArray())
|
||||||
}
|
}
|
||||||
1 -> {
|
1 -> {
|
||||||
// P2SH
|
|
||||||
val hash = convertBits(dataGroups, 5, 8, false) ?: return null
|
val hash = convertBits(dataGroups, 5, 8, false) ?: return null
|
||||||
if (hash.size != 20) return null
|
if (hash.size != 20) return null
|
||||||
val versionByte = if (addrHrp == "bc") 0x05 else 0xc4
|
val versionByte = if (addrHrp == "bc") 0x05 else 0xc4
|
||||||
encodeBase58Check(versionByte, hash.toByteArray())
|
encodeBase58Check(versionByte, hash.toByteArray())
|
||||||
}
|
}
|
||||||
17 -> {
|
17 -> encodeBech32Address(addrHrp, witnessVersion = 0, dataGroups)
|
||||||
// P2WPKH — witness version 0, 20-byte program
|
18 -> encodeBech32Address(addrHrp, witnessVersion = 0, dataGroups)
|
||||||
// dataGroups are already in 5-bit form for bech32 encoding;
|
|
||||||
// prepend witness version 0 as the first data group
|
|
||||||
encodeBech32Address(addrHrp, witnessVersion = 0, dataGroups)
|
|
||||||
}
|
|
||||||
18 -> {
|
|
||||||
// P2WSH — witness version 0, 32-byte program
|
|
||||||
encodeBech32Address(addrHrp, witnessVersion = 0, dataGroups)
|
|
||||||
}
|
|
||||||
else -> {
|
else -> {
|
||||||
Timber.w("Unknown fallback address version: $version")
|
Timber.w("Unknown fallback address version: $version")
|
||||||
null
|
null
|
||||||
@@ -293,10 +370,6 @@ object Bolt11Decoder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Encodes a Base58Check address: version_byte || payload || checksum.
|
|
||||||
* Checksum = first 4 bytes of SHA-256(SHA-256(version_byte || payload)).
|
|
||||||
*/
|
|
||||||
private fun encodeBase58Check(versionByte: Int, payload: ByteArray): String {
|
private fun encodeBase58Check(versionByte: Int, payload: ByteArray): String {
|
||||||
val versioned = byteArrayOf(versionByte.toByte()) + payload
|
val versioned = byteArrayOf(versionByte.toByte()) + payload
|
||||||
val checksum = sha256(sha256(versioned)).copyOfRange(0, 4)
|
val checksum = sha256(sha256(versioned)).copyOfRange(0, 4)
|
||||||
@@ -313,24 +386,17 @@ object Bolt11Decoder {
|
|||||||
sb.append(ALPHABET[remainder.toInt()])
|
sb.append(ALPHABET[remainder.toInt()])
|
||||||
num = quotient
|
num = quotient
|
||||||
}
|
}
|
||||||
// Leading zero bytes → leading '1' characters
|
|
||||||
for (byte in input) {
|
for (byte in input) {
|
||||||
if (byte == 0.toByte()) sb.append('1') else break
|
if (byte == 0.toByte()) sb.append('1') else break
|
||||||
}
|
}
|
||||||
return sb.reverse().toString()
|
return sb.reverse().toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Encodes a native SegWit (bech32) address.
|
|
||||||
* [dataGroups] are the 5-bit groups of the witness program (as stored in the 'f' field,
|
|
||||||
* already in 5-bit form — no convertBits needed).
|
|
||||||
*/
|
|
||||||
private fun encodeBech32Address(
|
private fun encodeBech32Address(
|
||||||
hrp: String,
|
hrp: String,
|
||||||
witnessVersion: Int,
|
witnessVersion: Int,
|
||||||
dataGroups: List<Int>
|
dataGroups: List<Int>
|
||||||
): String {
|
): String {
|
||||||
// data = [witnessVersion] + dataGroups
|
|
||||||
val data = listOf(witnessVersion) + dataGroups
|
val data = listOf(witnessVersion) + dataGroups
|
||||||
val checksum = bech32Checksum(hrp, data)
|
val checksum = bech32Checksum(hrp, data)
|
||||||
val sb = StringBuilder(hrp).append('1')
|
val sb = StringBuilder(hrp).append('1')
|
||||||
@@ -359,41 +425,14 @@ object Bolt11Decoder {
|
|||||||
return chk
|
return chk
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── secp256k1 ECDSA public-key recovery ──────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private fun recoverPayee(
|
|
||||||
hrp: String,
|
|
||||||
signedGroups: List<Int>,
|
|
||||||
sigGroups: List<Int>
|
|
||||||
): String? = runCatching {
|
|
||||||
val dataBytes = convertBits(signedGroups, 5, 8, true) ?: return null
|
|
||||||
val preimage = hrp.toByteArray(Charsets.US_ASCII) + dataBytes.toByteArray()
|
|
||||||
val message = sha256(preimage)
|
|
||||||
|
|
||||||
val sigBytes = convertBits(sigGroups, 5, 8, false)?.toByteArray() ?: return null
|
|
||||||
if (sigBytes.size != 65) {
|
|
||||||
Timber.w("Unexpected signature length: ${sigBytes.size}")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
val compactSig = sigBytes.copyOfRange(0, 64)
|
|
||||||
val recid = sigBytes[64].toInt()
|
|
||||||
if (recid !in 0..3) {
|
|
||||||
Timber.w("Invalid recovery id: $recid")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
val uncompressed = Secp256k1.ecdsaRecover(compactSig, message, recid)
|
|
||||||
Secp256k1.pubKeyCompress(uncompressed).toHex()
|
|
||||||
}.getOrElse {
|
|
||||||
Timber.w("Payee recovery failed: ${it.message}")
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private fun sha256(input: ByteArray): ByteArray =
|
private fun sha256(input: ByteArray): ByteArray =
|
||||||
MessageDigest.getInstance("SHA-256").digest(input)
|
MessageDigest.getInstance("SHA-256").digest(input)
|
||||||
|
|
||||||
|
private fun hexToByteArray(hex: String): ByteArray =
|
||||||
|
ByteArray(hex.length / 2) { hex.substring(it * 2, it * 2 + 2).toInt(16).toByte() }
|
||||||
|
|
||||||
private fun parseAmountMsat(amountStr: String): Long? {
|
private fun parseAmountMsat(amountStr: String): Long? {
|
||||||
if (amountStr.isEmpty()) return 0L
|
if (amountStr.isEmpty()) return 0L
|
||||||
val lastChar = amountStr.last()
|
val lastChar = amountStr.last()
|
||||||
@@ -405,7 +444,7 @@ object Bolt11Decoder {
|
|||||||
} else {
|
} else {
|
||||||
val number = amountStr.toLongOrNull() ?: return null
|
val number = amountStr.toLongOrNull() ?: return null
|
||||||
if (number <= 0L) return null
|
if (number <= 0L) return null
|
||||||
number * 100_000_000_000L // 1 BTC = 10^11 msat
|
number * 100_000_000_000L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user