Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
60 changes: 43 additions & 17 deletions app/src/main/java/to/bitkit/ui/ContentView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ import to.bitkit.ui.utils.AutoReadClipboardHandler
import to.bitkit.ui.utils.RequestNotificationPermissions
import to.bitkit.ui.utils.ScreenDeepLinks
import to.bitkit.ui.utils.SheetDeepLinks
import to.bitkit.ui.utils.SpendingHwSignLink
import to.bitkit.ui.utils.composableWithDefaultTransitions
import to.bitkit.ui.utils.deepLinkableComposable
import to.bitkit.ui.utils.navigationWithDefaultTransitions
Expand Down Expand Up @@ -334,23 +335,46 @@ fun ContentView(
val uri = pendingScreenDeepLink ?: return@LaunchedEffect

navController.currentBackStackEntryFlow.first()
appViewModel.consumeScreenDeepLink()

SheetDeepLinks.sheetFor(uri)?.let {
appViewModel.showSheet(it)
return@LaunchedEffect
}
val sheet = SheetDeepLinks.sheetFor(uri)
val hwSignLink = if (sheet == null) ScreenDeepLinks.spendingHwSignLink(uri) else null

val shouldNavigate = when {
sheet != null -> {
appViewModel.showSheet(sheet)
false
}

val request = Intent(Intent.ACTION_VIEW, uri)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
val handled = navController.handleDeepLink(request)
hwSignLink is SpendingHwSignLink.Malformed -> {
Logger.warn("Refused spending hw sign deeplink, malformed '$uri'", context = "ContentView")
false
}

// Refusals are logged with their specific reason inside prepareSpendingHwSign.
hwSignLink is SpendingHwSignLink.Valid -> transferViewModel.prepareSpendingHwSign(
walletId = hwSignLink.walletId,
amountSats = hwSignLink.amountSats,
)

if (shouldDismissSheetForScreenLink(handled, appViewModel.currentSheet.value)) {
appViewModel.hideSheet()
else -> true
}
if (!handled) {
Logger.warn("Unhandled screen deeplink '$uri'", context = "ContentView")

if (shouldNavigate) {
val request = Intent(Intent.ACTION_VIEW, uri)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
val handled = navController.handleDeepLink(request)

if (shouldDismissSheetForScreenLink(handled, appViewModel.currentSheet.value)) {
appViewModel.hideSheet()
}
if (!handled) {
Logger.warn("Unhandled screen deeplink '$uri'", context = "ContentView")
}
}

// Consumed once, at the end: the effect is keyed on pendingScreenDeepLink, so clearing it
// early cancels this coroutine mid-prepareSpendingHwSign.
appViewModel.consumeScreenDeepLink()
}

LaunchedEffect(appViewModel) {
Expand Down Expand Up @@ -1004,13 +1028,15 @@ private fun RootNavHost(
viewModel = transferViewModel,
isOffline = connectivityState != ConnectivityState.CONNECTED,
onBackClick = { navController.popBackStack() },
onQuoteReady = { navController.navigateTo(Routes.SpendingHwSign(walletId)) },
onQuoteReady = {
val amountSats = transferViewModel.spendingUiState.value.clientBalanceSat.toLong()
navController.navigateTo(Routes.SpendingHwSign(walletId, amountSats))
},
)
}
composableWithDefaultTransitions<Routes.SpendingHwSign> { entry ->
val walletId = entry.toRoute<Routes.SpendingHwSign>().walletId
deepLinkableComposable<Routes.SpendingHwSign> { entry ->
SpendingHwSignScreen(
walletId = walletId,
walletId = entry.toRoute<Routes.SpendingHwSign>().walletId,
viewModel = transferViewModel,
onBackClick = { navController.popBackStack() },
onCloseClick = { navController.navigateToHome() },
Expand Down Expand Up @@ -2335,7 +2361,7 @@ sealed interface Routes {
data class SpendingAmountHw(val walletId: String) : Routes.DeepLinkable

@Serializable
data class SpendingHwSign(val walletId: String) : Routes.InternalOnly
data class SpendingHwSign(val walletId: String, val amountSats: Long) : Routes.DeepLinkable

@Serializable
data object SpendingHwSigned : Routes.InternalOnly
Expand Down
33 changes: 33 additions & 0 deletions app/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.kt
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,33 @@ object ScreenDeepLinks {
fun isScreenDeepLink(uri: Uri): Boolean =
uri.scheme?.lowercase() == SCHEME && uri.host?.lowercase() == HOST

/**
* Parses `bitkit://screen/spending-hw-sign/{walletId}/{amountSats}`.
*
* Returns null when the URI is not for that screen at all, so the caller can fall through to the
* ordinary nav handling, and [SpendingHwSignLink.Malformed] when it is but carries unusable
* arguments - those must be refused rather than navigated to, or the sign screen opens without a
* quote and immediately bounces the user home.
*
* Gated on [isEnabled] so a release build cannot reach live transfer state through a dev-only URI
* even if a caller forgets to check [shouldQueue] first.
*/
fun spendingHwSignLink(uri: Uri): SpendingHwSignLink? {
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Comment thread
jvsena42 marked this conversation as resolved.
if (!isEnabled || !isScreenDeepLink(uri)) return null
val segments = uri.pathSegments.orEmpty()
val screenId = kebabId(Routes.SpendingHwSign::class)
if (segments.isEmpty() || screenId == null || !segments[0].equals(screenId, ignoreCase = true)) {
return null
}
if (segments.size != 3) return SpendingHwSignLink.Malformed

val walletId = segments[1]
val amountSats = segments[2].toLongOrNull()
if (walletId.isBlank() || amountSats == null || amountSats <= 0) return SpendingHwSignLink.Malformed

return SpendingHwSignLink.Valid(walletId = walletId, amountSats = amountSats)
}

fun detachScreenUri(intent: Intent): Boolean {
val uri = intent.data ?: return false
if (!isScreenDeepLink(uri)) return false
Expand All @@ -46,3 +73,9 @@ object ScreenDeepLinks {
return true
}
}

sealed interface SpendingHwSignLink {
data class Valid(val walletId: String, val amountSats: Long) : SpendingHwSignLink

data object Malformed : SpendingHwSignLink
}
141 changes: 90 additions & 51 deletions app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,10 @@ class TransferViewModel @Inject constructor(

fun onConfirmAmount(satsAmount: Long) {
if (confirmPayJob?.isActive == true || hwTransferSignJob?.isActive == true) return
viewModelScope.launch { quoteSpendingAmount(satsAmount) }
}

private suspend fun quoteSpendingAmount(satsAmount: Long): Boolean {
val values = blocktankRepo.calculateLiquidityOptions(satsAmount.toULong()).getOrNull()
if (values == null || values.maxLspBalanceSat == 0uL) {
setTransferEffect(
Expand All @@ -139,46 +143,45 @@ class TransferViewModel @Inject constructor(
),
)
)
return
return false
}

val lspBalance = maxOf(values.defaultLspBalanceSat, values.minLspBalanceSat)

viewModelScope.launch {
_spendingUiState.update { it.copy(isLoading = true) }
_spendingUiState.update { it.copy(isLoading = true) }

withTimeoutOrNull(1.minutes) {
isNodeRunning.first { it }
}
withTimeoutOrNull(1.minutes) {
isNodeRunning.first { it }
}

val feeSat = estimateSpendingFee(
clientBalanceSat = satsAmount.toULong(),
lspBalanceSat = lspBalance,
).getOrElse { e ->
setTransferEffect(TransferEffect.ToastException(e))
delay(1.seconds)
_spendingUiState.update { it.copy(isLoading = false) }
return@launch
}
val feeSat = estimateSpendingFee(
clientBalanceSat = satsAmount.toULong(),
lspBalanceSat = lspBalance,
).getOrElse { e ->
setTransferEffect(TransferEffect.ToastException(e))
delay(1.seconds)
_spendingUiState.update { it.copy(isLoading = false) }
return false
}

if (!canFundOrder(feeSat)) {
Logger.info("Rejected spending amount '$satsAmount' over funding budget", context = TAG)
setTransferEffect(
TransferEffect.ToastError(
title = context.getString(R.string.lightning__spending_amount__error_balance__title),
description = context.getString(
R.string.lightning__spending_amount__error_balance__description
),
)
if (!canFundOrder(feeSat)) {
Logger.info("Rejected spending amount '$satsAmount' over funding budget", context = TAG)
setTransferEffect(
TransferEffect.ToastError(
title = context.getString(R.string.lightning__spending_amount__error_balance__title),
description = context.getString(
R.string.lightning__spending_amount__error_balance__description
),
)
_spendingUiState.update { it.copy(isLoading = false) }
return@launch
}

onEstimateReady(satsAmount.toULong(), lspBalance, feeSat)
delay(1.seconds)
)
_spendingUiState.update { it.copy(isLoading = false) }
return false
}

val quoted = onEstimateReady(satsAmount.toULong(), lspBalance, feeSat)
delay(1.seconds)
_spendingUiState.update { it.copy(isLoading = false) }
return quoted
}

private suspend fun estimateSpendingFee(
Expand Down Expand Up @@ -644,9 +647,9 @@ class TransferViewModel @Inject constructor(
}
}

private suspend fun onEstimateReady(clientBalanceSat: ULong, lspBalanceSat: ULong, feeSat: ULong) {
private suspend fun onEstimateReady(clientBalanceSat: ULong, lspBalanceSat: ULong, feeSat: ULong): Boolean {
settingsStore.update { it.copy(lightningSetupStep = 0) }
if (confirmPayJob?.isActive == true || hwTransferSignJob?.isActive == true) return
if (confirmPayJob?.isActive == true || hwTransferSignJob?.isActive == true) return false
pendingHwFundingBroadcast = null
hwFeeEstimateJob?.cancel()
hwFeeEstimateJob = null
Expand All @@ -663,6 +666,7 @@ class TransferViewModel @Inject constructor(
)
}
setTransferEffect(TransferEffect.OnQuoteReady)
return true
}

private fun updateAvailableAmount() {
Expand Down Expand Up @@ -975,31 +979,66 @@ class TransferViewModel @Inject constructor(
// region Hardware Wallet

fun updateHwLimits(walletId: String) {
viewModelScope.launch {
_spendingUiState.update { it.copy(isLoading = true) }
viewModelScope.launch { loadHwLimits(walletId) }
}

val account = hwWalletRepo.getFundingAccount(walletId).getOrElse {
Logger.error("Failed to load hardware funding account", it, context = TAG)
_spendingUiState.update { s -> s.copy(isLoading = false, maxAllowedToSend = 0, balanceAfterFee = 0) }
setTransferEffect(TransferEffect.ToastException(it))
return@launch
}
private suspend fun loadHwLimits(walletId: String) {
_spendingUiState.update { it.copy(isLoading = true) }

awaitNodeRunning()
updateTransferValues(0uL)
val account = hwWalletRepo.getFundingAccount(walletId).getOrElse {
Logger.error("Failed to load hardware funding account", it, context = TAG)
_spendingUiState.update { s -> s.copy(isLoading = false, maxAllowedToSend = 0, balanceAfterFee = 0) }
setTransferEffect(TransferEffect.ToastException(it))
return
}

val availableAmount = account.balanceSats.safe() - hwFundingFeeReserve(account.balanceSats).safe()
_spendingUiState.update { it.copy(fundingBudgetSats = availableAmount, hwFundingWalletId = walletId) }
awaitNodeRunning()
updateTransferValues(0uL)

val initialLspFees = estimateInitialLspFees(availableAmount)
if (initialLspFees == null) {
_spendingUiState.update { it.copy(isLoading = false) }
return@launch
}
val availableAmount = account.balanceSats.safe() - hwFundingFeeReserve(account.balanceSats).safe()
_spendingUiState.update { it.copy(fundingBudgetSats = availableAmount, hwFundingWalletId = walletId) }

val balanceAfterLspFee = availableAmount.safe() - initialLspFees.safe()
estimateFinalMaxSendAmount(availableAmount, balanceAfterLspFee)
val initialLspFees = estimateInitialLspFees(availableAmount)
if (initialLspFees == null) {
_spendingUiState.update { it.copy(isLoading = false) }
return
}

val balanceAfterLspFee = availableAmount.safe() - initialLspFees.safe()
estimateFinalMaxSendAmount(availableAmount, balanceAfterLspFee)
}

/**
* Admits a `spending-hw-sign` deep link by producing the same quote the amount screen does, so the
* sign screen opens on live state instead of a route argument. Returns false when the link is
* refused, having logged why. Dev-mode only, gated by ScreenDeepLinks.isEnabled.
*/
suspend fun prepareSpendingHwSign(walletId: String, amountSats: Long): Boolean {
if (walletId.isBlank() || amountSats <= 0) return false

if (hwWalletRepo.wallets.value.none { it.id == walletId }) {
Logger.warn("Refused spending hw sign deeplink, unknown wallet '$walletId'", context = TAG)
return false
}

// An external link must never discard a signed-but-unbroadcast funding tx, or cancel a sign
// that is already running - the user would have to re-approve the spend on the device.
val isTransferInFlight = confirmPayJob?.isActive == true ||
hwTransferSignJob?.isActive == true ||
pendingHwFundingBroadcast != null
if (isTransferInFlight) {
Logger.warn("Refused spending hw sign deeplink, transfer in flight for '$walletId'", context = TAG)
return false
}

loadHwLimits(walletId)

if (!quoteSpendingAmount(amountSats)) {
Logger.warn("Refused spending hw sign deeplink, no quote for '$amountSats' sats", context = TAG)
return false
}

return true
}

/** Pays for the order by composing and signing the funding send on the Trezor, then watches it. */
Expand Down
52 changes: 52 additions & 0 deletions app/src/test/java/to/bitkit/ui/utils/ScreenDeepLinksTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,58 @@ class ScreenDeepLinksTest : BaseUnitTest() {
assertEquals("bitkit://screen/activity-assign-contact/{id}", links.single().uriPattern)
}

@Test
fun `SpendingHwSign required arguments are wallet and amount path segments`() {
if (!ScreenDeepLinks.isEnabled) return
val links = ScreenDeepLinks.linksFor(Routes.SpendingHwSign::class)

assertEquals(
"bitkit://screen/spending-hw-sign/{walletId}/{amountSats}",
links.single().uriPattern,
)
}

@Test
fun `spendingHwSignLink reads the wallet id and amount from the path`() {
if (!ScreenDeepLinks.isEnabled) return
val screenId = ScreenDeepLinks.kebabId(Routes.SpendingHwSign::class)
val uri = Uri.parse("bitkit://screen/$screenId/hardware-wallet/100000")

val link = ScreenDeepLinks.spendingHwSignLink(uri)

assertEquals(SpendingHwSignLink.Valid(walletId = "hardware-wallet", amountSats = 100_000L), link)
}

@Test
fun `spendingHwSignLink returns null when the uri is for another screen`() {
if (!ScreenDeepLinks.isEnabled) return

assertNull(ScreenDeepLinks.spendingHwSignLink(Uri.parse("bitkit://screen/settings")))
}

@Test
fun `spendingHwSignLink reports malformed arguments rather than falling through to nav`() {
// Falling through would navigate to a sign screen with no quote, which bounces the user home.
if (!ScreenDeepLinks.isEnabled) return

for (uri in listOf(
"bitkit://screen/spending-hw-sign/hardware-wallet",
"bitkit://screen/spending-hw-sign/hw/abc",
"bitkit://screen/spending-hw-sign/hw/0",
"bitkit://screen/spending-hw-sign/hw/-1",
)) {
assertEquals(SpendingHwSignLink.Malformed, ScreenDeepLinks.spendingHwSignLink(Uri.parse(uri)), uri)
}
}

@Test
fun `spendingHwSignLink returns null while screen deep links are disabled`() {
if (ScreenDeepLinks.isEnabled) return
val uri = Uri.parse("bitkit://screen/spending-hw-sign/hardware-wallet/100000")

assertNull(ScreenDeepLinks.spendingHwSignLink(uri))
}

@Test
fun `a route with both argument kinds keeps the required one in the path`() {
if (!ScreenDeepLinks.isEnabled) return
Expand Down
Loading
Loading