Skip to content

Commit 9a25b62

Browse files
committed
fix: harden public contact payments
1 parent de2cee7 commit 9a25b62

5 files changed

Lines changed: 70 additions & 5 deletions

File tree

app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,11 +276,13 @@ fun SendSheet(
276276
)
277277
},
278278
onPaymentPending = { paymentHash, amount ->
279+
appViewModel.preserveContactPaymentContext(paymentHash)
279280
navController.navigateTo(SendRoute.Pending(paymentHash, amount)) {
280281
popUpTo(startDestination) { inclusive = true }
281282
}
282283
},
283284
onShowError = { errorMessage ->
285+
appViewModel.clearActiveContactPaymentContext()
284286
navController.navigateTo(SendRoute.Error(errorMessage))
285287
}
286288
)

app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import kotlinx.coroutines.flow.SharingStarted
4646
import kotlinx.coroutines.flow.StateFlow
4747
import kotlinx.coroutines.flow.asSharedFlow
4848
import kotlinx.coroutines.flow.asStateFlow
49+
import kotlinx.coroutines.flow.collectLatest
4950
import kotlinx.coroutines.flow.debounce
5051
import kotlinx.coroutines.flow.distinctUntilChanged
5152
import kotlinx.coroutines.flow.first
@@ -153,6 +154,7 @@ import javax.inject.Inject
153154
import kotlin.coroutines.cancellation.CancellationException
154155
import kotlin.time.Duration
155156
import kotlin.time.Duration.Companion.milliseconds
157+
import kotlin.time.Duration.Companion.minutes
156158
import kotlin.time.Duration.Companion.seconds
157159
import kotlin.time.ExperimentalTime
158160

@@ -335,6 +337,7 @@ class AppViewModel @Inject constructor(
335337
}
336338
observeLdkNodeEvents()
337339
observePublicPaykitEndpoints()
340+
observePublicPaykitInvoiceExpiry()
338341
observeSendEvents()
339342
viewModelScope.launch {
340343
checkCriticalAppUpdate()
@@ -382,6 +385,22 @@ class AppViewModel @Inject constructor(
382385
}
383386
}
384387

388+
private fun observePublicPaykitInvoiceExpiry() {
389+
viewModelScope.launch {
390+
settingsStore.data
391+
.map { it.sharesPublicPaykitEndpoints to it.publicPaykitBolt11ExpiresAtMillis }
392+
.distinctUntilChanged()
393+
.collectLatest { (sharesPublicPaykitEndpoints, expiresAtMillis) ->
394+
if (!sharesPublicPaykitEndpoints || expiresAtMillis <= 0) return@collectLatest
395+
396+
val refreshAtMillis = expiresAtMillis - PUBLIC_PAYKIT_BOLT11_REFRESH_WINDOW.inWholeMilliseconds
397+
val delayMillis = (refreshAtMillis - System.currentTimeMillis()).coerceAtLeast(0)
398+
delay(delayMillis.milliseconds)
399+
refreshPublicPaykitEndpointsIfEnabled()
400+
}
401+
}
402+
}
403+
385404
fun refreshPublicPaykitEndpoints() {
386405
viewModelScope.launch { refreshPublicPaykitEndpointsIfEnabled() }
387406
}
@@ -670,6 +689,7 @@ class AppViewModel @Inject constructor(
670689
event.paymentHash?.let { paymentHash ->
671690
activityRepo.handlePaymentEvent(paymentHash)
672691
if (pendingPaymentRepo.isPending(paymentHash)) {
692+
clearPendingContactPaymentContext(paymentHash)
673693
pendingPaymentRepo.resolve(PendingPaymentResolution.Failure(paymentHash))
674694
if (_currentSheet.value !is Sheet.Send || !pendingPaymentRepo.isActive(paymentHash)) {
675695
notifyPendingPaymentFailed()
@@ -1437,12 +1457,18 @@ class AppViewModel @Inject constructor(
14371457
action()
14381458
}
14391459

1440-
private fun clearActiveContactPaymentContext() {
1460+
fun clearActiveContactPaymentContext() {
14411461
synchronized(contactPaymentContextLock) {
14421462
activeContactPaymentContext = null
14431463
}
14441464
}
14451465

1466+
private fun clearPendingContactPaymentContext(paymentHash: String) {
1467+
synchronized(contactPaymentContextLock) {
1468+
pendingContactPaymentContexts.remove(paymentHash)
1469+
}
1470+
}
1471+
14461472
@Suppress("LongMethod", "CyclomaticComplexMethod", "ReturnCount")
14471473
private suspend fun onScanOnchain(invoice: OnChainInvoice, scanResult: String) {
14481474
val validatedAddress = runCatching { validateBitcoinAddress(invoice.address) }
@@ -2722,6 +2748,7 @@ class AppViewModel @Inject constructor(
27222748
private const val AUTH_CHECK_SPLASH_DELAY_MS = 500L
27232749
private const val ADDRESS_VALIDATION_DEBOUNCE_MS = 1000L
27242750
private val PUBLIC_PAYKIT_SYNC_DEBOUNCE = 1.seconds
2751+
private val PUBLIC_PAYKIT_BOLT11_REFRESH_WINDOW = 30.minutes
27252752
private const val PUBKYAUTH_SCHEME = "pubkyauth"
27262753
}
27272754
}

app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import to.bitkit.usecases.FormatMoneyValue
5050
import to.bitkit.utils.timedsheets.TimedSheetManager
5151
import kotlin.test.assertEquals
5252
import kotlin.test.assertFalse
53+
import kotlin.test.assertNull
5354
import kotlin.test.assertTrue
5455

5556
@OptIn(ExperimentalCoroutinesApi::class)
@@ -288,6 +289,27 @@ class AppViewModelSendFlowTest : BaseUnitTest() {
288289
verify(activityRepo).setContact(contactPublicKey = contactKey, forPaymentId = paymentHash)
289290
}
290291

292+
@Test
293+
fun `pending contact lightning failure clears context`() = test {
294+
val paymentHash = "pending_hash"
295+
whenever(pendingPaymentRepo.isPending(paymentHash)).thenReturn(true)
296+
whenever(pendingPaymentRepo.isActive(paymentHash)).thenReturn(false)
297+
advanceUntilIdle()
298+
299+
setPendingContactPaymentContext(paymentHash, "pubkycontact")
300+
nodeEvents.emit(
301+
Event.PaymentFailed(
302+
paymentId = "payment_id",
303+
paymentHash = paymentHash,
304+
reason = null,
305+
),
306+
)
307+
advanceUntilIdle()
308+
309+
verify(pendingPaymentRepo).resolve(PendingPaymentResolution.Failure(paymentHash))
310+
assertNull(pendingContactPaymentContext(paymentHash))
311+
}
312+
291313
@Test
292314
fun `amount change clears confirmedWarnings`() = test {
293315
setUnifiedState(amount = 1000u)
@@ -349,6 +371,14 @@ class AppViewModelSendFlowTest : BaseUnitTest() {
349371
contexts[paymentHash] = ContactPaymentContext(publicKey)
350372
}
351373

374+
@Suppress("UNCHECKED_CAST")
375+
private fun pendingContactPaymentContext(paymentHash: String): ContactPaymentContext? {
376+
val field = AppViewModel::class.java.getDeclaredField("pendingContactPaymentContexts")
377+
field.isAccessible = true
378+
val contexts = field.get(sut) as MutableMap<String, ContactPaymentContext>
379+
return contexts[paymentHash]
380+
}
381+
352382
@Suppress("UNCHECKED_CAST")
353383
private fun setSendState(state: SendUiState) {
354384
val field = AppViewModel::class.java.getDeclaredField("_sendUiState")

scripts/collect-changelog.sh

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,15 +40,18 @@ if [[ "$target" != "next" && "$target" != "hotfix" ]]; then
4040
exit 1
4141
fi
4242

43-
python3 - "$target" <<'PY'
43+
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
44+
repo_root="$(cd "$script_dir/.." && pwd)"
45+
46+
python3 - "$target" "$repo_root" <<'PY'
4447
from __future__ import annotations
4548
4649
import re
4750
import sys
4851
from pathlib import Path
4952
5053
TARGET = sys.argv[1]
51-
ROOT = Path.cwd()
54+
ROOT = Path(sys.argv[2])
5255
CHANGELOG = ROOT / "CHANGELOG.md"
5356
FRAGMENT_DIR = ROOT / "changelog.d" / TARGET
5457

scripts/preview-changelog.sh

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,15 +40,18 @@ if [[ "$target" != "next" && "$target" != "hotfix" && "$target" != "all" ]]; the
4040
exit 1
4141
fi
4242

43-
python3 - "$target" <<'PY'
43+
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
44+
repo_root="$(cd "$script_dir/.." && pwd)"
45+
46+
python3 - "$target" "$repo_root" <<'PY'
4447
from __future__ import annotations
4548
4649
import re
4750
import sys
4851
from pathlib import Path
4952
5053
TARGET = sys.argv[1]
51-
ROOT = Path.cwd()
54+
ROOT = Path(sys.argv[2])
5255
CHANGELOG_DIR = ROOT / "changelog.d"
5356
5457
CATEGORY_LABELS = {

0 commit comments

Comments
 (0)