From 8e53a927a0b8048f29757cad0aa684abdb2c4dda Mon Sep 17 00:00:00 2001 From: rasputin Date: Thu, 4 Jun 2026 17:15:34 +0200 Subject: [PATCH] feat: parse bolt11 locally with network fallback --- app/build.gradle.kts | 5 + .../gudariwallet/data/DomainModels.kt | 7 +- .../data/LNbitsWalletRepository.kt | 39 +- .../bitcointxoko/gudariwallet/ui/UiStates.kt | 14 +- .../gudariwallet/ui/send/ConfirmCard.kt | 110 ++++- .../gudariwallet/ui/send/SendViewModel.kt | 10 +- .../gudariwallet/util/Bolt11Decoder.kt | 465 ++++++++++++++++++ gradle/libs.versions.toml | 3 + 8 files changed, 627 insertions(+), 26 deletions(-) create mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/util/Bolt11Decoder.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5d53484..8afb9e9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -122,6 +122,11 @@ dependencies { // animations implementation(libs.androidx.compose.animation) + + // secp256k1 + dependencies { + implementation(libs.secp256k1.kmp.android) + } } protobuf { diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/DomainModels.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/DomainModels.kt index ba726c7..4464928 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/data/DomainModels.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/DomainModels.kt @@ -2,6 +2,7 @@ package com.bitcointxoko.gudariwallet.data import com.bitcointxoko.gudariwallet.api.LnurlScanResponse import com.bitcointxoko.gudariwallet.api.RouteHintHop +import java.time.Instant /** * Domain model classes for the wallet layer. @@ -24,7 +25,11 @@ data class DecodedBolt11( val amountSats : Long, val memo : String, val payee : String?, - val routeHints : List = emptyList() + val routeHints : List = emptyList(), + val paymentHash : String, // hex; unique receipt ID (required field) + val createdAt : Instant, // invoice creation timestamp + val expiresAt : Instant, // createdAt + expiry (default +1h); show countdown + val fallbackAddress : String? = null // on-chain fallback address, if present ) data class ScannedLnurl( diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/LNbitsWalletRepository.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/LNbitsWalletRepository.kt index 758ece1..7a89aaa 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/data/LNbitsWalletRepository.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/LNbitsWalletRepository.kt @@ -4,9 +4,11 @@ import android.net.Uri import android.util.Log import com.bitcointxoko.gudariwallet.api.* import com.bitcointxoko.gudariwallet.security.SecretStore +import com.bitcointxoko.gudariwallet.util.Bolt11Decoder import com.bitcointxoko.gudariwallet.util.LnurlBech32 import com.bitcointxoko.gudariwallet.util.LnurlMetadataParser import com.bitcointxoko.gudariwallet.util.WalletConstants +import java.time.Instant private const val TAG = "LNbitsWalletRepo" @@ -47,22 +49,51 @@ class LNbitsWalletRepository( // ── Send ────────────────────────────────────────────────────────────────── override suspend fun decodeBolt11(bolt11: String): DecodedBolt11 { + // ── 1. Try local decode first (no network, no latency) ─────────────── + val local = runCatching { Bolt11Decoder.decode(bolt11) }.getOrNull() + if (local != null) { + Log.i("WalletRepository", "decodeBolt11: decoded locally ✓") + return local + } + + // ── 2. Fall back to network API ─────────────────────────────────────── + Log.w("WalletRepository", "decodeBolt11: local decode failed, falling back to network") val response = api.decodeInvoice(secrets.invoiceKey(), DecodeInvoiceRequest(data = bolt11)) + Log.i("WalletRepository", "decodeBolt11: decoded via network ✓") + val amountSats = response.amountMsat ?.takeIf { it > 0L } ?.div(WalletConstants.MSAT_PER_SAT) ?: throw UnsupportedOperationException( "Zero-amount invoices are not supported. Please ask the sender to specify an amount." ) + + val paymentHash = response.paymentHash + ?: throw UnsupportedOperationException( + "Invoice is missing a payment hash and cannot be paid." + ) + + // createdAt / expiresAt / fallbackAddress are not returned by the API — + // use safe defaults and warn so we know if this path is hit in practice. + Log.w("WalletRepository", "decodeBolt11: network response has no timestamp/expiry — " + + "using Instant.now() + 1h as fallback") + val createdAt = Instant.now() + val expiresAt = createdAt.plusSeconds(3600L) + return DecodedBolt11( - amountSats = amountSats, - memo = response.description ?: "", - payee = response.payee?.takeIf { it.isNotBlank() }, - routeHints = response.routeHints?.flatten() ?: emptyList() + amountSats = amountSats, + memo = response.description ?: "", + payee = response.payee?.takeIf { it.isNotBlank() }, + routeHints = response.routeHints?.flatten() ?: emptyList(), + paymentHash = paymentHash, + createdAt = createdAt, + expiresAt = expiresAt, + fallbackAddress = null ) } + override suspend fun scanLnurl(code: String): ScannedLnurl { val r = api.lnurlScan(secrets.invoiceKey(), code) return ScannedLnurl( diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/UiStates.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/UiStates.kt index 4470364..0c84c49 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/UiStates.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/UiStates.kt @@ -6,6 +6,7 @@ import com.bitcointxoko.gudariwallet.api.PaymentRecord import com.bitcointxoko.gudariwallet.api.RouteHintHop import com.bitcointxoko.gudariwallet.util.RouteHintRisk import com.bitcointxoko.gudariwallet.util.SendInputType +import java.time.Instant sealed class UiState { data object Loading : UiState() @@ -53,7 +54,10 @@ sealed class SendState { override val nodeAlias : String? = null, override val routeRisk : RouteHintRisk? = null, override val allRouteHints : List = emptyList(), - override val routeHintAliases : Map = emptyMap() + override val routeHintAliases : Map = emptyMap(), + override val paymentHash : String, + override val createdAt : Instant, + override val expiresAt : Instant ) : SendState(), DecodedInvoice data class LnurlReady( val callback : String, @@ -78,7 +82,10 @@ sealed class SendState { override val nodeAlias : String? = null, override val routeRisk : RouteHintRisk? = null, override val allRouteHints : List = emptyList(), - override val routeHintAliases : Map = emptyMap() + override val routeHintAliases : Map = emptyMap(), + override val paymentHash : String, + override val createdAt : Instant, + override val expiresAt : Instant ) : SendState(), DecodedInvoice data object Paying : SendState() data class PaymentSent( @@ -149,4 +156,7 @@ interface DecodedInvoice { val routeRisk : RouteHintRisk? val allRouteHints : List val routeHintAliases : Map + val paymentHash : String + val createdAt : Instant + val expiresAt : Instant } \ No newline at end of file diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/ConfirmCard.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/ConfirmCard.kt index 3003ea4..1615ade 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/ConfirmCard.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/ConfirmCard.kt @@ -13,6 +13,11 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextDecoration @@ -26,27 +31,32 @@ import com.bitcointxoko.gudariwallet.ui.components.CopyIconButton import com.bitcointxoko.gudariwallet.ui.components.DetailRow import com.bitcointxoko.gudariwallet.util.fiatLabel import com.bitcointxoko.gudariwallet.util.payingToLabel +import kotlinx.coroutines.delay +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter @Composable internal fun Bolt11ConfirmCard( state : SendState.Bolt11Decoded, - activity : FragmentActivity, + activity : FragmentActivity, vm : WalletViewModel, fiatRate : Double?, fiatCurrency: String? ) { ConfirmCardContent( - title = "Invoice Details", - invoice = state, - fiatLabel = fiatLabel(state.amountSats, fiatRate, fiatCurrency), + title = "Invoice Details", + invoice = state, + fiatLabel = fiatLabel(state.amountSats, fiatRate, fiatCurrency), onPay = { vm.requestPayment(activity, "Send ${state.amountSats} sats") { vm.payBolt11(state.bolt11) } }, - onCancel = { vm.resetSendState() } + onCancel = { vm.resetSendState() } ) } + @Composable internal fun LnurlInvoiceConfirmCard( state : SendState.LnurlInvoiceReady, @@ -57,16 +67,15 @@ internal fun LnurlInvoiceConfirmCard( ) { val label = payingToLabel(state.lnurl, state.domain) ConfirmCardContent( - title = "Paying to $label", - invoice = state, - fiatLabel = fiatLabel(state.amountSats, fiatRate, fiatCurrency), + title = "Paying to $label", + invoice = state, + fiatLabel = fiatLabel(state.amountSats, fiatRate, fiatCurrency), onPay = { - val label = payingToLabel(state.lnurl, state.domain) vm.requestPayment(activity, "Send ${state.amountSats} sats to $label") { vm.payLnurlInvoice(state) } }, - onCancel = { vm.resetSendState() } + onCancel = { vm.resetSendState() } ) } @@ -86,7 +95,50 @@ private fun ConfirmCardContent( val routeRisk = invoice.routeRisk val allRouteHints = invoice.allRouteHints val routeHintAliases = invoice.routeHintAliases - val context = LocalContext.current + val paymentHash = invoice.paymentHash + val createdAt = invoice.createdAt + val expiresAt = invoice.expiresAt + val context = LocalContext.current + + // ── Live expiry countdown — ticks every second ──────────────────────── + var now by remember { mutableStateOf(Instant.now()) } + LaunchedEffect(expiresAt) { + while (true) { + delay(1_000L) + now = Instant.now() + } + } + val isExpired = now.isAfter(expiresAt) + val secondsLeft = expiresAt.epochSecond - now.epochSecond + val expiryCountdown = when { + isExpired -> "Expired" + secondsLeft < 60 -> "${secondsLeft}s" + secondsLeft < 3600 -> "${secondsLeft / 60}m ${secondsLeft % 60}s" + else -> "${secondsLeft / 3600}h ${(secondsLeft % 3600) / 60}m" + } + val expiryColor = when { + isExpired -> MaterialTheme.colorScheme.error + secondsLeft < 60 -> MaterialTheme.colorScheme.error + secondsLeft < 300 -> MaterialTheme.colorScheme.tertiary // ~warning colour + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + + // ── createdAt formatted as local time ───────────────────────────────── + val createdAtLabel = remember(createdAt) { + val zone = ZoneId.systemDefault() + val invoiceDay = createdAt.atZone(zone).toLocalDate() + val today = java.time.LocalDate.now(zone) + val timeStr = DateTimeFormatter.ofPattern("HH:mm").withZone(zone).format(createdAt) + when (invoiceDay) { + today -> "Today, $timeStr" + today.minusDays(1) -> "Yesterday, $timeStr" + else -> DateTimeFormatter + .ofPattern("dd MMM yyyy, HH:mm") + .withZone(zone) + .format(createdAt) + } + } + Card( modifier = Modifier.fillMaxWidth(), elevation = CardDefaults.cardElevation(2.dp) @@ -107,7 +159,7 @@ private fun ConfirmCardContent( } if (!payee.isNullOrBlank()) { - val ambossUrl = "https://amboss.space/node/${payee}" + val ambossUrl = "https://amboss.space/node/$payee" val destinationLabel = nodeAlias ?: "${payee.take(8)}…${payee.takeLast(8)}" @@ -117,7 +169,7 @@ private fun ConfirmCardContent( value = destinationLabel, valueColor = MaterialTheme.colorScheme.primary, textDecoration = TextDecoration.Underline, - onValueClick = { + onValueClick = { context.startActivity(Intent(Intent.ACTION_VIEW, ambossUrl.toUri())) }, trailingContent = { @@ -126,6 +178,31 @@ private fun ConfirmCardContent( ) } + // ── NEW: Created at ─────────────────────────────────────────── + Spacer(Modifier.height(4.dp)) + DetailRow( + label = "Created", + value = createdAtLabel + ) + + // ── NEW: Expiry countdown ───────────────────────────────────── + Spacer(Modifier.height(4.dp)) + DetailRow( + label = "Expires in", + value = expiryCountdown, + valueColor = expiryColor + ) + + // ── NEW: Payment hash ───────────────────────────────────────── + Spacer(Modifier.height(4.dp)) + DetailRow( + label = "Hash", + value = "${paymentHash.take(8)}…${paymentHash.takeLast(8)}", + trailingContent = { + CopyIconButton("payment_hash", paymentHash) + } + ) + Spacer(Modifier.height(4.dp)) DetailRow( label = "Invoice", @@ -150,6 +227,7 @@ private fun ConfirmCardContent( Button( onClick = onPay, + enabled = !isExpired, // ── NEW: disable Pay if invoice has expired modifier = Modifier.fillMaxWidth() ) { PayButtonText(amountSats = invoice.amountSats, fiatLabel = fiatLabel) @@ -168,8 +246,6 @@ private fun PayButtonText(amountSats: Long, fiatLabel: String?) { } @Composable -private fun PayCancelButton( - onCancel : () -> Unit, -) { +private fun PayCancelButton(onCancel: () -> Unit) { OutlinedButton(onClick = onCancel, modifier = Modifier.fillMaxWidth()) { Text("Cancel") } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/SendViewModel.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/SendViewModel.kt index 55ab8dc..425aa21 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/SendViewModel.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/SendViewModel.kt @@ -191,7 +191,10 @@ class SendViewModel( nodeAlias = null, routeRisk = routeRisk, allRouteHints = decoded.routeHints, - routeHintAliases = emptyMap() + routeHintAliases = emptyMap(), + paymentHash = decoded.paymentHash, + createdAt = decoded.createdAt, + expiresAt = decoded.expiresAt ) resolveAliasesInBackground( @@ -338,7 +341,10 @@ class SendViewModel( nodeAlias = null, routeRisk = routeRisk, allRouteHints = decoded.routeHints, - routeHintAliases = emptyMap() + routeHintAliases = emptyMap(), + paymentHash = decoded.paymentHash, + createdAt = decoded.createdAt, + expiresAt = decoded.expiresAt ) resolveAliasesInBackground( payee = decoded.payee, diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/util/Bolt11Decoder.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/util/Bolt11Decoder.kt new file mode 100644 index 0000000..1245580 --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/util/Bolt11Decoder.kt @@ -0,0 +1,465 @@ +package com.bitcointxoko.gudariwallet.util + +import android.util.Log +import com.bitcointxoko.gudariwallet.api.RouteHintHop +import com.bitcointxoko.gudariwallet.data.DecodedBolt11 +import fr.acinq.secp256k1.Secp256k1 +import java.security.MessageDigest +import java.time.Instant + +/** + * Pure local BOLT11 invoice decoder (no network required). + * + * Spec: https://github.com/lightning/bolts/blob/master/11-payment-encoding.md + */ +object Bolt11Decoder { + + private const val TAG = "Bolt11Decoder" + + private const val CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" + + 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( + "lnbc" to "bc", + "lntb" to "tb", + "lntbs" to "tb", + "lnbcrt" to "bcrt" + ) + + private val MULTIPLIER_MSAT: Map = mapOf( + 'm' to 100_000_000L, // milli-BTC = 10^8 msat + 'u' to 100_000L, // micro-BTC = 10^5 msat + 'n' to 100L, // nano-BTC = 10^2 msat + 'p' to 1L // pico-BTC = 10^-1 msat (must be multiple of 10) + ) + + private const val TAG_PAYMENT_HASH = 1 + private const val TAG_DESCRIPTION = 13 + private const val TAG_PAYEE_PUBKEY = 19 + private const val TAG_ROUTE_HINTS = 3 + private const val TAG_EXPIRY = 6 // ── NEW + private const val TAG_FALLBACK_ADDR = 9 // ── NEW + + private const val SIGNATURE_GROUPS = 104 + private const val DEFAULT_EXPIRY_S = 3600L // ── NEW: BOLT11 default + + // ── bech32 address encoding constants ──────────────────────────────────── + private const val BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" + private val BECH32_GEN = intArrayOf( + 0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3 + ) + + // ------------------------------------------------------------------------- + + fun decode(bolt11: String): DecodedBolt11? { + val lower = bolt11.trim().lowercase() + + // ── 1. Split HRP / data ─────────────────────────────────────────────── + val sepIdx = lower.lastIndexOf('1') + if (sepIdx < 1) { + Log.w(TAG, "No bech32 separator found") + return null + } + + val hrp = lower.substring(0, sepIdx) + val dataChars = lower.substring(sepIdx + 1) + + if (dataChars.length < 6 + SIGNATURE_GROUPS + 7) { + Log.w(TAG, "Data part too short: ${dataChars.length} chars") + return null + } + + // ── 2. Parse HRP ────────────────────────────────────────────────────── + val prefix = KNOWN_PREFIXES.firstOrNull { hrp.startsWith(it) } + ?: run { + Log.w(TAG, "Unknown HRP prefix: $hrp") + return null + } + + val amountStr = hrp.removePrefix(prefix) + val amountMsat = parseAmountMsat(amountStr) + ?: run { + Log.w(TAG, "Could not parse amount from HRP: '$amountStr'") + return null + } + + if (amountMsat <= 0L) { + throw UnsupportedOperationException( + "Zero-amount invoices are not supported. Please ask the sender to specify an amount." + ) + } + + val amountSats = amountMsat / 1_000L + + // ── 3. Decode bech32 → 5-bit groups ────────────────────────────────── + val payload = dataChars.dropLast(6) + val groups = payload.map { c -> + val idx = CHARSET.indexOf(c) + if (idx < 0) { + Log.w(TAG, "Invalid bech32 character: '$c'") + return null + } + idx + } + + // ── 4. Split signature / signed data ────────────────────────────────── + val sigGroups = groups.takeLast(SIGNATURE_GROUPS) + val signedGroups = groups.dropLast(SIGNATURE_GROUPS) + if (signedGroups.size < 7) { + Log.w(TAG, "Data part too short for timestamp") + return null + } + + // ── NEW: decode 35-bit timestamp (7 groups × 5 bits) ───────────────── + var timestampSecs = 0L + for (i in 0 until 7) { + timestampSecs = (timestampSecs shl 5) or signedGroups[i].toLong() + } + val createdAt = Instant.ofEpochSecond(timestampSecs) + + val taggedGroups = signedGroups.drop(7) + + // ── 5. Parse tagged fields ──────────────────────────────────────────── + var paymentHash: String? = null // ── NEW + var memo: String? = null + var payee: String? = null + var expirySecs: Long? = null // ── NEW (null = use default) + var fallbackAddress: String? = null // ── NEW + val routeHintRoutes = mutableListOf>() + + // bech32 address HRP for this network (used for fallback address encoding) + val addrHrp = PREFIX_TO_ADDRESS_HRP[prefix] ?: "bc" // ── NEW + + var pos = 0 + while (pos + 2 < taggedGroups.size) { + val type = taggedGroups[pos] + val dataLen = (taggedGroups[pos + 1] shl 5) or taggedGroups[pos + 2] + pos += 3 + + if (pos + dataLen > taggedGroups.size) { + Log.w(TAG, "Tagged field overflows data (type=$type, len=$dataLen)") + break + } + + val fieldGroups = taggedGroups.subList(pos, pos + dataLen) + pos += dataLen + + when (type) { + + // ── NEW ─────────────────────────────────────────────────────── + TAG_PAYMENT_HASH -> { + // Fixed: 52 groups × 5 = 260 bits → 32 bytes + 4 padding bits + if (fieldGroups.size != 52) { + Log.w(TAG, "Malformed 'p' field (len=${fieldGroups.size}, expected 52)") + continue + } + val bytes = convertBits(fieldGroups, 5, 8, false) ?: continue + if (bytes.size == 32) paymentHash = bytes.toByteArray().toHex() + } + + TAG_DESCRIPTION -> { + val bytes = convertBits(fieldGroups, 5, 8, false) ?: continue + memo = String(bytes.toByteArray(), Charsets.UTF_8) + } + + TAG_PAYEE_PUBKEY -> { + if (fieldGroups.size != 53) { + Log.w(TAG, "Malformed 'n' field (len=${fieldGroups.size}, expected 53)") + continue + } + val bytes = convertBits(fieldGroups, 5, 8, false) ?: continue + if (bytes.size == 33) payee = bytes.toByteArray().toHex() + } + + // ── NEW ─────────────────────────────────────────────────────── + TAG_EXPIRY -> { + // Variable-length big-endian integer in 5-bit groups + var secs = 0L + for (g in fieldGroups) secs = (secs shl 5) or g.toLong() + expirySecs = secs + } + + // ── NEW ─────────────────────────────────────────────────────── + TAG_FALLBACK_ADDR -> { + fallbackAddress = decodeFallbackAddress(fieldGroups, addrHrp) + if (fallbackAddress == null) { + Log.w(TAG, "Could not decode fallback address") + } + } + + TAG_ROUTE_HINTS -> { + val bytes = convertBits(fieldGroups, 5, 8, false) ?: continue + val hops = mutableListOf() + var hopPos = 0 + while (hopPos + 51 <= bytes.size) { + val hop = bytes.subList(hopPos, hopPos + 51) + hops += RouteHintHop( + publicKey = hop.subList(0, 33).toByteArray().toHex(), + shortChannelId = hop.subList(33, 41).toByteArray().toScidString(), + baseFeeMsat = hop.subList(41, 45).toByteArray().toUInt32(), + ppmFee = hop.subList(45, 49).toByteArray().toUInt32(), + cltvExpiryDelta = hop.subList(49, 51).toByteArray().toUInt16() + ) + hopPos += 51 + } + if (hops.isNotEmpty()) routeHintRoutes += hops + } + + else -> { /* skip unused tags */ } + } + } + + // ── NEW: compute expiresAt ──────────────────────────────────────────── + val expiresAt = createdAt.plusSeconds(expirySecs ?: DEFAULT_EXPIRY_S) + + // ── 6. Recover payee from signature if 'n' field was absent ─────────── + if (payee == null) { + payee = recoverPayee(hrp, signedGroups, sigGroups) + if (payee != null) Log.d(TAG, "Recovered payee pubkey from signature") + } + + // ── NEW: paymentHash is required — fail loudly if absent ───────────── + if (paymentHash == null) { + Log.w(TAG, "Invoice missing required 'p' (payment hash) field") + return null + } + + val routeHints = routeHintRoutes.flatten() + + Log.d(TAG, "Decoded locally — amount: ${amountSats}sat, memo: \"$memo\", " + + "payee: $payee, routes: ${routeHintRoutes.size}, hops: ${routeHints.size}, " + + "createdAt: $createdAt, expiresAt: $expiresAt, " + + "fallback: $fallbackAddress, paymentHash: $paymentHash") + + return DecodedBolt11( + amountSats = amountSats, + memo = memo ?: "", + payee = payee?.takeIf { it.isNotBlank() }, + routeHints = routeHints, + paymentHash = paymentHash, + createdAt = createdAt, + expiresAt = expiresAt, + fallbackAddress = fallbackAddress + ) + } + + // ── Fallback address decoding ───────────────────────────────────────────── + + /** + * Decodes a BOLT11 'f' (fallback address) tagged field. + * + * The first 5-bit group is the address version/type: + * 0 = P2PKH → 20-byte hash → Base58Check (version byte 0x00 mainnet / 0x6f testnet) + * 1 = P2SH → 20-byte hash → Base58Check (version byte 0x05 mainnet / 0xc4 testnet) + * 17 = P2WPKH → 20-byte witness program → bech32 address (witness version 0) + * 18 = P2WSH → 32-byte witness program → bech32 address (witness version 0) + */ + private fun decodeFallbackAddress(fieldGroups: List, addrHrp: String): String? { + if (fieldGroups.isEmpty()) return null + val version = fieldGroups[0] + val dataGroups = fieldGroups.drop(1) + + return when (version) { + 0 -> { + // P2PKH + val hash = convertBits(dataGroups, 5, 8, false) ?: return null + if (hash.size != 20) return null + val versionByte = if (addrHrp == "bc") 0x00 else 0x6f + encodeBase58Check(versionByte, hash.toByteArray()) + } + 1 -> { + // P2SH + val hash = convertBits(dataGroups, 5, 8, false) ?: return null + if (hash.size != 20) return null + val versionByte = if (addrHrp == "bc") 0x05 else 0xc4 + encodeBase58Check(versionByte, hash.toByteArray()) + } + 17 -> { + // P2WPKH — witness version 0, 20-byte program + // 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 -> { + Log.w(TAG, "Unknown fallback address version: $version") + null + } + } + } + + /** + * 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 { + val versioned = byteArrayOf(versionByte.toByte()) + payload + val checksum = sha256(sha256(versioned)).copyOfRange(0, 4) + return encodeBase58(versioned + checksum) + } + + private fun encodeBase58(input: ByteArray): String { + val ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + var num = java.math.BigInteger(1, input) + val sb = StringBuilder() + val base = java.math.BigInteger.valueOf(58) + while (num > java.math.BigInteger.ZERO) { + val (quotient, remainder) = num.divideAndRemainder(base) + sb.append(ALPHABET[remainder.toInt()]) + num = quotient + } + // Leading zero bytes → leading '1' characters + for (byte in input) { + if (byte == 0.toByte()) sb.append('1') else break + } + 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( + hrp: String, + witnessVersion: Int, + dataGroups: List + ): String { + // data = [witnessVersion] + dataGroups + val data = listOf(witnessVersion) + dataGroups + val checksum = bech32Checksum(hrp, data) + val sb = StringBuilder(hrp).append('1') + for (d in data + checksum) sb.append(BECH32_CHARSET[d]) + return sb.toString() + } + + private fun bech32Checksum(hrp: String, data: List): List { + val values = bech32HrpExpand(hrp) + data + listOf(0, 0, 0, 0, 0, 0) + val poly = bech32Polymod(values) xor 1 + return (0 until 6).map { (poly shr (5 * (5 - it))) and 31 } + } + + private fun bech32HrpExpand(hrp: String): List = + hrp.map { it.code shr 5 } + listOf(0) + hrp.map { it.code and 31 } + + private fun bech32Polymod(values: List): Int { + var chk = 1 + for (v in values) { + val top = chk shr 25 + chk = ((chk and 0x1ffffff) shl 5) xor v + for (i in 0 until 5) { + if ((top shr i) and 1 != 0) chk = chk xor BECH32_GEN[i] + } + } + return chk + } + + // ── secp256k1 ECDSA public-key recovery ────────────────────────────────── + + private fun recoverPayee( + hrp: String, + signedGroups: List, + sigGroups: List + ): 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) { + Log.w(TAG, "Unexpected signature length: ${sigBytes.size}") + return null + } + val compactSig = sigBytes.copyOfRange(0, 64) + val recid = sigBytes[64].toInt() + if (recid !in 0..3) { + Log.w(TAG, "Invalid recovery id: $recid") + return null + } + + val uncompressed = Secp256k1.ecdsaRecover(compactSig, message, recid) + Secp256k1.pubKeyCompress(uncompressed).toHex() + }.getOrElse { + Log.w(TAG, "Payee recovery failed: ${it.message}") + null + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private fun sha256(input: ByteArray): ByteArray = + MessageDigest.getInstance("SHA-256").digest(input) + + private fun parseAmountMsat(amountStr: String): Long? { + if (amountStr.isEmpty()) return 0L + val lastChar = amountStr.last() + val multiplier = MULTIPLIER_MSAT[lastChar] + return if (multiplier != null) { + val number = amountStr.dropLast(1).toLongOrNull() ?: return null + if (number <= 0L) return null + number * multiplier + } else { + val number = amountStr.toLongOrNull() ?: return null + if (number <= 0L) return null + number * 100_000_000_000L // 1 BTC = 10^11 msat + } + } + + private fun convertBits( + data : List, + fromBits: Int, + toBits : Int, + pad : Boolean + ): List? { + var acc = 0 + var bits = 0 + val out = mutableListOf() + val maxv = (1 shl toBits) - 1 + for (value in data) { + if (value < 0 || value shr fromBits != 0) return null + acc = (acc shl fromBits) or value + bits += fromBits + while (bits >= toBits) { + bits -= toBits + out += (acc shr bits) and maxv + } + } + if (pad) { + if (bits > 0) out += (acc shl (toBits - bits)) and maxv + } else if (bits >= fromBits || ((acc shl (toBits - bits)) and maxv) != 0) { + return null + } + return out + } + + private fun List.toByteArray() = ByteArray(size) { this[it].toByte() } + + private fun ByteArray.toHex() = joinToString("") { "%02x".format(it) } + + private fun ByteArray.toUInt16(): Int { + require(size >= 2) + return ((this[0].toInt() and 0xFF) shl 8) or (this[1].toInt() and 0xFF) + } + + private fun ByteArray.toUInt32(): Long { + require(size >= 4) + return ((this[0].toInt() and 0xFF).toLong() shl 24) or + ((this[1].toInt() and 0xFF).toLong() shl 16) or + ((this[2].toInt() and 0xFF).toLong() shl 8) or + (this[3].toInt() and 0xFF).toLong() + } + + private fun ByteArray.toScidString(): String { + require(size >= 8) + var raw = 0UL + for (i in 0 until 8) raw = (raw shl 8) or (this[i].toInt() and 0xFF).toULong() + val blockHeight = (raw shr 40) and 0xFFFFFFUL + val txIndex = (raw shr 16) and 0xFFFFFFUL + val outputIndex = raw and 0xFFFFUL + return "${blockHeight}x${txIndex}x${outputIndex}" + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ff519f8..3bdac53 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,6 +26,7 @@ protobuf = "4.26.1" protobufPlugin = "0.10.0" tink = "1.13.0" ui = "1.11.2" +secp256k1-kmp = "0.23.0" [libraries] androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } @@ -66,6 +67,8 @@ protobuf-kotlin-lite = { group = "com.google.protobuf", name = "prot protobuf-protoc = { group = "com.google.protobuf", name = "protoc", version.ref = "protobuf" } tink-android = { group = "com.google.crypto.tink", name = "tink-android", version.ref = "tink" } androidx-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "ui" } +secp256k1-kmp-android = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-android", version.ref = "secp256k1-kmp" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" }