Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
8419738
fix: pass lnurl address to createLnurlInvoice
ovitrif Jul 21, 2025
916e31b
fix: number pad action buttons style and text
ovitrif Jul 21, 2025
cc3e70b
chore: update bitkit-core and remove withdraw url manual fix
ovitrif Jul 21, 2025
85c982f
refactor: unify sat to btc conversion
ovitrif Jul 22, 2025
454218d
feat: support override in NumberPadTextField
ovitrif Jul 22, 2025
bee5a9a
fix: keyboard clickable area
ovitrif Jul 22, 2025
f4a24e9
feat: keyboard key press ui and haptics
ovitrif Jul 22, 2025
785da16
refactor: rename to LnurlService
ovitrif Jul 22, 2025
32d5ed9
feat: fetchLnurlInvoice method
ovitrif Jul 22, 2025
2343d69
feat: ext for totalNextOutboundHtlcLimitSats
ovitrif Jul 22, 2025
61439aa
feat: balance maxSendLightningSats
ovitrif Jul 22, 2025
5737ac9
feat: minLines for TextInput
ovitrif Jul 22, 2025
ec75de9
feat: lnurlPay bech32 support via refactor
ovitrif Jul 22, 2025
f986a03
feat: lnurlPay amount screen
ovitrif Jul 22, 2025
9c51280
feat: lnurlPay confirm screen
ovitrif Jul 22, 2025
90e9867
feat: gradient bg and safe areas on sendAndReview
ovitrif Jul 22, 2025
15363e7
feat: QuickPay for LNURL Pay
ovitrif Jul 22, 2025
f83659e
chore: remove unused Scanner.LnurlAddress handling
ovitrif Jul 22, 2025
bf01fbf
Merge branch 'master' into feat/lnurl-pay
ovitrif Jul 22, 2025
4042fa2
feat: warn on channel open error
ovitrif Jul 22, 2025
39d8f01
feat: update ldk-node and impl bolt11 payment description
ovitrif Jul 22, 2025
daf4c0e
fix: screen ui when keyboard is open
ovitrif Jul 22, 2025
d94c688
fix: only clear clipboard after send if autoRead is on
ovitrif Jul 22, 2025
96bd52d
fix: lnurl pay fixed amount
ovitrif Jul 22, 2025
53a67f1
chore: unify logs with bolt11 send
ovitrif Jul 22, 2025
4a9f5cd
fix: set amount to minSendable
ovitrif Jul 22, 2025
f272332
fix: send bolt11 quickpay
ovitrif Jul 22, 2025
1330bf3
Merge branch 'refs/heads/master' into feat/lnurl-pay
ovitrif Jul 22, 2025
9b17ef8
chore: disable IDE reformat always adding trailing comma at call site
ovitrif Jul 22, 2025
0dacdbe
chore: cleanup
ovitrif Jul 22, 2025
8698acb
chore: extract LnurlCommentSection
ovitrif Jul 22, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ trim_trailing_whitespace = true

[*.{kt,kts}]
ij_kotlin_allow_trailing_comma = true
ij_kotlin_allow_trailing_comma_on_call_site = true
# ij_kotlin_allow_trailing_comma_on_call_site = true
ktlint_code_style = android_studio
ktlint_experimental = enabled
ktlint_function_naming_ignore_when_annotated_with = Composable
Expand Down
16 changes: 16 additions & 0 deletions app/src/main/java/to/bitkit/ext/ChannelDetails.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,22 @@ val ChannelDetails.amountOnClose: ULong
return outboundCapacitySat + reserveSats
}

/** Returns only `open` channels, filtering out pending ones. */
fun List<ChannelDetails>.filterOpen(): List<ChannelDetails> {
return this.filter { it.isChannelReady }
}

/** Returns only `pending` channels. */
fun List<ChannelDetails>.filterPending(): List<ChannelDetails> {
return this.filterNot { it.isChannelReady }
}

/** Returns a limit in sats as close as possible to the HTLC limit we can currently send. */
fun List<ChannelDetails>.totalNextOutboundHtlcLimitSats(): ULong {
return this.filter { it.isUsable }
.sumOf { it.nextOutboundHtlcLimitMsat / 1000uL }
}

fun createChannelDetails(): ChannelDetails {
return ChannelDetails(
channelId = "channelId",
Expand Down
10 changes: 10 additions & 0 deletions app/src/main/java/to/bitkit/ext/Lnurl.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package to.bitkit.ext

import com.synonym.bitkitcore.LnurlPayData
import com.synonym.bitkitcore.LnurlWithdrawData

fun LnurlPayData.commentAllowed(): Boolean = commentAllowed?.let { it > 0u } == true
fun LnurlPayData.maxSendableSat(): ULong = maxSendable / 1000u
fun LnurlPayData.minSendableSat(): ULong = minSendable / 1000u

fun LnurlWithdrawData.maxWithdrawableSat(): ULong = maxWithdrawable / 1000u
1 change: 1 addition & 0 deletions app/src/main/java/to/bitkit/models/BalanceState.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ import kotlinx.serialization.Serializable
data class BalanceState(
val totalOnchainSats: ULong = 0uL,
val totalLightningSats: ULong = 0uL,
val maxSendLightningSats: ULong = 0uL, // TODO use where applicable
val totalSats: ULong = 0uL,
)
7 changes: 6 additions & 1 deletion app/src/main/java/to/bitkit/models/Currency.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ package to.bitkit.models
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
import java.math.BigDecimal
import java.math.RoundingMode
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
import java.util.Locale

const val BITCOIN_SYMBOL = "₿"
const val SATS_IN_BTC = 100_000_000
const val BTC_SCALE = 8
const val BTC_PLACEHOLDER = "0.00000000"
const val SATS_PLACEHOLDER = "0"

Expand Down Expand Up @@ -54,7 +56,7 @@ data class ConvertedAmount(
val flag: String,
val sats: Long,
) {
val btcValue: BigDecimal = BigDecimal(sats).divide(BigDecimal(SATS_IN_BTC))
val btcValue: BigDecimal = sats.asBtc()

data class BitcoinDisplayComponents(
val symbol: String,
Expand Down Expand Up @@ -96,3 +98,6 @@ fun Long.formatToModernDisplay(): String {
}

fun ULong.formatToModernDisplay(): String = this.toLong().formatToModernDisplay()

/** Represent this sat value in Bitcoin BigDecimal. */
fun Long.asBtc(): BigDecimal = BigDecimal(this).divide(BigDecimal(SATS_IN_BTC), BTC_SCALE, RoundingMode.HALF_UP)
13 changes: 7 additions & 6 deletions app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,14 @@ import to.bitkit.data.CacheStore
import to.bitkit.data.SettingsStore
import to.bitkit.di.BgDispatcher
import to.bitkit.env.Env
import to.bitkit.models.BTC_SCALE
import to.bitkit.models.BitcoinDisplayUnit
import to.bitkit.models.ConvertedAmount
import to.bitkit.models.FxRate
import to.bitkit.models.PrimaryDisplay
import to.bitkit.models.SATS_IN_BTC
import to.bitkit.models.Toast
import to.bitkit.models.asBtc
import to.bitkit.services.CurrencyService
import to.bitkit.ui.shared.toast.ToastEventBus
import to.bitkit.ui.utils.formatCurrency
Expand Down Expand Up @@ -196,16 +198,16 @@ class CurrencyRepo @Inject constructor(
)
)

val btcAmount = BigDecimal(sats).divide(BigDecimal(SATS_IN_BTC), BTC_SCALE, RoundingMode.HALF_UP)
val value = btcAmount.multiply(BigDecimal.valueOf(rate.rate))
val formatted = value.formatCurrency() ?: return Result.failure(
val btcAmount = sats.asBtc()
val fiatValue = btcAmount.multiply(BigDecimal.valueOf(rate.rate))
val formatted = fiatValue.formatCurrency() ?: return Result.failure(
IllegalStateException(
"Failed to format value: $value for currency: $targetCurrency"
"Failed to format value: $fiatValue for currency: $targetCurrency"
)
)

ConvertedAmount(
value = value,
value = fiatValue,
formatted = formatted,
symbol = rate.currencySymbol,
currency = rate.quote,
Expand Down Expand Up @@ -243,7 +245,6 @@ class CurrencyRepo @Inject constructor(

companion object {
private const val TAG = "CurrencyRepo"
private const val BTC_SCALE = 8
}
}

Expand Down
62 changes: 19 additions & 43 deletions app/src/main/java/to/bitkit/repositories/LightningRepo.kt
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package to.bitkit.repositories

import android.net.Uri
import com.google.firebase.messaging.FirebaseMessaging
import com.synonym.bitkitcore.LightningInvoice
import com.synonym.bitkitcore.Scanner
import com.synonym.bitkitcore.createWithdrawCallbackUrl
import com.synonym.bitkitcore.decode
import com.synonym.bitkitcore.getLnurlInvoice
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.delay
Expand Down Expand Up @@ -39,7 +41,7 @@ import to.bitkit.services.CoreService
import to.bitkit.services.LdkNodeEventBus
import to.bitkit.services.LightningService
import to.bitkit.services.LnUrlWithdrawResponse
import to.bitkit.services.LnUrlWithdrawService
import to.bitkit.services.LnurlService
import to.bitkit.services.NodeEventHandler
import to.bitkit.utils.Logger
import to.bitkit.utils.ServiceError
Expand All @@ -59,7 +61,7 @@ class LightningRepo @Inject constructor(
private val blocktankNotificationsService: BlocktankNotificationsService,
private val firebaseMessaging: FirebaseMessaging,
private val keychain: Keychain,
private val lnUrlWithdrawService: LnUrlWithdrawService,
private val lnurlService: LnurlService,
private val cacheStore: CacheStore,
) {
private val _lightningState = MutableStateFlow(LightningState())
Expand Down Expand Up @@ -401,12 +403,19 @@ class LightningRepo @Inject constructor(
Result.success(invoice)
}

suspend fun createLnurlInvoice(
address: String,
amountSatoshis: ULong,
): Result<String> = executeWhenNodeRunning("getLnUrlInvoice") {
val invoice = getLnurlInvoice(address, amountSatoshis)
Result.success(invoice)
suspend fun fetchLnurlInvoice(
callbackUrl: String,
amountSats: ULong,
comment: String? = null,
): Result<LightningInvoice> {
return runCatching {
// TODO use bitkit-core getLnurlInvoice if it works with callbackUrl
val bolt11 = lnurlService.fetchLnurlInvoice(callbackUrl, amountSats, comment).pr
val decoded = (decode(bolt11) as Scanner.Lightning).invoice
return@runCatching decoded
}.onFailure {
Logger.error("Error fetching lnurl invoice, url: $callbackUrl, amount: $amountSats, comment: $comment", it)
}
}

suspend fun handleLnUrlWithdraw(
Expand All @@ -416,40 +425,7 @@ class LightningRepo @Inject constructor(
): Result<LnUrlWithdrawResponse> = executeWhenNodeRunning("create LnUrl withdraw callback") {
val callbackUrl = createWithdrawCallbackUrl(k1 = k1, callback = callback, paymentRequest = paymentRequest)
Logger.debug("handleLnUrlWithdraw callbackUrl generated:$callbackUrl")
val formattedCallbackUrl = callbackUrl.removeDuplicateQueryParams()
Logger.debug("handleLnUrlWithdraw formatted callbackUrl:$formattedCallbackUrl")
lnUrlWithdrawService.fetchWithdrawInfo(formattedCallbackUrl)
}

/**
* Extension function to remove duplicate query parameters from a URL string
* Keeps the first occurrence of each parameter
*/
private fun String.removeDuplicateQueryParams(): String { // TODO REMOVE AFTER CORE FIX
return try {
val uri = Uri.parse(this)
val builder = uri.buildUpon().clearQuery()

// Track seen parameters to avoid duplicates
val seenParams = mutableSetOf<String>()

// Get all query parameter names
uri.queryParameterNames.forEach { paramName ->
if (!seenParams.contains(paramName)) {
// Add only the first occurrence of each parameter
val value = uri.getQueryParameter(paramName)
if (value != null) {
builder.appendQueryParameter(paramName, value)
seenParams.add(paramName)
}
}
}

builder.build().toString()
} catch (e: Exception) {
// Return original string if parsing fails
this
}
lnurlService.fetchWithdrawInfo(callbackUrl)
}

suspend fun payInvoice(bolt11: String, sats: ULong? = null): Result<PaymentId> =
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/to/bitkit/repositories/WalletRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import to.bitkit.data.keychain.Keychain
import to.bitkit.di.BgDispatcher
import to.bitkit.env.Env
import to.bitkit.ext.toHex
import to.bitkit.ext.totalNextOutboundHtlcLimitSats
import to.bitkit.models.AddressModel
import to.bitkit.models.BalanceState
import to.bitkit.models.NodeLifecycleState
Expand Down Expand Up @@ -162,6 +163,7 @@ class WalletRepo @Inject constructor(
val newBalance = BalanceState(
totalOnchainSats = balance.totalOnchainBalanceSats,
totalLightningSats = balance.totalLightningBalanceSats,
maxSendLightningSats = lightningRepo.getChannels()?.totalNextOutboundHtlcLimitSats() ?: 0u,
totalSats = totalSats,
)
_balanceState.update { newBalance }
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/java/to/bitkit/services/CoreService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,7 @@ class ActivityService(
value = payment.amountSats ?: 0u,
fee = (payment.feePaidMsat ?: 0u) / 1000u,
invoice = "lnbc123_todo", // TODO
message = "",
message = kind.description.orEmpty(),
timestamp = payment.latestUpdateTimestamp,
preimage = kind.preimage,
createdAt = payment.latestUpdateTimestamp,
Expand Down
15 changes: 2 additions & 13 deletions app/src/main/java/to/bitkit/services/LightningService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import to.bitkit.data.keychain.Keychain
import to.bitkit.di.BgDispatcher
import to.bitkit.env.Env
import to.bitkit.ext.DatePattern
import to.bitkit.ext.totalNextOutboundHtlcLimitSats
import to.bitkit.ext.uByteList
import to.bitkit.models.ElectrumServer
import to.bitkit.models.LnPeer
Expand Down Expand Up @@ -390,9 +391,7 @@ class LightningService @Inject constructor(
return false
}

val totalNextOutboundHtlcLimitSats = channels
.filter { it.isUsable }
.sumOf { it.nextOutboundHtlcLimitMsat / 1000uL }
val totalNextOutboundHtlcLimitSats = channels.totalNextOutboundHtlcLimitSats()

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

// region helpers

/** Returns only `open` channels, filtering out pending ones. */
fun List<ChannelDetails>.filterOpen(): List<ChannelDetails> {
return this.filter { it.isChannelReady }
}

/** Returns only `pending` channels. */
fun List<ChannelDetails>.filterPending(): List<ChannelDetails> {
return this.filterNot { it.isChannelReady }
}

private fun generateLogFilePath(): String {
val dateFormatter = SimpleDateFormat(DatePattern.LOG_FILE, Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,13 @@ import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class LnUrlWithdrawService @Inject constructor(
class LnurlService @Inject constructor(
private val client: HttpClient,
) {

suspend fun fetchWithdrawInfo(lnUrlCallBack: String): Result<LnUrlWithdrawResponse> = runCatching {
val response: HttpResponse = client.get(lnUrlCallBack)
suspend fun fetchWithdrawInfo(callbackUrl: String): Result<LnUrlWithdrawResponse> = runCatching {
val response: HttpResponse = client.get(callbackUrl)
Logger.debug("Http call: $response")

if (!response.status.isSuccess()) {
throw Exception("HTTP error: ${response.status}")
Expand All @@ -34,9 +35,28 @@ class LnUrlWithdrawService @Inject constructor(
Logger.warn(e = it, msg = "Failed to fetch withdraw info", context = TAG)
}

suspend fun fetchLnurlInvoice(
callbackUrl: String,
amountSats: ULong,
comment: String? = null,
): LnurlPayResponse {
val response = client.get(callbackUrl) {
url {
parameters.append("amount", "${amountSats * 1000u}") // convert to msat
comment?.takeIf { it.isNotBlank() }?.let { parameters.append("comment", it) }
}
}
Logger.debug("Http call: $response")

if (!response.status.isSuccess()) {
throw Exception("HTTP error: ${response.status}")
}

return response.body<LnurlPayResponse>()
}

companion object {
private const val TAG = "LnUrlWithdrawService"
private const val TAG = "LnurlService"
}
}

Expand All @@ -52,3 +72,9 @@ data class LnUrlWithdrawResponse(
val maxWithdrawable: Long? = null,
val balanceCheck: String? = null
)

@Serializable
data class LnurlPayResponse(
val pr: String,
val routes: List<String>,
)
3 changes: 2 additions & 1 deletion app/src/main/java/to/bitkit/ui/ContentView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -335,8 +335,9 @@ fun ContentView(
walletViewModel = walletViewModel,
startDestination = sheet.route,
onComplete = { txSheet ->
appViewModel.setSendEvent(SendEvent.Reset)
appViewModel.resetSendState()
appViewModel.hideSheet()
appViewModel.clearClipboardForAutoRead()
txSheet?.let { appViewModel.showNewTransactionSheet(it) }
}
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import to.bitkit.R
import to.bitkit.models.BITCOIN_SYMBOL
import to.bitkit.models.ConvertedAmount
import to.bitkit.models.PrimaryDisplay
import to.bitkit.models.formatToModernDisplay
import to.bitkit.ui.LocalCurrencies
import to.bitkit.ui.currencyViewModel
import to.bitkit.ui.settingsViewModel
Expand Down Expand Up @@ -50,7 +51,7 @@ fun BalanceHeaderView(
smallRowSymbol = "$",
smallRowText = "12.34",
largeRowPrefix = prefix,
largeRowText = "$sats",
largeRowText = sats.formatToModernDisplay(),
largeRowSymbol = BITCOIN_SYMBOL,
showSymbol = showBitcoinSymbol,
hideBalance = false,
Expand Down
Loading
Loading