feat: parse bolt11 locally with network fallback

This commit is contained in:
2026-06-04 17:15:34 +02:00
parent 4dadc75aca
commit 8e53a927a0
8 changed files with 627 additions and 26 deletions
+5
View File
@@ -122,6 +122,11 @@ dependencies {
// animations // animations
implementation(libs.androidx.compose.animation) implementation(libs.androidx.compose.animation)
// secp256k1
dependencies {
implementation(libs.secp256k1.kmp.android)
}
} }
protobuf { protobuf {
@@ -2,6 +2,7 @@ package com.bitcointxoko.gudariwallet.data
import com.bitcointxoko.gudariwallet.api.LnurlScanResponse import com.bitcointxoko.gudariwallet.api.LnurlScanResponse
import com.bitcointxoko.gudariwallet.api.RouteHintHop import com.bitcointxoko.gudariwallet.api.RouteHintHop
import java.time.Instant
/** /**
* Domain model classes for the wallet layer. * Domain model classes for the wallet layer.
@@ -24,7 +25,11 @@ data class DecodedBolt11(
val amountSats : Long, val amountSats : Long,
val memo : String, val memo : String,
val payee : String?, val payee : String?,
val routeHints : List<RouteHintHop> = emptyList() val routeHints : List<RouteHintHop> = 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( data class ScannedLnurl(
@@ -4,9 +4,11 @@ import android.net.Uri
import android.util.Log import android.util.Log
import com.bitcointxoko.gudariwallet.api.* import com.bitcointxoko.gudariwallet.api.*
import com.bitcointxoko.gudariwallet.security.SecretStore import com.bitcointxoko.gudariwallet.security.SecretStore
import com.bitcointxoko.gudariwallet.util.Bolt11Decoder
import com.bitcointxoko.gudariwallet.util.LnurlBech32 import com.bitcointxoko.gudariwallet.util.LnurlBech32
import com.bitcointxoko.gudariwallet.util.LnurlMetadataParser import com.bitcointxoko.gudariwallet.util.LnurlMetadataParser
import com.bitcointxoko.gudariwallet.util.WalletConstants import com.bitcointxoko.gudariwallet.util.WalletConstants
import java.time.Instant
private const val TAG = "LNbitsWalletRepo" private const val TAG = "LNbitsWalletRepo"
@@ -47,22 +49,51 @@ class LNbitsWalletRepository(
// ── Send ────────────────────────────────────────────────────────────────── // ── Send ──────────────────────────────────────────────────────────────────
override suspend fun decodeBolt11(bolt11: String): DecodedBolt11 { 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)) val response = api.decodeInvoice(secrets.invoiceKey(), DecodeInvoiceRequest(data = bolt11))
Log.i("WalletRepository", "decodeBolt11: decoded via network ✓")
val amountSats = response.amountMsat val amountSats = response.amountMsat
?.takeIf { it > 0L } ?.takeIf { it > 0L }
?.div(WalletConstants.MSAT_PER_SAT) ?.div(WalletConstants.MSAT_PER_SAT)
?: throw UnsupportedOperationException( ?: throw UnsupportedOperationException(
"Zero-amount invoices are not supported. Please ask the sender to specify an amount." "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( return DecodedBolt11(
amountSats = amountSats, amountSats = amountSats,
memo = response.description ?: "", memo = response.description ?: "",
payee = response.payee?.takeIf { it.isNotBlank() }, payee = response.payee?.takeIf { it.isNotBlank() },
routeHints = response.routeHints?.flatten() ?: emptyList() routeHints = response.routeHints?.flatten() ?: emptyList(),
paymentHash = paymentHash,
createdAt = createdAt,
expiresAt = expiresAt,
fallbackAddress = null
) )
} }
override suspend fun scanLnurl(code: String): ScannedLnurl { override suspend fun scanLnurl(code: String): ScannedLnurl {
val r = api.lnurlScan(secrets.invoiceKey(), code) val r = api.lnurlScan(secrets.invoiceKey(), code)
return ScannedLnurl( return ScannedLnurl(
@@ -6,6 +6,7 @@ import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.api.RouteHintHop import com.bitcointxoko.gudariwallet.api.RouteHintHop
import com.bitcointxoko.gudariwallet.util.RouteHintRisk import com.bitcointxoko.gudariwallet.util.RouteHintRisk
import com.bitcointxoko.gudariwallet.util.SendInputType import com.bitcointxoko.gudariwallet.util.SendInputType
import java.time.Instant
sealed class UiState<out T> { sealed class UiState<out T> {
data object Loading : UiState<Nothing>() data object Loading : UiState<Nothing>()
@@ -53,7 +54,10 @@ sealed class SendState {
override val nodeAlias : String? = null, override val nodeAlias : String? = null,
override val routeRisk : RouteHintRisk? = null, override val routeRisk : RouteHintRisk? = null,
override val allRouteHints : List<RouteHintHop> = emptyList(), override val allRouteHints : List<RouteHintHop> = emptyList(),
override val routeHintAliases : Map<String, String> = emptyMap() override val routeHintAliases : Map<String, String> = emptyMap(),
override val paymentHash : String,
override val createdAt : Instant,
override val expiresAt : Instant
) : SendState(), DecodedInvoice ) : SendState(), DecodedInvoice
data class LnurlReady( data class LnurlReady(
val callback : String, val callback : String,
@@ -78,7 +82,10 @@ sealed class SendState {
override val nodeAlias : String? = null, override val nodeAlias : String? = null,
override val routeRisk : RouteHintRisk? = null, override val routeRisk : RouteHintRisk? = null,
override val allRouteHints : List<RouteHintHop> = emptyList(), override val allRouteHints : List<RouteHintHop> = emptyList(),
override val routeHintAliases : Map<String, String> = emptyMap() override val routeHintAliases : Map<String, String> = emptyMap(),
override val paymentHash : String,
override val createdAt : Instant,
override val expiresAt : Instant
) : SendState(), DecodedInvoice ) : SendState(), DecodedInvoice
data object Paying : SendState() data object Paying : SendState()
data class PaymentSent( data class PaymentSent(
@@ -149,4 +156,7 @@ interface DecodedInvoice {
val routeRisk : RouteHintRisk? val routeRisk : RouteHintRisk?
val allRouteHints : List<RouteHintHop> val allRouteHints : List<RouteHintHop>
val routeHintAliases : Map<String, String> val routeHintAliases : Map<String, String>
val paymentHash : String
val createdAt : Instant
val expiresAt : Instant
} }
@@ -13,6 +13,11 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
@@ -26,6 +31,10 @@ import com.bitcointxoko.gudariwallet.ui.components.CopyIconButton
import com.bitcointxoko.gudariwallet.ui.components.DetailRow import com.bitcointxoko.gudariwallet.ui.components.DetailRow
import com.bitcointxoko.gudariwallet.util.fiatLabel import com.bitcointxoko.gudariwallet.util.fiatLabel
import com.bitcointxoko.gudariwallet.util.payingToLabel import com.bitcointxoko.gudariwallet.util.payingToLabel
import kotlinx.coroutines.delay
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Composable @Composable
internal fun Bolt11ConfirmCard( internal fun Bolt11ConfirmCard(
@@ -47,6 +56,7 @@ internal fun Bolt11ConfirmCard(
onCancel = { vm.resetSendState() } onCancel = { vm.resetSendState() }
) )
} }
@Composable @Composable
internal fun LnurlInvoiceConfirmCard( internal fun LnurlInvoiceConfirmCard(
state : SendState.LnurlInvoiceReady, state : SendState.LnurlInvoiceReady,
@@ -61,7 +71,6 @@ internal fun LnurlInvoiceConfirmCard(
invoice = state, invoice = state,
fiatLabel = fiatLabel(state.amountSats, fiatRate, fiatCurrency), fiatLabel = fiatLabel(state.amountSats, fiatRate, fiatCurrency),
onPay = { onPay = {
val label = payingToLabel(state.lnurl, state.domain)
vm.requestPayment(activity, "Send ${state.amountSats} sats to $label") { vm.requestPayment(activity, "Send ${state.amountSats} sats to $label") {
vm.payLnurlInvoice(state) vm.payLnurlInvoice(state)
} }
@@ -86,7 +95,50 @@ private fun ConfirmCardContent(
val routeRisk = invoice.routeRisk val routeRisk = invoice.routeRisk
val allRouteHints = invoice.allRouteHints val allRouteHints = invoice.allRouteHints
val routeHintAliases = invoice.routeHintAliases val routeHintAliases = invoice.routeHintAliases
val paymentHash = invoice.paymentHash
val createdAt = invoice.createdAt
val expiresAt = invoice.expiresAt
val context = LocalContext.current 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( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(2.dp) elevation = CardDefaults.cardElevation(2.dp)
@@ -107,7 +159,7 @@ private fun ConfirmCardContent(
} }
if (!payee.isNullOrBlank()) { if (!payee.isNullOrBlank()) {
val ambossUrl = "https://amboss.space/node/${payee}" val ambossUrl = "https://amboss.space/node/$payee"
val destinationLabel = nodeAlias val destinationLabel = nodeAlias
?: "${payee.take(8)}${payee.takeLast(8)}" ?: "${payee.take(8)}${payee.takeLast(8)}"
@@ -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)) Spacer(Modifier.height(4.dp))
DetailRow( DetailRow(
label = "Invoice", label = "Invoice",
@@ -150,6 +227,7 @@ private fun ConfirmCardContent(
Button( Button(
onClick = onPay, onClick = onPay,
enabled = !isExpired, // ── NEW: disable Pay if invoice has expired
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
PayButtonText(amountSats = invoice.amountSats, fiatLabel = fiatLabel) PayButtonText(amountSats = invoice.amountSats, fiatLabel = fiatLabel)
@@ -168,8 +246,6 @@ private fun PayButtonText(amountSats: Long, fiatLabel: String?) {
} }
@Composable @Composable
private fun PayCancelButton( private fun PayCancelButton(onCancel: () -> Unit) {
onCancel : () -> Unit,
) {
OutlinedButton(onClick = onCancel, modifier = Modifier.fillMaxWidth()) { Text("Cancel") } OutlinedButton(onClick = onCancel, modifier = Modifier.fillMaxWidth()) { Text("Cancel") }
} }
@@ -191,7 +191,10 @@ class SendViewModel(
nodeAlias = null, nodeAlias = null,
routeRisk = routeRisk, routeRisk = routeRisk,
allRouteHints = decoded.routeHints, allRouteHints = decoded.routeHints,
routeHintAliases = emptyMap() routeHintAliases = emptyMap(),
paymentHash = decoded.paymentHash,
createdAt = decoded.createdAt,
expiresAt = decoded.expiresAt
) )
resolveAliasesInBackground( resolveAliasesInBackground(
@@ -338,7 +341,10 @@ class SendViewModel(
nodeAlias = null, nodeAlias = null,
routeRisk = routeRisk, routeRisk = routeRisk,
allRouteHints = decoded.routeHints, allRouteHints = decoded.routeHints,
routeHintAliases = emptyMap() routeHintAliases = emptyMap(),
paymentHash = decoded.paymentHash,
createdAt = decoded.createdAt,
expiresAt = decoded.expiresAt
) )
resolveAliasesInBackground( resolveAliasesInBackground(
payee = decoded.payee, payee = decoded.payee,
@@ -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<Char, Long> = 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<List<RouteHintHop>>()
// 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<RouteHintHop>()
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<Int>, 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<Int>
): 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<Int>): List<Int> {
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<Int> =
hrp.map { it.code shr 5 } + listOf(0) + hrp.map { it.code and 31 }
private fun bech32Polymod(values: List<Int>): 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<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) {
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<Int>,
fromBits: Int,
toBits : Int,
pad : Boolean
): List<Int>? {
var acc = 0
var bits = 0
val out = mutableListOf<Int>()
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<Int>.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}"
}
}
+3
View File
@@ -26,6 +26,7 @@ protobuf = "4.26.1"
protobufPlugin = "0.10.0" protobufPlugin = "0.10.0"
tink = "1.13.0" tink = "1.13.0"
ui = "1.11.2" ui = "1.11.2"
secp256k1-kmp = "0.23.0"
[libraries] [libraries]
androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } 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" } protobuf-protoc = { group = "com.google.protobuf", name = "protoc", version.ref = "protobuf" }
tink-android = { group = "com.google.crypto.tink", name = "tink-android", version.ref = "tink" } tink-android = { group = "com.google.crypto.tink", name = "tink-android", version.ref = "tink" }
androidx-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "ui" } 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] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }