feat: parse bolt11 locally with network fallback
This commit is contained in:
@@ -122,6 +122,11 @@ dependencies {
|
||||
|
||||
// animations
|
||||
implementation(libs.androidx.compose.animation)
|
||||
|
||||
// secp256k1
|
||||
dependencies {
|
||||
implementation(libs.secp256k1.kmp.android)
|
||||
}
|
||||
}
|
||||
|
||||
protobuf {
|
||||
|
||||
@@ -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<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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<out T> {
|
||||
data object Loading : UiState<Nothing>()
|
||||
@@ -53,7 +54,10 @@ sealed class SendState {
|
||||
override val nodeAlias : String? = null,
|
||||
override val routeRisk : RouteHintRisk? = null,
|
||||
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
|
||||
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<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
|
||||
data object Paying : SendState()
|
||||
data class PaymentSent(
|
||||
@@ -149,4 +156,7 @@ interface DecodedInvoice {
|
||||
val routeRisk : RouteHintRisk?
|
||||
val allRouteHints : List<RouteHintHop>
|
||||
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.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") }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user