From a9ae18622fd96af87e7e88aacf8253bfa1f22f08 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sun, 5 Apr 2026 22:56:01 +0200 Subject: [PATCH 1/6] refactor: add MSat value class Co-Authored-By: Claude Opus 4.6 (1M context) --- app/src/main/java/to/bitkit/models/MSat.kt | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 app/src/main/java/to/bitkit/models/MSat.kt diff --git a/app/src/main/java/to/bitkit/models/MSat.kt b/app/src/main/java/to/bitkit/models/MSat.kt new file mode 100644 index 000000000..43f329477 --- /dev/null +++ b/app/src/main/java/to/bitkit/models/MSat.kt @@ -0,0 +1,19 @@ +package to.bitkit.models + +/** + * A wrapper for millisatoshi [ULong] values that provides explicit sat conversions. + * + * Eliminates scattered `/ 1000u` and `(x + 999u) / 1000u` patterns by encoding + * the rounding intent directly in the API: [ceil] rounds up, [floor] truncates. + * + * Zero runtime overhead — this is an [inline value class][JvmInline]. + */ +@JvmInline +value class MSat(val value: ULong) { + + /** Round up to the nearest whole sat. Use for payment/display amounts. */ + fun ceil(): ULong = (value + 999u) / 1000u + + /** Truncate sub-sat remainder. Use for fees and upper bounds. */ + fun floor(): ULong = value / 1000u +} From 15889b81f8669f60a92399d1758c3e971914195f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sun, 5 Apr 2026 22:59:29 +0200 Subject: [PATCH 2/6] refactor: use MSat for conversions --- .../commands/NotifyPaymentReceivedHandler.kt | 3 +- .../main/java/to/bitkit/ext/ChannelDetails.kt | 7 +++-- app/src/main/java/to/bitkit/ext/Lnurl.kt | 28 ++++--------------- .../main/java/to/bitkit/ext/PaymentDetails.kt | 3 +- .../main/java/to/bitkit/fcm/WakeNodeWorker.kt | 3 +- .../java/to/bitkit/repositories/WalletRepo.kt | 3 +- .../java/to/bitkit/services/CoreService.kt | 7 +++-- .../to/bitkit/services/LightningService.kt | 11 ++++---- .../main/java/to/bitkit/ui/NodeInfoScreen.kt | 15 +++++----- .../settings/lightning/ChannelDetailScreen.kt | 5 ++-- .../lightning/LightningConnectionsScreen.kt | 3 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 3 +- 12 files changed, 43 insertions(+), 48 deletions(-) diff --git a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt index 11ac76f45..1b213ba00 100644 --- a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt +++ b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt @@ -18,6 +18,7 @@ import to.bitkit.models.NotificationDetails import to.bitkit.models.PrimaryDisplay import to.bitkit.models.formatToModernDisplay import to.bitkit.repositories.ActivityRepo +import to.bitkit.models.MSat import to.bitkit.repositories.CurrencyRepo import to.bitkit.utils.Logger import javax.inject.Inject @@ -97,7 +98,7 @@ class NotifyPaymentReceivedHandler @Inject constructor( is NotifyPaymentReceived.Command.Onchain -> command.event.txid }, sats = when (command) { - is NotifyPaymentReceived.Command.Lightning -> ((command.event.amountMsat + 999u) / 1000u).toLong() + is NotifyPaymentReceived.Command.Lightning -> MSat(command.event.amountMsat).ceil().toLong() is NotifyPaymentReceived.Command.Onchain -> command.event.details.amountSats }, ) diff --git a/app/src/main/java/to/bitkit/ext/ChannelDetails.kt b/app/src/main/java/to/bitkit/ext/ChannelDetails.kt index 4e1898d85..fcb71792d 100644 --- a/app/src/main/java/to/bitkit/ext/ChannelDetails.kt +++ b/app/src/main/java/to/bitkit/ext/ChannelDetails.kt @@ -3,6 +3,7 @@ package to.bitkit.ext import org.lightningdevkit.ldknode.ChannelConfig import org.lightningdevkit.ldknode.ChannelDetails import org.lightningdevkit.ldknode.MaxDustHtlcExposure +import to.bitkit.models.MSat /** * Calculates our total balance in the channel (see `value_to_self_msat` in rust-lightning). @@ -18,7 +19,7 @@ val ChannelDetails.amountOnClose: ULong @Suppress("ForbiddenComment") get() { // TODO: use channelDetails.claimableOnCloseSats - val outboundCapacitySat = this.outboundCapacityMsat / 1000u + val outboundCapacitySat = MSat(this.outboundCapacityMsat).floor() val ourReserve = this.unspendablePunishmentReserve ?: 0u return outboundCapacitySat + ourReserve @@ -32,13 +33,13 @@ fun List.filterPending(): List = this.filterNot /** Returns a limit in sats as close as possible to the HTLC limit we can currently send. */ fun List?.totalNextOutboundHtlcLimitSats(): ULong = this?.filter { it.isUsable } - ?.sumOf { it.nextOutboundHtlcLimitMsat / 1000u } + ?.sumOf { MSat(it.nextOutboundHtlcLimitMsat).floor() } ?: 0u /** Calculates the total remote balance (inbound capacity) from open channels. */ fun List.calculateRemoteBalance(): ULong = this .filterOpen() - .sumOf { it.inboundCapacityMsat / 1000u } + .sumOf { MSat(it.inboundCapacityMsat).floor() } fun createChannelDetails(): ChannelDetails = ChannelDetails( channelId = "channelId", diff --git a/app/src/main/java/to/bitkit/ext/Lnurl.kt b/app/src/main/java/to/bitkit/ext/Lnurl.kt index 13ce11d39..1f9a48550 100644 --- a/app/src/main/java/to/bitkit/ext/Lnurl.kt +++ b/app/src/main/java/to/bitkit/ext/Lnurl.kt @@ -2,27 +2,11 @@ package to.bitkit.ext import com.synonym.bitkitcore.LnurlPayData import com.synonym.bitkitcore.LnurlWithdrawData - -private const val MSATS_PER_SAT: ULong = 1000u - -/** - * LNURL amounts are expressed in millisatoshis (msat). - * - * When converting a minimum bound to whole sats we must round up: - * `minSendable = 100500 msat` means the minimum payable amount is `101 sat` (not `100 sat`). - */ -private fun msatsToSatsCeil(msats: ULong): ULong { - val quotient = msats / MSATS_PER_SAT - val remainder = msats % MSATS_PER_SAT - return when (remainder) { - 0uL -> quotient - else -> quotient + 1uL - } -} +import to.bitkit.models.MSat fun LnurlPayData.commentAllowed(): Boolean = commentAllowed?.let { it > 0u } == true -fun LnurlPayData.maxSendableSat(): ULong = maxSendable / MSATS_PER_SAT -fun LnurlPayData.minSendableSat(): ULong = msatsToSatsCeil(minSendable) +fun LnurlPayData.maxSendableSat(): ULong = MSat(maxSendable).floor() +fun LnurlPayData.minSendableSat(): ULong = MSat(minSendable).ceil() /** * True when the LNURL-pay endpoint specifies a single exact amount. @@ -44,10 +28,10 @@ fun LnurlPayData.isFixedAmount(): Boolean = * For variable-amount requests the user-selected sat amount is converted to msats. */ fun LnurlPayData.callbackAmountMsats(userSats: ULong? = null): ULong = - if (isFixedAmount()) minSendable else (userSats ?: minSendableSat()) * MSATS_PER_SAT + if (isFixedAmount()) minSendable else (userSats ?: minSendableSat()) * 1000u -fun LnurlWithdrawData.minWithdrawableSat(): ULong = msatsToSatsCeil(minWithdrawable ?: 0u) -fun LnurlWithdrawData.maxWithdrawableSat(): ULong = maxWithdrawable / MSATS_PER_SAT +fun LnurlWithdrawData.minWithdrawableSat(): ULong = MSat(minWithdrawable ?: 0u).ceil() +fun LnurlWithdrawData.maxWithdrawableSat(): ULong = MSat(maxWithdrawable).floor() /** * True when the LNURL-withdraw endpoint specifies a single exact amount, diff --git a/app/src/main/java/to/bitkit/ext/PaymentDetails.kt b/app/src/main/java/to/bitkit/ext/PaymentDetails.kt index cf93d71d0..2f8dfc164 100644 --- a/app/src/main/java/to/bitkit/ext/PaymentDetails.kt +++ b/app/src/main/java/to/bitkit/ext/PaymentDetails.kt @@ -1,6 +1,7 @@ package to.bitkit.ext import org.lightningdevkit.ldknode.PaymentDetails +import to.bitkit.models.MSat val PaymentDetails.amountSats: ULong? - get() = amountMsat?.let { (it + 999u) / 1000u } + get() = amountMsat?.let { MSat(it).ceil() } diff --git a/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt index c230a8b75..3fbff36f5 100644 --- a/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt +++ b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt @@ -26,6 +26,7 @@ import to.bitkit.ext.amountOnClose import to.bitkit.ext.toUserMessage import to.bitkit.models.BITCOIN_SYMBOL import to.bitkit.models.BlocktankNotificationType +import to.bitkit.models.MSat import to.bitkit.models.BlocktankNotificationType.cjitPaymentArrived import to.bitkit.models.BlocktankNotificationType.incomingHtlc import to.bitkit.models.BlocktankNotificationType.mutualClose @@ -192,7 +193,7 @@ class WakeNodeWorker @AssistedInject constructor( showDetails: Boolean, hiddenBody: String, ) { - val sats = (event.amountMsat + 999u) / 1000u + val sats = MSat(event.amountMsat).ceil() // Save for UI to pick up cacheStore.setBackgroundReceive( NewTransactionSheetDetails( diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 78a990867..4aefbdb42 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -32,6 +32,7 @@ import to.bitkit.ext.toHex import to.bitkit.models.ALL_ADDRESS_TYPE_STRINGS import to.bitkit.models.AddressModel import to.bitkit.models.BalanceState +import to.bitkit.models.MSat import to.bitkit.models.DEFAULT_ADDRESS_TYPE_STRING import to.bitkit.models.toDerivationPath import to.bitkit.services.CoreService @@ -565,7 +566,7 @@ class WalletRepo @Inject constructor( val channels = lightningRepo.lightningState.value.channels if (channels.filterOpen().isEmpty()) return@runCatching false - val inboundBalanceSats = channels.sumOf { it.inboundCapacityMsat / 1000u } + val inboundBalanceSats = channels.sumOf { MSat(it.inboundCapacityMsat).floor() } return@runCatching (_walletState.value.bip21AmountSats ?: 0uL) >= inboundBalanceSats }.onFailure { diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 5b73b1dea..ad0e9f6fe 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -72,6 +72,7 @@ import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.env.Env import to.bitkit.ext.amountSats +import to.bitkit.models.MSat import to.bitkit.ext.channelId import to.bitkit.ext.create import to.bitkit.ext.latestSpendingTxid @@ -501,7 +502,7 @@ class ActivityService( value = payment.amountSats ?: 0u, invoice = kind.bolt11 ?: "Loading...", timestamp = payment.latestUpdateTimestamp, - fee = (payment.feePaidMsat ?: 0u) / 1000u, + fee = MSat(payment.feePaidMsat ?: 0u).floor(), message = kind.description.orEmpty(), preimage = kind.preimage, seenAt = null, @@ -610,7 +611,7 @@ class ActivityService( ldkValue } - val ldkFeeSats = ldkFeeMsat / 1000u + val ldkFeeSats = MSat(ldkFeeMsat).floor() val updatedFee = if (existingActivity.v1.fee == 0uL && ldkFeeSats > 0uL) ldkFeeSats else existingActivity.v1.fee val updatedOnChain = existingActivity.v1.copy( @@ -649,7 +650,7 @@ class ActivityService( txType = payment.direction.toPaymentType(), txId = kind.txid, value = payment.amountSats ?: 0u, - fee = (payment.feePaidMsat ?: 0u) / 1000u, + fee = MSat(payment.feePaidMsat ?: 0u).floor(), address = resolvedAddress ?: "Loading...", timestamp = activityTimestamp, confirmed = confirmationData.isConfirmed, diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 86ddd0bc4..feb3d9ed0 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -49,6 +49,7 @@ import to.bitkit.env.Env import to.bitkit.ext.totalNextOutboundHtlcLimitSats import to.bitkit.ext.uByteList import to.bitkit.ext.uri +import to.bitkit.models.MSat import to.bitkit.models.OpenChannelResult import to.bitkit.models.toAddressType import to.bitkit.utils.AppError @@ -694,7 +695,7 @@ class LightningService @Inject constructor( return@background runCatching { val invoice = Bolt11Invoice.fromStr(bolt11) val feesMsat = node.bolt11Payment().estimateRoutingFees(invoice) - val feeSat = feesMsat / 1000u + val feeSat = MSat(feesMsat).floor() Result.success(feeSat) }.getOrElse { Result.failure(if (it is NodeException) LdkError(it) else it) @@ -710,7 +711,7 @@ class LightningService @Inject constructor( val invoice = Bolt11Invoice.fromStr(bolt11) val amountMsat = amountSats * 1000u val feesMsat = node.bolt11Payment().estimateRoutingFeesUsingAmount(invoice, amountMsat) - val feeSat = feesMsat / 1000u + val feeSat = MSat(feesMsat).floor() Result.success(feeSat) }.getOrElse { Result.failure(if (it is NodeException) LdkError(it) else it) @@ -728,7 +729,7 @@ class LightningService @Inject constructor( val invoiceAmountMsat = bolt11Invoice.amountMilliSatoshis() Logger.debug( - "sendProbes: invoiceAmountMsat=$invoiceAmountMsat (${invoiceAmountMsat?.let { it / 1000u }} sats)", + "sendProbes: invoiceAmountMsat=$invoiceAmountMsat (${invoiceAmountMsat?.let { MSat(it).floor() }} sats)", context = TAG ) @@ -751,8 +752,8 @@ class LightningService @Inject constructor( val invoiceAmountMsat = bolt11Invoice.amountMilliSatoshis() Logger.debug( - "sendProbesUsingAmount: customAmountMsat=$amountMsat (${amountMsat / 1000u} sats), " + - "invoiceAmountMsat=$invoiceAmountMsat (${invoiceAmountMsat?.let { it / 1000u }} sats)", + "sendProbesUsingAmount: customAmountMsat=$amountMsat (${MSat(amountMsat).floor()} sats), " + + "invoiceAmountMsat=$invoiceAmountMsat (${invoiceAmountMsat?.let { MSat(it).floor() }} sats)", context = TAG ) diff --git a/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt b/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt index 16216bc09..b90ebc695 100644 --- a/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt +++ b/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt @@ -50,6 +50,7 @@ import to.bitkit.ext.createChannelDetails import to.bitkit.ext.ellipsisMiddle import to.bitkit.ext.formatToString import to.bitkit.ext.uri +import to.bitkit.models.MSat import to.bitkit.models.NodeLifecycleState import to.bitkit.models.NodePeer import to.bitkit.models.alias @@ -336,8 +337,8 @@ private fun ChannelsSection( } LightningChannel( capacity = (channel.channelValueSats).toLong(), - localBalance = (channel.outboundCapacityMsat / 1000u).toLong(), - remoteBalance = (channel.inboundCapacityMsat / 1000u).toLong(), + localBalance = MSat(channel.outboundCapacityMsat).floor().toLong(), + remoteBalance = MSat(channel.inboundCapacityMsat).floor().toLong(), status = if (channel.isChannelReady) ChannelStatusUi.OPEN else ChannelStatusUi.PENDING, ) VerticalSpacer(8.dp) @@ -356,23 +357,23 @@ private fun ChannelsSection( ) ChannelDetailRow( title = stringResource(R.string.lightning__inbound_capacity), - value = "₿ ${(channel.inboundCapacityMsat / 1000u).formatToModernDisplay()}", + value = "₿ ${MSat(channel.inboundCapacityMsat).floor().formatToModernDisplay()}", ) ChannelDetailRow( title = stringResource(R.string.lightning__inbound_htlc_max), - value = "₿ ${(channel.inboundHtlcMaximumMsat?.div(1000u) ?: 0u).formatToModernDisplay()}", + value = "₿ ${(channel.inboundHtlcMaximumMsat?.let { MSat(it).floor() } ?: 0u).formatToModernDisplay()}", ) ChannelDetailRow( title = stringResource(R.string.lightning__inbound_htlc_min), - value = "₿ ${(channel.inboundHtlcMinimumMsat / 1000u).formatToModernDisplay()}", + value = "₿ ${MSat(channel.inboundHtlcMinimumMsat).floor().formatToModernDisplay()}", ) ChannelDetailRow( title = stringResource(R.string.lightning__next_outbound_htlc_limit), - value = "₿ ${(channel.nextOutboundHtlcLimitMsat / 1000u).formatToModernDisplay()}", + value = "₿ ${MSat(channel.nextOutboundHtlcLimitMsat).floor().formatToModernDisplay()}", ) ChannelDetailRow( title = stringResource(R.string.lightning__next_outbound_htlc_min), - value = "₿ ${(channel.nextOutboundHtlcMinimumMsat / 1000u).formatToModernDisplay()}", + value = "₿ ${MSat(channel.nextOutboundHtlcMinimumMsat).floor().formatToModernDisplay()}", ) ChannelDetailRow( title = stringResource(R.string.common__confirmations), diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt index 6727fe16d..6571733cd 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt @@ -60,6 +60,7 @@ import to.bitkit.ext.DatePattern import to.bitkit.ext.amountOnClose import to.bitkit.ext.createChannelDetails import to.bitkit.ext.setClipboardText +import to.bitkit.models.MSat import to.bitkit.models.Toast import to.bitkit.ui.Routes import to.bitkit.ui.appViewModel @@ -221,7 +222,7 @@ private fun ChannelDetailContent( val capacity = channel.details.channelValueSats.toLong() val localBalance = channel.details.amountOnClose.toLong() - val remoteBalance = (channel.details.inboundCapacityMsat / 1000u).toLong() + val remoteBalance = MSat(channel.details.inboundCapacityMsat).floor().toLong() val reserveBalance = (channel.details.unspendablePunishmentReserve ?: 0u).toLong() PullToRefreshBox( @@ -376,7 +377,7 @@ private fun ChannelDetailContent( name = stringResource(R.string.lightning__base_fee), valueContent = { MoneyCaptionB( - sats = (channel.details.config.forwardingFeeBaseMsat / 1000u).toLong(), + sats = MSat(channel.details.config.forwardingFeeBaseMsat.toULong()).floor().toLong(), symbol = true ) } diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt index dbbb430d2..6e722d01f 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt @@ -47,6 +47,7 @@ import kotlinx.collections.immutable.toImmutableList import to.bitkit.R import to.bitkit.ext.amountOnClose import to.bitkit.ext.createChannelDetails +import to.bitkit.models.MSat import to.bitkit.models.formatToModernDisplay import to.bitkit.ui.Routes import to.bitkit.ui.components.BodyM @@ -374,7 +375,7 @@ private fun ChannelItem( LightningChannel( capacity = channelUi.details.channelValueSats.toLong(), localBalance = channelUi.details.amountOnClose.toLong(), - remoteBalance = (channelUi.details.inboundCapacityMsat / 1000u).toLong(), + remoteBalance = MSat(channelUi.details.inboundCapacityMsat).floor().toLong(), status = status, ) VerticalSpacer(16.dp) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 392c4b5e1..a494719aa 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -91,6 +91,7 @@ import to.bitkit.ext.toUserMessage import to.bitkit.ext.totalValue import to.bitkit.ext.watchUntil import to.bitkit.models.FeeRate +import to.bitkit.models.MSat import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NewTransactionSheetDirection import to.bitkit.models.NewTransactionSheetType @@ -1894,7 +1895,7 @@ class AppViewModel @Inject constructor( ) } else { val withdrawAmountSats = _sendUiState.value.amount.coerceAtLeast( - (lnurl.data.minWithdrawable ?: 0u) / 1000u + MSat(lnurl.data.minWithdrawable ?: 0u).floor() ) _sendUiState.update { it.copy(amount = withdrawAmountSats) } lightningRepo.createInvoice( From 1e04e86dd99f3171ae1852d1a50ed657ee1b0a00 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 6 Apr 2026 00:15:23 +0200 Subject: [PATCH 3/6] fix: address review comments Co-Authored-By: Claude Opus 4.6 (1M context) --- app/src/main/java/to/bitkit/ext/Lnurl.kt | 2 +- app/src/main/java/to/bitkit/models/MSat.kt | 15 ++++++++------- app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt | 4 +++- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/to/bitkit/ext/Lnurl.kt b/app/src/main/java/to/bitkit/ext/Lnurl.kt index 1f9a48550..ba6b62b09 100644 --- a/app/src/main/java/to/bitkit/ext/Lnurl.kt +++ b/app/src/main/java/to/bitkit/ext/Lnurl.kt @@ -28,7 +28,7 @@ fun LnurlPayData.isFixedAmount(): Boolean = * For variable-amount requests the user-selected sat amount is converted to msats. */ fun LnurlPayData.callbackAmountMsats(userSats: ULong? = null): ULong = - if (isFixedAmount()) minSendable else (userSats ?: minSendableSat()) * 1000u + if (isFixedAmount()) minSendable else (userSats ?: minSendableSat()) * MSat.PER_SAT fun LnurlWithdrawData.minWithdrawableSat(): ULong = MSat(minWithdrawable ?: 0u).ceil() fun LnurlWithdrawData.maxWithdrawableSat(): ULong = MSat(maxWithdrawable).floor() diff --git a/app/src/main/java/to/bitkit/models/MSat.kt b/app/src/main/java/to/bitkit/models/MSat.kt index 43f329477..1b27b3390 100644 --- a/app/src/main/java/to/bitkit/models/MSat.kt +++ b/app/src/main/java/to/bitkit/models/MSat.kt @@ -1,19 +1,20 @@ package to.bitkit.models /** - * A wrapper for millisatoshi [ULong] values that provides explicit sat conversions. + * A non-boxing wrapper for millisatoshi [ULong] values that provides explicit sat conversions. * - * Eliminates scattered `/ 1000u` and `(x + 999u) / 1000u` patterns by encoding - * the rounding intent directly in the API: [ceil] rounds up, [floor] truncates. - * - * Zero runtime overhead — this is an [inline value class][JvmInline]. + * Encapsulates the rounding intent directly in the API: [ceil] rounds up, [floor] truncates. */ @JvmInline value class MSat(val value: ULong) { + companion object { + const val PER_SAT: ULong = 1000u + } + /** Round up to the nearest whole sat. Use for payment/display amounts. */ - fun ceil(): ULong = (value + 999u) / 1000u + fun ceil(): ULong = (value + PER_SAT - 1u) / PER_SAT /** Truncate sub-sat remainder. Use for fees and upper bounds. */ - fun floor(): ULong = value / 1000u + fun floor(): ULong = value / PER_SAT } diff --git a/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt b/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt index b90ebc695..a5480f472 100644 --- a/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt +++ b/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt @@ -361,7 +361,9 @@ private fun ChannelsSection( ) ChannelDetailRow( title = stringResource(R.string.lightning__inbound_htlc_max), - value = "₿ ${(channel.inboundHtlcMaximumMsat?.let { MSat(it).floor() } ?: 0u).formatToModernDisplay()}", + value = "₿ ${ + (channel.inboundHtlcMaximumMsat?.let { MSat(it).floor() } ?: 0u).formatToModernDisplay() + }", ) ChannelDetailRow( title = stringResource(R.string.lightning__inbound_htlc_min), From 6170ff3ce9cfeb0a1622f5adbe3d545f11037d51 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 6 Apr 2026 00:38:09 +0200 Subject: [PATCH 4/6] refactor: add msatCeilOf/msatFloorOf sugar functions Co-Authored-By: Claude Opus 4.6 (1M context) --- .../commands/NotifyPaymentReceivedHandler.kt | 4 ++-- .../main/java/to/bitkit/ext/ChannelDetails.kt | 8 ++++---- app/src/main/java/to/bitkit/ext/Lnurl.kt | 10 ++++++---- .../main/java/to/bitkit/ext/PaymentDetails.kt | 4 ++-- .../main/java/to/bitkit/fcm/WakeNodeWorker.kt | 4 ++-- app/src/main/java/to/bitkit/models/MSat.kt | 6 ++++++ .../java/to/bitkit/repositories/WalletRepo.kt | 4 ++-- .../main/java/to/bitkit/services/CoreService.kt | 8 ++++---- .../java/to/bitkit/services/LightningService.kt | 12 ++++++------ app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt | 16 ++++++++-------- .../ui/settings/lightning/ChannelDetailScreen.kt | 6 +++--- .../lightning/LightningConnectionsScreen.kt | 4 ++-- .../java/to/bitkit/viewmodels/AppViewModel.kt | 4 ++-- 13 files changed, 49 insertions(+), 41 deletions(-) diff --git a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt index 1b213ba00..1992e08cd 100644 --- a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt +++ b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt @@ -18,7 +18,7 @@ import to.bitkit.models.NotificationDetails import to.bitkit.models.PrimaryDisplay import to.bitkit.models.formatToModernDisplay import to.bitkit.repositories.ActivityRepo -import to.bitkit.models.MSat +import to.bitkit.models.msatCeilOf import to.bitkit.repositories.CurrencyRepo import to.bitkit.utils.Logger import javax.inject.Inject @@ -98,7 +98,7 @@ class NotifyPaymentReceivedHandler @Inject constructor( is NotifyPaymentReceived.Command.Onchain -> command.event.txid }, sats = when (command) { - is NotifyPaymentReceived.Command.Lightning -> MSat(command.event.amountMsat).ceil().toLong() + is NotifyPaymentReceived.Command.Lightning -> msatCeilOf(command.event.amountMsat).toLong() is NotifyPaymentReceived.Command.Onchain -> command.event.details.amountSats }, ) diff --git a/app/src/main/java/to/bitkit/ext/ChannelDetails.kt b/app/src/main/java/to/bitkit/ext/ChannelDetails.kt index fcb71792d..807aaaa75 100644 --- a/app/src/main/java/to/bitkit/ext/ChannelDetails.kt +++ b/app/src/main/java/to/bitkit/ext/ChannelDetails.kt @@ -3,7 +3,7 @@ package to.bitkit.ext import org.lightningdevkit.ldknode.ChannelConfig import org.lightningdevkit.ldknode.ChannelDetails import org.lightningdevkit.ldknode.MaxDustHtlcExposure -import to.bitkit.models.MSat +import to.bitkit.models.msatFloorOf /** * Calculates our total balance in the channel (see `value_to_self_msat` in rust-lightning). @@ -19,7 +19,7 @@ val ChannelDetails.amountOnClose: ULong @Suppress("ForbiddenComment") get() { // TODO: use channelDetails.claimableOnCloseSats - val outboundCapacitySat = MSat(this.outboundCapacityMsat).floor() + val outboundCapacitySat = msatFloorOf(this.outboundCapacityMsat) val ourReserve = this.unspendablePunishmentReserve ?: 0u return outboundCapacitySat + ourReserve @@ -33,13 +33,13 @@ fun List.filterPending(): List = this.filterNot /** Returns a limit in sats as close as possible to the HTLC limit we can currently send. */ fun List?.totalNextOutboundHtlcLimitSats(): ULong = this?.filter { it.isUsable } - ?.sumOf { MSat(it.nextOutboundHtlcLimitMsat).floor() } + ?.sumOf { msatFloorOf(it.nextOutboundHtlcLimitMsat) } ?: 0u /** Calculates the total remote balance (inbound capacity) from open channels. */ fun List.calculateRemoteBalance(): ULong = this .filterOpen() - .sumOf { MSat(it.inboundCapacityMsat).floor() } + .sumOf { msatFloorOf(it.inboundCapacityMsat) } fun createChannelDetails(): ChannelDetails = ChannelDetails( channelId = "channelId", diff --git a/app/src/main/java/to/bitkit/ext/Lnurl.kt b/app/src/main/java/to/bitkit/ext/Lnurl.kt index ba6b62b09..436d8b25a 100644 --- a/app/src/main/java/to/bitkit/ext/Lnurl.kt +++ b/app/src/main/java/to/bitkit/ext/Lnurl.kt @@ -3,10 +3,12 @@ package to.bitkit.ext import com.synonym.bitkitcore.LnurlPayData import com.synonym.bitkitcore.LnurlWithdrawData import to.bitkit.models.MSat +import to.bitkit.models.msatCeilOf +import to.bitkit.models.msatFloorOf fun LnurlPayData.commentAllowed(): Boolean = commentAllowed?.let { it > 0u } == true -fun LnurlPayData.maxSendableSat(): ULong = MSat(maxSendable).floor() -fun LnurlPayData.minSendableSat(): ULong = MSat(minSendable).ceil() +fun LnurlPayData.maxSendableSat(): ULong = msatFloorOf(maxSendable) +fun LnurlPayData.minSendableSat(): ULong = msatCeilOf(minSendable) /** * True when the LNURL-pay endpoint specifies a single exact amount. @@ -30,8 +32,8 @@ fun LnurlPayData.isFixedAmount(): Boolean = fun LnurlPayData.callbackAmountMsats(userSats: ULong? = null): ULong = if (isFixedAmount()) minSendable else (userSats ?: minSendableSat()) * MSat.PER_SAT -fun LnurlWithdrawData.minWithdrawableSat(): ULong = MSat(minWithdrawable ?: 0u).ceil() -fun LnurlWithdrawData.maxWithdrawableSat(): ULong = MSat(maxWithdrawable).floor() +fun LnurlWithdrawData.minWithdrawableSat(): ULong = msatCeilOf(minWithdrawable ?: 0u) +fun LnurlWithdrawData.maxWithdrawableSat(): ULong = msatFloorOf(maxWithdrawable) /** * True when the LNURL-withdraw endpoint specifies a single exact amount, diff --git a/app/src/main/java/to/bitkit/ext/PaymentDetails.kt b/app/src/main/java/to/bitkit/ext/PaymentDetails.kt index 2f8dfc164..93c1c8b13 100644 --- a/app/src/main/java/to/bitkit/ext/PaymentDetails.kt +++ b/app/src/main/java/to/bitkit/ext/PaymentDetails.kt @@ -1,7 +1,7 @@ package to.bitkit.ext import org.lightningdevkit.ldknode.PaymentDetails -import to.bitkit.models.MSat +import to.bitkit.models.msatCeilOf val PaymentDetails.amountSats: ULong? - get() = amountMsat?.let { MSat(it).ceil() } + get() = amountMsat?.let { msatCeilOf(it) } diff --git a/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt index 3fbff36f5..fe9a86efc 100644 --- a/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt +++ b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt @@ -26,7 +26,7 @@ import to.bitkit.ext.amountOnClose import to.bitkit.ext.toUserMessage import to.bitkit.models.BITCOIN_SYMBOL import to.bitkit.models.BlocktankNotificationType -import to.bitkit.models.MSat +import to.bitkit.models.msatCeilOf import to.bitkit.models.BlocktankNotificationType.cjitPaymentArrived import to.bitkit.models.BlocktankNotificationType.incomingHtlc import to.bitkit.models.BlocktankNotificationType.mutualClose @@ -193,7 +193,7 @@ class WakeNodeWorker @AssistedInject constructor( showDetails: Boolean, hiddenBody: String, ) { - val sats = MSat(event.amountMsat).ceil() + val sats = msatCeilOf(event.amountMsat) // Save for UI to pick up cacheStore.setBackgroundReceive( NewTransactionSheetDetails( diff --git a/app/src/main/java/to/bitkit/models/MSat.kt b/app/src/main/java/to/bitkit/models/MSat.kt index 1b27b3390..574556e83 100644 --- a/app/src/main/java/to/bitkit/models/MSat.kt +++ b/app/src/main/java/to/bitkit/models/MSat.kt @@ -18,3 +18,9 @@ value class MSat(val value: ULong) { /** Truncate sub-sat remainder. Use for fees and upper bounds. */ fun floor(): ULong = value / PER_SAT } + +/** Syntactic sugar for [MSat.ceil]. */ +fun msatCeilOf(msat: ULong): ULong = MSat(msat).ceil() + +/** Syntactic sugar for [MSat.floor]. */ +fun msatFloorOf(msat: ULong): ULong = MSat(msat).floor() diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 4aefbdb42..199e2ff3e 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -32,7 +32,7 @@ import to.bitkit.ext.toHex import to.bitkit.models.ALL_ADDRESS_TYPE_STRINGS import to.bitkit.models.AddressModel import to.bitkit.models.BalanceState -import to.bitkit.models.MSat +import to.bitkit.models.msatFloorOf import to.bitkit.models.DEFAULT_ADDRESS_TYPE_STRING import to.bitkit.models.toDerivationPath import to.bitkit.services.CoreService @@ -566,7 +566,7 @@ class WalletRepo @Inject constructor( val channels = lightningRepo.lightningState.value.channels if (channels.filterOpen().isEmpty()) return@runCatching false - val inboundBalanceSats = channels.sumOf { MSat(it.inboundCapacityMsat).floor() } + val inboundBalanceSats = channels.sumOf { msatFloorOf(it.inboundCapacityMsat) } return@runCatching (_walletState.value.bip21AmountSats ?: 0uL) >= inboundBalanceSats }.onFailure { diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index ad0e9f6fe..4349eced4 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -72,7 +72,7 @@ import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.env.Env import to.bitkit.ext.amountSats -import to.bitkit.models.MSat +import to.bitkit.models.msatFloorOf import to.bitkit.ext.channelId import to.bitkit.ext.create import to.bitkit.ext.latestSpendingTxid @@ -502,7 +502,7 @@ class ActivityService( value = payment.amountSats ?: 0u, invoice = kind.bolt11 ?: "Loading...", timestamp = payment.latestUpdateTimestamp, - fee = MSat(payment.feePaidMsat ?: 0u).floor(), + fee = msatFloorOf(payment.feePaidMsat ?: 0u), message = kind.description.orEmpty(), preimage = kind.preimage, seenAt = null, @@ -611,7 +611,7 @@ class ActivityService( ldkValue } - val ldkFeeSats = MSat(ldkFeeMsat).floor() + val ldkFeeSats = msatFloorOf(ldkFeeMsat) val updatedFee = if (existingActivity.v1.fee == 0uL && ldkFeeSats > 0uL) ldkFeeSats else existingActivity.v1.fee val updatedOnChain = existingActivity.v1.copy( @@ -650,7 +650,7 @@ class ActivityService( txType = payment.direction.toPaymentType(), txId = kind.txid, value = payment.amountSats ?: 0u, - fee = MSat(payment.feePaidMsat ?: 0u).floor(), + fee = msatFloorOf(payment.feePaidMsat ?: 0u), address = resolvedAddress ?: "Loading...", timestamp = activityTimestamp, confirmed = confirmationData.isConfirmed, diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index feb3d9ed0..74e1a2f3a 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -49,7 +49,7 @@ import to.bitkit.env.Env import to.bitkit.ext.totalNextOutboundHtlcLimitSats import to.bitkit.ext.uByteList import to.bitkit.ext.uri -import to.bitkit.models.MSat +import to.bitkit.models.msatFloorOf import to.bitkit.models.OpenChannelResult import to.bitkit.models.toAddressType import to.bitkit.utils.AppError @@ -695,7 +695,7 @@ class LightningService @Inject constructor( return@background runCatching { val invoice = Bolt11Invoice.fromStr(bolt11) val feesMsat = node.bolt11Payment().estimateRoutingFees(invoice) - val feeSat = MSat(feesMsat).floor() + val feeSat = msatFloorOf(feesMsat) Result.success(feeSat) }.getOrElse { Result.failure(if (it is NodeException) LdkError(it) else it) @@ -711,7 +711,7 @@ class LightningService @Inject constructor( val invoice = Bolt11Invoice.fromStr(bolt11) val amountMsat = amountSats * 1000u val feesMsat = node.bolt11Payment().estimateRoutingFeesUsingAmount(invoice, amountMsat) - val feeSat = MSat(feesMsat).floor() + val feeSat = msatFloorOf(feesMsat) Result.success(feeSat) }.getOrElse { Result.failure(if (it is NodeException) LdkError(it) else it) @@ -729,7 +729,7 @@ class LightningService @Inject constructor( val invoiceAmountMsat = bolt11Invoice.amountMilliSatoshis() Logger.debug( - "sendProbes: invoiceAmountMsat=$invoiceAmountMsat (${invoiceAmountMsat?.let { MSat(it).floor() }} sats)", + "sendProbes: invoiceAmountMsat=$invoiceAmountMsat (${invoiceAmountMsat?.let { msatFloorOf(it) }} sats)", context = TAG ) @@ -752,8 +752,8 @@ class LightningService @Inject constructor( val invoiceAmountMsat = bolt11Invoice.amountMilliSatoshis() Logger.debug( - "sendProbesUsingAmount: customAmountMsat=$amountMsat (${MSat(amountMsat).floor()} sats), " + - "invoiceAmountMsat=$invoiceAmountMsat (${invoiceAmountMsat?.let { MSat(it).floor() }} sats)", + "sendProbesUsingAmount: customAmountMsat=$amountMsat (${msatFloorOf(amountMsat)} sats), " + + "invoiceAmountMsat=$invoiceAmountMsat (${invoiceAmountMsat?.let { msatFloorOf(it) }} sats)", context = TAG ) diff --git a/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt b/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt index a5480f472..1b6f70840 100644 --- a/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt +++ b/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt @@ -50,7 +50,7 @@ import to.bitkit.ext.createChannelDetails import to.bitkit.ext.ellipsisMiddle import to.bitkit.ext.formatToString import to.bitkit.ext.uri -import to.bitkit.models.MSat +import to.bitkit.models.msatFloorOf import to.bitkit.models.NodeLifecycleState import to.bitkit.models.NodePeer import to.bitkit.models.alias @@ -337,8 +337,8 @@ private fun ChannelsSection( } LightningChannel( capacity = (channel.channelValueSats).toLong(), - localBalance = MSat(channel.outboundCapacityMsat).floor().toLong(), - remoteBalance = MSat(channel.inboundCapacityMsat).floor().toLong(), + localBalance = msatFloorOf(channel.outboundCapacityMsat).toLong(), + remoteBalance = msatFloorOf(channel.inboundCapacityMsat).toLong(), status = if (channel.isChannelReady) ChannelStatusUi.OPEN else ChannelStatusUi.PENDING, ) VerticalSpacer(8.dp) @@ -357,25 +357,25 @@ private fun ChannelsSection( ) ChannelDetailRow( title = stringResource(R.string.lightning__inbound_capacity), - value = "₿ ${MSat(channel.inboundCapacityMsat).floor().formatToModernDisplay()}", + value = "₿ ${msatFloorOf(channel.inboundCapacityMsat).formatToModernDisplay()}", ) ChannelDetailRow( title = stringResource(R.string.lightning__inbound_htlc_max), value = "₿ ${ - (channel.inboundHtlcMaximumMsat?.let { MSat(it).floor() } ?: 0u).formatToModernDisplay() + (channel.inboundHtlcMaximumMsat?.let { msatFloorOf(it) } ?: 0u).formatToModernDisplay() }", ) ChannelDetailRow( title = stringResource(R.string.lightning__inbound_htlc_min), - value = "₿ ${MSat(channel.inboundHtlcMinimumMsat).floor().formatToModernDisplay()}", + value = "₿ ${msatFloorOf(channel.inboundHtlcMinimumMsat).formatToModernDisplay()}", ) ChannelDetailRow( title = stringResource(R.string.lightning__next_outbound_htlc_limit), - value = "₿ ${MSat(channel.nextOutboundHtlcLimitMsat).floor().formatToModernDisplay()}", + value = "₿ ${msatFloorOf(channel.nextOutboundHtlcLimitMsat).formatToModernDisplay()}", ) ChannelDetailRow( title = stringResource(R.string.lightning__next_outbound_htlc_min), - value = "₿ ${MSat(channel.nextOutboundHtlcMinimumMsat).floor().formatToModernDisplay()}", + value = "₿ ${msatFloorOf(channel.nextOutboundHtlcMinimumMsat).formatToModernDisplay()}", ) ChannelDetailRow( title = stringResource(R.string.common__confirmations), diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt index 6571733cd..0b93407ac 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt @@ -60,7 +60,7 @@ import to.bitkit.ext.DatePattern import to.bitkit.ext.amountOnClose import to.bitkit.ext.createChannelDetails import to.bitkit.ext.setClipboardText -import to.bitkit.models.MSat +import to.bitkit.models.msatFloorOf import to.bitkit.models.Toast import to.bitkit.ui.Routes import to.bitkit.ui.appViewModel @@ -222,7 +222,7 @@ private fun ChannelDetailContent( val capacity = channel.details.channelValueSats.toLong() val localBalance = channel.details.amountOnClose.toLong() - val remoteBalance = MSat(channel.details.inboundCapacityMsat).floor().toLong() + val remoteBalance = msatFloorOf(channel.details.inboundCapacityMsat).toLong() val reserveBalance = (channel.details.unspendablePunishmentReserve ?: 0u).toLong() PullToRefreshBox( @@ -377,7 +377,7 @@ private fun ChannelDetailContent( name = stringResource(R.string.lightning__base_fee), valueContent = { MoneyCaptionB( - sats = MSat(channel.details.config.forwardingFeeBaseMsat.toULong()).floor().toLong(), + sats = msatFloorOf(channel.details.config.forwardingFeeBaseMsat.toULong()).toLong(), symbol = true ) } diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt index 6e722d01f..89f42eb0f 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt @@ -47,7 +47,7 @@ import kotlinx.collections.immutable.toImmutableList import to.bitkit.R import to.bitkit.ext.amountOnClose import to.bitkit.ext.createChannelDetails -import to.bitkit.models.MSat +import to.bitkit.models.msatFloorOf import to.bitkit.models.formatToModernDisplay import to.bitkit.ui.Routes import to.bitkit.ui.components.BodyM @@ -375,7 +375,7 @@ private fun ChannelItem( LightningChannel( capacity = channelUi.details.channelValueSats.toLong(), localBalance = channelUi.details.amountOnClose.toLong(), - remoteBalance = MSat(channelUi.details.inboundCapacityMsat).floor().toLong(), + remoteBalance = msatFloorOf(channelUi.details.inboundCapacityMsat).toLong(), status = status, ) VerticalSpacer(16.dp) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index a494719aa..761704867 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -91,7 +91,7 @@ import to.bitkit.ext.toUserMessage import to.bitkit.ext.totalValue import to.bitkit.ext.watchUntil import to.bitkit.models.FeeRate -import to.bitkit.models.MSat +import to.bitkit.models.msatFloorOf import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NewTransactionSheetDirection import to.bitkit.models.NewTransactionSheetType @@ -1895,7 +1895,7 @@ class AppViewModel @Inject constructor( ) } else { val withdrawAmountSats = _sendUiState.value.amount.coerceAtLeast( - MSat(lnurl.data.minWithdrawable ?: 0u).floor() + msatFloorOf(lnurl.data.minWithdrawable ?: 0u) ) _sendUiState.update { it.copy(amount = withdrawAmountSats) } lightningRepo.createInvoice( From ebc01a1c35d6ad174ba1944581fd0b251268b96e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 6 Apr 2026 00:39:36 +0200 Subject: [PATCH 5/6] test: add MSat unit tests Co-Authored-By: Claude Opus 4.6 (1M context) --- .../test/java/to/bitkit/models/MSatTest.kt | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 app/src/test/java/to/bitkit/models/MSatTest.kt diff --git a/app/src/test/java/to/bitkit/models/MSatTest.kt b/app/src/test/java/to/bitkit/models/MSatTest.kt new file mode 100644 index 000000000..fc17e8ebc --- /dev/null +++ b/app/src/test/java/to/bitkit/models/MSatTest.kt @@ -0,0 +1,78 @@ +package to.bitkit.models + +import org.junit.Test +import kotlin.test.assertEquals + +class MSatTest { + + // region ceil + @Test + fun `ceil returns exact sat for multiple of 1000`() { + assertEquals(1uL, MSat(1000uL).ceil()) + } + + @Test + fun `ceil rounds up for non-multiple`() { + assertEquals(2uL, MSat(1001uL).ceil()) + } + + @Test + fun `ceil rounds up for 1 msat`() { + assertEquals(1uL, MSat(1uL).ceil()) + } + + @Test + fun `ceil rounds up for 999 msat`() { + assertEquals(1uL, MSat(999uL).ceil()) + } + + @Test + fun `ceil returns zero for zero`() { + assertEquals(0uL, MSat(0uL).ceil()) + } + + @Test + fun `ceil handles realistic payment amount`() { + assertEquals(223uL, MSat(222_222uL).ceil()) + } + // endregion + + // region floor + @Test + fun `floor returns exact sat for multiple of 1000`() { + assertEquals(2uL, MSat(2000uL).floor()) + } + + @Test + fun `floor truncates for non-multiple`() { + assertEquals(1uL, MSat(1999uL).floor()) + } + + @Test + fun `floor returns zero for sub-sat value`() { + assertEquals(0uL, MSat(999uL).floor()) + } + + @Test + fun `floor returns zero for zero`() { + assertEquals(0uL, MSat(0uL).floor()) + } + + @Test + fun `floor handles realistic fee amount`() { + assertEquals(222uL, MSat(222_222uL).floor()) + } + // endregion + + // region sugar functions + @Test + fun `msatCeilOf matches MSat ceil`() { + assertEquals(MSat(1500uL).ceil(), msatCeilOf(1500uL)) + } + + @Test + fun `msatFloorOf matches MSat floor`() { + assertEquals(MSat(1500uL).floor(), msatFloorOf(1500uL)) + } + // endregion +} From d6ccbe738a527c8a5e02a06fca068dd0e3e8a6ee Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 6 Apr 2026 00:45:32 +0200 Subject: [PATCH 6/6] docs: improve msatCeilOf/msatFloorOf kdoc Co-Authored-By: Claude Opus 4.6 (1M context) --- app/src/main/java/to/bitkit/models/MSat.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/MSat.kt b/app/src/main/java/to/bitkit/models/MSat.kt index 574556e83..20b0936e9 100644 --- a/app/src/main/java/to/bitkit/models/MSat.kt +++ b/app/src/main/java/to/bitkit/models/MSat.kt @@ -19,8 +19,8 @@ value class MSat(val value: ULong) { fun floor(): ULong = value / PER_SAT } -/** Syntactic sugar for [MSat.ceil]. */ +/** Round [msat] up to the nearest whole sat. Use for payment/display amounts. */ fun msatCeilOf(msat: ULong): ULong = MSat(msat).ceil() -/** Syntactic sugar for [MSat.floor]. */ +/** Truncate sub-sat remainder from [msat]. Use for fees and upper bounds. */ fun msatFloorOf(msat: ULong): ULong = MSat(msat).floor()