feat: parse bolt11 locally with network fallback
This commit is contained in:
@@ -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,27 +31,32 @@ 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(
|
||||||
state : SendState.Bolt11Decoded,
|
state : SendState.Bolt11Decoded,
|
||||||
activity : FragmentActivity,
|
activity : FragmentActivity,
|
||||||
vm : WalletViewModel,
|
vm : WalletViewModel,
|
||||||
fiatRate : Double?,
|
fiatRate : Double?,
|
||||||
fiatCurrency: String?
|
fiatCurrency: String?
|
||||||
) {
|
) {
|
||||||
ConfirmCardContent(
|
ConfirmCardContent(
|
||||||
title = "Invoice Details",
|
title = "Invoice Details",
|
||||||
invoice = state,
|
invoice = state,
|
||||||
fiatLabel = fiatLabel(state.amountSats, fiatRate, fiatCurrency),
|
fiatLabel = fiatLabel(state.amountSats, fiatRate, fiatCurrency),
|
||||||
onPay = {
|
onPay = {
|
||||||
vm.requestPayment(activity, "Send ${state.amountSats} sats") {
|
vm.requestPayment(activity, "Send ${state.amountSats} sats") {
|
||||||
vm.payBolt11(state.bolt11)
|
vm.payBolt11(state.bolt11)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onCancel = { vm.resetSendState() }
|
onCancel = { vm.resetSendState() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun LnurlInvoiceConfirmCard(
|
internal fun LnurlInvoiceConfirmCard(
|
||||||
state : SendState.LnurlInvoiceReady,
|
state : SendState.LnurlInvoiceReady,
|
||||||
@@ -57,16 +67,15 @@ internal fun LnurlInvoiceConfirmCard(
|
|||||||
) {
|
) {
|
||||||
val label = payingToLabel(state.lnurl, state.domain)
|
val label = payingToLabel(state.lnurl, state.domain)
|
||||||
ConfirmCardContent(
|
ConfirmCardContent(
|
||||||
title = "Paying to $label",
|
title = "Paying to $label",
|
||||||
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)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onCancel = { vm.resetSendState() }
|
onCancel = { vm.resetSendState() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 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(
|
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)}"
|
||||||
|
|
||||||
@@ -117,7 +169,7 @@ private fun ConfirmCardContent(
|
|||||||
value = destinationLabel,
|
value = destinationLabel,
|
||||||
valueColor = MaterialTheme.colorScheme.primary,
|
valueColor = MaterialTheme.colorScheme.primary,
|
||||||
textDecoration = TextDecoration.Underline,
|
textDecoration = TextDecoration.Underline,
|
||||||
onValueClick = {
|
onValueClick = {
|
||||||
context.startActivity(Intent(Intent.ACTION_VIEW, ambossUrl.toUri()))
|
context.startActivity(Intent(Intent.ACTION_VIEW, ambossUrl.toUri()))
|
||||||
},
|
},
|
||||||
trailingContent = {
|
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))
|
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}"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
|
|||||||
Reference in New Issue
Block a user