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
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}"
}
}