Skip to content

Commit deccb74

Browse files
authored
Merge pull request #250 from synonymdev/feat/lnurl-pay
LNURL Pay and Lightning Address
2 parents 148d342 + 8698acb commit deccb74

35 files changed

+807
-614
lines changed

.editorconfig

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ trim_trailing_whitespace = true
1111

1212
[*.{kt,kts}]
1313
ij_kotlin_allow_trailing_comma = true
14-
ij_kotlin_allow_trailing_comma_on_call_site = true
14+
# ij_kotlin_allow_trailing_comma_on_call_site = true
1515
ktlint_code_style = android_studio
1616
ktlint_experimental = enabled
1717
ktlint_function_naming_ignore_when_annotated_with = Composable

app/src/main/java/to/bitkit/ext/ChannelDetails.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,22 @@ val ChannelDetails.amountOnClose: ULong
1515
return outboundCapacitySat + reserveSats
1616
}
1717

18+
/** Returns only `open` channels, filtering out pending ones. */
19+
fun List<ChannelDetails>.filterOpen(): List<ChannelDetails> {
20+
return this.filter { it.isChannelReady }
21+
}
22+
23+
/** Returns only `pending` channels. */
24+
fun List<ChannelDetails>.filterPending(): List<ChannelDetails> {
25+
return this.filterNot { it.isChannelReady }
26+
}
27+
28+
/** Returns a limit in sats as close as possible to the HTLC limit we can currently send. */
29+
fun List<ChannelDetails>.totalNextOutboundHtlcLimitSats(): ULong {
30+
return this.filter { it.isUsable }
31+
.sumOf { it.nextOutboundHtlcLimitMsat / 1000uL }
32+
}
33+
1834
fun createChannelDetails(): ChannelDetails {
1935
return ChannelDetails(
2036
channelId = "channelId",
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package to.bitkit.ext
2+
3+
import com.synonym.bitkitcore.LnurlPayData
4+
import com.synonym.bitkitcore.LnurlWithdrawData
5+
6+
fun LnurlPayData.commentAllowed(): Boolean = commentAllowed?.let { it > 0u } == true
7+
fun LnurlPayData.maxSendableSat(): ULong = maxSendable / 1000u
8+
fun LnurlPayData.minSendableSat(): ULong = minSendable / 1000u
9+
10+
fun LnurlWithdrawData.maxWithdrawableSat(): ULong = maxWithdrawable / 1000u

app/src/main/java/to/bitkit/models/BalanceState.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,6 @@ import kotlinx.serialization.Serializable
66
data class BalanceState(
77
val totalOnchainSats: ULong = 0uL,
88
val totalLightningSats: ULong = 0uL,
9+
val maxSendLightningSats: ULong = 0uL, // TODO use where applicable
910
val totalSats: ULong = 0uL,
1011
)

app/src/main/java/to/bitkit/models/Currency.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ package to.bitkit.models
33
import kotlinx.datetime.Instant
44
import kotlinx.serialization.Serializable
55
import java.math.BigDecimal
6+
import java.math.RoundingMode
67
import java.text.DecimalFormat
78
import java.text.DecimalFormatSymbols
89
import java.util.Locale
910

1011
const val BITCOIN_SYMBOL = ""
1112
const val SATS_IN_BTC = 100_000_000
13+
const val BTC_SCALE = 8
1214
const val BTC_PLACEHOLDER = "0.00000000"
1315
const val SATS_PLACEHOLDER = "0"
1416

@@ -54,7 +56,7 @@ data class ConvertedAmount(
5456
val flag: String,
5557
val sats: Long,
5658
) {
57-
val btcValue: BigDecimal = BigDecimal(sats).divide(BigDecimal(SATS_IN_BTC))
59+
val btcValue: BigDecimal = sats.asBtc()
5860

5961
data class BitcoinDisplayComponents(
6062
val symbol: String,
@@ -96,3 +98,6 @@ fun Long.formatToModernDisplay(): String {
9698
}
9799

98100
fun ULong.formatToModernDisplay(): String = this.toLong().formatToModernDisplay()
101+
102+
/** Represent this sat value in Bitcoin BigDecimal. */
103+
fun Long.asBtc(): BigDecimal = BigDecimal(this).divide(BigDecimal(SATS_IN_BTC), BTC_SCALE, RoundingMode.HALF_UP)

app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,14 @@ import to.bitkit.data.CacheStore
2323
import to.bitkit.data.SettingsStore
2424
import to.bitkit.di.BgDispatcher
2525
import to.bitkit.env.Env
26+
import to.bitkit.models.BTC_SCALE
2627
import to.bitkit.models.BitcoinDisplayUnit
2728
import to.bitkit.models.ConvertedAmount
2829
import to.bitkit.models.FxRate
2930
import to.bitkit.models.PrimaryDisplay
3031
import to.bitkit.models.SATS_IN_BTC
3132
import to.bitkit.models.Toast
33+
import to.bitkit.models.asBtc
3234
import to.bitkit.services.CurrencyService
3335
import to.bitkit.ui.shared.toast.ToastEventBus
3436
import to.bitkit.ui.utils.formatCurrency
@@ -196,16 +198,16 @@ class CurrencyRepo @Inject constructor(
196198
)
197199
)
198200

199-
val btcAmount = BigDecimal(sats).divide(BigDecimal(SATS_IN_BTC), BTC_SCALE, RoundingMode.HALF_UP)
200-
val value = btcAmount.multiply(BigDecimal.valueOf(rate.rate))
201-
val formatted = value.formatCurrency() ?: return Result.failure(
201+
val btcAmount = sats.asBtc()
202+
val fiatValue = btcAmount.multiply(BigDecimal.valueOf(rate.rate))
203+
val formatted = fiatValue.formatCurrency() ?: return Result.failure(
202204
IllegalStateException(
203-
"Failed to format value: $value for currency: $targetCurrency"
205+
"Failed to format value: $fiatValue for currency: $targetCurrency"
204206
)
205207
)
206208

207209
ConvertedAmount(
208-
value = value,
210+
value = fiatValue,
209211
formatted = formatted,
210212
symbol = rate.currencySymbol,
211213
currency = rate.quote,
@@ -243,7 +245,6 @@ class CurrencyRepo @Inject constructor(
243245

244246
companion object {
245247
private const val TAG = "CurrencyRepo"
246-
private const val BTC_SCALE = 8
247248
}
248249
}
249250

app/src/main/java/to/bitkit/repositories/LightningRepo.kt

Lines changed: 19 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package to.bitkit.repositories
22

3-
import android.net.Uri
43
import com.google.firebase.messaging.FirebaseMessaging
4+
import com.synonym.bitkitcore.LightningInvoice
5+
import com.synonym.bitkitcore.Scanner
56
import com.synonym.bitkitcore.createWithdrawCallbackUrl
7+
import com.synonym.bitkitcore.decode
68
import com.synonym.bitkitcore.getLnurlInvoice
79
import kotlinx.coroutines.CoroutineDispatcher
810
import kotlinx.coroutines.delay
@@ -39,7 +41,7 @@ import to.bitkit.services.CoreService
3941
import to.bitkit.services.LdkNodeEventBus
4042
import to.bitkit.services.LightningService
4143
import to.bitkit.services.LnUrlWithdrawResponse
42-
import to.bitkit.services.LnUrlWithdrawService
44+
import to.bitkit.services.LnurlService
4345
import to.bitkit.services.NodeEventHandler
4446
import to.bitkit.utils.Logger
4547
import to.bitkit.utils.ServiceError
@@ -59,7 +61,7 @@ class LightningRepo @Inject constructor(
5961
private val blocktankNotificationsService: BlocktankNotificationsService,
6062
private val firebaseMessaging: FirebaseMessaging,
6163
private val keychain: Keychain,
62-
private val lnUrlWithdrawService: LnUrlWithdrawService,
64+
private val lnurlService: LnurlService,
6365
private val cacheStore: CacheStore,
6466
) {
6567
private val _lightningState = MutableStateFlow(LightningState())
@@ -401,12 +403,19 @@ class LightningRepo @Inject constructor(
401403
Result.success(invoice)
402404
}
403405

404-
suspend fun createLnurlInvoice(
405-
address: String,
406-
amountSatoshis: ULong,
407-
): Result<String> = executeWhenNodeRunning("getLnUrlInvoice") {
408-
val invoice = getLnurlInvoice(address, amountSatoshis)
409-
Result.success(invoice)
406+
suspend fun fetchLnurlInvoice(
407+
callbackUrl: String,
408+
amountSats: ULong,
409+
comment: String? = null,
410+
): Result<LightningInvoice> {
411+
return runCatching {
412+
// TODO use bitkit-core getLnurlInvoice if it works with callbackUrl
413+
val bolt11 = lnurlService.fetchLnurlInvoice(callbackUrl, amountSats, comment).pr
414+
val decoded = (decode(bolt11) as Scanner.Lightning).invoice
415+
return@runCatching decoded
416+
}.onFailure {
417+
Logger.error("Error fetching lnurl invoice, url: $callbackUrl, amount: $amountSats, comment: $comment", it)
418+
}
410419
}
411420

412421
suspend fun handleLnUrlWithdraw(
@@ -416,40 +425,7 @@ class LightningRepo @Inject constructor(
416425
): Result<LnUrlWithdrawResponse> = executeWhenNodeRunning("create LnUrl withdraw callback") {
417426
val callbackUrl = createWithdrawCallbackUrl(k1 = k1, callback = callback, paymentRequest = paymentRequest)
418427
Logger.debug("handleLnUrlWithdraw callbackUrl generated:$callbackUrl")
419-
val formattedCallbackUrl = callbackUrl.removeDuplicateQueryParams()
420-
Logger.debug("handleLnUrlWithdraw formatted callbackUrl:$formattedCallbackUrl")
421-
lnUrlWithdrawService.fetchWithdrawInfo(formattedCallbackUrl)
422-
}
423-
424-
/**
425-
* Extension function to remove duplicate query parameters from a URL string
426-
* Keeps the first occurrence of each parameter
427-
*/
428-
private fun String.removeDuplicateQueryParams(): String { // TODO REMOVE AFTER CORE FIX
429-
return try {
430-
val uri = Uri.parse(this)
431-
val builder = uri.buildUpon().clearQuery()
432-
433-
// Track seen parameters to avoid duplicates
434-
val seenParams = mutableSetOf<String>()
435-
436-
// Get all query parameter names
437-
uri.queryParameterNames.forEach { paramName ->
438-
if (!seenParams.contains(paramName)) {
439-
// Add only the first occurrence of each parameter
440-
val value = uri.getQueryParameter(paramName)
441-
if (value != null) {
442-
builder.appendQueryParameter(paramName, value)
443-
seenParams.add(paramName)
444-
}
445-
}
446-
}
447-
448-
builder.build().toString()
449-
} catch (e: Exception) {
450-
// Return original string if parsing fails
451-
this
452-
}
428+
lnurlService.fetchWithdrawInfo(callbackUrl)
453429
}
454430

455431
suspend fun payInvoice(bolt11: String, sats: ULong? = null): Result<PaymentId> =

app/src/main/java/to/bitkit/repositories/WalletRepo.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import to.bitkit.data.keychain.Keychain
2525
import to.bitkit.di.BgDispatcher
2626
import to.bitkit.env.Env
2727
import to.bitkit.ext.toHex
28+
import to.bitkit.ext.totalNextOutboundHtlcLimitSats
2829
import to.bitkit.models.AddressModel
2930
import to.bitkit.models.BalanceState
3031
import to.bitkit.models.NodeLifecycleState
@@ -162,6 +163,7 @@ class WalletRepo @Inject constructor(
162163
val newBalance = BalanceState(
163164
totalOnchainSats = balance.totalOnchainBalanceSats,
164165
totalLightningSats = balance.totalLightningBalanceSats,
166+
maxSendLightningSats = lightningRepo.getChannels()?.totalNextOutboundHtlcLimitSats() ?: 0u,
165167
totalSats = totalSats,
166168
)
167169
_balanceState.update { newBalance }

app/src/main/java/to/bitkit/services/CoreService.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -341,7 +341,7 @@ class ActivityService(
341341
value = payment.amountSats ?: 0u,
342342
fee = (payment.feePaidMsat ?: 0u) / 1000u,
343343
invoice = "lnbc123_todo", // TODO
344-
message = "",
344+
message = kind.description.orEmpty(),
345345
timestamp = payment.latestUpdateTimestamp,
346346
preimage = kind.preimage,
347347
createdAt = payment.latestUpdateTimestamp,

app/src/main/java/to/bitkit/services/LightningService.kt

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import to.bitkit.data.keychain.Keychain
4141
import to.bitkit.di.BgDispatcher
4242
import to.bitkit.env.Env
4343
import to.bitkit.ext.DatePattern
44+
import to.bitkit.ext.totalNextOutboundHtlcLimitSats
4445
import to.bitkit.ext.uByteList
4546
import to.bitkit.models.ElectrumServer
4647
import to.bitkit.models.LnPeer
@@ -390,9 +391,7 @@ class LightningService @Inject constructor(
390391
return false
391392
}
392393

393-
val totalNextOutboundHtlcLimitSats = channels
394-
.filter { it.isUsable }
395-
.sumOf { it.nextOutboundHtlcLimitMsat / 1000uL }
394+
val totalNextOutboundHtlcLimitSats = channels.totalNextOutboundHtlcLimitSats()
396395

397396
if (totalNextOutboundHtlcLimitSats < amountSats) {
398397
Logger.warn("Insufficient outbound capacity: $totalNextOutboundHtlcLimitSats < $amountSats")
@@ -669,16 +668,6 @@ class LightningService @Inject constructor(
669668

670669
// region helpers
671670

672-
/** Returns only `open` channels, filtering out pending ones. */
673-
fun List<ChannelDetails>.filterOpen(): List<ChannelDetails> {
674-
return this.filter { it.isChannelReady }
675-
}
676-
677-
/** Returns only `pending` channels. */
678-
fun List<ChannelDetails>.filterPending(): List<ChannelDetails> {
679-
return this.filterNot { it.isChannelReady }
680-
}
681-
682671
private fun generateLogFilePath(): String {
683672
val dateFormatter = SimpleDateFormat(DatePattern.LOG_FILE, Locale.US).apply {
684673
timeZone = TimeZone.getTimeZone("UTC")

0 commit comments

Comments
 (0)