diff --git a/app/src/androidTest/java/to/bitkit/ui/components/DrawerMenuWidgetsTest.kt b/app/src/androidTest/java/to/bitkit/ui/components/DrawerMenuWidgetsTest.kt index 80b4c20c8e..1c9a3634de 100644 --- a/app/src/androidTest/java/to/bitkit/ui/components/DrawerMenuWidgetsTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/components/DrawerMenuWidgetsTest.kt @@ -21,6 +21,7 @@ import androidx.navigation.compose.rememberNavController import androidx.test.ext.junit.runners.AndroidJUnit4 import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.flow.flowOf import org.junit.Before import org.junit.Rule import org.junit.Test @@ -70,6 +71,7 @@ class DrawerMenuWidgetsTest { hasSeenShopIntro = true, onBeforeNavigate = {}, showWidgets = true, + profileIdentityExists = flowOf(false), ) } } @@ -102,6 +104,7 @@ class DrawerMenuWidgetsTest { hasSeenShopIntro = true, onBeforeNavigate = {}, showWidgets = true, + profileIdentityExists = flowOf(false), onOpenWidgetsHome = { openWidgetsHome.value = true }, ) if (openWidgetsHome.value) { @@ -138,6 +141,7 @@ class DrawerMenuWidgetsTest { hasSeenShopIntro = true, onBeforeNavigate = {}, showWidgets = false, + profileIdentityExists = flowOf(false), onOpenWidgetsHome = { error("Should not request home widgets page") }, onOpenWidgetsSheet = { openWidgetsSheet.value = true }, ) @@ -177,6 +181,7 @@ class DrawerMenuWidgetsTest { hasSeenShopIntro = true, onBeforeNavigate = {}, showWidgets = true, + profileIdentityExists = flowOf(true), isPaykitEnabled = true, ) } @@ -201,6 +206,7 @@ class DrawerMenuWidgetsTest { hasSeenShopIntro = true, onBeforeNavigate = {}, showWidgets = true, + profileIdentityExists = flowOf(true), isPaykitEnabled = false, ) } diff --git a/app/src/androidTest/java/to/bitkit/ui/components/SheetHostTest.kt b/app/src/androidTest/java/to/bitkit/ui/components/SheetHostTest.kt index be69ea85dc..bc8fb87289 100644 --- a/app/src/androidTest/java/to/bitkit/ui/components/SheetHostTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/components/SheetHostTest.kt @@ -88,6 +88,45 @@ class SheetHostTest { assertEquals(0, backgroundClickCount) } + @Test + fun dismissingSheetPresentsQueuedSheet() { + val sheet = mutableStateOf(Sheet.Receive()) + val request = Sheet.Send() + var dismissCount = 0 + var presentationCount = 0 + composeTestRule.setContent { + AppThemeSurface { + SheetHost( + shouldExpand = sheet.value != null, + visibilityKey = sheet.value, + onDismiss = { + dismissCount++ + sheet.value = null + sheet.value = request + }, + onVisible = { presentationCount++ }, + sheets = { + Box( + modifier = Modifier + .fillMaxWidth() + .height(320.dp) + .testTag(if (sheet.value === request) "PaymentRequestSheet" else "ReceiveSheet") + ) + }, + content = { Box(Modifier.fillMaxSize()) }, + ) + } + } + composeTestRule.onNodeWithTag("ReceiveSheet").assertIsDisplayed() + + pressBack() + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag("PaymentRequestSheet").assertIsDisplayed() + assertEquals(1, dismissCount) + assertEquals(2, presentationCount) + } + @Test fun programmaticHideDoesNotInvokeDismissalCallback() { val shouldExpand = mutableStateOf(true) diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreenTest.kt index 69456ee0c2..4e4f3cac9d 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreenTest.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput +import com.synonym.paykit.PaymentRequestLifecycleState import kotlinx.collections.immutable.persistentListOf import org.junit.Rule import org.junit.Test @@ -24,8 +25,10 @@ import to.bitkit.models.PubkyProfile import to.bitkit.models.USD_SYMBOL import to.bitkit.repositories.AmountInputHandler import to.bitkit.repositories.CurrencyState +import to.bitkit.repositories.PaykitBillingPeriod import to.bitkit.repositories.PaykitPaymentRequest import to.bitkit.repositories.PaykitPaymentRequestDeliveryStatus +import to.bitkit.repositories.PaykitPaymentRequestDirection import to.bitkit.repositories.PaykitPaymentRequestDraft import to.bitkit.repositories.PaykitPaymentRequestTarget import to.bitkit.test.annotations.ComposeUi @@ -130,11 +133,15 @@ class CreatePaymentRequestScreenTest { @Test fun sentShowsSuccessSurface() { val contact = PubkyProfile.forDisplay(target.publicKey, "Anna", imageUrl = null) - var deliveryStatus by mutableStateOf(PaykitPaymentRequestDeliveryStatus.Queued) + val createdRequest = request.copy(note = null, deliveryStatus = PaykitPaymentRequestDeliveryStatus.Queued) + val sentRequest = createdRequest.copy(deliveryStatus = PaykitPaymentRequestDeliveryStatus.Sent) + val proofRequest = sentRequest.copy(lifecycleState = PaymentRequestLifecycleState.PROOF_SUBMITTED) + var history by mutableStateOf(persistentListOf()) composeTestRule.setContent { AppThemeSurface { PaymentRequestSentContent( - request = request.copy(deliveryStatus = deliveryStatus), + request = createdRequest, + history = history, contact = contact, onDone = {}, ) @@ -145,14 +152,65 @@ class CreatePaymentRequestScreenTest { composeTestRule.onNodeWithTag("PaymentRequestSentCheck").assertIsDisplayed() composeTestRule.onNodeWithText("PAYMENT REQUESTED").assertIsDisplayed() composeTestRule.onNodeWithText("Anna").assertIsDisplayed() - composeTestRule.onNodeWithText("Dinner").assertIsDisplayed() + composeTestRule.onNodeWithText("Queued for delivery").assertIsDisplayed() composeTestRule.onNodeWithText("Your payment request is queued and will send automatically").assertIsDisplayed() composeTestRule.onNodeWithText("You have sent a payment request").assertDoesNotExist() - composeTestRule.runOnIdle { deliveryStatus = PaykitPaymentRequestDeliveryStatus.Sent } + composeTestRule.runOnIdle { history = persistentListOf(sentRequest) } composeTestRule.onNodeWithText("You have sent a payment request").assertIsDisplayed() composeTestRule.onNodeWithText("Your payment request is queued and will send automatically").assertDoesNotExist() + composeTestRule.onNodeWithText("Waiting for payment").assertIsDisplayed() + + composeTestRule.runOnIdle { history = persistentListOf(proofRequest) } + + composeTestRule.onNodeWithText("Proof submitted").assertIsDisplayed() + composeTestRule.onNodeWithText("Waiting for payment").assertDoesNotExist() + composeTestRule.onNodeWithText("PAYMENT REQUESTED").assertIsDisplayed() + + composeTestRule.runOnIdle { history = persistentListOf(proofRequest.copy(note = "Dinner")) } + + composeTestRule.onNodeWithText("Dinner").assertIsDisplayed() + composeTestRule.onNodeWithText("Proof submitted").assertDoesNotExist() + + composeTestRule.runOnIdle { history = persistentListOf() } + + composeTestRule.onNodeWithText("Queued for delivery").assertIsDisplayed() + composeTestRule.onNodeWithText("Dinner").assertDoesNotExist() + } + + @Test + fun sentIgnoresHistoryForOtherRequests() { + val createdRequest = request.copy(note = "", deliveryStatus = PaykitPaymentRequestDeliveryStatus.Queued) + val proofRequest = createdRequest.copy(lifecycleState = PaymentRequestLifecycleState.PROOF_SUBMITTED) + var history by mutableStateOf(persistentListOf()) + composeTestRule.setContent { + AppThemeSurface { + PaymentRequestSentContent( + request = createdRequest, + history = history, + contact = null, + onDone = {}, + ) + } + } + + listOf( + proofRequest.copy(paymentRequestId = "another-request"), + proofRequest.copy(counterparty = "another-counterparty"), + proofRequest.copy(direction = PaykitPaymentRequestDirection.Incoming), + proofRequest.copy( + billingPeriod = PaykitBillingPeriod( + startsAt = Instant.parse("2027-01-15T08:00:00Z"), + endsAt = Instant.parse("2027-02-15T08:00:00Z"), + ), + ), + ).forEach { unrelatedRequest -> + composeTestRule.runOnIdle { history = persistentListOf(unrelatedRequest) } + + composeTestRule.onNodeWithText("Queued for delivery").assertIsDisplayed() + composeTestRule.onNodeWithText("Proof submitted").assertDoesNotExist() + } } private val draft = PaykitPaymentRequestDraft( @@ -163,18 +221,17 @@ class CreatePaymentRequestScreenTest { private val target = PaykitPaymentRequestTarget( publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", - receiverPath = "bitkit/wallet", ) private val request = PaykitPaymentRequest( paymentRequestId = "payment-request", counterparty = target.publicKey, - counterpartyReceiverPath = target.receiverPath, amountValue = "0.00025", amountSats = draft.amountSats, note = draft.note, createdAt = Instant.parse("2027-01-15T08:00:00Z"), - expiresAt = draft.expiresAt, + expiresAt = Instant.DISTANT_FUTURE, acceptedPaymentEndpointIdentifiers = listOf("btc-lightning-bolt11"), + direction = PaykitPaymentRequestDirection.Outgoing, ) } diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt index de6f261ce4..4c7808fe97 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt @@ -4,8 +4,10 @@ package to.bitkit.ui.screens.paymentrequests import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertTextContains import androidx.compose.ui.test.assertTextEquals @@ -40,6 +42,31 @@ class PaymentRequestsScreenTest { @get:Rule val composeTestRule = createComposeRule() + @Test + fun detailsDisablePayWhilePreparationIsPending() { + val isPreparing = mutableStateOf(false) + val request = request() + composeTestRule.setContent { + PaymentRequestsTestSurface { + IncomingPaymentRequestDetailsContent( + request = request, + contact = PubkyProfile.placeholder(request.counterparty), + isPending = true, + isPreparing = isPreparing.value, + onBack = {}, + onPay = {}, + onDismiss = { Result.success(Unit) }, + ) + } + } + + composeTestRule.onNodeWithTag("PaymentRequestDetailsPay").assertIsEnabled() + composeTestRule.runOnIdle { isPreparing.value = true } + composeTestRule.onNodeWithTag("PaymentRequestDetailsPay").assertIsNotEnabled() + composeTestRule.runOnIdle { isPreparing.value = false } + composeTestRule.onNodeWithTag("PaymentRequestDetailsPay").assertIsEnabled() + } + @Test fun queueShowsIncomingRequestAndSeeAllAction() { val request = request(id = "incoming") @@ -289,7 +316,6 @@ class PaymentRequestsScreenTest { private fun request(id: String = "request") = PaykitPaymentRequest( paymentRequestId = id, counterparty = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", - counterpartyReceiverPath = "bitkit/wallet", amountValue = "0.00025", amountSats = 25_000uL, note = "Dinner", @@ -301,7 +327,6 @@ class PaymentRequestsScreenTest { private fun subscription(note: String) = PaykitSubscription( paymentRequestId = "subscription", counterparty = request().counterparty, - counterpartyReceiverPath = "bitkit/wallet", amountValue = "0.00025", amountSats = 25_000uL, note = note, diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreenTest.kt index 1cd6a6efaf..cf2aa3c9d5 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreenTest.kt @@ -93,7 +93,7 @@ class CreateSubscriptionScreenTest { @Test fun recipientAllowsExactlyOneSelectionAndChangesExpiry() { - val second = PaykitPaymentRequestTarget("pubky" + "z".repeat(52), "bitkit/wallet") + val second = PaykitPaymentRequestTarget("pubky" + "z".repeat(52)) var selected by mutableStateOf(null) var expiration by mutableStateOf(PaymentRequestExpiration.Week) var proposedTo: PaykitPaymentRequestTarget? = null @@ -158,13 +158,12 @@ class CreateSubscriptionScreenTest { composeTestRule.onNodeWithText("OK").assertIsDisplayed() } - private val target = PaykitPaymentRequestTarget("pubky" + "y".repeat(52), "bitkit/wallet") + private val target = PaykitPaymentRequestTarget("pubky" + "y".repeat(52)) private val contact = PubkyProfile.forDisplay(target.publicKey, "Anna", null) private val startsAt = Instant.parse("2027-01-15T08:00:00Z") private val subscription = PaykitSubscription( paymentRequestId = "creator-proposal", counterparty = target.publicKey, - counterpartyReceiverPath = target.receiverPath, amountValue = "0.00001", amountSats = 1000uL, note = "Support", diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreenTest.kt index 9b33631f3d..f15e5372bd 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreenTest.kt @@ -1,12 +1,22 @@ +@file:OptIn(ExperimentalTime::class) + package to.bitkit.ui.screens.wallets.send import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SheetState +import androidx.compose.material3.SheetValue +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.getUnclippedBoundsInRoot import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithTag @@ -14,26 +24,136 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeRight import androidx.compose.ui.test.swipeUp import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp -import kotlin.test.assertEquals -import kotlin.test.assertTrue +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Before import org.junit.Rule import org.junit.Test import to.bitkit.models.FeeRate import to.bitkit.models.PubkyProfile +import to.bitkit.repositories.PaykitPaymentRequest import to.bitkit.test.annotations.ComposeUi +import to.bitkit.ui.components.Sheet +import to.bitkit.ui.components.SheetHost +import to.bitkit.ui.shared.modifiers.sheetHeight +import to.bitkit.ui.sheets.SendRoute import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.viewmodels.OnchainFeeUi import to.bitkit.viewmodels.SendMethod import to.bitkit.viewmodels.SendUiState +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.ExperimentalTime +@HiltAndroidTest @ComposeUi +@OptIn(ExperimentalMaterial3Api::class) class SendConfirmScreenTest { + @get:Rule + val hiltRule = HiltAndroidRule(this) + @get:Rule val composeTestRule = createComposeRule() + @Before + fun setup() { + hiltRule.inject() + } + + @Test + fun preparingRequestShowsSavedMetadataUntilConfirmationIsReady() { + val request = PaykitPaymentRequest( + paymentRequestId = "preparing", + counterparty = "requester", + amountValue = "5000", + amountSats = 5_000u, + note = "Dinner", + expiresAt = null, + acceptedPaymentEndpointIdentifiers = listOf("bitcoin"), + ) + val contact = PubkyProfile.placeholder(request.counterparty).copy(name = "Coffee House") + val preparation = mutableStateOf(request) + val state = mutableStateOf( + SendUiState( + amount = 99_000u, + isAmountInputValid = true, + payMethod = SendMethod.LIGHTNING, + isInitialSubscriptionPayment = true, + initialSubscriptionPaymentAutoStartPending = true, + paymentRequestNote = "Stale note", + ), + ) + var paymentAttempts = 0 + var dismissCount = 0 + var visibleCount = 0 + lateinit var sheetState: SheetState + composeTestRule.setContent { + AppThemeSurface { + CompositionLocalProvider(LocalInspectionMode provides true) { + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + SheetHost( + shouldExpand = true, + visibilityKey = Sheet.Send(SendRoute.Confirm, preparingRequest = preparation.value), + onVisible = { visibleCount++ }, + onDismiss = { dismissCount++ }, + sheetState = sheetState, + sheets = { + SendConfirmContent( + uiState = state.value, + isNodeRunning = preparation.value == null, + isLoading = false, + showBiometrics = false, + preparingRequest = preparation.value, + preparingContact = contact, + onSwipeToConfirm = { paymentAttempts++ }, + modifier = Modifier.sheetHeight() + ) + }, + content = { Box(Modifier.fillMaxSize()) }, + ) + } + } + } + + composeTestRule.onNodeWithTag("PaymentRequestConfirm").assertIsDisplayed() + composeTestRule.onNodeWithTag("PaymentRequestFrom").assertTextEquals("Coffee House") + composeTestRule.onNodeWithTag("PaymentRequestFor").assertTextEquals("Dinner") + composeTestRule.onNodeWithTag("PaymentRequestPreparing").assertIsDisplayed().assertIsNotEnabled() + composeTestRule.onNodeWithTag("SendConfirmToggleDetails").assertDoesNotExist() + composeTestRule.onNodeWithText("Stale note").assertDoesNotExist() + composeTestRule.onNodeWithTag("PaymentRequestPreparing").performTouchInput { swipeRight() } + composeTestRule.runOnIdle { + assertEquals(0, paymentAttempts) + assertEquals(SheetValue.Expanded, sheetState.currentValue) + assertEquals(1, visibleCount) + state.value = SendUiState( + amount = request.amountSats, + isPaymentRequest = true, + isAmountInputValid = true, + contactPaymentProfile = contact, + paymentRequestNote = request.note, + incomingPaymentRequestId = request.id, + ) + preparation.value = null + } + + composeTestRule.onNodeWithTag("PaymentRequestPreparing").assertDoesNotExist() + composeTestRule.onNodeWithTag("PaymentRequestConfirm").assertIsDisplayed() + composeTestRule.onNodeWithTag("PaymentRequestFrom").assertTextEquals("Coffee House") + composeTestRule.onNodeWithTag("PaymentRequestFor").assertTextEquals("Dinner") + composeTestRule.onNodeWithTag("GRAB").assertIsDisplayed() + composeTestRule.runOnIdle { + assertEquals(SheetValue.Expanded, sheetState.currentValue) + assertEquals(SheetValue.Expanded, sheetState.targetValue) + assertEquals(2, visibleCount) + assertEquals(0, dismissCount) + } + } + @Test fun initialOnchainSubscriptionShowsFeeBeforeConfirmation() { val state = SendUiState( diff --git a/app/src/main/java/to/bitkit/data/CacheStore.kt b/app/src/main/java/to/bitkit/data/CacheStore.kt index 67e0becf8f..1fb975cf4b 100644 --- a/app/src/main/java/to/bitkit/data/CacheStore.kt +++ b/app/src/main/java/to/bitkit/data/CacheStore.kt @@ -59,6 +59,14 @@ class CacheStore internal constructor( store.updateData { it.copy(onchainAddress = address) } } + suspend fun setActivityContactDetached(activityId: String, walletId: String, detached: Boolean) { + val id = scopedActivityId(walletId, activityId) + store.updateData { + val contacts = it.detachedActivityContacts + it.copy(detachedActivityContacts = if (detached) contacts + id else contacts - id) + } + } + suspend fun saveBolt11(bolt11: String, paymentHash: String) { store.updateData { it.copy(bolt11 = bolt11, bolt11PaymentHash = paymentHash) } } @@ -171,6 +179,7 @@ data class AppCacheData( val balance: BalanceState? = null, val backupStatuses: Map = mapOf(), val deletedActivities: List = listOf(), + val detachedActivityContacts: Set = emptySet(), val pendingBoostActivities: List = listOf(), val backgroundReceive: NewTransactionSheetDetails? = null, val addressSearchLastUsedReceiveIndexes: Map = mapOf(), @@ -180,6 +189,9 @@ data class AppCacheData( /** LNURL-pay comments by payment hash, kept until the sent payment's activity stores them. */ val pendingLightningMessages: Map = mapOf(), ) { + fun isContactDetached(activityId: String, walletId: String): Boolean = + scopedActivityId(walletId, activityId) in detachedActivityContacts + fun isActivityDeleted(activityId: String, walletId: String): Boolean = scopedActivityId(walletId, activityId) in deletedActivities || walletId == WalletScope.default && activityId in deletedActivities diff --git a/app/src/main/java/to/bitkit/data/PrivatePaykitStores.kt b/app/src/main/java/to/bitkit/data/PrivatePaykitStores.kt index 3519b2186d..8aabaa02bc 100644 --- a/app/src/main/java/to/bitkit/data/PrivatePaykitStores.kt +++ b/app/src/main/java/to/bitkit/data/PrivatePaykitStores.kt @@ -68,10 +68,10 @@ data class PrivatePaykitCacheData( @Serializable data class PrivatePaykitContactCacheData( val remoteEndpoints: List = emptyList(), - val consumedPrivatePaymentListVersionsByReceiverPath: Map = emptyMap(), - val localInvoicesByReceiverPath: Map = emptyMap(), + val consumedPrivatePaymentListVersion: ULong? = null, + val localInvoice: PrivatePaykitStoredInvoiceData? = null, val receivedInvoicePaymentHashes: List = emptyList(), - val publishedPrivatePaymentReceiverPaths: Set = emptySet(), + val hasPublishedPrivatePaymentList: Boolean = false, ) @Serializable diff --git a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt index 2a41579d54..1a8df74c5d 100644 --- a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt +++ b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt @@ -232,10 +232,11 @@ class Keychain @Inject constructor( PIN, PIN_ATTEMPTS_REMAINING, PAYKIT_SESSION, - PAYKIT_RECEIVER_NOISE_SECRET_KEY, - PAYKIT_SDK_STATE, + PAYKIT_KEY_GENERATION, + PAYKIT_RECOVERY_BACKUP, PAYKIT_PENDING_BACKUP_RESTORE, PAYKIT_PENDING_PAYMENT_PROOFS, + PAYKIT_ACCEPTED_PAYMENT_REQUESTS, PAYKIT_PRESENTED_PAYMENT_REQUESTS, PUBKY_SECRET_KEY, SHARED_PUBKY_SOURCE, diff --git a/app/src/main/java/to/bitkit/di/EnvModule.kt b/app/src/main/java/to/bitkit/di/EnvModule.kt index 08578a1a91..f02d168da0 100644 --- a/app/src/main/java/to/bitkit/di/EnvModule.kt +++ b/app/src/main/java/to/bitkit/di/EnvModule.kt @@ -13,6 +13,7 @@ import java.util.Locale import javax.inject.Qualifier import kotlin.time.Clock import kotlin.time.ExperimentalTime +import kotlin.time.TimeSource @Module @InstallIn(SingletonComponent::class) @@ -25,6 +26,9 @@ object EnvModule { @Provides fun provideClock(): Clock = Clock.System + @Provides + fun provideTimeSource(): TimeSource = TimeSource.Monotonic + @Provides @SubscriptionClock fun provideSubscriptionClock(clock: Clock): Clock = SubscriptionClockOffset.subscriptionClock(clock) diff --git a/app/src/main/java/to/bitkit/ext/BroadcastExceptionExt.kt b/app/src/main/java/to/bitkit/ext/BroadcastExceptionExt.kt index f1ae748993..192354dc73 100644 --- a/app/src/main/java/to/bitkit/ext/BroadcastExceptionExt.kt +++ b/app/src/main/java/to/bitkit/ext/BroadcastExceptionExt.kt @@ -7,3 +7,7 @@ fun Throwable.isBroadcastConnectivityFailure(): Boolean = generateSequence(this) { it.cause }.any { it is TimeoutCancellationException || it is BroadcastException.ElectrumException } + +fun Throwable.isDefiniteHardwarePreBroadcastFailure(): Boolean = generateSequence(this) { it.cause }.any { + it is BroadcastException.InvalidHex || it is BroadcastException.InvalidTransaction +} diff --git a/app/src/main/java/to/bitkit/ext/PaykitExceptionExt.kt b/app/src/main/java/to/bitkit/ext/PaykitExceptionExt.kt index 2bbc009f3f..42133dc7c5 100644 --- a/app/src/main/java/to/bitkit/ext/PaykitExceptionExt.kt +++ b/app/src/main/java/to/bitkit/ext/PaykitExceptionExt.kt @@ -7,3 +7,9 @@ fun Throwable.isPaykitIdentityError(): Boolean = fun Throwable.isPaykitRecoveryRequired(): Boolean = generateSequence(this) { it.cause }.any { it is PaykitException.RecoveryRequired } + +fun Throwable.isPaykitTemporarilyUnavailable(): Boolean = + generateSequence(this) { it.cause }.any { + it is PaykitException.ConcurrentUpdate || it is PaykitException.SharedStateBusy || + it is PaykitException.Transport + } diff --git a/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt b/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt index f5301f8419..205a881e1f 100644 --- a/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt +++ b/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt @@ -16,6 +16,7 @@ import kotlin.time.Instant data class PaykitPaymentStateBackup( val subscriptions: Map, val pendingProofs: List, + val acceptedOneTimeRequests: Map>? = null, ) { @Serializable data class Subscription( @@ -41,6 +42,7 @@ data class PaykitPaymentStateBackup( val identity: String, val requestId: PaykitPaymentRequestId, val paymentEndpointIdentifier: String, + val paymentAppId: String, val kind: String, val paymentStarted: Boolean, val paymentIdentifier: String? = null, @@ -55,6 +57,7 @@ data class PaykitPaymentStateBackup( identity = proof.identity, requestId = proof.requestId, paymentEndpointIdentifier = proof.paymentEndpointIdentifier, + paymentAppId = proof.paymentAppId, kind = proof.kind.type, paymentStarted = proof.paymentStarted, paymentIdentifier = proof.paymentIdentifier, @@ -70,6 +73,7 @@ data class PaykitPaymentStateBackup( identity = identity, requestId = requestId.copy(billingPeriodStartsAt = billingPeriod?.startsAt?.toString()), paymentEndpointIdentifier = paymentEndpointIdentifier, + paymentAppId = paymentAppId, kind = requireNotNull(PaykitPaymentProofKind.entries.find { it.type == kind }), paymentStarted = paymentStarted, paymentIdentifier = paymentIdentifier, diff --git a/app/src/main/java/to/bitkit/models/PubkyAuthClaimCodec.kt b/app/src/main/java/to/bitkit/models/PubkyAuthClaimCodec.kt new file mode 100644 index 0000000000..7fe7b28ab3 --- /dev/null +++ b/app/src/main/java/to/bitkit/models/PubkyAuthClaimCodec.kt @@ -0,0 +1,47 @@ +package to.bitkit.models + +import java.nio.ByteBuffer + +object PubkyAuthClaimCodec { + /** Size of the versioned account metadata and serialized extended public key. */ + const val WATCH_ONLY_PAYLOAD_LENGTH = 84 + + /** Size of the version, generation, and Paykit identity secret. */ + const val PAYKIT_PAYLOAD_LENGTH = 41 + + /** Size of account metadata followed by the generation and Paykit identity secret. */ + const val COMBINED_PAYLOAD_LENGTH = 124 + + fun validateAccountPayload(claim: PubkyAuthClaim, accountPayload: ByteArray) { + if (!claim.includesWatchOnlyAccount) { + require(accountPayload.isEmpty()) { "Paykit-only approval cannot include an account" } + return + } + require(accountPayload.size == WATCH_ONLY_PAYLOAD_LENGTH) { "Invalid watch-only payload length" } + require(accountPayload[0] == 1.toByte() && accountPayload[5] == 0.toByte()) { + "Unsupported watch-only payload version or address type" + } + } + + fun encode( + claim: PubkyAuthClaim, + accountPayload: ByteArray, + generation: ULong? = null, + secret: ByteArray? = null, + ): ByteArray { + validateAccountPayload(claim, accountPayload) + if (!claim.includesPaykitAccess) { + require(generation == null && secret == null) { "Watch-only approval cannot include a Paykit key" } + return accountPayload.copyOf() + } + require(generation != null && generation > 0uL) { "Invalid Paykit key generation" } + require(secret?.size == 32) { "Invalid Paykit identity secret length" } + val prefix = if (claim.includesWatchOnlyAccount) accountPayload else byteArrayOf(1) + val length = if (claim.includesWatchOnlyAccount) COMBINED_PAYLOAD_LENGTH else PAYKIT_PAYLOAD_LENGTH + return ByteBuffer.allocate(length) + .put(prefix) + .putLong(generation.toLong()) + .put(secret) + .array() + } +} diff --git a/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt b/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt index 14e1f70d2c..05a6673a2d 100644 --- a/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt +++ b/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt @@ -1,39 +1,51 @@ package to.bitkit.models import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList import to.bitkit.utils.AppError import java.net.URI import java.net.URLDecoder import java.net.URLEncoder import java.nio.charset.StandardCharsets -enum class PubkyAuthClaim(val wireValue: String) { - WATCH_ONLY_ACCOUNT_V1("watch-only-account-v1"), - ; +@Immutable +@JvmInline +value class PubkyAuthClaim private constructor(val items: ImmutableList) { + constructor(vararg items: Item) : this(items.sortedBy { it.ordinal }.toImmutableList()) + + init { + require(items.isNotEmpty() && items.distinct().size == items.size) + } + + enum class Item(val wireValue: String) { + PAYKIT_ACCESS_V1("paykit-access-v1"), + WATCH_ONLY_ACCOUNT_V1("watch-only-account-v1"), + } + + val wireValue: String get() = items.joinToString(".") { it.wireValue } + val includesWatchOnlyAccount: Boolean get() = Item.WATCH_ONLY_ACCOUNT_V1 in items + val includesPaykitAccess: Boolean get() = Item.PAYKIT_ACCESS_V1 in items companion object { /** Query parameter used for Bitkit-specific Pubky auth claims. */ const val QUERY_PARAMETER = "x-bitkit-claim" - /** Both public and private Paykit Server capabilities required by the watch-only setup flow. */ - const val WATCH_ONLY_ACCOUNT_CAPABILITIES = - "/pub/paykit/v0/bitkit/server/:rw,/pub/paykit/v0/private/bitkit/server/:rw" - - private val watchOnlyAccountCapabilitySet = WATCH_ONLY_ACCOUNT_CAPABILITIES.split(",").toSet() + /** Exact Pubky storage scope required by Bitkit companion claims. */ + const val REQUIRED_CAPABILITIES = "/pub/paykit/:rw" - /** - * Matches exactly the required public and private capabilities regardless of ordering or surrounding spaces. - */ - fun matchesWatchOnlyAccountCapabilities(capabilities: String) = - capabilitySet(capabilities) == watchOnlyAccountCapabilitySet + /** Matches the required storage scope, allowing surrounding whitespace but no duplicate capabilities. */ + fun matchesRequiredCapabilities(capabilities: String) = + capabilities.trim() == REQUIRED_CAPABILITIES - private fun capabilitySet(capabilities: String): Set? { - val entries = capabilities.split(",").map { it.trim() } - if (entries.any { it.isEmpty() }) return null - return entries.toSet() + /** Preserves the received item order because the SDK signs and routes using this exact string. */ + fun fromWireValue(value: String): PubkyAuthClaim? { + val items = value.split(".").map { token -> + Item.entries.firstOrNull { it.wireValue == token } ?: return null + } + if (items.distinct().size != items.size) return null + return PubkyAuthClaim(items.toImmutableList()) } - - fun fromWireValue(value: String) = entries.firstOrNull { it.wireValue == value } } } @@ -189,8 +201,6 @@ data class PubkyAuthRequest( capabilities: String, ): Result = when { claimValues.size > 1 -> Result.failure(PubkyAuthRequestError.DuplicateBitkitClaim) - claimValues.isEmpty() && PubkyAuthClaim.matchesWatchOnlyAccountCapabilities(capabilities) -> - Result.failure(PubkyAuthRequestError.MissingBitkitClaim) claimValues.isEmpty() -> Result.success(null) else -> validateBitkitClaimValue(claimValues.first(), capabilities) } @@ -202,7 +212,7 @@ data class PubkyAuthRequest( val claim = PubkyAuthClaim.fromWireValue(claimValue) ?: return Result.failure(PubkyAuthRequestError.UnsupportedBitkitClaim(claimValue)) - return if (PubkyAuthClaim.matchesWatchOnlyAccountCapabilities(capabilities)) { + return if (PubkyAuthClaim.matchesRequiredCapabilities(capabilities)) { Result.success(claim) } else { Result.failure(PubkyAuthRequestError.InvalidBitkitClaimCapabilities) diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 4119de267f..af0023109b 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -105,6 +105,12 @@ class ActivityRepo @Inject constructor( Logger.debug("Activity state reset", context = TAG) } + suspend fun backfillPaykitContacts(): Result = withContext(bgDispatcher) { + runSuspendCatching { + if (coreService.activity.backfillPaykitContacts()) notifyActivitiesChanged() + }.onFailure { Logger.warn("Failed to backfill Paykit activity contacts", it, context = TAG) } + } + suspend fun syncActivities(): Result = withContext(bgDispatcher) { Logger.debug("syncActivities called", context = TAG) @@ -502,6 +508,7 @@ class ActivityRepo @Inject constructor( return@runCatching } + cacheStore.setActivityContactDetached(activity.rawId(), walletId, detached = false) val updatedAt = nowTimestamp().epochSecond.toULong() val updatedActivity = activity.withContact(normalizedKey, updatedAt) updateActivity(updatedActivity.rawId(), updatedActivity).getOrThrow() @@ -533,6 +540,7 @@ class ActivityRepo @Inject constructor( } if (activity.contact() == null) return@runCatching + cacheStore.setActivityContactDetached(activity.rawId(), walletId, detached = true) val updatedAt = nowTimestamp().epochSecond.toULong() val updatedActivity = activity.withContact(null, updatedAt) updateActivity(updatedActivity.rawId(), updatedActivity).getOrThrow() diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index a45f162c62..f759ce09fc 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -5,6 +5,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.currentCoroutineContext @@ -431,6 +432,7 @@ class BackupRepo @Inject constructor( } delay(BACKUP_DEBOUNCE) + if (category == BackupCategory.WALLET) paykitSdkService.isPaymentSubmissionActive.first { !it } val status = cacheStore.backupStatuses.first()[category] ?: BackupItemStatus() if (status.isRequired && !shouldSkipBackup()) { @@ -500,41 +502,53 @@ class BackupRepo @Inject constructor( val backupRequired = currentTimeMillis() runningBackups += category failedBackupRequired -= category - cacheStore.updateBackupStatus(category) { - it.copy(running = true, required = backupRequired) - } - - val data = runSuspendCatching { getBackupDataBytes(category) } - .getOrElse { - markBackupFailed(category, backupRequired, it) - return@withContext Result.failure(it) + try { + cacheStore.updateBackupStatus(category) { + it.copy(running = true, required = backupRequired) } - vssBackupClient.putObject(key = category.name, data = data) - .onSuccess { - runningBackups -= category - failedBackupRequired -= category - cacheStore.updateBackupStatus(category) { - it.copy( - running = false, - synced = currentTimeMillis(), - ) + val data = runSuspendCatching { getBackupDataBytes(category) } + .getOrElse { + markBackupFailed(category, backupRequired, it) + return@withContext Result.failure(it) + } + + vssBackupClient.putObject(key = category.name, data = data) + .onSuccess { + withContext(NonCancellable) { + cacheStore.updateBackupStatus(category) { + it.copy( + running = false, + synced = currentTimeMillis(), + ) + } + runningBackups -= category + failedBackupRequired -= category + } + Logger.info("Backup succeeded for: '$category'", context = TAG) + } + .onFailure { markBackupFailed(category, backupRequired, it) } + .map {} + } finally { + if (runningBackups.remove(category)) { + withContext(NonCancellable) { + cacheStore.updateBackupStatus(category) { it.copy(running = false) } } - Logger.info("Backup succeeded for: '$category'", context = TAG) } - .onFailure { markBackupFailed(category, backupRequired, it) } - .map {} + } } private suspend fun markBackupFailed(category: BackupCategory, backupRequired: Long, e: Throwable) { - runningBackups -= category - cacheStore.updateBackupStatus(category) { - if (it.required == backupRequired) { - failedBackupRequired[category] = backupRequired - } else { - failedBackupRequired -= category + withContext(NonCancellable) { + cacheStore.updateBackupStatus(category) { + if (it.required == backupRequired) { + failedBackupRequired[category] = backupRequired + } else { + failedBackupRequired -= category + } + it.copy(running = false) } - it.copy(running = false) + runningBackups -= category } Logger.error("Backup failed for: '$category'", e, context = TAG) } @@ -641,6 +655,7 @@ class BackupRepo @Inject constructor( paykitPaymentState = PaykitPaymentStateBackup( subscriptions = paykitPresentationStore.backupSnapshot(), pendingProofs = paykitPaymentProofRepo.get().backupSnapshot(), + acceptedOneTimeRequests = paykitPresentationStore.acceptedOneTimeBackupSnapshot(), ), ) @@ -781,6 +796,7 @@ class BackupRepo @Inject constructor( paykitPaymentRequestRepo.get().clear() paykitPresentationStore.restoreBackup(it.subscriptions) paykitPaymentProofRepo.get().restoreBackup(it.pendingProofs) + paykitPresentationStore.restoreAcceptedOneTimeRequests(it.acceptedOneTimeRequests.orEmpty()) } db.transferDao().upsert(parsed.transfers) watchOnlyAccountRepo.restore( diff --git a/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt b/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt index ec02d0eded..9603d6ca08 100644 --- a/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt @@ -4,12 +4,17 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.areContactPaymentsEnabled +import to.bitkit.data.hasPublicPaykitPublicationState +import to.bitkit.data.paykitDisabled import to.bitkit.di.IoDispatcher import to.bitkit.ext.runSuspendCatching +import to.bitkit.services.PaykitSdkOperationLock.Priority import javax.inject.Inject import javax.inject.Singleton @@ -21,11 +26,52 @@ class ContactPaymentSettingsRepo @Inject constructor( private val pubkyRepo: PubkyRepo, @IoDispatcher private val ioDispatcher: CoroutineDispatcher, ) { + private val sharingMutex = Mutex() + val isEnabled: Flow = settingsStore.data.map { it.areContactPaymentsEnabled() } suspend fun setEnabled(isEnabled: Boolean): Result = withContext(ioDispatcher) { - val contacts = pubkyRepo.contacts.value.map { it.publicKey } - if (isEnabled) enable(contacts) else disable(contacts) + sharingMutex.withLock { + val contacts = pubkyRepo.contacts.value.map { it.publicKey } + if (isEnabled) enable(contacts) else disable(contacts) + } + } + + suspend fun reconcilePendingEndpoints(reconcile: suspend () -> Unit) = withContext(ioDispatcher) { + if (!sharingMutex.tryLock()) return@withContext + try { + reconcile() + } finally { + sharingMutex.unlock() + } + } + + suspend fun disablePaykit(): Result = withContext(ioDispatcher) { + sharingMutex.withLock { + runSuspendCatching { + val previous = settingsStore.data.first() + val hadPublicState = previous.hasPublicPaykitPublicationState() + settingsStore.update { it.paykitDisabled(markPublicCleanupPending = hadPublicState) } + val contacts = pubkyRepo.contacts.value.map { it.publicKey } + val privateCleanup = privatePaykitRepo.disableSharingAndPruneUnsavedContactState(contacts) + val publicCleanup = when { + hadPublicState -> publicPaykitRepo.syncPublishedEndpoints( + publish = false, + appSyncPriority = Priority.Interactive, + ) + previous.sharesPrivatePaykitEndpoints -> publicPaykitRepo.syncPaykitApp( + privateSharingEnabled = false, + ) + else -> Result.success(Unit) + } + settingsStore.update { it.copy(publicPaykitCleanupPending = publicCleanup.isFailure) } + publicCleanup.exceptionOrNull()?.let { error -> + privateCleanup.exceptionOrNull()?.let(error::addSuppressed) + throw error + } + privateCleanup.getOrThrow() + } + } } private suspend fun enable(contacts: List): Result { @@ -67,7 +113,14 @@ class ContactPaymentSettingsRepo @Inject constructor( ) } }.onFailure(error::addSuppressed) - publicPaykitRepo.syncPublishedEndpoints(publish = previous.sharesPublicPaykitEndpoints) + if (!previous.sharesPrivatePaykitEndpoints) { + privatePaykitRepo.disableSharingAndPruneUnsavedContactState(contacts) + .onFailure(error::addSuppressed) + } + publicPaykitRepo.syncPublishedEndpoints( + publish = previous.sharesPublicPaykitEndpoints, + appSyncPriority = if (previous.sharesPublicPaykitEndpoints) Priority.Ordered else Priority.Interactive, + ) .onFailure { error.addSuppressed(it) markPublicPaykitRetry(error) @@ -76,14 +129,10 @@ class ContactPaymentSettingsRepo @Inject constructor( privatePaykitRepo.enableSharingAndPrepareSavedContacts( publicKeys = contacts, ).onFailure(error::addSuppressed) - } else { - privatePaykitRepo.disableSharingAndPruneUnsavedContactState(contacts) - .onFailure(error::addSuppressed) } } private suspend fun disable(contacts: List): Result { - val previous = settingsStore.data.first() runSuspendCatching { settingsStore.update { it.copy( @@ -100,23 +149,14 @@ class ContactPaymentSettingsRepo @Inject constructor( var publicCleanupError: Throwable? = null var privateCleanupError: Throwable? = null - publicPaykitRepo.syncPublishedEndpoints(publish = false) - .onFailure { publicCleanupError = it } - privatePaykitRepo.disableSharingAndPruneUnsavedContactState(contacts) .onFailure { privateCleanupError = it } + publicPaykitRepo.syncPublishedEndpoints(publish = false, appSyncPriority = Priority.Interactive) + .onFailure { publicCleanupError = it } + publicCleanupError?.let { error -> - runSuspendCatching { - settingsStore.update { settings -> - settings.copy(sharesPublicPaykitEndpoints = previous.sharesPublicPaykitEndpoints) - } - }.onFailure(error::addSuppressed) - publicPaykitRepo.syncPublishedEndpoints(publish = previous.sharesPublicPaykitEndpoints) - .onFailure { - error.addSuppressed(it) - markPublicPaykitRetry(error) - } + markPublicPaykitRetry(error) } val cleanupError = publicCleanupError ?: privateCleanupError publicCleanupError?.let { publicError -> diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index f7ee240137..6f03f11476 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -77,6 +77,7 @@ import kotlin.math.ceil import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant /** * Production hardware-wallet business layer. Tracks paired devices of every vendor as @@ -867,9 +868,13 @@ class HwWalletRepo @Inject constructor( /** Broadcasts a signed funding payment without requiring the hardware device. */ suspend fun broadcastFunding( signedTx: HwFundingSignedTx, + paymentDeadlineAt: Instant? = null, ): Result = withContext(ioDispatcher) { runSuspendCatching { - val txId = trezorRepo.broadcastRawTx(serializedTx = signedTx.serializedTx).getOrThrow() + val txId = trezorRepo.broadcastRawTx( + serializedTx = signedTx.serializedTx, + paymentDeadlineAt = paymentDeadlineAt, + ).getOrThrow() HwFundingBroadcastResult( txId = txId, miningFeeSats = signedTx.miningFeeSats, diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index f7bd302fa4..2ac1ab04d6 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -113,6 +113,7 @@ import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime +import kotlin.time.Instant @Singleton @Suppress("LongParameterList", "TooManyFunctions", "LargeClass") @@ -1373,10 +1374,17 @@ class LightningRepo @Inject constructor( bolt11: String, sats: ULong? = null, onBeforeSend: suspend () -> Boolean, + ): Result = payInvoice(bolt11, sats, null, onBeforeSend) + + suspend fun payInvoice( + bolt11: String, + sats: ULong?, + paymentDeadlineAt: Instant?, + onBeforeSend: suspend () -> Boolean, ): Result = executeWhenNodeRunning("payInvoice") { waitForUsableChannels() if (!onBeforeSend()) return@executeWhenNodeRunning Result.failure(PaymentAbortedBeforeSend()) - runCatching { lightningService.send(bolt11, sats) }.also { + runCatching { lightningService.send(bolt11, sats, paymentDeadlineAt) }.also { syncState() } } @@ -1470,6 +1478,7 @@ class LightningRepo @Inject constructor( tags: List = emptyList(), beforeSendAttempt: suspend () -> Unit = {}, onBroadcast: suspend (Txid) -> Unit = {}, + paymentDeadlineAt: Instant? = null, ): Result = executeWhenNodeRunning("sendOnChain") { require(address.isNotEmpty()) { "Send address cannot be empty" } @@ -1495,7 +1504,7 @@ class LightningRepo @Inject constructor( Logger.debug("UTXOs selected to spend: $utxosForSend", context = TAG) beforeSendAttempt() - val txId = lightningService.send(address, sats, satsPerVByte, utxosForSend, isMaxAmount) + val txId = lightningService.send(address, sats, satsPerVByte, utxosForSend, isMaxAmount, paymentDeadlineAt) onBroadcast(txId) val preActivityMetadata = PreActivityMetadata( diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index 7520856c17..34cfc8a739 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -4,9 +4,16 @@ import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.ActivityFilter import com.synonym.bitkitcore.PaymentType import com.synonym.paykit.BillingPeriod +import com.synonym.paykit.PubkyIdentityCapability import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -59,6 +66,7 @@ data class PendingPaykitPaymentProof( val identity: String, val requestId: PaykitPaymentRequestId, val paymentEndpointIdentifier: String, + val paymentAppId: String, val kind: PaykitPaymentProofKind, val paymentStarted: Boolean = false, val paymentIdentifier: String? = null, @@ -74,6 +82,7 @@ data class PaykitOnchainPaymentProofResolution( val identity: String, val requestId: PaykitPaymentRequestId, val transactionId: String, + val walletId: String = WalletScope.default, ) @Singleton @@ -122,6 +131,29 @@ class PaykitPaymentProofRepo @Inject constructor( private val operationMutex = Mutex() + fun paymentRequestStateChanges(identity: Flow): Flow = + combine(identity, store.backupStateVersion) { publicKey, _ -> + runSuspendCatching { + if (publicKey == null) return@runSuspendCatching null + val proofs = store.load().filter { PubkyPublicKeyFormat.matches(it.identity, publicKey) } + PaymentRequestProofState( + identity = publicKey, + completedProofKinds = proofs.filter { it.proofData != null }.associate { it.requestId to it.kind }, + inFlightRequestIds = proofs.filter { it.paymentStarted }.mapTo(mutableSetOf()) { it.requestId }, + ) + } + } + .distinctUntilChanged { old, new -> old.isSuccess && new.isSuccess && old == new } + .drop(1) + .map { Unit } + .flowOn(ioDispatcher) + + private data class PaymentRequestProofState( + val identity: String, + val completedProofKinds: Map, + val inFlightRequestIds: Set, + ) + suspend fun backupSnapshot(): List = withContext(ioDispatcher) { operationMutex.withLock { store.load().map { PaykitPaymentStateBackup.Proof(it) } } } @@ -136,11 +168,12 @@ class PaykitPaymentProofRepo @Inject constructor( suspend fun prepare( request: PaykitPaymentRequest, paymentEndpointIdentifier: String, + paymentAppId: String, kind: PaykitPaymentProofKind, ): Result = withContext(ioDispatcher) { runSuspendCatching { operationMutex.withLock { - val proof = pendingProof(request, paymentEndpointIdentifier, kind) + val proof = pendingProof(request, paymentEndpointIdentifier, paymentAppId, kind) val currentProofs = loadProofs() if (currentProofs.any { it.isStartedFor(proof.identity, request.id) }) { throw PaykitPaymentRequestError.OperationInProgress @@ -157,6 +190,7 @@ class PaykitPaymentProofRepo @Inject constructor( request: PaykitPaymentRequest, paymentHash: String, paymentEndpointIdentifier: String, + paymentAppId: String, ): Result = withContext(ioDispatcher) { runSuspendCatching { if (!paymentHash.isHex(HASH_BYTE_COUNT)) throw PaykitPaymentRequestError.RequestUnavailable @@ -177,7 +211,7 @@ class PaykitPaymentProofRepo @Inject constructor( paymentIdentifier = paymentHash.lowercase(), ) } else { - pendingProof(request, paymentEndpointIdentifier, PaykitPaymentProofKind.Lightning) + pendingProof(request, paymentEndpointIdentifier, paymentAppId, PaykitPaymentProofKind.Lightning) .copy( paymentStarted = true, paymentIdentifier = paymentHash.lowercase(), @@ -262,15 +296,26 @@ class PaykitPaymentProofRepo @Inject constructor( request: PaykitPaymentRequest, txid: String, paymentEndpointIdentifier: String, + paymentAppId: String, ) = withContext(ioDispatcher) { if (!txid.isHex(HASH_BYTE_COUNT)) { Logger.warn("Ignored a Paykit on-chain proof with an invalid transaction id", context = TAG) return@withContext } - val identity = currentIdentity() ?: return@withContext - val fallbackProof = runSuspendCatching { - pendingProof(request, paymentEndpointIdentifier, PaykitPaymentProofKind.Onchain) + val prepared = operationMutex.withLock { + runSuspendCatching { + loadProofs().filter { + it.requestId == request.id && it.paymentAppId == paymentAppId && + it.paymentEndpointIdentifier == paymentEndpointIdentifier && + it.kind == PaykitPaymentProofKind.Onchain && it.paymentStarted && + it.paymentIdentifier == null && it.proofData == null + }.singleOrNull() + }.getOrNull() + } + val identity = prepared?.identity ?: currentIdentity() ?: return@withContext + val fallbackProof = prepared ?: runSuspendCatching { + pendingProof(request, paymentEndpointIdentifier, paymentAppId, PaykitPaymentProofKind.Onchain) }.getOrNull() operationMutex.withLock { val completion = runSuspendCatching { @@ -289,7 +334,7 @@ class PaykitPaymentProofRepo @Inject constructor( proofData = txid.lowercase(), ) } else { - pendingProof(request, paymentEndpointIdentifier, PaykitPaymentProofKind.Onchain).copy( + pendingProof(request, paymentEndpointIdentifier, paymentAppId, PaykitPaymentProofKind.Onchain).copy( paymentStarted = true, paymentIdentifier = txid.lowercase(), proofData = txid.lowercase(), @@ -324,12 +369,15 @@ class PaykitPaymentProofRepo @Inject constructor( } suspend fun failLightningPayment(paymentHash: String, submissionError: Throwable): Boolean { - val error = submissionError.asNodeException() ?: submissionError + val error = generateSequence(submissionError) { it.cause } + .firstOrNull { it is ServiceError.PaymentDeadlineExpired } + ?: submissionError.asNodeException() ?: submissionError when (error) { is NodeNotRunningError, is NodeRunTimeoutError, is ServiceError.NodeNotSetup, is ServiceError.NodeNotStarted, + is ServiceError.PaymentDeadlineExpired, is NodeException.NotRunning, is NodeException.InvalidInvoice, is NodeException.InvalidAmount, @@ -367,8 +415,7 @@ class PaykitPaymentProofRepo @Inject constructor( PubkyPublicKeyFormat.matches(it.identity, identity) && it.requestId.billingPeriodStartsAt != null && it.requestId.paymentRequestId == subscriptionId.paymentRequestId && - it.requestId.counterparty == subscriptionId.counterparty && - it.requestId.counterpartyReceiverPath == subscriptionId.counterpartyReceiverPath + it.requestId.counterparty == subscriptionId.counterparty } val protectedRequestIds = proofs.filter(belongsToSubscription) .filter { it.paymentStarted || it.paymentIdentifier != null || it.proofData != null } @@ -396,7 +443,9 @@ class PaykitPaymentProofRepo @Inject constructor( return@runSuspendCatching } val identityStatus = paykitSdkService.identityStatus() - if (identityStatus?.liveSessionAvailable != true) return@runSuspendCatching + if (identityStatus?.capability != PubkyIdentityCapability.PRIVATE_LINK_CAPABLE) { + return@runSuspendCatching + } val publicKey = identityStatus.publicKey ?: return@runSuspendCatching val identity = PubkyPublicKeyFormat.normalized(publicKey) ?: return@runSuspendCatching val proofs = storedProofs.filter { PubkyPublicKeyFormat.matches(it.identity, identity) } @@ -486,6 +535,7 @@ class PaykitPaymentProofRepo @Inject constructor( identity = proof.identity, requestId = proof.requestId, transactionId = txid.lowercase(), + walletId = proof.onchainWalletId, ) _onchainPaymentResolutions.update { resolutions -> if (resolution in resolutions) resolutions else resolutions + resolution @@ -508,7 +558,7 @@ class PaykitPaymentProofRepo @Inject constructor( val proofData = proof.proofData ?: return false val identityStatus = paykitSdkService.identityStatus() if ( - identityStatus?.liveSessionAvailable != true || + identityStatus?.capability != PubkyIdentityCapability.PRIVATE_LINK_CAPABLE || !PubkyPublicKeyFormat.matches(identityStatus.publicKey, proof.identity) ) { return false @@ -516,21 +566,21 @@ class PaykitPaymentProofRepo @Inject constructor( val record = paykitSdkService.paymentRequests().firstOrNull { it.paymentRequestId == proof.requestId.paymentRequestId && - PubkyPublicKeyFormat.matches(it.counterparty, proof.requestId.counterparty) && - it.counterpartyReceiverPath == proof.requestId.counterpartyReceiverPath + PubkyPublicKeyFormat.matches(it.counterparty, proof.requestId.counterparty) } ?: return false val proofJson = proofJson(proof.kind, proofData) val alreadyQueued = record.paymentProofs.any { it.billingPeriod.matches(proof.billingPeriod) && it.paymentEndpointIdentifier == proof.paymentEndpointIdentifier && + it.paymentAppId == proof.paymentAppId && it.proof.exportText().proofValues() == proofJson.proofValues() } if (!alreadyQueued) { paykitSdkService.submitPaymentProof( counterparty = proof.requestId.counterparty, - counterpartyReceiverPath = proof.requestId.counterpartyReceiverPath, paymentRequestId = proof.requestId.paymentRequestId, paymentEndpointIdentifier = proof.paymentEndpointIdentifier, + paymentAppId = proof.paymentAppId, proofJson = proofJson, billingPeriod = proof.billingPeriod, ) @@ -563,9 +613,9 @@ class PaykitPaymentProofRepo @Inject constructor( request: PaykitPaymentRequest, predicate: (PendingPaykitPaymentProof) -> Boolean, ) = withContext(ioDispatcher) { - val identity = currentIdentity() - operationMutex.withLock { - runSuspendCatching { + runSuspendCatching { + val identity = currentIdentity() + operationMutex.withLock { val proofs = loadProofs() val candidateIdentities = proofs .filter { it.requestId == request.id && predicate(it) } @@ -578,8 +628,8 @@ class PaykitPaymentProofRepo @Inject constructor( predicate(it) } if (remaining != proofs) persist(remaining) - }.onFailure { Logger.warn("Failed to clear a pending Paykit payment proof", it, context = TAG) } - } + } + }.onFailure { Logger.warn("Failed to clear a pending Paykit payment proof", it, context = TAG) } } private suspend fun removeProofsLocked(predicate: (PendingPaykitPaymentProof) -> Boolean) { @@ -622,9 +672,11 @@ class PaykitPaymentProofRepo @Inject constructor( private suspend fun pendingProof( request: PaykitPaymentRequest, paymentEndpointIdentifier: String, + paymentAppId: String, kind: PaykitPaymentProofKind, ): PendingPaykitPaymentProof { if ( + paymentAppId.isBlank() || paymentEndpointIdentifier !in request.acceptedPaymentEndpointIdentifiers || !endpointSupports(paymentEndpointIdentifier, kind) ) { @@ -632,13 +684,14 @@ class PaykitPaymentProofRepo @Inject constructor( } val identityStatus = paykitSdkService.identityStatus() val identity = identityStatus?.publicKey?.let { PubkyPublicKeyFormat.normalized(it) } - if (identityStatus?.liveSessionAvailable != true || identity == null) { + if (identityStatus?.capability != PubkyIdentityCapability.PRIVATE_LINK_CAPABLE || identity == null) { throw PaykitPaymentRequestError.RequestUnavailable } return PendingPaykitPaymentProof( identity = identity, requestId = request.id, paymentEndpointIdentifier = paymentEndpointIdentifier, + paymentAppId = paymentAppId, kind = kind, billingPeriod = request.billingPeriod, ) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStore.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStore.kt index 8ffba9fe87..a50fa1d099 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStore.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStore.kt @@ -31,6 +31,7 @@ class PaykitPaymentRequestPresentationStore @Inject constructor( ) { companion object { private val KEY = Keychain.Key.PAYKIT_PRESENTED_PAYMENT_REQUESTS.name + private val ACCEPTED_KEY = Keychain.Key.PAYKIT_ACCEPTED_PAYMENT_REQUESTS.name } private val mutex = Mutex() @@ -73,6 +74,55 @@ class PaykitPaymentRequestPresentationStore @Inject constructor( } } + fun loadAcceptedOneTimeIds(identity: String): Set { + val normalizedIdentity = PubkyPublicKeyFormat.normalized(identity) ?: return emptySet() + return loadAcceptedOneTimeIdsByIdentity()[normalizedIdentity].orEmpty() + } + + suspend fun addAcceptedOneTimeId(identity: String, id: PaykitPaymentRequestId): Set = + mutex.withLock { + val normalizedIdentity = requireNotNull(PubkyPublicKeyFormat.normalized(identity)) + val current = loadAcceptedOneTimeIdsByIdentity() + val ids = current[normalizedIdentity].orEmpty() + id + val state = current + (normalizedIdentity to ids) + keychain.upsertString(ACCEPTED_KEY, Json.encodeToString(state)) + _backupStateVersion.update { it + 1 } + ids + } + + suspend fun removeAcceptedOneTimeIds( + identity: String, + ids: Set, + ): Set = + mutex.withLock { + val normalizedIdentity = requireNotNull(PubkyPublicKeyFormat.normalized(identity)) + val current = loadAcceptedOneTimeIdsByIdentity() + val storedIds = current[normalizedIdentity].orEmpty() + val remaining = storedIds - ids + if (remaining == storedIds) return@withLock remaining + val state = if (remaining.isEmpty()) { + current - normalizedIdentity + } else { + current + (normalizedIdentity to remaining) + } + keychain.upsertString(ACCEPTED_KEY, Json.encodeToString(state)) + _backupStateVersion.update { it + 1 } + remaining + } + + fun acceptedOneTimeBackupSnapshot(): Map> = loadAcceptedOneTimeIdsByIdentity() + + suspend fun restoreAcceptedOneTimeRequests(requests: Map>) = mutex.withLock { + keychain.upsertString(ACCEPTED_KEY, Json.encodeToString(requests)) + _backupStateVersion.update { it + 1 } + } + + private fun loadAcceptedOneTimeIdsByIdentity(): Map> { + val value = keychain.loadString(ACCEPTED_KEY) ?: return emptyMap() + return runCatching { Json.decodeFromString>>(value) } + .getOrElse { throw PaykitPaymentStateUnreadableError(ACCEPTED_KEY, it) } + } + fun loadSubscriptionState(identity: String): PaykitSubscriptionPresentationState { val normalizedIdentity = PubkyPublicKeyFormat.normalized(identity) ?: return PaykitSubscriptionPresentationState() diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 767df7a1db..1eac6d4569 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -2,17 +2,24 @@ package to.bitkit.repositories +import com.synonym.paykit.LinkedPeerRecord import com.synonym.paykit.LinkedPeerState import com.synonym.paykit.OutboundPrivateCounterpartySendReport import com.synonym.paykit.OutboundPrivateMessageStatus +import com.synonym.paykit.PaykitException +import com.synonym.paykit.PaymentDeadline import com.synonym.paykit.PaymentRequestLifecycleState import com.synonym.paykit.PaymentRequestLocalRole import com.synonym.paykit.PaymentRequestRecord import com.synonym.paykit.PaymentRequestTerms import com.synonym.paykit.PrivateJsonObject import com.synonym.paykit.PrivateStreamCounterpartyIntakeReport +import com.synonym.paykit.PubkyIdentityCapability import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -21,9 +28,10 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray @@ -47,8 +55,9 @@ import to.bitkit.models.satsToMsat import to.bitkit.services.PaykitPaymentRequestProposalTerms import to.bitkit.services.PaykitPaymentRequestRecurrenceTerms import to.bitkit.services.PaykitReadLane -import to.bitkit.services.PaykitReceiverPaths +import to.bitkit.services.PaykitSdkOperationLock.Priority import to.bitkit.services.PaykitSdkService +import to.bitkit.services.isBitkitPaymentRequest import to.bitkit.utils.AppError import to.bitkit.utils.Logger import to.bitkit.utils.SubscriptionIcon @@ -59,7 +68,7 @@ import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Clock import kotlin.time.Duration -import kotlin.time.Duration.Companion.seconds +import kotlin.time.Duration.Companion.nanoseconds import kotlin.time.ExperimentalTime import kotlin.time.Instant @@ -67,14 +76,12 @@ import kotlin.time.Instant data class PaykitPaymentRequestId( val paymentRequestId: String, val counterparty: String, - val counterpartyReceiverPath: String, val billingPeriodStartsAt: String? = null, ) data class PaykitPaymentRequest( val paymentRequestId: String, val counterparty: String, - val counterpartyReceiverPath: String, val amountValue: String, val amountSats: ULong, val note: String? = null, @@ -86,6 +93,7 @@ data class PaykitPaymentRequest( val lifecycleState: PaymentRequestLifecycleState = PaymentRequestLifecycleState.PROPOSED, val billingPeriod: PaykitBillingPeriod? = null, val paymentProofKind: PaykitPaymentProofKind? = null, + val paymentDeadlineAt: Instant? = null, ) { enum class ParseFailure( val logValue: String, @@ -111,16 +119,19 @@ data class PaykitPaymentRequest( get() = PaykitPaymentRequestId( paymentRequestId, counterparty, - counterpartyReceiverPath, billingPeriod?.startsAt?.toString(), ) val requiresAcceptance: Boolean get() = billingPeriod == null && lifecycleState == PaymentRequestLifecycleState.PROPOSED - fun isExpired(now: Instant): Boolean = + fun isProposalExpired(now: Instant): Boolean = lifecycleState == PaymentRequestLifecycleState.PROPOSED && expiresAt?.let { it <= now } == true + fun isPaymentDeadlineExpired(now: Instant): Boolean = paymentDeadlineAt?.let { now > it } == true + + fun isExpired(now: Instant): Boolean = isProposalExpired(now) || isPaymentDeadlineExpired(now) + fun acceptsLightningInvoiceAmountMsats(amountMsats: ULong?): Boolean = amountMsats == null || amountMsats == satsToMsat(amountSats) @@ -132,8 +143,7 @@ data class PaykitPaymentRequest( fun belongsTo(subscription: PaykitSubscription): Boolean = billingPeriod != null && paymentRequestId == subscription.paymentRequestId && - counterparty == subscription.counterparty && - counterpartyReceiverPath == subscription.counterpartyReceiverPath + counterparty == subscription.counterparty } internal sealed interface PaykitPaymentRequestParseResult { @@ -191,6 +201,7 @@ private data class ParsedPaykitPaymentRequestTerms( val amountSats: ULong, val endpoints: List, val expiresAt: Instant?, + val paymentDeadlineAt: Instant?, ) enum class PaykitPaymentRequestDeliveryStatus { Queued, Sent } @@ -199,7 +210,6 @@ enum class PaykitPaymentRequestDirection { Incoming, Outgoing } data class PaykitPaymentRequestTarget( val publicKey: String, - val receiverPath: String, ) data class PaykitPaymentRequestDraft( @@ -231,7 +241,7 @@ data class PaykitSubscriptionCreation( private data class PaykitPaymentRequestTargetContext( val savedPublicKeys: List, - val linkedReceiverPaths: Map>, + val linkedPublicKeys: Set, ) private data class PaykitPaymentRequestTargetDiscovery( @@ -252,6 +262,12 @@ sealed class PaykitPaymentRequestError(message: String) : AppError(message) { data object SubscriptionTooLong : PaykitPaymentRequestError("Subscription proposal exceeds the message size limit") } +enum class PaykitPaymentRequestRefreshMode { + STORED, + INBOX, + FULL, +} + @Suppress("TooManyFunctions", "LongParameterList", "LargeClass") @Singleton class PaykitPaymentRequestRepo @Inject constructor( @@ -268,15 +284,24 @@ class PaykitPaymentRequestRepo @Inject constructor( ) { companion object { private const val TAG = "PaykitPaymentRequestRepo" - private val TARGET_DISCOVERY_TIMEOUT = 5.seconds + + /** Maximum number of concurrent public capability lookups per discovery. */ + private const val PAYMENT_REQUEST_DISCOVERY_CONCURRENCY = 8 } private val operationMutex = Mutex() + private val refreshMutex = Mutex() private val targetDiscoveryMutex = Mutex() private val creationMutex = Mutex() private val processingLock = Any() private val processingRequestIds = mutableSetOf() private val stateGeneration = AtomicLong() + private val completedRefreshVersion = AtomicLong() + private var completedRefreshGeneration = -1L + private var completedRefreshProofVersion = -1L + private var completedRefreshActionVersion = -1L + private var actionVersion = 0L + private var completedRefreshMode = PaykitPaymentRequestRefreshMode.STORED private val repoScope = appScope(ioDispatcher, TAG) private var expirationJob: Job? = null private var cachedTargetContext: PaykitPaymentRequestTargetContext? = null @@ -293,12 +318,46 @@ class PaykitPaymentRequestRepo @Inject constructor( private val _isCreatingRequest = MutableStateFlow(false) val isCreatingRequest: StateFlow = _isCreatingRequest.asStateFlow() + val isPaymentSubmissionActive: StateFlow + get() = paykitSdkService.isPaymentSubmissionActive + + fun setPaymentSubmissionActive(active: Boolean) = paykitSdkService.setPaymentSubmissionActive(active) + @Volatile private var activeIdentity: String? = null + @Volatile + private var receivedContacts: ReceivedContactsSnapshot? = null + + internal val receivedPaymentContacts: PaykitReceivedPaymentContacts + get() = receivedContacts?.takeIf { isCurrentState(it.generation, it.identity) }?.contacts + ?: PaykitReceivedPaymentContacts.Empty + + internal val receivedPaymentContactsGeneration: Long + get() = stateGeneration.get() + + private data class ReceivedContactsSnapshot( + val generation: Long, + val identity: String, + val contacts: PaykitReceivedPaymentContacts, + ) + + private data class RefreshCheckpoint(val actionVersion: Long, val proofVersion: Long) + + private data class RequestSnapshot( + val records: List, + val blockedPeers: List, + ) + @Volatile private var presentedRequestIds = emptySet() + @Volatile + private var acceptedOneTimeRequestIds = emptySet() + + @Volatile + private var activatedGeneration = -1L + @Volatile private var subscriptionAcceptedAt = emptyMap() @@ -310,13 +369,23 @@ class PaykitPaymentRequestRepo @Inject constructor( suspend fun activate(identity: String) = withContext(ioDispatcher) { val normalizedIdentity = PubkyPublicKeyFormat.normalized(identity) ?: return@withContext - if (!PubkyPublicKeyFormat.matches(activeIdentity, normalizedIdentity)) { - stateGeneration.incrementAndGet() + val committedGeneration = activatedGeneration + if (PubkyPublicKeyFormat.matches(activeIdentity, normalizedIdentity) && + committedGeneration == stateGeneration.get() + ) { + return@withContext } + val generation = stateGeneration.incrementAndGet() operationMutex.withLock { - if (PubkyPublicKeyFormat.matches(activeIdentity, normalizedIdentity)) return@withLock + if (stateGeneration.get() != generation) return@withLock clearStateLocked() activeIdentity = null + acceptedOneTimeRequestIds = emptySet() + acceptedOneTimeRequestIds = runSuspendCatching { + presentationStore.loadAcceptedOneTimeIds(normalizedIdentity) + } + .onFailure { Logger.error("Failed to restore accepted Paykit payment requests", it, context = TAG) } + .getOrElse { return@withLock } presentedRequestIds = runSuspendCatching { presentationStore.load(normalizedIdentity) } .onFailure { Logger.error("Failed to restore surfaced Paykit payment requests", it, context = TAG) } .getOrDefault(emptySet()) @@ -326,7 +395,9 @@ class PaykitPaymentRequestRepo @Inject constructor( subscriptionAcceptedAt = subscriptionState.acceptedAt presentedSubscriptionProposalIds = subscriptionState.presentedProposalIds dismissedSubscriptionPaymentIds = subscriptionState.dismissedPaymentIds + if (stateGeneration.get() != generation) return@withLock activeIdentity = normalizedIdentity + activatedGeneration = generation } } @@ -342,6 +413,25 @@ class PaykitPaymentRequestRepo @Inject constructor( fun pendingRequest(id: PaykitPaymentRequestId): PaykitPaymentRequest? = _pendingRequests.value.firstOrNull { it.id == id } + internal suspend fun isSubscriptionNotificationHandled(id: PaykitPaymentRequestId, identity: String): Boolean { + val generation = stateGeneration.get() + return withContext(ioDispatcher) { + operationMutex.withLock { + if (completedRefreshGeneration != generation || completedRefreshActionVersion != actionVersion || + !isCurrentState(generation, identity) + ) { + return@withLock false + } + if (pendingRequest(id) != null) return@withLock false + id in dismissedSubscriptionPaymentIds || _paymentRequestHistory.value.any { it.id == id } || + _subscriptions.value.none { + it.id == PaykitSubscriptionId(id.paymentRequestId, id.counterparty) && + it.isActive(subscriptionClock.now()) + } + } + } + } + fun synchronizeSubscriptionNotifications(enabled: Boolean) { val identity = activeIdentity ?: return subscriptionNotificationScheduler.synchronize( @@ -353,14 +443,18 @@ class PaykitPaymentRequestRepo @Inject constructor( ) } - suspend fun markPresented(request: PaykitPaymentRequest): Boolean = withContext(ioDispatcher) { - operationMutex.withLock { - if (_pendingRequests.value.none { it.id == request.id }) return@withLock false - val identity = activeIdentity ?: return@withLock false - presentedRequestIds = presentedRequestIds + request.id - runSuspendCatching { presentationStore.save(identity, presentedRequestIds) } - .onFailure { Logger.warn("Failed to persist surfaced Paykit payment requests", it, context = TAG) } - true + suspend fun markPresented(request: PaykitPaymentRequest): Boolean { + val generation = stateGeneration.get() + val identity = activeIdentity ?: return false + return withContext(ioDispatcher) { + operationMutex.withLock { + if (!isCurrentState(generation, identity)) return@withLock false + if (_pendingRequests.value.none { it.id == request.id }) return@withLock false + presentedRequestIds = presentedRequestIds + request.id + runSuspendCatching { presentationStore.save(identity, presentedRequestIds) } + .onFailure { Logger.warn("Failed to persist surfaced Paykit payment requests", it, context = TAG) } + true + } } } @@ -414,19 +508,64 @@ class PaykitPaymentRequestRepo @Inject constructor( } } - suspend fun refresh(): Result { + suspend fun refresh(mode: PaykitPaymentRequestRefreshMode = PaykitPaymentRequestRefreshMode.FULL): Result = + refresh(mode, forceFresh = false) + + internal suspend fun refresh(mode: PaykitPaymentRequestRefreshMode, messagePriority: Priority): Result = + refresh(mode, forceFresh = false, messagePriority = messagePriority) + + suspend fun refreshAfterStateChange( + mode: PaykitPaymentRequestRefreshMode = PaykitPaymentRequestRefreshMode.FULL, + ): Result = refresh(mode, forceFresh = true) + + private suspend fun refresh( + mode: PaykitPaymentRequestRefreshMode, + forceFresh: Boolean, + messagePriority: Priority = Priority.Ordered, + ): Result { val generation = stateGeneration.get() val expectedIdentity = activeIdentity + val refreshVersion = completedRefreshVersion.get() return withContext(ioDispatcher) { runSuspendCatching { - operationMutex.withLock { - if (!isAvailable()) { - clearStateLocked() - return@withLock + refreshMutex.withLock refresh@{ + val checkpoint = operationMutex.withLock operation@{ + if (!isAvailable()) { + clearStateLocked() + return@operation null + } + if (!isCurrentState(generation, expectedIdentity)) return@operation null + val proofVersion = paymentProofStore.backupStateVersion.value + val hasFreshSnapshot = completedRefreshVersion.get() != refreshVersion && + completedRefreshGeneration == generation && completedRefreshProofVersion == proofVersion && + completedRefreshActionVersion == actionVersion + if (!forceFresh && hasFreshSnapshot && mode <= completedRefreshMode) return@operation null + completedRefreshGeneration = -1L + RefreshCheckpoint(actionVersion, proofVersion) + } ?: return@refresh + val snapshot = runSuspendCatching { + val priority = if (forceFresh) Priority.Ordered else Priority.Background + fetchRequestSnapshot(expectedIdentity, mode, priority, messagePriority) + } + operationMutex.withLock { + if (!isCurrentState(generation, expectedIdentity)) return@withLock + val proofVersion = paymentProofStore.backupStateVersion.value + runSuspendCatching { + val records = snapshot.getOrThrow() + if (checkpoint != RefreshCheckpoint(actionVersion, proofVersion)) { + synchronizeLocked(generation, expectedIdentity, PaykitPaymentRequestRefreshMode.STORED) + } else { + applyRequestSnapshotLocked(records, generation, expectedIdentity, proofVersion) + } + }.onFailure { discardExpiredRequestsLocked() }.getOrThrow() + if (isCurrentState(generation, expectedIdentity)) { + completedRefreshMode = mode + completedRefreshGeneration = generation + completedRefreshProofVersion = proofVersion + completedRefreshActionVersion = actionVersion + completedRefreshVersion.incrementAndGet() + } } - runSuspendCatching { synchronizeLocked(generation, expectedIdentity) } - .onFailure { discardExpiredRequestsLocked() } - .getOrThrow() } }.onFailure { Logger.warn("Failed to refresh incoming Paykit payment requests", it, context = TAG) @@ -456,7 +595,7 @@ class PaykitPaymentRequestRepo @Inject constructor( return@discovery } val ticket = targetRefreshTicket.incrementAndGet() - val context = targetContext(savedPublicKeys, expectedIdentity) + val context = targetContext(savedPublicKeys, expectedIdentity, Priority.Background) if (!force && context == cachedTargetContext) return@discovery val previousTargets = _eligibleTargets.value.associateBy { it.publicKey } val discovery = context?.let { eligibleTargets(it, previousTargets, PaykitReadLane.Bulk) } @@ -499,7 +638,7 @@ class PaykitPaymentRequestRepo @Inject constructor( if (!isAvailable() || expectedIdentity == null) return@runSuspendCatching unavailable val ticket = targetRefreshTicket.incrementAndGet() val previousTargets = _eligibleTargets.value.associateBy { it.publicKey } - val discovery = targetContext(listOf(publicKey), expectedIdentity) + val discovery = targetContext(listOf(publicKey), expectedIdentity, Priority.Interactive) ?.let { eligibleTargets(it, previousTargets) } ?: PaykitPaymentRequestTargetDiscovery(emptyList(), isComplete = true) val target = discovery.targets.firstOrNull() @@ -546,7 +685,7 @@ class PaykitPaymentRequestRepo @Inject constructor( PubkyPublicKeyFormat.matches(it, target.publicKey) } val targets = selectedSavedKey?.let { - targetContext(listOf(it), expectedIdentity) + targetContext(listOf(it), expectedIdentity, Priority.Interactive) }?.let { eligibleTargets(it).targets }.orEmpty() if (target !in targets) throw PaykitPaymentRequestError.RequestUnavailable val settings = settingsStore.data.first() @@ -562,20 +701,20 @@ class PaykitPaymentRequestRepo @Inject constructor( acceptedPaymentEndpointIdentifiers = endpointIdentifiers, metadataJson = JsonObject(mapOf("note" to JsonPrimitive(note))).toString(), ) + actionVersion++ val record = paykitSdkService.proposePaymentRequest( counterparty = target.publicKey, - counterpartyReceiverPath = target.receiverPath, proposal = proposal, expectedIdentity = expectedIdentity, ) - val reports = processPendingMessages() + val reports = processPendingMessages(target.publicKey) val request = record.toCreatedPaykitPaymentRequest( draft = draft.copy(note = note), target = target, endpointIdentifiers = endpointIdentifiers, createdAt = proposalDate, - reports = reports, + wasSent = proposalWasSent(record, reports, generation, expectedIdentity), ) publishCreatedRequest(request, generation, expectedIdentity) } @@ -636,13 +775,14 @@ class PaykitPaymentRequestRepo @Inject constructor( // The published recurrence start and anchor stay on real time, so the offset never leaves this device. val proposal = buildSubscriptionProposal(draft, name, description, iconUri, endpoints, clock.now()) PaykitSubscriptionProposal.validate(proposal) + actionVersion++ val record = paykitSdkService.proposePaymentRequest( counterparty = target.publicKey, - counterpartyReceiverPath = target.receiverPath, proposal = proposal, expectedIdentity = expectedIdentity, ) - val deliveryStatus = if (proposalWasSent(record, processPendingMessages())) { + val reports = processPendingMessages(target.publicKey) + val deliveryStatus = if (proposalWasSent(record, reports, generation, expectedIdentity)) { PaykitPaymentRequestDeliveryStatus.Sent } else { PaykitPaymentRequestDeliveryStatus.Queued @@ -679,7 +819,7 @@ class PaykitPaymentRequestRepo @Inject constructor( PubkyPublicKeyFormat.matches(it, target.publicKey) } val targets = selectedSavedKey?.let { savedKey -> - targetContext(listOf(savedKey), expectedIdentity)?.let { eligibleTargets(it).targets } + targetContext(listOf(savedKey), expectedIdentity, Priority.Interactive)?.let { eligibleTargets(it).targets } } .orEmpty() val settings = settingsStore.data.first() @@ -725,17 +865,35 @@ class PaykitPaymentRequestRepo @Inject constructor( ) } - private fun proposalWasSent( + private suspend fun proposalWasSent( record: PaymentRequestRecord, reports: List, + generation: Long, + expectedIdentity: String, ): Boolean { if (record.proposalOutboundStatus == OutboundPrivateMessageStatus.SENT) return true val messageId = record.proposalOutboundMessageId ?: return false - return reports.any { + val sentByDrain = reports.any { PubkyPublicKeyFormat.matches(it.counterparty, record.counterparty) && - it.counterpartyReceiverPath == record.counterpartyReceiverPath && messageId in it.report?.sent.orEmpty() } + if (sentByDrain) return true + val failedByDrain = reports.any { + PubkyPublicKeyFormat.matches(it.counterparty, record.counterparty) && + it.report?.failed.orEmpty().any { failure -> failure.outboundMessageId == messageId } + } + if (failedByDrain || !isCurrentState(generation, expectedIdentity)) return false + return runSuspendCatching { + paykitSdkService.allPaymentRequests(expectedIdentity, Priority.Interactive).any { + PubkyPublicKeyFormat.matches(it.counterparty, record.counterparty) && + it.paymentRequestId == record.paymentRequestId && + it.localRole == PaymentRequestLocalRole.PAYEE && + it.proposalOutboundMessageId == messageId && + it.proposalOutboundStatus == OutboundPrivateMessageStatus.SENT + } + }.onFailure { + Logger.warn("Failed to read Paykit proposal delivery status", it, context = TAG) + }.getOrDefault(false) } private fun publishCreatedSubscription( @@ -768,17 +926,49 @@ class PaykitPaymentRequestRepo @Inject constructor( return PaykitPaymentRequestCreation(request, creatorIdentity, wasPublishedToActiveState) } - suspend fun ensurePaymentAllowed(request: PaykitPaymentRequest): Result = withContext(ioDispatcher) { + suspend fun ensurePaymentAllowed(request: PaykitPaymentRequest): Result = + ensurePaymentAllowed(request, forExecution = true) + + private suspend fun ensurePaymentAllowed( + request: PaykitPaymentRequest, + forExecution: Boolean, + ): Result = withContext(ioDispatcher) { runSuspendCatching { + val identity = activeIdentity ?: throw PaykitPaymentRequestError.RequestUnavailable + val generation = stateGeneration.get() + if (request.isPaymentDeadlineExpired(clock.now())) throw PaykitPaymentRequestError.RequestExpired + if (!isLocallyPayable(request)) throw PaykitPaymentRequestError.RequestUnavailable + if (forExecution && !hasCurrentExecutionContext(request)) { + throw PaykitPaymentRequestError.RequestUnavailable + } if ( paykitSdkService.linkedPeers().any { it.state == LinkedPeerState.BLOCKED && - PubkyPublicKeyFormat.matches(it.counterparty, request.counterparty) && - it.counterpartyReceiverPath == request.counterpartyReceiverPath + PubkyPublicKeyFormat.matches(it.counterparty, request.counterparty) } ) { throw PaykitPaymentRequestError.RequestUnavailable } + if (!isCurrentState(generation, identity)) throw PaykitPaymentRequestError.RequestUnavailable + if (forExecution && !hasCurrentExecutionContext(request)) { + throw PaykitPaymentRequestError.RequestUnavailable + } + if (request.isPaymentDeadlineExpired(clock.now())) throw PaykitPaymentRequestError.RequestExpired + } + } + + suspend fun claimForPayment(request: PaykitPaymentRequest): Result = withContext(ioDispatcher) { + runSuspendCatching { + ensurePaymentAllowed(request, forExecution = false).getOrThrow() + val record = paykitSdkService.claimPaymentRequestForExecution( + request.counterparty, + request.paymentRequestId, + ) + request.billingPeriod?.let { period -> + val subscription = record.toPaykitSubscription() ?: throw PaykitPaymentRequestError.RequestUnavailable + if (period in subscription.paidPeriods) throw PaykitPaymentRequestError.RequestUnavailable + } + Unit } } @@ -786,19 +976,41 @@ class PaykitPaymentRequestRepo @Inject constructor( runSuspendCatching { if (!request.requiresAcceptance) { operationMutex.withLock { - ensurePaymentAllowed(request).getOrThrow() + ensurePaymentAllowed(request, forExecution = false).getOrThrow() if (_pendingRequests.value.none { it.id == request.id }) { throw PaykitPaymentRequestError.RequestUnavailable } } } else { updateRequest(request, PaymentRequestLifecycleState.ACCEPTED) { - ensurePaymentAllowed(it).getOrThrow() - paykitSdkService.acceptPaymentRequest( - counterparty = it.counterparty, - counterpartyReceiverPath = it.counterpartyReceiverPath, - paymentRequestId = it.paymentRequestId, - ) + val identity = activeIdentity ?: throw PaykitPaymentRequestError.RequestUnavailable + val generation = stateGeneration.get() + ensurePaymentAllowed(it, forExecution = false).getOrThrow() + val alreadySaved = it.id in presentationStore.loadAcceptedOneTimeIds(identity) + val acceptedIds = presentationStore.addAcceptedOneTimeId(identity, it.id) + if (!isCurrentState(generation, identity)) throw PaykitPaymentRequestError.RequestUnavailable + acceptedOneTimeRequestIds = acceptedIds + runSuspendCatching { + paykitSdkService.acceptPaymentRequest( + counterparty = it.counterparty, + paymentRequestId = it.paymentRequestId, + ) + }.onFailure { error -> + // Acceptance may already be committed. Reconcile before discarding its owner. + val uncertain = when (error) { + is PaykitException.Transport, + is PaykitException.Storage, + is PaykitException.Identity, + is PaykitException.ConcurrentUpdate, + is PaykitException.SharedStateBusy -> true + else -> false + } + if (!alreadySaved && !uncertain) { + val remaining = presentationStore.removeAcceptedOneTimeIds(identity, setOf(it.id)) + if (isCurrentState(generation, identity)) acceptedOneTimeRequestIds = remaining + } + }.getOrThrow() + if (!isCurrentState(generation, identity)) throw PaykitPaymentRequestError.RequestUnavailable }.getOrThrow() } }.onFailure { @@ -812,7 +1024,6 @@ class PaykitPaymentRequestRepo @Inject constructor( ) { paykitSdkService.rejectPaymentRequest( counterparty = it.counterparty, - counterpartyReceiverPath = it.counterpartyReceiverPath, paymentRequestId = it.paymentRequestId, ) }.onFailure { @@ -832,7 +1043,6 @@ class PaykitPaymentRequestRepo @Inject constructor( return updateRequest(request, PaymentRequestLifecycleState.CANCELED) { paykitSdkService.cancelPaymentRequest( counterparty = it.counterparty, - counterpartyReceiverPath = it.counterpartyReceiverPath, paymentRequestId = it.paymentRequestId, ) } @@ -848,18 +1058,18 @@ class PaykitPaymentRequestRepo @Inject constructor( val current = _subscriptions.value.firstOrNull { it.id == subscription.id } ?.takeIf { it == subscription && it.isPayer && it.isProposalActionable(validationDate) } ?: throw PaykitPaymentRequestError.RequestUnavailable + actionVersion++ val record = paykitSdkService.acceptPaymentRequest( current.counterparty, - current.counterpartyReceiverPath, current.paymentRequestId, ) - processPendingMessages() // Stored on real time, so a period the offset made due is not dropped when it turns Off. val acceptanceDate = clock.now() subscriptionAcceptedAt = subscriptionAcceptedAt + (current.id to acceptanceDate) persistSubscriptionState(identity) applySubscriptionRecordLocked(record, subscriptionClock.now()) synchronizeAfterSubscriptionAction(identity) + scheduleAcceptedResponse(current.counterparty) _pendingRequests.value .filter { it.belongsTo(current) } .minByOrNull { it.billingPeriod?.startsAt ?: Instant.DISTANT_FUTURE } @@ -879,7 +1089,7 @@ class PaykitPaymentRequestRepo @Inject constructor( throw PaykitPaymentRequestError.OperationInProgress } } - paykitSdkService.cancelPaymentRequest(it.counterparty, it.counterpartyReceiverPath, it.paymentRequestId) + paykitSdkService.cancelPaymentRequest(it.counterparty, it.paymentRequestId) } fun isPending(request: PaykitPaymentRequest): Boolean = @@ -900,27 +1110,54 @@ class PaykitPaymentRequestRepo @Inject constructor( clearStateLocked() activeIdentity = null presentedRequestIds = emptySet() + acceptedOneTimeRequestIds = emptySet() presentedSubscriptionProposalIds = emptySet() dismissedSubscriptionPaymentIds = emptySet() } } } - @Suppress("LongMethod", "CyclomaticComplexMethod") private suspend fun synchronizeLocked( generation: Long, expectedIdentity: String?, + mode: PaykitPaymentRequestRefreshMode = PaykitPaymentRequestRefreshMode.FULL, + ) { + val proofVersion = paymentProofStore.backupStateVersion.value + val snapshot = fetchRequestSnapshot(expectedIdentity, mode) + applyRequestSnapshotLocked(snapshot, generation, expectedIdentity, proofVersion) + } + + private suspend fun fetchRequestSnapshot( + expectedIdentity: String?, + mode: PaykitPaymentRequestRefreshMode, + priority: Priority = Priority.Ordered, + messagePriority: Priority = Priority.Ordered, + ): RequestSnapshot { + if (mode == PaykitPaymentRequestRefreshMode.FULL) processPendingMessages(priority = messagePriority) + if (mode != PaykitPaymentRequestRefreshMode.STORED) { + paykitSdkService.receivePrivateMessagesFromLinkedPeers(messagePriority).also(::logIntakeFailures) + } + val allRecords = paykitSdkService.allPaymentRequests(expectedIdentity, priority) + val blockedPeers = paykitSdkService.linkedPeers(priority).filter { it.state == LinkedPeerState.BLOCKED } + return RequestSnapshot(allRecords, blockedPeers) + } + + @Suppress("LongMethod", "CyclomaticComplexMethod") + private suspend fun applyRequestSnapshotLocked( + snapshot: RequestSnapshot, + generation: Long, + expectedIdentity: String?, + proofVersion: Long, ) { - processPendingMessages() - paykitSdkService.receivePrivateMessagesFromLinkedPeers().also(::logIntakeFailures) val now = clock.now() val subscriptionNow = subscriptionClock.now() - val records = paykitSdkService.paymentRequests() - val blockedPeers = paykitSdkService.linkedPeers().filter { it.state == LinkedPeerState.BLOCKED } + val allRecords = snapshot.records + val contacts = PaykitReceivedPaymentContacts.from(allRecords, Env.network) + val records = allRecords.filter(::isBitkitPaymentRequest) + val blockedPeers = snapshot.blockedPeers val availableRecords = records.filterNot { record -> blockedPeers.any { - PubkyPublicKeyFormat.matches(it.counterparty, record.counterparty) && - it.counterpartyReceiverPath == record.counterpartyReceiverPath + PubkyPublicKeyFormat.matches(it.counterparty, record.counterparty) } } val locallyCompletedProofKinds = expectedIdentity @@ -935,8 +1172,7 @@ class PaykitPaymentRequestRepo @Inject constructor( val blockedSubscriptionIds = allSubscriptions.mapNotNull { subscription -> subscription.id.takeIf { blockedPeers.any { - PubkyPublicKeyFormat.matches(it.counterparty, subscription.counterparty) && - it.counterpartyReceiverPath == subscription.counterpartyReceiverPath + PubkyPublicKeyFormat.matches(it.counterparty, subscription.counterparty) } } }.toSet() @@ -1007,7 +1243,9 @@ class PaykitPaymentRequestRepo @Inject constructor( null } } - }.filter { it.id !in locallyCompletedRequestIds && it.id !in locallyInFlightRequestIds } + }.filter { + isLocallyPayable(it) && it.id !in locallyCompletedRequestIds && it.id !in locallyInFlightRequestIds + } val incoming = (dueRequests + oneTimeIncoming).sortedBy { it.createdAt } val oneTimeHistory = records.mapNotNull { it.toPaykitPaymentRequestHistory(now) }.map { request -> val proofKind = locallyCompletedProofKinds[request.id] ?: return@map request @@ -1019,12 +1257,18 @@ class PaykitPaymentRequestRepo @Inject constructor( val history = (recurringHistory + oneTimeHistory) .sortedByDescending { it.createdAt } if (!isCurrentState(generation, expectedIdentity) || expectedIdentity == null) return + pruneAcceptedOneTimeRequestIds(records, expectedIdentity, generation) + if (!isCurrentState(generation, expectedIdentity)) return val subscriptionStateChanged = subscriptionAcceptedAt != updatedSubscriptionAcceptedAt || dismissedSubscriptionPaymentIds != updatedDismissedPaymentIds subscriptionAcceptedAt = updatedSubscriptionAcceptedAt dismissedSubscriptionPaymentIds = updatedDismissedPaymentIds if (subscriptionStateChanged) persistSubscriptionState(expectedIdentity) + if (proofVersion != paymentProofStore.backupStateVersion.value) { + throw PaykitPaymentRequestError.RequestUnavailable + } + receivedContacts = ReceivedContactsSnapshot(generation, expectedIdentity, contacts) _pendingRequests.update { incoming } _paymentRequestHistory.update { history } _subscriptions.update { subscriptions } @@ -1054,32 +1298,28 @@ class PaykitPaymentRequestRepo @Inject constructor( private suspend fun targetContext( savedPublicKeys: List, expectedIdentity: String, + priority: Priority, ): PaykitPaymentRequestTargetContext? { val settings = settingsStore.data.first() val endpointIdentifiers = acceptedPaymentEndpointIdentifiers(settings) if (!settings.sharesPrivatePaykitEndpoints || endpointIdentifiers.isEmpty()) return null val savedKeys = savedPublicKeys.mapNotNull(PubkyPublicKeyFormat::normalized).distinct() - val identityStatus = paykitSdkService.identityStatus() + val identityStatus = paykitSdkService.identityStatus(priority) if ( savedKeys.isEmpty() || - identityStatus?.liveSessionAvailable != true || + identityStatus?.capability != PubkyIdentityCapability.PRIVATE_LINK_CAPABLE || !PubkyPublicKeyFormat.matches(identityStatus.publicKey, expectedIdentity) ) { return null } - val linkedPaths = mutableMapOf>() - paykitSdkService.linkedPeers() + val linkedPublicKeys = paykitSdkService.linkedPeers(priority) .filter { it.state == LinkedPeerState.LINKED } - .forEach { peer -> - val publicKey = PubkyPublicKeyFormat.normalized(peer.counterparty) ?: return@forEach - if (peer.counterpartyReceiverPath in PaykitReceiverPaths.supported) { - linkedPaths.getOrPut(publicKey, ::mutableSetOf) += peer.counterpartyReceiverPath - } - } + .mapNotNull { PubkyPublicKeyFormat.normalized(it.counterparty) } + .toSet() return PaykitPaymentRequestTargetContext( savedPublicKeys = savedKeys, - linkedReceiverPaths = linkedPaths.mapValues { it.value.toSet() }, + linkedPublicKeys = linkedPublicKeys, ) } @@ -1090,39 +1330,33 @@ class PaykitPaymentRequestRepo @Inject constructor( ): PaykitPaymentRequestTargetDiscovery { var isComplete = true val failedPublicKeys = mutableSetOf() - val targets = context.savedPublicKeys.mapNotNull { publicKey -> - val linked = context.linkedReceiverPaths[publicKey] ?: return@mapNotNull null - val lookup = withTimeoutOrNull(TARGET_DISCOVERY_TIMEOUT) { - runSuspendCatching { paykitSdkService.paymentRequestReceiverPaths(publicKey, lane) } - } - if (lookup == null) { - isComplete = false - failedPublicKeys += publicKey - Logger.warn( - "Timed out inspecting payment request support for '${PubkyPublicKeyFormat.redacted(publicKey)}'", - context = TAG, - ) - return@mapNotNull previousTargets[publicKey]?.takeIf { it.receiverPath in linked } - } + val linkedKeys = context.savedPublicKeys.filter { it in context.linkedPublicKeys } + val targets = paymentRequestCapabilities(linkedKeys, lane).mapNotNull { (publicKey, lookup) -> val capable = lookup .onFailure { - isComplete = false - failedPublicKeys += publicKey Logger.warn( "Failed to inspect payment request support for '${PubkyPublicKeyFormat.redacted(publicKey)}'", it, context = TAG, ) } - .getOrElse { - return@mapNotNull previousTargets[publicKey]?.takeIf { it.receiverPath in linked } - } - val receiverPath = PaykitReceiverPaths.ordered.firstOrNull { it in linked && it in capable } - ?: run { - isComplete = false - return@mapNotNull null + .getOrNull() + if (capable == null) { + isComplete = false + failedPublicKeys += publicKey + if (lookup.isSuccess) { + Logger.warn( + "Timed out inspecting request support for '${PubkyPublicKeyFormat.redacted(publicKey)}'", + context = TAG, + ) } - PaykitPaymentRequestTarget(publicKey, receiverPath) + return@mapNotNull previousTargets[publicKey] + } + if (!capable) { + isComplete = false + return@mapNotNull null + } + PaykitPaymentRequestTarget(publicKey) } return PaykitPaymentRequestTargetDiscovery( targets = targets, @@ -1131,6 +1365,17 @@ class PaykitPaymentRequestRepo @Inject constructor( ) } + private suspend fun paymentRequestCapabilities(publicKeys: List, lane: PaykitReadLane) = coroutineScope { + val permits = Semaphore(PAYMENT_REQUEST_DISCOVERY_CONCURRENCY) + publicKeys.map { publicKey -> + async { + permits.withPermit { + publicKey to runSuspendCatching { paykitSdkService.canReceivePaymentRequests(publicKey, lane) } + } + } + }.awaitAll() + } + private fun acceptedPaymentEndpointIdentifiers(settings: SettingsData): List = buildList { if (PublicPaykitRepo.isLightningPaymentOptionEnabled(settings)) add(MethodId.Bolt11.rawValue) if (PublicPaykitRepo.isOnchainPaymentOptionEnabled(settings)) { @@ -1141,6 +1386,25 @@ class PaykitPaymentRequestRepo @Inject constructor( private suspend fun isAvailable(): Boolean = activeIdentity != null && PaykitFeatureFlags.isUiEnabled(settingsStore.isPaykitEnabled.first()) + private fun isLocallyPayable(request: PaykitPaymentRequest): Boolean = + request.billingPeriod != null || request.lifecycleState != PaymentRequestLifecycleState.ACCEPTED || + hasLocalAcceptance(request) + + private fun hasLocalAcceptance(request: PaykitPaymentRequest): Boolean = + activatedGeneration == stateGeneration.get() && request.id in acceptedOneTimeRequestIds + + private fun hasCurrentExecutionContext(request: PaykitPaymentRequest): Boolean { + if (request.billingPeriod == null) { + return hasLocalAcceptance(request) && _paymentRequestHistory.value.any { + it.id == request.id && it.lifecycleState == PaymentRequestLifecycleState.ACCEPTED + } + } + return activatedGeneration == stateGeneration.get() && _subscriptions.value.any { + it.isPayer && it.lifecycleState == PaymentRequestLifecycleState.ACTIVE_RECURRING && + request.belongsTo(it) && request.billingPeriod !in it.paidPeriods + } + } + private suspend fun updateRequest( request: PaykitPaymentRequest, resultingState: PaymentRequestLifecycleState, @@ -1158,14 +1422,18 @@ class PaykitPaymentRequestRepo @Inject constructor( val current = _pendingRequests.value.firstOrNull { it.id == request.id } ?: throw PaykitPaymentRequestError.RequestUnavailable + actionVersion++ operation(current) - processPendingMessages() + if (resultingState != PaymentRequestLifecycleState.ACCEPTED) processPendingMessages() val updatedRequest = current.copy(lifecycleState = resultingState) _paymentRequestHistory.update { requests -> listOf(updatedRequest) + requests.filterNot { it.id == current.id } } _pendingRequests.update { requests -> requests.filterNot { it.id == current.id } } discardExpiredRequestsLocked() + if (resultingState == PaymentRequestLifecycleState.ACCEPTED) { + scheduleAcceptedResponse(current.counterparty) + } Unit } } finally { @@ -1174,6 +1442,18 @@ class PaykitPaymentRequestRepo @Inject constructor( } } + private fun scheduleAcceptedResponse(counterparty: String) { + val identity = activeIdentity ?: return + val generation = stateGeneration.get() + repoScope.launch { + isPaymentSubmissionActive.first { !it } + if (!isCurrentState(generation, identity)) return@launch + runSuspendCatching { + paykitSdkService.processOutboundPrivateMessages(counterparty, Priority.Background, identity) + }.onFailure { Logger.warn("Failed to deliver accepted Paykit payment request", it, context = TAG) } + } + } + private suspend fun updateSubscription( subscription: PaykitSubscription, operation: suspend (PaykitSubscription) -> PaymentRequestRecord, @@ -1182,6 +1462,7 @@ class PaykitPaymentRequestRepo @Inject constructor( operationMutex.withLock { val current = _subscriptions.value.firstOrNull { it.id == subscription.id } ?: throw PaykitPaymentRequestError.RequestUnavailable + actionVersion++ val record = operation(current) processPendingMessages() val identity = activeIdentity ?: throw PaykitPaymentRequestError.RequestUnavailable @@ -1253,8 +1534,23 @@ class PaykitPaymentRequestRepo @Inject constructor( scheduleExpirationLocked() } - private suspend fun processPendingMessages(): List = - runSuspendCatching { paykitSdkService.processPendingPrivateMessages() } + private suspend fun processPendingMessages( + counterparty: String? = null, + priority: Priority = Priority.Ordered, + ): List = + runSuspendCatching { + if (counterparty == null) { + paykitSdkService.processPendingPrivateMessages(priority) + } else { + listOf( + OutboundPrivateCounterpartySendReport( + counterparty = counterparty, + report = paykitSdkService.processOutboundPrivateMessages(counterparty, Priority.Interactive), + error = null, + ), + ) + } + } .onSuccess(::logOutboundFailures) .onFailure { Logger.warn("Failed to deliver pending Paykit private messages", it, context = TAG) } .getOrDefault(emptyList()) @@ -1300,6 +1596,26 @@ class PaykitPaymentRequestRepo @Inject constructor( scheduleExpirationLocked() } + private suspend fun pruneAcceptedOneTimeRequestIds( + records: List, + identity: String, + generation: Long, + ) { + val finishedIds = records.filter { + it.localRole == PaymentRequestLocalRole.PAYER && it.terms?.recurrence == null && + it.state in setOf( + PaymentRequestLifecycleState.PROOF_SUBMITTED, + PaymentRequestLifecycleState.CANCELED, + PaymentRequestLifecycleState.REJECTED, + ) + }.mapTo(mutableSetOf()) { PaykitPaymentRequestId(it.paymentRequestId, it.counterparty) } + .intersect(acceptedOneTimeRequestIds) + if (finishedIds.isEmpty()) return + runSuspendCatching { presentationStore.removeAcceptedOneTimeIds(identity, finishedIds) } + .onSuccess { if (isCurrentState(generation, identity)) acceptedOneTimeRequestIds = it } + .onFailure { Logger.warn("Failed to prune accepted Paykit payment requests", it, context = TAG) } + } + private suspend fun prunePresentedRequestIds(requests: List) { val requestIds = requests.mapTo(mutableSetOf()) { it.id } val prunedIds = presentedRequestIds.intersect(requestIds) @@ -1333,6 +1649,9 @@ class PaykitPaymentRequestRepo @Inject constructor( } private fun clearStateLocked() { + setPaymentSubmissionActive(false) + completedRefreshGeneration = -1L + receivedContacts = null expirationJob?.cancel() expirationJob = null _pendingRequests.update { emptyList() } @@ -1354,6 +1673,9 @@ class PaykitPaymentRequestRepo @Inject constructor( .filter { it.lifecycleState == PaymentRequestLifecycleState.PROPOSED } .mapNotNull { it.expiresAt } .map { it - now } + val paymentDeadlineDelays = _pendingRequests.value + .mapNotNull { it.paymentDeadlineAt } + .map { it - now + 1.nanoseconds } // Subscription dates run on the subscription clock, which the offset can move ahead of real time. val subscriptionDelays = _subscriptions.value .filter { @@ -1363,7 +1685,8 @@ class PaykitPaymentRequestRepo @Inject constructor( .flatMap { listOfNotNull(it.proposalExpiresAt, it.recurrence.endsAt) } .filter { it > subscriptionNow } .map { it - subscriptionNow } - val delayDuration = (requestDelays + subscriptionDelays).minOrNull()?.coerceAtLeast(Duration.ZERO) + val delayDuration = (requestDelays + paymentDeadlineDelays + subscriptionDelays) + .minOrNull()?.coerceAtLeast(Duration.ZERO) ?: return expirationJob = repoScope.launch { delay(delayDuration) @@ -1376,7 +1699,7 @@ class PaykitPaymentRequestRepo @Inject constructor( } private fun List.withExpiredLifecycle(now: Instant): List = map { request -> - if (request.lifecycleState == PaymentRequestLifecycleState.PROPOSED && request.isExpired(now)) { + if (request.isProposalExpired(now)) { request.copy(lifecycleState = PaymentRequestLifecycleState.PROPOSAL_EXPIRED) } else { request @@ -1444,9 +1767,15 @@ private fun PaymentRequestRecord.parsePaykitPaymentRequest( }, ) } - if (requiresActionableRequest && requestTerms.paymentDeadline != null) { + val paymentDeadlineAt = (requestTerms.paymentDeadline as? PaymentDeadline.At)?.timestamp + ?.takeIf { it.endsWith('Z') } + ?.let { runCatching { Instant.parse(it) }.getOrNull() } + if (requiresActionableRequest && requestTerms.paymentDeadline != null && paymentDeadlineAt == null) { return PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.UnsupportedPaymentDeadline) } + if (requiresActionableRequest && paymentDeadlineAt?.let { now > it } == true) { + return PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.Expired) + } if (requestTerms.amount.asset != PaykitIssuerInterop.BITCOIN_ASSET) { return PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.UnsupportedAsset) } @@ -1472,7 +1801,7 @@ private fun PaymentRequestRecord.parsePaykitPaymentRequest( return PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.Expired) } - val parsedTerms = ParsedPaykitPaymentRequestTerms(requestTerms, amountSats, endpoints, expiresAt) + val parsedTerms = ParsedPaykitPaymentRequestTerms(requestTerms, amountSats, endpoints, expiresAt, paymentDeadlineAt) return PaykitPaymentRequestParseResult.Parsed(toPaykitPaymentRequest(expectedRole, parsedTerms, now)) } @@ -1483,12 +1812,12 @@ private fun PaymentRequestRecord.toPaykitPaymentRequest( ) = PaykitPaymentRequest( paymentRequestId = paymentRequestId, counterparty = counterparty, - counterpartyReceiverPath = counterpartyReceiverPath, amountValue = parsedTerms.terms.amount.value, amountSats = parsedTerms.amountSats, note = parsedTerms.terms.metadata.note(), createdAt = lastEventAt?.let { runCatching { Instant.parse(it) }.getOrNull() }, expiresAt = parsedTerms.expiresAt, + paymentDeadlineAt = parsedTerms.paymentDeadlineAt, acceptedPaymentEndpointIdentifiers = parsedTerms.endpoints, deliveryStatus = if (expectedRole == PaymentRequestLocalRole.PAYEE) { if (proposalOutboundStatus == OutboundPrivateMessageStatus.SENT) { @@ -1530,19 +1859,11 @@ private fun PaymentRequestRecord.toCreatedPaykitPaymentRequest( target: PaykitPaymentRequestTarget, endpointIdentifiers: List, createdAt: Instant, - reports: List, + wasSent: Boolean, ): PaykitPaymentRequest { - val wasSent = proposalOutboundMessageId?.let { messageId -> - reports.any { report -> - PubkyPublicKeyFormat.matches(report.counterparty, counterparty) && - report.counterpartyReceiverPath == counterpartyReceiverPath && - messageId in report.report?.sent.orEmpty() - } - } == true return PaykitPaymentRequest( paymentRequestId = paymentRequestId, counterparty = target.publicKey, - counterpartyReceiverPath = target.receiverPath, amountValue = draft.amountSats.toBitcoinAmount(), amountSats = draft.amountSats, note = draft.note.takeIf(String::isNotBlank), diff --git a/app/src/main/java/to/bitkit/repositories/PaykitReceivedPaymentContacts.kt b/app/src/main/java/to/bitkit/repositories/PaykitReceivedPaymentContacts.kt new file mode 100644 index 0000000000..680103b9f4 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PaykitReceivedPaymentContacts.kt @@ -0,0 +1,100 @@ +package to.bitkit.repositories + +import com.synonym.bitkitcore.validateBitcoinAddress +import com.synonym.paykit.PaymentRequestLifecycleState +import com.synonym.paykit.PaymentRequestLocalRole +import com.synonym.paykit.PaymentRequestRecord +import org.lightningdevkit.ldknode.Bolt11Invoice +import org.lightningdevkit.ldknode.Currency +import org.lightningdevkit.ldknode.Network +import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.models.toLdkNetwork + +@ConsistentCopyVisibility +internal data class PaykitReceivedPaymentContacts private constructor( + private val addresses: Map>, + private val paymentHashes: Map>, +) { + companion object { + val Empty = PaykitReceivedPaymentContacts(emptyMap(), emptyMap()) + + fun from( + records: List, + network: Network, + parseInvoice: (String) -> Bolt11Invoice = Bolt11Invoice::fromStr, + ): PaykitReceivedPaymentContacts { + val addresses = mutableMapOf>() + val hashes = mutableMapOf>() + for (record in records) { + val contact = validCounterparty(record) ?: continue + val terms = checkNotNull(record.terms) + // Use immutable request destinations for attribution, not proofs or current lists. + val acceptedEndpoints = terms.paymentEndpoints.orEmpty() + .filterKeys(terms.acceptedPaymentEndpointIdentifiers::contains) + for ((identifier, payload) in acceptedEndpoints) { + val endpoint = PublicPaykitRepo.parseEndpoint(identifier, payload, network) ?: continue + val value = normalizeAddressCase(endpoint.value) + runCatching { + when { + endpoint.methodId.isOnchain && isValidAddress(value, network) -> { + addresses.getOrPut(value) { mutableSetOf() }.add(contact) + } + endpoint.methodId == MethodId.Bolt11 -> { + val invoice = parseInvoice(value) + if (invoice.currency() == currency(network)) { + hashes.getOrPut(invoice.paymentHash()) { mutableSetOf() }.add(contact) + } + } + } + } + } + } + return if (addresses.isEmpty() && hashes.isEmpty()) { + Empty + } else { + PaykitReceivedPaymentContacts(addresses, hashes) + } + } + + private fun validCounterparty(record: PaymentRequestRecord): String? { + if (record.localRole != PaymentRequestLocalRole.PAYEE || record.invalidReason != null) return null + if (record.state == PaymentRequestLifecycleState.INVALID_CONFLICT || + record.state == PaymentRequestLifecycleState.UNKNOWN + ) { + return null + } + if (record.terms?.amount?.asset != PaykitIssuerInterop.BITCOIN_ASSET) return null + if (record.counterparty.trim().length > PubkyPublicKeyFormat.maximumInputLength) return null + return PubkyPublicKeyFormat.normalized(record.counterparty) + } + + private fun normalizeAddressCase(value: String): String { + if (value != value.uppercase()) return value + return if (value.startsWith("BC1") || value.startsWith("TB1") || value.startsWith("BCRT1")) { + value.lowercase() + } else { + value + } + } + + private fun isValidAddress(value: String, network: Network): Boolean { + val addressNetwork = validateBitcoinAddress(value).network.toLdkNetwork() + if (addressNetwork == network) return true + if (addressNetwork != Network.TESTNET) return false + return network == Network.SIGNET || + (network == Network.REGTEST && value.firstOrNull() in listOf('2', 'm', 'n')) + } + + private fun currency(network: Network): Currency = when (network) { + Network.BITCOIN -> Currency.BITCOIN + Network.TESTNET -> Currency.BITCOIN_TESTNET + Network.SIGNET -> Currency.SIGNET + Network.REGTEST -> Currency.REGTEST + } + } + + fun contactsForAddresses(values: Collection): Set = + values.flatMapTo(mutableSetOf()) { addresses[it].orEmpty() } + + fun contactsForPaymentHash(value: String): Set = paymentHashes[value.lowercase()].orEmpty() +} diff --git a/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt b/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt index b4d3dc61a2..a49ae1103e 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt @@ -168,13 +168,11 @@ enum class PaykitSubscriptionRole { Payer, Payee } data class PaykitSubscriptionId( val paymentRequestId: String, val counterparty: String, - val counterpartyReceiverPath: String, ) data class PaykitSubscription( val paymentRequestId: String, val counterparty: String, - val counterpartyReceiverPath: String, val amountValue: String, val amountSats: ULong, val note: String?, @@ -191,7 +189,7 @@ data class PaykitSubscription( val hasPaymentDeadline: Boolean = false, ) { val id: PaykitSubscriptionId - get() = PaykitSubscriptionId(paymentRequestId, counterparty, counterpartyReceiverPath) + get() = PaykitSubscriptionId(paymentRequestId, counterparty) val isPayer: Boolean get() = role == PaykitSubscriptionRole.Payer @@ -244,7 +242,6 @@ data class PaykitSubscription( PaykitPaymentRequest( paymentRequestId = paymentRequestId, counterparty = counterparty, - counterpartyReceiverPath = counterpartyReceiverPath, amountValue = amountValue, amountSats = amountSats, note = note, @@ -272,7 +269,6 @@ data class PaykitSubscription( PaykitPaymentRequest( paymentRequestId = paymentRequestId, counterparty = counterparty, - counterpartyReceiverPath = counterpartyReceiverPath, amountValue = amountValue, amountSats = amountSats, note = note, @@ -336,7 +332,6 @@ internal fun PaymentRequestRecord.toPaykitSubscription( return PaykitSubscription( paymentRequestId = paymentRequestId, counterparty = counterparty, - counterpartyReceiverPath = counterpartyReceiverPath, amountValue = requestTerms.amount.value, amountSats = amountSats, note = requestTerms.metadata.note()?.take(256), diff --git a/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionNotificationScheduler.kt b/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionNotificationScheduler.kt index 3bb9e0d758..ee990ca5dc 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionNotificationScheduler.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionNotificationScheduler.kt @@ -21,7 +21,6 @@ import to.bitkit.di.SubscriptionClock import to.bitkit.ext.runSuspendCatching import to.bitkit.ui.EXTRA_PAYKIT_BILLING_PERIOD_STARTS_AT import to.bitkit.ui.EXTRA_PAYKIT_COUNTERPARTY -import to.bitkit.ui.EXTRA_PAYKIT_COUNTERPARTY_RECEIVER_PATH import to.bitkit.ui.EXTRA_PAYKIT_PAYER_IDENTITY import to.bitkit.ui.EXTRA_PAYKIT_PAYMENT_REQUEST_ID import to.bitkit.ui.EXTRA_PAYKIT_SUBSCRIPTION_PAYMENT_DUE @@ -81,7 +80,7 @@ class PaykitSubscriptionNotificationScheduler @Inject constructor( } val pendingWorkNames = pendingRequestIds.mapNotNullTo(mutableSetOf()) { requestId -> requestId.billingPeriodStartsAt?.let { - "$WORK_PREFIX$payerIdentity|${requestId.counterparty}|${requestId.counterpartyReceiverPath}|" + + "$WORK_PREFIX$payerIdentity|${requestId.counterparty}|" + "${requestId.paymentRequestId}|$it" } } @@ -133,7 +132,7 @@ class PaykitSubscriptionNotificationScheduler @Inject constructor( private fun workName(payerIdentity: String, subscription: PaykitSubscription, period: PaykitBillingPeriod) = "$WORK_PREFIX$payerIdentity|${subscription.counterparty}|" + - "${subscription.counterpartyReceiverPath}|${subscription.paymentRequestId}|${period.startsAt}" + "${subscription.paymentRequestId}|${period.startsAt}" private fun notificationWork( payerIdentity: String, @@ -147,7 +146,6 @@ class PaykitSubscriptionNotificationScheduler @Inject constructor( EXTRA_PAYKIT_PAYMENT_REQUEST_ID to subscription.paymentRequestId, EXTRA_PAYKIT_PAYER_IDENTITY to payerIdentity, EXTRA_PAYKIT_COUNTERPARTY to subscription.counterparty, - EXTRA_PAYKIT_COUNTERPARTY_RECEIVER_PATH to subscription.counterpartyReceiverPath, EXTRA_PAYKIT_BILLING_PERIOD_STARTS_AT to period.startsAt.toString(), ) ) @@ -206,10 +204,6 @@ class PaykitSubscriptionNotificationWorker @AssistedInject constructor( putString(EXTRA_PAYKIT_PAYER_IDENTITY, inputData.getString(EXTRA_PAYKIT_PAYER_IDENTITY)) putString(EXTRA_PAYKIT_PAYMENT_REQUEST_ID, inputData.getString(EXTRA_PAYKIT_PAYMENT_REQUEST_ID)) putString(EXTRA_PAYKIT_COUNTERPARTY, inputData.getString(EXTRA_PAYKIT_COUNTERPARTY)) - putString( - EXTRA_PAYKIT_COUNTERPARTY_RECEIVER_PATH, - inputData.getString(EXTRA_PAYKIT_COUNTERPARTY_RECEIVER_PATH), - ) putString( EXTRA_PAYKIT_BILLING_PERIOD_STARTS_AT, inputData.getString(EXTRA_PAYKIT_BILLING_PERIOD_STARTS_AT), diff --git a/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionProposal.kt b/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionProposal.kt index e7cf4342bc..f22fa3ed14 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionProposal.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionProposal.kt @@ -26,6 +26,7 @@ internal object PaykitSubscriptionProposal { val wire = buildJsonObject { put("version", 1) put("kind", "paykit.payment_request") + put("app_id", "bitkit") put("event_id", uuid) put("payment_request_id", uuid) putJsonObject("request") { @@ -45,6 +46,7 @@ internal object PaykitSubscriptionProposal { putJsonArray("accepted_payment_endpoint_identifiers") { terms.acceptedPaymentEndpointIdentifiers.forEach { add(JsonPrimitive(it)) } } + put("required_app_id", "bitkit") put("metadata", Json.parseToJsonElement(terms.metadataJson)) } } diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt index 4b889a8ee6..db7d9d830e 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt @@ -22,7 +22,6 @@ import to.bitkit.models.addressTypeFromAddress import to.bitkit.models.toAddressType import to.bitkit.models.toSettingsString import to.bitkit.services.CoreService -import to.bitkit.services.PaykitReceiverPaths import to.bitkit.utils.AppError import to.bitkit.utils.Logger import javax.inject.Inject @@ -34,20 +33,6 @@ sealed class PrivatePaykitAddressReservationError(message: String) : AppError(me ) } -internal data class ContactAssignmentKey( - val publicKey: String, - val receiverPath: String, -) { - fun encoded(): String = - if (receiverPath == PaykitReceiverPaths.WALLET) publicKey else "$publicKey$SEPARATOR$receiverPath" - - companion object { - private const val SEPARATOR = '#' - - fun publicKeyOf(encoded: String): String = encoded.substringBefore(SEPARATOR) - } -} - @Singleton @Suppress("TooManyFunctions") class PrivatePaykitAddressReservationRepo @Inject constructor( @@ -62,7 +47,15 @@ class PrivatePaykitAddressReservationRepo @Inject constructor( } private val mutex = Mutex() + + @Volatile + internal var attributionVersion = 0L + private set private var ledger: PrivatePaykitReservationData? = null + set(value) { + if (field != value) attributionVersion++ + field = value + } private val _backupStateVersion = MutableStateFlow(0L) val backupStateVersion: StateFlow = _backupStateVersion.asStateFlow() @@ -98,10 +91,9 @@ class PrivatePaykitAddressReservationRepo @Inject constructor( suspend fun currentOrRotatedAddress( publicKey: String, - receiverPath: String, ): Result = withContext(ioDispatcher) { runSuspendCatching { - val assignmentKey = contactAssignmentKey(publicKey, receiverPath) + val assignmentKey = normalizedPublicKey(publicKey) val current = locked { it.contactAssignments[assignmentKey] } if (current != null && isAddressTypeMonitored(current.addressType)) { val address = resolvedAddress(current).getOrThrow() @@ -169,8 +161,8 @@ class PrivatePaykitAddressReservationRepo @Inject constructor( assignments.firstOrNull { (_, assignment) -> assignment.addressType == addressType && - (assignment.address == address || resolvedAddress(assignment).getOrNull() == address) - }?.first?.publicKeyFromAssignmentKey() + (assignment.address == address || resolvedAddress(assignment).getOrThrow() == address) + }?.first } suspend fun currentContactPublicKeyForReservedAddress(address: String): String? = withContext(ioDispatcher) { @@ -180,7 +172,7 @@ class PrivatePaykitAddressReservationRepo @Inject constructor( assignments.firstOrNull { (_, assignment) -> assignment.addressType == addressType && (assignment.address == address || resolvedAddress(assignment).getOrNull() == address) - }?.first?.publicKeyFromAssignmentKey() + }?.first } suspend fun contactsWithUsedReservedAddresses(): List = withContext(ioDispatcher) { @@ -191,13 +183,13 @@ class PrivatePaykitAddressReservationRepo @Inject constructor( .onFailure { Logger.warn( "Failed to check private Paykit address usage for " + - "'${redacted(publicKey.publicKeyFromAssignmentKey())}'", + "'${redacted(publicKey)}'", it, context = TAG, ) } .getOrDefault(false) - publicKey.publicKeyFromAssignmentKey().takeIf { isUsed } + publicKey.takeIf { isUsed } }.distinct() } @@ -207,40 +199,30 @@ class PrivatePaykitAddressReservationRepo @Inject constructor( return lightningRepo.getAddressBalance(address).getOrDefault(0u) > 0u } - suspend fun clearContactAssignment(publicKey: String) = withContext(ioDispatcher) { - val normalizedKey = normalizedPublicKey(publicKey) + suspend fun clearContactAssignments(excludingPublicKeys: Collection) = withContext(ioDispatcher) { + val savedKeys = excludingPublicKeys.mapNotNull { normalizedPublicKeyOrNull(it) }.toSet() locked { current -> - val hadAssignment = current.contactAssignments.keys.any { - it.publicKeyFromAssignmentKey() == normalizedKey - } - val hadHistory = current.contactAssignmentHistory.keys.any { - it.publicKeyFromAssignmentKey() == normalizedKey - } - if (!hadAssignment && !hadHistory) return@locked val next = current.copy( contactAssignments = current.contactAssignments.filterKeys { - it.publicKeyFromAssignmentKey() != normalizedKey + it in savedKeys }, contactAssignmentHistory = current.contactAssignmentHistory.filterKeys { - it.publicKeyFromAssignmentKey() != normalizedKey + it in savedKeys }, ) + if (next == current) return@locked ledger = next persist(next) notifyBackupStateChanged() } } - suspend fun clearContactAssignments(excludingPublicKeys: Collection) = withContext(ioDispatcher) { - val savedKeys = excludingPublicKeys.mapNotNull { normalizedPublicKeyOrNull(it) }.toSet() + suspend fun removeContactAssignments(publicKeys: Collection) = withContext(ioDispatcher) { + val removedKeys = publicKeys.mapNotNull { normalizedPublicKeyOrNull(it) }.toSet() locked { current -> val next = current.copy( - contactAssignments = current.contactAssignments.filterKeys { - it.publicKeyFromAssignmentKey() in savedKeys - }, - contactAssignmentHistory = current.contactAssignmentHistory.filterKeys { - it.publicKeyFromAssignmentKey() in savedKeys - }, + contactAssignments = current.contactAssignments.filterKeys { it !in removedKeys }, + contactAssignmentHistory = current.contactAssignmentHistory.filterKeys { it !in removedKeys }, ) if (next == current) return@locked ledger = next @@ -392,11 +374,6 @@ class PrivatePaykitAddressReservationRepo @Inject constructor( private fun normalizedPublicKeyOrNull(publicKey: String): String? = PubkyPublicKeyFormat.normalized(publicKey) - private fun contactAssignmentKey(publicKey: String, receiverPath: String): String = - ContactAssignmentKey(normalizedPublicKey(publicKey), receiverPath).encoded() - - private fun String.publicKeyFromAssignmentKey(): String = ContactAssignmentKey.publicKeyOf(this) - private fun redacted(publicKey: String): String = PubkyPublicKeyFormat.redacted(publicKey) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt index ebb38c4c7f..47c01cdf88 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext import to.bitkit.data.PrivatePaykitCacheStore import to.bitkit.di.IoDispatcher +import to.bitkit.models.PubkyPublicKeyFormat import javax.inject.Inject import javax.inject.Provider import javax.inject.Singleton @@ -14,24 +15,54 @@ class PrivatePaykitContactResolver @Inject constructor( @IoDispatcher private val ioDispatcher: CoroutineDispatcher, private val cacheStore: PrivatePaykitCacheStore, private val addressReservationRepo: Provider, + private val paymentRequestRepo: Provider, ) { + internal val receivedPaymentContacts: PaykitReceivedPaymentContacts + get() = paymentRequestRepo.get().receivedPaymentContacts + + internal val receivedPaymentContactsGeneration: Long + get() = paymentRequestRepo.get().receivedPaymentContactsGeneration + + internal val reservationVersion: Long + get() = addressReservationRepo.get().attributionVersion + suspend fun contactPublicKeyForPrivateInvoicePaymentHash(paymentHash: String): String? = withContext(ioDispatcher) { if (paymentHash.isBlank()) return@withContext null - cacheStore.data.first().contacts.firstNotNullOfOrNull { (publicKey, contactState) -> + val shared = receivedPaymentContacts + val contacts = cacheStore.data.first().contacts.mapNotNull { (publicKey, contactState) -> publicKey.takeIf { - contactState.localInvoicesByReceiverPath.values.any { invoice -> - invoice.paymentHash == paymentHash - } || + contactState.localInvoice?.paymentHash == paymentHash || paymentHash in contactState.receivedInvoicePaymentHashes } } + if (receivedPaymentContacts !== shared) return@withContext null + (contacts + shared.contactsForPaymentHash(paymentHash)) + .mapNotNull(PubkyPublicKeyFormat::normalized).distinct().singleOrNull() } - suspend fun contactPublicKeyForPrivateOnchainAddresses(addresses: Collection): String? = + suspend fun contactPublicKeyForReservedAddress(address: String): String? = withContext(ioDispatcher) { + addressReservationRepo.get().contactPublicKeyForReservedAddress(address) + } + + suspend fun contactPublicKeyForPrivateOnchainAddresses( + receivingAddress: String?, + addresses: Collection, + ): String? = withContext(ioDispatcher) { - addresses.firstNotNullOfOrNull { - addressReservationRepo.get().contactPublicKeyForReservedAddress(it) + if (receivingAddress.isNullOrBlank() || receivingAddress !in addresses) return@withContext null + val shared = receivedPaymentContacts + val reservedContacts = addresses.distinct().associateWith { + contactPublicKeyForReservedAddress(it) + } + val receivingContacts = listOfNotNull(reservedContacts[receivingAddress]) + + shared.contactsForAddresses(listOf(receivingAddress)) + val contact = receivingContacts.mapNotNull(PubkyPublicKeyFormat::normalized).distinct().singleOrNull() + ?: return@withContext null + if (receivedPaymentContacts !== shared) return@withContext null + contact.takeIf { + (reservedContacts.values.filterNotNull() + shared.contactsForAddresses(addresses)) + .mapNotNull(PubkyPublicKeyFormat::normalized).distinct().singleOrNull() == contact } } } diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitModels.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitModels.kt index 785f26ead8..49984ab725 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitModels.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitModels.kt @@ -35,36 +35,36 @@ internal data class PrivatePaykitState( internal data class ContactState( var remoteEndpoints: List = emptyList(), - var consumedPrivatePaymentListVersionsByReceiverPath: Map = emptyMap(), - var localInvoicesByReceiverPath: Map = emptyMap(), + var consumedPrivatePaymentListVersion: ULong? = null, + var localInvoice: StoredInvoice? = null, var receivedInvoicePaymentHashes: List = emptyList(), - var publishedPrivatePaymentReceiverPaths: Set = emptySet(), + var hasPublishedPrivatePaymentList: Boolean = false, ) { constructor(cache: PrivatePaykitContactCacheData) : this( remoteEndpoints = cache.remoteEndpoints.map { StoredPaymentEntry(it.methodId, it.endpointData) }, - consumedPrivatePaymentListVersionsByReceiverPath = cache.consumedPrivatePaymentListVersionsByReceiverPath, - localInvoicesByReceiverPath = cache.localInvoicesByReceiverPath.mapValues { (_, invoice) -> + consumedPrivatePaymentListVersion = cache.consumedPrivatePaymentListVersion, + localInvoice = cache.localInvoice?.let { invoice -> StoredInvoice(invoice.bolt11, invoice.paymentHash, invoice.expiresAt) }, receivedInvoicePaymentHashes = cache.receivedInvoicePaymentHashes, - publishedPrivatePaymentReceiverPaths = cache.publishedPrivatePaymentReceiverPaths, + hasPublishedPrivatePaymentList = cache.hasPublishedPrivatePaymentList, ) val hasCacheState: Boolean - get() = publishedPrivatePaymentReceiverPaths.isNotEmpty() || + get() = hasPublishedPrivatePaymentList || remoteEndpoints.isNotEmpty() || - consumedPrivatePaymentListVersionsByReceiverPath.isNotEmpty() || - localInvoicesByReceiverPath.isNotEmpty() || + (consumedPrivatePaymentListVersion != null) || + (localInvoice != null) || receivedInvoicePaymentHashes.isNotEmpty() fun cacheState() = PrivatePaykitContactCacheData( remoteEndpoints = remoteEndpoints.map { PrivatePaykitStoredPaymentEntryData(it.methodId, it.endpointData) }, - consumedPrivatePaymentListVersionsByReceiverPath = consumedPrivatePaymentListVersionsByReceiverPath, - localInvoicesByReceiverPath = localInvoicesByReceiverPath.mapValues { (_, invoice) -> + consumedPrivatePaymentListVersion = consumedPrivatePaymentListVersion, + localInvoice = localInvoice?.let { invoice -> PrivatePaykitStoredInvoiceData(invoice.bolt11, invoice.paymentHash, invoice.expiresAt) }, receivedInvoicePaymentHashes = receivedInvoicePaymentHashes, - publishedPrivatePaymentReceiverPaths = publishedPrivatePaymentReceiverPaths, + hasPublishedPrivatePaymentList = hasPublishedPrivatePaymentList, ) } @@ -76,7 +76,7 @@ internal data class StoredPaymentEntry( @Serializable internal data class PrivatePaykitBackup( val sdkState: String, - val consumedPrivatePaymentListVersions: Map>, + val consumedPrivatePaymentListVersions: Map, ) internal data class StoredInvoice( diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 456818145e..a539365f76 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -1,23 +1,25 @@ package to.bitkit.repositories import com.synonym.bitkitcore.Scanner +import com.synonym.paykit.IdentityStatus import com.synonym.paykit.LinkedPeerState +import com.synonym.paykit.PaykitException import com.synonym.paykit.PaymentAmountContext import com.synonym.paykit.PrivatePaymentEndpointReservationInput import com.synonym.paykit.PrivatePaymentListDeliveryReport import com.synonym.paykit.PrivatePaymentListReservationUpdateInput import com.synonym.paykit.PrivatePaymentResolutionState import com.synonym.paykit.PrivatePaymentResolutionStatus +import com.synonym.paykit.PubkyIdentityCapability import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update @@ -43,8 +45,7 @@ import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.services.CoreService import to.bitkit.services.PaykitPreparedPrivateContactPayment import to.bitkit.services.PaykitPrivateContactPaymentResolution -import to.bitkit.services.PaykitReadLane -import to.bitkit.services.PaykitReceiverPaths +import to.bitkit.services.PaykitSdkOperationLock.Priority import to.bitkit.services.PaykitSdkService import to.bitkit.services.PubkyService import to.bitkit.utils.Logger @@ -58,6 +59,7 @@ import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime +import kotlin.time.Instant as KotlinInstant @OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class) @Singleton @@ -80,6 +82,7 @@ class PrivatePaykitRepo @Inject constructor( private const val MAX_RECEIVED_INVOICE_HASHES_PER_CONTACT = 100 private val privateInvoiceExpiry = 24.hours private val invoiceRefreshBuffer = 30.minutes + private val unavailableLinkRetryDelay = 5.minutes // Private links can finish after a contact is added on the other device; // keep draining long enough for staggered mutual adds. @@ -91,7 +94,7 @@ class PrivatePaykitRepo @Inject constructor( 45.seconds, 90.seconds, ) - private val initialLinkBurstRetryDelays = List(14) { 2.seconds } + private val paymentListRetryDelays = List(14) { 2.seconds } private val privatePaymentResolutionRetryDelays = listOf(1.seconds, 3.seconds, 8.seconds) fun isDuplicatePaymentError(error: Throwable): Boolean = @@ -103,26 +106,32 @@ class PrivatePaykitRepo @Inject constructor( private val retryScope = appScope(serializedDispatcher, TAG) private val paymentPublishJobs = ConcurrentHashMap() private val knownSavedContactKeys = mutableSetOf() + private val pendingPreparationKeys = mutableSetOf() + private var activePreparationKeys = emptySet() + private val activeLinkPreparationKeys = mutableSetOf() + private var preparationJob: Job? = null + private var preparationGeneration = 0 + private var isDeletingProfile = false + private var pendingForceRefreshLightning = false + private val unavailableLinkRetryAt = mutableMapOf() private var state: PrivatePaykitState? = null private val pendingMessageDrainRetryLock = Any() - private val pendingMessageDrainRetryKeys = mutableSetOf() + private val pendingMessageDrainRetryKeys = mutableSetOf() private var pendingMessageDrainRetryJob: Job? = null private var pendingMessageDrainRetryGeneration = 0 - private val _initialLinkBurstStarted = MutableSharedFlow(extraBufferCapacity = 1) - val initialLinkBurstStarted: SharedFlow = _initialLinkBurstStarted.asSharedFlow() - private val initialLinkBurstLock = Any() - private val initialLinkBurstPublicKeys = mutableSetOf() - private var initialLinkBurstJob: Job? = null - private var initialLinkBurstGeneration = 0 + + private data class PrivateLinkPreparation( + val publicKeys: List, + val linkRetryKeys: List, + ) private data class PrivatePublicationPreparation( val updates: List, - val linkRetryKeys: List, val firstError: Throwable?, ) private data class PrivateEndpointCleanupPreparation( - val clearedRetryKeys: List, + val clearedRetryKeys: List, val failedPublicKeys: Set, val firstError: Throwable?, ) @@ -132,20 +141,10 @@ class PrivatePaykitRepo @Inject constructor( val invalidKeys: Set, ) - private data class LinkedReceiverPathsSnapshot( - val pathsByPublicKey: Map>, - val error: Throwable?, - ) - private data class PublishedEndpointCleanupState( val remoteEndpoints: List, - val localInvoicesByReceiverPath: Map, - val publishedPrivatePaymentReceiverPaths: Set, - ) - - private data class PrivateMessageDrainRetryKey( - val publicKey: String, - val receiverPath: String, + val localInvoice: StoredInvoice?, + val hasPublishedPrivatePaymentList: Boolean, ) private val _backupStateVersion = MutableStateFlow(0L) @@ -183,7 +182,7 @@ class PrivatePaykitRepo @Inject constructor( runSuspendCatching { val wasCleanupPending = isContactSharingCleanupPending() updateContactSharingCleanupPending(false) - prepareSavedContacts(publicKeys).onFailure { + scheduleSavedContactPreparation(publicKeys).onFailure { if (wasCleanupPending) { runSuspendCatching { updateContactSharingCleanupPending(true) }.onFailure(it::addSuppressed) } @@ -191,6 +190,71 @@ class PrivatePaykitRepo @Inject constructor( } } + suspend fun scheduleSavedContactPreparation(publicKeys: Collection): Result = + withContext(serializedDispatcher) { + runSuspendCatching { + val keys = rememberSavedContacts(publicKeys, replacing = true) + scheduleContactPreparation(keys) + } + } + + suspend fun awaitContactPreparation(): Unit = withContext(serializedDispatcher) { + preparationJob?.join() + } + + private fun scheduleContactPreparation(publicKeys: Collection, forceRefreshLightning: Boolean = false) { + if (isDeletingProfile) return + pendingPreparationKeys.addAll(publicKeys.filter { forceRefreshLightning || it !in activePreparationKeys }) + pendingForceRefreshLightning = pendingForceRefreshLightning || forceRefreshLightning + if (preparationJob?.isActive == true || pendingPreparationKeys.isEmpty()) return + preparationJob = retryScope.launch { + try { + while (pendingPreparationKeys.isNotEmpty()) { + val keys = pendingPreparationKeys.intersect(knownSavedContactKeys) + val forceRefresh = pendingForceRefreshLightning + pendingPreparationKeys.clear() + pendingForceRefreshLightning = false + activePreparationKeys = keys + val generation = preparationGeneration + runSuspendCatching { + if (isContactSharingCleanupPending()) return@runSuspendCatching + if (canPublishPrivateEndpoints()) { + addressReservationRepo.reconcileReservedIndexesWithLdk().getOrThrow() + if (generation != preparationGeneration) return@runSuspendCatching + publishLocalEndpoints(keys, "contact preparation", forceRefresh).getOrThrow() + } else { + prepareRelevantPrivateLinksIfAvailable(keys, "contact preparation") + } + }.onFailure { + Logger.warn("Failed to prepare private Paykit contacts", it, context = TAG) + } + activePreparationKeys = emptySet() + } + } finally { + activePreparationKeys = emptySet() + preparationJob = null + } + } + } + + private fun invalidateContactPreparation() { + preparationGeneration += 1 + pendingPreparationKeys.clear() + activePreparationKeys = emptySet() + pendingForceRefreshLightning = false + activeLinkPreparationKeys.clear() + clearPendingMessageDrainRetries() + } + + suspend fun beginProfileDeletion() = withContext(serializedDispatcher) { + isDeletingProfile = true + invalidateContactPreparation() + } + + suspend fun endProfileDeletion() = withContext(serializedDispatcher) { + isDeletingProfile = false + } + suspend fun refreshSavedContactEndpoints( publicKey: String, savedPublicKeys: Collection, @@ -213,56 +277,17 @@ class PrivatePaykitRepo @Inject constructor( forceRefreshLightning: Boolean = false, ): Result = withContext(serializedDispatcher) { runSuspendCatching { - if (!canPublishPrivateEndpoints()) { - prepareRelevantPrivateLinksIfAvailable(knownSavedContactKeys.toList(), reason) - return@runSuspendCatching - } - publishLocalEndpoints( - publicKeys = knownSavedContactKeys.toList(), - reason = reason, - forceRefreshLightning = forceRefreshLightning, - ).getOrThrow() + scheduleContactPreparation(knownSavedContactKeys.toList(), forceRefreshLightning) }.onFailure { Logger.warn("Failed to refresh private Paykit endpoints for '$reason'", it, context = TAG) } } - fun startInitialLinkBurst(publicKeys: Collection, reason: String) { - val publicKeys = normalizedPublicKeyBatch(publicKeys).normalizedKeys - - synchronized(initialLinkBurstLock) { - initialLinkBurstGeneration += 1 - initialLinkBurstJob?.cancel() - initialLinkBurstJob = null - initialLinkBurstPublicKeys.clear() - initialLinkBurstPublicKeys += publicKeys - if (publicKeys.isEmpty()) return - - val generation = initialLinkBurstGeneration - _initialLinkBurstStarted.tryEmit(Unit) - - initialLinkBurstJob = retryScope.launch { - (listOf(kotlin.time.Duration.ZERO) + initialLinkBurstRetryDelays).forEach { retryDelay -> - delay(retryDelay) - val keys = synchronized(initialLinkBurstLock) { - if (generation != initialLinkBurstGeneration) return@launch - initialLinkBurstPublicKeys.toList() - } - refreshSavedContactEndpointsDuringInitialLinkBurst(keys, reason) - } - synchronized(initialLinkBurstLock) { - if (generation != initialLinkBurstGeneration) return@launch - initialLinkBurstJob = null - initialLinkBurstPublicKeys.clear() - } - } - } - } - suspend fun retryPendingEndpointRemoval( savedPublicKeys: Collection, ): Result = withContext(serializedDispatcher) { runSuspendCatching { + if (isDeletingProfile) return@runSuspendCatching val settings = settingsStore.data.first() val hasDisabledPublications = !settings.sharesPrivatePaykitEndpoints && hasPublishedPrivateEndpoints() val cleanupPending = isContactSharingCleanupPending() @@ -270,6 +295,7 @@ class PrivatePaykitRepo @Inject constructor( if (!cleanupPending) updateContactSharingCleanupPending(true) removePublishedEndpoints().getOrThrow() clearUnsavedContactState(savedPublicKeys).getOrThrow() + syncPaykitAppAfterCleanup().getOrThrow() updateContactSharingCleanupPending(false) } retryPendingDeletedContactEndpointRemoval(savedPublicKeys).getOrThrow() @@ -284,44 +310,39 @@ class PrivatePaykitRepo @Inject constructor( runSuspendCatching { val savedKeys = rememberSavedContacts(savedPublicKeys, replacing = true).toSet() val staleKeys = ensureState().contacts.keys.filter { it !in savedKeys } - staleKeys.forEach { removeSavedContact(it).getOrThrow() } + removeSavedContacts(staleKeys).getOrThrow() addressReservationRepo.clearContactAssignments(excludingPublicKeys = savedKeys) } } - suspend fun removeSavedContact(publicKey: String): Result = withContext(serializedDispatcher) { + suspend fun removeSavedContact(publicKey: String): Result = removeSavedContacts(listOf(publicKey)) + + suspend fun removeSavedContacts(publicKeys: Collection): Result = withContext(serializedDispatcher) { runSuspendCatching { - val normalizedKey = normalizedPublicKey(publicKey) ?: return@runSuspendCatching - knownSavedContactKeys.remove(normalizedKey) - removePublishedEndpoints(normalizedKey).onFailure { - updateDeletedContactCleanupPending(normalizedKey, true) - Logger.warn( - "Failed to remove private Paykit endpoints for '${redacted(normalizedKey)}'", - it, - context = TAG, - ) - }.getOrThrow() - clearContactState(normalizedKey) - addressReservationRepo.clearContactAssignment(normalizedKey) - updateDeletedContactCleanupPending(normalizedKey, false) + val keys = publicKeys.mapNotNull(::normalizedPublicKey).toSet() + if (keys.isEmpty()) return@runSuspendCatching + knownSavedContactKeys.removeAll(keys) + pendingPreparationKeys.removeAll(keys) + keys.forEach(unavailableLinkRetryAt::remove) + if (!isDeletingProfile) { + removePublishedEndpoints(keys).onFailure { + updateDeletedContactCleanupPending(keys, true) + Logger.warn("Failed to remove private Paykit endpoints for deleted contacts", it, context = TAG) + }.getOrThrow() + } + clearContactStates(keys) + addressReservationRepo.removeContactAssignments(keys) + if (!isDeletingProfile) updateDeletedContactCleanupPending(keys, false) } } suspend fun disableSharingAndPruneUnsavedContactState(savedPublicKeys: Collection): Result = withContext(serializedDispatcher) { runSuspendCatching { - val removalError = removePublishedEndpoints().exceptionOrNull() - if (removalError != null) { - updateContactSharingCleanupPending(true) - Logger.warn( - "Deferred private Paykit endpoint cleanup after disable failed", - removalError, - context = TAG, - ) - return@runSuspendCatching - } - + updateContactSharingCleanupPending(true) + removePublishedEndpoints().getOrThrow() clearUnsavedContactState(savedPublicKeys).getOrThrow() + syncPaykitAppAfterCleanup().getOrThrow() updateContactSharingCleanupPending(false) } } @@ -334,23 +355,24 @@ class PrivatePaykitRepo @Inject constructor( } suspend fun removePublishedEndpointsForCleanup(context: String): Result = withContext(serializedDispatcher) { - clearInitialLinkBurst() - removePublishedEndpoints() - .onSuccess { - updateContactSharingCleanupPending(false) - } - .onFailure { - updateContactSharingCleanupPending(true) - Logger.warn("Failed to remove private Paykit endpoints during '$context'", it, context = TAG) - } + runSuspendCatching { + updateContactSharingCleanupPending(true) + removePublishedEndpoints().getOrThrow() + syncPaykitAppAfterCleanup().getOrThrow() + updateContactSharingCleanupPending(false) + }.onFailure { + updateContactSharingCleanupPending(true) + Logger.warn("Failed to remove private Paykit endpoints during '$context'", it, context = TAG) + } } suspend fun closeAndClear(): Result = withContext(serializedDispatcher) { runSuspendCatching { + invalidateContactPreparation() publicationMutex.withLock { - clearInitialLinkBurst() clearPendingMessageDrainRetries() knownSavedContactKeys.clear() + unavailableLinkRetryAt.clear() state = PrivatePaykitState() cacheStore.reset() addressReservationRepo.clearContactAssignments(excludingPublicKeys = emptySet()) @@ -382,7 +404,7 @@ class PrivatePaykitRepo @Inject constructor( request: PaykitPaymentRequest, ): Result = runSuspendCatching { var result = beginPaymentRequest(request).getOrThrow() - for (retryDelay in initialLinkBurstRetryDelays) { + for (retryDelay in paymentListRetryDelays) { if (result != PublicPaykitPaymentResult.WaitingForUpdatedPaymentList) return@runSuspendCatching result delay(retryDelay) result = beginPaymentRequest(request).getOrThrow() @@ -396,19 +418,18 @@ class PrivatePaykitRepo @Inject constructor( ): Result = withContext(serializedDispatcher) { runSuspendCatching { val normalizedKey = normalizedPublicKey(publicKey) ?: throw PrivatePaykitError.InvalidPublicKey + val paymentListVersion = context.paymentListVersion ?: return@runSuspendCatching val contactState = ensureState().contacts.getOrPut(normalizedKey) { ContactState() } - val consumedVersion = contactState.consumedPrivatePaymentListVersionsByReceiverPath[context.receiverPath] - if (consumedVersion != null && context.paymentListVersion <= consumedVersion) { + val consumedVersion = contactState.consumedPrivatePaymentListVersion + if (consumedVersion != null && paymentListVersion <= consumedVersion) { throw PrivatePaykitError.PaymentListAlreadyConsumed } - contactState.consumedPrivatePaymentListVersionsByReceiverPath = - contactState.consumedPrivatePaymentListVersionsByReceiverPath + - (context.receiverPath to context.paymentListVersion) + contactState.consumedPrivatePaymentListVersion = paymentListVersion contactState.remoteEndpoints = emptyList() persistState(markWalletBackup = true) Logger.info( - "Consumed private Paykit payment list version ${context.paymentListVersion} " + + "Consumed private Paykit payment list version $paymentListVersion " + "for '${redacted(normalizedKey)}'", context = TAG, ) @@ -423,15 +444,15 @@ class PrivatePaykitRepo @Inject constructor( ): Result = withContext(serializedDispatcher) { runSuspendCatching { val normalizedKey = normalizedPublicKey(publicKey) ?: throw PrivatePaykitError.InvalidPublicKey + val paymentListVersion = context.paymentListVersion ?: return@runSuspendCatching val contactState = ensureState().contacts[normalizedKey] ?: return@runSuspendCatching - val consumedVersion = contactState.consumedPrivatePaymentListVersionsByReceiverPath[context.receiverPath] - if (consumedVersion != context.paymentListVersion) return@runSuspendCatching + val consumedVersion = contactState.consumedPrivatePaymentListVersion + if (consumedVersion != paymentListVersion) return@runSuspendCatching - contactState.consumedPrivatePaymentListVersionsByReceiverPath = - contactState.consumedPrivatePaymentListVersionsByReceiverPath - context.receiverPath + contactState.consumedPrivatePaymentListVersion = null persistState(markWalletBackup = true) Logger.info( - "Released private Paykit payment list version '${context.paymentListVersion}' " + + "Released private Paykit payment list version '$paymentListVersion' " + "for '${redacted(normalizedKey)}'", context = TAG, ) @@ -479,7 +500,7 @@ class PrivatePaykitRepo @Inject constructor( val matches = buildList { ensureState().contacts.forEach { (publicKey, contactState) -> if (publicKey !in knownSavedContactKeys) return@forEach - contactState.localInvoicesByReceiverPath.values.forEach { invoice -> + contactState.localInvoice?.let { invoice -> if (invoice.paymentHash in paymentHashes) add(publicKey to invoice.paymentHash) } } @@ -498,7 +519,6 @@ class PrivatePaykitRepo @Inject constructor( suspend fun handleOnchainActivity(receivedAddresses: Collection = emptyList()): Result = withContext(serializedDispatcher) { runSuspendCatching { - if (!canPublishPrivateEndpoints()) return@runSuspendCatching val publicKeys = if (receivedAddresses.isEmpty()) { addressReservationRepo.contactsWithUsedReservedAddresses() } else { @@ -507,6 +527,7 @@ class PrivatePaykitRepo @Inject constructor( } }.filter { it in knownSavedContactKeys }.distinct() if (publicKeys.isEmpty()) return@runSuspendCatching + if (!canPublishPrivateEndpoints()) return@runSuspendCatching publishLocalEndpoints(publicKeys, reason = "on-chain rotation").getOrThrow() } @@ -517,19 +538,12 @@ class PrivatePaykitRepo @Inject constructor( if (paymentHash.isBlank()) return@withContext null ensureState().contacts.firstNotNullOfOrNull { (publicKey, contactState) -> publicKey.takeIf { - contactState.localInvoices().any { invoice -> invoice.paymentHash == paymentHash } || + contactState.localInvoice?.paymentHash == paymentHash || paymentHash in contactState.receivedInvoicePaymentHashes } } } - suspend fun contactPublicKeyForPrivateOnchainAddresses(addresses: Collection): String? = - withContext(serializedDispatcher) { - addresses.firstNotNullOfOrNull { - addressReservationRepo.contactPublicKeyForReservedAddress(it) - } - } - suspend fun backupSnapshot(): Result = withContext(serializedDispatcher) { runSuspendCatching { @@ -539,9 +553,7 @@ class PrivatePaykitRepo @Inject constructor( sdkState = paykitSdkService.exportBackupState(), consumedPrivatePaymentListVersions = ensureState().contacts .mapNotNull { (publicKey, contactState) -> - contactState.consumedPrivatePaymentListVersionsByReceiverPath - .takeIf { it.isNotEmpty() } - ?.let { publicKey to it } + contactState.consumedPrivatePaymentListVersion?.let { publicKey to it } }.toMap(), ) ) @@ -551,17 +563,18 @@ class PrivatePaykitRepo @Inject constructor( suspend fun restoreBackup(backup: String?): Result = withContext(serializedDispatcher) { runSuspendCatching { - clearPendingMessageDrainRetries() + val decoded = backup?.let { json.decodeFromString(it) } + decoded?.let { paykitSdkService.retainRecoveryBackup(it.sdkState) } + invalidateContactPreparation() state = PrivatePaykitState() knownSavedContactKeys.clear() + unavailableLinkRetryAt.clear() if (backup == null) { paykitSdkService.clearState() } else { - val decoded = json.decodeFromString(backup) - paykitSdkService.restoreBackupState(decoded.sdkState) - decoded.consumedPrivatePaymentListVersions.forEach { (publicKey, versions) -> + requireNotNull(decoded).consumedPrivatePaymentListVersions.forEach { (publicKey, versions) -> ensureState().contacts.getOrPut(publicKey) { ContactState() } - .consumedPrivatePaymentListVersionsByReceiverPath = versions + .consumedPrivatePaymentListVersion = versions } } persistState(preserveCleanupMarkers = false) @@ -575,44 +588,38 @@ class PrivatePaykitRepo @Inject constructor( ): Result = withContext(serializedDispatcher) { runSuspendCatching { - val receiverPath = paymentRequest?.counterpartyReceiverPath ?: PaykitReceiverPaths.WALLET - val consumedVersion = ensureState().contacts[publicKey] - ?.consumedPrivatePaymentListVersionsByReceiverPath - ?.get(receiverPath) + val consumedVersion = ensureState().contacts[publicKey]?.consumedPrivatePaymentListVersion val amount = paymentRequest?.let { PaymentAmountContext(it.amountValue, PaykitIssuerInterop.BITCOIN_ASSET) } val prepared = runSuspendCatching { preparePrivateContactPayment( publicKey = publicKey, - receiverPath = receiverPath, consumedVersion = consumedVersion, amount = amount, - allowPublicResolution = paymentRequest == null, + paymentRequest = paymentRequest, ) }.getOrElse { if (paymentRequest == null || !it.isPaykitRecoveryRequired()) throw it if (paymentRequest.isExpired(clock.now())) throw PaykitPaymentRequestError.RequestExpired - return@runSuspendCatching privateLinkPendingResult(publicKey, receiverPath) + return@runSuspendCatching privateLinkPendingResult(publicKey) } ?: return@runSuspendCatching publicPaykitRepo.beginPayment(publicKey).getOrThrow() val resolution = prepared.resolution - val linkState = currentLinkState(publicKey, receiverPath, prepared.linkState) + val linkState = currentLinkState(publicKey, prepared.linkState) if (paymentRequest == null && canUsePublicPayment(linkState, resolution.status, resolution.state)) { return@runSuspendCatching publicPaykitRepo.beginPayment(publicKey).getOrThrow() } val result = unresolvedPrivateLinkResult( publicKey = publicKey, - receiverPath = receiverPath, paymentRequest = paymentRequest, resolution = resolution, linkState = linkState, ) ?: privatePaymentResult( publicKey = publicKey, - receiverPath = receiverPath, resolution = resolution, consumedVersion = consumedVersion, - acceptedEndpointIdentifiers = paymentRequest?.acceptedPaymentEndpointIdentifiers?.toSet(), + paymentRequest = paymentRequest, ) if (paymentRequest?.isExpired(clock.now()) == true) { throw PaykitPaymentRequestError.RequestExpired @@ -622,10 +629,10 @@ class PrivatePaykitRepo @Inject constructor( } private suspend fun beginSavedContactPaymentWithRetry(publicKey: String): PublicPaykitPaymentResult { + var result = beginContactPayment(publicKey, paymentRequest = null).getOrThrow() paymentPublishJobs.compute(publicKey) { _, job -> job?.takeIf { it.isActive } ?: retryScope.launch { refreshPrivateEndpointsBeforePayment(publicKey) } } - var result = beginContactPayment(publicKey, paymentRequest = null).getOrThrow() for (retryDelay in privatePaymentResolutionRetryDelays) { if (result != PublicPaykitPaymentResult.WaitingForUpdatedPaymentList) return result delay(retryDelay) @@ -639,7 +646,6 @@ class PrivatePaykitRepo @Inject constructor( publishLocalEndpoints( publicKeys = listOf(publicKey), reason = "payment", - lane = PaykitReadLane.Interactive, ).onFailure { Logger.warn( "Failed to refresh private Paykit endpoints before payment for '${redacted(publicKey)}'", @@ -651,22 +657,28 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun preparePrivateContactPayment( publicKey: String, - receiverPath: String, consumedVersion: ULong?, amount: PaymentAmountContext?, - allowPublicResolution: Boolean, + paymentRequest: PaykitPaymentRequest?, ): PaykitPreparedPrivateContactPayment? { val result = runSuspendCatching { - paykitSdkService.prepareAndResolvePrivateContactPayment( - counterparty = publicKey, - receiverPath = receiverPath, - afterPrivatePaymentListVersion = consumedVersion, - amount = amount, - ) + if (paymentRequest != null) { + paykitSdkService.prepareAndResolvePrivatePaymentRequest( + counterparty = publicKey, + paymentRequestId = paymentRequest.paymentRequestId, + afterPrivatePaymentListVersion = consumedVersion, + ) + } else { + paykitSdkService.prepareAndResolvePrivateContactPayment( + counterparty = publicKey, + afterPrivatePaymentListVersion = consumedVersion, + amount = amount, + ) + } } val error = result.exceptionOrNull() ?: return result.getOrThrow() - if (!allowPublicResolution) throw error - if (!canUsePublicPayment(currentLinkState(publicKey, receiverPath))) throw error + if (paymentRequest != null) throw error + if (!canUsePublicPayment(currentLinkState(publicKey))) throw error Logger.warn( "Using public Paykit resolution for '${redacted(publicKey)}'", @@ -678,36 +690,47 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun privatePaymentResult( publicKey: String, - receiverPath: String, resolution: PaykitPrivateContactPaymentResolution, consumedVersion: ULong?, - acceptedEndpointIdentifiers: Set? = null, + paymentRequest: PaykitPaymentRequest?, ): PublicPaykitPaymentResult { val privateEndpoints = resolution.payableEndpoints - .mapNotNull { PublicPaykitRepo.parseEndpoint(it.identifier, it.payload) } - cacheResolvedPrivateEndpoints(publicKey, privateEndpoints) + .mapNotNull { PublicPaykitRepo.parseEndpoint(it.identifier, it.payload)?.copy(appId = it.appId) } + if (resolution.privatePaymentListVersion != null) { + cacheResolvedPrivateEndpoints(publicKey, privateEndpoints) + } + val acceptedEndpointIdentifiers = paymentRequest?.acceptedPaymentEndpointIdentifiers?.toSet() val acceptedEndpoints = privateEndpoints.filter { acceptedEndpointIdentifiers?.contains(it.methodId.rawValue) ?: true } - val privatePayable = privatePayableEndpoints(acceptedEndpoints, publicKey) + val privatePayable = privatePayableEndpoints( + acceptedEndpoints, + publicKey, + allowUsedOnchainAddress = paymentRequest?.billingPeriod != null && + resolution.privatePaymentListVersion == null, + ) val paymentListVersion = resolution.privatePaymentListVersion - if (privatePayable.isNotEmpty() && paymentListVersion != null) { + if (privatePayable.isNotEmpty() && (paymentListVersion != null || paymentRequest != null)) { Logger.info( "Opened private Paykit payment for '${redacted(publicKey)}' using payment list version " + - "$paymentListVersion after ${consumedVersion ?: "none"}", + "${paymentListVersion ?: "none"} after ${consumedVersion ?: "none"}", context = TAG, ) return PublicPaykitPaymentResult.Opened( paymentRequest = PublicPaykitRepo.paymentRequest(privatePayable), - privatePaymentContext = PrivatePaykitPaymentContext(receiverPath, paymentListVersion), + privatePaymentContext = PrivatePaykitPaymentContext( + paymentAppsByEndpoint = privatePayable.distinctBy { it.methodId } + .associate { it.methodId.rawValue to requireNotNull(it.appId) }, + paymentListVersion = paymentListVersion, + ), ) } if (resolution.status == PrivatePaymentResolutionStatus.WAITING_FOR_UPDATED_PAYMENT_LIST) { schedulePendingPrivateMessageDrainRetries( reason = "payment recovery", - retryKeys = listOf(PrivateMessageDrainRetryKey(publicKey, receiverPath)), + retryKeys = listOf(publicKey), ) Logger.info( "Waiting for a private Paykit payment list newer than ${consumedVersion ?: "none"} " + @@ -726,27 +749,25 @@ class PrivatePaykitRepo @Inject constructor( private fun unresolvedPrivateLinkResult( publicKey: String, - receiverPath: String, paymentRequest: PaykitPaymentRequest?, resolution: PaykitPrivateContactPaymentResolution, linkState: LinkedPeerState?, ): PublicPaykitPaymentResult? = when { resolution.state == PrivatePaymentResolutionState.RECOVERY_PENDING -> - privateLinkPendingResult(publicKey, receiverPath) + privateLinkPendingResult(publicKey) paymentRequest == null -> null linkState == LinkedPeerState.LINKING || linkState == LinkedPeerState.RECOVERY_REQUIRED -> - privateLinkPendingResult(publicKey, receiverPath) + privateLinkPendingResult(publicKey) linkState != LinkedPeerState.LINKED -> PublicPaykitPaymentResult.NoEndpoint else -> null } private fun privateLinkPendingResult( publicKey: String, - receiverPath: String, ): PublicPaykitPaymentResult { schedulePendingPrivateMessageDrainRetries( reason = "payment link recovery", - retryKeys = listOf(PrivateMessageDrainRetryKey(publicKey, receiverPath)), + retryKeys = listOf(publicKey), ) Logger.info( "Waiting for private Paykit link recovery for '${redacted(publicKey)}'", @@ -757,10 +778,9 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun currentLinkState( publicKey: String, - receiverPath: String, preparedState: LinkedPeerState? = null, ): LinkedPeerState? = preparedState ?: paykitSdkService.linkedPeers().firstOrNull { - PubkyPublicKeyFormat.matches(it.counterparty, publicKey) && it.counterpartyReceiverPath == receiverPath + PubkyPublicKeyFormat.matches(it.counterparty, publicKey) }?.state private fun canUsePublicPayment( @@ -790,172 +810,195 @@ class PrivatePaykitRepo @Inject constructor( reason: String, forceRefreshLightning: Boolean = false, requireImmediatePublication: Boolean = false, - lane: PaykitReadLane = PaykitReadLane.Bulk, ): Result = withContext(serializedDispatcher) { runSuspendCatching { val keys = publicKeys.mapNotNull { normalizedPublicKey(it) }.distinct() if (keys.isEmpty()) return@runSuspendCatching + val generation = preparationGeneration + val identity = pubkyService.currentPublicKey() ?: throw PublicPaykitError.SessionNotActive + val preparation = preparePrivateLinks( + publicKeys = keys, + reason = reason, + generation = generation, + retryUnavailableLinks = requireImmediatePublication, + ) - publicationMutex.withLock { - if (!canPublishPrivateEndpoints()) { - if (requireImmediatePublication) throw PrivatePaykitError.PrivateUnavailable - return@withLock - } - - pubkyService.currentPublicKey() ?: throw PublicPaykitError.SessionNotActive - val preparation = preparePrivatePaymentListReservations( - publicKeys = keys, - reason = reason, - forceRefreshLightning = forceRefreshLightning, - lane = lane, - ) - - if (preparation.updates.isEmpty()) { - drainAndSchedulePrivateLinkRetries(reason, preparation.linkRetryKeys) - if (requireImmediatePublication) preparation.firstError?.let { throw it } - return@withLock - } + runSuspendCatching { + publicationMutex.withLock { + val status = paykitSdkService.identityStatus() + if (!isCurrentPublication(generation, identity, requireImmediatePublication, status)) { + return@withLock + } + if (!canPublishPrivateEndpoints(status)) { + if (requireImmediatePublication) throw PrivatePaykitError.PrivateUnavailable + return@withLock + } - val report = paykitSdkService.syncPrivatePaymentListsWithReservations( - updates = preparation.updates, - clearUnlistedLinkedPeers = false, - ) - val deliveryError = applyPrivatePaymentListDeliveryReport(report, reason) - val firstError = preparation.firstError ?: deliveryError - val retryKeys = (preparation.linkRetryKeys + privatePaymentListDeliveryRetryKeys(report)).distinct() - drainAndSchedulePrivateLinkRetries(reason, retryKeys) + val publication = preparePrivatePaymentListReservations( + preparation.publicKeys, + reason, + forceRefreshLightning, + generation, + ) + if (!isCurrentPublication(generation, identity, requireImmediatePublication)) return@withLock + if (publication.updates.isEmpty()) { + schedulePendingPrivateMessageDrainRetries(reason, preparation.linkRetryKeys) + if (requireImmediatePublication) publication.firstError?.let { throw it } + return@withLock + } - if (firstError != null) { - if (requireImmediatePublication) throw firstError - Logger.warn( - "Deferred private Paykit endpoint publish during '$reason'", - firstError, - context = TAG, + val report = paykitSdkService.syncPrivatePaymentListsWithReservations( + updates = publication.updates, + clearUnlistedLinkedPeers = false, ) + val deliveryError = applyPrivatePaymentListDeliveryReport(report, reason) + val firstError = publication.firstError ?: deliveryError + val retryKeys = (preparation.linkRetryKeys + privatePaymentListDeliveryRetryKeys(report)).distinct() + schedulePendingPrivateMessageDrainRetries(reason, retryKeys) + + if (firstError != null) { + if (requireImmediatePublication) throw firstError + Logger.warn( + "Deferred private Paykit endpoint publish during '$reason'", + firstError, + context = TAG, + ) + } } - } + }.onFailure { + schedulePreparedLinkRetries(preparation.linkRetryKeys, reason, generation, identity) + }.getOrThrow() } } + private suspend fun schedulePreparedLinkRetries( + keys: Collection, + reason: String, + generation: Int, + identity: String, + ) { + if (keys.isEmpty()) return + val cleanupPending = isContactSharingCleanupPending() + val currentIdentity = pubkyService.currentPublicKey() + currentCoroutineContext().ensureActive() + if (generation == preparationGeneration && currentIdentity == identity && !cleanupPending) { + schedulePendingPrivateMessageDrainRetries(reason, keys.filter { it in knownSavedContactKeys }) + } + } + + private suspend fun isCurrentPublication( + generation: Int, + identity: String, + requireImmediatePublication: Boolean, + status: IdentityStatus? = null, + ): Boolean { + val isCurrent = generation == preparationGeneration && + (status?.publicKey ?: pubkyService.currentPublicKey()) == identity + if (!isCurrent && requireImmediatePublication) throw PrivatePaykitError.PrivateUnavailable + return isCurrent + } + private suspend fun preparePrivatePaymentListReservations( publicKeys: Collection, reason: String, forceRefreshLightning: Boolean, - lane: PaykitReadLane, + generation: Int, ): PrivatePublicationPreparation { var firstError: Throwable? = null - var receiverPathSelectionError: Throwable? = null - var hasPublicationUpdate = false val updates = mutableListOf() - val linkRetryKeys = mutableListOf() - val linkedReceiverPathsSnapshot = linkedReceiverPathsSnapshot(reason) - firstError = linkedReceiverPathsSnapshot.error - for (publicKey in publicKeys) { - val receiverPaths = runSuspendCatching { receiverPathsForSavedContact(publicKey, lane) } - .onFailure { - firstError = firstError ?: it - Logger.warn( - "Failed to read saved Paykit receivers for '${redacted(publicKey)}' during '$reason'", - it, - context = TAG, - ) - }.getOrNull() ?: continue - val receiverPathSelection = paykitSdkService.privateReceiverPathSelection(publicKey, receiverPaths, lane) - val linkableReceiverPaths = receiverPathSelection.linkableReceiverPaths - val publicationReceiverPaths = receiverPathSelection.publishableReceiverPaths - receiverPathSelection.error?.let { - logPrivateReceiverPathSelectionFailure(publicKey, reason, it) - receiverPathSelectionError = receiverPathSelectionError ?: it - } - val cleanupReceiverPaths = receiverPathsForPrivateEndpointCleanup( - publicKey = publicKey, - excludedReceiverPaths = publicationReceiverPaths + receiverPathSelection.cleanupProtectedReceiverPaths, - linkedReceiverPaths = linkedReceiverPathsSnapshot.pathsByPublicKey[publicKey].orEmpty(), - ) - - linkRetryKeys += preparePrivateLinks(publicKey, linkableReceiverPaths + cleanupReceiverPaths, reason) - - cleanupReceiverPaths.forEach { receiverPath -> - updates += PrivatePaymentListReservationUpdateInput( - counterparty = publicKey, - counterpartyReceiverPath = receiverPath, - reservations = emptyList(), - ) + if (generation != preparationGeneration) break + if (publicKey in knownSavedContactKeys) { + runSuspendCatching { privatePaymentListUpdate(publicKey, forceRefreshLightning) } + .onSuccess { updates += it } + .onFailure { + firstError = firstError ?: it + logPrivatePublicationPreparationFailure(publicKey, reason, it) + } } + } + return PrivatePublicationPreparation(updates.filter { it.counterparty in knownSavedContactKeys }, firstError) + } - publicationReceiverPaths.forEach { receiverPath -> + private suspend fun preparePrivateLinks( + publicKeys: Collection, + reason: String, + generation: Int, + retryUnavailableLinks: Boolean, + ): PrivateLinkPreparation { + val preparedKeys = mutableListOf() + val linkRetryKeys = mutableListOf() + val peerStates = paykitSdkService.linkedPeers().associate { it.counterparty to it.state } + for (publicKey in publicKeys.distinct()) { + if (generation != preparationGeneration) break + if (canPreparePrivateLink(publicKey, peerStates[publicKey], retryUnavailableLinks)) { runSuspendCatching { - privatePaymentListUpdate(publicKey, receiverPath, forceRefreshLightning) + if (peerStates[publicKey] == LinkedPeerState.LINKED) { + LinkedPeerState.LINKED + } else { + advanceLinkIfIdle(publicKey) + } }.onSuccess { - updates += it - hasPublicationUpdate = true + unavailableLinkRetryAt.remove(publicKey) + if (it != LinkedPeerState.LINKED) linkRetryKeys += publicKey + preparedKeys += publicKey }.onFailure { - firstError = firstError ?: it - logPrivatePublicationPreparationFailure(publicKey, reason, it) + Logger.warn("Failed to prepare private Paykit link during '$reason'", it, context = TAG) + val hasNoHandshake = it is PaykitException.Transport && runSuspendCatching { + val state = paykitSdkService.linkedPeers().firstOrNull { it.counterparty == publicKey }?.state + state == null || state == LinkedPeerState.NOT_LINKED + }.getOrDefault(false) + if (it is PaykitException.NotFound || hasNoHandshake) { + unavailableLinkRetryAt[publicKey] = clock.now() + unavailableLinkRetryDelay + } else if (it is PaykitException.Transport) { + unavailableLinkRetryAt.remove(publicKey) + } + if (it !is PaykitException.NotFound) linkRetryKeys += publicKey } } } - - if (!hasPublicationUpdate) firstError = firstError ?: receiverPathSelectionError - return PrivatePublicationPreparation(updates, linkRetryKeys.distinct(), firstError) + return PrivateLinkPreparation(preparedKeys, linkRetryKeys) } - private suspend fun prepareRelevantPrivateLinksIfAvailable(publicKeys: Collection, reason: String) { - if (!hasPrivatePaymentAccessForCurrentProfile()) return - - val retryKeys = mutableListOf() - for (publicKey in publicKeys) { - val receiverPaths = runSuspendCatching { receiverPathsForSavedContact(publicKey, PaykitReadLane.Bulk) } - .onFailure { - Logger.warn( - "Failed to read saved Paykit receivers for '${redacted(publicKey)}' during '$reason'", - it, - context = TAG, - ) - }.getOrNull() ?: continue - val selection = paykitSdkService.privateReceiverPathSelection(publicKey, receiverPaths, PaykitReadLane.Bulk) - selection.error?.let { - Logger.warn( - "Failed to inspect private Paykit receiver markers for '${redacted(publicKey)}' during '$reason'", - it, - context = TAG, - ) - } - retryKeys += selection.linkableReceiverPaths.map { PrivateMessageDrainRetryKey(publicKey, it) } + private suspend fun advanceLinkIfIdle( + publicKey: String, + priority: Priority = Priority.Ordered, + ): LinkedPeerState? { + currentCoroutineContext().ensureActive() + if (!activeLinkPreparationKeys.add(publicKey)) return null + val generation = preparationGeneration + return try { + paykitSdkService.ensureLinkWithPeer(publicKey, priority = priority).state + } finally { + if (generation == preparationGeneration) activeLinkPreparationKeys.remove(publicKey) } - - drainAndSchedulePrivateLinkRetries(reason, retryKeys.distinct()) } - private suspend fun preparePrivateLinks( + private suspend fun canPreparePrivateLink( publicKey: String, - receiverPaths: Collection, - reason: String, - ): List { - val retryKeys = mutableListOf() - for (receiverPath in receiverPaths.distinct()) { - runSuspendCatching { paykitSdkService.ensureLinkWithPeer(publicKey, receiverPath) }.onFailure { - Logger.warn( - "Failed to prepare private Paykit link for '${redacted(publicKey)}' during '$reason'", - it, - context = TAG, - ) - } - retryKeys += PrivateMessageDrainRetryKey(publicKey, receiverPath) - } + state: LinkedPeerState?, + retryUnavailableLinks: Boolean, + ): Boolean = publicKey in knownSavedContactKeys && + !isContactSharingCleanupPending() && state != LinkedPeerState.BLOCKED && + (retryUnavailableLinks || unavailableLinkRetryAt[publicKey]?.let { it > clock.now() } != true) - return retryKeys + private suspend fun prepareRelevantPrivateLinksIfAvailable(publicKeys: Collection, reason: String) { + if (isContactSharingCleanupPending() || !hasPrivatePaymentAccessForCurrentProfile()) return + drainAndSchedulePrivateLinkRetries(reason, publicKeys.distinct()) } private suspend fun drainAndSchedulePrivateLinkRetries( reason: String, - retryKeys: Collection, + retryKeys: Collection, ) { - if (retryKeys.isEmpty()) return + val newKeys = synchronized(pendingMessageDrainRetryLock) { + retryKeys.toSet() - pendingMessageDrainRetryKeys + } + val pendingKeys = pendingPrivateMessageDrainKeys(newKeys, retryMissingPeers = true) + if (pendingKeys.isEmpty()) return - drainPendingPrivateMessages(reason, advancingLinksFor = retryKeys.toList()) - val pendingRetryKeys = pendingPrivateMessageDrainKeys(retryKeys) + drainPendingPrivateMessages(reason, retryKeys = pendingKeys) + val pendingRetryKeys = pendingPrivateMessageDrainKeys(pendingKeys) if (pendingRetryKeys.isNotEmpty()) { schedulePendingPrivateMessageDrainRetries(reason, retryKeys = pendingRetryKeys) } @@ -963,15 +1006,13 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun privatePaymentListUpdate( publicKey: String, - receiverPath: String, forceRefreshLightning: Boolean, ): PrivatePaymentListReservationUpdateInput { - val endpoints = buildLocalEndpoints(publicKey, receiverPath, forceRefreshLightning).getOrThrow() + val endpoints = buildLocalEndpoints(publicKey, forceRefreshLightning).getOrThrow() if (endpoints.isEmpty()) throw PrivatePaykitError.PrivateUnavailable return PrivatePaymentListReservationUpdateInput( counterparty = publicKey, - counterpartyReceiverPath = receiverPath, - reservations = endpoints.map { endpoint -> privateReservation(publicKey, receiverPath, endpoint) }, + reservations = endpoints.map { endpoint -> privateReservation(publicKey, endpoint) }, ) } @@ -994,48 +1035,22 @@ class PrivatePaykitRepo @Inject constructor( } } - private fun logPrivateReceiverPathSelectionFailure( - publicKey: String, - reason: String, - error: Throwable, - ) { - Logger.warn( - "Failed to inspect private Paykit receiver markers for '${redacted(publicKey)}' during '$reason'", - error, - context = TAG, - ) - } - private suspend fun applyPrivatePaymentListDeliveryReport( report: PrivatePaymentListDeliveryReport, reason: String, ): Throwable? { - report.failedToQueue.forEach { - Logger.warn( - "Failed to queue private Paykit endpoints for '${redacted(it.counterparty)}' during '$reason': " + - (it.error ?: "unknown error"), - context = TAG, - ) - } - report.failedToDeliver.forEach { - Logger.warn( - "Failed to deliver private Paykit endpoints for '${redacted(it.counterparty)}' during '$reason': " + - it.error, - context = TAG, - ) - } + logPrivatePaymentListDeliveryFailures(report, reason) var didUpdateCache = false for (change in report.queued) { val publicKey = normalizedPublicKey(change.counterparty) ?: continue - recordPublishedPrivatePaymentListCache(publicKey, change.counterpartyReceiverPath) + recordPublishedPrivatePaymentListCache(publicKey) didUpdateCache = true } for (change in report.cleared) { didUpdateCache = clearPublishedPrivatePaymentListCache( counterparty = change.counterparty, - receiverPath = change.counterpartyReceiverPath, ) || didUpdateCache } @@ -1048,43 +1063,89 @@ class PrivatePaykitRepo @Inject constructor( } } + private fun logPrivatePaymentListDeliveryFailures(report: PrivatePaymentListDeliveryReport, reason: String) { + report.failedToQueue.forEach { + Logger.warn( + "Failed to queue private Paykit endpoints for '${redacted(it.counterparty)}' during '$reason': " + + (it.error?.redactedContext() ?: "unknown error"), + context = TAG, + ) + } + report.failedToDeliver.forEach { + Logger.warn( + "Failed to deliver private Paykit endpoints for '${redacted(it.counterparty)}' during '$reason': " + + it.error.redactedContext(), + context = TAG, + ) + } + } + private fun privatePaymentListDeliveryRetryKeys( report: PrivatePaymentListDeliveryReport, - ): List { - val changes = report.queued.map { it.counterparty to it.counterpartyReceiverPath } + - report.cleared.map { it.counterparty to it.counterpartyReceiverPath } + - report.failedToDeliver.map { it.counterparty to it.counterpartyReceiverPath } - - return changes - .mapNotNull { (counterparty, receiverPath) -> - normalizedPublicKey(counterparty)?.let { PrivateMessageDrainRetryKey(it, receiverPath) } - } + ): List { + return ( + report.queued.map { it.counterparty } + + report.cleared.map { it.counterparty } + + report.failedToDeliver.map { it.counterparty } + ) + .mapNotNull(::normalizedPublicKey) .distinct() } private suspend fun drainPendingPrivateMessages( reason: String, - advancingLinksFor: List = emptyList(), + retryKeys: Collection, + includeUnsavedPeers: Boolean = false, + priority: Priority = Priority.Ordered, ) { + val retryKeys = retryKeys.mapNotNull(::normalizedPublicKey).toSet() + currentCoroutineContext().ensureActive() + if (retryKeys.isEmpty()) return runSuspendCatching { - advancingLinksFor.distinct().forEach { retryKey -> + val generation = preparationGeneration + val alreadyLinkedKeys = paykitSdkService.linkedPeers(priority).filter { it.state == LinkedPeerState.LINKED } + .mapNotNull { normalizedPublicKey(it.counterparty) }.toSet() + (retryKeys - alreadyLinkedKeys).forEach { retryKey -> + currentCoroutineContext().ensureActive() + if (generation != preparationGeneration) return@runSuspendCatching + if (!includeUnsavedPeers && retryKey !in knownSavedContactKeys) return@forEach + if (unavailableLinkRetryAt[retryKey]?.let { it > clock.now() } == true) return@forEach runSuspendCatching { - paykitSdkService.ensureLinkWithPeer( - counterparty = retryKey.publicKey, - receiverPath = retryKey.receiverPath, - ) + advanceLinkIfIdle(retryKey, priority) }.onFailure { Logger.warn( - "Failed to advance private Paykit link for '${redacted(retryKey.publicKey)}' during '$reason'", + "Failed to advance private Paykit link for '${redacted(retryKey)}' during '$reason'", it, context = TAG, ) } } - paykitSdkService.processPendingPrivateMessages() - paykitSdkService.receivePrivateMessagesFromLinkedPeers() - paykitSdkService.processPendingPrivateMessages() - paykitSdkService.receivePrivateMessagesFromLinkedPeers() + currentCoroutineContext().ensureActive() + if (generation != preparationGeneration) return@runSuspendCatching + val pendingKeys = paykitSdkService.pendingOutboundPrivateCounterparties(priority) + .mapNotNull(::normalizedPublicKey).toSet().intersect(retryKeys) + pendingKeys.forEach { publicKey -> + currentCoroutineContext().ensureActive() + if (generation != preparationGeneration) return@runSuspendCatching + runSuspendCatching { + paykitSdkService.processOutboundPrivateMessages(publicKey) + }.onFailure { + Logger.warn("Failed to send private Paykit messages during '$reason'", it, context = TAG) + } + } + currentCoroutineContext().ensureActive() + if (generation != preparationGeneration) return@runSuspendCatching + val linkedKeys = paykitSdkService.linkedPeers(priority).filter { it.state == LinkedPeerState.LINKED } + .mapNotNull { normalizedPublicKey(it.counterparty) }.toSet().intersect(retryKeys) + linkedKeys.forEach { publicKey -> + currentCoroutineContext().ensureActive() + if (generation != preparationGeneration) return@runSuspendCatching + runSuspendCatching { + paykitSdkService.receivePrivateMessages(publicKey) + }.onFailure { + Logger.warn("Failed to receive private Paykit messages during '$reason'", it, context = TAG) + } + } }.onFailure { Logger.warn("Failed to process pending private Paykit messages during '$reason'", it, context = TAG) } @@ -1092,13 +1153,16 @@ class PrivatePaykitRepo @Inject constructor( private fun schedulePendingPrivateMessageDrainRetries( reason: String, - retryKeys: Collection, + retryKeys: Collection, ) { val retryKeys = retryKeys.toSet() if (retryKeys.isEmpty()) return synchronized(pendingMessageDrainRetryLock) { + val hasActiveRetry = + pendingMessageDrainRetryJob?.isActive == true && pendingMessageDrainRetryKeys.isNotEmpty() pendingMessageDrainRetryKeys.addAll(retryKeys) + if (hasActiveRetry) return pendingMessageDrainRetryGeneration += 1 val retryGeneration = pendingMessageDrainRetryGeneration pendingMessageDrainRetryJob?.cancel() @@ -1124,7 +1188,10 @@ class PrivatePaykitRepo @Inject constructor( pendingMessageDrainRetryKeys.toList() } if (retryKeys.isEmpty()) return@withContext - drainPendingPrivateMessages(reason, advancingLinksFor = retryKeys) + val pendingKeys = pendingPrivateMessageDrainKeys(retryKeys, priority = Priority.Background) + if (pendingKeys.isNotEmpty()) { + drainPendingPrivateMessages(reason, retryKeys = pendingKeys, priority = Priority.Background) + } updatePendingMessageDrainRetryKeys(retryKeys) } @@ -1141,8 +1208,8 @@ class PrivatePaykitRepo @Inject constructor( } } - private suspend fun updatePendingMessageDrainRetryKeys(retryKeys: Collection) { - val remainingKeys = pendingPrivateMessageDrainKeys(retryKeys) + private suspend fun updatePendingMessageDrainRetryKeys(retryKeys: Collection) { + val remainingKeys = pendingPrivateMessageDrainKeys(retryKeys, priority = Priority.Background) synchronized(pendingMessageDrainRetryLock) { pendingMessageDrainRetryKeys.removeAll(retryKeys.toSet()) pendingMessageDrainRetryKeys.addAll(remainingKeys) @@ -1150,37 +1217,36 @@ class PrivatePaykitRepo @Inject constructor( } private suspend fun pendingPrivateMessageDrainKeys( - retryKeys: Collection, - ): Set { + retryKeys: Collection, + retryMissingPeers: Boolean = false, + priority: Priority = Priority.Ordered, + ): Set { val retryKeys = retryKeys.toSet() if (retryKeys.isEmpty()) return emptySet() - val linkedPeers = runSuspendCatching { paykitSdkService.linkedPeers() } + val linkedPeers = runSuspendCatching { paykitSdkService.linkedPeers(priority) } .getOrElse { Logger.warn("Failed to inspect private Paykit link state", it, context = TAG) return retryKeys } .mapNotNull { peer -> normalizedPublicKey(peer.counterparty)?.let { publicKey -> - PrivateMessageDrainRetryKey(publicKey, peer.counterpartyReceiverPath) to peer.state + publicKey to peer.state } } .toMap() - val pendingOutbound = runSuspendCatching { paykitSdkService.pendingOutboundPrivateCounterparties() } + val pendingOutbound = runSuspendCatching { paykitSdkService.pendingOutboundPrivateCounterparties(priority) } .getOrElse { Logger.warn("Failed to inspect pending private Paykit messages", it, context = TAG) return retryKeys } - .mapNotNull { receiver -> - normalizedPublicKey(receiver.counterparty)?.let { - PrivateMessageDrainRetryKey(it, receiver.counterpartyReceiverPath) - } - } + .mapNotNull(::normalizedPublicKey) .toSet() return retryKeys.filterTo(mutableSetOf()) { retryKey -> when (linkedPeers[retryKey]) { - LinkedPeerState.LINKED, null -> retryKey in pendingOutbound + LinkedPeerState.LINKED -> retryKey in pendingOutbound + null -> retryMissingPeers || retryKey in pendingOutbound LinkedPeerState.BLOCKED, LinkedPeerState.UNKNOWN -> false else -> true } @@ -1196,21 +1262,18 @@ class PrivatePaykitRepo @Inject constructor( } } - private suspend fun recordPublishedPrivatePaymentListCache(publicKey: String, receiverPath: String) { + private suspend fun recordPublishedPrivatePaymentListCache(publicKey: String) { val contactState = ensureState().contacts.getOrPut(publicKey) { ContactState() } - contactState.publishedPrivatePaymentReceiverPaths = - (contactState.publishedPrivatePaymentReceiverPaths + receiverPath).toSortedSet() + contactState.hasPublishedPrivatePaymentList = true } private suspend fun clearPublishedPrivatePaymentListCache( counterparty: String, - receiverPath: String, ): Boolean { val publicKey = normalizedPublicKey(counterparty) ?: return false ensureState().contacts[publicKey]?.let { contactState -> - contactState.publishedPrivatePaymentReceiverPaths = - contactState.publishedPrivatePaymentReceiverPaths.filterTo(mutableSetOf()) { it != receiverPath } - contactState.localInvoicesByReceiverPath = contactState.localInvoicesByReceiverPath - receiverPath + contactState.hasPublishedPrivatePaymentList = false + contactState.localInvoice = null if (!contactState.hasCacheState) { state?.contacts?.remove(publicKey) } @@ -1220,7 +1283,6 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun buildLocalEndpoints( publicKey: String, - receiverPath: String, forceRefreshLightning: Boolean = false, ): Result> = withContext(serializedDispatcher) { runSuspendCatching { @@ -1229,7 +1291,6 @@ class PrivatePaykitRepo @Inject constructor( if (PublicPaykitRepo.isOnchainPaymentOptionEnabled(settings)) { val reservedAddress = addressReservationRepo.currentOrRotatedAddress( publicKey, - receiverPath, ).getOrThrow() walletRepo.refreshReusableReceiveAddressIfReserved().getOrThrow() endpoints += Endpoint( @@ -1242,7 +1303,6 @@ class PrivatePaykitRepo @Inject constructor( if (PublicPaykitRepo.isLightningPaymentOptionEnabled(settings) && lightningRepo.canReceive()) { currentOrRotatedInvoice( publicKey, - receiverPath, forceRefresh = forceRefreshLightning, ).onSuccess { invoice -> endpoints += Endpoint( @@ -1265,18 +1325,17 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun currentOrRotatedInvoice( publicKey: String, - receiverPath: String, forceRefresh: Boolean = false, ): Result = withContext(serializedDispatcher) { runSuspendCatching { - if (!forceRefresh) reusablePrivateInvoice(publicKey, receiverPath)?.let { return@runSuspendCatching it } + if (!forceRefresh) reusablePrivateInvoice(publicKey)?.let { return@runSuspendCatching it } val bolt11 = lightningRepo.createInvoice( amountSats = null, description = "", expirySeconds = privateInvoiceExpiry.inWholeSeconds.toUInt(), ).getOrThrow() - if (!forceRefresh) reusablePrivateInvoice(publicKey, receiverPath)?.let { return@runSuspendCatching it } + if (!forceRefresh) reusablePrivateInvoice(publicKey)?.let { return@runSuspendCatching it } val decoded = (coreService.decode(bolt11) as? Scanner.Lightning)?.invoice ?: throw PublicPaykitError.InvalidPayload @@ -1289,7 +1348,7 @@ class PrivatePaykitRepo @Inject constructor( paymentHash = decoded.paymentHash.toHex(), expiresAt = expiresAt, ) - setLocalInvoice(publicKey, receiverPath, invoice) + setLocalInvoice(publicKey, invoice) persistState() invoice } @@ -1297,9 +1356,8 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun reusablePrivateInvoice( publicKey: String, - receiverPath: String, ): StoredInvoice? { - val invoice = localInvoice(publicKey, receiverPath) ?: return null + val invoice = localInvoice(publicKey) ?: return null val refreshAt = clock.now().epochSeconds + invoiceRefreshBuffer.inWholeSeconds val decoded = (coreService.decode(invoice.bolt11) as? Scanner.Lightning)?.invoice ?: return null val isReusable = invoice.expiresAt > refreshAt && @@ -1312,31 +1370,28 @@ class PrivatePaykitRepo @Inject constructor( private fun privateReservation( publicKey: String, - receiverPath: String, endpoint: Endpoint, ): PrivatePaymentEndpointReservationInput { val contactState = state?.contacts?.get(publicKey) val attribution = if (endpoint.methodId == MethodId.Bolt11) { - val paymentHash = localInvoice(publicKey, receiverPath)?.takeIf { it.bolt11 == endpoint.value }?.paymentHash + val paymentHash = localInvoice(publicKey)?.takeIf { it.bolt11 == endpoint.value }?.paymentHash mapOf( "type" to "private_paykit", "counterparty" to publicKey, - "receiver_path" to receiverPath, ) + listOfNotNull(paymentHash?.let { "payment_hash" to it }).toMap() } else { mapOf( "type" to "private_paykit", "counterparty" to publicKey, - "receiver_path" to receiverPath, ) } val expiresAt = contactState - ?.let { localInvoice(publicKey, receiverPath) } + ?.let { localInvoice(publicKey) } ?.takeIf { endpoint.methodId == MethodId.Bolt11 && it.bolt11 == endpoint.value } ?.let { Instant.ofEpochSecond(it.expiresAt).toString() } return PrivatePaymentEndpointReservationInput( - reservationId = privateReservationId(publicKey, receiverPath, endpoint), + reservationId = privateReservationId(publicKey, endpoint), identifier = endpoint.methodId.rawValue, payload = endpoint.rawPayload, expiresAt = expiresAt, @@ -1344,12 +1399,12 @@ class PrivatePaykitRepo @Inject constructor( ) } - private fun privateReservationId(publicKey: String, receiverPath: String, endpoint: Endpoint): String { + private fun privateReservationId(publicKey: String, endpoint: Endpoint): String { val payloadHashPrefix = MessageDigest.getInstance("SHA-256") .digest(endpoint.rawPayload.toByteArray(Charsets.UTF_8)) .copyOfRange(0, 8) .toHex() - return "$publicKey:$receiverPath:${endpoint.methodId.rawValue}:$payloadHashPrefix" + return "$publicKey:${endpoint.methodId.rawValue}:$payloadHashPrefix" } private suspend fun cacheResolvedPrivateEndpoints(publicKey: String, endpoints: List) { @@ -1359,16 +1414,13 @@ class PrivatePaykitRepo @Inject constructor( } private suspend fun removePublishedEndpoints(): Result = withContext(serializedDispatcher) { - publicationMutex.withLock { - val keys = (knownSavedContactKeys + ensureState().contacts.keys + pendingDeletedContactCleanupPublicKeys()) - .distinct() - removePublishedEndpointsLocked(keys) + runSuspendCatching { + publicationMutex.withLock { + removePublishedEndpointsLocked().getOrThrow() + } } } - private suspend fun removePublishedEndpoints(publicKey: String): Result = - removePublishedEndpoints(listOf(publicKey)) - private suspend fun removePublishedEndpoints(publicKeys: Collection): Result = withContext(serializedDispatcher) { publicationMutex.withLock { @@ -1376,28 +1428,47 @@ class PrivatePaykitRepo @Inject constructor( } } - private suspend fun removePublishedEndpointsLocked(publicKeys: Collection): Result = + private suspend fun removePublishedEndpointsLocked(publicKeys: Collection? = null): Result = runSuspendCatching { - val normalizedBatch = normalizedPublicKeyBatch(publicKeys) + val peers = paykitSdkService.linkedPeers() + val linkedPublicKeys = peers + .filter { + it.state == LinkedPeerState.LINKED || it.state == LinkedPeerState.LINKING || + it.state == LinkedPeerState.RECOVERY_REQUIRED + } + .mapNotNull { normalizedPublicKey(it.counterparty) } + .toSet() + val keys = publicKeys ?: ( + knownSavedContactKeys + ensureState().contacts.keys + pendingDeletedContactCleanupPublicKeys() + + linkedPublicKeys + ) + val normalizedBatch = normalizedPublicKeyBatch(keys) discardInvalidCleanupKeys(normalizedBatch.invalidKeys) val normalizedKeys = normalizedBatch.normalizedKeys if (normalizedKeys.isEmpty()) return@runSuspendCatching ensureState() val cleanupStateByPublicKey = normalizedKeys.associateWith(::publishedEndpointCleanupState) - val linkedReceiverPathsSnapshot = linkedReceiverPathsSnapshot("private endpoint cleanup") - val preparation = clearPrivatePaymentLists(normalizedKeys, linkedReceiverPathsSnapshot) + val preparation = clearPrivatePaymentLists(normalizedKeys, linkedPublicKeys) val failedPublicKeys = preparation.failedPublicKeys.toMutableSet() var firstError = preparation.firstError if (preparation.clearedRetryKeys.isNotEmpty()) { - drainPendingPrivateMessages( - reason = "private endpoint cleanup", - advancingLinksFor = preparation.clearedRetryKeys, - ) - val pendingRetryKeys = pendingPrivateMessageDrainKeys(preparation.clearedRetryKeys) + var pendingRetryKeys = pendingPrivateMessageDrainKeys(preparation.clearedRetryKeys) if (pendingRetryKeys.isNotEmpty()) { - failedPublicKeys += pendingRetryKeys.map { it.publicKey } + drainPendingPrivateMessages( + reason = "private endpoint cleanup", + retryKeys = pendingRetryKeys, + includeUnsavedPeers = true, + ) + pendingRetryKeys = pendingPrivateMessageDrainKeys(preparation.clearedRetryKeys) + } + if (pendingRetryKeys.isNotEmpty()) { + Logger.warn( + "Private Paykit endpoint withdrawal remains pending for ${pendingRetryKeys.map(::redacted)}", + context = TAG, + ) + failedPublicKeys += pendingRetryKeys firstError = firstError ?: PrivatePaykitError.PrivateUnavailable } } @@ -1415,42 +1486,46 @@ class PrivatePaykitRepo @Inject constructor( clearPublishedEndpointCache(normalizedKeys.filterNot { it in failedPublicKeys }) firstError?.let { throw it } + if (publicKeys != null) publicPaykitRepo.syncPaykitApp().getOrThrow() + }.onFailure { + runSuspendCatching { settingsStore.update { it.copy(publicPaykitCleanupPending = true) } } + .onFailure(it::addSuppressed) + } + + private suspend fun syncPaykitAppAfterCleanup(): Result = + publicPaykitRepo.syncPaykitApp().onFailure { + runSuspendCatching { settingsStore.update { it.copy(publicPaykitCleanupPending = true) } } + .onFailure(it::addSuppressed) } private suspend fun clearPrivatePaymentLists( publicKeys: Collection, - linkedReceiverPathsSnapshot: LinkedReceiverPathsSnapshot, + linkedPublicKeys: Set, ): PrivateEndpointCleanupPreparation { - val failedPublicKeys = if (linkedReceiverPathsSnapshot.error == null) { - mutableSetOf() - } else { - publicKeys.toMutableSet() + val cleanupKeys = publicKeys.filter { + it in linkedPublicKeys || state?.contacts?.get(it)?.hasPublishedPrivatePaymentList == true } - val clearedRetryKeys = mutableListOf() - var firstError = linkedReceiverPathsSnapshot.error + if (cleanupKeys.isEmpty()) return PrivateEndpointCleanupPreparation(emptyList(), emptySet(), null) - publicKeys.forEach { publicKey -> - receiverPathsForCleanup( - publicKey = publicKey, - linkedReceiverPaths = linkedReceiverPathsSnapshot.pathsByPublicKey[publicKey].orEmpty(), - ).forEach { receiverPath -> - runSuspendCatching { - val report = paykitSdkService.clearPrivatePaymentList(publicKey, receiverPath) - ?: return@runSuspendCatching false - if (report.failedToQueue.isNotEmpty() || report.failedToDeliver.isNotEmpty()) { - throw PrivatePaykitError.PrivateUnavailable - } - true - }.onSuccess { - if (it) clearedRetryKeys += PrivateMessageDrainRetryKey(publicKey, receiverPath) - }.onFailure { - failedPublicKeys += publicKey - firstError = firstError ?: it - } - } + return runSuspendCatching { + val report = paykitSdkService.clearPrivatePaymentLists(cleanupKeys) + ?: return@runSuspendCatching PrivateEndpointCleanupPreparation(emptyList(), emptySet(), null) + logPrivatePaymentListDeliveryFailures(report, "cleanup") + val failedPublicKeys = ( + report.failedToQueue.map { it.counterparty } + + report.failedToDeliver.map { it.counterparty } + ).mapNotNull(::normalizedPublicKey).toSet() + val clearedRetryKeys = report.cleared.mapNotNull { normalizedPublicKey(it.counterparty) } + .filterNot { it in failedPublicKeys } + PrivateEndpointCleanupPreparation( + clearedRetryKeys, + failedPublicKeys, + PrivatePaykitError.PrivateUnavailable.takeIf { failedPublicKeys.isNotEmpty() }, + ) + }.getOrElse { + Logger.warn("Failed to clear private Paykit endpoints: ${it::class.simpleName}", context = TAG) + PrivateEndpointCleanupPreparation(emptyList(), cleanupKeys.toSet(), it) } - - return PrivateEndpointCleanupPreparation(clearedRetryKeys, failedPublicKeys, firstError) } private suspend fun clearPublishedEndpointCache(publicKeys: Collection) { @@ -1459,8 +1534,8 @@ class PrivatePaykitRepo @Inject constructor( publicKeys.forEach { publicKey -> state?.contacts?.get(publicKey)?.let { contactState -> contactState.remoteEndpoints = emptyList() - contactState.localInvoicesByReceiverPath = emptyMap() - contactState.publishedPrivatePaymentReceiverPaths = emptySet() + contactState.localInvoice = null + contactState.hasPublishedPrivatePaymentList = false if (!contactState.hasCacheState) { state?.contacts?.remove(publicKey) } @@ -1490,114 +1565,11 @@ class PrivatePaykitRepo @Inject constructor( val contactState = state?.contacts?.get(publicKey) return PublishedEndpointCleanupState( remoteEndpoints = contactState?.remoteEndpoints.orEmpty(), - localInvoicesByReceiverPath = contactState?.localInvoicesByReceiverPath.orEmpty(), - publishedPrivatePaymentReceiverPaths = contactState?.publishedPrivatePaymentReceiverPaths.orEmpty(), + localInvoice = contactState?.localInvoice, + hasPublishedPrivatePaymentList = contactState?.hasPublishedPrivatePaymentList == true, ) } - private suspend fun receiverPathsForSavedContact(publicKey: String, lane: PaykitReadLane): List { - val record = paykitSdkService.contactRecord(publicKey) - val savedPaths = supportedReceiverPaths(record?.receiverPaths.orEmpty()) - - return runSuspendCatching { - val discoveredPaths = pubkyService.discoverRelevantReceiverPaths(publicKey, lane) - val currentRecord = paykitSdkService.contactRecord(publicKey) - ?: return@runSuspendCatching savedPaths - val currentSavedPaths = supportedReceiverPaths(currentRecord.receiverPaths) - val mergedPaths = supportedReceiverPaths(currentSavedPaths + discoveredPaths) - if (mergedPaths == currentSavedPaths) return@runSuspendCatching currentSavedPaths - - val updatedRecord = pubkyService.saveContact(publicKey, currentRecord.label, mergedPaths) - _initialLinkBurstStarted.tryEmit(Unit) - Logger.info("Discovered new Paykit receiver paths for '${redacted(publicKey)}'", context = TAG) - supportedReceiverPaths(updatedRecord.receiverPaths) - }.getOrElse { - if (it is CancellationException) throw it - Logger.warn( - "Failed to refresh Paykit receiver paths for '${redacted(publicKey)}'; using saved paths", - it, - context = TAG, - ) - savedPaths - } - } - - private fun supportedReceiverPaths(receiverPaths: Collection): List = - PaykitReceiverPaths.supported.filter { it in receiverPaths } - .ifEmpty { listOf(PaykitReceiverPaths.WALLET) } - - private suspend fun refreshSavedContactEndpointsDuringInitialLinkBurst( - publicKeys: Collection, - reason: String, - ) = withContext(serializedDispatcher) { - if (!canPublishPrivateEndpoints()) { - prepareRelevantPrivateLinksIfAvailable(publicKeys, "$reason initial link burst") - return@withContext - } - publishLocalEndpoints(publicKeys.toList(), reason = "$reason initial link burst") - .onFailure { Logger.warn("Failed initial private Paykit sync for '$reason'", it, context = TAG) } - } - - private fun clearInitialLinkBurst() { - synchronized(initialLinkBurstLock) { - initialLinkBurstJob?.cancel() - initialLinkBurstJob = null - initialLinkBurstPublicKeys.clear() - initialLinkBurstGeneration += 1 - } - } - - private fun receiverPathsForPrivateEndpointCleanup( - publicKey: String, - excludedReceiverPaths: List, - linkedReceiverPaths: Collection, - ): List { - val publishedPaths = publishedPrivatePaymentReceiverPaths(publicKey) - return (publishedPaths + linkedReceiverPaths) - .filter { it in PaykitReceiverPaths.supported } - .filterNot { it in excludedReceiverPaths } - .distinct() - .sorted() - } - - private fun receiverPathsForCleanup( - publicKey: String, - linkedReceiverPaths: Collection, - ): List { - return (linkedReceiverPaths + publishedPrivatePaymentReceiverPaths(publicKey)) - .filter { it in PaykitReceiverPaths.supported } - .distinct() - .sorted() - } - - private suspend fun linkedReceiverPathsByPublicKey(): Map> { - val linkedPaths = mutableMapOf>() - paykitSdkService.linkedPeers().forEach { peer -> - val publicKey = normalizedPublicKey(peer.counterparty) ?: return@forEach - if (peer.counterpartyReceiverPath in PaykitReceiverPaths.supported) { - linkedPaths.getOrPut(publicKey, ::mutableSetOf) += peer.counterpartyReceiverPath - } - } - return linkedPaths - } - - private suspend fun linkedReceiverPathsSnapshot(reason: String): LinkedReceiverPathsSnapshot { - repeat(2) { attempt -> - val result = runSuspendCatching { linkedReceiverPathsByPublicKey() } - result.getOrNull()?.let { return LinkedReceiverPathsSnapshot(it, null) } - val error = result.exceptionOrNull() ?: PrivatePaykitError.PrivateUnavailable - val suffix = if (attempt == 0) "; retrying once" else " after retry" - Logger.warn( - "Failed to inspect private Paykit links during '$reason'$suffix", - error, - context = TAG, - ) - if (attempt == 1) return LinkedReceiverPathsSnapshot(emptyMap(), error) - } - - return LinkedReceiverPathsSnapshot(emptyMap(), PrivatePaykitError.PrivateUnavailable) - } - private fun normalizedPublicKeyBatch(publicKeys: Collection): NormalizedPublicKeyBatch { val invalidKeys = mutableSetOf() val normalizedKeys = publicKeys.mapNotNull { publicKey -> @@ -1609,27 +1581,15 @@ class PrivatePaykitRepo @Inject constructor( return NormalizedPublicKeyBatch(normalizedKeys, invalidKeys) } - private fun publishedPrivatePaymentReceiverPaths(publicKey: String): List { - val contactState = state?.contacts?.get(publicKey) ?: return emptyList() - return contactState.publishedPrivatePaymentReceiverPaths.toList() - } - private suspend fun clearUnsavedContactState(savedPublicKeys: Collection): Result = withContext(serializedDispatcher) { runSuspendCatching { val savedKeys = savedPublicKeys.mapNotNull { normalizedPublicKey(it) }.toSet() - ensureState().contacts.keys.filter { it !in savedKeys }.forEach { - clearContactState(it) - } + clearContactStates(ensureState().contacts.keys.filter { it !in savedKeys }) addressReservationRepo.clearContactAssignments(excludingPublicKeys = savedKeys) - persistState(markWalletBackup = true) } } - private suspend fun clearContactState(publicKey: String) { - clearContactStates(listOf(publicKey)) - } - private suspend fun clearContactStates(publicKeys: Collection) { if (publicKeys.isEmpty()) return @@ -1638,7 +1598,11 @@ class PrivatePaykitRepo @Inject constructor( persistState(markWalletBackup = true) } - private suspend fun privatePayableEndpoints(endpoints: List, publicKey: String): List { + private suspend fun privatePayableEndpoints( + endpoints: List, + publicKey: String, + allowUsedOnchainAddress: Boolean, + ): List { val payable = publicPaykitRepo.payableEndpoints(endpoints) val attemptedHashes = attemptedOutboundBolt11PaymentHashes() val staleLightningHashes = mutableSetOf() @@ -1664,7 +1628,7 @@ class PrivatePaykitRepo @Inject constructor( true } } - endpoint.methodId.isOnchain -> { + endpoint.methodId.isOnchain && !allowUsedOnchainAddress -> { val isUsed = runSuspendCatching { coreService.isAddressUsed(endpoint.value) } .onFailure { Logger.warn( @@ -1703,17 +1667,22 @@ class PrivatePaykitRepo @Inject constructor( return paymentHash in paymentHashes } - private suspend fun canPublishPrivateEndpoints(): Boolean { + private suspend fun canPublishPrivateEndpoints(status: IdentityStatus? = null): Boolean { val settings = settingsStore.data.first() - return settings.sharesPrivatePaykitEndpoints && - hasPrivatePaymentAccessForCurrentProfile() && + val locallyEligible = settings.sharesPrivatePaykitEndpoints && + !isContactSharingCleanupPending() && App.currentActivity?.value != null && walletRepo.walletExists() && lightningRepo.lightningState.value.nodeLifecycleState.isRunning() + if (!locallyEligible) return false + return if (status?.publicKey != null) { + status.capability == PubkyIdentityCapability.PRIVATE_LINK_CAPABLE + } else { + hasPrivatePaymentAccessForCurrentProfile() + } } private suspend fun hasPrivatePaymentAccessForCurrentProfile(): Boolean = runSuspendCatching { - pubkyService.currentPublicKey() ?: return@runSuspendCatching false paykitSdkService.hasPrivatePaymentAccess() }.getOrDefault(false) @@ -1721,9 +1690,10 @@ class PrivatePaykitRepo @Inject constructor( cacheStore.data.first().cleanupPending private suspend fun hasPublishedPrivateEndpoints(): Boolean = - ensureState().contacts.values.any { it.publishedPrivatePaymentReceiverPaths.isNotEmpty() } + ensureState().contacts.values.any { it.hasPublishedPrivatePaymentList } private suspend fun updateContactSharingCleanupPending(isPending: Boolean) { + if (isPending) invalidateContactPreparation() cacheStore.update { it.copy(cleanupPending = isPending) } } @@ -1762,9 +1732,7 @@ class PrivatePaykitRepo @Inject constructor( .mapNotNull(::normalizedPublicKey) .filterNot { it in remainingPendingKeys } clearContactStates(successfulKeys) - successfulKeys.forEach { publicKey -> - addressReservationRepo.clearContactAssignment(publicKey) - } + addressReservationRepo.removeContactAssignments(successfulKeys) removalResult.getOrThrow() } } @@ -1772,7 +1740,7 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun settledPrivateInvoicePaymentHashes(): List { val settled = receivedSettledPaymentHashes() return ensureState().contacts.values - .flatMap { it.localInvoices() } + .mapNotNull { it.localInvoice } .map { it.paymentHash } .filter(settled::contains) } @@ -1821,24 +1789,21 @@ class PrivatePaykitRepo @Inject constructor( persistState() } - private fun localInvoice(publicKey: String, receiverPath: String): StoredInvoice? { + private fun localInvoice(publicKey: String): StoredInvoice? { val contactState = state?.contacts?.get(publicKey) ?: return null - return contactState.localInvoicesByReceiverPath[receiverPath] + return contactState.localInvoice } - private suspend fun setLocalInvoice(publicKey: String, receiverPath: String, invoice: StoredInvoice) { + private suspend fun setLocalInvoice(publicKey: String, invoice: StoredInvoice) { val contactState = ensureState().contacts.getOrPut(publicKey) { ContactState() } - contactState.localInvoicesByReceiverPath = contactState.localInvoicesByReceiverPath + (receiverPath to invoice) - } - - private fun ContactState.localInvoices(): List { - return localInvoicesByReceiverPath.values.toList() + contactState.localInvoice = invoice } private fun rememberSavedContacts(publicKeys: Collection, replacing: Boolean): List { val normalizedKeys = publicKeys.mapNotNull { normalizedPublicKey(it) }.distinct() if (replacing) { knownSavedContactKeys.clear() + unavailableLinkRetryAt.keys.retainAll(normalizedKeys.toSet()) } knownSavedContactKeys.addAll(normalizedKeys) return normalizedKeys diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 0c9c706d2d..3fcbf211ae 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -3,8 +3,9 @@ package to.bitkit.repositories import android.graphics.Bitmap import android.graphics.BitmapFactory import coil3.ImageLoader -import com.synonym.paykit.ContactProfileResolution import com.synonym.paykit.ContactRecord +import com.synonym.paykit.ContactUpdate +import com.synonym.paykit.ProfileResolution import com.synonym.paykit.PubkyAuthCompanionClaim import io.ktor.client.HttpClient import io.ktor.client.call.body @@ -51,11 +52,13 @@ import to.bitkit.data.sharedpubky.SharedPubkyContract import to.bitkit.di.IoDispatcher import to.bitkit.env.Env import to.bitkit.ext.isPaykitIdentityError +import to.bitkit.ext.isPaykitTemporarilyUnavailable import to.bitkit.ext.nowMs import to.bitkit.ext.runSuspendCatching import to.bitkit.models.HomegateResponse import to.bitkit.models.PubkyAuthClaim import to.bitkit.models.PubkyAuthRequest +import to.bitkit.models.PubkyAuthRequestError import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyProfileData import to.bitkit.models.PubkyProfileLink @@ -64,7 +67,6 @@ import to.bitkit.models.PubkySessionBackupKind import to.bitkit.models.PubkySessionBackupV1 import to.bitkit.services.PaykitReadLane import to.bitkit.services.PaykitReadTimeoutError -import to.bitkit.services.PaykitReceiverPaths import to.bitkit.services.PubkyService import to.bitkit.utils.AppError import to.bitkit.utils.Logger @@ -146,12 +148,25 @@ class PubkyRepo @Inject constructor( /** Shortest time between two contact list updates of a background profile refresh. */ internal val CONTACT_REFRESH_BATCH_WINDOW = 300.milliseconds + + /** Maximum automatic restore attempts during one foreground recovery window. */ + private const val DEFERRED_RESTORE_ATTEMPTS = 8 + + /** Delay after a deferred restore before another attempt may begin. */ + private val DEFERRED_RESTORE_INTERVAL = 5.seconds } private val scope = appScope(ioDispatcher, TAG) private val serviceInitializeMutex = Mutex() private val initializeMutex = Mutex() + private val deferredRestoreMutex = Mutex() + private var deferredRestoreGeneration: Long? = null + private val completedRestoreVersion = AtomicLong() + private var completedRestoreSignInGeneration = -1L private val loadProfileMutex = Mutex() + private val completedProfileLoadVersion = AtomicLong() + private var completedProfileSignInGeneration = -1L + private var completedProfileWriteGeneration = -1L private val loadContactsMutex = Mutex() private val contactsLock = Any() private var contactsRevision = 0L @@ -245,6 +260,7 @@ class PubkyRepo @Inject constructor( data object NoSession : InitResult data class Restored(val publicKey: String) : InitResult data object RestorationFailed : InitResult + data object RestorationDeferred : InitResult } private data class SavedContact( @@ -321,39 +337,46 @@ class PubkyRepo @Inject constructor( suspend fun initialize() = withContext(ioDispatcher) { val restored = initializeMutex.withLock { initializeSession() } - if (restored) { - loadProfile() - loadContacts() - } + if (restored) loadIdentityData() checkAdoptedSource() } suspend fun restoreSessionIfNeeded() = withContext(ioDispatcher) { awaitInitialization() - val restored = initializeMutex.withLock { restoreSessionLocked() } - if (restored) { - loadProfile() - loadContacts() - } + val restored = restoreSession() + if (restored) loadIdentityData() restored } + /** Retries a temporarily unavailable saved session while the caller remains active. */ + suspend fun retryDeferredSessionRestoration() = withContext(ioDispatcher) { + deferredRestoreMutex.withLock { + awaitInitialization() + repeat(DEFERRED_RESTORE_ATTEMPTS) { + val generation = initializeMutex.withLock { + deferredRestoreGeneration?.takeIf { it == signInGeneration.get() } + } ?: return@withLock + delay(DEFERRED_RESTORE_INTERVAL) + if (restoreSession(expectedGeneration = generation)) { + loadIdentityData() + return@withLock + } + } + } + } + /** - * Waits until a saved identity can be used. When no session is active, it retries the restore once, after any - * resume retry, adoption, identity creation or backup restore already under way, so an earlier success is never - * restored twice. The retry runs in the repository scope, so cancelling the caller does not interrupt it, and the - * profile and contacts it loads are not awaited. + * Waits for a usable saved identity, sharing any in-flight restore. Later calls can retry failures. + * Restoration waits for active identity work and runs in the repository scope, so cancelling the caller does not + * interrupt it. Profile and contact loading are not awaited. */ suspend fun awaitIdentityReady(): PubkyIdentityReadiness = withContext(ioDispatcher) { awaitInitialization() if (_publicKey.value != null) return@withContext PubkyIdentityReadiness.Ready scope.async { - val restored = initializeMutex.withLock { restoreSessionLocked() } + val restored = restoreSession() if (restored) { - scope.launch { - loadProfile() - loadContacts() - } + scope.launch { loadIdentityData() } } }.await() when { @@ -363,6 +386,33 @@ class PubkyRepo @Inject constructor( } } + private suspend fun loadIdentityData() { + coroutineScope { + launch { loadProfile() } + launch { loadContacts() } + } + } + + private suspend fun restoreSession(expectedGeneration: Long? = null): Boolean { + val version = completedRestoreVersion.get() + return initializeMutex.withLock { + if (expectedGeneration != null && + (expectedGeneration != signInGeneration.get() || expectedGeneration != deferredRestoreGeneration) + ) { + return@withLock false + } + if (completedRestoreVersion.get() != version && + completedRestoreSignInGeneration == signInGeneration.get() + ) { + return@withLock false + } + restoreSessionLocked().also { + completedRestoreSignInGeneration = signInGeneration.get() + completedRestoreVersion.incrementAndGet() + } + } + } + private suspend fun restoreSessionLocked(): Boolean { if (_publicKey.value != null) return false return runSuspendCatching { @@ -376,27 +426,36 @@ class PubkyRepo @Inject constructor( private suspend fun initializeSession(notifyFailure: Boolean = true): Boolean { _isRestoringSession.update { true } try { - runSuspendCatching { - ensureServiceInitialized() + val savedSession = runSuspendCatching { keychain.loadString(Keychain.Key.PAYKIT_SESSION.name) } + val importedSession = runSuspendCatching { + ensureServiceInitialized(savedSession.getOrNull()) }.onFailure { Logger.error("Failed to initialize paykit", it, context = TAG) + deferredRestoreGeneration = signInGeneration.get().takeIf { _ -> it.isPaykitTemporarilyUnavailable() } if (notifyFailure && it.isPaykitIdentityError() && hasSavedSession()) { _sessionRestorationFailed.update { true } } - }.getOrNull() ?: return false + }.getOrElse { return false } if (notifyFailure) _sessionRestorationFailed.update { false } val result = runSuspendCatching { - val savedSessionSecret = keychain.loadString(Keychain.Key.PAYKIT_SESSION.name) + val savedSessionSecret = savedSession.getOrThrow() val storedSecretKeyHex = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) resolveSessionInitialization( savedSessionSecret = savedSessionSecret, storedSecretKeyHex = storedSecretKeyHex, + importedSession = importedSession, ) }.onFailure { Logger.error("Failed to initialize paykit", it, context = TAG) - }.getOrElse { InitResult.RestorationFailed } + }.getOrElse { + if (it.isPaykitTemporarilyUnavailable()) { + InitResult.RestorationDeferred + } else { + InitResult.RestorationFailed + } + } when (result) { is InitResult.NoSession -> { @@ -408,14 +467,15 @@ class PubkyRepo @Inject constructor( continueSignIn(result.publicKey) Logger.info("Restored paykit session for '${redacted(result.publicKey)}'", context = TAG) } - is InitResult.RestorationFailed -> { + InitResult.RestorationFailed, InitResult.RestorationDeferred -> { clearAuthenticatedState( clearCachedProfile = false, clearRestorationFailure = notifyFailure, ) - if (notifyFailure) _sessionRestorationFailed.update { true } + if (notifyFailure) _sessionRestorationFailed.update { result == InitResult.RestorationFailed } } } + deferredRestoreGeneration = signInGeneration.get().takeIf { result == InitResult.RestorationDeferred } initializationReady.complete(Unit) return result is InitResult.Restored } finally { @@ -427,24 +487,39 @@ class PubkyRepo @Inject constructor( keychain.loadString(Keychain.Key.PAYKIT_SESSION.name) }.getOrNull()?.isNotBlank() == true - private suspend fun ensureServiceInitialized() = withContext(ioDispatcher) { - serviceInitializeMutex.withLock { - if (!isServiceInitialized) { - pubkyService.initialize() + private suspend fun ensureServiceInitialized(savedSessionSecret: String? = null): Result? = + withContext(ioDispatcher) { + serviceInitializeMutex.withLock { + if (isServiceInitialized) return@withLock null + val importedSession = if (savedSessionSecret.isNullOrEmpty()) { + pubkyService.initialize() + null + } else { + pubkyService.initializeAndImportSession(savedSessionSecret) + } isServiceInitialized = true + importedSession } } - } private suspend fun resolveSessionInitialization( savedSessionSecret: String?, storedSecretKeyHex: String?, + importedSession: Result?, ): InitResult = withContext(ioDispatcher) { if (!savedSessionSecret.isNullOrEmpty()) { runSuspendCatching { - val publicKey = pubkyService.importSession(savedSessionSecret).ensurePubkyPrefix() - InitResult.Restored(publicKey) + val publicKey = if (importedSession != null) { + importedSession.getOrThrow() + } else { + pubkyService.importSession(savedSessionSecret) + } + InitResult.Restored(publicKey.ensurePubkyPrefix()) }.getOrElse { + if (it.isPaykitTemporarilyUnavailable()) { + Logger.warn("Deferred session restoration, keeping saved session", it, context = TAG) + return@getOrElse InitResult.RestorationDeferred + } Logger.warn("Failed to restore paykit session, attempting re-sign-in", it, context = TAG) resolveSignedInSession(savedSessionSecret, storedSecretKeyHex ?: adoptedSecretKeyHex()) } @@ -473,7 +548,11 @@ class PubkyRepo @Inject constructor( InitResult.Restored(publicKey) }.getOrElse { Logger.error("Failed re-sign-in recovery", it, context = TAG) - InitResult.RestorationFailed + if (it.isPaykitTemporarilyUnavailable()) { + InitResult.RestorationDeferred + } else { + InitResult.RestorationFailed + } } } } @@ -680,39 +759,45 @@ class PubkyRepo @Inject constructor( // region Profile loading suspend fun loadProfile() { - val pk = _publicKey.value ?: return - loadProfileMutex.lock() - if (_publicKey.value != pk) { - loadProfileMutex.unlock() - return - } - val writeGeneration = profileWriteGeneration.get() - val isCurrentLoad = { _publicKey.value == pk && profileWriteGeneration.get() == writeGeneration } + val signIn = currentSignIn() ?: return + val version = completedProfileLoadVersion.get() + loadProfileMutex.withLock { + if (!isCurrent(signIn)) return + val writeGeneration = profileWriteGeneration.get() + if (completedProfileLoadVersion.get() != version && completedProfileSignInGeneration == signIn.generation && + completedProfileWriteGeneration == writeGeneration + ) { + return + } + val isCurrentLoad = { isCurrent(signIn) && profileWriteGeneration.get() == writeGeneration } - _isLoadingProfile.update { true } - try { - runSuspendCatching { - withContext(ioDispatcher) { - resolveContactProfile(pk, retry = true).getOrThrow() - ?: throw AppError("Profile not found") - } - }.onSuccess { loadedProfile -> - var isCurrent = false - _profile.update { - isCurrent = isCurrentLoad() - if (isCurrent) loadedProfile else it - } - if (!isCurrent) { - Logger.debug("Skipped stale profile load for '${redacted(pk)}'", context = TAG) - return@onSuccess + _isLoadingProfile.update { true } + try { + runSuspendCatching { + withContext(ioDispatcher) { + resolveContactProfile(signIn.publicKey, retry = true).getOrThrow() + ?: throw AppError("Profile not found") + } + }.onSuccess { loadedProfile -> + var isCurrent = false + _profile.update { + isCurrent = isCurrentLoad() + if (isCurrent) loadedProfile else it + } + if (!isCurrent) { + Logger.debug("Skipped stale profile load for '${redacted(signIn.publicKey)}'", context = TAG) + return@onSuccess + } + cacheMetadata(loadedProfile, isCurrentLoad) + }.onFailure { + Logger.error("Failed to load profile", it, context = TAG) } - cacheMetadata(loadedProfile, isCurrentLoad) - }.onFailure { - Logger.error("Failed to load profile", it, context = TAG) + completedProfileSignInGeneration = signIn.generation + completedProfileWriteGeneration = writeGeneration + completedProfileLoadVersion.incrementAndGet() + } finally { + _isLoadingProfile.update { false } } - } finally { - _isLoadingProfile.update { false } - loadProfileMutex.unlock() } } @@ -931,6 +1016,7 @@ class PubkyRepo @Inject constructor( requireNotNull(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)) { "No session available" } + clearSessionContactProfiles() deleteAllContacts() runSuspendCatching { pubkyService.deletePaykitProfile() @@ -953,12 +1039,13 @@ class PubkyRepo @Inject constructor( if (!it.isMissingPubkyData()) throw it emptyList() } - records.forEach { record -> - runSuspendCatching { - pubkyService.removeContact(record.publicKey) - }.onFailure { - Logger.warn("Failed to delete contact '${redacted(record.publicKey)}'", it, context = TAG) + runSuspendCatching { + val removed = pubkyService.removeContacts(records.map { it.publicKey }) + if (removed.size != records.size) { + Logger.warn("Retained contacts that could not be removed during profile deletion", context = TAG) } + }.onFailure { + Logger.warn("Failed to delete contacts", it, context = TAG) } pubkyStore.update { it.copy(contactProfileOverrides = emptyMap()) } notifyBackupStateChanged() @@ -1073,9 +1160,9 @@ class PubkyRepo @Inject constructor( * because the background refresh of [loadContacts] has not finished looking it up, so a screen showing that * contact does not wait behind bulk reads and an edit made there keeps the contact's avatar, bio and links. The * lookup takes the contact over from the refresh, which stops its own lookup and never applies a result for it, - * and a caller arriving meanwhile waits for the same lookup. Only a sign-out or an identity change stops it, not a - * later refresh. It returns at once for any other row; when the lookup fails, the row keeps its label. A profile - * the refresh already found for the contact but has not applied to the list yet is applied at once instead. + * and a caller arriving meanwhile waits for the same lookup. Sign-out, profile deletion or an identity change + * stops it, not a later refresh. It returns at once for any other row; when the lookup fails, the row keeps its + * label. A profile already found by the refresh but not yet applied to the contact list is applied at once. */ suspend fun resolvePendingContactProfile(publicKey: String) { val owner = _publicKey.value ?: return @@ -1121,7 +1208,6 @@ class PubkyRepo @Inject constructor( pubkyService.saveContact( prefixedKey, profile.name, - relevantReceiverPaths(prefixedKey), restorePrivateConnection = true, ) _publicKey.value?.let { cacheSessionContactProfiles(it, listOf(profile)) } @@ -1134,16 +1220,6 @@ class PubkyRepo @Inject constructor( } } - suspend fun refreshContactReceiverPaths(publicKey: String): Result = runSuspendCatching { - withContext(ioDispatcher) { - val prefixedKey = requireAddableContactPublicKey(publicKey = publicKey, allowExisting = true) - val contact = _contacts.value.firstOrNull { PubkyPublicKeyFormat.matches(it.publicKey, prefixedKey) } - ?: return@withContext - pubkyService.saveContact(prefixedKey, contact.name, relevantReceiverPaths(prefixedKey)) - Logger.info("Refreshed contact receiver paths for '${redacted(prefixedKey)}'", context = TAG) - } - } - /** * Saves a contact's label and keeps the rest of its profile as the contact's local override. The edit belongs to * [signIn]: once that identity signs out or the next sign-in starts, it fails with @@ -1215,21 +1291,23 @@ class PubkyRepo @Inject constructor( /** * Saves [profiles], the follows [prepareImport] resolved, without looking them up again; receiver discovery runs - * during contact refresh. A contact already saved is skipped, and a failed save keeps the others and fails the - * import, so a retry saves only the missing contacts. The import runs in the repository scope, so it finishes even - * when the caller is cancelled, stops saving once the identity changes, and clears the pending import once it + * during contact refresh. Contacts already saved and duplicate selections are skipped. The remaining contacts + * are saved atomically, so a failed batch leaves them all pending for retry. The import runs in the repository + * scope, so it finishes even when the caller is cancelled, rejects an ended sign-in, and clears the import once it * succeeds. A success bumps [contactImportVersion] and a failure sets [contactImportFailure], so both reach the app * after the import screens are gone. An import stopped by an identity change, such as a sign-out, reports nothing. */ suspend fun importContacts(profiles: List): Result = scope.async(start = CoroutineStart.UNDISPATCHED) { - val owner = _publicKey.value + val signIn = currentSignIn() activeContactImports.update { it + 1 } try { - saveImportedContacts(profiles) - .onSuccess { _contactImportVersion.update { it + 1 } } + saveImportedContacts(profiles, signIn) + .onSuccess { + if (signIn != null && isCurrent(signIn)) _contactImportVersion.update { it + 1 } + } .onFailure { error -> - if (_publicKey.value != owner) { + if (signIn != null && !isCurrent(signIn)) { Logger.info("Stopped a contact import after the identity changed", context = TAG) return@onFailure } @@ -1241,33 +1319,25 @@ class PubkyRepo @Inject constructor( } }.await() - private suspend fun saveImportedContacts(profiles: List): Result = runSuspendCatching { + private suspend fun saveImportedContacts( + profiles: List, + expectedSignIn: PubkySignIn?, + ): Result = runSuspendCatching { withContext(ioDispatcher) { - val owner = requireNotNull(_publicKey.value) { "Not authenticated" } - val imported = mutableListOf() + val signIn = requireNotNull(expectedSignIn) { "Not authenticated" } + requireCurrent(signIn) val existing = _contacts.value.map { it.publicKey }.toMutableSet() - var firstError: Throwable? = null - for (profile in profiles.distinctBy { it.publicKey }) { - if (profile.publicKey in existing) continue - check(_publicKey.value == owner) { "Pubky identity changed while importing contacts" } - runSuspendCatching { - // The preview already resolved this profile. Receiver discovery runs during contact refresh. - pubkyService.saveContact( - profile.publicKey, - profile.name, - restorePrivateConnection = true, - expectedIdentity = owner, - ) - imported.add(profile) - existing.add(profile.publicKey) - }.onFailure { - firstError = firstError ?: it - Logger.warn("Failed to import contact '${redacted(profile.publicKey)}'", it, context = TAG) - } + val imported = profiles.filter { existing.add(it.publicKey) } + if (imported.isNotEmpty()) { + pubkyService.saveContacts( + updates = imported.map { ContactUpdate(it.publicKey, it.name) }, + expectedIdentity = signIn.publicKey, + isStillCurrent = { isCurrent(signIn) }, + ) } synchronized(contactsLock) { - check(_publicKey.value == owner) { "Pubky identity changed while importing contacts" } - cacheSessionContactProfiles(owner, imported) + requireCurrent(signIn) + cacheSessionContactProfiles(signIn.publicKey, imported) updateContacts { current -> val currentKeys = current.map { it.publicKey }.toSet() (current + imported.filter { it.publicKey !in currentKeys }) @@ -1276,7 +1346,6 @@ class PubkyRepo @Inject constructor( } markContactsLoaded() Logger.info("Imported '${imported.size}' contacts", context = TAG) - firstError?.let { throw it } clearPendingImport() } } @@ -1463,17 +1532,21 @@ class PubkyRepo @Inject constructor( unsignedPayload: ByteArray, ): Result = runSuspendCatching { withContext(ioDispatcher) { + val claim = PubkyAuthRequest.parseBitkitClaim( + authUrl, + PubkyAuthClaim.REQUIRED_CAPABILITIES, + ).getOrThrow() ?: throw PubkyAuthRequestError.MissingBitkitClaim val secretKeyHex = requireNotNull(activeSecretKeyHex()) { "No secret key available — use Ring to manage authorizations" } pubkyService.approveAuthWithCompanionClaim( authUrl = authUrl, - expectedCapabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES, + expectedCapabilities = PubkyAuthClaim.REQUIRED_CAPABILITIES, approvedClientId = approvedClientId, secretKeyHex = secretKeyHex, claim = PubkyAuthCompanionClaim( queryParameter = PubkyAuthClaim.QUERY_PARAMETER, - claimType = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue, + claimType = claim.wireValue, unsignedPayload = unsignedPayload, ), ) @@ -1793,7 +1866,7 @@ class PubkyRepo @Inject constructor( lane = lane, )?.let(::profileFromResolution) - private fun profileFromResolution(resolution: ContactProfileResolution): PubkyProfile { + private fun profileFromResolution(resolution: ProfileResolution): PubkyProfile { val prefixedKey = resolution.publicKey.ensurePubkyPrefix() resolution.paykitProfile?.let { return PubkyProfile.fromPaykitProfile(prefixedKey, it) @@ -1808,14 +1881,6 @@ class PubkyRepo @Inject constructor( ) } - private suspend fun relevantReceiverPaths(publicKey: String): List = - runSuspendCatching { - pubkyService.discoverRelevantReceiverPaths(publicKey) - }.onFailure { - Logger.warn("Failed to discover Paykit receivers for '${redacted(publicKey)}'", it, context = TAG) - }.getOrNull() - ?: listOf(PaykitReceiverPaths.WALLET) - private suspend fun upsertContactProfileOverride(profile: PubkyProfile, signIn: PubkySignIn) { val prefixedKey = profile.publicKey.ensurePubkyPrefix() pubkyStore.update { data -> @@ -1987,20 +2052,11 @@ class PubkyRepo @Inject constructor( settingsStore.setPubkyProfileSetupPending(false) } - private fun requireAddableContactPublicKey(publicKey: String, allowExisting: Boolean = false): String { - val prefixedKey = PubkyPublicKeyFormat.normalized(publicKey) - return requireValidAddableContactPublicKey(prefixedKey, allowExisting) - } - private fun requireCanonicalAddableContactPublicKey( publicKey: String, allowExisting: Boolean = false, ): String { val prefixedKey = PubkyPublicKeyFormat.canonicalized(publicKey) - return requireValidAddableContactPublicKey(prefixedKey, allowExisting) - } - - private fun requireValidAddableContactPublicKey(prefixedKey: String?, allowExisting: Boolean): String { contactValidationError(prefixedKey, allowExisting)?.let { throw it } return checkNotNull(prefixedKey) { "Normalized pubky key is required" } } diff --git a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt index 8c309ae7b5..1dce23798a 100644 --- a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -19,7 +19,7 @@ import to.bitkit.ext.toHex import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.toLdkNetwork import to.bitkit.services.CoreService -import to.bitkit.services.PaykitReceiverPaths +import to.bitkit.services.PaykitSdkOperationLock.Priority import to.bitkit.services.PaykitSdkService import to.bitkit.utils.AppError import to.bitkit.utils.Logger @@ -85,8 +85,8 @@ internal val PublicPaykitPaymentResult.incomingPaymentRequestFailureReason: } data class PrivatePaykitPaymentContext( - val receiverPath: String, - val paymentListVersion: ULong, + val paymentAppsByEndpoint: Map, + val paymentListVersion: ULong?, ) @OptIn(ExperimentalTime::class) @@ -209,22 +209,28 @@ class PublicPaykitRepo @Inject constructor( endpoints.filter { isPayable(it) } } - suspend fun syncPublishedEndpoints(publish: Boolean): Result = withContext(ioDispatcher) { + suspend fun syncPublishedEndpoints(publish: Boolean): Result = + syncPublishedEndpoints(publish, Priority.Ordered) + + internal suspend fun syncPublishedEndpoints( + publish: Boolean, + appSyncPriority: Priority, + ): Result = withContext(ioDispatcher) { runSuspendCatching { if (!publish) { val endpointError = runSuspendCatching { removePublishedEndpoints() }.exceptionOrNull() - val markerError = syncLocalReceiverMarker(publicSharingEnabled = false).exceptionOrNull() + val appError = syncPaykitApp(priority = appSyncPriority).exceptionOrNull() if (endpointError != null) { - markerError?.let(endpointError::addSuppressed) + appError?.let(endpointError::addSuppressed) throw endpointError } - markerError?.let { throw it } + appError?.let { throw it } settingsStore.update { it.copy(publicPaykitCleanupPending = false) } return@runSuspendCatching } val desired = buildWalletEndpoints(refresh = true) - syncLocalReceiverMarker(publicSharingEnabled = true).getOrThrow() + syncPaykitApp().getOrThrow() applyPublishedEndpoints(desired) settingsStore.update { it.copy(publicPaykitCleanupPending = false) } } @@ -235,12 +241,13 @@ class PublicPaykitRepo @Inject constructor( requireEndpoint: Boolean = false, ): Result = withContext(ioDispatcher) { runSuspendCatching { + pubkyRepo.isRestoringSession.first { !it } val desired = buildWalletEndpoints( refresh = false, forceRefreshLightning = forceRefreshLightning, requireEndpoint = requireEndpoint, ) - syncLocalReceiverMarker(publicSharingEnabled = true).getOrThrow() + syncPaykitApp().getOrThrow() applyPublishedEndpoints(desired) settingsStore.update { it.copy(publicPaykitCleanupPending = false) } } @@ -263,26 +270,24 @@ class PublicPaykitRepo @Inject constructor( val normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) ?: publicKey paykitSdkService.resolvePublicContactPayment( counterparty = normalizedKey, - receiverPath = PaykitReceiverPaths.WALLET, ).payableEndpoints - .mapNotNull { parseEndpoint(it.identifier, it.payload) } - .associateBy { it.methodId } - .values + .mapNotNull { parseEndpoint(it.identifier, it.payload)?.copy(appId = it.appId) } .sortedBy { endpoint -> payablePreferenceOrder.indexOf(endpoint.methodId) } } } - suspend fun syncLocalReceiverMarker( - publicSharingEnabled: Boolean? = null, + suspend fun syncPaykitApp( privateSharingEnabled: Boolean? = null, + ): Result = syncPaykitApp(privateSharingEnabled, Priority.Ordered) + + internal suspend fun syncPaykitApp( + privateSharingEnabled: Boolean? = null, + priority: Priority, ): Result = withContext(ioDispatcher) { runSuspendCatching { val settings = settingsStore.data.first() - val publicSharing = publicSharingEnabled ?: settings.sharesPublicPaykitEndpoints val privateSharing = privateSharingEnabled ?: settings.sharesPrivatePaykitEndpoints - paykitSdkService.syncLocalReceiverMarker( - isDiscoverable = publicSharing || privateSharing, - ) + paykitSdkService.syncPaykitApp(privatePaymentsEnabled = privateSharing, priority = priority) } } @@ -454,6 +459,7 @@ data class Endpoint( val min: String? = null, val max: String? = null, val rawPayload: String, + val appId: String? = null, ) { val paymentRequest: String get() = value diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index 431b046303..1c2e0dd355 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -95,6 +95,7 @@ import kotlin.time.Clock import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime +import kotlin.time.Instant import com.synonym.bitkitcore.Network as BitkitCoreNetwork @OptIn(ExperimentalTime::class) @@ -599,12 +600,14 @@ class TrezorRepo @Inject constructor( suspend fun broadcastRawTx( serializedTx: String, + paymentDeadlineAt: Instant? = null, ): Result = withContext(ioDispatcher) { runSuspendCatching { awaitSetup() trezorService.broadcastRawTx( serializedTx = serializedTx, electrumUrl = currentElectrumUrl(), + paymentDeadlineAt = paymentDeadlineAt, ) }.onFailure { Logger.error("Trezor broadcastRawTx failed", it, context = TAG) diff --git a/app/src/main/java/to/bitkit/repositories/WatchOnlyAccountRepo.kt b/app/src/main/java/to/bitkit/repositories/WatchOnlyAccountRepo.kt index 671dfc25ab..36bdc5b643 100644 --- a/app/src/main/java/to/bitkit/repositories/WatchOnlyAccountRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WatchOnlyAccountRepo.kt @@ -15,6 +15,7 @@ import to.bitkit.ext.nowMillis import to.bitkit.ext.runSuspendCatching import to.bitkit.models.PreparedWatchOnlyAccountClaim import to.bitkit.models.PubkyAuthClaim +import to.bitkit.models.PubkyAuthClaimCodec import to.bitkit.models.WATCH_ONLY_ACCOUNT_HIGHEST_PRE_REVEALED_ADDRESS_INDEX import to.bitkit.models.WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE import to.bitkit.models.WATCH_ONLY_ACCOUNT_SERIALIZED_XPUB_LENGTH @@ -328,7 +329,7 @@ object WatchOnlyAccountClaimCodec { const val VERSION: Byte = 1 const val NATIVE_SEGWIT_ADDRESS_TYPE: Byte = 0 const val SERIALIZED_XPUB_LENGTH = WATCH_ONLY_ACCOUNT_SERIALIZED_XPUB_LENGTH - const val PAYLOAD_LENGTH = 1 + 4 + 1 + SERIALIZED_XPUB_LENGTH + const val PAYLOAD_LENGTH = PubkyAuthClaimCodec.WATCH_ONLY_PAYLOAD_LENGTH fun encode( account: WatchOnlyAccountRecord, diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 9d4ab907c8..2c96b03aee 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -100,6 +100,7 @@ import to.bitkit.models.msatFloorOf import to.bitkit.models.toAddressType import to.bitkit.models.toCoreNetwork import to.bitkit.models.toSettingsString +import to.bitkit.repositories.PaykitReceivedPaymentContacts import to.bitkit.repositories.PrivatePaykitContactResolver import to.bitkit.utils.AppError import to.bitkit.utils.Logger @@ -210,6 +211,7 @@ class CoreService @Inject constructor( suspend fun wipeData(): Result = ServiceQueue.CORE.background { runCatching { + activity.invalidatePaykitContactBackfill() val result = wipeAllDatabases() Logger.info("Core DB wipe: '$result'", context = TAG) }.onFailure { @@ -371,9 +373,23 @@ class ActivityService( private val privatePaykitContactResolver: Provider, ) { private val defaultWalletId = WalletScope.default + private var paykitActivityRevision = 0L + private var lastPaykitContactBackfill: PaykitContactBackfillState? = null + + private data class PaykitContactBackfillState( + val contacts: PaykitReceivedPaymentContacts, + val generation: Long, + val activityRevision: Long, + val reservationVersion: Long, + ) + + internal suspend fun invalidatePaykitContactBackfill() = ServiceQueue.CORE.background { + paykitActivityRevision++ + } suspend fun removeAll() { ServiceQueue.CORE.background { + invalidatePaykitContactBackfill() // Get all activities and delete them one by one val activities = getActivities( walletId = null, @@ -396,18 +412,22 @@ class ActivityService( } suspend fun deleteByWalletId(walletId: String): UInt = ServiceQueue.CORE.background { + invalidatePaykitContactBackfill() deleteActivitiesByWalletId(walletId) } suspend fun insert(activity: Activity) = ServiceQueue.CORE.background { + invalidatePaykitContactBackfill() insertActivity(activity) } suspend fun upsert(activity: Activity) = ServiceQueue.CORE.background { + invalidatePaykitContactBackfill() upsertActivity(activity) } suspend fun upsertList(activities: List) = ServiceQueue.CORE.background { + invalidatePaykitContactBackfill() upsertActivities(activities) } @@ -427,6 +447,7 @@ class ActivityService( transactionDetails: List, transferChannelIdsByFundingTxId: Map, ): HwSnapshotResult = ServiceQueue.CORE.background { + invalidatePaykitContactBackfill() val existingActivities = getActivities( walletId = walletId, filter = ActivityFilter.ONCHAIN, @@ -564,10 +585,95 @@ class ActivityService( } suspend fun update(id: String, activity: Activity) = ServiceQueue.CORE.background { + invalidatePaykitContactBackfill() updateActivity(activityId = id, activity = activity) } + suspend fun backfillPaykitContacts(): Boolean = ServiceQueue.CORE.background { + val resolver = privatePaykitContactResolver.get() + val contacts = resolver.receivedPaymentContacts + if (contacts === PaykitReceivedPaymentContacts.Empty) { + lastPaykitContactBackfill = null + return@background false + } + val snapshot = PaykitContactBackfillState( + contacts = contacts, + generation = resolver.receivedPaymentContactsGeneration, + activityRevision = paykitActivityRevision, + reservationVersion = resolver.reservationVersion, + ) + if (snapshot == lastPaykitContactBackfill) return@background false + val activities = getActivities( + walletId = null, + filter = ActivityFilter.ALL, + txType = PaymentType.RECEIVED, + tags = null, + search = null, + minDate = null, + maxDate = null, + limit = null, + sortDirection = null, + ) + var changed = false + var complete = true + for (activity in activities) { + if (!isCurrentPaykitContactBackfill(resolver, snapshot)) return@background changed + if (cacheStore.data.first().isContactDetached(activity.rawId(), activity.walletId())) continue + val (updated, hydrated) = attributedPaykitActivity(activity, contacts) + complete = complete && hydrated + if (!isCurrentPaykitContactBackfill(resolver, snapshot)) return@background changed + if (updated != null && !cacheStore.data.first().isContactDetached(activity.rawId(), activity.walletId())) { + if (!isCurrentPaykitContactBackfill(resolver, snapshot)) return@background changed + updateActivity(activityId = activity.rawId(), activity = updated) + changed = true + } + } + if (complete && isCurrentPaykitContactBackfill(resolver, snapshot)) { + lastPaykitContactBackfill = snapshot + } + changed + } + + private fun isCurrentPaykitContactBackfill( + resolver: PrivatePaykitContactResolver, + snapshot: PaykitContactBackfillState, + ): Boolean = resolver.receivedPaymentContacts === snapshot.contacts && + resolver.receivedPaymentContactsGeneration == snapshot.generation && + paykitActivityRevision == snapshot.activityRevision && + resolver.reservationVersion == snapshot.reservationVersion + + private suspend fun attributedPaykitActivity( + activity: Activity, + contacts: PaykitReceivedPaymentContacts, + ): Pair = when (activity) { + is Activity.Lightning -> { + val contact = receivedPaykitContact(activity.v1, contacts) + contact?.let { Activity.Lightning(activity.v1.copy(contact = it)) } to true + } + is Activity.Onchain -> { + val (contact, hydrated) = receivedPaykitContact(activity.v1) + contact?.let { Activity.Onchain(activity.v1.copy(contact = it)) } to hydrated + } + } + + private fun receivedPaykitContact(row: LightningActivity, contacts: PaykitReceivedPaymentContacts): String? { + if (row.contact != null || row.txType != PaymentType.RECEIVED || row.status == PaymentState.FAILED) return null + return contacts.contactsForPaymentHash(row.id).singleOrNull() + } + + private suspend fun receivedPaykitContact(row: OnchainActivity): Pair { + if (row.contact != null || row.txType != PaymentType.RECEIVED) return null to true + val details = getBitkitCoreTransactionDetails(walletId = row.walletId, txId = row.txId) + val addresses = details?.outputs?.mapNotNull { it.scriptpubkeyAddress }.orEmpty() + if (row.address !in addresses) return null to false + return privatePaykitContactResolver.get().contactPublicKeyForPrivateOnchainAddresses( + receivingAddress = row.address, + addresses = addresses, + ) to true + } + suspend fun delete(id: String, walletId: String = defaultWalletId): Boolean = ServiceQueue.CORE.background { + invalidatePaykitContactBackfill() deleteActivityById(walletId = walletId, activityId = id) } @@ -784,7 +890,11 @@ class ActivityService( val contact = existingActivity ?.takeIf { it is Activity.Lightning } ?.let { (it as Activity.Lightning).v1.contact } - ?: privatePaykitContactPublicKeyForReceivedInvoicePaymentHash(payment.id, payment.direction) + ?: payment.takeUnless { + it.status == PaymentStatus.FAILED || cacheStore.data.first().isContactDetached(it.id, defaultWalletId) + }?.let { + privatePaykitContactPublicKeyForReceivedInvoicePaymentHash(it.id, it.direction) + } val ln = if (existingActivity is Activity.Lightning) { existingActivity.v1.withPaymentUpdate( @@ -809,6 +919,7 @@ class ActivityService( ) }.withPendingMessage(pendingMessage = pendingMessage, description = kind.description) + if (existingActivity != Activity.Lightning(ln)) invalidatePaykitContactBackfill() if (getActivityById(walletId = defaultWalletId, activityId = payment.id) != null) { updateActivity(activityId = payment.id, activity = Activity.Lightning(ln)) } else { @@ -833,7 +944,7 @@ class ActivityService( as? Activity.Lightning ?: return@background val updated = existing.v1.withPendingMessage(pendingMessage = message, description = description) if (updated != existing.v1) { - updateActivity(activityId = paymentHash, activity = Activity.Lightning(updated)) + update(paymentHash, Activity.Lightning(updated)) } cacheStore.removePendingLightningMessage(paymentHash) } @@ -871,11 +982,10 @@ class ActivityService( private suspend fun resolveAddressForInboundPayment( kind: PaymentKind.Onchain, payment: PaymentDetails, - transactionDetails: BitkitCoreTransactionDetails? = null, + details: BitkitCoreTransactionDetails?, ): String? { if (payment.direction != PaymentDirection.INBOUND) return null - val details = transactionDetails ?: fetchTransactionDetails(kind.txid) if (details == null) { Logger.verbose( "Skipped address resolution because transaction details are unavailable for '${kind.txid}'", @@ -900,7 +1010,7 @@ class ActivityService( private suspend fun findPrivateReservedAddress(details: BitkitCoreTransactionDetails): String? { for (output in details.outputs) { val address = output.scriptpubkeyAddress ?: continue - if (privatePaykitContactPublicKeyForReservedAddress(address) != null) return address + if (privatePaykitContactResolver.get().contactPublicKeyForReservedAddress(address) != null) return address } return null } @@ -927,23 +1037,48 @@ class ActivityService( } } + val accounts = runSuspendCatching { lightningService.listOnchainWalletAccounts() } + .onFailure { Logger.warn("Failed to list onchain wallet accounts", it, context = TAG) } + .getOrDefault(emptyList()) + .filter { it.accountIndex != 0u } + for (isChange in listOf(false, true)) { + for (account in accounts) { + searchReceivingAddressForType( + details = details, + value = value, + currentWalletAddress = "", + addressType = account.addressType.toBitkitAddressType(), + isChange = isChange, + accountIndex = account.accountIndex, + )?.let { return it } + } + } + return null } + @Suppress("LongParameterList") private suspend fun searchReceivingAddressForType( details: BitkitCoreTransactionDetails, value: ULong, currentWalletAddress: String, addressType: AddressType, isChange: Boolean, + accountIndex: UInt = 0u, ): String? { - val addressTypeKey = addressType.toSettingsString() + val addressTypeKey = addressType.toSettingsString().let { + if (accountIndex == 0u) it else "$it:account:$accountIndex" + } val endIndex = addressSearchEndIndex(lastUsedAddressSearchIndex(addressTypeKey, isChange)) var index = 0 var currentAddressBatch: Int? = null while (index < endIndex) { - val addresses = fetchAddressSearchBatch(addressType, isChange, index, addressTypeKey) ?: return null + val count = minOf(ADDRESS_SEARCH_BATCH_SIZE, endIndex - index) + val addressInfos = fetchAddressSearchBatch( + addressType, isChange, index, count, addressTypeKey, accountIndex, + ) ?: return null + val addresses = addressInfos.map { it.address } if ( currentWalletAddress.isNotBlank() && @@ -954,32 +1089,37 @@ class ActivityService( } findAddressSearchMatch(details, value, addresses)?.let { - saveLastUsedAddressSearchIndex(addressTypeKey, isChange, index) + val matchedIndex = addressInfos.first { info -> info.address == it }.index + saveLastUsedAddressSearchIndex(addressTypeKey, isChange, matchedIndex) return it } if (shouldStopAfterCurrentAddressBatch(currentAddressBatch, index)) return null - index += ADDRESS_SEARCH_BATCH_SIZE + index += count } return null } + @Suppress("LongParameterList") private suspend fun fetchAddressSearchBatch( addressType: AddressType, isChange: Boolean, index: Int, + count: Int, addressTypeKey: String, - ): List? { + accountIndex: UInt, + ): List? { val scope = if (isChange) "change" else "receive" - return runCatching { + return runSuspendCatching { lightningService.addressInfosForType( addressType = addressType, isChange = isChange, startIndex = index, - count = ADDRESS_SEARCH_BATCH_SIZE, - ).map { it.address } + count = count, + accountIndex = accountIndex, + ) }.onFailure { Logger.warn( "Skipping '$addressTypeKey' '$scope' address search batch '$index'", @@ -1010,7 +1150,7 @@ class ActivityService( private fun addressSearchEndIndex(lastUsed: Int?): Int { return lastUsed?.let { - if (it > Int.MAX_VALUE - ADDRESS_SEARCH_WINDOW) Int.MAX_VALUE else it + ADDRESS_SEARCH_WINDOW + if (it > Int.MAX_VALUE - ADDRESS_SEARCH_WINDOW - 1) Int.MAX_VALUE else it + ADDRESS_SEARCH_WINDOW + 1 } ?: ADDRESS_SEARCH_WINDOW } @@ -1200,10 +1340,19 @@ class ActivityService( } } - val resolvedAddress = resolveAddressForInboundPayment(kind, payment, transactionDetails) + val details = transactionDetails ?: payment.takeIf { it.direction == PaymentDirection.INBOUND } + ?.let { fetchTransactionDetails(kind.txid) } + val resolvedAddress = resolveAddressForInboundPayment(kind, payment, details) val existingContact = existingOnchainActivity?.v1?.contact - val contact = existingContact ?: if (payment.direction == PaymentDirection.INBOUND) { - resolvedAddress?.let { privatePaykitContactPublicKeyForReservedAddress(it) } + val contact = existingContact ?: if ( + payment.direction == PaymentDirection.INBOUND && payment.status != PaymentStatus.FAILED && + !cacheStore.data.first().isContactDetached(payment.id, defaultWalletId) && + !details?.outputs.isNullOrEmpty() + ) { + privatePaykitContactResolver.get().contactPublicKeyForPrivateOnchainAddresses( + receivingAddress = resolvedAddress, + addresses = details.outputs.mapNotNull { it.scriptpubkeyAddress }, + ) } else { null } @@ -1235,6 +1384,7 @@ class ActivityService( return } + if (existingActivity != Activity.Onchain(onChain)) invalidatePaykitContactBackfill() if (existingActivity != null && existingActivity is Activity.Onchain) { val existingOnchain = existingActivity.v1 updateActivity(activityId = existingOnchain.id, activity = Activity.Onchain(onChain)) @@ -1254,9 +1404,6 @@ class ActivityService( return privatePaykitContactResolver.get().contactPublicKeyForPrivateInvoicePaymentHash(paymentHash) } - private suspend fun privatePaykitContactPublicKeyForReservedAddress(address: String): String? = - privatePaykitContactResolver.get().contactPublicKeyForPrivateOnchainAddresses(listOf(address)) - // MARK: - Test Data Generation (regtest only) @Suppress("LongMethod") @@ -1334,7 +1481,7 @@ class ActivityService( } // Insert activity - insertActivity(activity) + insert(activity) // Add random tags val numTags = (0..3).random() @@ -1366,7 +1513,7 @@ class ActivityService( isTransfer = true, channelId = existing.channelId ?: channelId, ) - if (updated != existing) upsertActivity(Activity.Onchain(updated)) + if (updated != existing) upsert(Activity.Onchain(updated)) } Logger.debug("Activity already exists for txid $txid, skipping immediate creation", context = TAG) return@background @@ -1389,7 +1536,7 @@ class ActivityService( updatedAt = 0u, seenAt = now, ) - upsertActivity(Activity.Onchain(onchain)) + upsert(Activity.Onchain(onchain)) Logger.info("Created sent onchain activity for txid $txid from send result", context = TAG) }.onFailure { Logger.error("Failed to create sent onchain activity for txid $txid", it, context = TAG) @@ -1401,6 +1548,7 @@ class ActivityService( ServiceQueue.CORE.background { runCatching { val coreDetails = mapToCoreTransactionDetails(txid, details) + invalidatePaykitContactBackfill() upsertTransactionDetails(listOf(coreDetails)) val payments = lightningService.listPayments() ?: run { @@ -1432,6 +1580,7 @@ class ActivityService( ServiceQueue.CORE.background { runCatching { val coreDetails = mapToCoreTransactionDetails(txid, details) + invalidatePaykitContactBackfill() upsertTransactionDetails(listOf(coreDetails)) val payments = lightningService.listPayments() ?: run { @@ -1494,6 +1643,7 @@ class ActivityService( isBoosted = false, updatedAt = System.currentTimeMillis().toULong() / 1000u ) + paykitActivityRevision++ updateActivity(activityId = replacedActivity.id, activity = Activity.Onchain(updatedActivity)) Logger.info("Marked transaction $txid as replaced", context = TAG) } else { @@ -1554,7 +1704,7 @@ class ActivityService( contact = replacementActivity.contact ?: replacedActivity?.contact, updatedAt = System.currentTimeMillis().toULong() / 1000u, ) - updateActivity(activityId = replacementActivity.id, activity = Activity.Onchain(updatedActivity)) + update(replacementActivity.id, Activity.Onchain(updatedActivity)) if (replacedActivity != null) { copyTagsFromReplacedActivity(txid, conflictTxid, replacedActivity.id, replacementActivity.id) @@ -1598,7 +1748,7 @@ class ActivityService( updatedAt = System.currentTimeMillis().toULong() / 1000u ) - updateActivity(activityId = onchain.id, activity = Activity.Onchain(updatedActivity)) + update(onchain.id, Activity.Onchain(updatedActivity)) }.onFailure { e -> Logger.error("Error handling onchain transaction reorged for $txid", e, context = TAG) } @@ -1618,7 +1768,7 @@ class ActivityService( updatedAt = System.currentTimeMillis().toULong() / 1000u ) - updateActivity(activityId = onchain.id, activity = Activity.Onchain(updatedActivity)) + update(onchain.id, Activity.Onchain(updatedActivity)) }.onFailure { e -> Logger.error("Error handling onchain transaction evicted for $txid", e, context = TAG) } diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 71b75c30ef..a4d19d2ad3 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -37,6 +37,7 @@ import org.lightningdevkit.ldknode.KeychainKind import org.lightningdevkit.ldknode.Node import org.lightningdevkit.ldknode.NodeException import org.lightningdevkit.ldknode.NodeStatus +import org.lightningdevkit.ldknode.OnchainWalletAccount import org.lightningdevkit.ldknode.OnchainWalletAccountConfig import org.lightningdevkit.ldknode.PaymentDetails import org.lightningdevkit.ldknode.PaymentId @@ -81,8 +82,10 @@ import kotlin.coroutines.AbstractCoroutineContextElement import kotlin.coroutines.CoroutineContext import kotlin.coroutines.cancellation.CancellationException import kotlin.io.path.Path +import kotlin.time.Clock import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant import org.lightningdevkit.ldknode.AddressType as LdkAddressType typealias NodeEventHandler = suspend (Event) -> Unit @@ -114,6 +117,13 @@ internal fun enabledOnchainWalletAccountConfigs( ) } +internal fun LdkAddressType.toBitkitAddressType(): AddressType = when (this) { + LdkAddressType.LEGACY -> AddressType.P2PKH + LdkAddressType.NESTED_SEGWIT -> AddressType.P2SH + LdkAddressType.NATIVE_SEGWIT -> AddressType.P2WPKH + LdkAddressType.TAPROOT -> AddressType.P2TR +} + private fun accountKey(addressType: LdkAddressType, accountIndex: UInt): String = "${addressType.name}:$accountIndex" @@ -134,6 +144,7 @@ class LightningService internal constructor( private val loggerLdk: LoggerLdk, private val watchOnlyAccountLifecycleCoordinator: WatchOnlyAccountLifecycleCoordinator, private val ldkQueue: CoroutineContext, + private val clock: Clock = Clock.System, ) : BaseCoroutineScope(bgDispatcher, TAG) { companion object { @@ -179,6 +190,7 @@ class LightningService internal constructor( watchOnlyAccountStore: WatchOnlyAccountStore, loggerLdk: LoggerLdk, watchOnlyAccountLifecycleCoordinator: WatchOnlyAccountLifecycleCoordinator, + clock: Clock = Clock.System, ) : this( bgDispatcher = bgDispatcher, ioDispatcher = ioDispatcher, @@ -189,6 +201,7 @@ class LightningService internal constructor( loggerLdk = loggerLdk, watchOnlyAccountLifecycleCoordinator = watchOnlyAccountLifecycleCoordinator, ldkQueue = ServiceQueue.LDK.queueContext, + clock = clock, ) @Volatile @@ -679,14 +692,16 @@ class LightningService internal constructor( isChange: Boolean, startIndex: Int, count: Int, + accountIndex: UInt = 0u, ): List { val node = this.node ?: throw ServiceError.NodeNotSetup() val keychain = if (isChange) KeychainKind.INTERNAL else KeychainKind.EXTERNAL return ServiceQueue.LDK.background(ldkQueue) { node.onchainPayment() - .addressInfosForType( + .addressInfosForAccount( addressType.toLdkAddressType(), + accountIndex, keychain, startIndex.toUInt(), count.toUInt(), @@ -695,6 +710,11 @@ class LightningService internal constructor( } } + suspend fun listOnchainWalletAccounts(): List = ServiceQueue.LDK.background(ldkQueue) { + val n = node ?: throw ServiceError.NodeNotSetup() + n.listOnchainWalletAccounts() + } + suspend fun revealReceiveAddresses(toReceiveIndex: Int, forType: AddressType) { val node = this.node ?: throw ServiceError.NodeNotSetup() @@ -940,6 +960,7 @@ class LightningService internal constructor( satsPerVByte: ULong, utxosToSpend: List? = null, isMaxAmount: Boolean = false, + paymentDeadlineAt: Instant? = null, ): Txid { val node = this.node ?: throw ServiceError.NodeNotSetup() @@ -949,6 +970,7 @@ class LightningService internal constructor( ) return ServiceQueue.LDK.background(ldkQueue) { + ensurePaymentDeadline(paymentDeadlineAt) if (isMaxAmount) { node.onchainPayment().sendAllToAddress( address = address, @@ -966,7 +988,7 @@ class LightningService internal constructor( } } - suspend fun send(bolt11: String, sats: ULong? = null): PaymentId { + suspend fun send(bolt11: String, sats: ULong? = null, paymentDeadlineAt: Instant? = null): PaymentId { val node = this.node ?: throw ServiceError.NodeNotSetup() Logger.debug("Paying bolt11: $bolt11", context = TAG) @@ -975,6 +997,7 @@ class LightningService internal constructor( .getOrElse { throw LdkError(it as NodeException) } return ServiceQueue.LDK.background(ldkQueue) { + ensurePaymentDeadline(paymentDeadlineAt) runCatching { when (sats != null) { true -> node.bolt11Payment().sendUsingAmount(bolt11Invoice, sats * 1000u, null) @@ -986,6 +1009,12 @@ class LightningService internal constructor( }.getOrThrow() } + private fun ensurePaymentDeadline(paymentDeadlineAt: Instant?) { + if (paymentDeadlineAt != null && clock.now() > paymentDeadlineAt) { + throw ServiceError.PaymentDeadlineExpired() + } + } + suspend fun estimateRoutingFees(bolt11: String): Result { val node = this.node ?: throw ServiceError.NodeNotSetup() @@ -1330,13 +1359,6 @@ class LightningService internal constructor( n.listMonitoredAddressTypes().map { it.toBitkitAddressType() } } - private fun LdkAddressType.toBitkitAddressType(): AddressType = when (this) { - LdkAddressType.LEGACY -> AddressType.P2PKH - LdkAddressType.NESTED_SEGWIT -> AddressType.P2SH - LdkAddressType.NATIVE_SEGWIT -> AddressType.P2WPKH - LdkAddressType.TAPROOT -> AddressType.P2TR - } - private fun AddressType.toLdkAddressType(): LdkAddressType = when (this) { AddressType.P2PKH -> LdkAddressType.LEGACY AddressType.P2SH -> LdkAddressType.NESTED_SEGWIT diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkOperationLock.kt b/app/src/main/java/to/bitkit/services/PaykitSdkOperationLock.kt index 1d6e162405..ea3483bc12 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkOperationLock.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkOperationLock.kt @@ -1,27 +1,44 @@ package to.bitkit.services import com.synonym.paykit.PaykitException +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlin.coroutines.AbstractCoroutineContextElement import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.cancellation.CancellationException internal class PaykitSdkOperationLock { - private val mutex = Mutex() + companion object { + /** Maximum interactive overtakes before the oldest background operation gets a turn. */ + private const val MAX_INTERACTIVE_BYPASSES = 3 + } + + enum class Priority { + /** FIFO barrier for identity changes, cleanup, and operations not classified for reordering. */ + Ordered, + Interactive, + Background, + } + private val stateLock = Any() + private var isLocked = false + private val waiters = ArrayDeque() + private var interactiveBypasses = 0 private var generation = 0L private var isWiping = false - suspend fun withLock(operation: suspend () -> T): T { + suspend fun withLock(priority: Priority = Priority.Ordered, operation: suspend () -> T): T { if (ownsWipe()) return operation() val admittedGeneration = admit() - return mutex.withLock { + acquire(priority) + return try { currentCoroutineContext().ensureActive() checkAdmitted(admittedGeneration) operation() + } finally { + release() } } @@ -33,7 +50,11 @@ internal class PaykitSdkOperationLock { suspend fun withoutLock(operation: suspend () -> T): T { if (ownsWipe()) return operation() val admittedGeneration = admit() - return operation().also { checkAdmitted(admittedGeneration) } + currentCoroutineContext().ensureActive() + return operation().also { + currentCoroutineContext().ensureActive() + checkAdmitted(admittedGeneration) + } } suspend fun withWalletWipe(operation: suspend () -> T): T { @@ -43,14 +64,53 @@ internal class PaykitSdkOperationLock { generation++ } return try { - mutex.withLock { + acquire(Priority.Ordered) + try { withContext(WipeContext(this, generation)) { operation() } + } finally { + release() } } finally { synchronized(stateLock) { isWiping = false } } } + private suspend fun acquire(priority: Priority) { + val waiter = synchronized(stateLock) { + if (!isLocked) { + isLocked = true + return + } + Waiter(priority, CompletableDeferred()).also(waiters::addLast) + } + try { + waiter.ready.await() + } catch (error: CancellationException) { + if (synchronized(stateLock) { !waiters.remove(waiter) }) release() + throw error + } + } + + private fun release() { + val next = synchronized(stateLock) { + if (waiters.isEmpty()) { + isLocked = false + interactiveBypasses = 0 + return + } + val interactiveIndex = waiters.takeWhile { it.priority != Priority.Ordered } + .indexOfFirst { it.priority == Priority.Interactive } + val index = if (interactiveBypasses < MAX_INTERACTIVE_BYPASSES && interactiveIndex > 0) { + interactiveIndex + } else { + 0 + } + interactiveBypasses = if (index == 0) 0 else interactiveBypasses + 1 + waiters.removeAt(index) + } + next.ready.complete(Unit) + } + private suspend fun ownsWipe(): Boolean { val wipeContext = currentCoroutineContext()[WipeContext] return synchronized(stateLock) { @@ -77,6 +137,8 @@ internal class PaykitSdkOperationLock { context = "Paykit operation interrupted by wallet wipe", ) + private class Waiter(val priority: Priority, val ready: CompletableDeferred) + private class WipeContext( val owner: PaykitSdkOperationLock, val generation: Long, diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 3390e62bae..38590bc1d6 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -1,22 +1,21 @@ package to.bitkit.services import android.content.Context -import com.synonym.bitkitcore.mnemonicToSeed -import com.synonym.paykit.ContactProfileResolution +import androidx.annotation.VisibleForTesting import com.synonym.paykit.ContactRecord import com.synonym.paykit.ContactUpdate -import com.synonym.paykit.CounterpartyReceiver import com.synonym.paykit.EndpointSyncReport import com.synonym.paykit.IdentityStatus import com.synonym.paykit.LinkedPeerRecord import com.synonym.paykit.LinkedPeerState import com.synonym.paykit.OutboundPrivateCounterpartySendReport +import com.synonym.paykit.OutboundPrivateSendReport import com.synonym.paykit.PaykitAndroid +import com.synonym.paykit.PaykitAppCapabilities import com.synonym.paykit.PaykitException +import com.synonym.paykit.PaykitIdentitySecretKey import com.synonym.paykit.PaykitProfile import com.synonym.paykit.PaykitProfileRecord -import com.synonym.paykit.PaykitReceiverCapabilities -import com.synonym.paykit.PaykitReceiverMarker import com.synonym.paykit.PaykitSdk import com.synonym.paykit.PaykitSdkDefaults import com.synonym.paykit.PaymentAmountContext @@ -26,6 +25,7 @@ import com.synonym.paykit.PaymentReference import com.synonym.paykit.PaymentRequestAmount import com.synonym.paykit.PaymentRequestFilter import com.synonym.paykit.PaymentRequestLifecycleState +import com.synonym.paykit.PaymentRequestLocalRole import com.synonym.paykit.PaymentRequestRecord import com.synonym.paykit.PaymentRequestRecurrence import com.synonym.paykit.PaymentRequestTerms @@ -43,8 +43,10 @@ import com.synonym.paykit.PrivateReceivingDetail import com.synonym.paykit.PrivateReceivingDetailReservationResponse import com.synonym.paykit.PrivateReceivingDetailReservationResponseKind import com.synonym.paykit.PrivateStreamCounterpartyIntakeReport +import com.synonym.paykit.ProfileResolution import com.synonym.paykit.PubkyAuthCompanionClaim import com.synonym.paykit.PubkyClientConfig +import com.synonym.paykit.PubkyIdentityCapability import com.synonym.paykit.PubkyLocalSecretKey import com.synonym.paykit.PubkyProfile import com.synonym.paykit.PubkySessionAccess @@ -54,20 +56,14 @@ import com.synonym.paykit.PublicContactPaymentResolution import com.synonym.paykit.PublicPaymentEndpointCandidate import com.synonym.paykit.PublicPaymentEndpointSelectionRequest import com.synonym.paykit.PublicReceivingDetail -import com.synonym.paykit.ReceiverNoiseSecretKey import com.synonym.paykit.SdkPaymentAdapter import com.synonym.paykit.SdkPubkySessionProvider -import com.synonym.paykit.SdkStateBlob -import com.synonym.paykit.SdkStateBlobSnapshot -import com.synonym.paykit.SdkStateBlobStore -import com.synonym.paykit.decodeSdkStateBlobSnapshot import com.synonym.paykit.defaultConfig import com.synonym.paykit.defaultPubkyClientConfig -import com.synonym.paykit.encodeSdkStateBlobSnapshot import com.synonym.paykit.parsePubkyAuthUrl +import com.synonym.paykit.paykitAuthorizerSessionCapabilities import com.synonym.paykit.pubkyPublicKeyFromSecret import com.synonym.paykit.pubkySecretKeyFromBip39Mnemonic -import com.synonym.paykit.requiredSessionCapabilities import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher @@ -92,6 +88,7 @@ import kotlinx.coroutines.withTimeoutOrNull import org.lightningdevkit.ldknode.Network import to.bitkit.async.BaseCoroutineScope import to.bitkit.data.PubkyStore +import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain import to.bitkit.data.sharedpubky.SharedPubkyClient import to.bitkit.data.sharedpubky.SharedPubkyContract @@ -101,6 +98,9 @@ import to.bitkit.ext.fromHex import to.bitkit.ext.nowMillis import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.toHex +import to.bitkit.models.PubkyAuthClaim +import to.bitkit.models.PubkyAuthClaimCodec +import to.bitkit.models.PubkyAuthRequest import to.bitkit.models.PubkyAuthRequestError import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.repositories.Endpoint @@ -108,12 +108,9 @@ import to.bitkit.repositories.PaykitBillingPeriod import to.bitkit.repositories.PaykitIssuerInterop import to.bitkit.repositories.PubkyContactError import to.bitkit.repositories.PublicPaykitRepo +import to.bitkit.services.PaykitSdkOperationLock.Priority import to.bitkit.utils.AppError import to.bitkit.utils.Logger -import java.security.MessageDigest -import java.util.UUID -import javax.crypto.Mac -import javax.crypto.spec.SecretKeySpec import javax.inject.Inject import javax.inject.Singleton import kotlin.coroutines.cancellation.CancellationException @@ -139,6 +136,7 @@ data class PaykitPublicContactPaymentResolution( ) data class PaykitResolvedPaymentEndpoint( + val appId: String, val identifier: String, val payload: String, ) @@ -160,24 +158,8 @@ data class PaykitPaymentRequestRecurrenceTerms( val endsAt: String? = null, ) -data class PaykitPrivateReceiverPathSelection( - val linkableReceiverPaths: List, - val publishableReceiverPaths: List, - val cleanupProtectedReceiverPaths: List, - val error: Throwable?, -) - class PubkyFileNotFoundError : AppError("Pubky file not found") -internal object PaykitReceiverPaths { - const val WALLET = "bitkit/wallet" - const val SERVER = "bitkit/server" - - /** Current Bitkit flows only route its own receivers; cross-wallet routing can broaden this allowlist. */ - val ordered = listOf(WALLET, SERVER) - val supported = ordered.toSet() -} - /** Which public read slots a public Pubky read may use. */ enum class PaykitReadLane { /** @@ -246,8 +228,8 @@ class PaykitSdkService @Inject constructor( private val pubkyStore: PubkyStore, sharedPubky: SharedPubkyClient, @IoDispatcher ioDispatcher: CoroutineDispatcher, + private val settingsStore: SettingsStore, ) : BaseCoroutineScope(ioDispatcher, TAG) { - private val stateStore = PaykitSdkStateBlobStore(keychain) private val sessionProvider = PaykitSdkSessionProvider(keychain, sharedPubky) private val paymentAdapter = PaykitSdkPaymentAdapter() private val pubkyClientConfig by lazy { paykitPubkyClientConfig() } @@ -276,11 +258,19 @@ class PaykitSdkService @Inject constructor( @Volatile private var sdk: PaykitSdk? = null + + @Volatile + private var runtimeGeneration = 0L + + private var cachedPaykitKey: PaykitKeyGeneration? = null + private var cachedBackupState: PaykitBackupStateSnapshot? = null private val _backupStateVersion = MutableStateFlow(0L) val backupStateVersion: StateFlow = _backupStateVersion.asStateFlow() + private val _isPaymentSubmissionActive = MutableStateFlow(false) + val isPaymentSubmissionActive: StateFlow = _isPaymentSubmissionActive.asStateFlow() + private var sdkFactory: () -> PaykitSdk = { - PaykitSdk.withPaymentAdapterAndPubkyClientConfig( - stateStore = stateStore, + PaykitSdk.withPaymentAdapterAndPubkySharedStateAndClientConfig( sessionProvider = sessionProvider, paymentAdapter = paymentAdapter, config = paykitSdkConfig(), @@ -297,8 +287,9 @@ class PaykitSdkService @Inject constructor( ioDispatcher: CoroutineDispatcher = Dispatchers.IO, sharedPubky: SharedPubkyClient = SharedPubkyClient(context, ioDispatcher), platformInitializer: (() -> Unit)? = null, + settingsStore: SettingsStore, sdkFactory: () -> PaykitSdk, - ) : this(context, keychain, pubkyStore, sharedPubky, ioDispatcher) { + ) : this(context, keychain, pubkyStore, sharedPubky, ioDispatcher, settingsStore) { this.sdkFactory = sdkFactory if (bootstrapFactory != null) this.bootstrapFactory = bootstrapFactory if (platformInitializer == null) { @@ -308,8 +299,24 @@ class PaykitSdkService @Inject constructor( } } - @Suppress("TooGenericExceptionCaught") suspend fun initialize() { + initialize { initializeRuntime() } + } + + fun setPaymentSubmissionActive(active: Boolean) { + _isPaymentSubmissionActive.update { active } + } + + suspend fun initializeAndImportSession(secret: String): Result { + var imported: Result? = null + initialize { + imported = runSuspendCatching { importSessionLocked(secret) } + } + return imported ?: runSuspendCatching { importSession(secret) } + } + + @Suppress("TooGenericExceptionCaught") + private suspend fun initialize(activate: suspend () -> Unit) { setupMutex.withLock { if (isSetup.isCompleted && !setupFailed) return if (setupFailed) { @@ -320,29 +327,7 @@ class PaykitSdkService @Inject constructor( try { platformInitializer() launch { republishIdentityIfNeeded() } - operationLock.withLock { - var handle = handle() - try { - handle.initialize() - } catch (e: PaykitException.Identity) { - if (!sessionProvider.canDeferStaleSession(e.context)) throw e - - Logger.warn( - "Deferring stale Paykit session restoration until SDK setup completes", - e, - context = TAG, - ) - sessionProvider.suspendStoredSessionAccess() - resetRuntime() - try { - handle = handle() - handle.initialize() - } finally { - sessionProvider.resumeStoredSessionAccess() - } - } - publishReceiverMarkerIfLiveSessionAvailable(handle) - } + operationLock.withLock { activate() } isSetup.complete(Unit) } catch (t: Throwable) { setupFailed = true @@ -352,6 +337,32 @@ class PaykitSdkService @Inject constructor( } } + private suspend fun initializeRuntime() { + refreshPaykitKey(force = true) + var handle = handle() + val identityStatus = try { + completeSdkCall { handle.initialize() } + } catch (e: PaykitException.Identity) { + invalidatePaykitKeyIfNeeded(e) + if (!sessionProvider.canDeferStaleSession(e.context)) throw e + + Logger.warn( + "Deferring stale Paykit session restoration until SDK setup completes", + e, + context = TAG, + ) + sessionProvider.suspendStoredSessionAccess() + resetRuntime() + try { + handle = handle() + completeSdkCall { handle.initialize() } + } finally { + sessionProvider.resumeStoredSessionAccess() + } + } + publishAppIfLiveSessionAvailable(handle, identityStatus) + } + suspend fun republishIdentityIfNeeded(publicKey: String? = null, now: Long = nowMillis()) { val publication = launchIdentityRepublish(publicKey, now) withTimeoutOrNull(IDENTITY_REPUBLISH_WAIT_TIMEOUT) { @@ -406,38 +417,37 @@ class PaykitSdkService @Inject constructor( suspend fun currentPublicKey(): String? { isSetup.await() return operationLock.withLock { - val handle = handle() - handle.identityStatus()?.publicKey?.let { return@withLock it } - handle.initialize().identity.publicKey + withPaykitKey { handle -> + completeSdkCall { handle.identityStatus() }?.publicKey + ?: completeSdkCall { handle.initialize() }.publicKey + } } } suspend fun hasPrivatePaymentAccess(): Boolean { isSetup.await() return operationLock.withLock { - handle().identityStatus()?.liveSessionAvailable == true + withPaykitKey { + completeSdkCall { it.identityStatus() }?.capability == PubkyIdentityCapability.PRIVATE_LINK_CAPABLE + } } } suspend fun importSession(secret: String): PubkySessionBootstrapResult { isSetup.await() - return operationLock.withLock { - val previousPublicKey = currentSdkStatePublicKeyLocked() - val result = bootstrap().importSession( - sessionSecret = secret, - localSecretKey = sessionProvider.loadLocalSecretKey(), - receiverNoiseSecretKey = sessionProvider.loadOrDeriveReceiverNoiseSecretKey(), - requiredCapabilities = requiredSessionCapabilities(paykitSdkConfig()), - ) + return operationLock.withLock { importSessionLocked(secret) } + } - activateBootstrapResult( - result = result, - previousPublicKey = previousPublicKey, - ) + private suspend fun importSessionLocked(secret: String): PubkySessionBootstrapResult { + val result = bootstrap().importSession( + sessionSecret = secret, + localSecretKey = sessionProvider.loadLocalSecretKey(), + requiredCapabilities = paykitAuthorizerSessionCapabilities(), + ) - notifyBackupStateChanged() - result - } + activateBootstrapResult(result) + notifyBackupStateChanged() + return result } suspend fun signUp( @@ -447,10 +457,8 @@ class PaykitSdkService @Inject constructor( ): PubkySessionBootstrapResult { isSetup.await() return operationLock.withLock { - val previousPublicKey = currentSdkStatePublicKeyLocked() val result = bootstrap().signUp( localSecretKey = localSecretKey(secretKeyHex), - receiverNoiseSecretKey = sessionProvider.loadOrDeriveReceiverNoiseSecretKey(), homeserverPublicKey = homeserverPublicKey, signupCode = signupCode, requiredCapabilities = requiredCapabilities(), @@ -458,7 +466,6 @@ class PaykitSdkService @Inject constructor( activateBootstrapResult( result = result, - previousPublicKey = previousPublicKey, ) notifyBackupStateChanged() @@ -475,7 +482,6 @@ class PaykitSdkService @Inject constructor( return operationLock.withLock { bootstrap().signUp( localSecretKey = localSecretKey(secretKeyHex), - receiverNoiseSecretKey = sessionProvider.loadOrDeriveReceiverNoiseSecretKey(), homeserverPublicKey = homeserverPublicKey, signupCode = signupCode, requiredCapabilities = requiredCapabilities(), @@ -486,13 +492,10 @@ class PaykitSdkService @Inject constructor( suspend fun activateRegisteredIdentity(result: PubkySessionBootstrapResult) { isSetup.await() return operationLock.withLock { - val previousPublicKey = currentSdkStatePublicKeyLocked() - var activated = false try { activateBootstrapResult( result = result, - previousPublicKey = previousPublicKey, ) activated = true } finally { @@ -506,16 +509,13 @@ class PaykitSdkService @Inject constructor( suspend fun signIn(secretKeyHex: String): PubkySessionBootstrapResult { isSetup.await() return operationLock.withLock { - val previousPublicKey = currentSdkStatePublicKeyLocked() val result = bootstrap().signIn( localSecretKey = localSecretKey(secretKeyHex), - receiverNoiseSecretKey = sessionProvider.loadOrDeriveReceiverNoiseSecretKey(), requiredCapabilities = requiredCapabilities(), ) activateBootstrapResult( result = result, - previousPublicKey = previousPublicKey, ) notifyBackupStateChanged() @@ -548,11 +548,27 @@ class PaykitSdkService @Inject constructor( ) { isSetup.await() return operationLock.withLock { + val requestedClaim = PubkyAuthRequest.parseBitkitClaim(authUrl, expectedCapabilities).getOrThrow() + ?: throw PubkyAuthRequestError.MissingBitkitClaim + require( + claim.queryParameter == PubkyAuthClaim.QUERY_PARAMETER && claim.claimType == requestedClaim.wireValue + ) { + "Companion claim does not match the authorization request" + } + PubkyAuthClaimCodec.validateAccountPayload(requestedClaim, claim.unsignedPayload) + val paykitKey = if (requestedClaim.includesPaykitAccess) paykitKey(localSecretKey(secretKeyHex)) else null approvalBootstrap(authUrl, approvedClientId).approveAuthWithCompanionClaim( authUrl = authUrl, expectedCapabilities = expectedCapabilities, localSecretKey = localSecretKey(secretKeyHex), - claim = claim, + claim = claim.copy( + unsignedPayload = PubkyAuthClaimCodec.encode( + claim = requestedClaim, + accountPayload = claim.unsignedPayload, + generation = paykitKey?.keyGeneration(), + secret = paykitKey?.exportBytes(), + ), + ), ) } } @@ -563,8 +579,12 @@ class PaykitSdkService @Inject constructor( suspend fun publishPaykitProfile(profile: PaykitProfile): PaykitProfileRecord { isSetup.await() return operationLock.withLock { - handle().publishPaykitProfile(profile).also { - notifyBackupStateChanged() + withPaykitKey { handle -> + val publicKey = requireNotNull(completeSdkCall { handle.identityStatus() }?.publicKey) + val current = handle.fetchPaykitProfile(publicKey) + completeSdkCall { + handle.publishPaykitProfile(profile, current?.revision).also { notifyBackupStateChanged() } + } } } } @@ -573,14 +593,14 @@ class PaykitSdkService @Inject constructor( isSetup.await() return operationLock.withLock { if (expectedIdentity != null) { - val identityStatus = handle().identityStatus() + val identityStatus = completeSdkCall { handle().identityStatus() } check( - identityStatus?.liveSessionAvailable == true && + identityStatus?.capability == PubkyIdentityCapability.PRIVATE_LINK_CAPABLE && PubkyPublicKeyFormat.matches(identityStatus.publicKey, expectedIdentity) ) { "Paykit identity changed before uploading the subscription icon" } } - handle().uploadProfileAvatar(bytes, contentType).uri.also { - notifyBackupStateChanged() + completeSdkCall { + handle().uploadProfileAvatar(bytes, contentType).uri.also { notifyBackupStateChanged() } } } } @@ -588,8 +608,14 @@ class PaykitSdkService @Inject constructor( suspend fun deletePaykitProfile() { isSetup.await() operationLock.withLock { - handle().deletePaykitProfile() - notifyBackupStateChanged() + withPaykitKey { handle -> + val publicKey = requireNotNull(completeSdkCall { handle.identityStatus() }?.publicKey) + val current = handle.fetchPaykitProfile(publicKey) + completeSdkCall { + current?.let { handle.deletePaykitProfile(it.revision) } + notifyBackupStateChanged() + } + } } } @@ -597,19 +623,19 @@ class PaykitSdkService @Inject constructor( publicRead { it.fetchPubkyProfile(publicKey) }?.profile suspend fun fetchPubkyFollows(publicKey: String): List = - publicRead { it.fetchPubkyFollows(publicKey) } + publicRead { it.fetchPubkyFollows(publicKey, maxEntries = 10_000u) } suspend fun contactRecords(): List { isSetup.await() return operationLock.withLock { - handle().contactRecords() + withPaykitKey { completeSdkCall { it.contactRecords() } } } } suspend fun contactRecord(publicKey: String): ContactRecord? { isSetup.await() return operationLock.withLock { - handle().contactRecord(publicKey) + withPaykitKey { completeSdkCall { it.contactRecord(publicKey) } } } } @@ -622,7 +648,6 @@ class PaykitSdkService @Inject constructor( suspend fun saveContact( publicKey: String, label: String?, - receiverPaths: List? = null, restorePrivateConnection: Boolean = false, expectedIdentity: String? = null, isStillCurrent: (() -> Boolean)? = null, @@ -631,21 +656,45 @@ class PaykitSdkService @Inject constructor( return operationLock.withLock { withStateRevisionTracking { handle -> if (expectedIdentity != null) { - check(PubkyPublicKeyFormat.matches(handle.identityStatus()?.publicKey, expectedIdentity)) { + val identity = completeSdkCall { handle.identityStatus() }?.publicKey + check(PubkyPublicKeyFormat.matches(identity, expectedIdentity)) { "Paykit identity changed before saving the contact" } } if (isStillCurrent?.invoke() == false) throw PubkyContactError.SignInChanged - val existing = handle.contactRecord(publicKey) - check(restorePrivateConnection || existing != null) { "Contact no longer exists" } - val existingPaths = existing?.receiverPaths.orEmpty() - val contactPaths = mergedReceiverPaths(existingPaths + receiverPaths.orEmpty()) - val update = ContactUpdate(publicKey, contactPaths, label) - if (!restorePrivateConnection) return@withStateRevisionTracking handle.saveContact(update) - val blockedPeers = handle.linkedPeers().filter { - it.state == LinkedPeerState.BLOCKED && PubkyPublicKeyFormat.matches(it.counterparty, publicKey) + val update = ContactUpdate(publicKey, label) + if (!restorePrivateConnection) { + val existing = completeSdkCall { handle.contactRecord(publicKey) } + check(existing != null) { "Contact no longer exists" } + return@withStateRevisionTracking completeSdkCall { handle.saveContact(update) } } - restorePrivateContact(handle, blockedPeers, update) + completeSdkCall { handle.saveContactsAndUnblockPeers(listOf(update)) }.single() + } + } + } + + /** + * Explicitly re-adds [updates] and restores their blocked private connections atomically. The identity + * and sign-in checks run under the operation lock. + * An empty selection is skipped without accessing the SDK. + */ + suspend fun saveContacts( + updates: List, + expectedIdentity: String? = null, + isStillCurrent: (() -> Boolean)? = null, + ): List { + if (updates.isEmpty()) return emptyList() + isSetup.await() + return operationLock.withLock { + withStateRevisionTracking { handle -> + if (expectedIdentity != null) { + val identity = completeSdkCall { handle.identityStatus() }?.publicKey + check(PubkyPublicKeyFormat.matches(identity, expectedIdentity)) { + "Paykit identity changed before saving contacts" + } + } + if (isStillCurrent?.invoke() == false) throw PubkyContactError.SignInChanged + completeSdkCall { handle.saveContactsAndUnblockPeers(updates) } } } } @@ -654,14 +703,11 @@ class PaykitSdkService @Inject constructor( isSetup.await() return operationLock.withLock { withStateRevisionTracking { handle -> - val record = handle.contactRecord(publicKey) - val peers = handle.linkedPeers().filter { + val peers = completeSdkCall { handle.linkedPeers() }.filter { PubkyPublicKeyFormat.matches(it.counterparty, publicKey) } - val receiverPaths = - (record?.receiverPaths.orEmpty() + peers.map { it.counterpartyReceiverPath }).distinct() val now = nowMillis() - val hasActiveSubscription = handle.paymentRequests().any { + val hasActiveSubscription = completeSdkCall { handle.paymentRequests() }.any { val endsAt = it.terms?.recurrence?.endsAt?.let { timestamp -> runSuspendCatching { Instant.parse(timestamp).toEpochMilliseconds() }.getOrNull() } @@ -670,21 +716,38 @@ class PaykitSdkService @Inject constructor( (endsAt == null || endsAt > now) } if (hasActiveSubscription) throw PubkyContactError.ActiveSubscription - peers.filter { it.state == LinkedPeerState.LINKED }.forEach { peer -> + peers.filter { it.state == LinkedPeerState.LINKED }.forEach { runSuspendCatching { - val report = handle.clearPrivatePaymentListAndProcessOutbound( - publicKey, - peer.counterpartyReceiverPath, - ) + val report = completeSdkCall { handle.clearPrivatePaymentListAndProcessOutbound(publicKey) } if (report.failedToQueue.isNotEmpty() || report.failedToDeliver.isNotEmpty()) { Logger.warn("Failed to withdraw private endpoints before contact deletion", context = TAG) } }.onFailure { + invalidatePaykitKeyIfNeeded(it) Logger.warn("Failed to withdraw private endpoints before contact deletion", it, context = TAG) } } - receiverPaths.forEach { handle.blockPeer(publicKey, it) } - handle.removeContact(publicKey) + completeSdkCall { handle.blockPeer(publicKey) } + completeSdkCall { handle.removeContact(publicKey) } + } + } + } + + suspend fun removeContacts(publicKeys: List): List { + if (publicKeys.isEmpty()) return emptyList() + isSetup.await() + return operationLock.withLock { + withStateRevisionTracking { handle -> + val now = nowMillis() + val subscribedKeys = completeSdkCall { handle.paymentRequests() }.filter { + val endsAt = it.terms?.recurrence?.endsAt?.let { timestamp -> + runSuspendCatching { Instant.parse(timestamp).toEpochMilliseconds() }.getOrNull() + } + it.state == PaymentRequestLifecycleState.ACTIVE_RECURRING && (endsAt == null || endsAt > now) + }.mapNotNull { PubkyPublicKeyFormat.normalized(it.counterparty) }.toSet() + val removableKeys = publicKeys.filter { PubkyPublicKeyFormat.normalized(it) !in subscribedKeys } + if (removableKeys.isEmpty()) return@withStateRevisionTracking emptyList() + completeSdkCall { handle.removeContactsAndBlockPeers(removableKeys) } } } } @@ -694,193 +757,265 @@ class PaykitSdkService @Inject constructor( allowPubkyProfileFallback: Boolean, lane: PaykitReadLane = PaykitReadLane.Interactive, timeout: Duration? = null, - ): ContactProfileResolution? = publicRead(lane, timeout) { - it.resolveContactProfile(publicKey, PaykitReceiverPaths.WALLET, allowPubkyProfileFallback) + ): ProfileResolution? = publicRead(lane, timeout) { + it.resolveProfile(publicKey, allowPubkyProfileFallback) } - suspend fun discoverRelevantReceiverPaths( - publicKey: String, - lane: PaykitReadLane = PaykitReadLane.Interactive, - ): List = publicRead(lane) { handle -> - val discovered = handle.paykitReceiverPaths(publicKey) - .filter { it in PaykitReceiverPaths.supported } - .filter { - it == PaykitReceiverPaths.WALLET || - handle.paykitReceiverMarker(publicKey, it)?.requiresPrivateLink() == true - } - mergedReceiverPaths(discovered) - } + suspend fun syncPaykitApp(privatePaymentsEnabled: Boolean) = syncPaykitApp(privatePaymentsEnabled, Priority.Ordered) - suspend fun privateReceiverPathSelection( - publicKey: String, - savedReceiverPaths: List, - lane: PaykitReadLane, - ): PaykitPrivateReceiverPathSelection = publicRead(lane) { handle -> - val linkable = mutableListOf() - val publishable = mutableListOf() - val cleanupProtected = mutableListOf() - var firstError: Throwable? = null - - mergedReceiverPaths(savedReceiverPaths).forEach { receiverPath -> - runSuspendCatching { handle.paykitReceiverMarker(publicKey, receiverPath) } - .onSuccess { marker -> - if (marker?.requiresPrivateLink() == true) { - linkable += receiverPath - } - if (marker.canReceivePrivatePaymentDetails()) { - publishable += receiverPath - } - } - .onFailure { - cleanupProtected += receiverPath - firstError = firstError ?: it - } - } - - PaykitPrivateReceiverPathSelection( - linkableReceiverPaths = linkable, - publishableReceiverPaths = publishable, - cleanupProtectedReceiverPaths = cleanupProtected, - error = firstError, - ) - } - - suspend fun syncLocalReceiverMarker( - isDiscoverable: Boolean, - ) { + internal suspend fun syncPaykitApp(privatePaymentsEnabled: Boolean, priority: Priority) { isSetup.await() - operationLock.withLock { + operationLock.withLock(priority) { withStateRevisionTracking { handle -> - if (!isDiscoverable) { - handle.removePaykitReceiverMarker() - return@withStateRevisionTracking + val capabilities = appCapabilities(completeSdkCall { handle.identityStatus() }) + completeSdkCall { + handle.publishPaykitApp( + displayName = "Bitkit", + capabilities = capabilities.copy( + privatePayments = capabilities.privatePayments && privatePaymentsEnabled + ), + ) } - - handle.publishPaykitReceiverMarker(receiverCapabilities(handle)) } } } suspend fun syncPublicEndpoints(endpoints: List): EndpointSyncReport { isSetup.await() - return operationLock.withLock { + val priority = if (endpoints.isEmpty()) { + PaykitSdkOperationLock.Priority.Ordered + } else { + PaykitSdkOperationLock.Priority.Background + } + return operationLock.withLock(priority) { withStateRevisionTracking { handle -> - handle.syncPublicEndpointsWithReceivingDetails(endpoints.map { it.toPublicReceivingDetail() }) + completeSdkCall { + handle.syncPublicEndpointsWithReceivingDetails(endpoints.map { it.toPublicReceivingDetail() }) + } } } } - fun requiredCapabilities(): String = requiredSessionCapabilities(paykitSdkConfig()) + fun requiredCapabilities(): String = paykitAuthorizerSessionCapabilities() suspend fun syncPrivatePaymentListsWithReservations( updates: List, clearUnlistedLinkedPeers: Boolean, ): PrivatePaymentListDeliveryReport { isSetup.await() - return operationLock.withLock { + val priority = if (clearUnlistedLinkedPeers || updates.any { it.reservations.isEmpty() }) { + PaykitSdkOperationLock.Priority.Ordered + } else { + PaykitSdkOperationLock.Priority.Background + } + return operationLock.withLock(priority) { withStateRevisionTracking { handle -> - handle.syncPrivatePaymentListsWithReservationsAndProcessOutbound( - updates = updates, - clearUnlistedLinkedPeers = clearUnlistedLinkedPeers, - ) + completeSdkCall { + handle.syncPrivatePaymentListsWithReservationsAndProcessOutbound( + updates = updates, + clearUnlistedLinkedPeers = clearUnlistedLinkedPeers, + ) + } } } } - suspend fun ensureLinkWithPeer( + internal suspend fun ensureLinkWithPeer( counterparty: String, - receiverPath: String, - maxAdvanceSteps: UInt = 8u, + maxAdvanceSteps: UInt = 1u, + priority: Priority = Priority.Ordered, ) = run { isSetup.await() - operationLock.withLock { + operationLock.withLock(priority) { withStateRevisionTracking { handle -> - handle.ensureLinkWithPeer(counterparty, receiverPath, maxAdvanceSteps) + completeSdkCall { handle.ensureLinkWithPeer(counterparty, maxAdvanceSteps) } } } } - suspend fun clearPrivatePaymentList( - counterparty: String, - receiverPath: String, + suspend fun clearPrivatePaymentLists( + counterparties: List, ): PrivatePaymentListDeliveryReport? { + if (counterparties.isEmpty()) return null isSetup.await() return operationLock.withLock { withStateRevisionTracking { handle -> - if ( - handle.linkedPeers().any { - it.state == LinkedPeerState.BLOCKED && - PubkyPublicKeyFormat.matches(it.counterparty, counterparty) && - it.counterpartyReceiverPath == receiverPath + val peers = completeSdkCall { handle.linkedPeers() } + val blockedPeers = peers.filter { it.state == LinkedPeerState.BLOCKED } + val updates = counterparties.filterNot { counterparty -> + blockedPeers.any { + PubkyPublicKeyFormat.matches(it.counterparty, counterparty) } - ) { - return@withStateRevisionTracking null + }.map { PrivatePaymentListReservationUpdateInput(it, emptyList()) } + if (updates.isEmpty()) return@withStateRevisionTracking null + val publicKey = completeSdkCall { handle.identityStatus() }?.publicKey + if (publicKey != null) { + val app = handle.paykitAppRegistry(publicKey)?.apps?.find { it.appId == "bitkit" } + if (app?.capabilities?.privatePayments == false) return@withStateRevisionTracking null + } + for (update in updates) { + if (peers.any { + it.state == LinkedPeerState.RECOVERY_REQUIRED && + PubkyPublicKeyFormat.matches(it.counterparty, update.counterparty) + } + ) { + runSuspendCatching { + completeSdkCall { handle.ensureLinkWithPeer(update.counterparty, 1u) } + }.onFailure { + Logger.warn("Failed to recover private Paykit link before withdrawal", it, context = TAG) + } + } + } + completeSdkCall { + handle.syncPrivatePaymentListsWithReservationsAndProcessOutbound( + updates = updates, + clearUnlistedLinkedPeers = false, + ) } - handle.clearPrivatePaymentListAndProcessOutbound(counterparty, receiverPath) } } } - suspend fun receivePrivateMessagesFromLinkedPeers(): List { + suspend fun receivePrivateMessagesFromLinkedPeers(): List = + receivePrivateMessagesFromLinkedPeers(Priority.Ordered) + + internal suspend fun receivePrivateMessagesFromLinkedPeers( + priority: Priority, + ): List { isSetup.await() - return operationLock.withLock { + return operationLock.withLock(priority) { withStateRevisionTracking { handle -> - handle.receivePrivateMessagesFromLinkedPeers() + completeSdkCall { handle.receivePrivateMessagesFromLinkedPeers() } } } } - suspend fun processPendingPrivateMessages(): List { + suspend fun receivePrivateMessages(counterparty: String) = run { isSetup.await() - return operationLock.withLock { + operationLock.withLock { withStateRevisionTracking { handle -> - handle.processPendingPrivateMessages() + completeSdkCall { handle.receivePrivateMessages(counterparty) } } } } - suspend fun paymentRequests(): List { + suspend fun processOutboundPrivateMessages(counterparty: String) = + processOutboundPrivateMessages(counterparty, Priority.Ordered) + + internal suspend fun processOutboundPrivateMessages(counterparty: String, priority: Priority) = + processOutboundPrivateMessages(counterparty, priority, expectedIdentity = null) + + internal suspend fun processOutboundPrivateMessages( + counterparty: String, + priority: Priority, + expectedIdentity: String?, + ) = run { isSetup.await() - return operationLock.withLock { - handle().listPaymentRequests( - PaymentRequestFilter( - counterparty = null, - counterpartyReceiverPath = null, - localRole = null, - states = emptyList(), - recurring = null, - receivedOnly = false, - ) - ) + val generation = runtimeGeneration + val deferDuringPayment = priority == Priority.Background + var report: OutboundPrivateSendReport? + do { + if (deferDuringPayment) isPaymentSubmissionActive.first { !it } + report = operationLock.withLock(priority) { + if (deferDuringPayment) { + check(runtimeGeneration == generation) { "Paykit runtime changed before peer delivery" } + if (isPaymentSubmissionActive.value) return@withLock null + } + withStateRevisionTracking { handle -> + if (expectedIdentity != null) { + val identity = completeSdkCall { handle.identityStatus() }?.publicKey + check(PubkyPublicKeyFormat.matches(identity, expectedIdentity)) { + "Payment Request identity changed" + } + } + if (deferDuringPayment && isPaymentSubmissionActive.value) return@withStateRevisionTracking null + completeSdkCall { handle.processOutboundPrivateMessages(counterparty) } + } + } + } while (report == null) + report + } + + suspend fun processPendingPrivateMessages(): List = + processPendingPrivateMessages(Priority.Ordered) + + internal suspend fun processPendingPrivateMessages( + priority: Priority, + ): List { + isSetup.await() + return operationLock.withLock(priority) { + withStateRevisionTracking { handle -> + completeSdkCall { handle.processPendingPrivateMessages() } + } } } - suspend fun identityStatus(): IdentityStatus? { + suspend fun paymentRequests(): List = allPaymentRequests().filter(::isBitkitPaymentRequest) + + suspend fun allPaymentRequests(expectedIdentity: String? = null): List = + allPaymentRequests(expectedIdentity, Priority.Ordered) + + internal suspend fun allPaymentRequests( + expectedIdentity: String?, + priority: Priority, + ): List { isSetup.await() - return operationLock.withLock { - handle().identityStatus() + return operationLock.withLock(priority) { + withPaykitKey { handle -> + if (expectedIdentity != null) { + val identity = completeSdkCall { handle.identityStatus() }?.publicKey + check(PubkyPublicKeyFormat.matches(identity, expectedIdentity)) { + "Payment Request identity changed" + } + } + completeSdkCall { + handle.listPaymentRequests( + PaymentRequestFilter( + counterparty = null, + localRole = null, + states = emptyList(), + recurring = null, + receivedOnly = false, + ), + ) + } + } } } - suspend fun paymentRequestReceiverPaths(publicKey: String, lane: PaykitReadLane): List = - publicRead(lane) { handle -> - handle.paykitReceiverPaths(publicKey) - .filter { it in PaykitReceiverPaths.supported } - .filter { handle.paykitReceiverMarker(publicKey, it)?.capabilities?.paymentRequests == true } + suspend fun identityStatus(): IdentityStatus? = identityStatus(Priority.Ordered) + + internal suspend fun identityStatus(priority: Priority): IdentityStatus? { + isSetup.await() + return operationLock.withLock(priority) { + withPaykitKey { completeSdkCall { it.identityStatus() } } } + } + + /** Returns null on timeout or runtime replacement; public reads do not hold the mutation lock. */ + suspend fun canReceivePaymentRequests( + publicKey: String, + lane: PaykitReadLane = PaykitReadLane.Interactive, + ): Boolean? = publicRead(lane) { handle -> + val result = withTimeoutOrNull(PAYMENT_REQUEST_DISCOVERY_TIMEOUT) { + handle.paykitAppRegistry(publicKey)?.apps?.any { + it.capabilities.paymentRequests && it.capabilities.outgoingPayments + } == true + } + result.takeIf { sdk === handle } + } suspend fun proposePaymentRequest( counterparty: String, - counterpartyReceiverPath: String, proposal: PaykitPaymentRequestProposalTerms, expectedIdentity: String, ): PaymentRequestRecord { isSetup.await() - return operationLock.withLock { + return operationLock.withLock(PaykitSdkOperationLock.Priority.Interactive) { withStateRevisionTracking { handle -> - val identityStatus = handle.identityStatus() + val identityStatus = completeSdkCall { handle.identityStatus() } check( - identityStatus?.liveSessionAvailable == true && + identityStatus?.capability == PubkyIdentityCapability.PRIVATE_LINK_CAPABLE && PubkyPublicKeyFormat.matches(identityStatus.publicKey, expectedIdentity) ) { "Paykit identity changed before proposing the payment request" } val terms = PaymentRequestTerms( @@ -897,24 +1032,34 @@ class PaykitSdkService @Inject constructor( ) }, acceptedPaymentEndpointIdentifiers = proposal.acceptedPaymentEndpointIdentifiers, + paymentEndpoints = null, + requiredAppId = "bitkit", conversion = null, paymentDeadline = null, metadata = PrivateJsonObject(proposal.metadataJson), ) - handle.proposePaymentRequest(counterparty, counterpartyReceiverPath, terms) + completeSdkCall { handle.proposePaymentRequest(counterparty, terms) } } } } suspend fun acceptPaymentRequest( counterparty: String, - counterpartyReceiverPath: String, paymentRequestId: String, ): PaymentRequestRecord { isSetup.await() - return operationLock.withLock { + return operationLock.withLock(Priority.Interactive) { withStateRevisionTracking { handle -> - handle.acceptPaymentRequest(counterparty, counterpartyReceiverPath, paymentRequestId) + completeSdkCall { handle.claimAndAcceptPaymentRequest(counterparty, paymentRequestId) } + } + } + } + + suspend fun claimPaymentRequestForExecution(counterparty: String, paymentRequestId: String): PaymentRequestRecord { + isSetup.await() + return operationLock.withLock(Priority.Interactive) { + withStateRevisionTracking { + completeSdkCall { it.claimPaymentRequestForExecution(counterparty, paymentRequestId) } } } } @@ -922,8 +1067,8 @@ class PaykitSdkService @Inject constructor( @Suppress("LongParameterList") suspend fun submitPaymentProof( counterparty: String, - counterpartyReceiverPath: String, paymentRequestId: String, + paymentAppId: String, paymentEndpointIdentifier: String, proofJson: String, billingPeriod: PaykitBillingPeriod? = null, @@ -931,80 +1076,85 @@ class PaykitSdkService @Inject constructor( isSetup.await() return operationLock.withLock { withStateRevisionTracking { handle -> - handle.submitPaymentProof( - counterparty, - counterpartyReceiverPath, - paymentRequestId, - PaymentProofSubmission( - billingPeriod = billingPeriod?.sdkValue, - paymentEndpointIdentifier = paymentEndpointIdentifier, - allowanceId = null, - conversionQuoteId = null, - proof = PrivateJsonObject(proofJson), - ), - ) + completeSdkCall { + handle.submitPaymentProof( + counterparty, + paymentRequestId, + PaymentProofSubmission( + billingPeriod = billingPeriod?.sdkValue, + paymentAppId = paymentAppId, + paymentEndpointIdentifier = paymentEndpointIdentifier, + allowanceId = null, + conversionQuoteId = null, + proof = PrivateJsonObject(proofJson), + ), + ) + } } } } suspend fun rejectPaymentRequest( counterparty: String, - counterpartyReceiverPath: String, paymentRequestId: String, reason: String? = null, ): PaymentRequestRecord { isSetup.await() return operationLock.withLock { withStateRevisionTracking { handle -> - handle.rejectPaymentRequest(counterparty, counterpartyReceiverPath, paymentRequestId, reason) + completeSdkCall { handle.rejectPaymentRequest(counterparty, paymentRequestId, reason) } } } } suspend fun cancelPaymentRequest( counterparty: String, - counterpartyReceiverPath: String, paymentRequestId: String, reason: String? = null, ): PaymentRequestRecord { isSetup.await() return operationLock.withLock { withStateRevisionTracking { handle -> - handle.cancelPaymentRequest(counterparty, counterpartyReceiverPath, paymentRequestId, reason) + completeSdkCall { handle.cancelPaymentRequest(counterparty, paymentRequestId, reason) } } } } - suspend fun linkedPeers(): List { + suspend fun linkedPeers(): List = linkedPeers(Priority.Ordered) + + internal suspend fun linkedPeers(priority: Priority): List { isSetup.await() - return operationLock.withLock { - handle().linkedPeers() + return operationLock.withLock(priority) { + withPaykitKey { completeSdkCall { it.linkedPeers() } } } } - suspend fun pendingOutboundPrivateCounterparties(): List { + suspend fun pendingOutboundPrivateCounterparties(): List = + pendingOutboundPrivateCounterparties(Priority.Ordered) + + internal suspend fun pendingOutboundPrivateCounterparties(priority: Priority): List { isSetup.await() - return operationLock.withLock { - handle().pendingOutboundPrivateCounterparties() + return operationLock.withLock(priority) { + withPaykitKey { completeSdkCall { it.pendingOutboundPrivateCounterparties() } } } } suspend fun prepareAndResolvePrivateContactPayment( counterparty: String, - receiverPath: String, afterPrivatePaymentListVersion: ULong?, amount: PaymentAmountContext? = null, ): PaykitPreparedPrivateContactPayment { isSetup.await() - val prepared = operationLock.withLock { + val prepared = operationLock.withLock(PaykitSdkOperationLock.Priority.Interactive) { withStateRevisionTracking { handle -> - handle.prepareAndResolvePrivateContactPayment( - counterparty = counterparty, - counterpartyReceiverPath = receiverPath, - amount = amount, - afterPrivatePaymentListVersion = afterPrivatePaymentListVersion, - maxAdvanceSteps = 8u, - ) + completeSdkCall { + handle.prepareAndResolvePrivateContactPayment( + counterparty = counterparty, + amount = amount, + afterPrivatePaymentListVersion = afterPrivatePaymentListVersion, + maxAdvanceSteps = 1u, + ) + } } } return PaykitPreparedPrivateContactPayment( @@ -1013,13 +1163,37 @@ class PaykitSdkService @Inject constructor( ) } + suspend fun prepareAndResolvePrivatePaymentRequest( + counterparty: String, + paymentRequestId: String, + afterPrivatePaymentListVersion: ULong?, + ): PaykitPreparedPrivateContactPayment { + isSetup.await() + val prepared = operationLock.withLock(PaykitSdkOperationLock.Priority.Interactive) { + withStateRevisionTracking { + completeSdkCall { + it.prepareAndResolvePrivatePaymentRequest( + counterparty, + paymentRequestId, + afterPrivatePaymentListVersion, + 1u, + ) + } + } + } + return PaykitPreparedPrivateContactPayment( + prepared.resolution.toPaykitPrivateContactPaymentResolution(), + prepared.linkReport?.state, + ) + } + suspend fun resolvePublicContactPayment( counterparty: String, - receiverPath: String, ): PaykitPublicContactPaymentResolution { - isSetup.await() - val resolution = operationLock.withLock { - handle().resolvePublicContactPayment(counterparty, receiverPath, amount = null) + val resolution = publicRead { handle -> + val result = handle.resolvePublicContactPayment(counterparty, amount = null) + check(sdk === handle) { "Paykit runtime changed while resolving public payment endpoints" } + result } return resolution.toPaykitPublicContactPaymentResolution() } @@ -1031,6 +1205,7 @@ class PaykitSdkService @Inject constructor( privatePaymentListVersion = privatePaymentListVersion, payableEndpoints = payableEndpoints.map { PaykitResolvedPaymentEndpoint( + appId = it.appId, identifier = it.identifier, payload = it.target.payload.exportText(), ) @@ -1041,6 +1216,7 @@ class PaykitSdkService @Inject constructor( PaykitPublicContactPaymentResolution( payableEndpoints = payableEndpoints.map { PaykitResolvedPaymentEndpoint( + appId = it.appId, identifier = it.identifier, payload = it.target.payload.exportText(), ) @@ -1049,28 +1225,35 @@ class PaykitSdkService @Inject constructor( suspend fun exportBackupState(): String { isSetup.await() - return operationLock.withLock { - handle().exportBackupString() + val generation = runtimeGeneration + return operationLock.withoutLock { + var backup: String? + do { + isPaymentSubmissionActive.first { !it } + backup = operationLock.withLock(Priority.Background) { + check(runtimeGeneration == generation) { "Paykit runtime changed before backup export" } + if (isPaymentSubmissionActive.value) return@withLock null + withPaykitKey { completeSdkCall { it.exportBackupString() } } + } + } while (backup == null) + backup } } - suspend fun restoreBackupState(backup: String) { - isSetup.await() - operationLock.withLock { - withStateRevisionTracking { handle -> - handle.restoreBackupString(backup) - } - resetRuntime() - } + suspend fun retainRecoveryBackup(backup: String) { + keychain.upsertString(Keychain.Key.PAYKIT_RECOVERY_BACKUP.name, backup) } suspend fun signOut() { isSetup.await() operationLock.withLock { - withStateRevisionTracking { handle -> - handle.signOut() + try { + withStateRevisionTracking { handle -> + completeSdkCall { handle.signOut() } + } + } finally { + resetRuntime() } - resetRuntime() } } @@ -1080,8 +1263,9 @@ class PaykitSdkService @Inject constructor( isSetup.await() operationLock.withLock { try { - withStateRevisionTracking { handle -> - handle.forgetSessionAccess() + completeSdkCall { + handle().forgetSessionAccess() + notifyBackupStateChanged() } } finally { resetRuntime() @@ -1106,28 +1290,50 @@ class PaykitSdkService @Inject constructor( suspend fun clearState() { operationLock.withLock { - clearStateLocked() + resetRuntime() + notifyBackupStateChanged() } } - private suspend fun clearStateLocked() { - keychain.delete(Keychain.Key.PAYKIT_SDK_STATE.name) - resetRuntime() - notifyBackupStateChanged() + private suspend fun refreshPaykitKey(force: Boolean = false) { + if (force) { + cachedPaykitKey = null + cachedBackupState = null + } + val root = sessionProvider.loadLocalSecretKey() ?: run { + if (cachedPaykitKey != null) cachedBackupState = null + cachedPaykitKey = null + return + } + val publicKey = pubkyPublicKeyFromSecret(root) + val savedGeneration = keychain.loadString("${Keychain.Key.PAYKIT_KEY_GENERATION.name}:$publicKey")?.toULong() + val cached = cachedPaykitKey + if (cached != null && cached.publicKey == publicKey && cached.generation == savedGeneration) return + cachedPaykitKey = null + cachedBackupState = null + val key = paykitKey(root) + sessionProvider.setPaykitIdentitySecretKey(key) + cachedPaykitKey = PaykitKeyGeneration(publicKey, key.keyGeneration()) } - private suspend fun currentSdkStatePublicKeyLocked(): String? { - sessionProvider.suspendStoredSessionAccess() - return try { - handle().identityStatus()?.publicKey - } finally { - sessionProvider.resumeStoredSessionAccess() - } + @VisibleForTesting + internal suspend fun paykitKeyForAuthorization(secretKeyHex: String): PaykitIdentitySecretKey { + isSetup.await() + return operationLock.withLock { paykitKey(localSecretKey(secretKeyHex)) } + } + + private suspend fun paykitKey(root: PubkyLocalSecretKey): PaykitIdentitySecretKey { + val publicKey = pubkyPublicKeyFromSecret(root) + val generation = handle().paykitAppRegistry(publicKey)?.keyGeneration ?: 1uL + val storageKey = "${Keychain.Key.PAYKIT_KEY_GENERATION.name}:$publicKey" + val saved = keychain.loadString(storageKey)?.toULong() + check(generation >= (saved ?: 1uL)) { "The Paykit App Registry has an older key generation" } + if (saved != generation) keychain.upsertString(storageKey, generation.toString()) + return root.derivePaykitIdentitySecretKey(generation) } private suspend fun persistSessionAccess(access: PubkySessionAccess) { keychain.upsertString(Keychain.Key.PAYKIT_SESSION.name, access.exportSessionSecret()) - sessionProvider.persistReceiverNoiseSecretKey(access.exportReceiverNoiseSecretKey()) val localSecret = access.exportLocalSecretKey() if (localSecret != null && sessionProvider.adoptedPubky() == null) { keychain.upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, secretKeyHex(localSecret)) @@ -1138,48 +1344,50 @@ class PaykitSdkService @Inject constructor( private suspend fun activateBootstrapResult( result: PubkySessionBootstrapResult, - previousPublicKey: String?, ) { persistSessionAccess(result.sessionAccess) sessionProvider.setLiveSessionAccess(result.sessionAccess) - val cachedOwner = pubkyStore.data.first().ownerPublicKey - val previousOwner = cachedOwner ?: previousPublicKey + val previousOwner = pubkyStore.data.first().ownerPublicKey if (previousOwner != null && !PubkyPublicKeyFormat.matches(previousOwner, result.publicKey)) { pubkyStore.reset() } - if (!PubkyPublicKeyFormat.matches(previousPublicKey, result.publicKey)) { - keychain.delete(Keychain.Key.PAYKIT_SDK_STATE.name) - } resetRuntime() + refreshPaykitKey() val handle = handle() - handle.initialize() - publishReceiverMarkerIfLiveSessionAvailable(handle) + val identityStatus = completeSdkCall { handle.initialize() } + if (result.sessionAccess.exportLocalSecretKey() != null) { + completeSdkCall { handle.publishPaykitNoiseKeyAuthorization() } + } + publishAppIfLiveSessionAvailable(handle, identityStatus) launchIdentityRepublish(publicKey = result.publicKey) } private suspend fun clearRegisteredIdentityActivationLocked() = withContext(NonCancellable) { runSuspendCatching { sessionProvider.clearSessionAccess() } .onFailure { Logger.warn("Failed to clear incomplete Pubky signup session", it, context = TAG) } - runSuspendCatching { keychain.delete(Keychain.Key.PAYKIT_SDK_STATE.name) } - .onFailure { Logger.warn("Failed to clear incomplete Pubky signup state", it, context = TAG) } resetRuntime() notifyBackupStateChanged() } - private suspend fun publishReceiverMarkerIfLiveSessionAvailable(handle: PaykitSdk) { + private suspend fun publishAppIfLiveSessionAvailable(handle: PaykitSdk, identityStatus: IdentityStatus?) { runSuspendCatching { - val capabilities = receiverCapabilities(handle) + val capabilities = appCapabilities(identityStatus) if (capabilities.privatePayments) { - handle.publishPaykitReceiverMarker(capabilities) + val privatePayments = settingsStore.data.first().sharesPrivatePaykitEndpoints + completeSdkCall { + handle.publishPaykitApp("Bitkit", capabilities.copy(privatePayments = privatePayments)) + } } }.onFailure { - Logger.warn("Failed to publish Paykit receiver marker", it, context = TAG) + invalidatePaykitKeyIfNeeded(it) + Logger.warn("Failed to publish Paykit app", it, context = TAG) } } - private suspend fun receiverCapabilities(handle: PaykitSdk): PaykitReceiverCapabilities { - val hasPrivatePaymentAccess = handle.identityStatus()?.liveSessionAvailable == true - return PaykitReceiverCapabilities( + private fun appCapabilities(identityStatus: IdentityStatus?): PaykitAppCapabilities { + val hasPrivatePaymentAccess = + identityStatus?.capability == PubkyIdentityCapability.PRIVATE_LINK_CAPABLE + return PaykitAppCapabilities( privatePayments = hasPrivatePaymentAccess, paymentRequests = hasPrivatePaymentAccess, receipts = false, @@ -1191,37 +1399,34 @@ class PaykitSdkService @Inject constructor( _backupStateVersion.update { it + 1 } } - private suspend fun restorePrivateContact( - handle: PaykitSdk, - blockedPeers: List, - update: ContactUpdate, - ): ContactRecord { - var failure: Throwable? = null - return try { - runSuspendCatching { - blockedPeers.forEach { handle.unblockPeer(it.counterparty, it.counterpartyReceiverPath) } - handle.saveContact(update) - }.onFailure { failure = it }.getOrThrow() - } catch (error: CancellationException) { - failure = error - throw error - } finally { - failure?.let { restorationError -> - withContext(NonCancellable) { - blockedPeers.forEach { peer -> - runSuspendCatching { - handle.blockPeer(peer.counterparty, peer.counterpartyReceiverPath) - }.onFailure(restorationError::addSuppressed) - } - } - } - } + private suspend fun withPaykitKey(block: suspend (PaykitSdk) -> T): T { + refreshPaykitKey() + return runSuspendCatching { block(handle()) } + .onFailure(::invalidatePaykitKeyIfNeeded) + .getOrThrow() } - private suspend fun withStateRevisionTracking(block: suspend (PaykitSdk) -> T): T { - val handle = handle() - return withPaykitBackupStateTracking( - readRevision = { handle.backupStateRevision() }, + private fun invalidatePaykitKeyIfNeeded(error: Throwable) { + if (error !is PaykitException.Identity) return + cachedPaykitKey = null + cachedBackupState = null + } + + private suspend fun withStateRevisionTracking(block: suspend (PaykitSdk) -> T): T = withPaykitKey { handle -> + withPaykitBackupStateTracking( + readRevision = { + runSuspendCatching { completeSdkCall { handle.backupStateRevision() } } + .onFailure(::invalidatePaykitKeyIfNeeded) + .getOrThrow() + }, + readStateRevision = { handle.stateRevision() }, + readObservedSnapshot = { + handle.observedBackupStateRevision()?.let { + PaykitBackupStateSnapshot(it.stateRevision, it.backupRevision) + } + }, + cachedSnapshot = cachedBackupState, + onSnapshot = { cachedBackupState = it }, onChange = ::notifyBackupStateChanged, ) { block(handle) @@ -1233,6 +1438,14 @@ class PaykitSdkService @Inject constructor( sdkFactory().also { sdk = it } } + private suspend fun completeSdkCall(operation: suspend () -> T): T { + currentCoroutineContext().ensureActive() + val result = withContext(NonCancellable) { runSuspendCatching { operation() } } + .onFailure(::invalidatePaykitKeyIfNeeded) + currentCoroutineContext().ensureActive() + return result.getOrThrow() + } + /** * Runs [block] on the SDK instance without [operationLock]. [block] may only call unauthenticated public * Pubky reads, never session, secret, state-blob or publishing APIs. Without an instance it builds one under @@ -1279,21 +1492,12 @@ class PaykitSdkService @Inject constructor( } private fun resetRuntime() { + runtimeGeneration++ sdk = null + cachedPaykitKey = null + cachedBackupState = null } - private fun mergedReceiverPaths(paths: List): List { - return (listOf(PaykitReceiverPaths.WALLET) + paths) - .filter { it in PaykitReceiverPaths.supported } - .distinct() - } - - private fun PaykitReceiverMarker.requiresPrivateLink(): Boolean = - capabilities.privatePayments || capabilities.paymentRequests || capabilities.receipts - - private fun PaykitReceiverMarker?.canReceivePrivatePaymentDetails(): Boolean = - this?.capabilities?.let { it.privatePayments && it.outgoingPayments } == true - companion object { private const val TAG = "PaykitSdkService" @@ -1309,6 +1513,9 @@ class PaykitSdkService @Inject constructor( /** Maximum time identity maintenance may delay its caller. */ private val IDENTITY_REPUBLISH_WAIT_TIMEOUT = 5.seconds + /** Maximum duration of a public payment-request capability lookup. */ + private val PAYMENT_REQUEST_DISCOVERY_TIMEOUT = 5.seconds + /** Maximum concurrent public Pubky reads that run outside the operation lock. */ private const val PUBLIC_READ_PERMITS = 6 @@ -1332,17 +1539,56 @@ class PaykitSdkService @Inject constructor( } } +internal fun isBitkitPaymentRequest(record: PaymentRequestRecord): Boolean = when (record.localRole) { + PaymentRequestLocalRole.PAYEE -> record.proposalAppId == "bitkit" + PaymentRequestLocalRole.PAYER -> record.executionClaimAppId?.let { it == "bitkit" } ?: when (record.state) { + PaymentRequestLifecycleState.ACTIVE_RECURRING -> true + PaymentRequestLifecycleState.ACCEPTED if record.paymentProofs.isEmpty() -> true + else -> record.payerAppId == null || record.payerAppId == "bitkit" + } + else -> false +} + +private data class PaykitKeyGeneration(val publicKey: String, val generation: ULong) + +internal data class PaykitBackupStateSnapshot(val stateRevision: String, val backupRevision: String) + +@Suppress("LongParameterList") internal suspend fun withPaykitBackupStateTracking( readRevision: suspend () -> String, + readStateRevision: () -> String? = { null }, + readObservedSnapshot: () -> PaykitBackupStateSnapshot? = { null }, + cachedSnapshot: PaykitBackupStateSnapshot? = null, + onSnapshot: (PaykitBackupStateSnapshot?) -> Unit = {}, onChange: () -> Unit, operation: suspend () -> T, ): T { - val previousRevision = runSuspendCatching { readRevision() }.getOrNull() + val previousRevision = cachedSnapshot?.backupRevision + ?: runSuspendCatching { readRevision() }.getOrNull() + var succeeded = false return try { - operation() + operation().also { succeeded = true } } finally { withContext(NonCancellable) { - val nextRevision = runSuspendCatching { readRevision() }.getOrNull() + if (!succeeded) { + onSnapshot(null) + onChange() + return@withContext + } + val nextStateRevision = runSuspendCatching { readStateRevision() }.getOrNull() + val observedSnapshot = runSuspendCatching { readObservedSnapshot() }.getOrNull() + ?.takeIf { it.stateRevision == nextStateRevision } + val nextRevision = observedSnapshot?.backupRevision + ?: runSuspendCatching { readRevision() }.getOrNull() + val stateRevision = observedSnapshot?.stateRevision + ?: runSuspendCatching { readStateRevision() }.getOrNull() + onSnapshot( + if (stateRevision != null && nextRevision != null) { + PaykitBackupStateSnapshot(stateRevision, nextRevision) + } else { + null + }, + ) if (previousRevision == null || nextRevision == null || previousRevision != nextRevision) { onChange() } @@ -1352,18 +1598,11 @@ internal suspend fun withPaykitBackupStateTracking( internal object BitkitPaykitSdkConfig { val clientId: String - get() = profileNamespace - val profileNamespace: String get() = if (Env.network == Network.BITCOIN) "bitkit.to" else "staging.bitkit.to" - val endpointManagementScope = PaykitSdkDefaults.DEFAULT_ENDPOINT_MANAGEMENT_SCOPE - val encryptedLinkRecoveryMarkers = PaykitSdkDefaults.DEFAULT_ENCRYPTED_LINK_RECOVERY_MARKER_POLICY val publicContactSharing = PaykitSdkDefaults.DEFAULT_PUBLIC_CONTACT_SHARING_POLICY } -internal fun paykitSdkConfig() = defaultConfig(PaykitReceiverPaths.WALLET).copy( - profileNamespace = BitkitPaykitSdkConfig.profileNamespace, - endpointManagementScope = BitkitPaykitSdkConfig.endpointManagementScope, - encryptedLinkRecoveryMarkers = BitkitPaykitSdkConfig.encryptedLinkRecoveryMarkers, +internal fun paykitSdkConfig() = defaultConfig("bitkit").copy( publicContactSharing = BitkitPaykitSdkConfig.publicContactSharing, ) @@ -1387,61 +1626,23 @@ internal fun validatedApprovalClientId(requestClientId: String, approvedClientId return requestClientId } -private class PaykitSdkStateBlobStore( - private val keychain: Keychain, -) : SdkStateBlobStore { - private val lock = Any() - - override fun loadStateBlob(): SdkStateBlobSnapshot? = paykitStorageCallback("state_load_failed") { - synchronized(lock) { - val data = keychain.accessBlocking { - load(Keychain.Key.PAYKIT_SDK_STATE.name) - } ?: return@synchronized null - decodeSdkStateBlobSnapshot(data) - } - } - - override fun saveStateBlobAtomically( - blob: SdkStateBlob, - expectedRevision: String?, - ): String = paykitStorageCallback("state_save_failed") { - synchronized(lock) { - val currentRevision = keychain.accessBlocking { - load(Keychain.Key.PAYKIT_SDK_STATE.name) - } - ?.let { decodeSdkStateBlobSnapshot(it).revision } - if (currentRevision != expectedRevision) { - throw PaykitException.Storage( - code = "revision_conflict", - context = "SDK state revision changed", - ) - } - - val nextRevision = UUID.randomUUID().toString() - val snapshot = SdkStateBlobSnapshot(blob = blob, revision = nextRevision) - keychain.accessBlocking { - upsert(Keychain.Key.PAYKIT_SDK_STATE.name, encodeSdkStateBlobSnapshot(snapshot)) - } - nextRevision - } - } -} - internal class PaykitSdkSessionProvider( private val keychain: Keychain, private val sharedPubky: SharedPubkyClient, ) : SdkPubkySessionProvider { private val lock = Any() - private val receiverNoiseKeyStore = PaykitReceiverNoiseKeyStore(keychain) + private var paykitIdentitySecretKey: PaykitIdentitySecretKey? = null private var liveSessionAccess: PubkySessionAccess? = null private var isStoredSessionAccessSuspended = false fun setLiveSessionAccess(access: PubkySessionAccess) = synchronized(lock) { liveSessionAccess = access + paykitIdentitySecretKey = access.exportPaykitIdentitySecretKey() } fun clearLiveSessionAccess() = synchronized(lock) { liveSessionAccess = null + paykitIdentitySecretKey = null } override fun loadSessionAccess(): PubkySessionAccess? = paykitStorageCallback("session_load_failed") { @@ -1459,8 +1660,8 @@ internal class PaykitSdkSessionProvider( clientId = BitkitPaykitSdkConfig.clientId, sessionSecret = sessionSecret, localSecretKey = loadLocalSecretKey(), - receiverNoiseSecretKey = loadOrDeriveReceiverNoiseSecretKey(), - ) + paykitIdentitySecretKey = paykitIdentitySecretKey, + ).also { liveSessionAccess = it } } } @@ -1505,48 +1706,12 @@ internal class PaykitSdkSessionProvider( return PaykitSdkService.localSecretKey(secretKeyHex) } - fun loadOrDeriveReceiverNoiseSecretKey(): ReceiverNoiseSecretKey = - receiverNoiseKeyStore.loadOrDerive() - - fun persistReceiverNoiseSecretKey(key: ReceiverNoiseSecretKey) { - receiverNoiseKeyStore.persist(key) - } -} - -internal object PaykitReceiverNoiseKeyDerivation { - private const val DOMAIN = "bitkit/paykit/receiver-noise-key" - private const val VERSION = "v1" - - fun deriveFromWalletSeed( - mnemonic: String, - passphrase: String?, - network: String, - receiverPath: String, - ): ByteArray { - val seed = mnemonicToSeed(mnemonic, passphrase?.takeIf { it.isNotEmpty() }) - return try { - derive(seed, network, receiverPath) - } finally { - seed.fill(0) + fun setPaykitIdentitySecretKey(key: PaykitIdentitySecretKey) = synchronized(lock) { + if (paykitIdentitySecretKey?.keyGeneration() != key.keyGeneration()) { + liveSessionAccess = null } + paykitIdentitySecretKey = key } - - fun derive(seed: ByteArray, network: String, receiverPath: String): ByteArray { - val salt = MessageDigest.getInstance("SHA-256").digest(DOMAIN.encodeToByteArray()) - val prk = hmacSha256(key = salt, data = seed) - return try { - val info = "$VERSION\u0000$network\u0000$receiverPath".encodeToByteArray() + byteArrayOf(0x01) - hmacSha256(key = prk, data = info) - } finally { - prk.fill(0) - } - } - - private fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray = - Mac.getInstance("HmacSHA256").run { - init(SecretKeySpec(key, "HmacSHA256")) - doFinal(data) - } } internal fun clearPubkySessionCredentials(deleteKeychainValue: (String) -> Unit) { @@ -1556,88 +1721,15 @@ internal fun clearPubkySessionCredentials(deleteKeychainValue: (String) -> Unit) localSecretResult.getOrThrow() } -internal class PaykitReceiverNoiseKeyStore( - private val loadBytes: () -> ByteArray?, - private val upsertBytes: (ByteArray) -> Unit, - private val deriveBytes: () -> ByteArray, -) { - constructor(keychain: Keychain) : this( - loadBytes = { - keychain.accessBlocking { - load(Keychain.Key.PAYKIT_RECEIVER_NOISE_SECRET_KEY.name) - } - }, - upsertBytes = { bytes -> - keychain.accessBlocking { - upsert(Keychain.Key.PAYKIT_RECEIVER_NOISE_SECRET_KEY.name, bytes) - } - }, - deriveBytes = { - val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) - ?: throw AppError("Mnemonic not found while deriving the Paykit receiver Noise key") - val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) - PaykitReceiverNoiseKeyDerivation.deriveFromWalletSeed( - mnemonic = mnemonic, - passphrase = passphrase, - network = Env.network.name.lowercase(), - receiverPath = PaykitReceiverPaths.WALLET, - ) - }, - ) - - @Synchronized - fun loadOrDerive(): ReceiverNoiseSecretKey = - ReceiverNoiseSecretKey(validatedKeyBytes().copyOf()) - - @Synchronized - fun persist(key: ReceiverNoiseSecretKey) { - persistBytes(key.exportBytes()) - } - - @Synchronized - internal fun loadOrDeriveBytes(): ByteArray = - validatedKeyBytes().copyOf() - - @Synchronized - internal fun persistBytes(bytes: ByteArray) { - if (!validatedKeyBytes().contentEquals(bytes)) { - throw AppError("Paykit receiver Noise key changed unexpectedly") - } - } - - private fun validatedKeyBytes(): ByteArray { - loadBytes()?.let { - checkKeyLength(it, "Stored Paykit receiver Noise key is invalid") - return it - } - - val derivedBytes = deriveBytes() - checkKeyLength(derivedBytes, "Derived Paykit receiver Noise key is invalid") - upsertBytes(derivedBytes.copyOf()) - - return derivedBytes - } - - private fun checkKeyLength(bytes: ByteArray, message: String) { - if (bytes.size != RECEIVER_NOISE_KEY_LENGTH) throw AppError(message) - } - - private companion object { - const val RECEIVER_NOISE_KEY_LENGTH = 32 - } -} - class PaykitSdkPaymentAdapter : SdkPaymentAdapter { override fun currentPublicReceivingDetails(): List = emptyList() override fun currentPrivateReceivingDetails( counterparty: String, - counterpartyReceiverPath: String, ): List = emptyList() override fun reservePrivateReceivingDetails( counterparty: String, - counterpartyReceiverPath: String, ): PrivateReceivingDetailReservationResponse = PrivateReceivingDetailReservationResponse( kind = PrivateReceivingDetailReservationResponseKind.USE_CURRENT_RECEIVING_DETAILS, diff --git a/app/src/main/java/to/bitkit/services/PubkyService.kt b/app/src/main/java/to/bitkit/services/PubkyService.kt index 61b9acf309..9a13d4dc7b 100644 --- a/app/src/main/java/to/bitkit/services/PubkyService.kt +++ b/app/src/main/java/to/bitkit/services/PubkyService.kt @@ -1,10 +1,11 @@ package to.bitkit.services import com.synonym.bitkitcore.approvePubkyAuth -import com.synonym.paykit.ContactProfileResolution import com.synonym.paykit.ContactRecord +import com.synonym.paykit.ContactUpdate import com.synonym.paykit.PaykitProfile import com.synonym.paykit.PaykitPublicKeys +import com.synonym.paykit.ProfileResolution import com.synonym.paykit.PubkyAuthCompanionClaim import com.synonym.paykit.PubkySessionBootstrapResult import kotlinx.coroutines.CoroutineScope @@ -33,6 +34,10 @@ class PubkyService @Inject constructor( paykitSdkService.initialize() } + suspend fun initializeAndImportSession(secret: String): Result = ServiceQueue.CORE.background { + paykitSdkService.initializeAndImportSession(secret).map { it.publicKey } + } + suspend fun republishIdentityIfNeeded(publicKey: String? = null) = paykitSdkService.republishIdentityIfNeeded(publicKey) @@ -63,11 +68,11 @@ class PubkyService @Inject constructor( val report = paykitSdkService.syncPublicEndpoints(emptyList()) if (report.failed.isNotEmpty()) throw AppError("Failed to remove Paykit payment endpoints") }.exceptionOrNull() - val markerError = runSuspendCatching { - paykitSdkService.syncLocalReceiverMarker(isDiscoverable = false) + val appError = runSuspendCatching { + paykitSdkService.syncPaykitApp(privatePaymentsEnabled = false) }.exceptionOrNull() - val cleanupError = endpointError ?: markerError - if (endpointError != null && markerError != null) endpointError.addSuppressed(markerError) + val cleanupError = endpointError ?: appError + if (endpointError != null && appError != null) endpointError.addSuppressed(appError) cleanupError?.let { throw it } } @@ -204,7 +209,6 @@ class PubkyService @Inject constructor( suspend fun saveContact( publicKey: String, label: String?, - receiverPaths: List? = null, restorePrivateConnection: Boolean = false, expectedIdentity: String? = null, isStillCurrent: (() -> Boolean)? = null, @@ -212,33 +216,36 @@ class PubkyService @Inject constructor( paykitSdkService.saveContact( publicKey, label, - receiverPaths, restorePrivateConnection, expectedIdentity, isStillCurrent, ) } + suspend fun saveContacts( + updates: List, + expectedIdentity: String? = null, + isStillCurrent: (() -> Boolean)? = null, + ): List = ServiceQueue.CORE.background { + paykitSdkService.saveContacts(updates, expectedIdentity, isStillCurrent) + } + suspend fun removeContact(publicKey: String): ContactRecord? = ServiceQueue.CORE.background { paykitSdkService.removeContact(publicKey) } + suspend fun removeContacts(publicKeys: List): List = ServiceQueue.CORE.background { + paykitSdkService.removeContacts(publicKeys) + } + suspend fun resolveContactProfile( publicKey: String, allowPubkyProfileFallback: Boolean, lane: PaykitReadLane = PaykitReadLane.Interactive, timeout: Duration? = null, - ): ContactProfileResolution? = cancellablePublicRead { + ): ProfileResolution? = cancellablePublicRead { paykitSdkService.resolveContactProfile(publicKey, allowPubkyProfileFallback, lane, timeout) } - - suspend fun discoverRelevantReceiverPaths( - publicKey: String, - lane: PaykitReadLane = PaykitReadLane.Interactive, - ): List = cancellablePublicRead { - paykitSdkService.discoverRelevantReceiverPaths(publicKey, lane) - } - // endregion private suspend fun cancellablePublicRead(block: suspend CoroutineScope.() -> T): T = diff --git a/app/src/main/java/to/bitkit/services/TrezorService.kt b/app/src/main/java/to/bitkit/services/TrezorService.kt index 5760c5ce84..cb258786b1 100644 --- a/app/src/main/java/to/bitkit/services/TrezorService.kt +++ b/app/src/main/java/to/bitkit/services/TrezorService.kt @@ -48,8 +48,11 @@ import com.synonym.bitkitcore.trezorSignMessage import com.synonym.bitkitcore.trezorSignTxFromPsbt import com.synonym.bitkitcore.trezorVerifyMessage import to.bitkit.async.ServiceQueue +import to.bitkit.utils.ServiceError import javax.inject.Inject import javax.inject.Singleton +import kotlin.time.Clock +import kotlin.time.Instant import com.synonym.bitkitcore.Network as BitkitCoreNetwork @Suppress("TooManyFunctions") @@ -57,6 +60,7 @@ import com.synonym.bitkitcore.Network as BitkitCoreNetwork class TrezorService @Inject constructor( private val transport: TrezorTransport, private val uiHandler: TrezorUiHandler, + private val clock: Clock = Clock.System, ) { @Volatile private var callbackRegistered = false @@ -237,8 +241,15 @@ class TrezorService @Inject constructor( } } - suspend fun broadcastRawTx(serializedTx: String, electrumUrl: String): String { + suspend fun broadcastRawTx( + serializedTx: String, + electrumUrl: String, + paymentDeadlineAt: Instant? = null, + ): String { return ServiceQueue.CORE.background { + if (paymentDeadlineAt != null && clock.now() > paymentDeadlineAt) { + throw ServiceError.PaymentDeadlineExpired() + } onchainBroadcastRawTx(serializedTx = serializedTx, electrumUrl = electrumUrl) } } diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index d046be7a63..28a99984b7 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -271,6 +271,7 @@ fun ContentView( modifier: Modifier = Modifier, ) { val navController = rememberNavController() + val navBackStackEntry by navController.currentBackStackEntryAsState() val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val context = LocalContext.current @@ -539,6 +540,7 @@ fun ContentView( walletViewModel = walletViewModel, startDestination = sheet.route, hardwareWalletId = sheet.hardwareWalletId, + preparingRequest = sheet.preparingRequest, hwSendViewModel = hwSendViewModel, ) } @@ -694,7 +696,6 @@ fun ContentView( onHomeCalculatorInputActiveChanged = { isHomeCalculatorInputActive = it }, ) - val navBackStackEntry by navController.currentBackStackEntryAsState() val currentRoute = navBackStackEntry?.destination?.route LaunchedEffect( isPaykitEnabled, @@ -773,6 +774,11 @@ fun ContentView( ) BottomSheetOverlayHost(state = bottomSheetOverlayState) + + val hasOverlaySheet = bottomSheetOverlayState.entries.isNotEmpty() || drawerState.isOpen + LaunchedEffect(hasOverlaySheet) { + appViewModel.setPaymentRequestOverlayVisible(hasOverlaySheet) + } } } } @@ -854,7 +860,6 @@ private fun RootNavHost( Routes.SubscriptionDetail( paymentRequestId = it.paymentRequestId, counterparty = it.counterparty, - counterpartyReceiverPath = it.counterpartyReceiverPath, ) ) }, @@ -871,7 +876,6 @@ private fun RootNavHost( id = PaykitSubscriptionId( paymentRequestId = route.paymentRequestId, counterparty = route.counterparty, - counterpartyReceiverPath = route.counterpartyReceiverPath, ), onBack = { navController.popBackStack() }, ) @@ -1431,7 +1435,6 @@ private fun NavGraphBuilder.contacts( Sheet.Receive( route = ReceiveRoute.PaymentRequestAmount( publicKey = it.publicKey, - receiverPath = it.receiverPath, ) ) ) @@ -2190,14 +2193,12 @@ fun NavController.navigateToLanguageSettings() = navigateTo(Routes.LanguageSetti private fun PaykitPaymentRequestId.toRoute() = Routes.PaymentRequestDetails( paymentRequestId = paymentRequestId, counterparty = counterparty, - counterpartyReceiverPath = counterpartyReceiverPath, billingPeriodStartsAt = billingPeriodStartsAt, ) private fun Routes.PaymentRequestDetails.toId() = PaykitPaymentRequestId( paymentRequestId = paymentRequestId, counterparty = counterparty, - counterpartyReceiverPath = counterpartyReceiverPath, billingPeriodStartsAt = billingPeriodStartsAt, ) @@ -2525,14 +2526,12 @@ sealed interface Routes { data class SubscriptionDetail( val paymentRequestId: String, val counterparty: String, - val counterpartyReceiverPath: String, ) : Routes.InternalOnly @Serializable data class PaymentRequestDetails( val paymentRequestId: String, val counterparty: String, - val counterpartyReceiverPath: String, val billingPeriodStartsAt: String? = null, ) : Routes.InternalOnly diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index 979911b325..c17983214f 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -309,13 +309,11 @@ class MainActivity : FragmentActivity() { private fun Intent.paykitPaymentRequestId(): PaykitPaymentRequestId? { val requestId = getStringExtra(EXTRA_PAYKIT_PAYMENT_REQUEST_ID) ?: return null val counterparty = getStringExtra(EXTRA_PAYKIT_COUNTERPARTY) ?: return null - val receiverPath = getStringExtra(EXTRA_PAYKIT_COUNTERPARTY_RECEIVER_PATH) ?: return null val billingPeriodStartsAt = getStringExtra(EXTRA_PAYKIT_BILLING_PERIOD_STARTS_AT) ?: return null return PaykitPaymentRequestId( paymentRequestId = requestId, counterparty = counterparty, - counterpartyReceiverPath = receiverPath, billingPeriodStartsAt = billingPeriodStartsAt, ) } diff --git a/app/src/main/java/to/bitkit/ui/Notifications.kt b/app/src/main/java/to/bitkit/ui/Notifications.kt index 1e8d7b9668..f04ae82195 100644 --- a/app/src/main/java/to/bitkit/ui/Notifications.kt +++ b/app/src/main/java/to/bitkit/ui/Notifications.kt @@ -30,7 +30,6 @@ const val EXTRA_PAYKIT_SUBSCRIPTION_PAYMENT_DUE = "paykit_subscription_payment_d const val EXTRA_PAYKIT_PAYER_IDENTITY = "paykit_payer_identity" const val EXTRA_PAYKIT_PAYMENT_REQUEST_ID = "paykit_payment_request_id" const val EXTRA_PAYKIT_COUNTERPARTY = "paykit_counterparty" -const val EXTRA_PAYKIT_COUNTERPARTY_RECEIVER_PATH = "paykit_counterparty_receiver_path" const val EXTRA_PAYKIT_BILLING_PERIOD_STARTS_AT = "paykit_billing_period_starts_at" val Context.CHANNEL_MAIN get() = getString(R.string.app_notifications_channel_id) diff --git a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt index 7f284dd301..ced2e3015a 100644 --- a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt +++ b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt @@ -33,6 +33,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import kotlinx.coroutines.launch import to.bitkit.models.SamRockSetupRequest +import to.bitkit.repositories.PaykitPaymentRequest import to.bitkit.repositories.PaykitSubscriptionId import to.bitkit.ui.screens.wallets.receive.ReceiveRoute import to.bitkit.ui.shared.modifiers.clickableAlpha @@ -66,6 +67,7 @@ sealed interface Sheet { data class Send( val route: SendRoute = SendRoute.Recipient, val hardwareWalletId: String? = null, + val preparingRequest: PaykitPaymentRequest? = null, ) : Sheet data class Receive( val route: ReceiveRoute = ReceiveRoute.QR, @@ -140,8 +142,7 @@ fun SheetHost( var wasSheetVisible by remember { mutableStateOf(false) } var visibleKey by remember { mutableStateOf(null) } - // Automatically expand or hide the bottom sheet based on bool flag - LaunchedEffect(shouldExpand) { + LaunchedEffect(shouldExpand, visibilityKey) { if (shouldExpand) { scaffoldState.bottomSheetState.expand() } else { diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt index bb4e3920a9..9ecf84fd70 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt @@ -23,7 +23,7 @@ import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.PublicPaykitPaymentResult import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.ui.shared.toast.ToastEventBus -import to.bitkit.usecases.RefreshContactPaykitReceiversUseCase +import to.bitkit.usecases.RefreshContactPaykitLinkUseCase import to.bitkit.utils.Logger import javax.inject.Inject @@ -32,7 +32,7 @@ class AddContactViewModel @Inject constructor( @ApplicationContext private val context: Context, private val pubkyRepo: PubkyRepo, private val publicPaykitRepo: PublicPaykitRepo, - private val refreshContactPaykitReceivers: RefreshContactPaykitReceiversUseCase, + private val refreshContactPaykitLink: RefreshContactPaykitLinkUseCase, savedStateHandle: SavedStateHandle, ) : ViewModel() { @@ -96,7 +96,7 @@ class AddContactViewModel @Inject constructor( ) } if (error == PubkyContactError.AlreadyExists) { - refreshContactPaykitReceivers(publicKey) + refreshContactPaykitLink(publicKey) } } } diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsViewModel.kt index d6daf9db82..c62b3de2ca 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsViewModel.kt @@ -16,13 +16,13 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import to.bitkit.models.PubkyProfile import to.bitkit.repositories.PubkyRepo -import to.bitkit.usecases.RefreshContactPaykitReceiversUseCase +import to.bitkit.usecases.RefreshContactPaykitLinkUseCase import javax.inject.Inject @HiltViewModel class ContactsViewModel @Inject constructor( private val pubkyRepo: PubkyRepo, - private val refreshContactPaykitReceivers: RefreshContactPaykitReceiversUseCase, + private val refreshContactPaykitLink: RefreshContactPaykitLinkUseCase, ) : ViewModel() { private val _searchText = MutableStateFlow("") @@ -75,7 +75,7 @@ class ContactsViewModel @Inject constructor( } fun refreshExistingContact(publicKey: String) { - viewModelScope.launch { refreshContactPaykitReceivers(publicKey) } + viewModelScope.launch { refreshContactPaykitLink(publicKey) } } } diff --git a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt index 1bb53ef3b7..ab04f43d03 100644 --- a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt @@ -48,6 +48,7 @@ import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.repositories.PaykitPaymentRequest import to.bitkit.repositories.PaykitPaymentRequestDeliveryStatus +import to.bitkit.repositories.PaykitPaymentRequestDirection import to.bitkit.repositories.PaykitPaymentRequestDraft import to.bitkit.repositories.PaykitPaymentRequestTarget import to.bitkit.ui.LocalCurrencies @@ -481,7 +482,7 @@ internal fun PaymentRequestRecipientContent( } items( items = recipients, - key = { (target, _) -> "${target.publicKey}|${target.receiverPath}" }, + key = { (target, _) -> target.publicKey }, ) { (target, contact) -> PubkyContactRow( profile = contact, @@ -505,17 +506,27 @@ fun PaymentRequestSentScreen( onDone: () -> Unit, ) { val contacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() + val history by appViewModel.paymentRequestHistory.collectAsStateWithLifecycle() val contact = contacts.firstOrNull { PubkyPublicKeyFormat.matches(it.publicKey, request.counterparty) } - PaymentRequestSentContent(request = request, contact = contact, onDone = onDone) + PaymentRequestSentContent( + request = request, + history = history.toImmutableList(), + contact = contact, + onDone = onDone, + ) } @Composable internal fun PaymentRequestSentContent( modifier: Modifier = Modifier, request: PaykitPaymentRequest, + history: ImmutableList, contact: PubkyProfile?, onDone: () -> Unit, ) { + val currentRequest = history.firstOrNull { + it.id == request.id && it.direction == PaykitPaymentRequestDirection.Outgoing + } ?: request Column( horizontalAlignment = Alignment.Start, modifier = modifier @@ -543,7 +554,7 @@ internal fun PaymentRequestSentContent( VerticalSpacer(12.dp) BodyM( text = stringResource( - if (request.deliveryStatus == PaykitPaymentRequestDeliveryStatus.Sent) { + if (currentRequest.deliveryStatus == PaykitPaymentRequestDeliveryStatus.Sent) { R.string.wallet__payment_request_sent_description } else { R.string.wallet__payment_request_queued_description @@ -555,15 +566,9 @@ internal fun PaymentRequestSentContent( ) VerticalSpacer(24.dp) PaymentRequestCard( - request = request, + request = currentRequest, contact = contact, - compactSubtitle = request.note?.takeIf(String::isNotBlank) ?: if ( - request.deliveryStatus == PaykitPaymentRequestDeliveryStatus.Sent - ) { - stringResource(R.string.wallet__payment_request_waiting) - } else { - stringResource(R.string.wallet__payment_request_sending) - }, + compactSubtitle = currentRequest.note?.takeIf(String::isNotBlank) ?: paymentRequestStatus(currentRequest), ) VerticalSpacer(32.dp) PrimaryButton( @@ -592,19 +597,18 @@ private val previewDraft = PaykitPaymentRequestDraft( private val previewTarget = PaykitPaymentRequestTarget( publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", - receiverPath = "bitkit/wallet", ) private val previewCreatedRequest = PaykitPaymentRequest( paymentRequestId = "payment-request", counterparty = previewTarget.publicKey, - counterpartyReceiverPath = previewTarget.receiverPath, amountValue = "0.00025", amountSats = previewDraft.amountSats, note = previewDraft.note, createdAt = Instant.parse("2027-01-15T08:00:00Z"), expiresAt = previewDraft.expiresAt, acceptedPaymentEndpointIdentifiers = listOf("btc-lightning-bolt11"), + direction = PaykitPaymentRequestDirection.Outgoing, ) @Preview(showSystemUi = true) @@ -649,6 +653,7 @@ private fun PaymentRequestSentPreview() { BottomSheetPreview { PaymentRequestSentContent( request = previewCreatedRequest, + history = persistentListOf(), contact = PubkyProfile.placeholder(previewTarget.publicKey), onDone = {}, modifier = Modifier.sheetHeight(), diff --git a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/IncomingPaymentRequestDetailsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/IncomingPaymentRequestDetailsScreen.kt index fc6ae324d3..172131cc33 100644 --- a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/IncomingPaymentRequestDetailsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/IncomingPaymentRequestDetailsScreen.kt @@ -79,6 +79,7 @@ fun IncomingPaymentRequestDetailsScreen( val pending by appViewModel.pendingPaymentRequests.collectAsStateWithLifecycle() val history by appViewModel.paymentRequestHistory.collectAsStateWithLifecycle() val contacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() + val requestedPaymentRequestId by appViewModel.requestedPaymentRequestId.collectAsStateWithLifecycle() val request = pending.firstOrNull { it.id == id } ?: history.firstOrNull { it.id == id } val contact = request?.let { paymentRequest -> contacts.firstOrNull { PubkyPublicKeyFormat.matches(it.publicKey, paymentRequest.counterparty) } @@ -90,6 +91,7 @@ fun IncomingPaymentRequestDetailsScreen( request = request, contact = contact, isPending = isPending, + isPreparing = requestedPaymentRequestId == id, onBack = onBack, onPay = { appViewModel.openIncomingPaymentRequestWithTags(id, it) }, onDismiss = request?.let { { appViewModel.dismissIncomingPaymentRequest(it) } }, @@ -97,10 +99,11 @@ fun IncomingPaymentRequestDetailsScreen( } @Composable -private fun IncomingPaymentRequestDetailsContent( +internal fun IncomingPaymentRequestDetailsContent( request: PaykitPaymentRequest?, contact: PubkyProfile?, isPending: Boolean, + isPreparing: Boolean, onBack: () -> Unit, onPay: (List) -> Unit, onDismiss: (suspend () -> Result)?, @@ -250,6 +253,7 @@ private fun IncomingPaymentRequestDetailsContent( PrimaryButton( text = stringResource(R.string.wallet__payment_request_pay), enabled = !isDismissing, + isLoading = isPreparing, onClick = { onPay(selectedTags) }, icon = { Icon( @@ -258,7 +262,9 @@ private fun IncomingPaymentRequestDetailsContent( modifier = Modifier.size(16.dp) ) }, - modifier = Modifier.weight(1f) + modifier = Modifier + .weight(1f) + .testTag("PaymentRequestDetailsPay") ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt index b5a744f931..90a5523e75 100644 --- a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt @@ -665,7 +665,7 @@ private fun List.nameFor(request: PaykitPaymentRequest): Str } private val PaykitPaymentRequest.lazyListKey: String - get() = "$paymentRequestId|$counterparty|$counterpartyReceiverPath|${billingPeriod?.startsAt ?: ""}" + get() = "$paymentRequestId|$counterparty|${billingPeriod?.startsAt ?: ""}" internal val PaykitPaymentRequest.paymentRailIconColor get() = if (paymentProofKind == PaykitPaymentProofKind.Lightning) Colors.Purple else Colors.Brand @@ -692,7 +692,6 @@ private fun PaymentRailIcon(request: PaykitPaymentRequest, paymentWasSent: Boole private val previewRequest = PaykitPaymentRequest( paymentRequestId = "payment-request", counterparty = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", - counterpartyReceiverPath = "bitkit/wallet", amountValue = "0.00025", amountSats = 25_000uL, note = "Dinner", diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileViewModel.kt index eab101886a..4c5abbe74f 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileViewModel.kt @@ -268,6 +268,7 @@ class EditProfileViewModel @Inject constructor( ) } try { + privatePaykitRepo.beginProfileDeletion() privatePaykitRepo.removePublishedEndpointsForCleanup(TAG) val result = pubkyRepo.deleteProfileWithSessionRetry() if (result.isSuccess) { @@ -283,6 +284,7 @@ class EditProfileViewModel @Inject constructor( _uiState.update { it.copy(showDeleteFailureDialog = true) } } } finally { + withContext(NonCancellable) { privatePaykitRepo.endProfileDeletion() } _uiState.update { it.copy(isDeleting = false) } } } diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalSheet.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalSheet.kt index 42bffae193..9d8c31c4eb 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalSheet.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalSheet.kt @@ -11,8 +11,10 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.runtime.Composable @@ -38,6 +40,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import to.bitkit.R import to.bitkit.models.PubkyAuthClaim +import to.bitkit.models.PubkyAuthClaim.Item import to.bitkit.models.PubkyAuthPermission import to.bitkit.models.PubkyProfile import to.bitkit.ui.appViewModel @@ -265,7 +268,7 @@ private fun approvalBackAction( onCancel: () -> Unit, onDismiss: () -> Unit, ): (() -> Unit)? = when (approvalState) { - ApprovalState.Authorize if bitkitClaim == PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1 -> onBackToWatchOnly + ApprovalState.Authorize if bitkitClaim?.includesWatchOnlyAccount == true -> onBackToWatchOnly ApprovalState.Authorize, ApprovalState.Authenticating, ApprovalState.Authorizing -> onCancel ApprovalState.Success -> onDismiss else -> null @@ -384,7 +387,7 @@ private fun ColumnScope.AuthorizingContent( private fun ColumnScope.ApprovalDetails( uiState: PubkyAuthApprovalUiState, ) { - Column(modifier = Modifier.weight(1f)) { + Column(modifier = Modifier.weight(1f).verticalScroll(rememberScrollState())) { VerticalSpacer(26.dp) if (uiState.homeserverPublicKey != null) { @@ -410,7 +413,11 @@ private fun ColumnScope.ApprovalDetails( if (uiState.permissions.isNotEmpty()) { PermissionsSection(permissions = uiState.permissions) } - FillHeight(min = 32.dp) + if (uiState.bitkitClaim?.includesPaykitAccess == true) { + VerticalSpacer(16.dp) + PaykitAccessSection() + } + VerticalSpacer(32.dp) TrustWarning() VerticalSpacer(16.dp) @@ -432,6 +439,15 @@ private fun ColumnScope.ApprovalDetails( } } +@Composable +private fun PaykitAccessSection() { + Column(modifier = Modifier.testTag("PubkyAuthPaykitAccess")) { + BodyMSB(text = stringResource(R.string.profile__auth_approval_paykit_access_title)) + VerticalSpacer(8.dp) + BodyM(text = stringResource(R.string.profile__auth_approval_paykit_access_description), color = Colors.White64) + } +} + @Composable private fun ColumnScope.SuccessContent( uiState: PubkyAuthApprovalUiState, @@ -592,7 +608,7 @@ private fun WatchOnlyConsentPreview() { uiState = PubkyAuthApprovalUiState( state = ApprovalState.WatchOnlyConsent, serviceName = "paykit", - bitkitClaim = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1, + bitkitClaim = PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1), ), isCurrentRequest = true, onAuthorize = {}, @@ -619,7 +635,7 @@ private fun AuthorizePreview() { PubkyAuthPermission(path = "/pub/pubky.app/", accessLevel = "rw"), PubkyAuthPermission(path = "/pub/paykit/v0/", accessLevel = "rw"), ), - bitkitClaim = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1, + bitkitClaim = PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1), profile = PubkyProfile( publicKey = "pk8e3qm5f4kgczagxhertyuiop1gxag", name = "Satoshi Nakamoto", diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModel.kt index 9d6eeb3157..0cb1229438 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModel.kt @@ -21,6 +21,7 @@ import to.bitkit.ext.runSuspendCatching import to.bitkit.models.PubkyAuthClaim import to.bitkit.models.PubkyAuthPermission import to.bitkit.models.PubkyAuthRequest +import to.bitkit.models.PubkyAuthRequestError import to.bitkit.models.PubkyProfile import to.bitkit.models.Toast import to.bitkit.models.WatchOnlyAccountSetupState @@ -76,16 +77,9 @@ class PubkyAuthApprovalViewModel @Inject constructor( if (_uiState.value.authUrl != authUrl) return@launch val unknownService = context.getString(R.string.profile__auth_approval_service_unknown) val serviceName = request.serviceNames.firstOrNull() ?: unknownService - val profile = pubkyRepo.profile.value ?: pubkyRepo.publicKey.value?.let { publicKey -> - PubkyProfile.forDisplay( - publicKey = publicKey, - name = pubkyRepo.displayName.value, - imageUrl = pubkyRepo.displayImageUri.value, - ) - } _uiState.update { it.copy( - state = if (request.bitkitClaim == PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1) { + state = if (request.bitkitClaim?.includesWatchOnlyAccount == true) { ApprovalState.WatchOnlyConsent } else { ApprovalState.Authorize @@ -95,7 +89,7 @@ class PubkyAuthApprovalViewModel @Inject constructor( serviceName = serviceName, permissions = request.permissions.toImmutableList(), bitkitClaim = request.bitkitClaim, - profile = profile, + profile = approvalProfile(), ) } } @@ -125,7 +119,7 @@ class PubkyAuthApprovalViewModel @Inject constructor( if ( state.authUrl == authUrl && state.state == ApprovalState.Authorize && - state.bitkitClaim == PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1 + state.bitkitClaim?.includesWatchOnlyAccount == true ) { state.copy(state = ApprovalState.WatchOnlyConsent) } else { @@ -170,6 +164,12 @@ class PubkyAuthApprovalViewModel @Inject constructor( } val approvalState = _uiState.value if (approvalState.authUrl != authUrl) return + if (request.bitkitClaim != approvalState.bitkitClaim || request.clientId != approvalState.clientId || + request.permissions != approvalState.permissions + ) { + handleApprovalFailure(PubkyAuthRequestError.RequesterChanged, authUrl) + return + } if (!approveRequest(request, authUrl)) return Logger.info("Auth approved for '${request.serviceNames.firstOrNull().orEmpty()}'", context = TAG) @@ -202,7 +202,7 @@ class PubkyAuthApprovalViewModel @Inject constructor( authUrl: String, ): Boolean { val preparedClaim = runSuspendCatching { - if (request.bitkitClaim == PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1) { + if (request.bitkitClaim?.includesWatchOnlyAccount == true) { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, defaultWatchOnlyAccountName(request)) } else { null @@ -226,9 +226,11 @@ class PubkyAuthApprovalViewModel @Inject constructor( } } - val approvalResult = preparedClaim?.let { - pubkyRepo.approveAuthWithCompanionClaim(authUrl, request.clientId, it.payload) - } ?: pubkyRepo.approveAuth(authUrl, request.capabilities, request.clientId) + val approvalResult = if (request.bitkitClaim != null) { + pubkyRepo.approveAuthWithCompanionClaim(authUrl, request.clientId, preparedClaim?.payload ?: byteArrayOf()) + } else { + pubkyRepo.approveAuth(authUrl, request.capabilities, request.clientId) + } if (approvalResult.isFailure) { val approvalError = checkNotNull(approvalResult.exceptionOrNull()) { "Authorization failed" } preparedClaim?.let { claim -> @@ -320,6 +322,14 @@ class PubkyAuthApprovalViewModel @Inject constructor( return context.getString(R.string.profile__auth_approval_watch_only_account_default_name, serviceName) } + private fun approvalProfile() = pubkyRepo.profile.value ?: pubkyRepo.publicKey.value?.let { publicKey -> + PubkyProfile.forDisplay( + publicKey = publicKey, + name = pubkyRepo.displayName.value, + imageUrl = pubkyRepo.displayImageUri.value, + ) + } + fun dismiss() { viewModelScope.launch { _effects.emit(PubkyAuthApprovalEffect.Dismiss) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt index 1e2d7aeaca..e71d3e1bb1 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt @@ -111,8 +111,7 @@ fun ReceiveSheet( mutableStateOf( (startRoute as? ReceiveRoute.PaymentRequestAmount)?.let { val publicKey = it.publicKey ?: return@let null - val receiverPath = it.receiverPath ?: return@let null - PaykitPaymentRequestTarget(publicKey, receiverPath) + PaykitPaymentRequestTarget(publicKey) } ) } @@ -198,7 +197,7 @@ fun ReceiveSheet( composableWithDefaultTransitions { backStackEntry -> val route = backStackEntry.toRoute() val routeTarget = route.publicKey?.let { publicKey -> - route.receiverPath?.let { receiverPath -> PaykitPaymentRequestTarget(publicKey, receiverPath) } + PaykitPaymentRequestTarget(publicKey) } val contact = (routeTarget ?: selectedPaymentRequestTarget)?.let { target -> paymentRequestContacts.firstOrNull { @@ -544,7 +543,6 @@ sealed interface ReceiveRoute { @Serializable data class PaymentRequestAmount( val publicKey: String? = null, - val receiverPath: String? = null, ) : InternalOnly @Serializable diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendSignScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendSignScreen.kt index ab5855afdf..84d4b3861e 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendSignScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendSignScreen.kt @@ -38,6 +38,7 @@ import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.viewmodels.SendUiState +import kotlin.time.Instant private const val SEND_SIGN_VISUAL_TOP_RATIO = 0.54f @@ -49,6 +50,10 @@ fun HwSendSignScreen( viewModel: HwSendViewModel, prepareContactPayment: suspend () -> Boolean, authorizeContactPayment: suspend (hasAttemptedBroadcast: Boolean) -> Boolean, + onPaymentDeadlineExpired: suspend (hasAttemptedBroadcast: Boolean) -> Unit, + onPaymentSubmissionChange: (Boolean) -> Unit, + onBroadcastAttemptChange: (Boolean) -> Unit, + paymentDeadlineAt: Instant?, onBack: () -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -62,15 +67,32 @@ fun HwSendSignScreen( amountSats = sendUiState.amount, satsPerVByte = satsPerVByte, tags = sendUiState.selectedTags, + paymentDeadlineAt = paymentDeadlineAt, + paymentRequestId = sendUiState.incomingPaymentRequestId, ) val onBackRequest: () -> Unit = { if (uiState.canLeave) onBack() } + val signAndBroadcast: () -> Unit = { + viewModel.signAndBroadcast( + request, + prepareContactPayment, + authorizeContactPayment, + onPaymentDeadlineExpired, + onBroadcastAttemptChange, + ) + } LaunchedEffect(walletId) { viewModel.warmUp(walletId) } + LaunchedEffect(uiState.isSigning) { + onPaymentSubmissionChange(uiState.isSigning) + } DisposableEffect(viewModel) { - onDispose(viewModel::cancel) + onDispose { + viewModel.cancel() + onPaymentSubmissionChange(false) + } } // Without this the sheet's NavHost pops the route itself, ignoring the guard and skipping onBack BackHandler(onBack = onBackRequest) @@ -82,21 +104,14 @@ fun HwSendSignScreen( hasPendingBroadcast = uiState.hasPendingBroadcast, vendor = vendor, onBack = onBackRequest, - onOpenConnect = { - viewModel.signAndBroadcast(request, prepareContactPayment, authorizeContactPayment) - }, + onOpenConnect = signAndBroadcast, ) if (uiState.isPassphraseRequired) { HwPassphrasePromptSheet( isVerifying = uiState.isVerifyingPassphrase, onSubmit = { passphrase -> - viewModel.submitPassphrase( - request, - passphrase, - prepareContactPayment, - authorizeContactPayment, - ) + viewModel.submitPassphrase(walletId, passphrase, signAndBroadcast) }, onDismiss = viewModel::dismissPassphrase, ) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt index 942f0242fa..9d45f78a4f 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt @@ -10,6 +10,8 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -19,6 +21,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import to.bitkit.R import to.bitkit.ext.isBroadcastConnectivityFailure +import to.bitkit.ext.isDefiniteHardwarePreBroadcastFailure import to.bitkit.ext.isHwDeviceBusy import to.bitkit.ext.isHwFirmwareError import to.bitkit.ext.isHwSessionFailure @@ -34,13 +37,17 @@ import to.bitkit.repositories.HwPassphraseMismatchError import to.bitkit.repositories.HwPassphraseRequiredError import to.bitkit.repositories.HwWalletMismatchError import to.bitkit.repositories.HwWalletRepo +import to.bitkit.repositories.PaykitPaymentRequestId import to.bitkit.repositories.PreActivityMetadataRepo import to.bitkit.services.CoreService import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.HwErrorPresenter import to.bitkit.utils.Logger +import to.bitkit.utils.ServiceError import javax.inject.Inject +import kotlin.time.Clock import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant @HiltViewModel class HwSendViewModel @Inject constructor( @@ -49,6 +56,7 @@ class HwSendViewModel @Inject constructor( private val preActivityMetadataRepo: PreActivityMetadataRepo, private val coreService: CoreService, private val activityRepo: ActivityRepo, + private val clock: Clock = Clock.System, ) : ViewModel() { private companion object { const val TAG = "HwSendViewModel" @@ -80,6 +88,8 @@ class HwSendViewModel @Inject constructor( request: HwSendRequest, prepareContactPayment: suspend () -> Boolean = { true }, authorizeContactPayment: suspend (hasAttemptedBroadcast: Boolean) -> Boolean = { true }, + onPaymentDeadlineExpired: suspend (hasAttemptedBroadcast: Boolean) -> Unit = {}, + onBroadcastAttemptChanged: (Boolean) -> Unit = {}, ) { if (_uiState.value.isSigning || signingJob?.isActive == true) return if (pendingBroadcast?.matches(request) == false) return @@ -111,13 +121,13 @@ class HwSendViewModel @Inject constructor( payment = payment.copy(isPreparedForBroadcast = true) pendingBroadcast = payment } - if (!authorizeContactPayment(payment.hasAttemptedBroadcast)) return@runCatching - _uiState.update { it.copy(isBroadcastUnresolved = true) } - payment = payment.copy(hasAttemptedBroadcast = true) - pendingBroadcast = payment - val result = withTimeout(BROADCAST_TIMEOUT) { - hwWalletRepo.broadcastFunding(payment.signedTx).getOrThrow() + if (!authorizeBroadcast(payment, authorizeContactPayment, onPaymentDeadlineExpired)) { + return@runCatching } + currentCoroutineContext().ensureActive() + val result = broadcast(payment, onPaymentDeadlineExpired, onBroadcastAttemptChanged) + ?: return@runCatching + currentCoroutineContext().ensureActive() runSuspendCatching { persistResult(request, result) } .onFailure { Logger.error("Failed to persist hardware send result", it, context = TAG) } pendingResult.update { HwSendResult(request.walletId, result.txId, request.amountSats) } @@ -136,11 +146,49 @@ class HwSendViewModel @Inject constructor( } } + private suspend fun authorizeBroadcast( + payment: PendingHwSendBroadcast, + authorizeContactPayment: suspend (Boolean) -> Boolean, + onPaymentDeadlineExpired: suspend (Boolean) -> Unit, + ): Boolean { + if (payment.request.paymentDeadlineAt?.let { clock.now() > it } == true) { + onPaymentDeadlineExpired(payment.hasAttemptedBroadcast) + return false + } + return authorizeContactPayment(payment.hasAttemptedBroadcast) + } + + private suspend fun broadcast( + payment: PendingHwSendBroadcast, + onPaymentDeadlineExpired: suspend (Boolean) -> Unit, + onBroadcastAttemptChanged: (Boolean) -> Unit, + ): HwFundingBroadcastResult? { + val wasBroadcastUnresolved = _uiState.value.isBroadcastUnresolved + _uiState.update { it.copy(isBroadcastUnresolved = true) } + pendingBroadcast = payment.copy(hasAttemptedBroadcast = true) + onBroadcastAttemptChanged(true) + return withTimeout(BROADCAST_TIMEOUT) { + hwWalletRepo.broadcastFunding(payment.signedTx, payment.request.paymentDeadlineAt) + }.getOrElse { error -> + if (generateSequence(error) { it.cause }.any { it is ServiceError.PaymentDeadlineExpired }) { + pendingBroadcast = payment + onBroadcastAttemptChanged(payment.hasAttemptedBroadcast) + _uiState.update { it.copy(isBroadcastUnresolved = wasBroadcastUnresolved) } + onPaymentDeadlineExpired(payment.hasAttemptedBroadcast) + return null + } + if (error.isDefiniteHardwarePreBroadcastFailure()) { + pendingBroadcast = payment + onBroadcastAttemptChanged(payment.hasAttemptedBroadcast) + } + throw error + } + } + fun submitPassphrase( - request: HwSendRequest, + walletId: String, passphrase: String, - prepareContactPayment: suspend () -> Boolean = { true }, - authorizeContactPayment: suspend (hasAttemptedBroadcast: Boolean) -> Boolean = { true }, + onVerified: () -> Unit, ) { if (passphrase.isEmpty()) return val state = _uiState.value @@ -151,11 +199,11 @@ class HwSendViewModel @Inject constructor( _uiState.update { it.copy(isVerifyingPassphrase = true) } passphraseJob = viewModelScope.launch { try { - hwWalletRepo.reconnectWithPassphrase(request.walletId, passphrase) + hwWalletRepo.reconnectWithPassphrase(walletId, passphrase) .onSuccess { if (!_uiState.value.isPassphraseRequired) return@onSuccess _uiState.update { it.copy(isPassphraseRequired = false) } - signAndBroadcast(request, prepareContactPayment, authorizeContactPayment) + onVerified() } .onFailure { error -> if (error is HwPassphraseMismatchError) { @@ -165,7 +213,7 @@ class HwSendViewModel @Inject constructor( description = context.getString(R.string.hardware__passphrase_mismatch), ) } else { - handleFailure(error, request.walletId) + handleFailure(error, walletId) } } } finally { @@ -209,6 +257,37 @@ class HwSendViewModel @Inject constructor( } } + fun resolveBroadcast(walletId: String, requestId: PaykitPaymentRequestId, transactionId: String): Boolean { + val pending = pendingBroadcast ?: return false + if (!pending.hasAttemptedBroadcast) return false + if (pending.request.walletId != walletId || pending.request.paymentRequestId != requestId) return false + if (pendingResult.value != null) return true + + // Reconciliation has confirmed payment. Stop retries and use the normal completion path. + signingJob?.cancel() + val attempt = ++signingAttempt + _uiState.update { it.copy(isSigning = true) } + signingJob = viewModelScope.launch { + try { + val result = HwFundingBroadcastResult( + txId = transactionId, + miningFeeSats = pending.signedTx.miningFeeSats, + feeRate = pending.signedTx.feeRate, + totalSpent = pending.signedTx.totalSpent, + ) + runSuspendCatching { persistResult(pending.request, result) } + .onFailure { Logger.error("Failed to persist hardware send result", it, context = TAG) } + pendingResult.update { HwSendResult(walletId, transactionId, pending.request.amountSats) } + } finally { + if (signingAttempt == attempt) { + _uiState.update { it.copy(isSigning = false, isConnectingDevice = false) } + signingJob = null + } + } + } + return true + } + private suspend fun prepareSignedTransaction( walletId: String, address: String, @@ -329,7 +408,7 @@ class HwSendViewModel @Inject constructor( description = context.getString(R.string.wallet__payment_timeout), ) else -> { - if (pendingBroadcast != null) { + if (pendingBroadcast?.hasAttemptedBroadcast != true) { pendingBroadcast = null _uiState.update { it.copy(hasPendingBroadcast = false) } } @@ -376,6 +455,8 @@ data class HwSendRequest( val amountSats: ULong, val satsPerVByte: ULong, val tags: List, + val paymentDeadlineAt: Instant? = null, + val paymentRequestId: PaykitPaymentRequestId? = null, ) private data class PendingHwSendBroadcast( diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt index 44a83033ff..2753505fad 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt @@ -46,6 +46,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.disabled import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.text.style.TextOverflow @@ -67,6 +68,7 @@ import to.bitkit.ext.formatInvoiceExpiryRelative import to.bitkit.models.FeeRate import to.bitkit.models.PubkyProfile import to.bitkit.models.TransactionSpeed +import to.bitkit.repositories.PaykitPaymentRequest import to.bitkit.ui.components.AddTagButton import to.bitkit.ui.components.BalanceHeaderView import to.bitkit.ui.components.BiometricsView @@ -211,12 +213,15 @@ fun SendConfirmScreen( } @Composable +@Suppress("CyclomaticComplexMethod") internal fun SendConfirmContent( uiState: SendUiState, isNodeRunning: Boolean, isLoading: Boolean, showBiometrics: Boolean, modifier: Modifier = Modifier, + preparingRequest: PaykitPaymentRequest? = null, + preparingContact: PubkyProfile? = null, canGoBack: Boolean = true, initialShowDetails: Boolean = false, onBack: () -> Unit = {}, @@ -227,9 +232,12 @@ internal fun SendConfirmContent( onBiometricsSuccess: () -> Unit = {}, onBiometricsFailure: () -> Unit = {}, ) { + val isPreparing = preparingRequest != null + val isAutomaticPaymentLoading = uiState.shouldAutomaticallyPay && + (uiState.initialSubscriptionPaymentAutoStartPending || isLoading) Box( modifier = modifier.testTag( - if (uiState.isPaymentRequest) "PaymentRequestConfirm" else "SendConfirm", + if (isPreparing || uiState.isPaymentRequest) "PaymentRequestConfirm" else "SendConfirm", ) ) { Column( @@ -242,26 +250,29 @@ internal fun SendConfirmContent( SendContactTopBar( titleText = when { + isPreparing -> stringResource(R.string.wallet__payment_request) uiState.isInitialSubscriptionPayment -> stringResource(R.string.subscriptions__review_and_subscribe) uiState.isSubscriptionPayment -> stringResource(R.string.subscriptions__subscription) uiState.isPaymentRequest -> stringResource(R.string.wallet__payment_request) isLnurlPay -> stringResource(R.string.wallet__lnurl_p_title) else -> stringResource(R.string.wallet__send_review) }, - contact = uiState.contactPaymentProfile, + contact = if (isPreparing) preparingContact else uiState.contactPaymentProfile, onBack = onBack.takeIf { canGoBack }, ) Spacer(Modifier.height(16.dp)) - if (uiState.shouldAutomaticallyPay && (uiState.initialSubscriptionPaymentAutoStartPending || isLoading)) { + if (!isPreparing && isAutomaticPaymentLoading) { FillHeight() GradientCircularProgressIndicator(modifier = Modifier.size(32.dp).align(Alignment.CenterHorizontally)) FillHeight() - } else if (isNodeRunning) { + } else if (isPreparing || isNodeRunning) { ContentRunning( uiState = uiState, isLoading = isLoading, + preparingRequest = preparingRequest, + preparingContact = preparingContact, initialShowDetails = initialShowDetails, onEvent = onEvent, onClickAddTag = onClickAddTag, @@ -278,14 +289,14 @@ internal fun SendConfirmContent( } } - if (showBiometrics) { + if (showBiometrics && !isPreparing) { BiometricsView( onSuccess = onBiometricsSuccess, onFailure = onBiometricsFailure, ) } - uiState.showSanityWarningDialog?.let { dialog -> + uiState.showSanityWarningDialog?.takeUnless { isPreparing }?.let { dialog -> AppAlertDialog( title = stringResource(R.string.common__are_you_sure), text = stringResource(dialog.message), @@ -314,6 +325,8 @@ private fun ContentRunning( uiState: SendUiState, isLoading: Boolean, modifier: Modifier = Modifier, + preparingRequest: PaykitPaymentRequest? = null, + preparingContact: PubkyProfile? = null, initialShowDetails: Boolean = false, onEvent: (SendEvent) -> Unit = {}, onClickAddTag: () -> Unit = {}, @@ -322,12 +335,14 @@ private fun ContentRunning( ) { var showDetails by rememberSaveable { mutableStateOf(initialShowDetails) } val swipeProgress = remember { mutableFloatStateOf(0f) } - val isLnurlPay = uiState.lnurl is LnurlParams.LnurlPay + val isPreparing = preparingRequest != null + val isLnurlPay = !isPreparing && uiState.lnurl is LnurlParams.LnurlPay val isHardwareFeeLoading = uiState.hardwareWalletId != null && uiState.onchainFeeUi.isLoading - val accentColor = when (uiState.payMethod) { - SendMethod.ONCHAIN -> Colors.Brand - SendMethod.LIGHTNING -> Colors.Purple + val accentColor = when { + isPreparing -> Colors.Brand + uiState.payMethod == SendMethod.LIGHTNING -> Colors.Purple + else -> Colors.Brand } Column( @@ -336,9 +351,9 @@ private fun ContentRunning( .fillMaxSize() ) { BalanceHeaderView( - sats = uiState.amount.toLong(), + sats = (preparingRequest?.amountSats ?: uiState.amount).toLong(), useSwipeToHide = false, - onClick = { onEvent(SendEvent.BackToAmount) }, + onClick = { onEvent(SendEvent.BackToAmount) }.takeUnless { isPreparing }, testTag = "ReviewAmount", modifier = Modifier .fillMaxWidth() @@ -357,11 +372,11 @@ private fun ContentRunning( .verticalScroll(rememberScrollState()) .heightIn(min = maxHeight) ) { - VerticalSpacer(if (uiState.isOneOffPaymentRequest && !isLnurlPay) 24.dp else 44.dp) + VerticalSpacer(if (isPreparing || uiState.isOneOffPaymentRequest && !isLnurlPay) 24.dp else 44.dp) if (isLnurlPay) { LnurlPayDetails(uiState = uiState, onEvent = onEvent) - } else if (showDetails) { + } else if (showDetails && !isPreparing) { when (uiState.payMethod) { SendMethod.ONCHAIN -> { OnChainDetails( @@ -387,8 +402,12 @@ private fun ContentRunning( } } } else { - if (uiState.isOneOffPaymentRequest) { - PaymentRequestSummary(uiState = uiState, iconColor = accentColor) + if (isPreparing || uiState.isOneOffPaymentRequest) { + PaymentRequestSummary( + profile = if (isPreparing) preparingContact else uiState.contactPaymentProfile, + note = if (isPreparing) preparingRequest.note else uiState.oneOffPaymentRequestNote, + iconColor = accentColor, + ) VerticalSpacer(16.dp) } Image( @@ -406,7 +425,7 @@ private fun ContentRunning( } } - if (!isLnurlPay) { + if (!isLnurlPay && !isPreparing) { PrimaryButton( text = stringResource( if (showDetails) R.string.common__hide_details else R.string.common__show_details @@ -443,20 +462,25 @@ private fun ContentRunning( SwipeToConfirm( text = stringResource( - if (uiState.isInitialSubscriptionPayment) { + if (!isPreparing && uiState.isInitialSubscriptionPayment) { R.string.subscriptions__swipe_to_subscribe_and_pay } else { R.string.wallet__send_swipe } ), color = accentColor, - enabled = uiState.isAmountInputValid && + enabled = !isPreparing && uiState.isAmountInputValid && !uiState.isFundingSourceLoading && !isHardwareFeeLoading, - loading = isLoading, - confirmed = isLoading, + loading = isPreparing || isLoading, + confirmed = !isPreparing && isLoading, progress = swipeProgress, onConfirm = onSwipeToConfirm, + modifier = if (isPreparing) { + Modifier.semantics { disabled() }.testTag("PaymentRequestPreparing") + } else { + Modifier + } ) VerticalSpacer(16.dp) } @@ -840,12 +864,12 @@ private fun ContactRecipient( @Composable private fun PaymentRequestSummary( - uiState: SendUiState, + profile: PubkyProfile?, + note: String?, iconColor: Color, modifier: Modifier = Modifier, ) { - val profile = uiState.contactPaymentProfile ?: return - val note = uiState.oneOffPaymentRequestNote + if (profile == null) return val noteColor = if (note != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondary Row( diff --git a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index 416beb4907..abc9b961fc 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -43,8 +43,11 @@ import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NewTransactionSheetDirection import to.bitkit.models.NewTransactionSheetType import to.bitkit.models.NodeLifecycleState +import to.bitkit.models.PubkyProfile +import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.SendFailureDetails import to.bitkit.repositories.ConnectivityState +import to.bitkit.repositories.PaykitPaymentRequest import to.bitkit.ui.components.ConnectionIssuesView import to.bitkit.ui.components.SyncNodeView import to.bitkit.ui.navigateTo @@ -58,6 +61,7 @@ import to.bitkit.ui.screens.wallets.send.PIN_CHECK_RESULT_KEY import to.bitkit.ui.screens.wallets.send.SendAddressScreen import to.bitkit.ui.screens.wallets.send.SendAmountScreen import to.bitkit.ui.screens.wallets.send.SendCoinSelectionScreen +import to.bitkit.ui.screens.wallets.send.SendConfirmContent import to.bitkit.ui.screens.wallets.send.SendConfirmScreen import to.bitkit.ui.screens.wallets.send.SendContactSelectScreen import to.bitkit.ui.screens.wallets.send.SendContactSelectViewModel @@ -100,15 +104,18 @@ fun SendSheet( hwSendViewModel: HwSendViewModel, startDestination: SendRoute = SendRoute.Recipient, hardwareWalletId: String? = null, + preparingRequest: PaykitPaymentRequest? = null, ) { val context = LocalContext.current val connectivityState by appViewModel.isOnline.collectAsStateWithLifecycle() val isOffline by remember { derivedStateOf { connectivityState != ConnectivityState.CONNECTED } } val lightningState by walletViewModel.lightningState.collectAsStateWithLifecycle() val sendUiState by appViewModel.sendUiState.collectAsStateWithLifecycle() + val contacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() var routingCacheResetAttempted by rememberSaveable(startDestination) { mutableStateOf(false) } val shouldShowSyncOverlay = run { + if (preparingRequest != null) return@run false if (sendUiState.hardwareWalletId != null) return@run false if (!lightningState.nodeLifecycleState.isRunning()) return@run true val hasAnyChannels = lightningState.channels.isNotEmpty() @@ -147,6 +154,14 @@ fun SendSheet( .testTag("SendSheet"), ) { val navController = rememberNavController() + LaunchedEffect(hwSendViewModel, sendUiState.resolvedHardwarePaymentTxId) { + val transactionId = sendUiState.resolvedHardwarePaymentTxId ?: return@LaunchedEffect + val walletId = sendUiState.hardwareWalletId ?: return@LaunchedEffect + val requestId = sendUiState.incomingPaymentRequestId ?: return@LaunchedEffect + if (!hwSendViewModel.resolveBroadcast(walletId, requestId, transactionId)) { + appViewModel.acknowledgeHardwarePaymentResolution(transactionId) + } + } LaunchedEffect(hwSendViewModel, navController) { hwSendViewModel.results.collect { result -> appViewModel.completeHardwareContactPayment(result.txId) @@ -302,6 +317,20 @@ fun SendSheet( } } composableWithDefaultTransitions { + if (preparingRequest != null) { + SendConfirmContent( + uiState = SendUiState(), + isNodeRunning = false, + isLoading = false, + showBiometrics = false, + preparingRequest = preparingRequest, + preparingContact = contacts.firstOrNull { + PubkyPublicKeyFormat.matches(it.publicKey, preparingRequest.counterparty) + } ?: PubkyProfile.placeholder(preparingRequest.counterparty), + canGoBack = false, + ) + return@composableWithDefaultTransitions + } val uiState by appViewModel.sendUiState.collectAsStateWithLifecycle() val lightningState by walletViewModel.lightningState.collectAsStateWithLifecycle() @@ -342,6 +371,10 @@ fun SendSheet( viewModel = hwSendViewModel, prepareContactPayment = appViewModel::prepareHardwareContactPayment, authorizeContactPayment = appViewModel::authorizeHardwareContactPayment, + onPaymentDeadlineExpired = appViewModel::onHardwarePaymentDeadlineExpired, + onPaymentSubmissionChange = appViewModel::onHardwarePaymentSubmissionChanged, + onBroadcastAttemptChange = appViewModel::onHardwareBroadcastAttemptChanged, + paymentDeadlineAt = appViewModel.hardwarePaymentDeadlineAt, onBack = { navController.previousBackStackEntry ?.savedStateHandle @@ -593,7 +626,7 @@ fun SendSheet( } AnimatedVisibility( - visible = isOffline, + visible = isOffline && preparingRequest == null, enter = fadeIn(), exit = fadeOut(), ) { diff --git a/app/src/main/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCase.kt b/app/src/main/java/to/bitkit/usecases/RefreshContactPaykitLinkUseCase.kt similarity index 71% rename from app/src/main/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCase.kt rename to app/src/main/java/to/bitkit/usecases/RefreshContactPaykitLinkUseCase.kt index be69e7623f..e85ff5460e 100644 --- a/app/src/main/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/RefreshContactPaykitLinkUseCase.kt @@ -10,24 +10,22 @@ import to.bitkit.repositories.PubkyRepo import to.bitkit.utils.Logger import javax.inject.Inject -class RefreshContactPaykitReceiversUseCase @Inject constructor( +class RefreshContactPaykitLinkUseCase @Inject constructor( @IoDispatcher private val ioDispatcher: CoroutineDispatcher, private val pubkyRepo: PubkyRepo, private val privatePaykitRepo: PrivatePaykitRepo, ) { companion object { - private const val TAG = "RefreshContactPaykitReceiversUseCase" + private const val TAG = "RefreshContactPaykitLinkUseCase" } suspend operator fun invoke(publicKey: String): Result = withContext(ioDispatcher) { runSuspendCatching { - pubkyRepo.refreshContactReceiverPaths(publicKey).getOrThrow() val savedPublicKeys = (pubkyRepo.contacts.value.map { it.publicKey } + publicKey).distinct() privatePaykitRepo.refreshSavedContactEndpoints(publicKey, savedPublicKeys).getOrThrow() - privatePaykitRepo.startInitialLinkBurst(savedPublicKeys, "contact receiver refresh") }.onFailure { Logger.warn( - "Failed to refresh Paykit receivers for '${PubkyPublicKeyFormat.redacted(publicKey)}'", + "Failed to refresh the Paykit link for '${PubkyPublicKeyFormat.redacted(publicKey)}'", it, context = TAG, ) diff --git a/app/src/main/java/to/bitkit/utils/Crypto.kt b/app/src/main/java/to/bitkit/utils/Crypto.kt index 22b59d90f8..6cd53121fe 100644 --- a/app/src/main/java/to/bitkit/utils/Crypto.kt +++ b/app/src/main/java/to/bitkit/utils/Crypto.kt @@ -78,13 +78,16 @@ class Crypto @Inject constructor() { private val params = ECNamedCurveTable.getParameterSpec("secp256k1") private val transformation = "AES/GCM/NoPadding" - init { - installSecurityProvider() + // Preserve Android's registered providers for concurrent TLS initialization. + private val provider = try { + BouncyCastleProvider() + } catch (_: Exception) { + throw CryptoError.SecurityProviderSetupFailed() } fun generateKeyPair(): KeyPair { try { - val (privateKey, publicKey) = KeyPairGenerator.getInstance("EC", "BC").run { + val (privateKey, publicKey) = KeyPairGenerator.getInstance("EC", provider).run { initialize(params) val keys = generateKeyPair() val private = (keys.private as BCECPrivateKey).run { BigIntegers.asUnsignedByteArray(32, d) } @@ -107,14 +110,14 @@ class Crypto @Inject constructor() { derivationName: String? = null, ): ByteArray { try { - val keyFactory = KeyFactory.getInstance("EC", "BC") + val keyFactory = KeyFactory.getInstance("EC", provider) val privateKey = keyFactory.generatePrivate(ECPrivateKeySpec(BigInteger(1, privateKeyBytes), params)) val publicKey = let { val publicKeyPoint = params.curve.decodePoint(nodePubkey.fromHex()) keyFactory.generatePublic(ECPublicKeySpec(publicKeyPoint, params)) } - val baseSecret = KeyAgreement.getInstance("ECDH", "BC").run { + val baseSecret = KeyAgreement.getInstance("ECDH", provider).run { // init(privateKey); doPhase(publicKey, true); generateSecret() val sharedPoint = (publicKey as ECPublicKey).q.multiply((privateKey as ECPrivateKey).d) sharedPoint.getEncoded(true) @@ -136,7 +139,7 @@ class Crypto @Inject constructor() { require(secretKey.size == 32) { "Key must be 256 bits (32 bytes) for AES-256-GCM" } val key = SecretKeySpec(secretKey, "AES") - val cipher = Cipher.getInstance(transformation).apply { init(Cipher.ENCRYPT_MODE, key) } + val cipher = Cipher.getInstance(transformation, provider).apply { init(Cipher.ENCRYPT_MODE, key) } val result = cipher.doFinal(blob) return EncryptedPayload( @@ -152,7 +155,7 @@ class Crypto @Inject constructor() { val key = SecretKeySpec(secretKey, "AES") val spec = GCMParameterSpec(128, encryptedPayload.iv) - val cipher = Cipher.getInstance(transformation).apply { init(Cipher.DECRYPT_MODE, key, spec) } + val cipher = Cipher.getInstance(transformation, provider).apply { init(Cipher.DECRYPT_MODE, key, spec) } return cipher.doFinal(encryptedPayload.cipher + encryptedPayload.tag) } catch (e: Exception) { @@ -191,7 +194,7 @@ class Crypto @Inject constructor() { }.getOrElse { throw CryptoError.SigningFailed() } fun getPublicKey(privateKey: ByteArray): ByteArray = runCatching { - val keyFactory = KeyFactory.getInstance("EC", "BC") + val keyFactory = KeyFactory.getInstance("EC", provider) val privateKeySpec = ECPrivateKeySpec(BigInteger(1, privateKey), params) val privateKeyObj = keyFactory.generatePrivate(privateKeySpec) diff --git a/app/src/main/java/to/bitkit/utils/Errors.kt b/app/src/main/java/to/bitkit/utils/Errors.kt index 53ac5cf420..9aa0542d2d 100644 --- a/app/src/main/java/to/bitkit/utils/Errors.kt +++ b/app/src/main/java/to/bitkit/utils/Errors.kt @@ -15,6 +15,7 @@ open class AppError( sealed class ServiceError(message: String) : AppError(message) { class NodeNotSetup : ServiceError("Node is not setup") class NodeNotStarted : ServiceError("Node is not started") + class PaymentDeadlineExpired : ServiceError("Payment deadline has expired") class MnemonicNotFound : ServiceError("Mnemonic not found") class VssAuthRequired : ServiceError("VSS requires LNURL-auth") class NodeStillRunning : ServiceError("Node is still running") diff --git a/app/src/main/java/to/bitkit/utils/SubscriptionClockOffset.kt b/app/src/main/java/to/bitkit/utils/SubscriptionClockOffset.kt index 904489ea73..21812272b2 100644 --- a/app/src/main/java/to/bitkit/utils/SubscriptionClockOffset.kt +++ b/app/src/main/java/to/bitkit/utils/SubscriptionClockOffset.kt @@ -79,7 +79,7 @@ class SubscriptionClockOffsetSync @Inject constructor( settingsStore.subscriptionClockOffsetDays.distinctUntilChanged().collect { SubscriptionClockOffset.setOffsetDays(it) if (it != 0) Logger.info("Set the subscription clock offset to '$it' days", context = TAG) - if (!isInitialOffset) paykitPaymentRequestRepo.get().refresh() + if (!isInitialOffset) paykitPaymentRequestRepo.get().refreshAfterStateChange() isInitialOffset = false } } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 5ea1805dd8..ce1f7ef551 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -43,7 +43,9 @@ import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -56,6 +58,7 @@ import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn @@ -145,6 +148,7 @@ import to.bitkit.repositories.BackupRepo import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.ConnectivityRepo import to.bitkit.repositories.ConnectivityState +import to.bitkit.repositories.ContactPaymentSettingsRepo import to.bitkit.repositories.CurrencyRepo import to.bitkit.repositories.HealthRepo import to.bitkit.repositories.HwWalletRepo @@ -162,6 +166,7 @@ import to.bitkit.repositories.PaykitPaymentRequestDiagnostics import to.bitkit.repositories.PaykitPaymentRequestDraft import to.bitkit.repositories.PaykitPaymentRequestError import to.bitkit.repositories.PaykitPaymentRequestId +import to.bitkit.repositories.PaykitPaymentRequestRefreshMode import to.bitkit.repositories.PaykitPaymentRequestRepo import to.bitkit.repositories.PaykitPaymentRequestTarget import to.bitkit.repositories.PaykitSubscription @@ -188,6 +193,7 @@ import to.bitkit.services.AppUpdaterService import to.bitkit.services.CoreService import to.bitkit.services.MigrationService import to.bitkit.services.NodeServiceFgState +import to.bitkit.services.PaykitSdkOperationLock.Priority import to.bitkit.services.PubkyService import to.bitkit.ui.Routes import to.bitkit.ui.components.Sheet @@ -200,7 +206,7 @@ import to.bitkit.ui.theme.TRANSITION_SCREEN_MS import to.bitkit.ui.utils.ScreenDeepLinks import to.bitkit.ui.utils.localizedPubkyAuthMessage import to.bitkit.usecases.FormatMoneyValue -import to.bitkit.usecases.RefreshContactPaykitReceiversUseCase +import to.bitkit.usecases.RefreshContactPaykitLinkUseCase import to.bitkit.utils.AppError import to.bitkit.utils.Bip21Utils import to.bitkit.utils.Logger @@ -223,6 +229,8 @@ import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime +import kotlin.time.Instant +import kotlin.time.TimeSource @OptIn(ExperimentalTime::class) @Suppress("TooManyFunctions", "LargeClass", "LongParameterList") @@ -257,10 +265,11 @@ class AppViewModel @Inject constructor( private val pubkyRepo: PubkyRepo, private val publicPaykitRepo: PublicPaykitRepo, private val privatePaykitRepo: PrivatePaykitRepo, + private val contactPaymentSettingsRepo: ContactPaymentSettingsRepo, private val paykitPaymentRequestRepo: PaykitPaymentRequestRepo, private val paykitPaymentProofRepo: PaykitPaymentProofRepo, private val paykitPaymentRequestDiagnostics: PaykitPaymentRequestDiagnostics, - private val refreshContactPaykitReceivers: RefreshContactPaykitReceiversUseCase, + private val refreshContactPaykitLink: RefreshContactPaykitLinkUseCase, private val samRockRepo: SamRockRepo, private val appUpdateSheet: AppUpdateTimedSheet, private val backupSheet: BackupTimedSheet, @@ -269,6 +278,7 @@ class AppViewModel @Inject constructor( private val highBalanceSheet: HighBalanceTimedSheet, private val formatMoneyValue: FormatMoneyValue, private val widgetsRepo: WidgetsRepo, + private val timeSource: TimeSource, ) : ViewModel() { val healthState = healthRepo.healthState @@ -321,6 +331,7 @@ class AppViewModel @Inject constructor( private var onchainSendRefreshJob: Job? = null fun setSendEvent(event: SendEvent) { + if ((currentSheet.value as? Sheet.Send)?.preparingRequest != null) return when (event) { SendEvent.AmountContinue -> { if (amountContinuePending) return @@ -376,28 +387,46 @@ class AppViewModel @Inject constructor( val onchainAddress: String, ) + private data class SubscriptionNotificationTarget( + val identity: String, + val requestId: PaykitPaymentRequestId, + ) + private val processedPaymentsLock = Any() private val processedPayments = mutableSetOf() private val contactPaymentContextLock = Any() private var activeContactPaymentContext: ContactPaymentContext? = null private val pendingContactPaymentContexts = mutableMapOf() - private var requestedPaymentRequestId: PaykitPaymentRequestId? = null + private val _requestedPaymentRequestId = MutableStateFlow(null) + val requestedPaymentRequestId = _requestedPaymentRequestId.asStateFlow() + private val paymentRequestPreparation = MutableStateFlow(null) + private val dismissedPreparingRequestIds = mutableSetOf() private var requestedPaymentRequest: PaykitPaymentRequest? = null private var shouldRestorePaymentRequestSheet = false private var preparedContactPaymentContext: ContactPaymentContext? = null + private var attemptedHardwarePaymentContext: ContactPaymentContext? = null + private var privatePaykitAppRegistrationIdentity: String? = null private var requestedPaymentRequestIdentity: String? = null + private var deferredSubscriptionNotification: SubscriptionNotificationTarget? = null private var requestedPaymentRequestTags: ImmutableList = persistentListOf() private var uncertainOnchainPaymentRequestId: PaykitPaymentRequestId? = null private var isPresentingPaymentRequest = false + private var isPaymentRequestPollingStopped = false + private var isPaymentRequestOverlayVisible = false private var paymentRequestPresentationGeneration = 0L private var activePaymentRequestPresentationGeneration: Long? = null private var paymentRequestIdentity: String? = null private var isPaymentRequestIdentityActivating = false private var isSubmittingPaymentRequest = false + set(value) { + field = value + paykitPaymentRequestRepo.setPaymentSubmissionActive(value) + } private var paykitPaymentRequestPollingJob: Job? = null - private var initialPaykitPaymentRequestPollingJob: Job? = null + private var paykitSessionRestoreRetryJob: Job? = null private val paymentRequestPresentationRetryAttempts = mutableMapOf() private val paymentRequestPresentationRetryJobs = mutableMapOf() + private val paymentRequestPresentationCompletionJobs = mutableMapOf() private val timedSheetManager = timedSheetManagerProvider(viewModelScope).apply { registerSheet(appUpdateSheet) registerSheet(backupSheet) @@ -419,7 +448,10 @@ class AppViewModel @Inject constructor( fun setIsAuthenticated(value: Boolean) { _isAuthenticated.value = value - if (!value) return + if (!value) { + clearPaymentRequestPreparation() + return + } markDeferredScanUnlocked() @@ -584,7 +616,6 @@ class AppViewModel @Inject constructor( observePublicPaykitInvoiceExpiry() observePrivatePaykitContacts() observePaykitPaymentRequestConnectivity() - observeInitialPaykitLinkBursts() observeIncomingPaykitPaymentRequests() observePaykitOnchainPaymentResolution() observeSendEvents() @@ -755,40 +786,40 @@ class AppViewModel @Inject constructor( } isPaymentRequestIdentityActivating = true - try { + val requestRefresh = try { paykitPaymentRequestRepo.activate(state.publicKey) if (!PubkyPublicKeyFormat.matches(pubkyRepo.publicKey.value, state.publicKey)) return paymentRequestIdentity = state.publicKey - refreshPrivateOnlyPaykitReceiverMarker("contact sync") + refreshPrivateOnlyPaykitApp("contact sync", onlyIfNeeded = !identityChanged) if (!state.contactsLoaded) return val removedKeys = lastPrivatePaykitContactKeys - state.contactKeys - removedKeys.forEach { - privatePaykitRepo.removeSavedContact(it) + if (removedKeys.isNotEmpty()) { + privatePaykitRepo.removeSavedContacts(removedKeys) .onFailure { error -> Logger.warn( - "Failed to remove private Paykit contact '${PubkyPublicKeyFormat.redacted(it)}'", + "Failed to remove private Paykit contacts", error, context = TAG, ) } } - privatePaykitRepo.prepareSavedContacts(state.contactKeys) + privatePaykitRepo.scheduleSavedContactPreparation(state.contactKeys) .onFailure { Logger.warn("Failed to prepare private Paykit contacts", it, context = TAG) } privatePaykitRepo.pruneUnsavedContactState(state.contactKeys) .onFailure { Logger.warn("Failed to prune private Paykit contact state", it, context = TAG) } - privatePaykitRepo.startInitialLinkBurst(state.contactKeys, "contact sync") if (!PubkyPublicKeyFormat.matches(pubkyRepo.publicKey.value, state.publicKey)) return - refreshIncomingPaykitPaymentRequests() + val result = refreshIncomingPaykitPaymentRequests(forceFresh = true) refreshPaymentRequestTargets(force = true) lastPrivatePaykitContactKeys = state.contactKeys + result } finally { if (PubkyPublicKeyFormat.matches(pubkyRepo.publicKey.value, state.publicKey)) { isPaymentRequestIdentityActivating = false } } - presentNextIncomingPaykitPaymentRequest() + requestRefresh.onSuccess { presentNextIncomingPaykitPaymentRequest() } } private suspend fun refreshPrivatePaykitEndpointsIfEnabled( @@ -800,14 +831,13 @@ class AppViewModel @Inject constructor( if (!isPaykitEnabled.value) return - refreshPrivateOnlyPaykitReceiverMarker(reason) + refreshPrivateOnlyPaykitApp(reason) privatePaykitRepo.reconcileReservedReceiveIndexes() .onFailure { Logger.warn("Failed to reconcile private Paykit receive indexes for '$reason'", it, context = TAG) } privatePaykitRepo.refreshKnownSavedContactEndpoints(reason, forceRefreshLightning = forceRefreshLightning) - privatePaykitRepo.startInitialLinkBurst(contactKeys, reason) - refreshIncomingPaykitPaymentRequests() + refreshIncomingPaykitPaymentRequests(forceFresh = true) refreshPaymentRequestTargets(force = true) } @@ -828,12 +858,6 @@ class AppViewModel @Inject constructor( } } - private fun observeInitialPaykitLinkBursts() { - viewModelScope.launch { - privatePaykitRepo.initialLinkBurstStarted.collect { startInitialPaykitPaymentRequestPolling() } - } - } - private fun observePaykitOnchainPaymentResolution() { viewModelScope.launch { paykitPaymentProofRepo.onchainPaymentResolutions.collect { resolutions -> @@ -845,6 +869,16 @@ class AppViewModel @Inject constructor( private fun handlePaykitOnchainPaymentResolution(resolution: PaykitOnchainPaymentProofResolution) { if (!PubkyPublicKeyFormat.matches(pubkyRepo.publicKey.value, resolution.identity)) return paykitPaymentProofRepo.consumeOnchainPaymentResolution(resolution) + val sendState = _sendUiState.value + val matchesHardwareWallet = sendState.hardwareWalletId != null && + sendState.hardwareWalletId == resolution.walletId + if (_currentSheet.value is Sheet.Send && matchesHardwareWallet && + sendState.incomingPaymentRequestId == resolution.requestId + ) { + synchronizeResolvedPaykitOnchainPayment(resolution, updateSendDetails = false) + _sendUiState.update { it.copy(resolvedHardwarePaymentTxId = resolution.transactionId) } + return + } val resolvesCurrentPayment = uncertainOnchainPaymentRequestId == resolution.requestId if (!resolvesCurrentPayment) { synchronizeResolvedPaykitOnchainPayment(resolution, updateSendDetails = false) @@ -875,12 +909,15 @@ class AppViewModel @Inject constructor( updateSendDetails: Boolean, ) { viewModelScope.launch { - lightningRepo.sync() - activityRepo.syncActivities() + if (resolution.walletId == WalletScope.default) { + lightningRepo.sync() + activityRepo.syncActivities() + } activityRepo.setContact( contactPublicKey = resolution.requestId.counterparty, forPaymentId = resolution.transactionId, syncLdkPayments = false, + walletId = resolution.walletId, ).onFailure { Logger.warn("Failed to associate a resolved Paykit payment with its contact", it, context = TAG) } @@ -890,14 +927,42 @@ class AppViewModel @Inject constructor( } } - private suspend fun refreshIncomingPaykitPaymentRequests(refreshMaintenance: Boolean = true) { - if (!isPaykitEnabled.value || pubkyRepo.publicKey.value == null || !walletRepo.walletExists()) return - if (refreshMaintenance) paykitPaymentProofRepo.reconcile() - paykitPaymentRequestRepo.refresh().onSuccess { + private suspend fun refreshIncomingPaykitPaymentRequests( + mode: PaykitPaymentRequestRefreshMode = PaykitPaymentRequestRefreshMode.FULL, + forceFresh: Boolean = false, + messagePriority: Priority = Priority.Ordered, + ): Result { + if (!isPaykitEnabled.value || pubkyRepo.publicKey.value == null || !walletRepo.walletExists()) { + return Result.failure(PaykitPaymentRequestError.RequestUnavailable) + } + if (mode == PaykitPaymentRequestRefreshMode.FULL) paykitPaymentProofRepo.reconcile() + val result = if (forceFresh) { + paykitPaymentRequestRepo.refreshAfterStateChange(mode) + } else { + paykitPaymentRequestRepo.refresh(mode, messagePriority) + } + return result.onSuccess { + activityRepo.backfillPaykitContacts() + clearHandledSubscriptionNotification() presentNextIncomingPaykitPaymentRequest() } } + private suspend fun clearHandledSubscriptionNotification() { + val requestId = requestedPaymentRequestId.value ?: return + val identity = requestedPaymentRequestIdentity ?: return + val generation = paymentRequestPresentationGeneration + if (!paykitPaymentRequestRepo.isSubscriptionNotificationHandled(requestId, identity)) return + if (!requestedPaymentRequestTargetsCurrentIdentity()) return + if (generation != paymentRequestPresentationGeneration || requestedPaymentRequestId.value != requestId || + requestedPaymentRequestIdentity != identity + ) { + return + } + invalidatePaymentRequestPresentation() + clearRequestedPaymentRequest() + } + private suspend fun refreshPaymentRequestTargets(force: Boolean = false) { if (!isPaykitEnabled.value || pubkyRepo.publicKey.value == null || !walletRepo.walletExists()) return paykitPaymentRequestRepo.refreshEligibleTargets( @@ -907,29 +972,69 @@ class AppViewModel @Inject constructor( } fun startPaykitPaymentRequestPolling() { + isPaymentRequestPollingStopped = false if (paykitPaymentRequestPollingJob?.isActive == true) return + startPaykitSessionRestoreRetries() paykitPaymentRequestPollingJob = viewModelScope.launch { if (isOnline.value == ConnectivityState.CONNECTED) pubkyRepo.republishIdentityIfNeeded() + refreshIncomingPaykitPaymentRequests(messagePriority = Priority.Background) + refreshPaymentRequestTargets() var maintenanceIntervalIndex = 0 - var maintenanceDelay = PAYKIT_MAINTENANCE_INTERVALS.first() + var nextMaintenance = timeSource.markNow() + PAYKIT_MAINTENANCE_INTERVALS.first() while (true) { delay(PAYKIT_PAYMENT_REQUEST_REFRESH_INTERVAL) - maintenanceDelay -= PAYKIT_PAYMENT_REQUEST_REFRESH_INTERVAL if (isOnline.value != ConnectivityState.CONNECTED) continue - val refreshMaintenance = maintenanceDelay <= Duration.ZERO + val refreshMaintenance = nextMaintenance.hasPassedNow() if (refreshMaintenance) { - pubkyRepo.republishIdentityIfNeeded() - privatePaykitRepo.refreshKnownSavedContactEndpoints("payment request polling") maintenanceIntervalIndex = (maintenanceIntervalIndex + 1).coerceAtMost(PAYKIT_MAINTENANCE_INTERVALS.lastIndex) - maintenanceDelay = PAYKIT_MAINTENANCE_INTERVALS[maintenanceIntervalIndex] + nextMaintenance = timeSource.markNow() + PAYKIT_MAINTENANCE_INTERVALS[maintenanceIntervalIndex] + if (isPaykitEnabled.value && walletRepo.walletExists()) pubkyRepo.restoreSessionIfNeeded() + pubkyRepo.republishIdentityIfNeeded() + retryPendingPaykitEndpointRemoval( + contactKeys = pubkyRepo.contacts.value.map { it.publicKey }, + reason = "payment request polling", + ) + } + val refreshIdentity = pubkyRepo.publicKey.value + refreshIncomingPaykitPaymentRequests( + if (refreshMaintenance) { + PaykitPaymentRequestRefreshMode.FULL + } else { + PaykitPaymentRequestRefreshMode.INBOX + }, + messagePriority = Priority.Background, + ) + if (refreshMaintenance) { + refreshIdlePaykitContactEndpoints(refreshIdentity) + refreshPaymentRequestTargets(force = true) } - refreshIncomingPaykitPaymentRequests(refreshMaintenance) - if (refreshMaintenance) refreshPaymentRequestTargets(force = true) } } - startInitialPaykitPaymentRequestPolling() + } + + private fun startPaykitSessionRestoreRetries() { + if (paykitSessionRestoreRetryJob?.isActive == true) return + paykitSessionRestoreRetryJob = viewModelScope.launch { + combine(isOnline, isPaykitEnabled) { connectivity, enabled -> + connectivity == ConnectivityState.CONNECTED && enabled + }.distinctUntilChanged().collectLatest { shouldRetry -> + if (shouldRetry && walletRepo.walletExists()) pubkyRepo.retryDeferredSessionRestoration() + } + } + } + + private suspend fun refreshIdlePaykitContactEndpoints(expectedIdentity: String?) { + currentCoroutineContext().ensureActive() + if (expectedIdentity == null) return + if (!PubkyPublicKeyFormat.matches(expectedIdentity, pubkyRepo.publicKey.value)) return + if (!isPaykitEnabled.value || isOnline.value != ConnectivityState.CONNECTED || + isPaymentRequestPresentationBlocked() + ) { + return + } + privatePaykitRepo.refreshKnownSavedContactEndpoints("payment request polling") } fun synchronizeSubscriptionNotifications(enabled: Boolean) { @@ -944,43 +1049,68 @@ class AppViewModel @Inject constructor( val currentIdentity = pubkyRepo.publicKey.value if (currentIdentity != null && !PubkyPublicKeyFormat.matches(currentIdentity, payerIdentity)) return invalidatePaymentRequestPresentation() - requestedPaymentRequestId = requestId + deferredSubscriptionNotification = null + _requestedPaymentRequestId.update { requestId } requestedPaymentRequest = null shouldRestorePaymentRequestSheet = false requestedPaymentRequestIdentity = payerIdentity requestedPaymentRequestTags = persistentListOf() } viewModelScope.launch { - refreshIncomingPaykitPaymentRequests() + val hasIdentity = pubkyRepo.publicKey.value != null || + runSuspendCatching { pubkyRepo.hasIdentity() }.getOrDefault(true) + val canPresent = PaykitFeatureFlags.isUiEnabled(settingsStore.isPaykitEnabled.first()) && + walletRepo.walletExists() && hasIdentity + if (!canPresent) { + if (requestedPaymentRequestId.value == requestId && requestedPaymentRequestIdentity == payerIdentity) { + invalidatePaymentRequestPresentation() + clearRequestedPaymentRequest() + } + return@launch + } + refreshIncomingPaykitPaymentRequests(PaykitPaymentRequestRefreshMode.STORED) } } fun stopPaykitPaymentRequestPolling() { + isPaymentRequestPollingStopped = true + paykitSessionRestoreRetryJob?.cancel() + paykitSessionRestoreRetryJob = null + clearPaymentRequestPreparation() paykitPaymentRequestPollingJob?.cancel() paykitPaymentRequestPollingJob = null - initialPaykitPaymentRequestPollingJob?.cancel() - initialPaykitPaymentRequestPollingJob = null clearPaymentRequestPresentationRetries() } - private fun startInitialPaykitPaymentRequestPolling() { - if (paykitPaymentRequestPollingJob?.isActive != true) return - initialPaykitPaymentRequestPollingJob?.cancel() - initialPaykitPaymentRequestPollingJob = viewModelScope.launch { - refreshIncomingPaykitPaymentRequests() - refreshPaymentRequestTargets() - INITIAL_PAYKIT_SYNC_RETRY_DELAYS.forEach { - delay(it) - refreshIncomingPaykitPaymentRequests() - refreshPaymentRequestTargets() - } - } - } - private fun observeIncomingPaykitPaymentRequests() { viewModelScope.launch { + combine(paymentRequestPreparation, pubkyRepo.publicKey, pendingPaymentRequests) { + preparation, identity, pending -> + preparation?.takeUnless { + PubkyPublicKeyFormat.matches(it.identity, identity) && it.request in pending + } + }.filterNotNull().collect(::clearPaymentRequestPreparation) + } + viewModelScope.launch { + var changeVersion = 0L + paykitPaymentProofRepo.paymentRequestStateChanges(pubkyRepo.publicKey) + .map { ++changeVersion } + .combine(paykitPaymentRequestRepo.isPaymentSubmissionActive) { version, active -> + version.takeUnless { active } + } + .filterNotNull() + .distinctUntilChanged() + .collect { + refreshIncomingPaykitPaymentRequests(PaykitPaymentRequestRefreshMode.STORED, forceFresh = true) + } + } + viewModelScope.launch { + var previousSheet: Sheet? = null currentSheet.collect { sheet -> - if (sheet == null) { + val wasPreparingRequest = (previousSheet as? Sheet.Send)?.preparingRequest != null + previousSheet = sheet + if (sheet != null && sheet !== paymentRequestPreparation.value?.sheet) clearPaymentRequestPreparation() + if (sheet == null && !wasPreparingRequest) { presentNextIncomingPaykitPaymentRequest() } } @@ -1017,27 +1147,25 @@ class AppViewModel @Inject constructor( } return } - if (sheet !is Sheet.Send || currentSheet.value !is Sheet.Send) return + if (sheet !is Sheet.Send || sheet !== currentSheet.value || sheet.preparingRequest != null) return val request = activeIncomingPaymentRequest() ?: return - viewModelScope.launch { - if (currentSheet.value !is Sheet.Send || activeIncomingPaymentRequest()?.id != request.id) return@launch - if (paykitPaymentRequestRepo.markPresented(request)) { - paymentRequestPresentationGeneration++ - clearRequestedPaymentRequest() - clearPaymentRequestPresentationRetry(request.id) - } - } + if (!paykitPaymentRequestRepo.isPending(request)) return + completePaymentRequestPresentation(request) + paymentRequestPresentationGeneration++ + clearRequestedPaymentRequest(restoreDeferredReminder = false) + clearPaymentRequestPresentationRetry(request.id) } private suspend fun presentNextIncomingPaykitPaymentRequest() { - if (isPresentingPaymentRequest || isPaymentRequestPresentationBlocked()) return - if (requestedPaymentRequestId == null) { + val preparation = paymentRequestPreparation.value + if (isPresentingPaymentRequest || isPaymentRequestPresentationBlocked(preparation)) return + if (preparation == null && requestedPaymentRequestId.value == null) { paykitPaymentRequestRepo.automaticSubscriptionProposals().firstOrNull()?.let { showSheet(Sheet.Subscription(SubscriptionRoute.Review(it.id))) return } } - val requests = paymentRequestsForPresentation() ?: return + val requests = paymentRequestsForPresentation(preparation) ?: return val generation = paymentRequestPresentationGeneration isPresentingPaymentRequest = true activePaymentRequestPresentationGeneration = generation @@ -1063,11 +1191,21 @@ class AppViewModel @Inject constructor( } } + private fun paymentRequestsForPresentation(preparation: PaymentRequestPreparation?): List? = + paymentRequestsForPresentation() + ?.filter { preparation == null || it.id == preparation.request.id } + ?.takeIf { it.isNotEmpty() } + private fun paymentRequestsForPresentation(): List? { - val requestedId = requestedPaymentRequestId + val requestedId = requestedPaymentRequestId.value return if (requestedId == null) { paykitPaymentRequestRepo.automaticPendingRequests().filter { request -> + val isCompleting = synchronized(contactPaymentContextLock) { + request.id in paymentRequestPresentationCompletionJobs + } !paykitPaymentRequestRepo.isProcessing(request) && + request.id !in dismissedPreparingRequestIds && + !isCompleting && paymentRequestPresentationRetryJobs[request.id]?.isActive != true }.takeIf { it.isNotEmpty() } } else { @@ -1084,6 +1222,7 @@ class AppViewModel @Inject constructor( requestedPaymentRequest = request listOf(request) } else { + if (requestedPaymentRequestIdentity != null) return null requestedPaymentRequest?.takeIf { it.id == requestedId }?.let { if (paykitPaymentRequestRepo.isExpired(it)) { finishExpiredPaymentRequestPresentation(it) @@ -1107,13 +1246,63 @@ class AppViewModel @Inject constructor( return PubkyPublicKeyFormat.matches(currentIdentity, requestedIdentity) } + private fun subscriptionNotificationToDefer(): SubscriptionNotificationTarget? { + val presentationInProgress = isPresentingPaymentRequest || isSubmittingPaymentRequest || + paymentRequestPreparation.value != null + val paymentInProgress = hasActiveContactPaymentContext() || isScanPendingOrActive() || + paymentRequestSheetTransitionJob?.isActive == true + if (presentationInProgress || paymentInProgress) return null + val identity = requestedPaymentRequestIdentity ?: return null + val requestId = requestedPaymentRequestId.value ?: return null + if (!PubkyPublicKeyFormat.matches(identity, pubkyRepo.publicKey.value) || + paykitPaymentRequestRepo.pendingRequest(requestId) != null + ) { + return null + } + return SubscriptionNotificationTarget(identity, requestId) + } + private suspend fun presentIncomingPaymentRequestOrStop( request: PaykitPaymentRequest, generation: Long, ): Boolean { - val presentationResult = privatePaykitRepo.beginPaymentRequest(request) - val result = presentationResult.getOrNull() - if (!isCurrentPaymentRequestPresentation(request, generation) || isPaymentRequestPresentationBlocked()) { + val preparation = paymentRequestPreparation.value?.takeIf { + it.request == request && it.generation == generation && ownsPaymentRequestPreparation(it) + } ?: paymentRequestIdentity?.takeIf { + request.billingPeriod == null && !paykitPaymentRequestRepo.isExpired(request) && + PubkyPublicKeyFormat.matches(it, pubkyRepo.publicKey.value) + }?.let { + PaymentRequestPreparation(request, it, generation) + } + paymentRequestPreparation.update { preparation } + try { + if (preparation != null) { + if (currentSheet.value !== preparation.sheet) { + showSheet(preparation.sheet) + sheetTransitionJob?.join() + } + if (!ownsPaymentRequestPreparation(preparation)) return true + } + val presentationResult = privatePaykitRepo.beginPaymentRequest(request) + return finishIncomingPaymentRequestPreparation(request, generation, preparation, presentationResult) + } finally { + if (scheduledScan?.contactPaymentContext?.incomingPaymentRequest?.id != request.id && + paymentRequestPresentationRetryJobs[request.id]?.isActive != true + ) { + finishPaymentRequestPreparation(preparation) + } + } + } + + private fun finishIncomingPaymentRequestPreparation( + request: PaykitPaymentRequest, + generation: Long, + preparation: PaymentRequestPreparation?, + presentationResult: Result, + ): Boolean { + if (!isCurrentPaymentRequestPresentation(request, generation, preparation) || + isPaymentRequestPresentationBlocked(preparation) + ) { return true } val error = presentationResult.exceptionOrNull() @@ -1123,12 +1312,13 @@ class AppViewModel @Inject constructor( } if (error != null) paykitPaymentRequestDiagnostics.logPresentationFailure(request.counterparty, error) if (!paykitPaymentRequestRepo.isPending(request)) { - if (requestedPaymentRequestId == request.id) { + if (requestedPaymentRequestId.value == request.id && requestedPaymentRequestIdentity == null) { invalidatePaymentRequestPresentation() clearRequestedPaymentRequest() } return false } + val result = presentationResult.getOrNull() if (result !is PublicPaykitPaymentResult.Opened) { if (result == PublicPaykitPaymentResult.PrivateLinkPending) { finishPrivateLinkPendingPaymentRequestPresentation(request) @@ -1139,15 +1329,14 @@ class AppViewModel @Inject constructor( ?: IncomingPaykitPaymentRequestFailureReason.ResolutionFailed, ) } - return false + return preparation != null && paymentRequestPresentationRetryJobs[request.id]?.isActive == true } - openContactPayment( paymentRequest = result.paymentRequest, publicKey = request.counterparty, privatePaymentContext = result.privatePaymentContext, incomingPaymentRequest = request, - selectedTags = requestedPaymentRequestTags.takeIf { requestedPaymentRequestId == request.id } + selectedTags = requestedPaymentRequestTags.takeIf { requestedPaymentRequestId.value == request.id } ?: persistentListOf(), ) return true @@ -1158,14 +1347,18 @@ class AppViewModel @Inject constructor( request.counterparty, IncomingPaykitPaymentRequestFailureReason.PaymentDetailsPending, ) - val isRequested = requestedPaymentRequestId == request.id + val isRequested = requestedPaymentRequestId.value == request.id val restorePaymentRequestSheet = isRequested && shouldRestorePaymentRequestSheet if (isRequested) { paymentRequestPresentationGeneration++ clearRequestedPaymentRequest() } clearPaymentRequestPresentationRetry(request.id) + if (paymentRequestPreparation.value?.request?.id == request.id) { + dismissedPreparingRequestIds.add(request.id) + } if (!isRequested) return + finishPaymentRequestPreparation(paymentRequestPreparation.value) toast( type = Toast.ToastType.INFO, title = context.getString(R.string.wallet__payment_request), @@ -1174,11 +1367,17 @@ class AppViewModel @Inject constructor( if (restorePaymentRequestSheet && currentSheet.value == null) showSheet(Sheet.PaymentRequests) } - private fun isCurrentPaymentRequestPresentation(request: PaykitPaymentRequest, generation: Long): Boolean = - activePaymentRequestPresentationGeneration == generation && + private fun isCurrentPaymentRequestPresentation( + request: PaykitPaymentRequest, + generation: Long, + preparation: PaymentRequestPreparation?, + ): Boolean { + if (preparation != null && !ownsPaymentRequestPreparation(preparation)) return false + return activePaymentRequestPresentationGeneration == generation && paymentRequestPresentationGeneration == generation && !paykitPaymentRequestRepo.isProcessing(request) && - (requestedPaymentRequestId?.let { it == request.id } ?: true) + (requestedPaymentRequestId.value?.let { it == request.id } ?: true) + } private fun deferPaymentRequestPresentation( request: PaykitPaymentRequest, @@ -1187,7 +1386,7 @@ class AppViewModel @Inject constructor( paykitPaymentRequestDiagnostics.logPresentationRejection(request.counterparty, reason) val attempt = paymentRequestPresentationRetryAttempts[request.id] ?: 0 val retryDelay = PAYKIT_PAYMENT_REQUEST_PRESENTATION_RETRY_DELAYS.getOrNull(attempt) - ?: if (requestedPaymentRequestId == request.id) { + ?: if (requestedPaymentRequestId.value == request.id) { Logger.warn( "Stopped retrying requested incoming Paykit payment request after " + "'${attempt + 1}' presentation attempts", @@ -1203,16 +1402,14 @@ class AppViewModel @Inject constructor( testTag = "PaymentRequestUnavailableToast", ) if (restorePaymentRequestSheet) showSheet(Sheet.PaymentRequests) - viewModelScope.launch { - paykitPaymentRequestRepo.markPresented(request) - } + completePaymentRequestPresentation(request) return } else { PAYKIT_PAYMENT_REQUEST_PRESENTATION_RETRY_INTERVAL } paymentRequestPresentationRetryAttempts[request.id] = (attempt + 1).coerceAtMost(PAYKIT_PAYMENT_REQUEST_PRESENTATION_RETRY_DELAYS.size) - if (attempt == 0 && requestedPaymentRequestId == request.id) { + if (attempt == 0 && requestedPaymentRequestId.value == request.id) { toast( type = Toast.ToastType.INFO, title = context.getString(R.string.wallet__payment_request), @@ -1233,9 +1430,9 @@ class AppViewModel @Inject constructor( IncomingPaykitPaymentRequestFailureReason.RequestExpired, ) val restorePaymentRequestSheet = - requestedPaymentRequestId == request.id && shouldRestorePaymentRequestSheet - val showExpiredToast = requestedPaymentRequestId == request.id - if (requestedPaymentRequestId == request.id) { + requestedPaymentRequestId.value == request.id && shouldRestorePaymentRequestSheet + val showExpiredToast = requestedPaymentRequestId.value == request.id + if (requestedPaymentRequestId.value == request.id) { val hideExpiredRequestSendSheet = invalidatePaymentRequestPresentation(requestId = request.id) clearRequestedPaymentRequest() if (hideExpiredRequestSendSheet) hideSheet() @@ -1257,9 +1454,9 @@ class AppViewModel @Inject constructor( IncomingPaykitPaymentRequestFailureReason.ResolutionFailed, ) val restorePaymentRequestSheet = - requestedPaymentRequestId == request.id && shouldRestorePaymentRequestSheet - val showUnavailableToast = requestedPaymentRequestId == request.id - if (requestedPaymentRequestId == request.id) { + requestedPaymentRequestId.value == request.id && shouldRestorePaymentRequestSheet + val showUnavailableToast = requestedPaymentRequestId.value == request.id + if (requestedPaymentRequestId.value == request.id) { invalidatePaymentRequestPresentation() clearRequestedPaymentRequest() } @@ -1276,10 +1473,12 @@ class AppViewModel @Inject constructor( private fun retainPaymentRequestPresentationState(requests: List) { val requestIds = requests.mapTo(mutableSetOf()) { it.id } + dismissedPreparingRequestIds.retainAll(requestIds) paymentRequestPresentationRetryAttempts.keys.retainAll(requestIds) paymentRequestPresentationRetryJobs.keys.filter { it !in requestIds }.forEach { paymentRequestPresentationRetryJobs.remove(it)?.cancel() } + if (requestedPaymentRequestIdentity != null) return val requestedRequest = requestedPaymentRequest if (requestedRequest != null && requestedRequest.id !in requestIds) { if (paykitPaymentRequestRepo.isExpired(requestedRequest)) { @@ -1287,7 +1486,7 @@ class AppViewModel @Inject constructor( return } finishUnavailablePaymentRequestPresentation(requestedRequest) - } else if (requestedPaymentRequestId?.let { it !in requestIds } == true) { + } else if (requestedPaymentRequestId.value?.let { it !in requestIds } == true) { invalidatePaymentRequestPresentation() clearRequestedPaymentRequest() } @@ -1304,29 +1503,108 @@ class AppViewModel @Inject constructor( paymentRequestPresentationRetryAttempts.clear() } + private fun completePaymentRequestPresentation(request: PaykitPaymentRequest) { + synchronized(contactPaymentContextLock) { + if (request.id in paymentRequestPresentationCompletionJobs) return + val job = viewModelScope.launch(start = CoroutineStart.LAZY) { + paykitPaymentRequestRepo.markPresented(request) + } + paymentRequestPresentationCompletionJobs[request.id] = job + job.invokeOnCompletion { + synchronized(contactPaymentContextLock) { + paymentRequestPresentationCompletionJobs.remove(request.id, job) + } + } + job.start() + } + } + private fun resetPaykitPresentationState( dismissActiveRequest: Boolean, preserveRequestedPaymentRequest: Boolean, ) { + _sendUiState.update { it.copy(resolvedHardwarePaymentTxId = null) } + dismissedPreparingRequestIds.clear() invalidatePaymentRequestPresentation(dismissActiveRequest) clearPaymentRequestPresentationRetries() - if (!preserveRequestedPaymentRequest) clearRequestedPaymentRequest() + val completions = synchronized(contactPaymentContextLock) { + paymentRequestPresentationCompletionJobs.values.toList().also { + paymentRequestPresentationCompletionJobs.clear() + } + } + completions.forEach { it.cancel() } + if (!preserveRequestedPaymentRequest) { + deferredSubscriptionNotification = null + clearRequestedPaymentRequest() + } paymentRequestSheetTransitionJob?.cancel() paymentRequestSheetTransitionJob = null } - private fun clearRequestedPaymentRequest() { - requestedPaymentRequestId = null + private fun clearRequestedPaymentRequest(restoreDeferredReminder: Boolean = true) { + _requestedPaymentRequestId.update { null } requestedPaymentRequest = null shouldRestorePaymentRequestSheet = false requestedPaymentRequestIdentity = null requestedPaymentRequestTags = persistentListOf() + if (restoreDeferredReminder) restoreDeferredSubscriptionNotification() + } + + private fun restoreDeferredSubscriptionNotification() { + if (requestedPaymentRequestId.value != null) return + val reminder = deferredSubscriptionNotification?.takeIf { + PubkyPublicKeyFormat.matches(it.identity, pubkyRepo.publicKey.value) + } + deferredSubscriptionNotification = null + _requestedPaymentRequestId.update { reminder?.requestId } + requestedPaymentRequestIdentity = reminder?.identity + } + + fun setPaymentRequestOverlayVisible(visible: Boolean) { + if (isPaymentRequestOverlayVisible == visible) return + isPaymentRequestOverlayVisible = visible + if (visible) { + clearPaymentRequestPreparation() + } else { + viewModelScope.launch { presentNextIncomingPaykitPaymentRequest() } + } + } + + private fun clearPaymentRequestPreparation() { + clearPaymentRequestPreparation(paymentRequestPreparation.value) + } + + private fun ownsPaymentRequestPreparation(preparation: PaymentRequestPreparation): Boolean { + if (paymentRequestPreparation.value !== preparation || currentSheet.value !== preparation.sheet) return false + if (preparation.generation != paymentRequestPresentationGeneration || !_isAuthenticated.value) return false + return PubkyPublicKeyFormat.matches(preparation.identity, pubkyRepo.publicKey.value) && + paykitPaymentRequestRepo.pendingRequest(preparation.request.id) == preparation.request + } + + private fun finishPaymentRequestPreparation(preparation: PaymentRequestPreparation?) { + if (preparation == null || !paymentRequestPreparation.compareAndSet(preparation, null)) return + _currentSheet.update { if (it === preparation.sheet) null else it } + } + + private fun clearPaymentRequestPreparation(preparation: PaymentRequestPreparation?) { + if (preparation == null || !paymentRequestPreparation.compareAndSet(preparation, null)) return + paymentRequestPresentationGeneration++ + scheduledScan?.takeIf { it.contactPaymentContext?.incomingPaymentRequest?.id == preparation.request.id } + ?.job?.cancel() + synchronized(contactPaymentContextLock) { + if (activeContactPaymentContext?.incomingPaymentRequest?.id == preparation.request.id) { + activeContactPaymentContext = null + preparedContactPaymentContext = null + } + } + _currentSheet.update { if (it === preparation.sheet) null else it } } private fun invalidatePaymentRequestPresentation( dismissActiveRequest: Boolean = false, requestId: PaykitPaymentRequestId? = null, ): Boolean { + clearPaymentRequestPreparation() fun targetsRequest(context: ContactPaymentContext?): Boolean { val scanRequestId = context?.incomingPaymentRequest?.id ?: return false return requestId == null || scanRequestId == requestId @@ -1366,38 +1644,47 @@ class AppViewModel @Inject constructor( return shouldHideRequestSendSheet } - private suspend fun refreshPrivateOnlyPaykitReceiverMarker(reason: String) { + private suspend fun refreshPrivateOnlyPaykitApp(reason: String, onlyIfNeeded: Boolean = false) { val settings = settingsStore.data.first() if (!settings.sharesPrivatePaykitEndpoints || settings.sharesPublicPaykitEndpoints) return - if (pubkyRepo.publicKey.value == null) return - - publicPaykitRepo.syncLocalReceiverMarker() + val identity = pubkyRepo.publicKey.value ?: return + if (onlyIfNeeded && PubkyPublicKeyFormat.matches(privatePaykitAppRegistrationIdentity, identity)) return + privatePaykitAppRegistrationIdentity = null + + publicPaykitRepo.syncPaykitApp() + .onSuccess { + if (PubkyPublicKeyFormat.matches(pubkyRepo.publicKey.value, identity)) { + privatePaykitAppRegistrationIdentity = identity + } + } .onFailure { - Logger.warn("Failed to refresh private Paykit receiver marker for '$reason'", it, context = TAG) + Logger.warn("Failed to refresh private Paykit app registration for '$reason'", it, context = TAG) } } private suspend fun retryPendingPaykitEndpointRemoval(contactKeys: Collection, reason: String) { - val settings = settingsStore.data.first() - if (settings.publicPaykitCleanupPending) { - val reconciliationResult = if (settings.sharesPublicPaykitEndpoints) { - publicPaykitRepo.syncCurrentPublishedEndpoints() - } else { - publicPaykitRepo.syncPublishedEndpoints(publish = false) - } - reconciliationResult - .onSuccess { - settingsStore.update { it.copy(publicPaykitCleanupPending = false) } - } + contactPaymentSettingsRepo.reconcilePendingEndpoints { + privatePaykitRepo.retryPendingEndpointRemoval(contactKeys) .onFailure { - Logger.warn("Failed to reconcile public Paykit state for '$reason'", it, context = TAG) + Logger.warn("Failed to retry private Paykit endpoint removal for '$reason'", it, context = TAG) } - } - privatePaykitRepo.retryPendingEndpointRemoval(contactKeys) - .onFailure { - Logger.warn("Failed to retry private Paykit endpoint removal for '$reason'", it, context = TAG) + val settings = settingsStore.data.first() + if (settings.publicPaykitCleanupPending) { + val reconciliationResult = if (settings.sharesPublicPaykitEndpoints) { + publicPaykitRepo.syncCurrentPublishedEndpoints() + } else { + publicPaykitRepo.syncPublishedEndpoints(publish = false) + } + reconciliationResult + .onSuccess { + settingsStore.update { it.copy(publicPaykitCleanupPending = false) } + } + .onFailure { + Logger.warn("Failed to reconcile public Paykit state for '$reason'", it, context = TAG) + } } + } } @Suppress("CyclomaticComplexMethod") @@ -1715,15 +2002,7 @@ class AppViewModel @Inject constructor( ) { closeSettledReceiveSheet(receiveSheetToClose) val addresses = event.details.outputs.mapNotNull { it.scriptpubkeyAddress } - val contactPublicKey = privatePaykitRepo.contactPublicKeyForPrivateOnchainAddresses(addresses) notifyPaymentReceived(event) - if (contactPublicKey != null) { - activityRepo.setContact( - contactPublicKey = contactPublicKey, - forPaymentId = event.txid, - syncLdkPayments = false, - ) - } privatePaykitRepo.handleOnchainActivity(addresses) .onFailure { Logger.warn("Failed to rotate private Paykit address for '${event.txid}'", it, context = TAG) @@ -2377,6 +2656,7 @@ class AppViewModel @Inject constructor( ) } + @Suppress("LongMethod") private fun launchScan( source: ScanSource, data: String, @@ -2387,6 +2667,9 @@ class AppViewModel @Inject constructor( allowPubkyAuth: Boolean = isMainScanner, suppressQuickPay: Boolean = false, ): Job? { + val previousPreparation = paymentRequestPreparation.value?.takeUnless { + it.request.id == contactPaymentContext?.incomingPaymentRequest?.id + } val deferrable = DeferredScan( source = source, data = data, @@ -2419,6 +2702,7 @@ class AppViewModel @Inject constructor( val previousJob = scheduled?.job val nextJob = viewModelScope.launch(start = CoroutineStart.LAZY) { + clearPaymentRequestPreparation(previousPreparation) scanMutex.withLock { if (!awaitPubkyDeeplinkInitialization(source, data, allowPubkyAuth)) return@withLock if (deferLockedScan(deferrable)) return@withLock @@ -2564,13 +2848,14 @@ class AppViewModel @Inject constructor( return hasDeferredScan() } - private fun isPaymentRequestPresentationBlocked() = isPaymentRequestIdentityActivating || - !_isAuthenticated.value || - currentSheet.value != null || - sheetTransitionJob?.isActive == true || - paymentRequestSheetTransitionJob?.isActive == true || - hasActiveContactPaymentContext() || - isScanPendingOrActive() + private fun isPaymentRequestPresentationBlocked(preparation: PaymentRequestPreparation? = null) = + isPaymentRequestIdentityActivating || isPaymentRequestPollingStopped || isPaymentRequestOverlayVisible || + !_isAuthenticated.value || + (currentSheet.value != null && currentSheet.value !== preparation?.sheet) || + sheetTransitionJob?.isActive == true || + paymentRequestSheetTransitionJob?.isActive == true || + hasActiveContactPaymentContext() || + isScanPendingOrActive() private fun flushDeferredScan() { if (!_isAuthenticated.value) return @@ -3088,7 +3373,7 @@ class AppViewModel @Inject constructor( if (currentSheet.value is Sheet.Send) hideSheet() mainScreenEffect(MainScreenEffect.Navigate(route)) if (route is Routes.ContactDetail) { - refreshContactPaykitReceivers(route.publicKey) + refreshContactPaykitLink(route.publicKey) } return@withContext } @@ -3106,6 +3391,7 @@ class AppViewModel @Inject constructor( .onSuccess { logDecodedScan(it, isPaymentRequest) } .getOrNull() + currentCoroutineContext().ensureActive() handleDecodedScan(scan, input, fromMainScanner, suppressQuickPay) } @@ -3228,7 +3514,8 @@ class AppViewModel @Inject constructor( IncomingPaykitPaymentRequestFailureReason.InvalidPaymentTarget, ): Boolean { val hasIncomingPaymentRequest = activeIncomingPaymentRequest() != null - val shouldHideSheet = !hasIncomingPaymentRequest || currentSheet.value is Sheet.Send + val shouldHideSheet = !hasIncomingPaymentRequest || + currentSheet.value is Sheet.Send && paymentRequestPreparation.value == null clearActiveContactPaymentContext(failureReason = failureReason) if (shouldHideSheet) hideSheet() return hasIncomingPaymentRequest @@ -3238,32 +3525,36 @@ class AppViewModel @Inject constructor( failureReason: IncomingPaykitPaymentRequestFailureReason, retryIncomingRequest: Boolean = true, ) { + isSubmittingPaymentRequest = false uncertainOnchainPaymentRequestId = null val interruptedRequest = synchronized(contactPaymentContextLock) { val request = activeContactPaymentContext?.incomingPaymentRequest + if (request != null && !retryIncomingRequest) completePaymentRequestPresentation(request) activeContactPaymentContext = null preparedContactPaymentContext = null + attemptedHardwarePaymentContext = null request } if (interruptedRequest == null) return + paymentRequestPreparation.value?.takeIf { it.request.id == interruptedRequest.id }?.let { + finishPaymentRequestPreparation(it) + } if (!retryIncomingRequest) { paymentRequestPresentationGeneration++ - if (requestedPaymentRequestId == interruptedRequest.id) { + if (requestedPaymentRequestId.value == interruptedRequest.id) { clearRequestedPaymentRequest() } clearPaymentRequestPresentationRetry(interruptedRequest.id) - viewModelScope.launch { paykitPaymentRequestRepo.markPresented(interruptedRequest) } return } if ( - requestedPaymentRequestId == interruptedRequest.id || + requestedPaymentRequestId.value == interruptedRequest.id || paykitPaymentRequestRepo.automaticPendingRequests().any { it.id == interruptedRequest.id } ) { deferPaymentRequestPresentation(interruptedRequest, failureReason) } - isSubmittingPaymentRequest = false } private suspend fun rejectPubkyAuthScan( @@ -3295,7 +3586,7 @@ class AppViewModel @Inject constructor( private suspend fun markIncomingPaymentRequestPresented(request: PaykitPaymentRequest) { paymentRequestPresentationGeneration++ - if (requestedPaymentRequestId == request.id) { + if (requestedPaymentRequestId.value == request.id) { clearRequestedPaymentRequest() } clearPaymentRequestPresentationRetry(request.id) @@ -3805,6 +4096,16 @@ class AppViewModel @Inject constructor( route: SendRoute, effect: SendEffect, ) { + val preparation = paymentRequestPreparation.value + if (preparation != null && route == SendRoute.Confirm) { + if (!ownsPaymentRequestPreparation(preparation)) return + if (activeIncomingPaymentRequest()?.id != preparation.request.id) return + paymentRequestPreparation.compareAndSet(preparation, null) + _currentSheet.update { + if (it === preparation.sheet) preparation.sheet.copy(preparingRequest = null) else it + } + return + } if (fromMainScanner) { showSheet(Sheet.Send(route)) return @@ -4066,12 +4367,19 @@ class AppViewModel @Inject constructor( val incomingPaymentRequest = contactPaymentContext?.incomingPaymentRequest val proofPreparation = preparePaymentProof(incomingPaymentRequest) - if (proofPreparation.exceptionOrNull() is PaykitPaymentRequestError.OperationInProgress) { - handlePaymentPreparationFailure(PaykitPaymentRequestError.OperationInProgress, contactPaymentContext) + proofPreparation.exceptionOrNull()?.let { + handlePaymentPreparationFailure(it, contactPaymentContext) return } val preparedPaymentProofRequest = proofPreparation.getOrNull() + contactPaymentContext?.incomingPaymentRequest?.let { request -> + paykitPaymentRequestRepo.claimForPayment(request).onFailure { + cancelPaymentProofPreparation(preparedPaymentProofRequest) + handlePaymentPreparationFailure(it, contactPaymentContext) + return + } + } consumePrivatePaymentListIfNeeded(contactPaymentContext).onFailure { cancelPaymentProofPreparation(preparedPaymentProofRequest) handlePaymentPreparationFailure(it, contactPaymentContext) @@ -4146,6 +4454,7 @@ class AppViewModel @Inject constructor( ) { val address = _sendUiState.value.address val tags = _sendUiState.value.selectedTags + val proofPreparation = incomingPaymentRequest?.let { paymentProofPreparation(contactPaymentContext) } var proofRequest = preparedPaymentProofRequest var paymentProofStarted = false var sendAttempted = false @@ -4153,6 +4462,7 @@ class AppViewModel @Inject constructor( address = address, amount = amount, tags = tags, + paymentDeadlineAt = incomingPaymentRequest?.paymentDeadlineAt, beforeSendAttempt = { if (preparedPaymentProofRequest != null) { markOnchainPaymentStarted(incomingPaymentRequest, address).getOrThrow() @@ -4163,7 +4473,7 @@ class AppViewModel @Inject constructor( }, onBroadcast = { txId -> proofRequest = null - completeOnchainPaymentProofInBackground(incomingPaymentRequest, txId) + completeOnchainPaymentProofInBackground(incomingPaymentRequest, txId, proofPreparation) }, ).onSuccess { txId -> Logger.info("Onchain send result txid: $txId", context = TAG) @@ -4253,7 +4563,9 @@ class AppViewModel @Inject constructor( val paymentHash = decodedInvoice.paymentHash.toHex() associateLightningPaymentProof(incomingPaymentRequest, paymentHash).onFailure { cancelPaymentProofPreparation(proofRequest) - proofRequest = null + releasePrivatePaymentListIfNeeded(contactPaymentContext) + handlePaymentPreparationFailure(it, contactPaymentContext) + return } val tags = _sendUiState.value.selectedTags @@ -4272,7 +4584,7 @@ class AppViewModel @Inject constructor( val lnurlComment = savePendingLnurlComment(decodedInvoice, paymentHash) var authorizationError: Throwable? = null - val result = sendLightning(decodedInvoice.bolt11, paymentAmount) { + val result = sendLightning(decodedInvoice.bolt11, paymentAmount, incomingPaymentRequest?.paymentDeadlineAt) { authorizationError = incomingPaymentRequest?.let { paykitPaymentRequestRepo.ensurePaymentAllowed(it).exceptionOrNull() } @@ -4349,10 +4661,17 @@ class AppViewModel @Inject constructor( else -> paykitPaymentProofRepo.failLightningPayment(paymentHash, error) } + @Suppress("ReturnCount") private suspend fun prepareContactPayment(contactPaymentContext: ContactPaymentContext?): Boolean { if (isPreparedContactPayment(contactPaymentContext)) return true if (!validateIncomingPaymentRequest(contactPaymentContext)) return false + contactPaymentContext?.incomingPaymentRequest?.let { request -> + paykitPaymentRequestRepo.claimForPayment(request).onFailure { + handlePaymentPreparationFailure(it, contactPaymentContext) + return false + } + } consumePrivatePaymentListIfNeeded(contactPaymentContext).onFailure { handlePaymentPreparationFailure(it, contactPaymentContext) return false @@ -4376,10 +4695,11 @@ class AppViewModel @Inject constructor( private suspend fun preparePaymentProof(request: PaykitPaymentRequest?): Result { if (request == null) return Result.success(null) - val preparation = paymentProofPreparation() + val preparation = runSuspendCatching { paymentProofPreparation() }.getOrElse { return Result.failure(it) } return paykitPaymentProofRepo.prepare( request = request, paymentEndpointIdentifier = preparation.endpointIdentifier, + paymentAppId = preparation.appId, kind = preparation.kind, ).map { request } } @@ -4387,23 +4707,30 @@ class AppViewModel @Inject constructor( private suspend fun associateLightningPaymentProof( request: PaykitPaymentRequest?, paymentHash: String, - ): Result = request?.let { + ): Result = runSuspendCatching { + if (request == null) return@runSuspendCatching + val preparation = paymentProofPreparation() paykitPaymentProofRepo.associateLightningPayment( - request = it, + request = request, paymentHash = paymentHash, - paymentEndpointIdentifier = paymentProofPreparation().endpointIdentifier, - ) + paymentEndpointIdentifier = preparation.endpointIdentifier, + paymentAppId = preparation.appId, + ).getOrThrow() } - ?: Result.success(Unit) - private fun completeOnchainPaymentProofInBackground(request: PaykitPaymentRequest?, txId: String) { + private fun completeOnchainPaymentProofInBackground( + request: PaykitPaymentRequest?, + txId: String, + preparation: PaymentProofPreparation?, + ) { val paymentRequest = request ?: return - val endpointIdentifier = paymentProofPreparation().endpointIdentifier + if (preparation == null) return viewModelScope.launch { paykitPaymentProofRepo.completeOnchainPayment( request = paymentRequest, txid = txId, - paymentEndpointIdentifier = endpointIdentifier, + paymentEndpointIdentifier = preparation.endpointIdentifier, + paymentAppId = preparation.appId, ) if (paymentRequest.billingPeriod != null) refreshIncomingPaykitPaymentRequests() } @@ -4420,7 +4747,11 @@ class AppViewModel @Inject constructor( request?.let { paykitPaymentProofRepo.cancelPreparation(it) } } - private fun paymentProofPreparation(): PaymentProofPreparation { + private fun paymentProofPreparation( + contactPaymentContext: ContactPaymentContext? = synchronized(contactPaymentContextLock) { + activeContactPaymentContext + }, + ): PaymentProofPreparation { val methodId = when (_sendUiState.value.payMethod) { SendMethod.ONCHAIN -> PublicPaykitRepo.onchainMethodId(_sendUiState.value.address) SendMethod.LIGHTNING -> if (_sendUiState.value.lnurl is LnurlParams.LnurlPay) { @@ -4429,8 +4760,11 @@ class AppViewModel @Inject constructor( MethodId.Bolt11 } } + val appId = contactPaymentContext?.privatePaymentContext?.paymentAppsByEndpoint?.get(methodId.rawValue) + ?: throw PaykitPaymentRequestError.RequestUnavailable return PaymentProofPreparation( endpointIdentifier = methodId.rawValue, + appId = appId, kind = if (methodId.isOnchain) PaykitPaymentProofKind.Onchain else PaykitPaymentProofKind.Lightning, ) } @@ -4605,6 +4939,7 @@ class AppViewModel @Inject constructor( address: String, amount: ULong, tags: List = emptyList(), + paymentDeadlineAt: Instant? = null, beforeSendAttempt: suspend () -> Unit = {}, onBroadcast: suspend (Txid) -> Unit = {}, ): Result { @@ -4618,6 +4953,7 @@ class AppViewModel @Inject constructor( amount == walletRepo.balanceState.value.maxSendOnchainSats, tags = tags, beforeSendAttempt = beforeSendAttempt, + paymentDeadlineAt = paymentDeadlineAt, onBroadcast = { broadcastTxId = it onBroadcast(it) @@ -4628,9 +4964,15 @@ class AppViewModel @Inject constructor( private suspend fun sendLightning( bolt11: String, amount: ULong? = null, + paymentDeadlineAt: Instant? = null, onBeforeSend: suspend () -> Boolean, ): Result { - return lightningRepo.payInvoice(bolt11 = bolt11, sats = amount, onBeforeSend = onBeforeSend).onSuccess { hash -> + return lightningRepo.payInvoice( + bolt11 = bolt11, + sats = amount, + paymentDeadlineAt = paymentDeadlineAt, + onBeforeSend = onBeforeSend, + ).onSuccess { hash -> // Wait until matching payment event is received (with timeout for hold invoices) val result = lightningRepo.nodeEvents.watchUntil(LightningRepo.SEND_LN_TIMEOUT) { when (it) { @@ -5014,6 +5356,7 @@ class AppViewModel @Inject constructor( } fun showSheet(sheetType: Sheet) { + if (sheetType !== paymentRequestPreparation.value?.sheet) clearPaymentRequestPreparation() val previousJob = sheetTransitionJob val nextJob = viewModelScope.launch(start = CoroutineStart.LAZY) { receiveSheetContext = null @@ -5050,6 +5393,12 @@ class AppViewModel @Inject constructor( ) { return } + paymentRequestPreparation.value?.takeIf { currentSheet.value === it.sheet }?.let { + dismissedPreparingRequestIds.add(it.request.id) + clearPaymentRequestPreparation(it) + if (requestedPaymentRequestId.value == it.request.id) clearRequestedPaymentRequest() + clearPaymentRequestPresentationRetry(it.request.id) + } if (_currentSheet.value is Sheet.Send) { cancelHardwarePaymentRequestIfNeeded() resetQuickPay() @@ -5073,6 +5422,7 @@ class AppViewModel @Inject constructor( else -> _currentSheet.update { null } } + restoreDeferredSubscriptionNotification() showQueuedPairingCodeSheet() if (shouldFlushDeferredScan) flushDeferredScan() } @@ -5269,14 +5619,19 @@ class AppViewModel @Inject constructor( } } + override fun onCleared() { + isSubmittingPaymentRequest = false + super.onCleared() + } + suspend fun prepareHardwareContactPayment(): Boolean { val contactPaymentContext = synchronized(contactPaymentContextLock) { activeContactPaymentContext } if (isPreparedContactPayment(contactPaymentContext)) return true val incomingPaymentRequest = contactPaymentContext?.incomingPaymentRequest val proofPreparation = preparePaymentProof(incomingPaymentRequest) - if (proofPreparation.exceptionOrNull() is PaykitPaymentRequestError.OperationInProgress) { - handlePaymentPreparationFailure(PaykitPaymentRequestError.OperationInProgress, contactPaymentContext) + proofPreparation.exceptionOrNull()?.let { + handlePaymentPreparationFailure(it, contactPaymentContext) return false } val preparedPaymentProofRequest = proofPreparation.getOrNull() @@ -5299,29 +5654,74 @@ class AppViewModel @Inject constructor( return true } + val hardwarePaymentDeadlineAt: Instant? + get() = synchronized(contactPaymentContextLock) { + activeContactPaymentContext?.incomingPaymentRequest?.paymentDeadlineAt + } + + fun onHardwarePaymentSubmissionChanged(active: Boolean) { + paykitPaymentRequestRepo.setPaymentSubmissionActive(active && activeIncomingPaymentRequest() != null) + } + + fun onHardwareBroadcastAttemptChanged(attempted: Boolean) { + synchronized(contactPaymentContextLock) { + attemptedHardwarePaymentContext = if (attempted) preparedContactPaymentContext else null + } + } + suspend fun authorizeHardwareContactPayment(hasAttemptedBroadcast: Boolean): Boolean { val contactPaymentContext = synchronized(contactPaymentContextLock) { activeContactPaymentContext } val request = contactPaymentContext?.incomingPaymentRequest ?: return true val error = paykitPaymentRequestRepo.ensurePaymentAllowed(request).exceptionOrNull() ?: return true - if (hasAttemptedBroadcast) { + handleHardwarePaymentFailure(error, contactPaymentContext, hasAttemptedBroadcast) + return false + } + + suspend fun onHardwarePaymentDeadlineExpired(hasAttemptedBroadcast: Boolean) { + val contactPaymentContext = synchronized(contactPaymentContextLock) { activeContactPaymentContext } + handleHardwarePaymentFailure( + PaykitPaymentRequestError.RequestExpired, + contactPaymentContext, + hasAttemptedBroadcast, + ) + } + + private suspend fun handleHardwarePaymentFailure( + error: Throwable, + contactPaymentContext: ContactPaymentContext?, + hasAttemptedBroadcast: Boolean, + ) { + val preservesUncertainPayment = synchronized(contactPaymentContextLock) { + contactPaymentContext != null && contactPaymentContext == attemptedHardwarePaymentContext + } + if (hasAttemptedBroadcast || preservesUncertainPayment) { toast(error) - return false + return } - paykitPaymentProofRepo.failOnchainPayment(request) + contactPaymentContext?.incomingPaymentRequest?.let { paykitPaymentProofRepo.failOnchainPayment(it) } + isSubmittingPaymentRequest = false releasePrivatePaymentListIfNeeded(contactPaymentContext) synchronized(contactPaymentContextLock) { if (preparedContactPaymentContext == contactPaymentContext) preparedContactPaymentContext = null } handlePaymentPreparationFailure(error, contactPaymentContext) - return false } fun completeHardwareContactPayment(txId: String) { - val incomingPaymentRequest = synchronized(contactPaymentContextLock) { - activeContactPaymentContext?.incomingPaymentRequest + acknowledgeHardwarePaymentResolution(txId) + val context = synchronized(contactPaymentContextLock) { + preparedContactPaymentContext + } + val request = context?.incomingPaymentRequest + val preparation = request?.let { paymentProofPreparation(context) } + completeOnchainPaymentProofInBackground(request, txId, preparation) + } + + fun acknowledgeHardwarePaymentResolution(txId: String) { + _sendUiState.update { + if (it.resolvedHardwarePaymentTxId == txId) it.copy(resolvedHardwarePaymentTxId = null) else it } - completeOnchainPaymentProofInBackground(incomingPaymentRequest, txId) } fun onHardwareSignCancelled() { @@ -5330,6 +5730,9 @@ class AppViewModel @Inject constructor( private fun cancelHardwarePaymentRequestIfNeeded() { val request = synchronized(contactPaymentContextLock) { + if (preparedContactPaymentContext == attemptedHardwarePaymentContext) { + preparedContactPaymentContext = null + } activeContactPaymentContext ?.takeIf { it == preparedContactPaymentContext && _sendUiState.value.hardwareWalletId != null } ?.incomingPaymentRequest @@ -5567,13 +5970,19 @@ class AppViewModel @Inject constructor( fun openIncomingPaymentRequestWithTags(id: PaykitPaymentRequestId, tags: List) { val request = paykitPaymentRequestRepo.pendingRequest(id) ?: return - if (paykitPaymentRequestRepo.isProcessing(request) || requestedPaymentRequestId != null) { + val reminder = subscriptionNotificationToDefer() + if (paykitPaymentRequestRepo.isProcessing(request) || + (requestedPaymentRequestId.value != null && reminder == null) + ) { toast(PaykitPaymentRequestError.OperationInProgress) return } + if (reminder != null) deferredSubscriptionNotification = reminder + dismissedPreparingRequestIds.remove(id) invalidatePaymentRequestPresentation() clearPaymentRequestPresentationRetry(id) - requestedPaymentRequestId = id + _requestedPaymentRequestId.update { id } + requestedPaymentRequestIdentity = null requestedPaymentRequest = request shouldRestorePaymentRequestSheet = _currentSheet.value is Sheet.PaymentRequests requestedPaymentRequestTags = tags.filter(String::isNotBlank).distinct().toImmutableList() @@ -5603,7 +6012,7 @@ class AppViewModel @Inject constructor( } clearActiveContactPaymentContext() viewModelScope.launch { - refreshIncomingPaykitPaymentRequests() + refreshIncomingPaykitPaymentRequests(forceFresh = true) openIncomingPaymentRequestWithTags(id, _sendUiState.value.selectedTags) } } @@ -5619,7 +6028,7 @@ class AppViewModel @Inject constructor( clearActiveContactPaymentContext() viewModelScope.launch { try { - refreshIncomingPaykitPaymentRequests() + refreshIncomingPaykitPaymentRequests(forceFresh = true) val request = paykitPaymentRequestRepo.pendingRequest(id) ?: run { toast(PaykitPaymentRequestError.RequestUnavailable) return@launch @@ -5648,7 +6057,7 @@ class AppViewModel @Inject constructor( } suspend fun dismissIncomingPaymentRequest(request: PaykitPaymentRequest): Result { - if (requestedPaymentRequestId == request.id || request.id in _rejectingPaymentRequestIds.value) { + if (requestedPaymentRequestId.value == request.id || request.id in _rejectingPaymentRequestIds.value) { return Result.failure(PaykitPaymentRequestError.OperationInProgress).onFailure(::toast) } _rejectingPaymentRequestIds.update { it + request.id } @@ -5708,6 +6117,11 @@ class AppViewModel @Inject constructor( toast(error) hideSheet() } + if (contactPaymentContext?.incomingPaymentRequest != null) { + viewModelScope.launch { + refreshIncomingPaykitPaymentRequests(PaykitPaymentRequestRefreshMode.STORED, forceFresh = true) + } + } } fun handleDeeplinkIntent(intent: Intent) { @@ -5977,8 +6391,7 @@ class AppViewModel @Inject constructor( private const val ADDRESS_VALIDATION_DEBOUNCE_MS = 1000L private const val PAYKIT_CHANNEL_USABILITY_REFRESH_DELAY_MS = 5_000L private val PAYKIT_PAYMENT_REQUEST_REFRESH_INTERVAL = 10.seconds - private val PAYKIT_MAINTENANCE_INTERVALS = listOf(30.seconds, 60.seconds, 120.seconds) - private val INITIAL_PAYKIT_SYNC_RETRY_DELAYS = List(14) { 2.seconds } + private val PAYKIT_MAINTENANCE_INTERVALS = listOf(30.seconds, 60.seconds) private val PAYKIT_PAYMENT_REQUEST_PRESENTATION_RETRY_DELAYS = List(14) { 2.seconds } private val PAYKIT_PAYMENT_REQUEST_PRESENTATION_RETRY_INTERVAL = 120.seconds private val PUBLIC_PAYKIT_SYNC_DEBOUNCE = 1.seconds @@ -6057,6 +6470,7 @@ data class SendUiState( val isPaymentRequest: Boolean = false, val paymentRequestNote: String? = null, val hardwareWalletId: String? = null, + val resolvedHardwarePaymentTxId: String? = null, val hardwareWalletName: String? = null, val hardwareAvailableSats: ULong = 0uL, val isSubscriptionPayment: Boolean = false, @@ -6102,9 +6516,18 @@ data class ContactPaymentContext( private data class PaymentProofPreparation( val endpointIdentifier: String, + val appId: String, val kind: PaykitPaymentProofKind, ) +private data class PaymentRequestPreparation( + val request: PaykitPaymentRequest, + val identity: String, + val generation: Long, +) { + val sheet = Sheet.Send(SendRoute.Confirm, preparingRequest = request) +} + private data class PaykitContactSyncState( val publicKey: String?, val contactKeys: Set, @@ -6187,20 +6610,24 @@ private class LightningPaymentFailedError( private fun Throwable.isDefiniteOnchainPreBroadcastFailure(): Boolean = generateSequence(this as Throwable?) { it.cause } .any { - it is ServiceError.NodeNotSetup || - it is ServiceError.NodeNotStarted || - it is NodeException.NotRunning || - it is NodeException.OnchainTxCreationFailed || - it is NodeException.OnchainTxSigningFailed || - it is NodeException.WalletOperationFailed || - it is NodeException.PersistenceFailed || - it is NodeException.InvalidAddress || - it is NodeException.InvalidAmount || - it is NodeException.InvalidNetwork || - it is NodeException.InvalidFeeRate || - it is NodeException.InsufficientFunds || - it is NodeException.CoinSelectionFailed || - it is NodeException.NoSpendableOutputs + when (it) { + is ServiceError.NodeNotSetup, + is ServiceError.NodeNotStarted, + is ServiceError.PaymentDeadlineExpired, + is NodeException.NotRunning, + is NodeException.OnchainTxCreationFailed, + is NodeException.OnchainTxSigningFailed, + is NodeException.WalletOperationFailed, + is NodeException.PersistenceFailed, + is NodeException.InvalidAddress, + is NodeException.InvalidAmount, + is NodeException.InvalidNetwork, + is NodeException.InvalidFeeRate, + is NodeException.InsufficientFunds, + is NodeException.CoinSelectionFailed, + is NodeException.NoSpendableOutputs -> true + else -> false + } } sealed interface LnurlParams { diff --git a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt index 403c97d022..99e724e7a7 100644 --- a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt @@ -21,17 +21,13 @@ import to.bitkit.R import to.bitkit.data.SettingsStore import to.bitkit.data.WidgetsStore import to.bitkit.data.hasPaykitState -import to.bitkit.data.hasPublicPaykitPublicationState -import to.bitkit.data.paykitDisabled import to.bitkit.ext.runSuspendCatching import to.bitkit.flags.PaykitFeatureFlags import to.bitkit.models.Toast import to.bitkit.models.TransactionSpeed import to.bitkit.repositories.ContactPaymentSettingsRepo -import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.PublicPaykitError -import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.repositories.WidgetsRepo import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger @@ -45,8 +41,6 @@ class SettingsViewModel @Inject constructor( private val settingsStore: SettingsStore, private val pubkyRepo: PubkyRepo, private val contactPaymentSettingsRepo: ContactPaymentSettingsRepo, - private val publicPaykitRepo: PublicPaykitRepo, - private val privatePaykitRepo: PrivatePaykitRepo, private val widgetsStore: WidgetsStore, private val widgetsRepo: WidgetsRepo, ) : ViewModel() { @@ -276,38 +270,9 @@ class SettingsViewModel @Inject constructor( } private suspend fun clearPaykitState() { - val previousSettings = settingsStore.data.first() - val hadPublicPaykitState = previousSettings.hasPublicPaykitPublicationState() - val hadPrivatePaykitState = previousSettings.sharesPrivatePaykitEndpoints - settingsStore.update { - it.paykitDisabled(markPublicCleanupPending = it.hasPublicPaykitPublicationState()) - } - removePaykitEndpoints(hadPublicPaykitState, hadPrivatePaykitState) - } - - private suspend fun removePaykitEndpoints(hadPublicPaykitState: Boolean, hadPrivatePaykitState: Boolean) { - val contacts = pubkyRepo.contacts.value.map { it.publicKey } - - if (hadPublicPaykitState) { - publicPaykitRepo.syncPublishedEndpoints(publish = false) - .onSuccess { - settingsStore.update { it.copy(publicPaykitCleanupPending = false) } - } - .onFailure { - settingsStore.update { it.copy(publicPaykitCleanupPending = true) } - Logger.warn("Failed to remove public Paykit endpoints after disabling Paykit UI", it, context = TAG) - } - } else if (hadPrivatePaykitState) { - publicPaykitRepo.syncLocalReceiverMarker(publicSharingEnabled = false, privateSharingEnabled = false) - .onFailure { - settingsStore.update { settings -> settings.copy(publicPaykitCleanupPending = true) } - Logger.warn("Failed to remove Paykit receiver marker after disabling Paykit UI", it, context = TAG) - } - } - - privatePaykitRepo.disableSharingAndPruneUnsavedContactState(contacts) + contactPaymentSettingsRepo.disablePaykit() .onFailure { - Logger.warn("Failed to remove private Paykit endpoints after disabling Paykit UI", it, context = TAG) + Logger.warn("Failed to remove Paykit endpoints after disabling Paykit UI", it, context = TAG) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7fb21c0f53..9ad062d609 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -655,6 +655,8 @@ Authorize Authorizing… OK + Read and manage your private Paykit data, and send Paykit messages on your behalf. Your Pubky identity secret and wallet spending keys are not shared. + Paykit access Requested permissions Requester ID: %1$s A service is requesting permission to access and edit your <accent>%1$s</accent> data. diff --git a/app/src/test/java/to/bitkit/ext/BroadcastExceptionExtTest.kt b/app/src/test/java/to/bitkit/ext/BroadcastExceptionExtTest.kt index f4e5c99846..cba5b16adc 100644 --- a/app/src/test/java/to/bitkit/ext/BroadcastExceptionExtTest.kt +++ b/app/src/test/java/to/bitkit/ext/BroadcastExceptionExtTest.kt @@ -30,4 +30,20 @@ class BroadcastExceptionExtTest { assertFalse(error.isBroadcastConnectivityFailure()) } + + @Test + fun `only invalid raw transactions are definite broadcast failures`() { + val invalidTransactions = listOf( + BroadcastException.InvalidHex("bad hex"), + BroadcastException.InvalidTransaction("bad tx"), + ) + for (error in invalidTransactions) { + assertTrue(AppError(error).isDefiniteHardwarePreBroadcastFailure()) + } + assertFalse( + BroadcastException.ElectrumException("Broadcast failed: disconnected") + .isDefiniteHardwarePreBroadcastFailure() + ) + assertFalse(AppError("unknown result").isDefiniteHardwarePreBroadcastFailure()) + } } diff --git a/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt b/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt index 912a5174ac..2b958d3061 100644 --- a/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt +++ b/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt @@ -6,6 +6,7 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import org.junit.Test import to.bitkit.repositories.PaykitPaymentProofKind +import to.bitkit.repositories.PaykitPaymentRequestId import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -18,7 +19,7 @@ class PaykitPaymentStateBackupTest { fun `payment backup accepts shared wire format and retains pending payment`() { WalletScope.pushTestOverride("wallet0").use { val fixture = """ - {"subscriptions":{"alice":{"acceptances":[{"id":{"paymentRequestId":"request","counterparty":"bob","counterpartyReceiverPath":"bitkit/server"},"acceptedAt":"2026-09-24T10:00:00.123Z"}],"presentedProposalIds":[]}},"pendingProofs":[{"identity":"alice","requestId":{"paymentRequestId":"request","counterparty":"bob","counterpartyReceiverPath":"bitkit/server","billingPeriodStartsAt":"2026-09-24T10:00:00.100Z"},"paymentEndpointIdentifier":"bitcoin-onchain","kind":"bitcoin-onchain-txid","paymentStarted":true,"billingPeriod":{"startsAt":"2026-09-24T10:00:00.100Z","endsAt":"2026-09-25T10:00:00.100Z"},"onchainMatchingTransactionIdsBeforeAttempt":[]}]} + {"subscriptions":{"alice":{"acceptances":[{"id":{"paymentRequestId":"request","counterparty":"bob"},"acceptedAt":"2026-09-24T10:00:00.123Z"}],"presentedProposalIds":[]}},"pendingProofs":[{"identity":"alice","requestId":{"paymentRequestId":"request","counterparty":"bob","billingPeriodStartsAt":"2026-09-24T10:00:00.100Z"},"paymentAppId":"bitkit","paymentEndpointIdentifier":"bitcoin-onchain","kind":"bitcoin-onchain-txid","paymentStarted":true,"billingPeriod":{"startsAt":"2026-09-24T10:00:00.100Z","endsAt":"2026-09-25T10:00:00.100Z"},"onchainMatchingTransactionIdsBeforeAttempt":[]}]} """.trimIndent() val backup = Json.decodeFromString(fixture) val restored = backup.pendingProofs.single().restored() @@ -36,10 +37,12 @@ class PaykitPaymentStateBackupTest { ) }, pendingProofs = listOf(PaykitPaymentStateBackup.Proof(restored)), + acceptedOneTimeRequests = mapOf("alice" to setOf(PaykitPaymentRequestId("one-time", "bob"))), ) val decoded = Json.decodeFromString(Json.encodeToString(rebuilt)) assertEquals(restored, decoded.pendingProofs.single().restored()) assertEquals(backup.subscriptions, decoded.subscriptions) + assertEquals(rebuilt.acceptedOneTimeRequests, decoded.acceptedOneTimeRequests) val hardwareProof = restored.copy(onchainWalletId = "hardware-wallet") val hardwareBackup = PaykitPaymentStateBackup.Proof(hardwareProof) diff --git a/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt b/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt index 38afcab01f..32f92a9362 100644 --- a/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt +++ b/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt @@ -1,8 +1,10 @@ package to.bitkit.models +import to.bitkit.models.PubkyAuthClaim.Item import java.net.URLEncoder import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertNull @@ -10,6 +12,92 @@ import kotlin.test.assertTrue class PubkyAuthRequestTest { + @Test + fun `parse preserves each companion selection and received item order`() { + val expected = mapOf( + "watch-only-account-v1" to listOf(Item.WATCH_ONLY_ACCOUNT_V1), + "paykit-access-v1" to listOf(Item.PAYKIT_ACCESS_V1), + "paykit-access-v1.watch-only-account-v1" to listOf(Item.PAYKIT_ACCESS_V1, Item.WATCH_ONLY_ACCOUNT_V1), + "watch-only-account-v1.paykit-access-v1" to listOf(Item.WATCH_ONLY_ACCOUNT_V1, Item.PAYKIT_ACCESS_V1), + ) + for ((wireValue, items) in expected) { + val claim = requireNotNull( + PubkyAuthRequest.parseBitkitClaim( + authUrl("/pub/paykit/:rw", wireValue), + "/pub/paykit/:rw", + ).getOrThrow(), + ) + assertEquals(items, claim.items) + assertEquals(wireValue, claim.wireValue) + assertEquals(Item.PAYKIT_ACCESS_V1 in items, claim.includesPaykitAccess) + assertEquals(Item.WATCH_ONLY_ACCOUNT_V1 in items, claim.includesWatchOnlyAccount) + } + } + + @Test + fun `constructor copies items in canonical order and rejects empty or duplicate selections`() { + val items = arrayOf(Item.WATCH_ONLY_ACCOUNT_V1, Item.PAYKIT_ACCESS_V1) + val claim = PubkyAuthClaim(*items) + items[0] = Item.PAYKIT_ACCESS_V1 + + assertEquals(listOf(Item.PAYKIT_ACCESS_V1, Item.WATCH_ONLY_ACCOUNT_V1), claim.items) + assertEquals("paykit-access-v1.watch-only-account-v1", claim.wireValue) + assertFailsWith { PubkyAuthClaim() } + for (item in Item.entries) { + assertFailsWith { PubkyAuthClaim(item, item) } + } + } + + @Test + fun `parse rejects empty duplicate unknown and combined identifiers`() { + val invalid = listOf( + "", ".", ".paykit-access-v1", "paykit-access-v1.", + "paykit-access-v1..watch-only-account-v1", + "paykit-access-v1.paykit-access-v1", "watch-only-account-v1.watch-only-account-v1", + "paykit-access-v1.watch-only-account-v1.paykit-access-v1", + "unknown-v1", "paykit-access-v1.unknown-v1", "unknown-v1.watch-only-account-v1", + "paykit-access-and-watch-only-account-v1", "PAYKIT-ACCESS-V1", "paykit-access-v1%20", + ) + for (wireValue in invalid) { + assertIs( + PubkyAuthRequest.parseBitkitClaim(authUrl("/pub/paykit/:rw", wireValue), "/pub/paykit/:rw") + .exceptionOrNull(), + wireValue, + ) + } + } + + @Test + fun `parse rejects duplicate mixed or encoded companion parameters`() { + for (claim in companionSelections()) { + for (other in companionSelections()) { + val url = authUrl("/pub/paykit/:rw", claim.wireValue) + "&x-bitkit-%63laim=${other.wireValue}" + assertIs( + PubkyAuthRequest.parseBitkitClaim(url, "/pub/paykit/:rw").exceptionOrNull(), + ) + } + } + } + + @Test + fun `all companion claims require the exact Paykit scope`() { + val invalidCapabilities = listOf( + "/pub/paykit/:r", + "/pub/paykit/v0/:rw", + "/pub/:rw", + "/pub/paykit/:rw,/pub/other/:rw", + "/pub/paykit/:rw,/pub/paykit/:rw", + ) + for (claim in companionSelections()) { + for (capabilities in invalidCapabilities) { + assertIs( + PubkyAuthRequest.parseBitkitClaim(authUrl(capabilities, claim.wireValue), capabilities) + .exceptionOrNull(), + ) + } + } + } + @Test fun `parse authorized signup preserves registration and authorization details`() { listOf("pubkyring", "pubkyauth").forEach { scheme -> @@ -57,35 +145,22 @@ class PubkyAuthRequestTest { @Test fun `parse recognizes watch-only account claim`() { - val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES + val capabilities = PubkyAuthClaim.REQUIRED_CAPABILITIES val request = PubkyAuthRequest.parse( - rawUrl = authUrl(capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue), + rawUrl = authUrl(capabilities, PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1).wireValue), clientId = "paykit.test", relay = "https://httprelay.pubky.app/inbox/", capabilities = capabilities, ).getOrThrow() - assertEquals(PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1, request.bitkitClaim) + assertEquals(PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1), request.bitkitClaim) } @Test - fun `parse recognizes watch-only account claim with reordered capabilities`() { - val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES.split(",").reversed().joinToString(",") - val request = PubkyAuthRequest.parse( - rawUrl = authUrl(capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue), - clientId = "paykit.test", - relay = "https://httprelay.pubky.app/inbox/", - capabilities = capabilities, - ).getOrThrow() - - assertEquals(PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1, request.bitkitClaim) - } - - @Test - fun `matcher recognizes watch-only account claim with capability whitespace`() { - val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES.replace(",", " , ") + fun `matcher recognizes the Paykit scope with surrounding whitespace`() { + val capabilities = " ${PubkyAuthClaim.REQUIRED_CAPABILITIES} " - assertTrue(PubkyAuthClaim.matchesWatchOnlyAccountCapabilities(capabilities)) + assertTrue(PubkyAuthClaim.matchesRequiredCapabilities(capabilities)) } @Test @@ -134,8 +209,8 @@ class PubkyAuthRequestTest { } @Test - fun `parse rejects watch-only capability without Bitkit claim`() { - val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES + fun `parse permits Paykit authorization without a companion claim`() { + val capabilities = PubkyAuthClaim.REQUIRED_CAPABILITIES val result = PubkyAuthRequest.parse( rawUrl = authUrl(capabilities), clientId = "paykit.test", @@ -143,17 +218,17 @@ class PubkyAuthRequestTest { capabilities = capabilities, ) - assertIs(result.exceptionOrNull()) + assertEquals(null, result.getOrThrow().bitkitClaim) } @Test fun `parse rejects duplicate Bitkit claim`() { - val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES + val capabilities = PubkyAuthClaim.REQUIRED_CAPABILITIES val result = PubkyAuthRequest.parse( rawUrl = authUrl( capabilities, - PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue, - PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue, + PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1).wireValue, + PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1).wireValue, ), clientId = "paykit.test", relay = "https://httprelay.pubky.app/inbox/", @@ -165,7 +240,7 @@ class PubkyAuthRequestTest { @Test fun `parse rejects unknown Bitkit claim`() { - val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES + val capabilities = PubkyAuthClaim.REQUIRED_CAPABILITIES val result = PubkyAuthRequest.parse( rawUrl = authUrl(capabilities, "unknown-v1"), clientId = "paykit.test", @@ -181,7 +256,7 @@ class PubkyAuthRequestTest { fun `parse rejects watch-only claim with other capabilities`() { val capabilities = "/pub/paykit/v0/:rw" val result = PubkyAuthRequest.parse( - rawUrl = authUrl(capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue), + rawUrl = authUrl(capabilities, PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1).wireValue), clientId = "paykit.test", relay = "https://httprelay.pubky.app/inbox/", capabilities = capabilities, @@ -191,10 +266,10 @@ class PubkyAuthRequestTest { } @Test - fun `parse rejects watch-only claim without private capability`() { - val capabilities = "/pub/paykit/v0/bitkit/server/:rw" + fun `parse rejects watch-only claim without write capability`() { + val capabilities = "/pub/paykit/:r" val result = PubkyAuthRequest.parse( - rawUrl = authUrl(capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue), + rawUrl = authUrl(capabilities, PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1).wireValue), clientId = "paykit.test", relay = "https://httprelay.pubky.app/inbox/", capabilities = capabilities, @@ -205,9 +280,9 @@ class PubkyAuthRequestTest { @Test fun `parse rejects watch-only claim with empty capability`() { - val capabilities = "${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}," + val capabilities = "${PubkyAuthClaim.REQUIRED_CAPABILITIES}," val result = PubkyAuthRequest.parse( - rawUrl = authUrl(capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue), + rawUrl = authUrl(capabilities, PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1).wireValue), clientId = "paykit.test", relay = "https://httprelay.pubky.app/inbox/", capabilities = capabilities, @@ -272,8 +347,8 @@ class PubkyAuthRequestTest { @Test fun `displayPath removes capability separator`() { - val perm = PubkyAuthPermission(path = "/pub/paykit/v0/bitkit/server/", accessLevel = "rw") - assertEquals("/pub/paykit/v0/bitkit/server", perm.displayPath) + val perm = PubkyAuthPermission(path = "/pub/paykit/", accessLevel = "rw") + assertEquals("/pub/paykit", perm.displayPath) } @Test @@ -303,6 +378,13 @@ class PubkyAuthRequestTest { ) } + private fun companionSelections() = listOf( + PubkyAuthClaim(Item.PAYKIT_ACCESS_V1), + PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1), + PubkyAuthClaim(Item.PAYKIT_ACCESS_V1, Item.WATCH_ONLY_ACCOUNT_V1), + requireNotNull(PubkyAuthClaim.fromWireValue("watch-only-account-v1.paykit-access-v1")), + ) + private fun authUrl(capabilities: String, vararg claimValues: String): String { val claims = claimValues.joinToString(separator = "") { "&${PubkyAuthClaim.QUERY_PARAMETER}=$it" diff --git a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt index 61b8b3e102..7db4f233e2 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt @@ -173,6 +173,23 @@ class ActivityRepoTest : BaseUnitTest() { wheneverBlocking { coreService.activity.allPossibleTags() }.thenReturn(emptyList()) } + @Test + fun `Paykit backfill notifies activity observers only after a changed row`() = test { + whenever(coreService.activity.backfillPaykitContacts()).thenReturn(false, true) + + sut.backfillPaykitContacts().getOrThrow() + assertEquals(0L, sut.activitiesChanged.value) + sut.backfillPaykitContacts().getOrThrow() + assertTrue(sut.activitiesChanged.value > 0L) + assertEquals(0L, sut.activityTagsChanged.value) + } + + @Test + fun `Paykit backfill preserves cancellation`() = test { + whenever(coreService.activity.backfillPaykitContacts()).thenThrow(CancellationException("canceled")) + assertFailsWith { sut.backfillPaykitContacts() } + } + @Test fun `syncActivities success flow`() = test { val payments = listOf(testPaymentDetails) diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index df3195696d..adcb8760a8 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -6,9 +6,12 @@ import com.synonym.bitkitcore.PreActivityMetadata import com.synonym.vssclient.VssItem import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent import kotlinx.serialization.json.JsonObject @@ -104,12 +107,14 @@ class BackupRepoTest : BaseUnitTest() { private val settingsData = MutableStateFlow(SettingsData()) private val widgetsData = MutableStateFlow(WidgetsData()) private val hwWalletData = MutableStateFlow(HwWalletData()) + private val paymentSubmissionActive = MutableStateFlow(false) private lateinit var sut: BackupRepo @Before fun setUp() = test { whenever(clock.now()).thenReturn(Instant.fromEpochMilliseconds(1_000)) + whenever(paykitSdkService.isPaymentSubmissionActive).thenReturn(paymentSubmissionActive) whenever(db.transferDao()).thenReturn(transferDao) whenever { transferDao.upsert(any>()) }.thenReturn(Unit) whenever { cacheStore.updateBackupStatus(any(), any()) }.thenReturn(Unit) @@ -145,6 +150,136 @@ class BackupRepoTest : BaseUnitTest() { sut = createSut() } + @Test + fun `wallet backup waits for submission and includes the latest pending state`() = test { + val statuses = MutableStateFlow( + mapOf(BackupCategory.WALLET to BackupItemStatus(required = 1_000)), + ) + val allowClear = CompletableDeferred(Unit) + stubBackupStatuses(statuses, allowClear) {} + stubBackupObservers() + val proofVersion = MutableStateFlow(0L) + whenever(paykitPaymentProofStore.backupStateVersion).thenReturn(proofVersion) + paymentSubmissionActive.value = true + try { + sut.startObservingBackups() + runCurrent() + advanceTimeBy(5_000) + runCurrent() + proofVersion.value++ + runCurrent() + proofVersion.value++ + runCurrent() + + assertTrue(statuses.value.getValue(BackupCategory.WALLET).isRequired) + verify(privatePaykitRepo, never()).backupSnapshot() + verify(vssBackupClient, never()).putObject(eq(BackupCategory.WALLET.name), any()) + + whenever(privatePaykitRepo.backupSnapshot()).thenReturn(Result.success("pending-write")) + paymentSubmissionActive.value = false + runCurrent() + + val payload = argumentCaptor() + verify(vssBackupClient).putObject(eq(BackupCategory.WALLET.name), payload.capture()) + assertEquals( + "pending-write", + json.decodeFromString(String(payload.firstValue)).paykitSdkBackupState, + ) + verify(paykitPaymentProofRepo).backupSnapshot() + verify(privatePaykitRepo).backupSnapshot() + } finally { + sut.stopObservingBackups() + } + } + + @Test + fun `cancelled manual wallet backup retains pending state and resumes automatically`() = test { + val statuses = MutableStateFlow( + mapOf(BackupCategory.WALLET to BackupItemStatus(required = 1_000)), + ) + stubBackupStatuses(statuses, CompletableDeferred(Unit)) {} + stubBackupObservers() + paymentSubmissionActive.value = true + whenever(privatePaykitRepo.backupSnapshot()).doSuspendableAnswer { + paymentSubmissionActive.first { !it } + Result.success("pending-write") + } + + val backup = launch { sut.triggerBackup(BackupCategory.WALLET) } + runCurrent() + verify(privatePaykitRepo).backupSnapshot() + assertTrue(statuses.value.getValue(BackupCategory.WALLET).running) + + backup.cancelAndJoin() + + assertTrue(backup.isCancelled) + assertFalse(statuses.value.getValue(BackupCategory.WALLET).running) + assertTrue(statuses.value.getValue(BackupCategory.WALLET).isRequired) + verify(vssBackupClient, never()).putObject(eq(BackupCategory.WALLET.name), any()) + + try { + sut.startObservingBackups() + paymentSubmissionActive.value = false + runCurrent() + advanceTimeBy(5_000) + runCurrent() + + val payload = argumentCaptor() + verify(vssBackupClient).putObject(eq(BackupCategory.WALLET.name), payload.capture()) + assertEquals( + "pending-write", + json.decodeFromString(String(payload.firstValue)).paykitSdkBackupState, + ) + assertFalse(statuses.value.getValue(BackupCategory.WALLET).running) + assertFalse(statuses.value.getValue(BackupCategory.WALLET).isRequired) + } finally { + sut.stopObservingBackups() + } + } + + @Test + fun `cancelled manual backup completes success and failure status writes`() = test { + for (uploadSucceeds in listOf(true, false)) { + val category = BackupCategory.SETTINGS + val statuses = MutableStateFlow(mapOf(category to BackupItemStatus(required = 1_000))) + val statusWriteStarted = CompletableDeferred() + val finishStatusWrite = CompletableDeferred() + whenever(cacheStore.backupStatuses).thenReturn(statuses) + whenever(cacheStore.updateBackupStatus(eq(category), any())).doSuspendableAnswer { + val transform = it.getArgument<(BackupItemStatus) -> BackupItemStatus>(1) + val updated = transform(statuses.value.getValue(category)) + if (!updated.running) { + statusWriteStarted.complete(Unit) + finishStatusWrite.await() + } + statuses.update { current -> current + (category to updated) } + } + val uploadResult = if (uploadSucceeds) { + Result.success(VssItem(key = category.name, value = byteArrayOf(), version = 1)) + } else { + Result.failure(BackupRepoTestError("upload failed")) + } + whenever(vssBackupClient.putObject(eq(category.name), any())).thenReturn(uploadResult) + + val backup = launch { sut.triggerBackup(category) } + runCurrent() + assertTrue(statusWriteStarted.isCompleted) + assertTrue(statuses.value.getValue(category).running) + + backup.cancel() + runCurrent() + assertFalse(backup.isCompleted) + finishStatusWrite.complete(Unit) + backup.join() + + val status = statuses.value.getValue(category) + assertTrue(backup.isCancelled) + assertFalse(status.running) + assertEquals(if (uploadSucceeds) 1_000L else 0L, status.synced) + assertEquals(!uploadSucceeds, status.isRequired) + } + } + @Test fun `start observing is skipped while wiping`() = test { sut.setWiping(true) diff --git a/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt index a386543212..cbbf15cafe 100644 --- a/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt @@ -1,12 +1,21 @@ package to.bitkit.repositories import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent import org.junit.Before import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.clearInvocations +import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify @@ -14,9 +23,12 @@ import org.mockito.kotlin.whenever import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.models.PubkyProfile +import to.bitkit.services.PaykitSdkOperationLock.Priority import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError +import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertSame import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) @@ -43,7 +55,8 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { Unit } whenever { publicPaykitRepo.syncPublishedEndpoints(any()) }.thenReturn(Result.success(Unit)) - whenever { publicPaykitRepo.syncLocalReceiverMarker(anyOrNull(), anyOrNull()) } + whenever { publicPaykitRepo.syncPublishedEndpoints(any(), any()) }.thenReturn(Result.success(Unit)) + whenever { publicPaykitRepo.syncPaykitApp(anyOrNull()) } .thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.enableSharingAndPrepareSavedContacts(any>()) } .thenReturn(Result.success(Unit)) @@ -105,20 +118,31 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { assertTrue(result.isFailure) assertFalse(settingsFlow.value.sharesPublicPaykitEndpoints) assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) - verify(publicPaykitRepo).syncPublishedEndpoints(publish = false) + inOrder(privatePaykitRepo, publicPaykitRepo) { + verify(privatePaykitRepo).disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) + verify(publicPaykitRepo).syncPublishedEndpoints(publish = false, appSyncPriority = Priority.Interactive) + } } @Test - fun `failed private setup restores disabled settings`() = test { + fun `failed private setup restores sharing with withdrawal priority only when disabled`() = test { whenever { privatePaykitRepo.enableSharingAndPrepareSavedContacts(any>()) } .thenReturn(Result.failure(ContactPaymentSettingsTestError("private setup failed"))) - val result = createSut().setEnabled(true) + for (wasPublic in listOf(false, true)) { + clearInvocations(publicPaykitRepo) + settingsFlow.value = SettingsData(sharesPublicPaykitEndpoints = wasPublic) - assertTrue(result.isFailure) - assertFalse(settingsFlow.value.sharesPublicPaykitEndpoints) - assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) - verify(publicPaykitRepo).syncPublishedEndpoints(publish = false) + val result = createSut().setEnabled(true) + + assertTrue(result.isFailure) + assertEquals(wasPublic, settingsFlow.value.sharesPublicPaykitEndpoints) + assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) + verify(publicPaykitRepo).syncPublishedEndpoints( + publish = wasPublic, + appSyncPriority = if (wasPublic) Priority.Ordered else Priority.Interactive, + ) + } } @Test @@ -134,8 +158,10 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) assertFalse(settingsFlow.value.sharesPublicPaykitEndpoints) assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) - verify(publicPaykitRepo).syncPublishedEndpoints(publish = false) - verify(privatePaykitRepo).disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) + inOrder(privatePaykitRepo, publicPaykitRepo) { + verify(privatePaykitRepo).disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) + verify(publicPaykitRepo).syncPublishedEndpoints(publish = false, appSyncPriority = Priority.Interactive) + } } @Test @@ -154,6 +180,186 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { verify(privatePaykitRepo, never()).enableSharingAndPrepareSavedContacts(any>()) } + @Test + fun `failed public cleanup keeps sharing disabled and retains its retry`() = test { + val cleanupResults = listOf(Result.success(Unit), Result.failure(ContactPaymentSettingsTestError("withdrawal"))) + for (privateCleanup in cleanupResults) { + settingsFlow.value = SettingsData( + sharesPublicPaykitEndpoints = true, + sharesPrivatePaykitEndpoints = true, + ) + val failure = ContactPaymentSettingsTestError("app update failed") + whenever(publicPaykitRepo.syncPublishedEndpoints(publish = false, appSyncPriority = Priority.Interactive)) + .thenReturn(Result.failure(failure)) + whenever(privatePaykitRepo.disableSharingAndPruneUnsavedContactState(any>())) + .thenReturn(privateCleanup) + + val result = createSut().setEnabled(false) + + assertSame(failure, result.exceptionOrNull()) + assertFalse(settingsFlow.value.sharesPublicPaykitEndpoints) + assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) + assertTrue(settingsFlow.value.publicPaykitCleanupPending) + verify(publicPaykitRepo, never()).syncPublishedEndpoints(publish = true) + verify(privatePaykitRepo, never()).enableSharingAndPrepareSavedContacts(any>()) + } + } + + @Test + fun `enabling waits for both withdrawal phases even when cleanup fails`() = test { + val cleanupResults = listOf(Result.success(Unit), Result.failure(ContactPaymentSettingsTestError("withdrawal"))) + val cases = cleanupResults.flatMap { result -> listOf(false to result, true to result) } + for ((disablePaykit, cleanupResult) in cases) { + clearInvocations(privatePaykitRepo, publicPaykitRepo) + settingsFlow.value = SettingsData(sharesPublicPaykitEndpoints = true, sharesPrivatePaykitEndpoints = true) + val privateCleanup = CompletableDeferred() + val publicCleanup = CompletableDeferred() + whenever(privatePaykitRepo.disableSharingAndPruneUnsavedContactState(any>())) + .doSuspendableAnswer { + privateCleanup.await() + cleanupResult + } + whenever(publicPaykitRepo.syncPublishedEndpoints(publish = false, appSyncPriority = Priority.Interactive)) + .doSuspendableAnswer { + publicCleanup.await() + cleanupResult + } + val sut = createSut() + + val disable = async { if (disablePaykit) sut.disablePaykit() else sut.setEnabled(false) } + runCurrent() + val enable = async { sut.setEnabled(true) } + runCurrent() + + assertFalse(settingsFlow.value.sharesPublicPaykitEndpoints) + assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) + assertFalse(enable.isCompleted) + verify(publicPaykitRepo, never()).syncPublishedEndpoints(publish = true) + privateCleanup.complete(Unit) + runCurrent() + + assertFalse(settingsFlow.value.sharesPublicPaykitEndpoints) + assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) + assertFalse(enable.isCompleted) + verify(publicPaykitRepo, never()).syncPublishedEndpoints(publish = true) + publicCleanup.complete(Unit) + + assertEquals(cleanupResult.isSuccess, disable.await().isSuccess) + assertTrue(enable.await().isSuccess) + assertTrue(settingsFlow.value.sharesPublicPaykitEndpoints) + assertTrue(settingsFlow.value.sharesPrivatePaykitEndpoints) + inOrder(privatePaykitRepo, publicPaykitRepo) { + verify(privatePaykitRepo).disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) + verify(publicPaykitRepo).syncPublishedEndpoints(publish = false, appSyncPriority = Priority.Interactive) + verify(publicPaykitRepo).syncPublishedEndpoints(publish = true) + verify(privatePaykitRepo).enableSharingAndPrepareSavedContacts(listOf(CONTACT_KEY)) + } + } + } + + @Test + fun `sharing changes wait for failed enable rollback`() = test { + val rollback = CompletableDeferred() + whenever(privatePaykitRepo.enableSharingAndPrepareSavedContacts(any>())) + .thenReturn(Result.failure(ContactPaymentSettingsTestError("private setup failed"))) + whenever(privatePaykitRepo.disableSharingAndPruneUnsavedContactState(any>())) + .doSuspendableAnswer { + rollback.await() + Result.success(Unit) + } + val sut = createSut() + + val enable = async { sut.setEnabled(true) } + runCurrent() + val disable = async { sut.setEnabled(false) } + runCurrent() + + assertFalse(disable.isCompleted) + verify(privatePaykitRepo).disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) + verify(publicPaykitRepo, never()).syncPublishedEndpoints( + publish = false, + appSyncPriority = Priority.Interactive, + ) + rollback.complete(Unit) + + assertTrue(enable.await().isFailure) + assertTrue(disable.await().isSuccess) + assertFalse(settingsFlow.value.sharesPublicPaykitEndpoints) + assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) + } + + @Test + fun `foreground cleanup coalesces and sharing waits for it`() = test { + val cleanup = CompletableDeferred() + var cleanupCount = 0 + val sut = createSut() + val reconciliation = launch { + sut.reconcilePendingEndpoints { + cleanupCount++ + cleanup.await() + } + } + runCurrent() + sut.reconcilePendingEndpoints { cleanupCount++ } + val enable = async { sut.setEnabled(true) } + runCurrent() + + assertEquals(1, cleanupCount) + assertFalse(enable.isCompleted) + assertFalse(settingsFlow.value.sharesPublicPaykitEndpoints) + verify(publicPaykitRepo, never()).syncPublishedEndpoints(publish = true) + cleanup.complete(Unit) + reconciliation.join() + + assertTrue(enable.await().isSuccess) + sut.reconcilePendingEndpoints { cleanupCount++ } + assertEquals(2, cleanupCount) + } + + @Test + fun `cancelling queued enable does not publish or block later changes`() = test { + val cleanup = CompletableDeferred() + whenever(privatePaykitRepo.disableSharingAndPruneUnsavedContactState(any>())) + .doSuspendableAnswer { + cleanup.await() + Result.success(Unit) + } + val sut = createSut() + val disable = async { sut.setEnabled(false) } + runCurrent() + val enable = async { sut.setEnabled(true) } + runCurrent() + + assertFalse(enable.isCompleted) + enable.cancelAndJoin() + cleanup.complete(Unit) + + assertTrue(disable.await().isSuccess) + assertTrue(sut.setEnabled(false).isSuccess) + assertFalse(settingsFlow.value.sharesPublicPaykitEndpoints) + assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) + verify(publicPaykitRepo, never()).syncPublishedEndpoints(publish = true) + verify(privatePaykitRepo, never()).enableSharingAndPrepareSavedContacts(any>()) + } + + @Test + fun `cancelling foreground cleanup releases waiting sharing change`() = test { + val sut = createSut() + val reconciliation = launch { + sut.reconcilePendingEndpoints { awaitCancellation() } + } + runCurrent() + val enable = async { sut.setEnabled(true) } + runCurrent() + + assertFalse(enable.isCompleted) + reconciliation.cancelAndJoin() + + assertTrue(enable.await().isSuccess) + assertTrue(settingsFlow.value.sharesPublicPaykitEndpoints) + assertTrue(settingsFlow.value.sharesPrivatePaykitEndpoints) + } + private fun createSut() = ContactPaymentSettingsRepo( settingsStore = settingsStore, publicPaykitRepo = publicPaykitRepo, diff --git a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt index ad7830fbd2..d48ccb4cfb 100644 --- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -2197,7 +2197,7 @@ class HwWalletRepoTest : BaseUnitTest() { assertEquals(true, result.isFailure) verify(trezorRepo, never()).signTxFromPsbt(any(), anyOrNull()) - verify(trezorRepo, never()).broadcastRawTx(any()) + verify(trezorRepo, never()).broadcastRawTx(any(), anyOrNull()) verify(trezorRepo, never()).disconnectStaleSession(any()) } @@ -2227,7 +2227,7 @@ class HwWalletRepoTest : BaseUnitTest() { assertEquals(1_250uL, result.getOrThrow().miningFeeSats) assertEquals(3uL, result.getOrThrow().feeRate) assertEquals(26_250uL, result.getOrThrow().totalSpent) - verify(trezorRepo, never()).broadcastRawTx(any()) + verify(trezorRepo, never()).broadcastRawTx(any(), anyOrNull()) } @Test @@ -2287,7 +2287,7 @@ class HwWalletRepoTest : BaseUnitTest() { assertEquals(true, result.isFailure) verify(trezorRepo).disconnectStaleSession("dev1") - verify(trezorRepo, never()).broadcastRawTx(any()) + verify(trezorRepo, never()).broadcastRawTx(any(), anyOrNull()) } @Test @@ -2328,7 +2328,7 @@ class HwWalletRepoTest : BaseUnitTest() { assertEquals(true, result.isFailure) verify(trezorRepo, never()).disconnectStaleSession(any()) - verify(trezorRepo, never()).broadcastRawTx(any()) + verify(trezorRepo, never()).broadcastRawTx(any(), anyOrNull()) } @Test diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 7bf99bdebe..e76eb0c097 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -975,6 +975,23 @@ class LightningRepoTest : BaseUnitTest() { assertEquals(testPaymentId, result.getOrNull()) } + @Test + fun `payInvoice forwards the payment deadline after final authorization`() = test { + startNodeForTesting() + val deadline = kotlin.time.Instant.parse("2026-10-06T12:00:00Z") + whenever(lightningService.send("bolt11", 1000uL, deadline)).thenReturn("payment-id") + var authorized = false + + val result = sut.payInvoice("bolt11", 1000uL, deadline) { + authorized = true + true + } + + assertTrue(authorized) + assertEquals("payment-id", result.getOrThrow()) + verify(lightningService).send("bolt11", 1000uL, deadline) + } + @Test fun `payInvoice should proceed after timeout when channels are not usable`() = test { startNodeForTesting() @@ -1531,7 +1548,8 @@ class LightningRepoTest : BaseUnitTest() { sats = any(), satsPerVByte = any(), utxosToSpend = anyOrNull(), - isMaxAmount = any() + isMaxAmount = any(), + paymentDeadlineAt = anyOrNull(), ) ).thenReturn("testPaymentId") diff --git a/app/src/test/java/to/bitkit/repositories/PaykitIssuerInteropTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitIssuerInteropTest.kt index 9bafd350c0..a5febcda89 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitIssuerInteropTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitIssuerInteropTest.kt @@ -114,7 +114,6 @@ class PaykitIssuerInteropTest { endpointIdentifiers: List, ) = PaymentRequestRecord( counterparty = "pubkyissuerfixture", - counterpartyReceiverPath = "bitkit/server", paymentRequestId = "71300000-0000-4000-8000-000000000001", localRole = PaymentRequestLocalRole.PAYER, state = PaymentRequestLifecycleState.PROPOSED, @@ -131,6 +130,8 @@ class PaykitIssuerInteropTest { conversion = null, paymentDeadline = null, metadata = METADATA, + paymentEndpoints = null, + requiredAppId = "bitkit", ), acceptedEventId = null, acceptedOutboundStatus = null, @@ -145,6 +146,9 @@ class PaykitIssuerInteropTest { lastOutboundStatus = null, lastEventAt = NOW.toString(), invalidReason = null, + proposalAppId = "bitkit", + payerAppId = null, + executionClaimAppId = null, ) } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index 9079e92502..6d301477f1 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -2,6 +2,8 @@ package to.bitkit.repositories import com.synonym.paykit.BillingPeriod import com.synonym.paykit.IdentityStatus +import com.synonym.paykit.PaykitException +import com.synonym.paykit.PaymentDeadline import com.synonym.paykit.PaymentProofRecord import com.synonym.paykit.PaymentReference import com.synonym.paykit.PaymentRequestAmount @@ -10,7 +12,16 @@ import com.synonym.paykit.PaymentRequestLocalRole import com.synonym.paykit.PaymentRequestRecord import com.synonym.paykit.PaymentRequestTerms import com.synonym.paykit.PrivateJsonObject +import com.synonym.paykit.PubkyIdentityCapability +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent import org.junit.Before import org.junit.Test import org.lightningdevkit.ldknode.NodeException @@ -29,20 +40,22 @@ import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import to.bitkit.data.keychain.Keychain import to.bitkit.models.NodeLifecycleState import to.bitkit.models.WalletScope -import to.bitkit.services.PaykitReceiverPaths import to.bitkit.services.PaykitSdkService import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError import to.bitkit.utils.LdkError import to.bitkit.utils.ServiceError import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Instant +@OptIn(ExperimentalCoroutinesApi::class) class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { companion object { private const val LOCAL_IDENTITY = "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" @@ -61,6 +74,12 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { private var shouldFailNextLoad = false private var shouldFailNextSave = false private var shouldFailProofRemoval = false + private val keychain = mock() + private val projectionStore = PaykitPaymentProofStore(keychain) + private val projectionIdentity = MutableStateFlow(LOCAL_IDENTITY) + private var storedProjectionJson: String? = null + private var unreadableProjectionState = false + private var requestStateChanges = 0 @Before fun setUp() = test { @@ -69,7 +88,9 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { shouldFailNextSave = false shouldFailProofRemoval = false whenever(store.hasPendingProofs()).thenReturn(true) - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.processPendingPrivateMessages()).thenReturn(emptyList()) whenever(onchainPaymentLookup.existingTransactionIds(any(), any(), any())).thenReturn(emptySet()) whenever(store.load()).thenAnswer { @@ -91,6 +112,90 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { } storedProofs = proofs } + whenever(keychain.loadString(any())).thenAnswer { + if (unreadableProjectionState) "not-json" else storedProjectionJson + } + whenever(keychain.upsertString(any(), any())).doSuspendableAnswer { + storedProjectionJson = it.getArgument(1) + } + whenever(keychain.delete(any())).doSuspendableAnswer { storedProjectionJson = null } + } + + @Test + fun `unstarted proof preparation notifies backup without refreshing requests`() = test { + observeRequestStateChanges() + val repo = paymentProofRepo(projectionStore) + val request = paymentRequest(MethodId.Bolt11.rawValue) + + repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() + runCurrent() + assertEquals(0, requestStateChanges) + assertEquals(1L, projectionStore.backupStateVersion.value) + + repo.cancelPreparation(request) + runCurrent() + assertEquals(0, requestStateChanges) + assertEquals(2L, projectionStore.backupStateVersion.value) + } + + @Test + fun `request refresh follows completed and in flight proof projections`() = test { + observeRequestStateChanges() + val started = readyLightningProof(PAYMENT_REQUEST_ID).copy(proofData = null) + val changes = listOf( + listOf(started) to 1, + listOf(started.copy(paymentIdentifier = "different-payment")) to 1, + listOf(started.copy(proofData = PREIMAGE)) to 2, + listOf(started.copy(proofData = PREIMAGE, kind = PaykitPaymentProofKind.Onchain)) to 3, + emptyList() to 4, + ) + + changes.forEach { (proofs, expectedChanges) -> + projectionStore.save(proofs) + runCurrent() + assertEquals(expectedChanges, requestStateChanges) + } + assertEquals(changes.size.toLong(), projectionStore.backupStateVersion.value) + } + + @Test + fun `request proof projection is scoped to identity and resets after sign out`() = test { + observeRequestStateChanges() + projectionStore.save(listOf(readyLightningProof(PAYMENT_REQUEST_ID).copy(identity = COUNTERPARTY))) + runCurrent() + assertEquals(0, requestStateChanges) + + projectionIdentity.update { COUNTERPARTY } + runCurrent() + assertEquals(1, requestStateChanges) + projectionIdentity.update { null } + runCurrent() + assertEquals(2, requestStateChanges) + projectionStore.save(emptyList()) + runCurrent() + assertEquals(2, requestStateChanges) + projectionIdentity.update { LOCAL_IDENTITY } + runCurrent() + assertEquals(3, requestStateChanges) + } + + @Test + fun `unreadable proof changes keep requesting refresh and recover after repair`() = test { + observeRequestStateChanges() + unreadableProjectionState = true + repeat(2) { index -> + projectionStore.save(emptyList()) + runCurrent() + assertEquals(index + 1, requestStateChanges) + } + + unreadableProjectionState = false + projectionStore.save(emptyList()) + runCurrent() + assertEquals(3, requestStateChanges) + projectionStore.save(emptyList()) + runCurrent() + assertEquals(3, requestStateChanges) } @Test @@ -114,7 +219,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `completed lightning proof retries after repository restart`() = test { + fun `completed lightning proof retains the payment app when retrying after repository restart`() = test { val record = paymentRequestRecord() val request = paymentRequest(MethodId.Bolt11.rawValue) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) @@ -123,8 +228,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { .thenReturn(record) val firstRepo = paymentProofRepo() - firstRepo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() - firstRepo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue).getOrThrow() + firstRepo.prepare(request, MethodId.Bolt11.rawValue, "merchant", PaykitPaymentProofKind.Lightning).getOrThrow() + firstRepo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "merchant").getOrThrow() firstRepo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) assertEquals(PREIMAGE, storedProofs.single().proofData) @@ -135,8 +240,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val proofCaptor = argumentCaptor() verify(paykitSdkService, times(2)).submitPaymentProof( counterparty = any(), - counterpartyReceiverPath = any(), paymentRequestId = any(), + paymentAppId = eq("merchant"), paymentEndpointIdentifier = endpointCaptor.capture(), proofJson = proofCaptor.capture(), billingPeriod = isNull(), @@ -150,6 +255,43 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { verify(paykitSdkService).processPendingPrivateMessages() } + @Test + fun `completed payments remain reconcilable after their payment deadline`() = test { + val deadline = PaymentDeadline.At("2000-01-01T00:00:00Z") + val record = paymentRequestRecord().let { + it.copy( + state = PaymentRequestLifecycleState.ACCEPTED, + terms = requireNotNull(it.terms).copy(paymentDeadline = deadline), + ) + } + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())) + .thenReturn(record) + val lightning = readyLightningProof(PAYMENT_REQUEST_ID) + val onchain = lightning.copy( + kind = PaykitPaymentProofKind.Onchain, + paymentEndpointIdentifier = MethodId.P2wpkh.rawValue, + paymentIdentifier = "ab".repeat(32), + proofData = "ab".repeat(32), + ) + + listOf(lightning, onchain).forEach { proof -> + storedProofs = listOf(proof) + + paymentProofRepo().reconcile() + + assertTrue(storedProofs.isEmpty()) + verify(paykitSdkService).submitPaymentProof( + counterparty = eq(COUNTERPARTY), + paymentRequestId = eq(PAYMENT_REQUEST_ID), + paymentAppId = eq("bitkit"), + paymentEndpointIdentifier = eq(proof.paymentEndpointIdentifier), + proofJson = any(), + billingPeriod = isNull(), + ) + } + } + @Test fun `failed proof reconciliation does not stop later proofs`() = test { val secondPaymentRequestId = "550e8400-e29b-41d4-a716-446655440001" @@ -172,8 +314,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val paymentRequestIdCaptor = argumentCaptor() verify(paykitSdkService, times(2)).submitPaymentProof( counterparty = any(), - counterpartyReceiverPath = any(), paymentRequestId = paymentRequestIdCaptor.capture(), + paymentAppId = eq("bitkit"), paymentEndpointIdentifier = any(), proofJson = any(), billingPeriod = isNull(), @@ -200,8 +342,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) val firstRepo = paymentProofRepo() - firstRepo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() - firstRepo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue).getOrThrow() + firstRepo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() + firstRepo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "bitkit").getOrThrow() assertNull(storedProofs.single().proofData) paymentProofRepo().reconcile() @@ -210,8 +352,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val proofCaptor = argumentCaptor() verify(paykitSdkService).submitPaymentProof( counterparty = any(), - counterpartyReceiverPath = any(), paymentRequestId = any(), + paymentAppId = eq("bitkit"), paymentEndpointIdentifier = any(), proofJson = proofCaptor.capture(), billingPeriod = isNull(), @@ -231,7 +373,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) val repo = paymentProofRepo() - repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue).getOrThrow() + repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "bitkit").getOrThrow() repo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) @@ -243,8 +385,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(MethodId.Bolt11.rawValue) val repo = paymentProofRepo() - repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() - repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue).getOrThrow() + repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() + repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "bitkit").getOrThrow() repo.completeLightningPayment(PAYMENT_HASH, "01".repeat(32)) assertNull(storedProofs.single().proofData) @@ -259,6 +401,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val existingProof = mock { on { billingPeriod } doReturn null on { paymentEndpointIdentifier } doReturn MethodId.Bolt11.rawValue + on { paymentAppId } doReturn "bitkit" on { proof } doReturn existingProofJson } val record = paymentRequestRecord(listOf(existingProof)) @@ -266,8 +409,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) val repo = paymentProofRepo() - repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() - repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue).getOrThrow() + repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() + repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "bitkit").getOrThrow() repo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) assertTrue(storedProofs.isEmpty()) @@ -279,8 +422,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(MethodId.Bolt11.rawValue) val repo = paymentProofRepo() - repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() - repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue).getOrThrow() + repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() + repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "bitkit").getOrThrow() repo.failLightningPayment(PAYMENT_HASH) assertTrue(storedProofs.isEmpty()) @@ -302,8 +445,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { for (error in errors) { val request = paymentRequest(MethodId.Bolt11.rawValue) val repo = paymentProofRepo() - repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() - repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue).getOrThrow() + repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() + repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "bitkit").getOrThrow() val failed = repo.failLightningPayment(PAYMENT_HASH, error) repo.cancelPreparation(request) @@ -313,7 +456,12 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertFalse(failed) assertTrue(storedProofs.single().paymentStarted) assertEquals(PAYMENT_HASH, storedProofs.single().paymentIdentifier) - val retry = restartedRepo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning) + val retry = restartedRepo.prepare( + request, + MethodId.Bolt11.rawValue, + "bitkit", + PaykitPaymentProofKind.Lightning + ) assertTrue(retry.exceptionOrNull() is PaykitPaymentRequestError.OperationInProgress) restartedRepo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) @@ -327,6 +475,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val errors = listOf( ServiceError.NodeNotSetup(), ServiceError.NodeNotStarted(), + to.bitkit.utils.AppError(ServiceError.PaymentDeadlineExpired()), NodeNotRunningError("payInvoice", NodeLifecycleState.Stopped), NodeRunTimeoutError("payInvoice"), NodeException.NotRunning("stopped"), @@ -337,8 +486,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { for (error in errors) { val request = paymentRequest(MethodId.Bolt11.rawValue) val repo = paymentProofRepo() - repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() - repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue).getOrThrow() + repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() + repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "bitkit").getOrThrow() assertTrue(repo.failLightningPayment(PAYMENT_HASH, error)) assertTrue(storedProofs.isEmpty()) @@ -354,10 +503,10 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) val repo = paymentProofRepo() - repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() assertTrue(storedProofs.single().paymentStarted) - repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue) + repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue, "bitkit") val endpointCaptor = argumentCaptor() val proofCaptor = argumentCaptor() @@ -382,7 +531,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(MethodId.P2wpkh.rawValue) val repo = paymentProofRepo() - repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() repo.cancelPreparation(request) @@ -394,7 +543,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(MethodId.P2wpkh.rawValue) val repo = paymentProofRepo() - repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() repo.failOnchainPayment(request) @@ -405,7 +554,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `onchain failure clears started proof without a live identity`() = test { val request = paymentRequest(MethodId.P2wpkh.rawValue) val repo = paymentProofRepo() - repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() whenever(paykitSdkService.identityStatus()).thenReturn(null) @@ -418,7 +567,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `cancel preparation clears proof without a live identity`() = test { val request = paymentRequest(MethodId.Bolt11.rawValue) val repo = paymentProofRepo() - repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() + repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() whenever(paykitSdkService.identityStatus()).thenReturn(null) repo.cancelPreparation(request) @@ -426,6 +575,39 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(storedProofs.isEmpty()) } + @Test + fun `cancel preparation retains proofs when identity lookup fails`() = test { + val request = paymentRequest(MethodId.Bolt11.rawValue) + val repo = paymentProofRepo() + repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() + val preparedProofs = storedProofs + doSuspendableAnswer { + throw PaykitException.ConcurrentUpdate("concurrent_update", "Identity read locked") + }.whenever(paykitSdkService).identityStatus() + + repo.cancelPreparation(request) + + assertEquals(preparedProofs, storedProofs) + doSuspendableAnswer { throw CancellationException("Cancelled") }.whenever(paykitSdkService).identityStatus() + assertFailsWith { repo.cancelPreparation(request) } + assertEquals(preparedProofs, storedProofs) + } + + @Test + fun `broadcast transaction id is retained without a live identity`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val repo = paymentProofRepo() + val txid = "ab".repeat(32) + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + whenever(paykitSdkService.identityStatus()).thenReturn(null) + + repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue, "bitkit") + + assertEquals(txid, storedProofs.single().paymentIdentifier) + assertEquals(txid, storedProofs.single().proofData) + } + @Test fun `onchain proof submits without prepared proof`() = test { val txid = "ab".repeat(32) @@ -436,7 +618,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) val repo = paymentProofRepo() - repo.completeOnchainPayment(request, txid, endpoint) + repo.completeOnchainPayment(request, txid, endpoint, "bitkit") verify(paykitSdkService).submitPaymentProof(any(), any(), any(), eq(endpoint), any(), isNull()) assertTrue(storedProofs.isEmpty()) @@ -454,15 +636,15 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), any())).thenReturn(record) val repo = paymentProofRepo() - repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() - repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue).getOrThrow() + repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() + repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "bitkit").getOrThrow() repo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) val periodCaptor = argumentCaptor() verify(paykitSdkService).submitPaymentProof( counterparty = any(), - counterpartyReceiverPath = any(), paymentRequestId = any(), + paymentAppId = eq("bitkit"), paymentEndpointIdentifier = any(), proofJson = any(), billingPeriod = periodCaptor.capture(), @@ -485,6 +667,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { endsAt = "2027-02-01T08:00:00.000Z", ) on { paymentEndpointIdentifier } doReturn MethodId.Bolt11.rawValue + on { paymentAppId } doReturn "bitkit" on { proof } doReturn existingProofJson } val record = paymentRequestRecord(listOf(existingProof)) @@ -493,8 +676,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), any())).thenReturn(record) val repo = paymentProofRepo() - repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() - repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue).getOrThrow() + repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() + repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "bitkit").getOrThrow() repo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), any()) @@ -505,15 +688,15 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(MethodId.Bolt11.rawValue) val repo = paymentProofRepo() - repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() - repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue).getOrThrow() - val retry = repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning) + repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() + repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "bitkit").getOrThrow() + val retry = repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning) assertTrue(retry.exceptionOrNull() is PaykitPaymentRequestError.OperationInProgress) assertEquals(PAYMENT_HASH, storedProofs.single().paymentIdentifier) repo.failLightningPayment(PAYMENT_HASH) - repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() + repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() assertEquals(1, storedProofs.size) } @@ -524,9 +707,9 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val secondRequest = paymentRequest(MethodId.Bolt11.rawValue, secondRequestId) val repo = paymentProofRepo() - repo.prepare(firstRequest, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() + repo.prepare(firstRequest, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() storedProofs = emptyList() - repo.prepare(secondRequest, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() + repo.prepare(secondRequest, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() assertEquals(1, storedProofs.size) assertEquals(secondRequestId, storedProofs.single().requestId.paymentRequestId) @@ -541,10 +724,10 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) val repo = paymentProofRepo() - repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() shouldFailNextSave = true - repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue) + repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue, "bitkit") verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) assertTrue(storedProofs.isEmpty()) @@ -560,10 +743,10 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { .thenThrow(IllegalStateException("transient submission failure")) val repo = paymentProofRepo() - repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() shouldFailNextSave = true - repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue) + repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue, "bitkit") assertEquals(txid, storedProofs.single().proofData) verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) @@ -578,10 +761,10 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) val repo = paymentProofRepo() - repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() shouldFailProofRemoval = true - repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue) + repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue, "bitkit") verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) assertEquals(txid, storedProofs.single().proofData) @@ -597,17 +780,17 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) val repo = paymentProofRepo() - repo.prepare(request, endpoint, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.prepare(request, endpoint, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() shouldFailNextLoad = true - repo.completeOnchainPayment(request, txid, endpoint) + repo.completeOnchainPayment(request, txid, endpoint, "bitkit") val endpointCaptor = argumentCaptor() val proofCaptor = argumentCaptor() verify(paykitSdkService).submitPaymentProof( counterparty = any(), - counterpartyReceiverPath = any(), paymentRequestId = any(), + paymentAppId = eq("bitkit"), paymentEndpointIdentifier = endpointCaptor.capture(), proofJson = proofCaptor.capture(), billingPeriod = isNull(), @@ -637,7 +820,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { ).thenReturn(txid) val repo = paymentProofRepo() - repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() repo.reconcile() @@ -645,8 +828,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val proofCaptor = argumentCaptor() verify(paykitSdkService).submitPaymentProof( counterparty = eq(request.counterparty), - counterpartyReceiverPath = eq(request.counterpartyReceiverPath), paymentRequestId = eq(request.paymentRequestId), + paymentAppId = eq("bitkit"), paymentEndpointIdentifier = eq(MethodId.P2wpkh.rawValue), proofJson = proofCaptor.capture(), billingPeriod = isNull(), @@ -670,10 +853,10 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(onchainPaymentLookup.transactionId(any(), any(), any(), any())) .thenReturn("ab".repeat(32), "cd".repeat(32)) val repo = paymentProofRepo() - repo.prepare(firstRequest, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.prepare(firstRequest, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(firstRequest, ONCHAIN_ADDRESS).getOrThrow() - repo.prepare(secondRequest, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() - repo.markOnchainPaymentStarted(secondRequest, ONCHAIN_ADDRESS).getOrThrow() + repo.prepare(secondRequest, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(secondRequest, ONCHAIN_ADDRESS, "hardware-wallet").getOrThrow() repo.reconcile() @@ -681,6 +864,10 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { listOf(PAYMENT_REQUEST_ID, secondPaymentRequestId), repo.onchainPaymentResolutions.value.map { it.requestId.paymentRequestId }, ) + assertEquals( + listOf(WalletScope.default, "hardware-wallet"), + repo.onchainPaymentResolutions.value.map { it.walletId }, + ) } @Test @@ -700,7 +887,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { ).thenReturn(null) val repo = paymentProofRepo() - repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() repo.reconcile() @@ -717,11 +904,12 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { requestId = request.id, paymentEndpointIdentifier = MethodId.Bolt11.rawValue, kind = PaykitPaymentProofKind.Lightning, + paymentAppId = "bitkit", ) storedProofs = listOf(otherIdentityProof) val repo = paymentProofRepo() - repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() + repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() repo.cancelPreparation(request) assertEquals(listOf(otherIdentityProof), storedProofs) @@ -735,11 +923,11 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) val request = paymentRequest(MethodId.Bolt11.rawValue, billingPeriod = period) val repo = paymentProofRepo() - repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() + repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() val protectedRequestIds = repo.protectedRequestIdsForSubscriptionCancellation( LOCAL_IDENTITY, - PaykitSubscriptionId(PAYMENT_REQUEST_ID, COUNTERPARTY, PaykitReceiverPaths.WALLET), + PaykitSubscriptionId(PAYMENT_REQUEST_ID, COUNTERPARTY), ).getOrThrow() assertTrue(protectedRequestIds.isEmpty()) @@ -754,19 +942,26 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) val request = paymentRequest(MethodId.Bolt11.rawValue, billingPeriod = period) val repo = paymentProofRepo() - repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() - repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue).getOrThrow() + repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() + repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "bitkit").getOrThrow() val protectedRequestIds = repo.protectedRequestIdsForSubscriptionCancellation( LOCAL_IDENTITY, - PaykitSubscriptionId(PAYMENT_REQUEST_ID, COUNTERPARTY, PaykitReceiverPaths.WALLET), + PaykitSubscriptionId(PAYMENT_REQUEST_ID, COUNTERPARTY), ).getOrThrow() assertEquals(setOf(request.id), protectedRequestIds) assertEquals(listOf(request.id), storedProofs.map { it.requestId }) } - private fun paymentProofRepo() = PaykitPaymentProofRepo( + private fun TestScope.observeRequestStateChanges() { + paymentProofRepo(projectionStore).paymentRequestStateChanges(projectionIdentity) + .onEach { requestStateChanges++ } + .launchIn(backgroundScope) + runCurrent() + } + + private fun paymentProofRepo(store: PaykitPaymentProofStore = this.store) = PaykitPaymentProofRepo( ioDispatcher = testDispatcher, paykitSdkService = paykitSdkService, lightningRepo = lightningRepo, @@ -781,7 +976,6 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) = PaykitPaymentRequest( paymentRequestId = paymentRequestId, counterparty = COUNTERPARTY, - counterpartyReceiverPath = PaykitReceiverPaths.WALLET, amountValue = "0.00001", amountSats = 1_000uL, expiresAt = null, @@ -793,7 +987,6 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { identity = LOCAL_IDENTITY, requestId = PaykitPaymentRequestId( counterparty = COUNTERPARTY, - counterpartyReceiverPath = PaykitReceiverPaths.WALLET, paymentRequestId = paymentRequestId, ), paymentEndpointIdentifier = MethodId.Bolt11.rawValue, @@ -801,6 +994,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentStarted = true, paymentIdentifier = PAYMENT_HASH, proofData = PREIMAGE, + paymentAppId = "bitkit", ) private fun paymentRequestRecord( @@ -808,7 +1002,6 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentRequestId: String = PAYMENT_REQUEST_ID, ) = PaymentRequestRecord( counterparty = COUNTERPARTY, - counterpartyReceiverPath = PaykitReceiverPaths.WALLET, paymentRequestId = paymentRequestId, localRole = PaymentRequestLocalRole.PAYER, state = PaymentRequestLifecycleState.PROPOSED, @@ -825,6 +1018,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { conversion = null, paymentDeadline = null, metadata = mock(), + paymentEndpoints = null, + requiredAppId = "bitkit", ), acceptedEventId = null, acceptedOutboundStatus = null, @@ -839,5 +1034,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { lastOutboundStatus = null, lastEventAt = "2027-01-15T08:00:00Z", invalidReason = null, + proposalAppId = "bitkit", + payerAppId = null, + executionClaimAppId = null, ) } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStoreTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStoreTest.kt index 4b4310e50f..015d11d2da 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStoreTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStoreTest.kt @@ -8,6 +8,7 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import org.junit.Test import org.mockito.kotlin.any +import org.mockito.kotlin.clearInvocations import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -40,7 +41,7 @@ class PaykitPaymentRequestPresentationStoreTest : BaseUnitTest() { Unit } val sut = PaykitPaymentRequestPresentationStore(keychain) - val requestId = PaykitPaymentRequestId("request", COUNTERPARTY, "bitkit/server") + val requestId = PaykitPaymentRequestId("request", COUNTERPARTY) val loadError = assertFailsWith { sut.load(IDENTITY) } val saveError = assertFailsWith { sut.save(IDENTITY, setOf(requestId)) } @@ -63,8 +64,8 @@ class PaykitPaymentRequestPresentationStoreTest : BaseUnitTest() { Unit } val sut = PaykitPaymentRequestPresentationStore(keychain) - val requestId = PaykitPaymentRequestId("request", COUNTERPARTY, "bitkit/server") - val subscriptionId = PaykitSubscriptionId("subscription", COUNTERPARTY, "bitkit/server") + val requestId = PaykitPaymentRequestId("request", COUNTERPARTY) + val subscriptionId = PaykitSubscriptionId("subscription", COUNTERPARTY) val dismissedOnly = PaykitSubscriptionPresentationState(dismissedPaymentIds = setOf(requestId)) val accepted = dismissedOnly.copy( acceptedAt = mapOf(subscriptionId to Instant.parse("2026-09-24T08:00:00.123Z")), @@ -92,6 +93,82 @@ class PaykitPaymentRequestPresentationStoreTest : BaseUnitTest() { assertEquals(2L, sut.backupStateVersion.value) } + @Test + fun `accepted one time ownership survives reopening and wallet restore`() = test { + val keychain = mock() + val values = mutableMapOf() + whenever(keychain.loadString(any())).thenAnswer { values[it.getArgument(0)] } + whenever(keychain.upsertString(any(), any())).thenAnswer { + values[it.getArgument(0)] = it.getArgument(1) + Unit + } + val sut = PaykitPaymentRequestPresentationStore(keychain) + val requestId = PaykitPaymentRequestId("request", COUNTERPARTY) + val secondId = PaykitPaymentRequestId("second", COUNTERPARTY) + sut.addAcceptedOneTimeId(IDENTITY, requestId) + sut.addAcceptedOneTimeId(COUNTERPARTY, secondId) + sut.addAcceptedOneTimeId(IDENTITY, secondId) + sut.save(IDENTITY, setOf(requestId)) + + val reopened = PaykitPaymentRequestPresentationStore(keychain) + assertEquals(setOf(requestId, secondId), reopened.loadAcceptedOneTimeIds(IDENTITY)) + assertEquals(setOf(secondId), reopened.loadAcceptedOneTimeIds(COUNTERPARTY)) + assertEquals(emptyMap(), reopened.backupSnapshot()) + assertEquals(3L, sut.backupStateVersion.value) + val backup = reopened.acceptedOneTimeBackupSnapshot() + reopened.restoreBackup(emptyMap()) + assertEquals(setOf(requestId, secondId), reopened.loadAcceptedOneTimeIds(IDENTITY)) + + values.clear() + reopened.restoreBackup(emptyMap()) + reopened.restoreAcceptedOneTimeRequests(backup) + assertEquals(setOf(requestId, secondId), reopened.loadAcceptedOneTimeIds(IDENTITY)) + assertEquals(setOf(secondId), reopened.loadAcceptedOneTimeIds(COUNTERPARTY)) + } + + @Test + fun `acceptance cleanup removes only specified ids from the current stored identity`() = test { + val keychain = mock() + val key = Keychain.Key.PAYKIT_ACCEPTED_PAYMENT_REQUESTS.name + var stored: String? = null + whenever(keychain.loadString(key)).thenAnswer { stored } + whenever(keychain.upsertString(eq(key), any())).thenAnswer { + stored = it.getArgument(1) + Unit + } + val sut = PaykitPaymentRequestPresentationStore(keychain) + val finished = PaykitPaymentRequestId("finished", COUNTERPARTY) + val live = PaykitPaymentRequestId("live", COUNTERPARTY) + sut.addAcceptedOneTimeId(IDENTITY, finished) + sut.addAcceptedOneTimeId(COUNTERPARTY, finished) + sut.addAcceptedOneTimeId(IDENTITY, live) + + assertEquals(setOf(live), sut.removeAcceptedOneTimeIds(IDENTITY, setOf(finished))) + val reopened = PaykitPaymentRequestPresentationStore(keychain) + assertEquals(setOf(live), reopened.loadAcceptedOneTimeIds(IDENTITY)) + assertEquals(setOf(finished), reopened.loadAcceptedOneTimeIds(COUNTERPARTY)) + clearInvocations(keychain) + reopened.removeAcceptedOneTimeIds(IDENTITY, setOf(finished)) + verify(keychain, never()).upsertString(any(), any()) + } + + @Test + fun `unreadable accepted one time ownership is preserved and fails closed`() = test { + val keychain = mock() + val acceptedKey = Keychain.Key.PAYKIT_ACCEPTED_PAYMENT_REQUESTS.name + whenever(keychain.loadString(acceptedKey)).thenReturn("not-json") + val sut = PaykitPaymentRequestPresentationStore(keychain) + + assertFailsWith { sut.loadAcceptedOneTimeIds(IDENTITY) } + assertFailsWith { + sut.addAcceptedOneTimeId(IDENTITY, PaykitPaymentRequestId("request", COUNTERPARTY)) + } + assertFailsWith { + sut.removeAcceptedOneTimeIds(IDENTITY, setOf(PaykitPaymentRequestId("request", COUNTERPARTY))) + } + verify(keychain, never()).upsertString(any(), any()) + } + @Test fun `backup restore preserves precise acceptance billing boundaries`() = test { val keychain = mock() @@ -102,8 +179,8 @@ class PaykitPaymentRequestPresentationStoreTest : BaseUnitTest() { Unit } val sut = PaykitPaymentRequestPresentationStore(keychain) - val millisecondId = PaykitSubscriptionId("millisecond", COUNTERPARTY, "bitkit/server") - val nanosecondId = PaykitSubscriptionId("nanosecond", COUNTERPARTY, "bitkit/server") + val millisecondId = PaykitSubscriptionId("millisecond", COUNTERPARTY) + val nanosecondId = PaykitSubscriptionId("nanosecond", COUNTERPARTY) val acceptedAt = mapOf( millisecondId to Instant.parse("2026-09-24T10:00:00.123Z"), nanosecondId to Instant.parse("2026-09-24T10:00:00.123456789Z"), @@ -139,7 +216,7 @@ class PaykitPaymentRequestPresentationStoreTest : BaseUnitTest() { fun `invalid subscription timestamp identifies its preserved key`() = test { val keychain = mock() val value = """ - {"subscriptionStatesByIdentity":{"$IDENTITY":{"acceptances":[{"id":{"paymentRequestId":"subscription","counterparty":"$COUNTERPARTY","counterpartyReceiverPath":"bitkit/server"},"acceptedAt":"not-a-timestamp"}]}}} + {"subscriptionStatesByIdentity":{"$IDENTITY":{"acceptances":[{"id":{"paymentRequestId":"subscription","counterparty":"$COUNTERPARTY"},"acceptedAt":"not-a-timestamp"}]}}} """.trimIndent() whenever(keychain.loadString(KEY)).thenReturn(value) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt index baaa8cc031..2c348ba81e 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt @@ -6,6 +6,8 @@ import com.synonym.paykit.BillingPeriod import com.synonym.paykit.IdentityStatus import com.synonym.paykit.LinkedPeerRecord import com.synonym.paykit.LinkedPeerState +import com.synonym.paykit.OutboundPrivateMessageStatus +import com.synonym.paykit.OutboundPrivateSendReport import com.synonym.paykit.PaymentDeadline import com.synonym.paykit.PaymentProofRecord import com.synonym.paykit.PaymentReference @@ -16,9 +18,13 @@ import com.synonym.paykit.PaymentRequestRecord import com.synonym.paykit.PaymentRequestRecurrence import com.synonym.paykit.PaymentRequestTerms import com.synonym.paykit.PrivateJsonObject +import com.synonym.paykit.PubkyIdentityCapability import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceTimeBy @@ -41,7 +47,6 @@ import org.mockito.kotlin.whenever import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.services.PaykitPaymentRequestProposalTerms -import to.bitkit.services.PaykitReceiverPaths import to.bitkit.services.PaykitSdkService import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals @@ -93,9 +98,26 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat fun setUp() = test { schedulerOriginMillis = testDispatcher.scheduler.currentTime whenever(paykitSdkService.processPendingPrivateMessages()).thenReturn(emptyList()) + whenever(paykitSdkService.processOutboundPrivateMessages(any())).thenReturn( + OutboundPrivateSendReport(emptyList(), emptyList(), emptyList(), emptyList(), emptyList()), + ) whenever(paykitSdkService.receivePrivateMessagesFromLinkedPeers()).thenReturn(emptyList()) - whenever(paykitSdkService.paymentRequests()).thenReturn(emptyList()) + whenever(paykitSdkService.processPendingPrivateMessages(any())).doSuspendableAnswer { + paykitSdkService.processPendingPrivateMessages() + } + whenever(paykitSdkService.receivePrivateMessagesFromLinkedPeers(any())).doSuspendableAnswer { + paykitSdkService.receivePrivateMessagesFromLinkedPeers() + } + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(emptyList()) whenever(paykitSdkService.linkedPeers()).thenReturn(emptyList()) + whenever(paykitSdkService.identityStatus(any())).doSuspendableAnswer { paykitSdkService.identityStatus() } + whenever(paykitSdkService.linkedPeers(any())).doSuspendableAnswer { paykitSdkService.linkedPeers() } + whenever(paykitSdkService.allPaymentRequests(anyOrNull(), any())).doSuspendableAnswer { + paykitSdkService.allPaymentRequests(it.getArgument(0)) + } + whenever(paykitSdkService.processOutboundPrivateMessages(any(), any())).doSuspendableAnswer { + paykitSdkService.processOutboundPrivateMessages(it.getArgument(0)) + } whenever(settingsStore.isPaykitEnabled).thenReturn(flowOf(true)) whenever(settingsStore.data).thenReturn(flowOf(SettingsData(sharesPrivatePaykitEndpoints = true))) whenever(presentationStore.load(LOCAL_IDENTITY)).thenReturn(emptySet()) @@ -104,6 +126,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat ).thenReturn(PaykitSubscriptionPresentationState()) whenever(paymentProofStore.completedRequestProofKindsAwaitingSubmission(LOCAL_IDENTITY)).thenReturn(emptyMap()) whenever(paymentProofStore.inFlightRequestIds(LOCAL_IDENTITY)).thenReturn(emptySet()) + whenever(paymentProofStore.backupStateVersion).thenReturn(MutableStateFlow(0L)) whenever(paymentProofRepo.protectedRequestIdsForSubscriptionCancellation(any(), any())) .thenReturn(Result.success(emptySet())) sut = PaykitPaymentRequestRepo( @@ -127,14 +150,14 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat } @Test - fun `refresh maps active subscription and exposes current unpaid period`() = test { + fun `inbox refresh maps active subscription and exposes current unpaid period`() = test { val metadataText = """ {"note":"Mobile plan","subscription":{"version":1,"description":"10 GB every month","benefits":["Roaming"]}} """.trimIndent() val metadata = mock { on { exportText() } doReturn metadataText } - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf( paymentRequestRecord( id = "recurring", @@ -144,8 +167,10 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat ), ) - sut.refresh().getOrThrow() + sut.refresh(PaykitPaymentRequestRefreshMode.STORED).getOrThrow() + verify(paykitSdkService, never()).processPendingPrivateMessages() + verify(paykitSdkService, never()).receivePrivateMessagesFromLinkedPeers() val subscription = sut.subscriptions.value.single() assertEquals("Mobile plan", subscription.note) assertEquals("10 GB every month", subscription.metadata.description) @@ -157,6 +182,99 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat assertEquals(Instant.parse("2027-02-01T08:00:00Z"), request.billingPeriod?.endsAt) } + @Test + fun `recurring final authorization survives in flight filtering but rejects identity switches`() = test { + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( + listOf(paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING)), + ) + sut.refresh().getOrThrow() + val request = sut.pendingRequests.value.single() + sut.accept(request).getOrThrow() + sut.ensurePaymentAllowed(request).getOrThrow() + whenever(paymentProofStore.inFlightRequestIds(LOCAL_IDENTITY)).thenReturn(setOf(request.id)) + sut.refresh().getOrThrow() + assertTrue(sut.pendingRequests.value.isEmpty()) + sut.ensurePaymentAllowed(request).getOrThrow() + + val checking = CompletableDeferred() + val checked = CompletableDeferred() + whenever(paykitSdkService.linkedPeers()).doSuspendableAnswer { + checking.complete(Unit) + checked.await() + emptyList() + } + val authorization = async { sut.ensurePaymentAllowed(request) } + checking.await() + sut.activate(SECOND_IDENTITY) + checked.complete(Unit) + + assertTrue(authorization.await().isFailure) + assertTrue(sut.ensurePaymentAllowed(request).isFailure) + } + + @Test + fun `a canceled subscription cannot finish payment authorization`() = test { + val record = paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) + sut.refresh().getOrThrow() + val request = sut.pendingRequests.value.single() + sut.accept(request).getOrThrow() + val checking = CompletableDeferred() + val checked = CompletableDeferred() + var pauseNextLookup = true + whenever(paykitSdkService.linkedPeers()).doSuspendableAnswer { + if (pauseNextLookup) { + pauseNextLookup = false + checking.complete(Unit) + checked.await() + } + emptyList() + } + + val authorization = async { sut.ensurePaymentAllowed(request) } + checking.await() + whenever(paykitSdkService.allPaymentRequests(anyOrNull())) + .thenReturn(listOf(record.copy(state = PaymentRequestLifecycleState.CANCELED))) + sut.refresh(PaykitPaymentRequestRefreshMode.STORED).getOrThrow() + checked.complete(Unit) + + assertTrue(authorization.await().isFailure) + assertTrue(sut.ensurePaymentAllowed(request).isFailure) + assertTrue(sut.accept(request).isFailure) + } + + @Test + fun `a paid subscription period stays blocked while its next unpaid period is authorized`() = test { + val record = paymentRequestRecord( + state = PaymentRequestLifecycleState.ACTIVE_RECURRING, + endpoints = listOf(MethodId.P2wpkh.rawValue), + ) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) + sut.refresh().getOrThrow() + val firstPeriod = sut.pendingRequests.value.single() + sut.ensurePaymentAllowed(firstPeriod).getOrThrow() + val proof = mock { + on { billingPeriod } doReturn requireNotNull(firstPeriod.billingPeriod).sdkValue + on { paymentEndpointIdentifier } doReturn MethodId.P2wpkh.rawValue + on { outboundStatus } doReturn OutboundPrivateMessageStatus.PENDING + } + val paidRecord = record.copy(paymentProofs = listOf(proof)) + whenever(paykitSdkService.claimPaymentRequestForExecution(COUNTERPARTY, PAYMENT_REQUEST_ID)) + .thenReturn(paidRecord) + assertTrue(sut.subscriptions.value.single().paidPeriods.isEmpty()) + assertTrue(sut.claimForPayment(firstPeriod).exceptionOrNull() is PaykitPaymentRequestError.RequestUnavailable) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())) + .thenReturn(listOf(paidRecord)) + subscriptionOffset = 31.days + sut.refresh().getOrThrow() + + assertTrue(sut.ensurePaymentAllowed(firstPeriod).isFailure) + val nextPeriod = sut.pendingRequests.value.single() + assertEquals(Instant.parse("2027-02-01T08:00:00Z"), nextPeriod.billingPeriod?.startsAt) + sut.claimForPayment(nextPeriod).getOrThrow() + sut.ensurePaymentAllowed(nextPeriod).getOrThrow() + } + @Test fun `refresh keeps creator subscription without generating a payer payment`() = test { val metadataText = """ @@ -173,7 +291,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat val metadata = mock { on { exportText() } doReturn metadataText } - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf( paymentRequestRecord( role = PaymentRequestLocalRole.PAYEE, @@ -228,24 +346,37 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat fun `creator proposal sends recurring terms and stays queued until delivery`() = test { val target = stubSubscriptionProposal() val expiresAt = clock.now().plus(60.seconds) + val unrelatedDelivery = CompletableDeferred() + whenever(paykitSdkService.processPendingPrivateMessages()).doSuspendableAnswer { + unrelatedDelivery.await() + emptyList() + } - val creation = sut.proposeSubscription( - draft = PaykitSubscriptionDraft( - amountSats = 100_000uL, - name = " Monthly support ", - description = " Thank you ", - frequency = PaykitRecurrenceUnit.Month, - expiresAt = expiresAt, - ), - target = target, - savedPublicKeys = listOf(COUNTERPARTY), - ).getOrThrow() + val proposal = async { + sut.proposeSubscription( + draft = PaykitSubscriptionDraft( + amountSats = 100_000uL, + name = " Monthly support ", + description = " Thank you ", + frequency = PaykitRecurrenceUnit.Month, + expiresAt = expiresAt, + ), + target = target, + savedPublicKeys = listOf(COUNTERPARTY), + ) + } + runCurrent() + val completedBeforeUnrelatedDelivery = proposal.isCompleted + unrelatedDelivery.complete(Unit) + val creation = proposal.await().getOrThrow() + assertTrue(completedBeforeUnrelatedDelivery) + verify(paykitSdkService).processOutboundPrivateMessages(COUNTERPARTY) + verify(paykitSdkService, never()).processPendingPrivateMessages() val captured = argumentCaptor() verifyBlocking(paykitSdkService) { proposePaymentRequest( eq(COUNTERPARTY), - eq(PaykitReceiverPaths.SERVER), captured.capture(), eq(LOCAL_IDENTITY), ) @@ -266,6 +397,33 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat assertEquals(listOf(creation.subscription), sut.subscriptions.value) } + @Test + fun `creator proposal reports delivery completed by another drain`() = test { + val target = stubSubscriptionProposal() + val record = paymentRequestRecord(role = PaymentRequestLocalRole.PAYEE).copy(proposalOutboundMessageId = 7uL) + whenever(paykitSdkService.proposePaymentRequest(any(), any(), eq(LOCAL_IDENTITY))).thenReturn(record) + whenever(paykitSdkService.allPaymentRequests(LOCAL_IDENTITY)).thenReturn( + listOf(record.copy(proposalOutboundStatus = OutboundPrivateMessageStatus.SENT)), + ) + + val creation = sut.proposeSubscription( + draft = PaykitSubscriptionDraft( + amountSats = 100_000uL, + name = "Monthly support", + description = "Thank you", + frequency = PaykitRecurrenceUnit.Month, + expiresAt = clock.now().plus(60.seconds), + ), + target = target, + savedPublicKeys = listOf(COUNTERPARTY), + ).getOrThrow() + + assertEquals(PaykitPaymentRequestDeliveryStatus.Sent, creation.subscription.deliveryStatus) + assertEquals(listOf(creation.subscription), sut.subscriptions.value) + verify(paykitSdkService).allPaymentRequests(LOCAL_IDENTITY) + verify(paykitSdkService).proposePaymentRequest(any(), any(), eq(LOCAL_IDENTITY)) + } + @Test fun `creator proposal publishes real time while the subscription clock offset is on`() = test { val target = stubSubscriptionProposal() @@ -285,7 +443,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat val captured = argumentCaptor() verifyBlocking(paykitSdkService) { - proposePaymentRequest(any(), any(), captured.capture(), any()) + proposePaymentRequest(any(), captured.capture(), any()) } assertEquals(clock.now().toString(), captured.firstValue.recurrence?.startsAt) assertEquals(clock.now().toString(), captured.firstValue.recurrence?.anchor) @@ -293,13 +451,15 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat @Test fun `oversized creator proposal is rejected before icon upload or enqueue`() = test { - val target = PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER) - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + val target = PaykitPaymentRequestTarget(COUNTERPARTY) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(eq(COUNTERPARTY), any())) - .thenReturn(listOf(PaykitReceiverPaths.SERVER)) + whenever(paykitSdkService.canReceivePaymentRequests(eq(COUNTERPARTY), any())) + .thenReturn(true) listOf(null, byteArrayOf(0, 1, 2)).forEach { icon -> val result = sut.proposeSubscription( @@ -318,7 +478,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat } verifyBlocking(paykitSdkService, never()) { uploadProfileAvatar(any(), any(), anyOrNull()) } - verifyBlocking(paykitSdkService, never()) { proposePaymentRequest(any(), any(), any(), any()) } + verifyBlocking(paykitSdkService, never()) { proposePaymentRequest(any(), any(), any()) } assertTrue(sut.subscriptions.value.isEmpty()) assertFalse(sut.isCreatingRequest.value) } @@ -330,11 +490,10 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat role = PaymentRequestLocalRole.PAYEE, state = PaymentRequestLifecycleState.CANCELED, ) - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(proposed), listOf(canceled)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(proposed), listOf(canceled)) whenever( paykitSdkService.cancelPaymentRequest( COUNTERPARTY, - PaykitReceiverPaths.SERVER, PAYMENT_REQUEST_ID, ) ).thenReturn(canceled) @@ -346,7 +505,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat protectedRequestIdsForSubscriptionCancellation(any(), any()) } verifyBlocking(paykitSdkService) { - cancelPaymentRequest(COUNTERPARTY, PaykitReceiverPaths.SERVER, PAYMENT_REQUEST_ID) + cancelPaymentRequest(COUNTERPARTY, PAYMENT_REQUEST_ID) } assertEquals(PaymentRequestLifecycleState.CANCELED, sut.subscriptions.value.single().lifecycleState) assertFalse(sut.subscriptions.value.single().isCreatedVisible(clock.now())) @@ -369,9 +528,9 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat state = PaymentRequestLifecycleState.CANCELED, paymentProofs = listOf(proof), ) - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(active), listOf(canceled)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(active), listOf(canceled)) whenever( - paykitSdkService.cancelPaymentRequest(COUNTERPARTY, PaykitReceiverPaths.SERVER, PAYMENT_REQUEST_ID) + paykitSdkService.cancelPaymentRequest(COUNTERPARTY, PAYMENT_REQUEST_ID) ).thenReturn(canceled) sut.refresh().getOrThrow() val subscription = sut.subscriptions.value.single() @@ -394,7 +553,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat @Test fun `refresh does not report subscription proposals as one time parse failures`() = test { - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(paymentRequestRecord())) sut.refresh().getOrThrow() @@ -412,7 +571,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat anchor = "2027-01-01T08:00:00Z", endsAt = null, ) - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf(paymentRequestRecord(recurrence = unsupportedRecurrence)), ) @@ -430,20 +589,21 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat fun `accepting subscription returns current period and preserves payment targets`() = test { val proposal = paymentRequestRecord() val active = paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING) - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(proposal), listOf(active)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(proposal), listOf(active)) whenever( paykitSdkService.acceptPaymentRequest( COUNTERPARTY, - PaykitReceiverPaths.SERVER, PAYMENT_REQUEST_ID, ) ).thenReturn(active) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(eq(COUNTERPARTY), any())) - .thenReturn(listOf(PaykitReceiverPaths.SERVER)) - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever(paykitSdkService.canReceivePaymentRequests(eq(COUNTERPARTY), any())) + .thenReturn(true) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) sut.refresh().getOrThrow() sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() @@ -461,14 +621,13 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat } @Test - fun `subscription clock offset makes the next billing period due`() = test { + fun `subscription clock change refreshes periods after an overlapping refresh`() = test { val proposal = paymentRequestRecord() val active = paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING) - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(proposal), listOf(active)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(proposal), listOf(active)) whenever( paykitSdkService.acceptPaymentRequest( COUNTERPARTY, - PaykitReceiverPaths.SERVER, PAYMENT_REQUEST_ID, ) ).thenReturn(active) @@ -479,8 +638,21 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat sut.pendingRequests.value.mapNotNull { it.billingPeriod?.startsAt }, ) + val reading = CompletableDeferred() + val resume = CompletableDeferred() + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).doSuspendableAnswer { + reading.complete(Unit) + resume.await() + listOf(active) + } + val first = async { sut.refresh() } + reading.await() subscriptionOffset = 31.days - sut.refresh().getOrThrow() + val afterClockChange = async { sut.refreshAfterStateChange() } + runCurrent() + resume.complete(Unit) + first.await().getOrThrow() + afterClockChange.await().getOrThrow() assertEquals( listOf(Instant.parse("2027-01-01T08:00:00Z"), Instant.parse("2027-02-01T08:00:00Z")), @@ -492,11 +664,10 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat fun `accepting with the subscription clock offset on keeps the first period due`() = test { val proposal = paymentRequestRecord() val active = paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING) - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(proposal), listOf(active)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(proposal), listOf(active)) whenever( paykitSdkService.acceptPaymentRequest( COUNTERPARTY, - PaykitReceiverPaths.SERVER, PAYMENT_REQUEST_ID, ) ).thenReturn(active) @@ -525,11 +696,10 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat fun `subscription clock offset lists the paid next period in the payment history`() = test { val proposal = paymentRequestRecord() val active = paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING) - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(proposal), listOf(active)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(proposal), listOf(active)) whenever( paykitSdkService.acceptPaymentRequest( COUNTERPARTY, - PaykitReceiverPaths.SERVER, PAYMENT_REQUEST_ID, ) ).thenReturn(active) @@ -539,7 +709,6 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat PaykitPaymentRequestId( paymentRequestId = PAYMENT_REQUEST_ID, counterparty = COUNTERPARTY, - counterpartyReceiverPath = PaykitReceiverPaths.SERVER, billingPeriodStartsAt = it, ) to PaykitPaymentProofKind.Onchain } @@ -560,7 +729,9 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat fun `accepting subscription rejects terms changed after review`() = test { val reviewedRecord = paymentRequestRecord() val changedRecord = paymentRequestRecord(amount = "0.002") - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(reviewedRecord), listOf(changedRecord)) + whenever( + paykitSdkService.allPaymentRequests(anyOrNull()) + ).thenReturn(listOf(reviewedRecord), listOf(changedRecord)) sut.refresh().getOrThrow() val reviewedSubscription = sut.subscriptions.value.single() sut.refresh().getOrThrow() @@ -568,20 +739,19 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat val result = sut.accept(reviewedSubscription) assertTrue(result.exceptionOrNull() is PaykitPaymentRequestError.RequestUnavailable) - verifyBlocking(paykitSdkService, never()) { acceptPaymentRequest(any(), any(), any()) } + verifyBlocking(paykitSdkService, never()) { acceptPaymentRequest(any(), any()) } } @Test fun `accepted subscription stays successful when its immediate refresh fails`() = test { val proposal = paymentRequestRecord() val active = paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING) - whenever(paykitSdkService.paymentRequests()) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())) .thenReturn(listOf(proposal)) .thenThrow(IllegalStateException("refresh failed")) whenever( paykitSdkService.acceptPaymentRequest( COUNTERPARTY, - PaykitReceiverPaths.SERVER, PAYMENT_REQUEST_ID, ) ).thenReturn(active) @@ -596,7 +766,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat @Test fun `dismissed subscription period stays out of queue after refresh`() = test { - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf(paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING)), ) sut.refresh().getOrThrow() @@ -608,6 +778,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat sut.refresh().getOrThrow() assertTrue(sut.pendingRequests.value.isEmpty()) + assertTrue(sut.isSubscriptionNotificationHandled(request.id, LOCAL_IDENTITY)) verifyBlocking(presentationStore) { saveSubscriptionState(eq(LOCAL_IDENTITY), argThat { dismissedPaymentIds == setOf(request.id) }) } @@ -615,7 +786,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat @Test fun `failed dismissal persistence keeps subscription payment in queue`() = test { - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf(paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING)), ) sut.refresh().getOrThrow() @@ -642,7 +813,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat resumeRefresh.await() emptyList() } - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf(paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING)), ) whenever(presentationStore.load(SECOND_IDENTITY)).thenReturn(emptySet()) @@ -666,29 +837,32 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat val requestId = PaykitPaymentRequestId( paymentRequestId = PAYMENT_REQUEST_ID, counterparty = COUNTERPARTY, - counterpartyReceiverPath = PaykitReceiverPaths.SERVER, billingPeriodStartsAt = "2027-01-01T08:00:00Z", ) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( + listOf(paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING)), + ) + sut.refresh(PaykitPaymentRequestRefreshMode.STORED).getOrThrow() + assertEquals(requestId, sut.pendingRequests.value.single().id) whenever(paymentProofStore.completedRequestProofKindsAwaitingSubmission(LOCAL_IDENTITY)) .thenReturn(mapOf(requestId to PaykitPaymentProofKind.Onchain)) - whenever(paykitSdkService.paymentRequests()).thenReturn( - listOf( - paymentRequestRecord( - state = PaymentRequestLifecycleState.ACTIVE_RECURRING, - paymentDeadline = PaymentDeadline.PeriodStart(3600uL), - ), - ), - ) - sut.refresh().getOrThrow() + sut.refreshAfterStateChange(PaykitPaymentRequestRefreshMode.STORED).getOrThrow() assertTrue(sut.pendingRequests.value.isEmpty()) + assertTrue(sut.automaticPendingRequests().isEmpty()) + verify(paykitSdkService, never()).processPendingPrivateMessages() + verify(paykitSdkService, never()).receivePrivateMessagesFromLinkedPeers() assertEquals(requestId, sut.paymentRequestHistory.value.single().id) assertEquals( PaymentRequestLifecycleState.PROOF_SUBMITTED, sut.paymentRequestHistory.value.single().lifecycleState, ) assertEquals(PaykitPaymentProofKind.Onchain, sut.paymentRequestHistory.value.single().paymentProofKind) + assertTrue(sut.isSubscriptionNotificationHandled(requestId, LOCAL_IDENTITY)) + val nextPeriodId = requestId.copy(billingPeriodStartsAt = "2027-02-01T08:00:00Z") + assertFalse(sut.isSubscriptionNotificationHandled(nextPeriodId, LOCAL_IDENTITY)) + assertTrue(sut.pendingRequests.value.none { it.id == nextPeriodId }) } @Test @@ -700,7 +874,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat ) on { paymentEndpointIdentifier } doReturn MethodId.Bolt11.rawValue } - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf( paymentRequestRecord( state = PaymentRequestLifecycleState.ACTIVE_RECURRING, @@ -725,7 +899,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat ) on { paymentEndpointIdentifier } doReturn MethodId.Bolt11.rawValue } - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf( paymentRequestRecord( state = PaymentRequestLifecycleState.CANCELED, @@ -734,7 +908,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat ), ) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.BLOCKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.BLOCKED)), ) sut.refresh().getOrThrow() @@ -752,7 +926,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat ) on { paymentEndpointIdentifier } doReturn MethodId.Bolt11.rawValue } - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf( paymentRequestRecord( state = PaymentRequestLifecycleState.CANCELED, @@ -761,7 +935,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat ), ) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.BLOCKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.BLOCKED)), ) sut.refresh().getOrThrow() @@ -785,7 +959,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat ) on { paymentEndpointIdentifier } doReturn MethodId.Bolt11.rawValue } - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf( paymentRequestRecord( role = PaymentRequestLocalRole.PAYEE, @@ -795,7 +969,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat ), ) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.BLOCKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.BLOCKED)), ) sut.refresh().getOrThrow() @@ -818,7 +992,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat ) on { paymentEndpointIdentifier } doReturn MethodId.Bolt11.rawValue } - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf( paymentRequestRecord( state = PaymentRequestLifecycleState.ACTIVE_RECURRING, @@ -828,7 +1002,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat ), ) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.BLOCKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.BLOCKED)), ) sut.refresh().getOrThrow() @@ -847,7 +1021,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat on { billingPeriod } doReturn BillingPeriod("2027-01-01T08:00:00Z", "2027-02-01T08:00:00Z") on { paymentEndpointIdentifier } doReturn MethodId.Bolt11.rawValue } - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf(PaymentRequestLocalRole.PAYER, PaymentRequestLocalRole.PAYEE).map { role -> paymentRequestRecord( id = role.name, @@ -875,15 +1049,14 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat } @Test - fun `in flight subscription payment is neither offered nor marked paid`() = test { + fun `in flight subscription reminder remains unhandled until the same period returns`() = test { val requestId = PaykitPaymentRequestId( paymentRequestId = PAYMENT_REQUEST_ID, counterparty = COUNTERPARTY, - counterpartyReceiverPath = PaykitReceiverPaths.SERVER, billingPeriodStartsAt = "2027-01-01T08:00:00Z", ) whenever(paymentProofStore.inFlightRequestIds(LOCAL_IDENTITY)).thenReturn(setOf(requestId)) - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf(paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING)), ) @@ -891,6 +1064,67 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat assertTrue(sut.pendingRequests.value.isEmpty()) assertTrue(sut.paymentRequestHistory.value.isEmpty()) + assertFalse(sut.isSubscriptionNotificationHandled(requestId, LOCAL_IDENTITY)) + + whenever(paymentProofStore.inFlightRequestIds(LOCAL_IDENTITY)).thenReturn(emptySet()) + sut.refreshAfterStateChange(PaykitPaymentRequestRefreshMode.STORED).getOrThrow() + + assertEquals(requestId, sut.pendingRequests.value.single().id) + assertFalse(sut.isSubscriptionNotificationHandled(requestId, LOCAL_IDENTITY)) + } + + @Test + fun `subscription reminder clears for missing or inactive subscriptions`() = test { + val requestId = PaykitPaymentRequestId(PAYMENT_REQUEST_ID, COUNTERPARTY, "2027-01-01T08:00:00Z") + for (state in listOf(null, PaymentRequestLifecycleState.CANCELED, PaymentRequestLifecycleState.REJECTED)) { + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( + state?.let { listOf(paymentRequestRecord(state = it)) }.orEmpty(), + ) + + sut.refresh(PaykitPaymentRequestRefreshMode.STORED).getOrThrow() + + assertTrue(sut.isSubscriptionNotificationHandled(requestId, LOCAL_IDENTITY), state.toString()) + } + } + + @Test + fun `subscription reminder requires a successful snapshot for the current identity`() = test { + val requestId = PaykitPaymentRequestId(PAYMENT_REQUEST_ID, COUNTERPARTY, "2027-01-01T08:00:00Z") + assertFalse(sut.isSubscriptionNotificationHandled(requestId, LOCAL_IDENTITY)) + sut.refresh(PaykitPaymentRequestRefreshMode.STORED).getOrThrow() + assertTrue(sut.isSubscriptionNotificationHandled(requestId, LOCAL_IDENTITY)) + assertFalse(sut.isSubscriptionNotificationHandled(requestId, SECOND_IDENTITY)) + + whenever(settingsStore.data).thenReturn(flow { throw IllegalStateException("refresh failed") }) + assertTrue(sut.refresh(PaykitPaymentRequestRefreshMode.STORED).isFailure) + assertFalse(sut.isSubscriptionNotificationHandled(requestId, LOCAL_IDENTITY)) + + whenever(settingsStore.data).thenReturn(flowOf(SettingsData(sharesPrivatePaykitEndpoints = true))) + sut.refresh(PaykitPaymentRequestRefreshMode.STORED).getOrThrow() + val refreshStarted = CompletableDeferred() + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).doSuspendableAnswer { + refreshStarted.complete(Unit) + awaitCancellation() + } + val refresh = async { sut.refresh(PaykitPaymentRequestRefreshMode.STORED) } + refreshStarted.await() + assertFalse(sut.isSubscriptionNotificationHandled(requestId, LOCAL_IDENTITY)) + refresh.cancel() + refresh.join() + assertTrue(refresh.isCancelled) + assertFalse(sut.isSubscriptionNotificationHandled(requestId, LOCAL_IDENTITY)) + + doReturn(emptyList()).whenever(paykitSdkService).allPaymentRequests(anyOrNull()) + sut.refresh(PaykitPaymentRequestRefreshMode.STORED).getOrThrow() + whenever(settingsStore.isPaykitEnabled).thenReturn(flowOf(false)) + sut.refresh(PaykitPaymentRequestRefreshMode.STORED).getOrThrow() + assertFalse(sut.isSubscriptionNotificationHandled(requestId, LOCAL_IDENTITY)) + + whenever(settingsStore.isPaykitEnabled).thenReturn(flowOf(true)) + sut.refresh(PaykitPaymentRequestRefreshMode.STORED).getOrThrow() + sut.clear() + sut.activate(LOCAL_IDENTITY) + assertFalse(sut.isSubscriptionNotificationHandled(requestId, LOCAL_IDENTITY)) } @Test @@ -898,20 +1132,19 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat val requestId = PaykitPaymentRequestId( paymentRequestId = PAYMENT_REQUEST_ID, counterparty = COUNTERPARTY, - counterpartyReceiverPath = PaykitReceiverPaths.SERVER, billingPeriodStartsAt = "2027-01-01T08:00:00Z", ) val active = paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING) whenever(paymentProofRepo.protectedRequestIdsForSubscriptionCancellation(eq(LOCAL_IDENTITY), any())) .thenReturn(Result.success(setOf(requestId))) - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(active)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(active)) sut.refresh().getOrThrow() val result = sut.cancel(sut.subscriptions.value.single()) assertTrue(result.exceptionOrNull() is PaykitPaymentRequestError.OperationInProgress) assertEquals(1, sut.subscriptions.value.size) - verifyBlocking(paykitSdkService, never()) { cancelPaymentRequest(any(), any(), any(), anyOrNull()) } + verifyBlocking(paykitSdkService, never()) { cancelPaymentRequest(any(), any(), anyOrNull()) } } @Test @@ -924,11 +1157,10 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat state = PaymentRequestLifecycleState.CANCELED, paymentDeadline = PaymentDeadline.PeriodStart(3600uL), ) - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(active), emptyList()) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(active), emptyList()) whenever( paykitSdkService.cancelPaymentRequest( COUNTERPARTY, - PaykitReceiverPaths.SERVER, PAYMENT_REQUEST_ID, ) ).thenReturn(canceled) @@ -937,13 +1169,13 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat sut.cancel(sut.subscriptions.value.single()).getOrThrow() verifyBlocking(paykitSdkService) { - cancelPaymentRequest(COUNTERPARTY, PaykitReceiverPaths.SERVER, PAYMENT_REQUEST_ID) + cancelPaymentRequest(COUNTERPARTY, PAYMENT_REQUEST_ID) } } @Test fun `malformed expiry is rejected and unsupported payment details disable acceptance`() = test { - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf( paymentRequestRecord(id = "malformed", expiresAt = "not-a-timestamp"), paymentRequestRecord(id = "deadline", paymentDeadline = PaymentDeadline.PeriodStart(3600uL)), @@ -960,12 +1192,12 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat val deadlineSubscription = subscriptions.first { it.paymentRequestId == "deadline" } assertEquals(null, deadlineSubscription.paymentDueOnAcceptance(clock.now())) assertTrue(sut.accept(deadlineSubscription).exceptionOrNull() is PaykitPaymentRequestError.RequestUnavailable) - verifyBlocking(paykitSdkService, never()) { acceptPaymentRequest(any(), any(), any()) } + verifyBlocking(paykitSdkService, never()) { acceptPaymentRequest(any(), any()) } } @Test fun `presented subscription stays available without auto presenting after reactivation`() = test { - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf(paymentRequestRecord(id = "subscription")), ) sut.refresh().getOrThrow() @@ -990,7 +1222,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat @Test fun `subscription proposal moves to expired at its deadline`() = test { - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf(paymentRequestRecord(expiresAt = clock.now().plus(10.seconds).toString())), ) sut.refresh().getOrThrow() @@ -1011,7 +1243,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat anchor = "2027-01-01T08:00:00Z", endsAt = clock.now().plus(10.seconds).toString(), ) - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf(paymentRequestRecord(recurrence = endingRecurrence)), ) sut.refresh().getOrThrow() @@ -1025,7 +1257,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat @Test fun `ended subscription keeps its unpaid period available`() = test { - val subscriptionId = PaykitSubscriptionId(PAYMENT_REQUEST_ID, COUNTERPARTY, PaykitReceiverPaths.SERVER) + val subscriptionId = PaykitSubscriptionId(PAYMENT_REQUEST_ID, COUNTERPARTY) val endingRecurrence = PaymentRequestRecurrence( every = 1u, unit = "month", @@ -1039,7 +1271,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat acceptedAt = mapOf(subscriptionId to Instant.parse("2027-01-01T08:00:00Z")), ), ) - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf( paymentRequestRecord( state = PaymentRequestLifecycleState.ACTIVE_RECURRING, @@ -1059,17 +1291,17 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat } private suspend fun stubSubscriptionProposal(): PaykitPaymentRequestTarget { - val target = PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER) - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) - whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + val target = PaykitPaymentRequestTarget(COUNTERPARTY) + whenever(paykitSdkService.identityStatus()).thenReturn( + IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(eq(COUNTERPARTY), any())).thenReturn( - listOf(PaykitReceiverPaths.SERVER), + whenever(paykitSdkService.linkedPeers()).thenReturn( + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) - whenever(paykitSdkService.proposePaymentRequest(any(), any(), any(), eq(LOCAL_IDENTITY))) + whenever(paykitSdkService.canReceivePaymentRequests(eq(COUNTERPARTY), any())).thenReturn(true) + whenever(paykitSdkService.proposePaymentRequest(any(), any(), eq(LOCAL_IDENTITY))) .thenAnswer { invocation -> - val proposal = invocation.getArgument(2) + val proposal = invocation.getArgument(1) paymentRequestRecord( role = PaymentRequestLocalRole.PAYEE, expiresAt = proposal.proposalExpiresAt, @@ -1098,7 +1330,6 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat paymentProofs: List = emptyList(), ) = PaymentRequestRecord( counterparty = COUNTERPARTY, - counterpartyReceiverPath = PaykitReceiverPaths.SERVER, paymentRequestId = id, localRole = role, state = state, @@ -1115,6 +1346,8 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat conversion = null, paymentDeadline = paymentDeadline, metadata = metadata, + paymentEndpoints = null, + requiredAppId = "bitkit", ), acceptedEventId = null, acceptedOutboundStatus = null, @@ -1129,14 +1362,15 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat lastOutboundStatus = null, lastEventAt = clock.now().toString(), invalidReason = null, + proposalAppId = "bitkit", + payerAppId = null, + executionClaimAppId = null, ) private fun linkedPeer( publicKey: String, state: LinkedPeerState, - receiverPath: String, ) = LinkedPeerRecord( counterparty = publicKey, - counterpartyReceiverPath = receiverPath, state = state, lastSyncAt = null, lastPrivateReceiveAt = null, diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index fbcaa8f5e9..8b123bd50e 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -2,9 +2,17 @@ package to.bitkit.repositories +import com.synonym.bitkitcore.AddressType +import com.synonym.bitkitcore.NetworkType +import com.synonym.bitkitcore.ValidationResult +import com.synonym.bitkitcore.validateBitcoinAddress import com.synonym.paykit.IdentityStatus import com.synonym.paykit.LinkedPeerRecord import com.synonym.paykit.LinkedPeerState +import com.synonym.paykit.OutboundPrivateMessageStatus +import com.synonym.paykit.OutboundPrivateSendFailure +import com.synonym.paykit.OutboundPrivateSendReport +import com.synonym.paykit.PaykitException import com.synonym.paykit.PaymentDeadline import com.synonym.paykit.PaymentProofRecord import com.synonym.paykit.PaymentReference @@ -17,9 +25,15 @@ import com.synonym.paykit.PaymentRequestTerms import com.synonym.paykit.PrivateJsonObject import com.synonym.paykit.PrivateOperationError import com.synonym.paykit.PrivateStreamCounterpartyIntakeReport +import com.synonym.paykit.PubkyIdentityCapability +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceTimeBy @@ -27,9 +41,12 @@ import kotlinx.coroutines.test.runCurrent import org.junit.After import org.junit.Before import org.junit.Test +import org.mockito.Mockito.mockStatic import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.clearInvocations +import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doReturn import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.eq @@ -41,18 +58,21 @@ import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.whenever import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore +import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.services.PaykitPaymentRequestProposalTerms import to.bitkit.services.PaykitReadLane -import to.bitkit.services.PaykitReceiverPaths +import to.bitkit.services.PaykitSdkOperationLock.Priority import to.bitkit.services.PaykitSdkService import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNull +import kotlin.test.assertSame import kotlin.test.assertTrue import kotlin.time.Clock import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.nanoseconds import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime import kotlin.time.Instant @@ -77,6 +97,8 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { private val presentationStore = mock() private val diagnostics = mock() private val paymentProofStore = mock() + private val proofStateVersion = MutableStateFlow(0L) + private val paymentSubmissionActive = MutableStateFlow(false) private val paymentProofRepo = mock() private val subscriptionNotificationScheduler = mock() private var schedulerOriginMillis = 0L @@ -90,18 +112,46 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Before fun setUp() = test { schedulerOriginMillis = testDispatcher.scheduler.currentTime + whenever(paykitSdkService.isPaymentSubmissionActive).thenReturn(paymentSubmissionActive) + whenever(paykitSdkService.setPaymentSubmissionActive(any())).thenAnswer { + paymentSubmissionActive.value = it.getArgument(0) + Unit + } whenever(paykitSdkService.processPendingPrivateMessages()).thenReturn(emptyList()) + whenever(paykitSdkService.processOutboundPrivateMessages(any())).thenReturn( + OutboundPrivateSendReport(emptyList(), emptyList(), emptyList(), emptyList(), emptyList()), + ) whenever(paykitSdkService.receivePrivateMessagesFromLinkedPeers()).thenReturn(emptyList()) - whenever(paykitSdkService.paymentRequests()).thenReturn(emptyList()) + whenever(paykitSdkService.processPendingPrivateMessages(any())).doSuspendableAnswer { + paykitSdkService.processPendingPrivateMessages() + } + whenever(paykitSdkService.receivePrivateMessagesFromLinkedPeers(any())).doSuspendableAnswer { + paykitSdkService.receivePrivateMessagesFromLinkedPeers() + } + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(emptyList()) whenever(paykitSdkService.linkedPeers()).thenReturn(emptyList()) + whenever(paykitSdkService.identityStatus(any())).doSuspendableAnswer { paykitSdkService.identityStatus() } + whenever(paykitSdkService.linkedPeers(any())).doSuspendableAnswer { paykitSdkService.linkedPeers() } + whenever(paykitSdkService.allPaymentRequests(anyOrNull(), any())).doSuspendableAnswer { + paykitSdkService.allPaymentRequests(it.getArgument(0)) + } + whenever(paykitSdkService.processOutboundPrivateMessages(any(), any())).doSuspendableAnswer { + paykitSdkService.processOutboundPrivateMessages(it.getArgument(0)) + } whenever(settingsStore.isPaykitEnabled).thenReturn(flowOf(true)) whenever(settingsStore.data).thenReturn(flowOf(SettingsData(sharesPrivatePaykitEndpoints = true))) whenever(presentationStore.load(LOCAL_IDENTITY)).thenReturn(emptySet()) + whenever(presentationStore.loadAcceptedOneTimeIds(any())).thenReturn(emptySet()) + whenever(presentationStore.addAcceptedOneTimeId(any(), any())).thenAnswer { + setOf(it.getArgument(1)) + } + whenever(presentationStore.removeAcceptedOneTimeIds(any(), any())).thenReturn(emptySet()) whenever( presentationStore.loadSubscriptionState(any()) ).thenReturn(PaykitSubscriptionPresentationState()) whenever(paymentProofStore.completedRequestProofKindsAwaitingSubmission(LOCAL_IDENTITY)).thenReturn(emptyMap()) whenever(paymentProofStore.inFlightRequestIds(LOCAL_IDENTITY)).thenReturn(emptySet()) + whenever(paymentProofStore.backupStateVersion).thenReturn(proofStateVersion) whenever(paymentProofRepo.protectedRequestIdsForSubscriptionCancellation(any(), any())) .thenReturn(Result.success(emptySet())) sut = PaykitPaymentRequestRepo( @@ -124,38 +174,431 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.clear() } + @Test + fun `refresh modes control message intake and outbound maintenance`() = test { + val record = paymentRequestRecord() + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) + + sut.refresh(PaykitPaymentRequestRefreshMode.STORED).getOrThrow() + + assertEquals(record.paymentRequestId, sut.pendingRequests.value.single().paymentRequestId) + verify(paykitSdkService, never()).processPendingPrivateMessages() + verify(paykitSdkService, never()).receivePrivateMessagesFromLinkedPeers() + + sut.refresh(PaykitPaymentRequestRefreshMode.INBOX).getOrThrow() + + verify(paykitSdkService, never()).processPendingPrivateMessages() + verify(paykitSdkService).receivePrivateMessagesFromLinkedPeers() + + sut.refresh().getOrThrow() + + verify(paykitSdkService).processPendingPrivateMessages() + verify(paykitSdkService, times(2)).receivePrivateMessagesFromLinkedPeers() + verify(paykitSdkService, times(3)).allPaymentRequests(LOCAL_IDENTITY, Priority.Background) + verify(paykitSdkService, times(3)).linkedPeers(Priority.Background) + verify(paykitSdkService).processPendingPrivateMessages(Priority.Ordered) + verify(paykitSdkService, times(2)).receivePrivateMessagesFromLinkedPeers(Priority.Ordered) + } + + @Test + fun `passive refresh message priority does not change action or forced refresh drains`() = test { + sut.refresh(PaykitPaymentRequestRefreshMode.STORED, Priority.Background).getOrThrow() + verify(paykitSdkService, never()).processPendingPrivateMessages(any()) + verify(paykitSdkService, never()).receivePrivateMessagesFromLinkedPeers(any()) + + sut.refresh(PaykitPaymentRequestRefreshMode.INBOX, Priority.Background).getOrThrow() + verify(paykitSdkService, never()).processPendingPrivateMessages(any()) + verify(paykitSdkService).receivePrivateMessagesFromLinkedPeers(Priority.Background) + + sut.refresh(PaykitPaymentRequestRefreshMode.FULL, Priority.Background).getOrThrow() + verify(paykitSdkService).processPendingPrivateMessages(Priority.Background) + verify(paykitSdkService, times(2)).receivePrivateMessagesFromLinkedPeers(Priority.Background) + + clearInvocations(paykitSdkService) + sut.refreshAfterStateChange().getOrThrow() + verify(paykitSdkService).processPendingPrivateMessages(Priority.Ordered) + verify(paykitSdkService).receivePrivateMessagesFromLinkedPeers(Priority.Ordered) + verify(paykitSdkService).allPaymentRequests(LOCAL_IDENTITY, Priority.Ordered) + verify(paykitSdkService, never()).processPendingPrivateMessages(Priority.Background) + + val record = paymentRequestRecord() + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) + sut.refresh(PaykitPaymentRequestRefreshMode.STORED).getOrThrow() + whenever(paykitSdkService.rejectPaymentRequest(COUNTERPARTY, PAYMENT_REQUEST_ID)) + .thenReturn(record.copy(state = PaymentRequestLifecycleState.REJECTED)) + clearInvocations(paykitSdkService) + sut.reject(sut.pendingRequests.value.single()).getOrThrow() + verify(paykitSdkService).processPendingPrivateMessages(Priority.Ordered) + verify(paykitSdkService, never()).processPendingPrivateMessages(Priority.Background) + } + + @Test + fun `refresh refetches after a committed uncertain or cancelled action`() = test { + val failures = listOf( + null, + PaykitException.Storage("write_uncertain", "Request may be rejected"), + CancellationException("Action cancelled after commit"), + ) + for (failure in failures) { + val record = paymentRequestRecord() + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) + sut.refresh(PaykitPaymentRequestRefreshMode.STORED).getOrThrow() + val request = sut.pendingRequests.value.single() + val reading = CompletableDeferred() + val resume = CompletableDeferred() + var reads = 0 + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).doSuspendableAnswer { + if (reads++ == 0) { + reading.complete(Unit) + resume.await() + listOf(record) + } else { + listOf(record.copy(state = PaymentRequestLifecycleState.REJECTED)) + } + } + doAnswer { + failure?.let { throw it } + record.copy(state = PaymentRequestLifecycleState.REJECTED) + }.whenever(paykitSdkService).rejectPaymentRequest(COUNTERPARTY, PAYMENT_REQUEST_ID) + clearInvocations(paykitSdkService) + val refresh = async { sut.refresh(PaykitPaymentRequestRefreshMode.FULL) } + reading.await() + + if (failure is CancellationException) { + assertFailsWith { sut.reject(request) } + } else { + assertEquals(failure != null, sut.reject(request).isFailure) + } + resume.complete(Unit) + refresh.await().getOrThrow() + + assertTrue(sut.pendingRequests.value.isEmpty()) + assertEquals(PaymentRequestLifecycleState.REJECTED, sut.paymentRequestHistory.value.single().lifecycleState) + assertEquals(2, reads) + verify(paykitSdkService, times(if (failure == null) 2 else 1)).processPendingPrivateMessages() + verify(paykitSdkService).receivePrivateMessagesFromLinkedPeers() + verify(paykitSdkService).allPaymentRequests(LOCAL_IDENTITY, Priority.Background) + verify(paykitSdkService).allPaymentRequests(LOCAL_IDENTITY, Priority.Ordered) + } + } + + @Test + fun `refresh applies expiration using current time after request fetch`() = test { + val record = paymentRequestRecord(expiresAt = clock.now().plus(1.seconds).toString()) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) + sut.refresh(PaykitPaymentRequestRefreshMode.STORED).getOrThrow() + val reading = CompletableDeferred() + val resume = CompletableDeferred() + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).doSuspendableAnswer { + reading.complete(Unit) + resume.await() + listOf(record) + } + val refresh = async { sut.refresh(PaykitPaymentRequestRefreshMode.STORED) } + reading.await() + advanceTimeBy(2.seconds) + runCurrent() + assertTrue(sut.pendingRequests.value.isEmpty()) + resume.complete(Unit) + refresh.await().getOrThrow() + assertTrue(sut.pendingRequests.value.isEmpty()) + } + + @Test + fun `overlapping refreshes reuse a completed full refresh`() = test { + val reading = CompletableDeferred() + val resume = CompletableDeferred() + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).doSuspendableAnswer { + reading.complete(Unit) + resume.await() + emptyList() + } + val first = async { sut.refresh(PaykitPaymentRequestRefreshMode.FULL, Priority.Background) } + reading.await() + val queued = PaykitPaymentRequestRefreshMode.entries.map { mode -> async { sut.refresh(mode) } } + runCurrent() + resume.complete(Unit) + + first.await().getOrThrow() + queued.forEach { it.await().getOrThrow() } + verify(paykitSdkService).allPaymentRequests(anyOrNull()) + verify(paykitSdkService).processPendingPrivateMessages() + verify(paykitSdkService).processPendingPrivateMessages(Priority.Background) + verify(paykitSdkService, never()).processPendingPrivateMessages(Priority.Ordered) + + sut.refresh().getOrThrow() + verify(paykitSdkService, times(2)).allPaymentRequests(anyOrNull()) + verify(paykitSdkService).processPendingPrivateMessages(Priority.Ordered) + } + + @Test + fun `full refresh still runs after an overlapping inbox refresh`() = test { + val reading = CompletableDeferred() + val resume = CompletableDeferred() + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).doSuspendableAnswer { + reading.complete(Unit) + resume.await() + emptyList() + } + val inbox = async { sut.refresh(PaykitPaymentRequestRefreshMode.INBOX) } + reading.await() + val full = async { sut.refresh() } + runCurrent() + resume.complete(Unit) + + inbox.await().getOrThrow() + full.await().getOrThrow() + verify(paykitSdkService, times(2)).allPaymentRequests(anyOrNull()) + verify(paykitSdkService).processPendingPrivateMessages() + } + + @Test + fun `queued refresh retries a failed refresh`() = test { + val reading = CompletableDeferred() + val resume = CompletableDeferred() + var attempts = 0 + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).doSuspendableAnswer { + if (attempts++ == 0) { + reading.complete(Unit) + resume.await() + throw PaykitPaymentRequestError.RequestUnavailable + } + emptyList() + } + val failed = async { sut.refresh() } + reading.await() + val retry = async { sut.refresh() } + runCurrent() + resume.complete(Unit) + + assertTrue(failed.await().isFailure) + retry.await().getOrThrow() + verify(paykitSdkService, times(2)).allPaymentRequests(anyOrNull()) + } + + @Test + fun `overlapping refresh rereads proof state changed after the first snapshot`() = test { + val record = paymentRequestRecord() + val requestId = PaykitPaymentRequestId(record.paymentRequestId, record.counterparty) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) + whenever(paymentProofStore.inFlightRequestIds(LOCAL_IDENTITY)).thenReturn(setOf(requestId)) + val reading = CompletableDeferred() + val resume = CompletableDeferred() + whenever(settingsStore.data).thenReturn( + flow { + reading.complete(Unit) + resume.await() + emit(SettingsData(sharesPrivatePaykitEndpoints = true)) + }, + ) + val first = async { sut.refresh() } + reading.await() + assertTrue(sut.pendingRequests.value.isEmpty()) + + whenever(paymentProofStore.inFlightRequestIds(LOCAL_IDENTITY)).thenReturn(emptySet()) + proofStateVersion.value += 1 + val afterFailure = async { sut.refreshAfterStateChange(PaykitPaymentRequestRefreshMode.STORED) } + runCurrent() + resume.complete(Unit) + first.await().getOrThrow() + afterFailure.await().getOrThrow() + + assertEquals(requestId, sut.pendingRequests.value.single().id) + verify(paykitSdkService, times(2)).allPaymentRequests(anyOrNull()) + verify(paykitSdkService).processPendingPrivateMessages() + verify(paykitSdkService).receivePrivateMessagesFromLinkedPeers() + } + + @Test + fun `refresh rereads proof changes before applying and rejects a changing reload`() = test { + val record = paymentRequestRecord() + val requestId = PaykitPaymentRequestId(record.paymentRequestId, record.counterparty) + val submitted = record.copy(state = PaymentRequestLifecycleState.PROOF_SUBMITTED) + for (changeDuringReload in listOf(false, true)) { + val reading = CompletableDeferred() + val resume = CompletableDeferred() + var reads = 0 + whenever(paymentProofStore.completedRequestProofKindsAwaitingSubmission(LOCAL_IDENTITY)) + .thenReturn(mapOf(requestId to PaykitPaymentProofKind.Lightning)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).doSuspendableAnswer { + if (reads++ == 0) { + reading.complete(Unit) + resume.await() + listOf(record) + } else { + if (changeDuringReload) proofStateVersion.value += 1 + listOf(submitted) + } + } + clearInvocations(paykitSdkService) + val refresh = async { sut.refresh(PaykitPaymentRequestRefreshMode.STORED) } + reading.await() + whenever(paymentProofStore.completedRequestProofKindsAwaitingSubmission(LOCAL_IDENTITY)) + .thenReturn(emptyMap()) + proofStateVersion.value += 1 + resume.complete(Unit) + + val result = refresh.await() + + assertEquals(changeDuringReload, result.isFailure) + assertTrue(sut.pendingRequests.value.isEmpty()) + if (!changeDuringReload) { + assertEquals( + PaymentRequestLifecycleState.PROOF_SUBMITTED, + sut.paymentRequestHistory.value.single().lifecycleState, + ) + } + verify(paykitSdkService).allPaymentRequests(LOCAL_IDENTITY, Priority.Background) + verify(paykitSdkService).allPaymentRequests(LOCAL_IDENTITY, Priority.Ordered) + verify(paykitSdkService, never()).processPendingPrivateMessages() + verify(paykitSdkService, never()).receivePrivateMessagesFromLinkedPeers() + } + } + + @Test + fun `state change refresh rereads a peer blocked after the first snapshot`() = test { + val record = paymentRequestRecord() + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) + val reading = CompletableDeferred() + val resume = CompletableDeferred() + whenever(settingsStore.data).thenReturn( + flow { + reading.complete(Unit) + resume.await() + emit(SettingsData(sharesPrivatePaykitEndpoints = true)) + }, + ) + val first = async { sut.refresh() } + reading.await() + assertEquals(record.paymentRequestId, sut.pendingRequests.value.single().paymentRequestId) + + whenever(paykitSdkService.linkedPeers()).thenReturn( + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.BLOCKED)), + ) + val afterBlock = async { sut.refreshAfterStateChange() } + runCurrent() + resume.complete(Unit) + first.await().getOrThrow() + afterBlock.await().getOrThrow() + + assertTrue(sut.pendingRequests.value.isEmpty()) + verify(paykitSdkService, times(2)).linkedPeers() + verify(paykitSdkService).linkedPeers(Priority.Background) + verify(paykitSdkService).linkedPeers(Priority.Ordered) + verify(paykitSdkService).allPaymentRequests(LOCAL_IDENTITY, Priority.Background) + verify(paykitSdkService).allPaymentRequests(LOCAL_IDENTITY, Priority.Ordered) + } + + @Test + fun `state change refresh rereads a request drained after the first snapshot`() = test { + val reading = CompletableDeferred() + val resume = CompletableDeferred() + whenever(settingsStore.data).thenReturn( + flow { + reading.complete(Unit) + resume.await() + emit(SettingsData(sharesPrivatePaykitEndpoints = true)) + }, + ) + val first = async { sut.refresh() } + reading.await() + assertTrue(sut.pendingRequests.value.isEmpty()) + + val record = paymentRequestRecord() + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) + val afterDrain = async { sut.refreshAfterStateChange() } + runCurrent() + resume.complete(Unit) + first.await().getOrThrow() + afterDrain.await().getOrThrow() + + assertEquals(record.paymentRequestId, sut.pendingRequests.value.single().paymentRequestId) + verify(paykitSdkService, times(2)).allPaymentRequests(anyOrNull()) + } + + @Test + fun `shared app destinations survive refresh failure but clear on identity switch`() = test { + val address = PaykitReceivedPaymentContactsTest.ADDRESS + val record = paymentRequestRecord(role = PaymentRequestLocalRole.PAYEE).let { + it.copy( + proposalAppId = "marketplace", + terms = requireNotNull(it.terms).copy( + acceptedPaymentEndpointIdentifiers = listOf(MethodId.P2wpkh.rawValue), + paymentEndpoints = mapOf( + MethodId.P2wpkh.rawValue to PaykitReceivedPaymentContactsTest.payload(address), + ), + ), + ) + } + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) + mockStatic(Class.forName("com.synonym.bitkitcore.Bitkitcore_androidKt")).use { native -> + native.`when` { validateBitcoinAddress(address) } + .thenReturn(ValidationResult(address, NetworkType.REGTEST, AddressType.P2WPKH)) + sut.refresh().getOrThrow() + } + assertEquals( + setOf(PubkyPublicKeyFormat.normalized(COUNTERPARTY)), + sut.receivedPaymentContacts.contactsForAddresses(listOf(address)), + ) + assertTrue(sut.pendingRequests.value.isEmpty()) + assertTrue(sut.paymentRequestHistory.value.isEmpty()) + verify(paykitSdkService).allPaymentRequests(PubkyPublicKeyFormat.normalized(LOCAL_IDENTITY)) + + val contacts = sut.receivedPaymentContacts + val reading = CompletableDeferred() + val resume = CompletableDeferred() + whenever(paykitSdkService.receivePrivateMessagesFromLinkedPeers()).doSuspendableAnswer { + reading.complete(Unit) + resume.await() + throw PaykitPaymentRequestError.RequestUnavailable + } + val refresh = async { sut.refresh(PaykitPaymentRequestRefreshMode.INBOX) } + reading.await() + assertSame(contacts, sut.receivedPaymentContacts) + resume.complete(Unit) + assertTrue(refresh.await().isFailure) + assertSame(contacts, sut.receivedPaymentContacts) + + sut.activate(SECOND_IDENTITY) + assertTrue(sut.receivedPaymentContacts.contactsForAddresses(listOf(address)).isEmpty()) + sut.clear() + assertTrue(sut.receivedPaymentContacts.contactsForAddresses(listOf(address)).isEmpty()) + } + @Test fun `blocking peer hides requests from an earlier snapshot`() = test { val record = paymentRequestRecord() - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) sut.refresh().getOrThrow() assertEquals(1, sut.pendingRequests.value.size) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.BLOCKED, record.counterpartyReceiverPath)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.BLOCKED)), ) sut.refresh().getOrThrow() assertTrue(sut.pendingRequests.value.isEmpty()) - whenever(paykitSdkService.paymentRequests()).thenReturn(emptyList()) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(emptyList()) sut.refresh().getOrThrow() assertTrue(sut.paymentRequestHistory.value.isEmpty()) } @Test fun `blocking an already presented accepted request prevents payment`() = test { + restoreAcceptedRequest() val record = paymentRequestRecord(state = PaymentRequestLifecycleState.ACCEPTED) - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) sut.refresh().getOrThrow() val request = sut.pendingRequests.value.single() whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.BLOCKED, record.counterpartyReceiverPath)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.BLOCKED)), ) assertEquals(PaykitPaymentRequestError.RequestUnavailable, sut.accept(request).exceptionOrNull()) } @Test - fun `accepted request checks blocking after waiting for synchronization`() = test { + fun `accepted request checks blocking during synchronization`() = test { + restoreAcceptedRequest() val record = paymentRequestRecord(state = PaymentRequestLifecycleState.ACCEPTED) - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) sut.refresh().getOrThrow() val request = sut.pendingRequests.value.single() val refreshPaused = CompletableDeferred() @@ -168,7 +611,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { resumeRefresh.await() emptyList() } else if (blocked) { - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.BLOCKED, record.counterpartyReceiverPath)) + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.BLOCKED)) } else { emptyList() } @@ -176,9 +619,9 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { val refresh = async { sut.refresh() } runCurrent() refreshPaused.await() + blocked = true val acceptance = async { sut.accept(request) } runCurrent() - blocked = true resumeRefresh.complete(Unit) refresh.await().getOrThrow() assertEquals(PaykitPaymentRequestError.RequestUnavailable, acceptance.await().exceptionOrNull()) @@ -187,7 +630,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `refresh maps actionable bitcoin request`() = test { val record = paymentRequestRecord(expiresAt = clock.now().plus(60.seconds).toString()) - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) sut.refresh().getOrThrow() @@ -196,18 +639,47 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals(listOf(MethodId.Bolt11.rawValue), request.acceptedPaymentEndpointIdentifiers) } + @Test + fun `absolute payment deadlines preserve fractional seconds and include the deadline instant`() { + val deadline = START_TIME + 123.milliseconds + val record = paymentRequestRecord(paymentDeadline = PaymentDeadline.At(deadline.toString())) + + listOf(deadline - 1.nanoseconds, deadline).forEach { now -> + val parsed = record.parseIncomingPaykitPaymentRequest(now) as PaykitPaymentRequestParseResult.Parsed + assertEquals(deadline, parsed.request.paymentDeadlineAt) + assertFalse(parsed.request.isExpired(now)) + } + val expired = record.parseIncomingPaykitPaymentRequest(deadline + 1.nanoseconds) + as PaykitPaymentRequestParseResult.Rejected + assertEquals(PaykitPaymentRequest.ParseFailure.Expired, expired.reason) + } + + @Test + fun `malformed and recurring payment deadlines remain non actionable`() { + val deadlines = listOf( + PaymentDeadline.At("not-a-timestamp"), + PaymentDeadline.At("2027-01-15T09:00:00+01:00"), + PaymentDeadline.PeriodStart(3600uL), + ) + + deadlines.forEach { deadline -> + val result = paymentRequestRecord(paymentDeadline = deadline).parseIncomingPaykitPaymentRequest(START_TIME) + as PaykitPaymentRequestParseResult.Rejected + assertEquals(PaykitPaymentRequest.ParseFailure.UnsupportedPaymentDeadline, result.reason) + } + } + @Test fun `peer intake failure does not drop received requests`() = test { val record = paymentRequestRecord() val error = mock { on { redactedContext() } doReturn "transport failure" } - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) whenever(paykitSdkService.receivePrivateMessagesFromLinkedPeers()).thenReturn( listOf( PrivateStreamCounterpartyIntakeReport( counterparty = COUNTERPARTY, - counterpartyReceiverPath = PaykitReceiverPaths.WALLET, report = null, error = error, ), @@ -230,7 +702,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { PaykitPaymentRequest.ParseFailure.NonActionableState, paymentRequestRecord().copy(terms = null) to PaykitPaymentRequest.ParseFailure.MissingTerms, paymentRequestRecord(asset = "BTC") to PaykitPaymentRequest.ParseFailure.UnsupportedAsset, - paymentRequestRecord(paymentDeadline = PaymentDeadline.At(clock.now().plus(1.seconds).toString())) to + paymentRequestRecord(paymentDeadline = PaymentDeadline.PeriodStart(3600uL)) to PaykitPaymentRequest.ParseFailure.UnsupportedPaymentDeadline, paymentRequestRecord(amount = "not-bitcoin") to PaykitPaymentRequest.ParseFailure.InvalidAmount, paymentRequestRecord(amount = "184467440737.09551615") to @@ -256,7 +728,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { asset = "BTC", counterparty = "secret", ) - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) sut.refresh().getOrThrow() @@ -264,22 +736,22 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `refresh logs unknown local role as unsupported_local_role`() = test { + fun `refresh excludes unknown local roles at the app boundary`() = test { val record = paymentRequestRecord( role = PaymentRequestLocalRole.UNKNOWN, counterparty = "secret", ) - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) sut.refresh().getOrThrow() - verify(diagnostics).logParseRejection("secret", PaykitPaymentRequest.ParseFailure.UnsupportedLocalRole) + verify(diagnostics, never()).logParseRejection(any(), any()) assertTrue(sut.pendingRequests.value.isEmpty()) } @Test fun `refresh does not log outgoing payee requests`() = test { - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf(paymentRequestRecord(role = PaymentRequestLocalRole.PAYEE, counterparty = "secret")), ) @@ -290,7 +762,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `refresh does not log expired requests`() = test { - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf(paymentRequestRecord(expiresAt = clock.now().toString())), ) @@ -301,7 +773,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `refresh rejects amounts outside the app payment range`() = test { - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf( paymentRequestRecord(id = "millisatoshi-safe-max", amount = "184467440.73709551"), paymentRequestRecord(id = "millisatoshi-overflow", amount = "184467440.73709552"), @@ -322,7 +794,6 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = PaykitPaymentRequest( paymentRequestId = PAYMENT_REQUEST_ID, counterparty = COUNTERPARTY, - counterpartyReceiverPath = PaykitReceiverPaths.SERVER, amountValue = "0.000025", amountSats = 2_500uL, expiresAt = null, @@ -337,7 +808,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `refresh drops expired unsupported and non payer requests`() = test { - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf( paymentRequestRecord(expiresAt = clock.now().toString()), paymentRequestRecord(id = "unsupported", endpoints = listOf("btc-unsupported-method")), @@ -352,7 +823,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `refresh keeps one time bitcoin lifecycle history`() = test { - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf( paymentRequestRecord(id = "incoming"), paymentRequestRecord(id = "accepted", state = PaymentRequestLifecycleState.ACCEPTED), @@ -375,14 +846,14 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentRequestRecord( id = "deadline-$state", state = state, - paymentDeadline = PaymentDeadline.At(clock.now().toString()), + paymentDeadline = PaymentDeadline.At((clock.now() - 1.seconds).toString()), ) }, ) sut.refresh().getOrThrow() - assertEquals(listOf("incoming", "accepted"), sut.pendingRequests.value.map { it.paymentRequestId }) + assertEquals(listOf("incoming"), sut.pendingRequests.value.map { it.paymentRequestId }) assertEquals( setOf( "incoming", "accepted", "rejected", "expired", "outgoing", "unsupported", @@ -407,11 +878,10 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { val requestId = PaykitPaymentRequestId( paymentRequestId = PAYMENT_REQUEST_ID, counterparty = COUNTERPARTY, - counterpartyReceiverPath = PaykitReceiverPaths.SERVER, ) whenever(paymentProofStore.completedRequestProofKindsAwaitingSubmission(LOCAL_IDENTITY)) .thenReturn(mapOf(requestId to PaykitPaymentProofKind.Onchain)) - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) sut.refresh().getOrThrow() @@ -425,7 +895,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { val proof = mock { on { paymentEndpointIdentifier } doReturn MethodId.Bolt11.rawValue } - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf( paymentRequestRecord( state = PaymentRequestLifecycleState.PROOF_SUBMITTED, @@ -441,7 +911,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `pending request is removed exactly when it expires`() = test { - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf(paymentRequestRecord(expiresAt = clock.now().plus(10.seconds).toString())), ) sut.refresh().getOrThrow() @@ -461,7 +931,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `outgoing request moves to expired history exactly when it expires`() = test { - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf( paymentRequestRecord( role = PaymentRequestLocalRole.PAYEE, @@ -484,24 +954,477 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `accept removes current request and delivers queued response`() = test { + fun `accept commits locally without waiting for peer delivery during payment`() = test { val record = paymentRequestRecord() - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) whenever( paykitSdkService.acceptPaymentRequest( COUNTERPARTY, - PaykitReceiverPaths.SERVER, PAYMENT_REQUEST_ID, ), ).thenReturn(record) sut.refresh().getOrThrow() clearInvocations(paykitSdkService) + sut.setPaymentSubmissionActive(true) sut.accept(sut.pendingRequests.value.single()).getOrThrow() + runCurrent() assertTrue(sut.pendingRequests.value.isEmpty()) assertEquals(PaymentRequestLifecycleState.ACCEPTED, sut.paymentRequestHistory.value.single().lifecycleState) - verifyBlocking(paykitSdkService) { processPendingPrivateMessages() } + verify(paykitSdkService, never()).processPendingPrivateMessages(any()) + verify(paykitSdkService, never()).processOutboundPrivateMessages(any(), any(), anyOrNull()) + verify(presentationStore).addAcceptedOneTimeId( + LOCAL_IDENTITY, + PaykitPaymentRequestId(PAYMENT_REQUEST_ID, COUNTERPARTY), + ) + whenever(paykitSdkService.processOutboundPrivateMessages(COUNTERPARTY, Priority.Background, LOCAL_IDENTITY)) + .doSuspendableAnswer { throw PaykitException.Transport("transport_error", "Offline") } + sut.setPaymentSubmissionActive(false) + runCurrent() + verify(paykitSdkService).processOutboundPrivateMessages(COUNTERPARTY, Priority.Background, LOCAL_IDENTITY) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())) + .thenReturn(listOf(record.copy(state = PaymentRequestLifecycleState.ACCEPTED))) + sut.refresh(PaykitPaymentRequestRefreshMode.FULL, Priority.Background).getOrThrow() + verify(paykitSdkService).processPendingPrivateMessages(Priority.Background) + verify(paykitSdkService, times(1)).acceptPaymentRequest(any(), any()) + } + + @Test + fun `deferred acceptance delivery is discarded when identity changes`() = test { + val record = paymentRequestRecord() + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) + whenever(paykitSdkService.acceptPaymentRequest(any(), any())).thenReturn(record) + sut.refresh().getOrThrow() + sut.setPaymentSubmissionActive(true) + sut.accept(sut.pendingRequests.value.single()).getOrThrow() + runCurrent() + + sut.activate(SECOND_IDENTITY) + runCurrent() + + verify(paykitSdkService, never()).processOutboundPrivateMessages(any(), any(), anyOrNull()) + assertFalse(paymentSubmissionActive.value) + } + + @Test + fun `local acceptance survives refresh and reconnect without accepting twice`() = test { + val proposed = paymentRequestRecord() + val accepted = proposed.copy(state = PaymentRequestLifecycleState.ACCEPTED) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(proposed)) + whenever(paykitSdkService.acceptPaymentRequest(any(), any())).thenReturn(accepted) + sut.refresh().getOrThrow() + val request = sut.pendingRequests.value.single() + sut.accept(request).getOrThrow() + + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(accepted)) + sut.refresh().getOrThrow() + sut.accept(sut.pendingRequests.value.single()).getOrThrow() + whenever(presentationStore.loadAcceptedOneTimeIds(LOCAL_IDENTITY)).thenReturn(setOf(request.id)) + sut.clear() + sut.activate(LOCAL_IDENTITY) + sut.refresh().getOrThrow() + sut.accept(sut.pendingRequests.value.single()).getOrThrow() + verify(paykitSdkService, times(1)).acceptPaymentRequest(any(), any()) + sut.ensurePaymentAllowed(request).getOrThrow() + + sut.activate(SECOND_IDENTITY) + assertTrue(sut.ensurePaymentAllowed(request).isFailure) + } + + @Test + fun `acceptance cleanup removes only confirmed finished requests`() = test { + val records = listOf( + paymentRequestRecord(id = "paid", state = PaymentRequestLifecycleState.PROOF_SUBMITTED), + paymentRequestRecord(id = "canceled", state = PaymentRequestLifecycleState.CANCELED), + paymentRequestRecord(id = "rejected", state = PaymentRequestLifecycleState.REJECTED), + paymentRequestRecord( + id = "retry", + state = PaymentRequestLifecycleState.ACCEPTED, + expiresAt = "2020-01-01T00:00:00Z", + ), + paymentRequestRecord(id = "recovery", state = PaymentRequestLifecycleState.RECOVERY_REQUIRED), + paymentRequestRecord(id = "conflict", state = PaymentRequestLifecycleState.INVALID_CONFLICT), + ) + val ids = records.mapTo(mutableSetOf()) { PaykitPaymentRequestId(it.paymentRequestId, it.counterparty) } + + PaykitPaymentRequestId("missing", COUNTERPARTY) + val finished = setOf("paid", "canceled", "rejected") + .mapTo(mutableSetOf()) { PaykitPaymentRequestId(it, COUNTERPARTY) } + whenever(presentationStore.loadAcceptedOneTimeIds(LOCAL_IDENTITY)).thenReturn(ids) + sut.clear() + sut.activate(LOCAL_IDENTITY) + sut.refresh().getOrThrow() + verify(presentationStore, never()).removeAcceptedOneTimeIds(any(), any()) + + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(records) + whenever(presentationStore.removeAcceptedOneTimeIds(LOCAL_IDENTITY, finished)).thenReturn(ids - finished) + sut.refresh().getOrThrow() + verify(presentationStore).removeAcceptedOneTimeIds(LOCAL_IDENTITY, finished) + val retry = sut.pendingRequests.value.single() + assertEquals("retry", retry.paymentRequestId) + sut.ensurePaymentAllowed(retry).getOrThrow() + sut.refresh().getOrThrow() + verify(presentationStore, times(1)).removeAcceptedOneTimeIds(any(), any()) + } + + @Test + fun `failed acceptance cleanup retains ids and retries`() = test { + val record = paymentRequestRecord(state = PaymentRequestLifecycleState.PROOF_SUBMITTED) + val ids = setOf(PaykitPaymentRequestId(record.paymentRequestId, record.counterparty)) + whenever(presentationStore.loadAcceptedOneTimeIds(LOCAL_IDENTITY)).thenReturn(ids) + whenever(presentationStore.removeAcceptedOneTimeIds(LOCAL_IDENTITY, ids)) + .thenThrow(IllegalStateException("disk")).thenReturn(emptySet()) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) + sut.clear() + sut.activate(LOCAL_IDENTITY) + + sut.refresh().getOrThrow() + assertTrue(sut.pendingRequests.value.isEmpty()) + sut.refresh().getOrThrow() + sut.refresh().getOrThrow() + verify(presentationStore, times(2)).removeAcceptedOneTimeIds(LOCAL_IDENTITY, ids) + } + + @Test + fun `terminal refresh revokes one time authorization even when acceptance cleanup fails`() = test { + for (state in listOf(PaymentRequestLifecycleState.CANCELED, PaymentRequestLifecycleState.PROOF_SUBMITTED)) { + restoreAcceptedRequest() + val record = paymentRequestRecord(state = PaymentRequestLifecycleState.ACCEPTED) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) + sut.refresh(PaykitPaymentRequestRefreshMode.STORED).getOrThrow() + val preparedRequest = sut.pendingRequests.value.single() + sut.ensurePaymentAllowed(preparedRequest).getOrThrow() + + whenever(presentationStore.removeAcceptedOneTimeIds(any(), any())) + .thenThrow(IllegalStateException("disk")) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record.copy(state = state))) + sut.refresh(PaykitPaymentRequestRefreshMode.STORED).getOrThrow() + + assertEquals(state, sut.paymentRequestHistory.value.single().lifecycleState) + assertTrue(sut.ensurePaymentAllowed(preparedRequest).isFailure) + } + } + + @Test + fun `final authorization rechecks identity after asynchronous peer lookup`() = test { + restoreAcceptedRequest() + whenever(paykitSdkService.allPaymentRequests(anyOrNull())) + .thenReturn( + listOf( + paymentRequestRecord( + state = PaymentRequestLifecycleState.ACCEPTED, + paymentDeadline = PaymentDeadline.At((clock.now() + 60.seconds).toString()), + ), + ), + ) + sut.refresh().getOrThrow() + val request = sut.pendingRequests.value.single() + val checking = CompletableDeferred() + val checked = CompletableDeferred() + whenever(paykitSdkService.linkedPeers()).doSuspendableAnswer { + checking.complete(Unit) + checked.await() + emptyList() + } + val authorization = async { sut.ensurePaymentAllowed(request) } + checking.await() + sut.activate(SECOND_IDENTITY) + checked.complete(Unit) + + assertTrue(authorization.await().isFailure) + } + + @Test + fun `accepted request remains payable after proposal expiry until its payment deadline`() = test { + restoreAcceptedRequest() + val deadline = clock.now() + 1.seconds + val record = paymentRequestRecord( + state = PaymentRequestLifecycleState.ACCEPTED, + expiresAt = (clock.now() - 1.seconds).toString(), + paymentDeadline = PaymentDeadline.At(deadline.toString()), + ) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) + sut.refresh().getOrThrow() + val request = sut.pendingRequests.value.single() + + sut.ensurePaymentAllowed(request).getOrThrow() + sut.ensurePaymentAllowed(request.copy(lifecycleState = PaymentRequestLifecycleState.PROPOSED)).getOrThrow() + advanceTimeBy(1_000) + runCurrent() + assertTrue(sut.isPending(request)) + sut.ensurePaymentAllowed(request).getOrThrow() + advanceTimeBy(1) + runCurrent() + + assertFalse(sut.isPending(request)) + assertTrue(sut.pendingRequests.value.isEmpty()) + assertEquals(PaykitPaymentRequestError.RequestExpired, sut.ensurePaymentAllowed(request).exceptionOrNull()) + assertEquals(PaymentRequestLifecycleState.ACCEPTED, sut.paymentRequestHistory.value.single().lifecycleState) + assertEquals(deadline, sut.paymentRequestHistory.value.single().paymentDeadlineAt) + verify(presentationStore, never()).removeAcceptedOneTimeIds(any(), any()) + } + + @Test + fun `final authorization rejects a deadline crossed during peer lookup`() = test { + restoreAcceptedRequest() + val record = paymentRequestRecord( + state = PaymentRequestLifecycleState.ACCEPTED, + paymentDeadline = PaymentDeadline.At((clock.now() + 1.seconds).toString()), + ) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) + sut.refresh().getOrThrow() + val request = sut.pendingRequests.value.single() + val checking = CompletableDeferred() + val checked = CompletableDeferred() + whenever(paykitSdkService.linkedPeers()).doSuspendableAnswer { + checking.complete(Unit) + checked.await() + emptyList() + } + + val authorization = async { sut.ensurePaymentAllowed(request) } + checking.await() + advanceTimeBy(1_001) + checked.complete(Unit) + + assertEquals(PaykitPaymentRequestError.RequestExpired, authorization.await().exceptionOrNull()) + } + + @Test + fun `deadline authorization preserves cancellation during peer lookup`() = test { + restoreAcceptedRequest() + val record = paymentRequestRecord( + state = PaymentRequestLifecycleState.ACCEPTED, + paymentDeadline = PaymentDeadline.At((clock.now() + 60.seconds).toString()), + ) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) + sut.refresh().getOrThrow() + val request = sut.pendingRequests.value.single() + whenever(paykitSdkService.linkedPeers()).thenThrow(CancellationException("cancelled")) + + assertFailsWith { sut.ensurePaymentAllowed(request) } + } + + @Test + fun `queued identity switch invalidates ownership before acquiring the repository mutex`() = test { + restoreAcceptedRequest() + whenever(paykitSdkService.allPaymentRequests(anyOrNull())) + .thenReturn(listOf(paymentRequestRecord(state = PaymentRequestLifecycleState.ACCEPTED))) + sut.refresh().getOrThrow() + val request = sut.pendingRequests.value.single() + val refreshing = CompletableDeferred() + val refreshed = CompletableDeferred() + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).doSuspendableAnswer { + refreshing.complete(Unit) + refreshed.await() + emptyList() + } + val refresh = async { sut.refresh() } + refreshing.await() + val activation = async { sut.activate(SECOND_IDENTITY) } + runCurrent() + + assertTrue(sut.ensurePaymentAllowed(request).isFailure) + refreshed.complete(Unit) + refresh.await() + activation.await() + } + + @Test + fun `overlapping identity activations preserve restored acceptance`() = test { + val record = paymentRequestRecord(state = PaymentRequestLifecycleState.ACCEPTED) + val requestId = PaykitPaymentRequestId(record.paymentRequestId, record.counterparty) + for (lastIdentity in listOf(SECOND_IDENTITY, LOCAL_IDENTITY)) { + sut.clear() + for (identity in listOf(LOCAL_IDENTITY, SECOND_IDENTITY)) { + whenever(presentationStore.loadAcceptedOneTimeIds(identity)) + .thenReturn(if (identity == lastIdentity) setOf(requestId) else emptySet()) + } + sut.activate(LOCAL_IDENTITY) + val refreshing = CompletableDeferred() + val refreshed = CompletableDeferred() + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).doSuspendableAnswer { + refreshing.complete(Unit) + refreshed.await() + listOf(record) + } + val refresh = async { sut.refresh() } + refreshing.await() + val firstActivation = async { sut.activate(SECOND_IDENTITY) } + val secondActivation = async { sut.activate(lastIdentity) } + runCurrent() + refreshed.complete(Unit) + refresh.await().getOrThrow() + firstActivation.await() + secondActivation.await() + + sut.refresh().getOrThrow() + val request = sut.pendingRequests.value.single() + assertEquals(requestId, request.id) + sut.ensurePaymentAllowed(request).getOrThrow() + sut.activate(lastIdentity) + sut.ensurePaymentAllowed(request).getOrThrow() + } + } + + @Test + fun `failed intent persistence prevents remote acceptance`() = test { + val proposed = paymentRequestRecord() + val accepted = proposed.copy(state = PaymentRequestLifecycleState.ACCEPTED) + whenever(presentationStore.addAcceptedOneTimeId(any(), any())).thenThrow(IllegalStateException("disk")) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(proposed)) + sut.refresh().getOrThrow() + assertTrue(sut.accept(sut.pendingRequests.value.single()).isFailure) + verify(paykitSdkService, never()).acceptPaymentRequest(any(), any()) + + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(accepted)) + sut.refresh().getOrThrow() + assertTrue(sut.pendingRequests.value.isEmpty()) + assertTrue(sut.accept(sut.paymentRequestHistory.value.single()).isFailure) + assertTrue(sut.ensurePaymentAllowed(sut.paymentRequestHistory.value.single()).isFailure) + } + + @Test + fun `lost acceptance response is reconciled from shared state`() = test { + for (error in listOf( + PaykitException.Transport("transport_error", "response lost"), + PaykitException.ConcurrentUpdate("concurrent_update", "response read locked"), + PaykitException.SharedStateBusy("shared_state_busy", "response read busy"), + )) { + sut.clear() + whenever(presentationStore.loadAcceptedOneTimeIds(any())).thenReturn(emptySet()) + sut.activate(LOCAL_IDENTITY) + val proposed = paymentRequestRecord() + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(proposed)) + doSuspendableAnswer { throw error }.whenever(paykitSdkService).acceptPaymentRequest(any(), any()) + sut.refresh().getOrThrow() + val request = sut.pendingRequests.value.single() + assertTrue(sut.accept(request).isFailure) + assertTrue(sut.ensurePaymentAllowed(request).isFailure) + verify(presentationStore, never()).removeAcceptedOneTimeIds(any(), any()) + + whenever(paykitSdkService.allPaymentRequests(anyOrNull())) + .thenReturn(listOf(proposed.copy(state = PaymentRequestLifecycleState.ACCEPTED))) + whenever(presentationStore.loadAcceptedOneTimeIds(LOCAL_IDENTITY)).thenReturn(setOf(request.id)) + sut.clear() + sut.activate(LOCAL_IDENTITY) + sut.refresh().getOrThrow() + val retry = sut.pendingRequests.value.single() + sut.accept(retry).getOrThrow() + sut.ensurePaymentAllowed(retry).getOrThrow() + } + } + + @Test + fun `final authorization requires durable ownership regardless of snapshot lifecycle`() = test { + val proposed = paymentRequestRecord() + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(proposed)) + whenever(paykitSdkService.acceptPaymentRequest(any(), any())) + .thenReturn(proposed.copy(state = PaymentRequestLifecycleState.ACCEPTED)) + val saving = CompletableDeferred() + val saved = CompletableDeferred() + whenever(presentationStore.addAcceptedOneTimeId(any(), any())).doSuspendableAnswer { + saving.complete(Unit) + saved.await() + setOf(it.getArgument(1)) + } + sut.refresh().getOrThrow() + val request = sut.pendingRequests.value.single() + val acceptedSnapshot = request.copy(lifecycleState = PaymentRequestLifecycleState.ACCEPTED) + val acceptance = async { sut.accept(request) } + saving.await() + assertFalse(acceptance.isCompleted) + assertTrue(sut.ensurePaymentAllowed(request).isFailure) + assertTrue(sut.ensurePaymentAllowed(acceptedSnapshot).isFailure) + + saved.complete(Unit) + acceptance.await().getOrThrow() + sut.ensurePaymentAllowed(request).getOrThrow() + sut.ensurePaymentAllowed(acceptedSnapshot).getOrThrow() + } + + @Test + fun `second install cannot accept stale proposal or resume first installs acceptance`() = test { + val otherStore = mock() + whenever(otherStore.removeAcceptedOneTimeIds(any(), any())).thenReturn(emptySet()) + whenever(otherStore.loadSubscriptionState(any())).thenReturn(PaykitSubscriptionPresentationState()) + val other = PaykitPaymentRequestRepo( + testDispatcher, + paykitSdkService, + settingsStore, + otherStore, + diagnostics, + paymentProofStore, + paymentProofRepo, + subscriptionNotificationScheduler, + clock, + clock, + ) + other.activate(LOCAL_IDENTITY) + var record = paymentRequestRecord() + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenAnswer { listOf(record) } + whenever(paykitSdkService.acceptPaymentRequest(any(), any())).thenAnswer { + check(record.state == PaymentRequestLifecycleState.PROPOSED) + record = record.copy(state = PaymentRequestLifecycleState.ACCEPTED) + record + } + sut.refresh().getOrThrow() + other.refresh().getOrThrow() + val stale = other.pendingRequests.value.single() + sut.accept(sut.pendingRequests.value.single()).getOrThrow() + + assertTrue(other.accept(stale).isFailure) + other.refresh().getOrThrow() + assertTrue(other.pendingRequests.value.isEmpty()) + assertTrue(other.automaticPendingRequests().isEmpty()) + val remoteAccepted = other.paymentRequestHistory.value.single() + assertNull(other.pendingRequest(remoteAccepted.id)) + assertTrue(other.accept(remoteAccepted).isFailure) + assertTrue(other.claimForPayment(remoteAccepted).isFailure) + assertTrue(other.ensurePaymentAllowed(remoteAccepted).isFailure) + verify(paykitSdkService, never()).claimPaymentRequestForExecution(any(), any()) + verify(otherStore).removeAcceptedOneTimeIds(eq(LOCAL_IDENTITY), eq(setOf(stale.id))) + sut.refresh().getOrThrow() + sut.accept(sut.pendingRequests.value.single()).getOrThrow() + other.clear() + } + + @Test + fun `identity switch during acceptance saves only the captured identity and prevents execution`() = test { + val proposed = paymentRequestRecord() + val accepting = CompletableDeferred() + val accepted = CompletableDeferred() + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(proposed)) + whenever(paykitSdkService.acceptPaymentRequest(any(), any())).doSuspendableAnswer { + accepting.complete(Unit) + accepted.await() + proposed.copy(state = PaymentRequestLifecycleState.ACCEPTED) + } + sut.refresh().getOrThrow() + val request = sut.pendingRequests.value.single() + val acceptance = async { sut.accept(request) } + accepting.await() + val activation = async { sut.activate(SECOND_IDENTITY) } + runCurrent() + accepted.complete(Unit) + + assertTrue(acceptance.await().isFailure) + activation.await() + verify(presentationStore).addAcceptedOneTimeId(LOCAL_IDENTITY, request.id) + verify(presentationStore, never()).addAcceptedOneTimeId(eq(SECOND_IDENTITY), any()) + val snapshot = request.copy(lifecycleState = PaymentRequestLifecycleState.ACCEPTED) + assertTrue(sut.ensurePaymentAllowed(snapshot).isFailure) + } + + @Test + fun `unreadable accepted ownership prevents activation`() = test { + sut.clear() + whenever(presentationStore.loadAcceptedOneTimeIds(LOCAL_IDENTITY)) + .thenThrow(IllegalStateException("unreadable")) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(paymentRequestRecord())) + sut.activate(LOCAL_IDENTITY) + sut.refresh().getOrThrow() + assertTrue(sut.pendingRequests.value.isEmpty()) } @Test @@ -509,11 +1432,10 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { val record = paymentRequestRecord() val deliveryStarted = CompletableDeferred() val finishDelivery = CompletableDeferred() - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) whenever( paykitSdkService.rejectPaymentRequest( COUNTERPARTY, - PaykitReceiverPaths.SERVER, PAYMENT_REQUEST_ID, ), ).thenReturn(record) @@ -540,11 +1462,25 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `surfaced request stays pending and is excluded from automatic presentation`() = test { - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(paymentRequestRecord())) sut.refresh().getOrThrow() val request = sut.pendingRequests.value.single() - assertTrue(sut.markPresented(request)) + val finishRefresh = CompletableDeferred() + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).doSuspendableAnswer { + finishRefresh.await() + listOf(paymentRequestRecord()) + } + val refresh = async { sut.refresh() } + runCurrent() + val marking = async { sut.markPresented(request) } + runCurrent() + assertTrue(marking.isCompleted) + assertTrue(marking.await()) + assertTrue(sut.automaticPendingRequests().isEmpty()) + finishRefresh.complete(Unit) + refresh.await().getOrThrow() + sut.refresh(PaykitPaymentRequestRefreshMode.STORED).getOrThrow() assertEquals(listOf(request), sut.pendingRequests.value) assertTrue(sut.automaticPendingRequests().isEmpty()) @@ -553,7 +1489,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `switching identity clears request state and restores only that identity suppression`() = test { - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(paymentRequestRecord())) sut.refresh().getOrThrow() val request = sut.pendingRequests.value.single() sut.markPresented(request) @@ -567,7 +1503,10 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `identity switch invalidates an in-flight refresh before waiting for the operation lock`() = test { + fun `identity switch invalidates an in-flight refresh without waiting for network`() = test { + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(paymentRequestRecord())) + sut.refresh().getOrThrow() + val request = sut.pendingRequests.value.single() val refreshStarted = CompletableDeferred() val resumeRefresh = CompletableDeferred() whenever(paykitSdkService.processPendingPrivateMessages()).doSuspendableAnswer { @@ -575,18 +1514,19 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { resumeRefresh.await() emptyList() } - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) whenever(presentationStore.load(SECOND_IDENTITY)).thenReturn(emptySet()) val refresh = async { sut.refresh() } runCurrent() refreshStarted.await() - val activation = async { sut.activate(SECOND_IDENTITY) } - runCurrent() + sut.clear() + sut.activate(SECOND_IDENTITY) + assertFalse(refresh.isCompleted) + assertFalse(sut.markPresented(request)) resumeRefresh.complete(Unit) refresh.await().getOrThrow() - activation.await() + verify(presentationStore, never()).save(any(), any()) assertTrue(sut.pendingRequests.value.isEmpty()) assertTrue(sut.paymentRequestHistory.value.isEmpty()) @@ -595,18 +1535,19 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `proposal uses exact linked capable path and canonical bitcoin terms`() = test { - val target = PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER) - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + val target = PaykitPaymentRequestTarget(COUNTERPARTY) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(eq(COUNTERPARTY), any())).thenReturn( - listOf(PaykitReceiverPaths.SERVER), + whenever(paykitSdkService.canReceivePaymentRequests(eq(COUNTERPARTY), any())).thenReturn( + true, ) whenever( paykitSdkService.proposePaymentRequest( eq(COUNTERPARTY), - eq(PaykitReceiverPaths.SERVER), any(), eq(LOCAL_IDENTITY), ), @@ -614,7 +1555,6 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentRequestRecord( role = PaymentRequestLocalRole.PAYEE, counterparty = COUNTERPARTY, - receiverPath = PaykitReceiverPaths.SERVER, ), ) val expiry = clock.now().plus(60.seconds) @@ -630,7 +1570,6 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { verifyBlocking(paykitSdkService) { proposePaymentRequest( eq(COUNTERPARTY), - eq(PaykitReceiverPaths.SERVER), proposal.capture(), eq(LOCAL_IDENTITY), ) @@ -644,32 +1583,158 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals(PaykitPaymentRequestDeliveryStatus.Queued, request.deliveryStatus) assertEquals(LOCAL_IDENTITY, creation.creatorIdentity) assertTrue(creation.wasPublishedToActiveState) + verify(paykitSdkService).identityStatus(Priority.Interactive) + verify(paykitSdkService).linkedPeers(Priority.Interactive) + verify(paykitSdkService).processOutboundPrivateMessages(COUNTERPARTY, Priority.Interactive) + } + + @Test + fun `proposal uses fresh delivery status only for the exact committed proposal`() = test { + val record = paymentRequestRecord(role = PaymentRequestLocalRole.PAYEE).copy(proposalOutboundMessageId = 7uL) + val sent = record.copy(proposalOutboundStatus = OutboundPrivateMessageStatus.SENT) + val target = stubProposal(record) + val cases = listOf( + sent to PaykitPaymentRequestDeliveryStatus.Sent, + record to PaykitPaymentRequestDeliveryStatus.Queued, + null to PaykitPaymentRequestDeliveryStatus.Queued, + sent.copy(counterparty = SECOND_IDENTITY) to PaykitPaymentRequestDeliveryStatus.Queued, + sent.copy(paymentRequestId = "another-request") to PaykitPaymentRequestDeliveryStatus.Queued, + sent.copy(proposalOutboundMessageId = 8uL) to PaykitPaymentRequestDeliveryStatus.Queued, + sent.copy(localRole = PaymentRequestLocalRole.PAYER) to PaykitPaymentRequestDeliveryStatus.Queued, + ) + for ((freshRecord, expectedStatus) in cases) { + whenever(paykitSdkService.allPaymentRequests(LOCAL_IDENTITY)).thenReturn(listOfNotNull(freshRecord)) + + val creation = sut.propose( + PaykitPaymentRequestDraft(1uL, "Lunch", clock.now().plus(60.seconds)), + target, + listOf(COUNTERPARTY), + ).getOrThrow() + + assertEquals(expectedStatus, creation.request.deliveryStatus) + assertTrue(creation.wasPublishedToActiveState) + assertEquals(LOCAL_IDENTITY, creation.creatorIdentity) + } + verify(paykitSdkService, times(cases.size)).allPaymentRequests(LOCAL_IDENTITY, Priority.Interactive) + verify(paykitSdkService, times(cases.size)).proposePaymentRequest(any(), any(), eq(LOCAL_IDENTITY)) + } + + @Test + fun `proposal skips status lookup only after its durable send failure`() = test { + val record = paymentRequestRecord(role = PaymentRequestLocalRole.PAYEE).copy(proposalOutboundMessageId = 7uL) + val target = stubProposal(record) + val error = mock { + on { redactedContext() } doReturn "send failed" + } + whenever(paykitSdkService.allPaymentRequests(LOCAL_IDENTITY)).thenReturn( + listOf(record.copy(proposalOutboundStatus = OutboundPrivateMessageStatus.SENT)), + ) + for (failedMessageId in listOf(7uL, 8uL, null)) { + whenever(paykitSdkService.processOutboundPrivateMessages(COUNTERPARTY)).doSuspendableAnswer { + if (failedMessageId == null) throw PaykitException.Storage("write_uncertain", "Send may be committed") + OutboundPrivateSendReport( + attempted = listOf(failedMessageId), + sent = emptyList(), + failed = listOf(OutboundPrivateSendFailure(failedMessageId, error)), + reservationCleanupFailures = emptyList(), + recoveryMarkerFailures = emptyList(), + ) + } + clearInvocations(paykitSdkService) + + val creation = sut.propose( + PaykitPaymentRequestDraft(1uL, "Lunch", clock.now().plus(60.seconds)), + target, + listOf(COUNTERPARTY), + ).getOrThrow() + + val expected = if (failedMessageId == 7uL) { + PaykitPaymentRequestDeliveryStatus.Queued + } else { + PaykitPaymentRequestDeliveryStatus.Sent + } + assertEquals(expected, creation.request.deliveryStatus) + verify(paykitSdkService, times(if (failedMessageId == 7uL) 0 else 1)).allPaymentRequests(LOCAL_IDENTITY) + } + } + + @Test + fun `proposal remains created after status read failure and propagates status read cancellation`() = test { + val record = paymentRequestRecord(role = PaymentRequestLocalRole.PAYEE).copy(proposalOutboundMessageId = 7uL) + val target = stubProposal(record) + val draft = PaykitPaymentRequestDraft(1uL, "Lunch", clock.now().plus(60.seconds)) + var cancelled = false + whenever(paykitSdkService.allPaymentRequests(LOCAL_IDENTITY)).doSuspendableAnswer { + if (cancelled) throw CancellationException() + error("Unavailable") + } + + val creation = sut.propose(draft, target, listOf(COUNTERPARTY)).getOrThrow() + + assertEquals(PaykitPaymentRequestDeliveryStatus.Queued, creation.request.deliveryStatus) + assertEquals(listOf(creation.request), sut.paymentRequestHistory.value) + cancelled = true + assertFailsWith { sut.propose(draft, target, listOf(COUNTERPARTY)) } + verify(paykitSdkService, times(2)).proposePaymentRequest(any(), any(), eq(LOCAL_IDENTITY)) + assertFalse(sut.isCreatingRequest.value) } @Test - fun `proposal revalidates only the selected saved contact`() = test { - val target = PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER) + fun `proposal already marked sent does not read delivery status again`() = test { + val target = stubProposal( + paymentRequestRecord(role = PaymentRequestLocalRole.PAYEE).copy( + proposalOutboundMessageId = 7uL, + proposalOutboundStatus = OutboundPrivateMessageStatus.SENT, + ), + ) + + val creation = sut.propose( + PaykitPaymentRequestDraft(1uL, "Lunch", clock.now().plus(60.seconds)), + target, + listOf(COUNTERPARTY), + ).getOrThrow() + + assertEquals(PaykitPaymentRequestDeliveryStatus.Sent, creation.request.deliveryStatus) + verify(paykitSdkService, never()).allPaymentRequests(anyOrNull()) + } + + @Test + fun `proposal revalidates and drains only the selected saved contact`() = test { + val target = PaykitPaymentRequestTarget(COUNTERPARTY) val stalledDiscovery = CompletableDeferred() - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + val unrelatedDelivery = CompletableDeferred() + whenever(paykitSdkService.processPendingPrivateMessages()).doSuspendableAnswer { + unrelatedDelivery.await() + emptyList() + } + whenever(paykitSdkService.processOutboundPrivateMessages(SECOND_IDENTITY)).doSuspendableAnswer { + unrelatedDelivery.await() + OutboundPrivateSendReport(emptyList(), emptyList(), emptyList(), emptyList(), emptyList()) + } + whenever(paykitSdkService.processOutboundPrivateMessages(COUNTERPARTY)).thenReturn( + OutboundPrivateSendReport(listOf(7uL), listOf(7uL), emptyList(), emptyList(), emptyList()), + ) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( listOf( - linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER), - linkedPeer(SECOND_IDENTITY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER), + linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED), + linkedPeer(SECOND_IDENTITY, LinkedPeerState.LINKED), ), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(eq(COUNTERPARTY), any())).thenReturn( - listOf(PaykitReceiverPaths.SERVER), + whenever(paykitSdkService.canReceivePaymentRequests(eq(COUNTERPARTY), any())).thenReturn( + true, ) - whenever(paykitSdkService.paymentRequestReceiverPaths(eq(SECOND_IDENTITY), any())).doSuspendableAnswer { + whenever(paykitSdkService.canReceivePaymentRequests(eq(SECOND_IDENTITY), any())).doSuspendableAnswer { stalledDiscovery.await() - listOf(PaykitReceiverPaths.SERVER) + true } - whenever(paykitSdkService.proposePaymentRequest(any(), any(), any(), eq(LOCAL_IDENTITY))).thenReturn( + whenever(paykitSdkService.proposePaymentRequest(any(), any(), eq(LOCAL_IDENTITY))).thenReturn( paymentRequestRecord( role = PaymentRequestLocalRole.PAYEE, counterparty = COUNTERPARTY, - receiverPath = PaykitReceiverPaths.SERVER, - ), + ).copy(proposalOutboundMessageId = 7uL), ) val proposal = async { @@ -681,27 +1746,37 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { } runCurrent() - assertTrue(proposal.isCompleted) - proposal.await().getOrThrow() - verifyBlocking(paykitSdkService, never()) { paymentRequestReceiverPaths(eq(SECOND_IDENTITY), any()) } + val completedBeforeUnrelatedDelivery = proposal.isCompleted + unrelatedDelivery.complete(Unit) + stalledDiscovery.complete(Unit) + val creation = proposal.await().getOrThrow() + assertTrue(completedBeforeUnrelatedDelivery) + assertEquals(PaykitPaymentRequestDeliveryStatus.Sent, creation.request.deliveryStatus) + verify(paykitSdkService, never()).allPaymentRequests(anyOrNull()) + verifyBlocking(paykitSdkService, never()) { canReceivePaymentRequests(eq(SECOND_IDENTITY), any()) } + verify(paykitSdkService).processOutboundPrivateMessages(COUNTERPARTY) + verify(paykitSdkService, never()).processOutboundPrivateMessages(SECOND_IDENTITY) + verify(paykitSdkService, never()).processPendingPrivateMessages() } @Test fun `identity switch keeps a committed proposal out of the replacement identity state`() = test { - val target = PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER) + val target = PaykitPaymentRequestTarget(COUNTERPARTY) val proposalStarted = CompletableDeferred() val finishProposal = CompletableDeferred() - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(eq(COUNTERPARTY), any())).thenReturn( - listOf(PaykitReceiverPaths.SERVER), + whenever(paykitSdkService.canReceivePaymentRequests(eq(COUNTERPARTY), any())).thenReturn( + true, ) - whenever(paykitSdkService.proposePaymentRequest(any(), any(), any(), eq(LOCAL_IDENTITY))).doSuspendableAnswer { + whenever(paykitSdkService.proposePaymentRequest(any(), any(), eq(LOCAL_IDENTITY))).doSuspendableAnswer { proposalStarted.complete(Unit) finishProposal.await() - paymentRequestRecord(role = PaymentRequestLocalRole.PAYEE) + paymentRequestRecord(role = PaymentRequestLocalRole.PAYEE).copy(proposalOutboundMessageId = 7uL) } whenever(presentationStore.load(SECOND_IDENTITY)).thenReturn(emptySet()) @@ -723,85 +1798,184 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals(LOCAL_IDENTITY, creation.creatorIdentity) assertFalse(creation.wasPublishedToActiveState) assertTrue(sut.paymentRequestHistory.value.isEmpty()) + verify(paykitSdkService, never()).allPaymentRequests(anyOrNull()) } @Test fun `incoming refresh does not wait for recipient discovery`() = test { - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(paymentRequestRecord())) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) sut.refresh().getOrThrow() assertEquals(1, sut.pendingRequests.value.size) - verifyBlocking(paykitSdkService, never()) { paymentRequestReceiverPaths(any(), any()) } + verifyBlocking(paykitSdkService, never()) { canReceivePaymentRequests(any(), any()) } } @Test fun `recipient discovery reuses unchanged link state`() = test { - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(eq(COUNTERPARTY), any())).thenReturn( - listOf(PaykitReceiverPaths.SERVER), + whenever(paykitSdkService.canReceivePaymentRequests(eq(COUNTERPARTY), any())).thenReturn( + true, ) sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() assertEquals( - listOf(PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER)), + listOf(PaykitPaymentRequestTarget(COUNTERPARTY)), sut.eligibleTargets.value, ) - verifyBlocking(paykitSdkService, times(1)) { paymentRequestReceiverPaths(eq(COUNTERPARTY), any()) } + verifyBlocking(paykitSdkService, times(1)) { canReceivePaymentRequests(eq(COUNTERPARTY), any()) } + } + + @Test + fun `recipient discovery bounds public lookups without a slow peer blocking later peers`() = test { + val keys = "yb".flatMap { first -> + "ybndrfg8ejkmcpqxot1uwisza345h769".map { second -> + COUNTERPARTY.replace("pubky3r", "pubky$first$second") + } + }.take(61) + whenever(paykitSdkService.identityStatus()).thenReturn( + IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE), + ) + whenever(paykitSdkService.linkedPeers()).thenReturn(keys.map { linkedPeer(it, LinkedPeerState.LINKED) }) + whenever(paykitSdkService.canReceivePaymentRequests(any(), any())).thenReturn(true) + sut.refreshEligibleTargets(keys).getOrThrow() + val releaseSlowPeer = CompletableDeferred() + val completedPeers = mutableSetOf() + var active = 0 + var maxActive = 0 + whenever(paykitSdkService.canReceivePaymentRequests(any(), any())).doSuspendableAnswer { + val publicKey = it.getArgument(0) + active++ + maxActive = maxOf(maxActive, active) + try { + if (publicKey == keys.first()) { + releaseSlowPeer.await() + null + } else { + delay(500.milliseconds) + completedPeers += publicKey + if (publicKey == keys[1]) throw PaykitException.Transport("transport", "Registry unavailable") + true + } + } finally { + active-- + } + } + val startedAt = testScheduler.currentTime + val discovery = async { sut.refreshEligibleTargets(keys, force = true).getOrThrow() } + try { + runCurrent() + assertEquals(8, active) + advanceTimeBy(4500) + runCurrent() + assertEquals(keys.drop(1).toSet(), completedPeers) + assertFalse(discovery.isCompleted) + assertEquals(1, active) + } finally { + releaseSlowPeer.complete(Unit) + } + discovery.await() + + assertEquals(4500L, testScheduler.currentTime - startedAt) + assertEquals(8, maxActive) + assertEquals(0, active) + assertEquals(keys.map(::PaykitPaymentRequestTarget), sut.eligibleTargets.value) + } + + @Test + fun `cancelling recipient discovery cancels every active public lookup`() = test { + val keys = "ybndrfg8e".map { COUNTERPARTY.replace("pubky3", "pubky$it") } + whenever(paykitSdkService.identityStatus()).thenReturn( + IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE), + ) + whenever(paykitSdkService.linkedPeers()).thenReturn(keys.map { linkedPeer(it, LinkedPeerState.LINKED) }) + var active = 0 + whenever(paykitSdkService.canReceivePaymentRequests(any(), any())).doSuspendableAnswer { + active++ + try { + awaitCancellation() + } finally { + active-- + } + } + val discovery = async { sut.refreshEligibleTargets(keys) } + runCurrent() + assertEquals(8, active) + + discovery.cancel() + discovery.join() + + assertEquals(0, active) + verify(paykitSdkService, times(8)).canReceivePaymentRequests(any(), any()) + assertTrue(sut.eligibleTargets.value.isEmpty()) } @Test fun `single recipient refresh adds a newly eligible contact`() = test { - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(eq(COUNTERPARTY), any())).thenReturn( - listOf(PaykitReceiverPaths.SERVER), + whenever(paykitSdkService.canReceivePaymentRequests(eq(COUNTERPARTY), any())).thenReturn( + true, ) val target = sut.refreshEligibleTarget(COUNTERPARTY).getOrThrow().target - val expected = PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER) + val expected = PaykitPaymentRequestTarget(COUNTERPARTY) assertEquals(expected, target) assertEquals(listOf(expected), sut.eligibleTargets.value) } @Test fun `single recipient refresh reads on the interactive lane and a full refresh in bulk`() = test { - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(eq(COUNTERPARTY), any())).thenReturn( - listOf(PaykitReceiverPaths.SERVER), + whenever(paykitSdkService.canReceivePaymentRequests(eq(COUNTERPARTY), any())).thenReturn( + true, ) sut.refreshEligibleTarget(COUNTERPARTY).getOrThrow() - verifyBlocking(paykitSdkService) { paymentRequestReceiverPaths(COUNTERPARTY, PaykitReadLane.Interactive) } + verifyBlocking(paykitSdkService) { canReceivePaymentRequests(COUNTERPARTY, PaykitReadLane.Interactive) } sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() - verifyBlocking(paykitSdkService) { paymentRequestReceiverPaths(COUNTERPARTY, PaykitReadLane.Bulk) } + verifyBlocking(paykitSdkService) { canReceivePaymentRequests(COUNTERPARTY, PaykitReadLane.Bulk) } + verify(paykitSdkService).identityStatus(Priority.Interactive) + verify(paykitSdkService).linkedPeers(Priority.Interactive) + verify(paykitSdkService).identityStatus(Priority.Background) + verify(paykitSdkService).linkedPeers(Priority.Background) } @Test fun `single recipient refresh removes a contact that is no longer linked`() = test { - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), emptyList(), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(eq(COUNTERPARTY), any())).thenReturn( - listOf(PaykitReceiverPaths.SERVER), + whenever(paykitSdkService.canReceivePaymentRequests(eq(COUNTERPARTY), any())).thenReturn( + true, ) sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() @@ -813,12 +1987,14 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `single recipient refresh removes a contact that stopped accepting requests`() = test { - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(eq(COUNTERPARTY), any())) - .thenReturn(listOf(PaykitReceiverPaths.SERVER), emptyList()) + whenever(paykitSdkService.canReceivePaymentRequests(eq(COUNTERPARTY), any())) + .thenReturn(true, false) sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() val target = sut.refreshEligibleTarget(COUNTERPARTY).getOrThrow().target @@ -829,18 +2005,20 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `single recipient refresh keeps a known target while capability lookup fails`() = test { - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(eq(COUNTERPARTY), any())) - .thenReturn(listOf(PaykitReceiverPaths.SERVER)) + whenever(paykitSdkService.canReceivePaymentRequests(eq(COUNTERPARTY), any())) + .thenReturn(true) .thenThrow(IllegalStateException("marker unavailable")) sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() val check = sut.refreshEligibleTarget(COUNTERPARTY).getOrThrow() - val expected = PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER) + val expected = PaykitPaymentRequestTarget(COUNTERPARTY) assertEquals(expected, check.target) assertFalse(check.isComplete) assertEquals(listOf(expected), sut.eligibleTargets.value) @@ -851,18 +2029,20 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { val fullLookupStarted = CompletableDeferred() val releaseFullLookup = CompletableDeferred() var lookups = 0 - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(eq(COUNTERPARTY), any())).doSuspendableAnswer { + whenever(paykitSdkService.canReceivePaymentRequests(eq(COUNTERPARTY), any())).doSuspendableAnswer { lookups += 1 when (lookups) { - 1 -> listOf(PaykitReceiverPaths.SERVER) + 1 -> true 2 -> { fullLookupStarted.complete(Unit) releaseFullLookup.await() - emptyList() + false } else -> error("marker unavailable") } @@ -884,20 +2064,22 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { val fullLinkLookupStarted = CompletableDeferred() val releaseFullLinkLookup = CompletableDeferred() var linkLookups = 0 - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).doSuspendableAnswer { linkLookups += 1 if (linkLookups > 1) { return@doSuspendableAnswer listOf( - linkedPeer(SECOND_IDENTITY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER), + linkedPeer(SECOND_IDENTITY, LinkedPeerState.LINKED), ) } fullLinkLookupStarted.complete(Unit) releaseFullLinkLookup.await() error("linked peers unavailable") } - whenever(paykitSdkService.paymentRequestReceiverPaths(eq(SECOND_IDENTITY), any())) - .thenReturn(listOf(PaykitReceiverPaths.SERVER)) + whenever(paykitSdkService.canReceivePaymentRequests(eq(SECOND_IDENTITY), any())) + .thenReturn(true) val fullRefresh = async { sut.refreshEligibleTargets(listOf(COUNTERPARTY)) } fullLinkLookupStarted.await() @@ -906,7 +2088,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(fullRefresh.await().isFailure) assertEquals( - listOf(PaykitPaymentRequestTarget(SECOND_IDENTITY, PaykitReceiverPaths.SERVER)), + listOf(PaykitPaymentRequestTarget(SECOND_IDENTITY)), sut.eligibleTargets.value, ) } @@ -916,16 +2098,18 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { val fullLookupStarted = CompletableDeferred() val releaseFullLookup = CompletableDeferred() var lookups = 0 - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(eq(COUNTERPARTY), any())).doSuspendableAnswer { + whenever(paykitSdkService.canReceivePaymentRequests(eq(COUNTERPARTY), any())).doSuspendableAnswer { lookups += 1 - if (lookups > 1) return@doSuspendableAnswer emptyList() + if (lookups > 1) return@doSuspendableAnswer false fullLookupStarted.complete(Unit) releaseFullLookup.await() - listOf(PaykitReceiverPaths.SERVER) + true } val fullRefresh = async { sut.refreshEligibleTargets(listOf(COUNTERPARTY)) } @@ -943,16 +2127,18 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { val singleLookupStarted = CompletableDeferred() val releaseSingleLookup = CompletableDeferred() var lookups = 0 - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(eq(COUNTERPARTY), any())).doSuspendableAnswer { + whenever(paykitSdkService.canReceivePaymentRequests(eq(COUNTERPARTY), any())).doSuspendableAnswer { lookups += 1 - if (lookups > 1) return@doSuspendableAnswer listOf(PaykitReceiverPaths.SERVER) + if (lookups > 1) return@doSuspendableAnswer true singleLookupStarted.complete(Unit) releaseSingleLookup.await() - emptyList() + false } val singleRefresh = async { sut.refreshEligibleTarget(COUNTERPARTY) } @@ -961,7 +2147,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { releaseSingleLookup.complete(Unit) val target = singleRefresh.await().getOrThrow().target - val expected = PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER) + val expected = PaykitPaymentRequestTarget(COUNTERPARTY) assertEquals(expected, target) assertEquals(listOf(expected), sut.eligibleTargets.value) } @@ -971,22 +2157,24 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { val fullLookupStarted = CompletableDeferred() val releaseFullLookup = CompletableDeferred() var counterpartyLookups = 0 - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( listOf( - linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER), - linkedPeer(SECOND_IDENTITY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER), + linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED), + linkedPeer(SECOND_IDENTITY, LinkedPeerState.LINKED), ), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(eq(COUNTERPARTY), any())).doSuspendableAnswer { + whenever(paykitSdkService.canReceivePaymentRequests(eq(COUNTERPARTY), any())).doSuspendableAnswer { counterpartyLookups += 1 - if (counterpartyLookups > 1) return@doSuspendableAnswer emptyList() + if (counterpartyLookups > 1) return@doSuspendableAnswer false fullLookupStarted.complete(Unit) releaseFullLookup.await() - listOf(PaykitReceiverPaths.SERVER) + true } - whenever(paykitSdkService.paymentRequestReceiverPaths(eq(SECOND_IDENTITY), any())) - .thenReturn(listOf(PaykitReceiverPaths.SERVER)) + whenever(paykitSdkService.canReceivePaymentRequests(eq(SECOND_IDENTITY), any())) + .thenReturn(true) val fullRefresh = async { sut.refreshEligibleTargets(listOf(COUNTERPARTY, SECOND_IDENTITY)) } fullLookupStarted.await() @@ -995,19 +2183,21 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { fullRefresh.await().getOrThrow() assertEquals( - listOf(PaykitPaymentRequestTarget(SECOND_IDENTITY, PaykitReceiverPaths.SERVER)), + listOf(PaykitPaymentRequestTarget(SECOND_IDENTITY)), sut.eligibleTargets.value, ) } @Test fun `failed recipient discovery drops contacts that are no longer saved`() = test { - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()) - .thenReturn(listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER))) + .thenReturn(listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED))) .thenThrow(IllegalStateException("linked peers unavailable")) - whenever(paykitSdkService.paymentRequestReceiverPaths(eq(COUNTERPARTY), any())).thenReturn( - listOf(PaykitReceiverPaths.SERVER), + whenever(paykitSdkService.canReceivePaymentRequests(eq(COUNTERPARTY), any())).thenReturn( + true, ) sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() @@ -1019,103 +2209,105 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `recipient discovery retries capabilities that are not published yet`() = test { - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(eq(COUNTERPARTY), any())) - .thenReturn(emptyList(), listOf(PaykitReceiverPaths.SERVER)) + whenever(paykitSdkService.canReceivePaymentRequests(eq(COUNTERPARTY), any())) + .thenReturn(false, true) sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() assertEquals( - listOf(PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER)), + listOf(PaykitPaymentRequestTarget(COUNTERPARTY)), sut.eligibleTargets.value, ) - verifyBlocking(paykitSdkService, times(2)) { paymentRequestReceiverPaths(eq(COUNTERPARTY), any()) } + verifyBlocking(paykitSdkService, times(2)) { canReceivePaymentRequests(eq(COUNTERPARTY), any()) } } @Test fun `recipient discovery retains a known target while capability refresh fails`() = test { - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(eq(COUNTERPARTY), any())) - .thenReturn(listOf(PaykitReceiverPaths.SERVER)) + whenever(paykitSdkService.canReceivePaymentRequests(eq(COUNTERPARTY), any())) + .thenReturn(true) .thenThrow(IllegalStateException("marker unavailable")) sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() sut.refreshEligibleTargets(listOf(COUNTERPARTY), force = true).getOrThrow() assertEquals( - listOf(PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER)), + listOf(PaykitPaymentRequestTarget(COUNTERPARTY)), sut.eligibleTargets.value, ) - verifyBlocking(paykitSdkService, times(2)) { paymentRequestReceiverPaths(eq(COUNTERPARTY), any()) } + verifyBlocking(paykitSdkService, times(2)) { canReceivePaymentRequests(eq(COUNTERPARTY), any()) } } @Test - fun `recipient discovery bounds a stalled capability lookup`() = test { - val discoveryStarted = CompletableDeferred() - val stalledDiscovery = CompletableDeferred() - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + fun `recipient discovery preserves a known target after a lookup timeout`() = test { + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(eq(COUNTERPARTY), any())).doSuspendableAnswer { - discoveryStarted.complete(Unit) - stalledDiscovery.await() - listOf(PaykitReceiverPaths.SERVER) - } - - val targetRefresh = async { sut.refreshEligibleTargets(listOf(COUNTERPARTY)) } - discoveryStarted.await() - advanceTimeBy(5.seconds.inWholeMilliseconds) - runCurrent() + whenever(paykitSdkService.canReceivePaymentRequests(eq(COUNTERPARTY), any())) + .thenReturn(true) + .thenReturn(null) + sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() - assertTrue(targetRefresh.isCompleted) - targetRefresh.await().getOrThrow() - assertTrue(sut.eligibleTargets.value.isEmpty()) + val result = sut.refreshEligibleTarget(COUNTERPARTY).getOrThrow() + assertFalse(result.isComplete) + assertEquals(PaykitPaymentRequestTarget(COUNTERPARTY), result.target) } @Test fun `outgoing requests require private payment publication`() = test { whenever(settingsStore.data).thenReturn(flowOf(SettingsData(sharesPrivatePaykitEndpoints = false))) - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(eq(COUNTERPARTY), any())).thenReturn( - listOf(PaykitReceiverPaths.SERVER), + whenever(paykitSdkService.canReceivePaymentRequests(eq(COUNTERPARTY), any())).thenReturn( + true, ) sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() assertTrue(sut.eligibleTargets.value.isEmpty()) - verifyBlocking(paykitSdkService, never()) { proposePaymentRequest(any(), any(), any(), any()) } + verifyBlocking(paykitSdkService, never()) { proposePaymentRequest(any(), any(), any()) } } @Test fun `outgoing requests require the active SDK identity`() = test { - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(SECOND_IDENTITY, true)) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(SECOND_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(eq(COUNTERPARTY), any())).thenReturn( - listOf(PaykitReceiverPaths.SERVER), + whenever(paykitSdkService.canReceivePaymentRequests(eq(COUNTERPARTY), any())).thenReturn( + true, ) sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() assertTrue(sut.eligibleTargets.value.isEmpty()) - verifyBlocking(paykitSdkService, never()) { proposePaymentRequest(any(), any(), any(), any()) } + verifyBlocking(paykitSdkService, never()) { proposePaymentRequest(any(), any(), any()) } } @Test fun `expired draft is rejected before proposal is queued`() = test { - val target = PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER) + val target = PaykitPaymentRequestTarget(COUNTERPARTY) assertFailsWith { sut.propose( @@ -1124,13 +2316,13 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { savedPublicKeys = listOf(COUNTERPARTY), ).getOrThrow() } - verifyBlocking(paykitSdkService, never()) { proposePaymentRequest(any(), any(), any(), any()) } + verifyBlocking(paykitSdkService, never()) { proposePaymentRequest(any(), any(), any()) } } @Test fun `expired request cannot be accepted`() = test { val record = paymentRequestRecord(expiresAt = clock.now().plus(1.seconds).toString()) - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) sut.refresh().getOrThrow() val request = sut.pendingRequests.value.single() advanceTimeBy(1_000) @@ -1139,14 +2331,14 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.accept(request).getOrThrow() } verifyBlocking(paykitSdkService, never()) { - acceptPaymentRequest(COUNTERPARTY, PaykitReceiverPaths.SERVER, PAYMENT_REQUEST_ID) + acceptPaymentRequest(COUNTERPARTY, PAYMENT_REQUEST_ID) } } @Test fun `expired request is no longer pending before the expiration job runs`() = test { val record = paymentRequestRecord(expiresAt = clock.now().plus(1.seconds).toString()) - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) sut.refresh().getOrThrow() val request = sut.pendingRequests.value.single() advanceTimeBy(1_000) @@ -1154,6 +2346,24 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(!sut.isPending(request)) } + private suspend fun stubProposal(record: PaymentRequestRecord): PaykitPaymentRequestTarget { + whenever(paykitSdkService.identityStatus()).thenReturn( + IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE), + ) + whenever(paykitSdkService.linkedPeers()).thenReturn(listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED))) + whenever(paykitSdkService.canReceivePaymentRequests(eq(COUNTERPARTY), any())).thenReturn(true) + whenever(paykitSdkService.proposePaymentRequest(any(), any(), eq(LOCAL_IDENTITY))).thenReturn(record) + return PaykitPaymentRequestTarget(COUNTERPARTY) + } + + private suspend fun restoreAcceptedRequest() { + whenever(presentationStore.loadAcceptedOneTimeIds(LOCAL_IDENTITY)).thenReturn( + setOf(PaykitPaymentRequestId(PAYMENT_REQUEST_ID, COUNTERPARTY)), + ) + sut.clear() + sut.activate(LOCAL_IDENTITY) + } + @Suppress("LongParameterList") private fun paymentRequestRecord( id: String = PAYMENT_REQUEST_ID, @@ -1165,13 +2375,11 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentDeadline: PaymentDeadline? = null, endpoints: List = listOf(MethodId.Bolt11.rawValue), counterparty: String = COUNTERPARTY, - receiverPath: String = PaykitReceiverPaths.SERVER, recurrence: PaymentRequestRecurrence? = null, metadata: PrivateJsonObject = METADATA, paymentProofs: List = emptyList(), ) = PaymentRequestRecord( counterparty = counterparty, - counterpartyReceiverPath = receiverPath, paymentRequestId = id, localRole = role, state = state, @@ -1188,6 +2396,8 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { conversion = null, paymentDeadline = paymentDeadline, metadata = metadata, + paymentEndpoints = null, + requiredAppId = "bitkit", ), acceptedEventId = null, acceptedOutboundStatus = null, @@ -1202,15 +2412,16 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { lastOutboundStatus = null, lastEventAt = clock.now().toString(), invalidReason = null, + proposalAppId = "bitkit", + payerAppId = null, + executionClaimAppId = null, ) private fun linkedPeer( publicKey: String, state: LinkedPeerState, - receiverPath: String, ) = LinkedPeerRecord( counterparty = publicKey, - counterpartyReceiverPath = receiverPath, state = state, lastSyncAt = null, lastPrivateReceiveAt = null, diff --git a/app/src/test/java/to/bitkit/repositories/PaykitReceivedPaymentContactsTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitReceivedPaymentContactsTest.kt new file mode 100644 index 0000000000..58f2bd953b --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/PaykitReceivedPaymentContactsTest.kt @@ -0,0 +1,276 @@ +package to.bitkit.repositories + +import com.synonym.bitkitcore.AddressType +import com.synonym.bitkitcore.NetworkType +import com.synonym.bitkitcore.ValidationResult +import com.synonym.bitkitcore.validateBitcoinAddress +import com.synonym.paykit.PaymentProofRecord +import com.synonym.paykit.PaymentRequestAmount +import com.synonym.paykit.PaymentRequestLifecycleState +import com.synonym.paykit.PaymentRequestLocalRole +import com.synonym.paykit.PaymentRequestRecord +import com.synonym.paykit.PaymentRequestTerms +import com.synonym.paykit.PrivateJsonObject +import org.junit.Test +import org.lightningdevkit.ldknode.Bolt11Invoice +import org.lightningdevkit.ldknode.Currency +import org.lightningdevkit.ldknode.Network +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class PaykitReceivedPaymentContactsTest { + companion object { + /** Synthetic payer identity shared by request fixtures. */ + const val BUYER = "pubky7don8zi885feihpjsyx7t53srod6z1n4xjiyaaxucpqarm6sh85o" + + /** Second payer identity for ambiguous attribution checks. */ + const val OTHER_BUYER = "pubkya3mduedw686dysw8ndr5c1dyry5h8k6i8hzbbmx9gf9k43zq4s9o" + + /** Valid regtest receiving address used by request fixtures. */ + const val ADDRESS = "bcrt1qfn50lqawrce0evh66qrnlt8j447lwmeyqp5gmd" + + /** Distinct regtest address for unrelated-output checks. */ + const val OTHER_ADDRESS = "bcrt1qpsps9chsjnnd3veems9phzlvw42em682rsj8hh" + + /** Mainnet address sentinel accepted by the mocked decoder. */ + const val MAINNET_ADDRESS = "bc1qexample" + + /** Testnet address sentinel accepted by the mocked decoder. */ + const val TESTNET_ADDRESS = "tb1qexample" + + /** Network-ambiguous prefixes reported as testnet by the mocked decoder. */ + val LEGACY_ADDRESSES = listOf("mLegacy", "nlegacy", "2legacy") + + /** Regtest on-chain endpoint identifier for address fixtures. */ + const val METHOD = "btc-regtest-p2wpkh" + + /** Endpoint identifier for the invoice fixtures. */ + const val BOLT11_METHOD = "btc-lightning-bolt11" + + /** Regtest invoice sentinel accepted by the test invoice parser. */ + const val INVOICE = "lnbcrt1example" + + /** Mainnet invoice sentinel for wrong-network checks. */ + const val MAINNET_INVOICE = "lnbc1example" + + /** Payment hash returned by the test invoice parser. */ + val HASH = "ab".repeat(32) + + fun payload(value: String) = "{\"value\":\"$value\"}" + + @Suppress("LongParameterList") + fun receivedRequest( + counterparty: String = BUYER, + role: PaymentRequestLocalRole? = PaymentRequestLocalRole.PAYEE, + state: PaymentRequestLifecycleState = PaymentRequestLifecycleState.PROOF_SUBMITTED, + invalidReason: String? = null, + asset: String = "btc", + endpoints: Map? = mapOf(METHOD to payload(ADDRESS)), + accepted: List = listOf(METHOD, BOLT11_METHOD), + terms: PaymentRequestTerms? = PaymentRequestTerms( + amount = PaymentRequestAmount("0.00015", asset), paymentReference = mock(), + proposalExpiresAt = null, recurrence = null, acceptedPaymentEndpointIdentifiers = accepted, + paymentEndpoints = endpoints, requiredAppId = "marketplace", conversion = null, + paymentDeadline = null, metadata = mock(), + ), + ): PaymentRequestRecord = mock { + on { this.counterparty }.thenReturn(counterparty) + on { this.localRole }.thenReturn(role) + on { this.state }.thenReturn(state) + on { this.invalidReason }.thenReturn(invalidReason) + on { this.terms }.thenReturn(terms) + on { proposalAppId }.thenReturn("marketplace") + } + } + + @Test + fun `shared app immutable address attributes received payment`() = withDecoder { + val contacts = index(receivedRequest()) + assertEquals(setOf(BUYER), contacts.contactsForAddresses(listOf(ADDRESS))) + assertTrue(contacts.contactsForAddresses(listOf(OTHER_ADDRESS)).isEmpty()) + } + + @Test + fun `address normalization preserves validation and ambiguity`() = withDecoder { + val uppercase = receivedRequest(endpoints = mapOf(METHOD to payload(ADDRESS.uppercase()))) + assertEquals(setOf(BUYER), index(uppercase).contactsForAddresses(listOf(ADDRESS))) + for (mixedCase in listOf("B" + ADDRESS.drop(1), "b" + ADDRESS.uppercase().drop(1))) { + val request = receivedRequest(endpoints = mapOf(METHOD to payload(mixedCase))) + assertTrue(index(request).contactsForAddresses(listOf(ADDRESS)).isEmpty()) + } + val legacy = LEGACY_ADDRESSES.first() + val legacyContacts = index(receivedRequest(endpoints = mapOf(METHOD to payload(legacy)))) + assertEquals(setOf(BUYER), legacyContacts.contactsForAddresses(listOf(legacy))) + assertTrue(legacyContacts.contactsForAddresses(listOf(legacy.lowercase())).isEmpty()) + assertEquals( + setOf(BUYER, OTHER_BUYER), + index(uppercase, receivedRequest(counterparty = OTHER_BUYER)).contactsForAddresses(listOf(ADDRESS)), + ) + } + + @Test + fun `contact snapshots compare by attribution values not record order or lifecycle`() = withDecoder { + val first = index(receivedRequest(), receivedRequest(counterparty = OTHER_BUYER)) + val refreshed = index( + receivedRequest(counterparty = OTHER_BUYER, state = PaymentRequestLifecycleState.ACCEPTED), + receivedRequest(state = PaymentRequestLifecycleState.CANCELED), + ) + assertEquals(first, refreshed) + assertEquals(first.hashCode(), refreshed.hashCode()) + } + + @Test + fun `terminal request states retain exact destination attribution`() = withDecoder { + for (state in PaymentRequestLifecycleState.entries.filterNot { + it == PaymentRequestLifecycleState.INVALID_CONFLICT || it == PaymentRequestLifecycleState.UNKNOWN + }) { + assertEquals(setOf(BUYER), index(receivedRequest(state = state)).contactsForAddresses(listOf(ADDRESS))) + } + } + + @Test + fun `payer unknown role and invalid records cannot attribute`() = withDecoder { + val invalidRecords = listOf( + receivedRequest(role = PaymentRequestLocalRole.PAYER), + receivedRequest(role = null), + receivedRequest(state = PaymentRequestLifecycleState.INVALID_CONFLICT), + receivedRequest(state = PaymentRequestLifecycleState.UNKNOWN), + receivedRequest(invalidReason = "conflict"), + receivedRequest(counterparty = "invalid"), + receivedRequest(counterparty = BUYER + "excess"), + receivedRequest(asset = "usd"), + receivedRequest(terms = null), + ) + assertTrue(index(*invalidRecords.toTypedArray()).contactsForAddresses(listOf(ADDRESS)).isEmpty()) + } + + @Test + fun `missing unaccepted malformed and wrong network endpoints cannot attribute`() = withDecoder { + val records = listOf( + receivedRequest(endpoints = null), + receivedRequest(endpoints = emptyMap()), + receivedRequest(accepted = emptyList()), + receivedRequest(endpoints = mapOf(METHOD to "not json")), + receivedRequest(endpoints = mapOf(METHOD to "{\"value\":\"\"}")), + receivedRequest(endpoints = mapOf(METHOD to payload("malformed"))), + receivedRequest(endpoints = mapOf("btc-bitcoin-p2wpkh" to payload(ADDRESS))), + receivedRequest(endpoints = mapOf(METHOD to payload(MAINNET_ADDRESS))), + receivedRequest(endpoints = mapOf(METHOD to payload(TESTNET_ADDRESS))), + ) + assertTrue( + index(*records.toTypedArray()) + .contactsForAddresses(listOf(ADDRESS, MAINNET_ADDRESS, TESTNET_ADDRESS)).isEmpty(), + ) + } + + @Test + fun `signet accepts testnet address encoding only with a signet endpoint`() = withDecoder { + val identifier = "btc-signet-p2wpkh" + val record = receivedRequest( + accepted = listOf(identifier), + endpoints = mapOf(identifier to payload(TESTNET_ADDRESS)), + ) + val contacts = PaykitReceivedPaymentContacts.from(listOf(record), Network.SIGNET) + assertEquals(setOf(BUYER), contacts.contactsForAddresses(listOf(TESTNET_ADDRESS))) + assertTrue(index(record).contactsForAddresses(listOf(TESTNET_ADDRESS)).isEmpty()) + } + + @Test + fun `regtest accepts network ambiguous legacy address encodings`() = withDecoder { + for (address in LEGACY_ADDRESSES) { + val identifier = if (address.startsWith("2")) "btc-regtest-p2sh" else "btc-regtest-p2pkh" + val record = receivedRequest( + accepted = listOf(identifier), + endpoints = mapOf(identifier to payload(address)), + ) + assertEquals(setOf(BUYER), index(record).contactsForAddresses(listOf(address))) + } + } + + @Test + fun `normalized duplicate counterparties remain unique`() = withDecoder { + val contacts = index(receivedRequest(), receivedRequest(counterparty = BUYER.removePrefix("pubky").uppercase())) + assertEquals(setOf(BUYER), contacts.contactsForAddresses(listOf(ADDRESS, ADDRESS))) + } + + @Test + fun `same destination shared by different counterparties stays ambiguous`() = withDecoder { + val contacts = index(receivedRequest(), receivedRequest(counterparty = OTHER_BUYER)) + assertEquals(setOf(BUYER, OTHER_BUYER), contacts.contactsForAddresses(listOf(ADDRESS))) + } + + @Test + fun `all output addresses contribute to ambiguity`() = withDecoder { + val contacts = index( + receivedRequest(), + receivedRequest(counterparty = OTHER_BUYER, endpoints = mapOf(METHOD to payload(OTHER_ADDRESS))), + ) + assertEquals(setOf(BUYER, OTHER_BUYER), contacts.contactsForAddresses(listOf(ADDRESS, OTHER_ADDRESS))) + } + + @Test + fun `validated expired bolt11 matches exact payment hash`() = withDecoder { + val contacts = index(receivedRequest(endpoints = mapOf(BOLT11_METHOD to payload(INVOICE)))) + assertEquals(setOf(BUYER), contacts.contactsForPaymentHash(HASH.uppercase())) + assertTrue(contacts.contactsForPaymentHash("cd".repeat(32)).isEmpty()) + } + + @Test + fun `malformed or wrong network bolt11 cannot attribute`() = withDecoder { + val contacts = index( + receivedRequest(endpoints = mapOf(BOLT11_METHOD to payload("invalid-invoice"))), + receivedRequest(endpoints = mapOf(BOLT11_METHOD to payload(MAINNET_INVOICE))), + ) + assertTrue(contacts.contactsForPaymentHash(HASH).isEmpty()) + } + + @Test + fun `proof hash alone cannot attribute without immutable request endpoints`() = withDecoder { + val record = receivedRequest(endpoints = null) + val proof = mock { + on { exportText() }.thenReturn("{\"payment_hash\":\"$HASH\",\"txid\":\"$HASH\"}") + } + val proofRecord = mock { on { this.proof }.thenReturn(proof) } + whenever(record.paymentProofs).thenReturn(listOf(proofRecord)) + + assertTrue(index(record).contactsForPaymentHash(HASH).isEmpty()) + assertTrue(index(record).contactsForAddresses(listOf(ADDRESS)).isEmpty()) + } + + @Test + fun `same invoice hash across contacts stays ambiguous`() = withDecoder { + val contacts = index( + receivedRequest(endpoints = mapOf(BOLT11_METHOD to payload(INVOICE))), + receivedRequest(counterparty = OTHER_BUYER, endpoints = mapOf(BOLT11_METHOD to payload(INVOICE))), + ) + assertEquals(setOf(BUYER, OTHER_BUYER), contacts.contactsForPaymentHash(HASH)) + } + + private fun index(vararg records: PaymentRequestRecord) = + PaykitReceivedPaymentContacts.from(records.toList(), Network.REGTEST) { value -> + require(value == INVOICE || value == MAINNET_INVOICE) + mock { + on { currency() }.thenReturn(if (value == INVOICE) Currency.REGTEST else Currency.BITCOIN) + on { paymentHash() }.thenReturn(HASH) + } + } + + private fun withDecoder(block: () -> Unit) { + mockStatic(Class.forName("com.synonym.bitkitcore.Bitkitcore_androidKt")).use { native -> + for (address in listOf(ADDRESS, OTHER_ADDRESS, MAINNET_ADDRESS, TESTNET_ADDRESS) + LEGACY_ADDRESSES) { + val network = when (address) { + MAINNET_ADDRESS -> NetworkType.BITCOIN + TESTNET_ADDRESS, in LEGACY_ADDRESSES -> NetworkType.TESTNET + else -> NetworkType.REGTEST + } + native.`when` { validateBitcoinAddress(address) }.thenReturn( + ValidationResult(address, network, AddressType.UNKNOWN), + ) + } + block() + } + } +} diff --git a/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionNotificationSchedulerTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionNotificationSchedulerTest.kt index ad1e9dea63..572f7d7d08 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionNotificationSchedulerTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionNotificationSchedulerTest.kt @@ -28,7 +28,6 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import to.bitkit.ui.EXTRA_PAYKIT_BILLING_PERIOD_STARTS_AT import to.bitkit.ui.EXTRA_PAYKIT_COUNTERPARTY -import to.bitkit.ui.EXTRA_PAYKIT_COUNTERPARTY_RECEIVER_PATH import to.bitkit.ui.EXTRA_PAYKIT_PAYER_IDENTITY import to.bitkit.ui.EXTRA_PAYKIT_PAYMENT_REQUEST_ID import to.bitkit.utils.SubscriptionClockOffset @@ -47,11 +46,10 @@ class PaykitSubscriptionNotificationSchedulerTest { const val COUNTERPARTY = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" const val PAYER_IDENTITY = "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" const val PAYMENT_REQUEST_ID = "request-id" - const val RECEIVER_PATH = "bitkit/server" const val WORK_TAG = "paykit-subscriptions" val NOW = Instant.parse("2027-01-02T08:00:00Z") val NEXT_PERIOD_START = Instant.parse("2027-01-08T08:00:00Z") - val WORK_NAME = "paykit-subscription-$PAYER_IDENTITY|$COUNTERPARTY|$RECEIVER_PATH|" + + val WORK_NAME = "paykit-subscription-$PAYER_IDENTITY|$COUNTERPARTY|" + "$PAYMENT_REQUEST_ID|$NEXT_PERIOD_START" } @@ -94,7 +92,6 @@ class PaykitSubscriptionNotificationSchedulerTest { assertEquals(PAYMENT_REQUEST_ID, request.workSpec.input.getString(EXTRA_PAYKIT_PAYMENT_REQUEST_ID)) assertEquals(PAYER_IDENTITY, request.workSpec.input.getString(EXTRA_PAYKIT_PAYER_IDENTITY)) assertEquals(COUNTERPARTY, request.workSpec.input.getString(EXTRA_PAYKIT_COUNTERPARTY)) - assertEquals(RECEIVER_PATH, request.workSpec.input.getString(EXTRA_PAYKIT_COUNTERPARTY_RECEIVER_PATH)) assertEquals( NEXT_PERIOD_START.toString(), request.workSpec.input.getString(EXTRA_PAYKIT_BILLING_PERIOD_STARTS_AT), @@ -174,7 +171,6 @@ class PaykitSubscriptionNotificationSchedulerTest { PaykitPaymentRequestId( paymentRequestId = PAYMENT_REQUEST_ID, counterparty = COUNTERPARTY, - counterpartyReceiverPath = RECEIVER_PATH, billingPeriodStartsAt = NEXT_PERIOD_START.toString(), ) ), @@ -209,7 +205,6 @@ class PaykitSubscriptionNotificationSchedulerTest { PaykitPaymentRequestId( paymentRequestId = PAYMENT_REQUEST_ID, counterparty = COUNTERPARTY, - counterpartyReceiverPath = RECEIVER_PATH, billingPeriodStartsAt = it.startsAt.toString(), ) }, @@ -253,7 +248,6 @@ class PaykitSubscriptionNotificationSchedulerTest { PaykitPaymentRequestId( paymentRequestId = PAYMENT_REQUEST_ID, counterparty = COUNTERPARTY, - counterpartyReceiverPath = RECEIVER_PATH, billingPeriodStartsAt = NEXT_PERIOD_START.toString(), ) ), @@ -328,7 +322,6 @@ class PaykitSubscriptionNotificationSchedulerTest { private fun subscription() = PaykitSubscription( paymentRequestId = PAYMENT_REQUEST_ID, counterparty = COUNTERPARTY, - counterpartyReceiverPath = RECEIVER_PATH, amountValue = "0.00025", amountSats = 25_000uL, note = "Weekly coffee", diff --git a/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionProposalTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionProposalTest.kt index 82ce0ec167..437899bea1 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionProposalTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionProposalTest.kt @@ -8,19 +8,29 @@ import to.bitkit.services.PaykitPaymentRequestProposalTerms import to.bitkit.services.PaykitPaymentRequestRecurrenceTerms import kotlin.test.assertEquals import kotlin.test.assertFailsWith -import kotlin.test.assertTrue class PaykitSubscriptionProposalTest { @Test - fun `transport limit includes envelope endpoints and public icon`() { + fun `transport limit includes app ids endpoints and public icon`() { + val emptyWire = """ + {"version":1,"kind":"paykit.payment_request","app_id":"bitkit", + "event_id":"00000000-0000-0000-0000-000000000000","payment_request_id":"00000000-0000-0000-0000-000000000000", + "request":{"amount":{"value":"0.001","asset":"btc"},"payment_reference":"bitkit-00000000-0000-0000-0000-000000000000", + "proposal_expires_at":"2027-01-22T08:00:00.000Z", + "recurrence":{"every":1,"unit":"month","starts_at":"2027-01-15T08:00:00.000Z","anchor":"2027-01-15T08:00:00.000Z","ends_at":null}, + "accepted_payment_endpoint_identifiers":["btc-regtest-p2wpkh","btc-lightning-bolt11","btc-lightning-lnurl"],"required_app_id":"bitkit", + "metadata":{"note":"Support","subscription":{"benefits":[],"description":"", + "icon_uri":"pubky://${"x".repeat(122)}","version":1}}}} + """.trimIndent().lines().joinToString("") + assertEquals(840, emptyWire.encodeToByteArray().size) val empty = terms("", PaykitSubscriptionProposal.reservedIconUri) - val available = PaykitSubscriptionProposal.MAX_MESSAGE_BYTES - PaykitSubscriptionProposal.encodedSize(empty) - assertTrue(available > 0) - val full = terms("a".repeat(available), PaykitSubscriptionProposal.reservedIconUri) + assertEquals(emptyWire.encodeToByteArray().size, PaykitSubscriptionProposal.encodedSize(empty)) + val full = terms("a".repeat(160), PaykitSubscriptionProposal.reservedIconUri) assertEquals(1000, PaykitSubscriptionProposal.encodedSize(full)) PaykitSubscriptionProposal.validate(full) + val oversized = terms("a".repeat(161), PaykitSubscriptionProposal.reservedIconUri) + assertEquals(1001, PaykitSubscriptionProposal.encodedSize(oversized)) assertFailsWith { - val oversized = terms("a".repeat(available + 1), PaykitSubscriptionProposal.reservedIconUri) PaykitSubscriptionProposal.validate(oversized) } } @@ -43,7 +53,11 @@ class PaykitSubscriptionProposalTest { startsAt = "2027-01-15T08:00:00.000Z", anchor = "2027-01-15T08:00:00.000Z", ), - acceptedPaymentEndpointIdentifiers = listOf("bitcoin:regtest", "lightning:bolt11", "lightning:lnurl"), + acceptedPaymentEndpointIdentifiers = listOf( + "btc-regtest-p2wpkh", + "btc-lightning-bolt11", + "btc-lightning-lnurl", + ), metadataJson = buildJsonObject { put("note", "Support") put( diff --git a/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionTest.kt index dc9d822b82..7751a3f744 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionTest.kt @@ -168,7 +168,7 @@ class PaykitSubscriptionTest { } @Test - fun `subscription payment matching includes counterparty and receiver path`() { + fun `subscription payment matching includes counterparty`() { val recurrence = PaykitSubscriptionRecurrence( every = 1, unit = PaykitRecurrenceUnit.Month, @@ -179,7 +179,6 @@ class PaykitSubscriptionTest { val subscription = PaykitSubscription( paymentRequestId = "shared", counterparty = "counterparty-a", - counterpartyReceiverPath = "bitkit/server", amountValue = "0.001", amountSats = 100_000uL, note = null, @@ -198,6 +197,5 @@ class PaykitSubscriptionTest { assertTrue(request.belongsTo(subscription)) assertFalse(request.copy(counterparty = "counterparty-b").belongsTo(subscription)) - assertFalse(request.copy(counterpartyReceiverPath = "bitkit/wallet").belongsTo(subscription)) } } diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt index b39eb66159..fd4c70180d 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt @@ -17,11 +17,12 @@ import to.bitkit.data.SettingsStore import to.bitkit.models.NodeLifecycleState import to.bitkit.services.AddressDerivationInfo import to.bitkit.services.CoreService -import to.bitkit.services.PaykitReceiverPaths import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNull +import kotlin.test.assertTrue class PrivatePaykitAddressReservationRepoTest : BaseUnitTest() { companion object { @@ -60,22 +61,6 @@ class PrivatePaykitAddressReservationRepoTest : BaseUnitTest() { ) } - @Test - fun `wallet assignment key keeps bare public key`() { - val key = ContactAssignmentKey(CONTACT_KEY, PaykitReceiverPaths.WALLET) - - assertEquals(CONTACT_KEY, key.encoded()) - assertEquals(CONTACT_KEY, ContactAssignmentKey.publicKeyOf(key.encoded())) - } - - @Test - fun `server assignment key includes receiver path`() { - val key = ContactAssignmentKey(CONTACT_KEY, PaykitReceiverPaths.SERVER) - - assertEquals("$CONTACT_KEY#${PaykitReceiverPaths.SERVER}", key.encoded()) - assertEquals(CONTACT_KEY, ContactAssignmentKey.publicKeyOf(key.encoded())) - } - @Test fun `contactsWithUsedReservedAddresses treats positive ldk address balance as used`() = test { reservationData.value = PrivatePaykitReservationData( @@ -185,7 +170,52 @@ class PrivatePaykitAddressReservationRepoTest : BaseUnitTest() { } @Test - fun `clearContactAssignment removes private address attribution history`() = test { + fun `removeContactAssignments removes selected attribution and preserves unrelated reservations`() = test { + val historyOnlyKey = "pubky4rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + val savedKey = "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + val assignment = PrivatePaykitStoredAssignmentData("nativeSegwit", 1, PRIVATE_ADDRESS) + val oldAssignment = assignment.copy(receiveIndex = 2, address = "bcrt1qold") + val historyOnlyAssignment = assignment.copy(receiveIndex = 3, address = "bcrt1qhistory") + val savedAssignment = assignment.copy(receiveIndex = 4, address = "bcrt1qsaved") + val savedHistory = assignment.copy(receiveIndex = 5, address = "bcrt1qsavedhistory") + val original = PrivatePaykitReservationData( + reservedReceiveIndexesByAddressType = mapOf("nativeSegwit" to setOf(1, 2, 3, 4, 5)), + contactAssignments = mapOf(CONTACT_KEY to assignment, savedKey to savedAssignment), + contactAssignmentHistory = mapOf( + CONTACT_KEY to listOf(oldAssignment), + historyOnlyKey to listOf(historyOnlyAssignment), + savedKey to listOf(savedHistory), + ), + restoredReservedReceiveIndexCeilingsByAddressType = mapOf("taproot" to 6), + ) + reservationData.value = original + whenever(lightningRepo.addressInfoForType(AddressType.P2WPKH, 1)).thenReturn( + Result.success(AddressDerivationInfo(address = PRIVATE_ADDRESS, index = 1)), + ) + + sut.removeContactAssignments(listOf(CONTACT_KEY.removePrefix("pubky"), historyOnlyKey)) + + assertEquals( + original.copy( + contactAssignments = mapOf(savedKey to savedAssignment), + contactAssignmentHistory = mapOf(savedKey to listOf(savedHistory)), + ), + reservationData.value, + ) + listOf(assignment, oldAssignment, historyOnlyAssignment).forEach { + assertNull(sut.contactPublicKeyForReservedAddress(it.address)) + assertNull(sut.currentContactPublicKeyForReservedAddress(it.address)) + } + assertEquals(savedKey, sut.currentContactPublicKeyForReservedAddress(savedAssignment.address)) + assertEquals(savedKey, sut.contactPublicKeyForReservedAddress(savedHistory.address)) + assertTrue(sut.isUnavailableForReusableReceive(PRIVATE_ADDRESS)) + verify(reservationStore).update(any()) + } + + @Test + fun `removeContactAssignments invalidates attribution even if persistence fails`() = test { + val historyOnlyKey = "pubky4rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + val historicalAddress = "bcrt1qhistory" reservationData.value = PrivatePaykitReservationData( contactAssignments = mapOf( CONTACT_KEY to PrivatePaykitStoredAssignmentData( @@ -202,12 +232,42 @@ class PrivatePaykitAddressReservationRepoTest : BaseUnitTest() { address = PRIVATE_ADDRESS, ), ), + historyOnlyKey to listOf( + PrivatePaykitStoredAssignmentData("nativeSegwit", 2, historicalAddress), + ), ), ) - sut.clearContactAssignment(CONTACT_KEY) + assertEquals(CONTACT_KEY, sut.contactPublicKeyForReservedAddress(PRIVATE_ADDRESS)) + assertEquals(historyOnlyKey, sut.contactPublicKeyForReservedAddress(historicalAddress)) + val persisted = reservationData.value + val version = sut.attributionVersion + whenever(reservationStore.update(any())).thenThrow(IllegalStateException("disk")) + assertFailsWith { + sut.removeContactAssignments(listOf(CONTACT_KEY, historyOnlyKey)) + } + assertTrue(sut.attributionVersion > version) assertNull(sut.contactPublicKeyForReservedAddress(PRIVATE_ADDRESS)) + assertNull(sut.currentContactPublicKeyForReservedAddress(PRIVATE_ADDRESS)) + assertNull(sut.contactPublicKeyForReservedAddress(historicalAddress)) + assertEquals(persisted, reservationData.value) + } + + @Test + fun `reservation derivation failure propagates and remains retryable`() = test { + reservationData.value = PrivatePaykitReservationData( + contactAssignments = mapOf( + CONTACT_KEY to PrivatePaykitStoredAssignmentData(addressType = "nativeSegwit", receiveIndex = 1), + ), + ) + whenever(lightningRepo.addressInfoForType(any(), any())).thenReturn( + Result.failure(IllegalStateException("unavailable")), + Result.success(AddressDerivationInfo(address = PRIVATE_ADDRESS, index = 1)), + ) + + assertFailsWith { sut.contactPublicKeyForReservedAddress(PRIVATE_ADDRESS) } + assertEquals(CONTACT_KEY, sut.contactPublicKeyForReservedAddress(PRIVATE_ADDRESS)) } @Test diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitContactResolverTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitContactResolverTest.kt index dcb74d2530..500c8252a3 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitContactResolverTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitContactResolverTest.kt @@ -9,6 +9,7 @@ import to.bitkit.data.PrivatePaykitCacheData import to.bitkit.data.PrivatePaykitCacheStore import to.bitkit.data.PrivatePaykitContactCacheData import to.bitkit.data.PrivatePaykitStoredInvoiceData +import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.test.BaseUnitTest import javax.inject.Provider import kotlin.test.assertEquals @@ -23,6 +24,7 @@ class PrivatePaykitContactResolverTest : BaseUnitTest() { private val cacheStore = mock() private val addressReservationRepo = mock() + private val paymentRequestRepo = mock() private val cacheData = MutableStateFlow(PrivatePaykitCacheData()) private lateinit var sut: PrivatePaykitContactResolver @@ -30,24 +32,60 @@ class PrivatePaykitContactResolverTest : BaseUnitTest() { @Before fun setUp() { whenever(cacheStore.data).thenReturn(cacheData) + whenever(paymentRequestRepo.receivedPaymentContacts).thenReturn(PaykitReceivedPaymentContacts.Empty) sut = PrivatePaykitContactResolver( ioDispatcher = testDispatcher, cacheStore = cacheStore, addressReservationRepo = Provider { addressReservationRepo }, + paymentRequestRepo = Provider { paymentRequestRepo }, ) } + @Test + fun `shared request invoice hash resolves without local invoice reservations`() = test { + val shared = mock() + whenever(shared.contactsForPaymentHash(PAYMENT_HASH)).thenReturn(setOf(CONTACT_KEY)) + whenever(paymentRequestRepo.receivedPaymentContacts).thenReturn(shared) + + assertEquals( + PubkyPublicKeyFormat.normalized(CONTACT_KEY), + sut.contactPublicKeyForPrivateInvoicePaymentHash(PAYMENT_HASH) + ) + } + + @Test + fun `conflicting local reservation and shared request stay unattributed`() = test { + val shared = mock() + whenever(shared.contactsForAddresses(listOf(PRIVATE_ADDRESS))) + .thenReturn(setOf(PaykitReceivedPaymentContactsTest.BUYER)) + whenever(paymentRequestRepo.receivedPaymentContacts).thenReturn(shared) + whenever(addressReservationRepo.contactPublicKeyForReservedAddress(PRIVATE_ADDRESS)).thenReturn(CONTACT_KEY) + + assertNull(sut.contactPublicKeyForPrivateOnchainAddresses(PRIVATE_ADDRESS, listOf(PRIVATE_ADDRESS))) + } + + @Test + fun `identity changes while resolving discard old shared request matches`() = test { + val shared = mock() + whenever(shared.contactsForAddresses(listOf(PRIVATE_ADDRESS))).thenReturn(setOf(CONTACT_KEY)) + whenever(paymentRequestRepo.receivedPaymentContacts).thenReturn(shared) + whenever(addressReservationRepo.contactPublicKeyForReservedAddress(PRIVATE_ADDRESS)).thenAnswer { + whenever(paymentRequestRepo.receivedPaymentContacts).thenReturn(PaykitReceivedPaymentContacts.Empty) + null + } + + assertNull(sut.contactPublicKeyForPrivateOnchainAddresses(PRIVATE_ADDRESS, listOf(PRIVATE_ADDRESS))) + } + @Test fun `contactPublicKeyForPrivateInvoicePaymentHash resolves current local invoice`() = test { cacheData.value = PrivatePaykitCacheData( contacts = mapOf( CONTACT_KEY to PrivatePaykitContactCacheData( - localInvoicesByReceiverPath = mapOf( - "bitkit/wallet" to PrivatePaykitStoredInvoiceData( - bolt11 = "lnbcrt1private", - paymentHash = PAYMENT_HASH, - expiresAt = 1_700_000_000L, - ), + localInvoice = PrivatePaykitStoredInvoiceData( + bolt11 = "lnbcrt1private", + paymentHash = PAYMENT_HASH, + expiresAt = 1_700_000_000L, ), ), ), @@ -55,7 +93,7 @@ class PrivatePaykitContactResolverTest : BaseUnitTest() { val result = sut.contactPublicKeyForPrivateInvoicePaymentHash(PAYMENT_HASH) - assertEquals(CONTACT_KEY, result) + assertEquals(PubkyPublicKeyFormat.normalized(CONTACT_KEY), result) } @Test @@ -70,7 +108,7 @@ class PrivatePaykitContactResolverTest : BaseUnitTest() { val result = sut.contactPublicKeyForPrivateInvoicePaymentHash(PAYMENT_HASH) - assertEquals(CONTACT_KEY, result) + assertEquals(PubkyPublicKeyFormat.normalized(CONTACT_KEY), result) } @Test @@ -85,8 +123,29 @@ class PrivatePaykitContactResolverTest : BaseUnitTest() { whenever(addressReservationRepo.contactPublicKeyForReservedAddress(PRIVATE_ADDRESS)) .thenReturn(CONTACT_KEY) - val result = sut.contactPublicKeyForPrivateOnchainAddresses(listOf(PRIVATE_ADDRESS)) + val result = sut.contactPublicKeyForPrivateOnchainAddresses(PRIVATE_ADDRESS, listOf(PRIVATE_ADDRESS)) + + assertEquals(PubkyPublicKeyFormat.normalized(CONTACT_KEY), result) + } + + @Test + fun `shared request is not a local address reservation`() = test { + val shared = mock() + whenever(shared.contactsForAddresses(listOf(PRIVATE_ADDRESS))).thenReturn(setOf(CONTACT_KEY)) + whenever(paymentRequestRepo.receivedPaymentContacts).thenReturn(shared) + + assertNull(sut.contactPublicKeyForReservedAddress(PRIVATE_ADDRESS)) + whenever(addressReservationRepo.contactPublicKeyForReservedAddress(PRIVATE_ADDRESS)).thenReturn(CONTACT_KEY) + assertEquals(CONTACT_KEY, sut.contactPublicKeyForReservedAddress(PRIVATE_ADDRESS)) + } + + @Test + fun `unrelated output cannot supply a contact for the receiving address`() = test { + val outputs = listOf("wallet-address", PRIVATE_ADDRESS) + whenever(addressReservationRepo.contactPublicKeyForReservedAddress(PRIVATE_ADDRESS)).thenReturn(CONTACT_KEY) - assertEquals(CONTACT_KEY, result) + assertNull(sut.contactPublicKeyForPrivateOnchainAddresses("wallet-address", outputs)) + assertNull(sut.contactPublicKeyForPrivateOnchainAddresses(null, outputs)) + assertNull(sut.contactPublicKeyForPrivateOnchainAddresses(PRIVATE_ADDRESS, listOf("wallet-address"))) } } diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 4288f1e224..163098c5a6 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -5,21 +5,26 @@ import com.synonym.bitkitcore.LightningInvoice import com.synonym.bitkitcore.NetworkType import com.synonym.bitkitcore.Scanner import com.synonym.paykit.ContactRecord -import com.synonym.paykit.CounterpartyReceiver +import com.synonym.paykit.IdentityStatus +import com.synonym.paykit.LinkedPeerHandshakeReport import com.synonym.paykit.LinkedPeerRecord import com.synonym.paykit.LinkedPeerState +import com.synonym.paykit.OutboundPrivateSendReport import com.synonym.paykit.PaykitException -import com.synonym.paykit.PaymentAmountContext +import com.synonym.paykit.PaymentRequestLifecycleState +import com.synonym.paykit.PrivatePaymentListDeliveryFailure import com.synonym.paykit.PrivatePaymentListDeliveryReport import com.synonym.paykit.PrivatePaymentListReservationUpdateInput import com.synonym.paykit.PrivatePaymentListSyncChange import com.synonym.paykit.PrivatePaymentResolutionState import com.synonym.paykit.PrivatePaymentResolutionStatus +import com.synonym.paykit.PubkyIdentityCapability import com.synonym.paykit.PublicationStatus import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceTimeBy @@ -28,15 +33,11 @@ import kotlinx.coroutines.test.runCurrent import org.junit.After import org.junit.Before import org.junit.Test -import org.lightningdevkit.ldknode.PaymentDetails -import org.lightningdevkit.ldknode.PaymentDirection -import org.lightningdevkit.ldknode.PaymentKind -import org.lightningdevkit.ldknode.PaymentStatus import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.atLeast import org.mockito.kotlin.clearInvocations -import org.mockito.kotlin.doReturn import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.mock @@ -44,6 +45,7 @@ import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.verifyBlocking +import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever import to.bitkit.App import to.bitkit.CurrentActivity @@ -56,9 +58,8 @@ import to.bitkit.models.NodeLifecycleState import to.bitkit.services.CoreService import to.bitkit.services.PaykitPreparedPrivateContactPayment import to.bitkit.services.PaykitPrivateContactPaymentResolution -import to.bitkit.services.PaykitPrivateReceiverPathSelection -import to.bitkit.services.PaykitReadLane import to.bitkit.services.PaykitResolvedPaymentEndpoint +import to.bitkit.services.PaykitSdkOperationLock.Priority import to.bitkit.services.PaykitSdkService import to.bitkit.services.PubkyService import to.bitkit.test.BaseUnitTest @@ -79,17 +80,13 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { companion object { private const val CONTACT_KEY = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" private const val OTHER_CONTACT_KEY = "pubky5rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" - private const val NEW_CONTACT_KEY = "pubky4rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" private const val OWN_KEY = "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" private const val PRIVATE_ADDRESS = "bcrt1qs04g2ka4pr9s3mv73nu32tvfy7r3cxd27wkyu8" private const val OTHER_PRIVATE_ADDRESS = "bcrt1q9x0pz2tqf8clz0lq6m9wj8t47zffnrdz2tkt6v" private const val PRIVATE_BOLT11 = "lnbcrt1private" private const val SERVER_PRIVATE_BOLT11 = "lnbcrt1serverprivate" - private const val ROTATED_PRIVATE_BOLT11 = "lnbcrt1rotatedprivate" private const val PRIVATE_BOLT11_EXPIRY_SECONDS = 86_400u private const val NOW_SECONDS = 1_700_000_000L - private const val WALLET_RECEIVER_PATH = "bitkit/wallet" - private const val SERVER_RECEIVER_PATH = "bitkit/server" } private val paykitSdkService = mock() @@ -125,28 +122,41 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { cacheData.value = PrivatePaykitCacheData() } whenever(settingsStore.data).thenReturn(settingsData) + whenever(settingsStore.update(any())).thenAnswer { + val transform = it.getArgument<(SettingsData) -> SettingsData>(0) + settingsData.value = transform(settingsData.value) + } whenever(lightningRepo.lightningState).thenReturn(lightningState) whenever(clock.now()).thenReturn(Instant.fromEpochSeconds(NOW_SECONDS)) whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) - whenever { pubkyService.discoverRelevantReceiverPaths(any(), any()) } - .thenReturn(listOf(WALLET_RECEIVER_PATH)) whenever(paykitSdkService.hasPrivatePaymentAccess()).thenReturn(true) + whenever(paykitSdkService.identityStatus()) + .thenReturn(IdentityStatus(OWN_KEY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(walletRepo.walletExists()).thenReturn(true) whenever { walletRepo.refreshReusableReceiveAddressIfReserved() }.thenReturn(Result.success(Unit)) whenever { addressReservationRepo.reconcileReservedIndexesWithLdk() }.thenReturn(Result.success(Unit)) - whenever { addressReservationRepo.currentOrRotatedAddress(CONTACT_KEY, WALLET_RECEIVER_PATH) } + whenever { addressReservationRepo.currentOrRotatedAddress(CONTACT_KEY) } .thenReturn(Result.success(PRIVATE_ADDRESS)) - whenever { paykitSdkService.privateReceiverPathSelection(any(), any(), any()) }.thenAnswer { - privateReceiverPathSelection(it.getArgument(1)) - } whenever { paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any()) } .thenReturn(privateListDeliveryReport(queuedCounterparties = listOf(CONTACT_KEY))) whenever { paykitSdkService.linkedPeers() }.thenReturn(emptyList()) + whenever { paykitSdkService.ensureLinkWithPeer(any(), any(), eq(Priority.Ordered)) }.thenAnswer { + LinkedPeerHandshakeReport(it.getArgument(0), LinkedPeerState.LINKED, 1uL, null) + } + whenever(paykitSdkService.ensureLinkWithPeer(any(), any(), eq(Priority.Background))) + .doSuspendableAnswer { paykitSdkService.ensureLinkWithPeer(it.getArgument(0)) } whenever { paykitSdkService.pendingOutboundPrivateCounterparties() }.thenReturn(emptyList()) - whenever { paykitSdkService.clearPrivatePaymentList(any(), any()) }.thenReturn(privateListDeliveryReport()) + whenever(paykitSdkService.linkedPeers(any())).doSuspendableAnswer { paykitSdkService.linkedPeers() } + whenever(paykitSdkService.pendingOutboundPrivateCounterparties(any())).doSuspendableAnswer { + paykitSdkService.pendingOutboundPrivateCounterparties() + } + whenever { paykitSdkService.clearPrivatePaymentLists(any()) }.thenAnswer { + privateListDeliveryReport(clearedCounterparties = it.getArgument(0)) + } whenever { publicPaykitRepo.beginPayment(any()) } .thenReturn(Result.success(PublicPaykitPaymentResult.Opened("bitcoin:bcrt1qpublic"))) whenever { publicPaykitRepo.payableEndpoints(any()) }.thenAnswer { it.getArgument>(0) } + whenever { publicPaykitRepo.syncPaykitApp(anyOrNull()) }.thenReturn(Result.success(Unit)) whenever(lightningRepo.getPayments()).thenReturn(Result.success(emptyList())) PublicPaykitRepo.lightningRouteHintsValidator = { true } @@ -160,6 +170,45 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { App.currentActivity = null } + @Test + fun `handleOnchainActivity skips SDK access when publication is locally unnecessary`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false) + sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() + clearInvocations(paykitSdkService, pubkyService) + + for (unavailable in listOf("unused address", "background", "wallet", "node")) { + whenever(addressReservationRepo.contactsWithUsedReservedAddresses()) + .thenReturn(if (unavailable == "unused address") emptyList() else listOf(CONTACT_KEY)) + App.currentActivity = if (unavailable == "background") { + null + } else { + CurrentActivity().also { it.onActivityStarted(mock()) } + } + whenever(walletRepo.walletExists()).thenReturn(unavailable != "wallet") + lightningState.value = LightningState( + nodeLifecycleState = if (unavailable == "node") { + NodeLifecycleState.Stopped + } else { + NodeLifecycleState.Running + }, + ) + + sut.handleOnchainActivity().getOrThrow() + } + + verify(paykitSdkService, never()).hasPrivatePaymentAccess() + verify(paykitSdkService, never()).identityStatus() + verify(pubkyService, never()).currentPublicKey() + } + + @Test + fun `hasPrivatePaymentAccess uses one SDK identity check`() = test { + assertTrue(sut.hasPrivatePaymentAccess()) + + verify(paykitSdkService).hasPrivatePaymentAccess() + verify(pubkyService, never()).currentPublicKey() + } + @Test fun `prepareSavedContacts publishes private reservations through SDK`() = test { settingsData.value = SettingsData( @@ -167,6 +216,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { publicPaykitLightningEnabled = false, publicPaykitOnchainEnabled = true, ) + whenever(paykitSdkService.linkedPeers()).thenReturn(listOf(linkedPeer(CONTACT_KEY, LinkedPeerState.LINKED))) val result = sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) @@ -181,457 +231,502 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals(PublicPaykitRepo.serializePayload(PRIVATE_ADDRESS), reservation.payload) assertTrue( reservation.reservationId.startsWith( - "$CONTACT_KEY:$WALLET_RECEIVER_PATH:${MethodId.P2wpkh.rawValue}:", + "$CONTACT_KEY:${MethodId.P2wpkh.rawValue}:", ), ) assertTrue(reservation.reservationId.length <= 128) assertEquals("private_paykit", reservation.attribution["type"]) assertEquals(CONTACT_KEY, reservation.attribution["counterparty"]) - assertEquals(WALLET_RECEIVER_PATH, reservation.attribution["receiver_path"]) assertEquals( - setOf(WALLET_RECEIVER_PATH), - cacheData.value.contacts.getValue(CONTACT_KEY).publishedPrivatePaymentReceiverPaths, + true, + cacheData.value.contacts.getValue(CONTACT_KEY).hasPublishedPrivatePaymentList, ) - } + verify(paykitSdkService, never()).ensureLinkWithPeer(CONTACT_KEY) + verify(paykitSdkService, never()).processPendingPrivateMessages() + verify(paykitSdkService, never()).receivePrivateMessagesFromLinkedPeers() + verify(paykitSdkService).identityStatus() + verify(paykitSdkService).hasPrivatePaymentAccess() + verify(pubkyService, times(2)).currentPublicKey() - @Test - fun `hasPrivatePaymentAccess returns false when the SDK check fails`() = test { - whenever(paykitSdkService.hasPrivatePaymentAccess()).thenThrow(IllegalStateException("Paykit unavailable")) + clearInvocations(paykitSdkService, pubkyService) + whenever(paykitSdkService.identityStatus()).thenReturn(null) - assertFalse(sut.hasPrivatePaymentAccess()) + sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true).getOrThrow() + + verify(paykitSdkService).identityStatus() + verify(paykitSdkService, times(2)).hasPrivatePaymentAccess() + verify(pubkyService, times(3)).currentPublicKey() + verify(paykitSdkService).syncPrivatePaymentListsWithReservations(any(), eq(false)) } @Test - fun `prepareSavedContacts publishes distinct private reservations for eligible receiver paths`() = test { - settingsData.value = SettingsData( - sharesPrivatePaykitEndpoints = true, - publicPaykitLightningEnabled = false, - publicPaykitOnchainEnabled = true, - ) - whenever { paykitSdkService.contactRecord(CONTACT_KEY) } - .thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH))) - whenever { addressReservationRepo.currentOrRotatedAddress(CONTACT_KEY, SERVER_RECEIVER_PATH) } - .thenReturn(Result.success(OTHER_PRIVATE_ADDRESS)) - whenever { paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any()) }.thenAnswer { - privateListDeliveryReportForUpdates(it.getArgument(0)) + fun `hasPrivatePaymentAccess returns false when the SDK check fails`() = test { + var accessFails = true + whenever(paykitSdkService.hasPrivatePaymentAccess()).thenAnswer { + check(!accessFails) { "Paykit unavailable" } + true } - val result = sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) - - assertTrue(result.isSuccess, result.exceptionOrNull().toString()) - val captor = argumentCaptor>() - verifyBlocking(paykitSdkService) { syncPrivatePaymentListsWithReservations(captor.capture(), eq(false)) } + assertFalse(sut.hasPrivatePaymentAccess()) - assertEquals( - listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH), - captor.firstValue.map { it.counterpartyReceiverPath }, - ) - assertEquals( - listOf(PRIVATE_ADDRESS, OTHER_PRIVATE_ADDRESS).map(PublicPaykitRepo::serializePayload), - captor.firstValue.map { it.reservations.single().payload }, - ) - assertEquals(2, captor.firstValue.map { it.reservations.single().reservationId }.distinct().size) - assertEquals( - setOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH), - cacheData.value.contacts.getValue(CONTACT_KEY).publishedPrivatePaymentReceiverPaths, - ) - verifyBlocking(paykitSdkService, atLeast(1)) { ensureLinkWithPeer(CONTACT_KEY, WALLET_RECEIVER_PATH) } - verifyBlocking(paykitSdkService, atLeast(1)) { ensureLinkWithPeer(CONTACT_KEY, SERVER_RECEIVER_PATH) } + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false) + accessFails = false + val failures = listOf(AppError("Identity unavailable"), CancellationException("Cancelled")) + var failure: Throwable = failures.first() + whenever(paykitSdkService.identityStatus()).thenAnswer { throw failure } + for (nextFailure in failures) { + failure = nextFailure + + if (failure is CancellationException) { + assertFailsWith { sut.prepareSavedContacts(listOf(CONTACT_KEY)) } + } else { + assertEquals(failure, sut.prepareSavedContacts(listOf(CONTACT_KEY)).exceptionOrNull()) + } + } + verify(addressReservationRepo, never()).currentOrRotatedAddress(any()) + verify(paykitSdkService, never()).syncPrivatePaymentListsWithReservations(any(), any()) } @Test - fun `prepareSavedContacts skips public-only receiver paths`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) - whenever { paykitSdkService.contactRecord(CONTACT_KEY) } - .thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH))) - whenever { paykitSdkService.privateReceiverPathSelection(eq(CONTACT_KEY), any(), any()) } - .thenReturn(privateReceiverPathSelection(emptyList())) - - val result = sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) - - assertTrue(result.isSuccess, result.exceptionOrNull().toString()) - verifyBlocking(paykitSdkService, never()) { ensureLinkWithPeer(any(), any(), any()) } - verifyBlocking(paykitSdkService, never()) { syncPrivatePaymentListsWithReservations(any(), any()) } + fun `publication checks identity capability and settings after identity lookup`() = test { + for (unavailable in listOf("identity", "capability", "sharing", "cleanup")) { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false) + cacheData.value = PrivatePaykitCacheData() + val status = CompletableDeferred() + whenever(paykitSdkService.identityStatus()) doSuspendableAnswer { status.await() } + val publication = async { + sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) + } + runCurrent() + assertFalse(publication.isCompleted) + if (unavailable == "sharing") { + settingsData.value = settingsData.value.copy(sharesPrivatePaykitEndpoints = false) + } + if (unavailable == "cleanup") cacheData.value = cacheData.value.copy(cleanupPending = true) + status.complete( + IdentityStatus( + if (unavailable == "identity") OTHER_CONTACT_KEY else OWN_KEY, + if (unavailable == "capability") { + PubkyIdentityCapability.PUBLIC_ONLY + } else { + PubkyIdentityCapability.PRIVATE_LINK_CAPABLE + }, + ), + ) - assertTrue(sut.removePublishedEndpointsForCleanup("test").isSuccess) - verifyBlocking(paykitSdkService, never()) { clearPrivatePaymentList(any(), any()) } + assertIs(publication.await().exceptionOrNull()) + } + verify(addressReservationRepo, never()).currentOrRotatedAddress(any()) + verify(paykitSdkService, never()).syncPrivatePaymentListsWithReservations(any(), any()) } @Test - fun `prepareSavedContacts links server receiver without publishing payment details`() = test { - settingsData.value = SettingsData( - sharesPrivatePaykitEndpoints = true, - publicPaykitLightningEnabled = false, - publicPaykitOnchainEnabled = true, - ) - whenever { paykitSdkService.contactRecord(CONTACT_KEY) } - .thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH))) - whenever { paykitSdkService.privateReceiverPathSelection(eq(CONTACT_KEY), any(), any()) } - .thenReturn( - privateReceiverPathSelection( - linkableReceiverPaths = listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH), - publishableReceiverPaths = listOf(WALLET_RECEIVER_PATH), - ), - ) - whenever { paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any()) }.thenAnswer { - privateListDeliveryReportForUpdates(it.getArgument(0)) + fun `publication rechecks identity after wallet reservation`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false) + val address = CompletableDeferred() + whenever(addressReservationRepo.currentOrRotatedAddress(CONTACT_KEY)) doSuspendableAnswer { + Result.success(address.await()) } + val publication = async { + sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) + } + runCurrent() + assertFalse(publication.isCompleted) + verify(paykitSdkService).identityStatus() + whenever(pubkyService.currentPublicKey()).thenReturn(OTHER_CONTACT_KEY) + address.complete(PRIVATE_ADDRESS) - val result = sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) - - assertTrue(result.isSuccess, result.exceptionOrNull().toString()) - verifyBlocking(paykitSdkService, atLeast(1)) { ensureLinkWithPeer(CONTACT_KEY, SERVER_RECEIVER_PATH) } - val captor = argumentCaptor>() - verifyBlocking(paykitSdkService) { syncPrivatePaymentListsWithReservations(captor.capture(), eq(false)) } - assertEquals(listOf(WALLET_RECEIVER_PATH), captor.firstValue.map { it.counterpartyReceiverPath }) + assertIs(publication.await().exceptionOrNull()) + verify(pubkyService, times(2)).currentPublicKey() + verify(paykitSdkService, never()).syncPrivatePaymentListsWithReservations(any(), any()) } @Test - fun `prepareSavedContacts links relevant receivers when endpoint sharing is disabled`() = test { + fun `prepareSavedContacts links contacts when endpoint sharing is disabled`() = test { settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) whenever { paykitSdkService.contactRecord(CONTACT_KEY) } - .thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH))) - whenever { paykitSdkService.privateReceiverPathSelection(eq(CONTACT_KEY), any(), any()) } - .thenReturn( - privateReceiverPathSelection( - linkableReceiverPaths = listOf(SERVER_RECEIVER_PATH), - publishableReceiverPaths = emptyList(), - ), - ) - + .thenReturn(contactRecord(CONTACT_KEY)) val result = sut.prepareSavedContacts(listOf(CONTACT_KEY)) assertTrue(result.isSuccess, result.exceptionOrNull().toString()) - verifyBlocking(paykitSdkService) { ensureLinkWithPeer(CONTACT_KEY, SERVER_RECEIVER_PATH) } + verifyBlocking(paykitSdkService) { ensureLinkWithPeer(CONTACT_KEY) } verifyBlocking(paykitSdkService, never()) { syncPrivatePaymentListsWithReservations(any(), any()) } - verify(addressReservationRepo, never()).currentOrRotatedAddress(any(), any()) + verify(addressReservationRepo, never()).currentOrRotatedAddress(any()) } @Test - fun `refreshKnownSavedContactEndpoints succeeds when a contact is saved during receiver discovery`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) - assertTrue(sut.prepareSavedContacts(listOf(CONTACT_KEY, OTHER_CONTACT_KEY)).isSuccess) - clearInvocations(pubkyService) - val discoveryStarted = CompletableDeferred() - val resumeDiscovery = CompletableDeferred() - whenever { pubkyService.discoverRelevantReceiverPaths(CONTACT_KEY, PaykitReadLane.Bulk) } - .doSuspendableAnswer { - discoveryStarted.complete(Unit) - resumeDiscovery.await() - listOf(WALLET_RECEIVER_PATH) - } - - val refresh = async { sut.refreshKnownSavedContactEndpoints("test") } - discoveryStarted.await() - val saveResult = sut.refreshSavedContactEndpoints( - NEW_CONTACT_KEY, - listOf(CONTACT_KEY, OTHER_CONTACT_KEY, NEW_CONTACT_KEY), - ) - resumeDiscovery.complete(Unit) - val result = refresh.await() + fun `publication failure retains pending link retries`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) + var linkPrepared = false + whenever(paykitSdkService.linkedPeers()).thenReturn(listOf(linkedPeer(CONTACT_KEY, LinkedPeerState.LINKING))) + whenever(paykitSdkService.ensureLinkWithPeer(CONTACT_KEY)).thenAnswer { + linkPrepared = true + LinkedPeerHandshakeReport(CONTACT_KEY, LinkedPeerState.LINKING, 1uL, null) + } + whenever(paykitSdkService.identityStatus()).doSuspendableAnswer { + if (linkPrepared) throw PaykitException.Transport("offline", "Unavailable homeserver") + IdentityStatus(OWN_KEY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE) + } - assertTrue(saveResult.isSuccess, saveResult.exceptionOrNull().toString()) - assertTrue(result.isSuccess, result.exceptionOrNull().toString()) - verifyBlocking(pubkyService) { discoverRelevantReceiverPaths(NEW_CONTACT_KEY, PaykitReadLane.Bulk) } - verifyBlocking(pubkyService) { discoverRelevantReceiverPaths(OTHER_CONTACT_KEY, PaykitReadLane.Bulk) } - sut.closeAndClear() + try { + assertTrue(sut.prepareSavedContacts(listOf(CONTACT_KEY)).isFailure) + advanceTimeBy(1_000) + runCurrent() + verify(paykitSdkService, times(2)).ensureLinkWithPeer(CONTACT_KEY) + } finally { + sut.closeAndClear() + } } @Test - fun `initial link burst discovers a server receiver published after the contact was saved`() = test { + fun `repeated refreshes preserve pending link retry backoff`() = test { settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) - whenever { paykitSdkService.contactRecord(CONTACT_KEY) } - .thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH))) - whenever { pubkyService.discoverRelevantReceiverPaths(CONTACT_KEY, PaykitReadLane.Bulk) } - .thenReturn(listOf(WALLET_RECEIVER_PATH)) - .thenReturn(listOf(WALLET_RECEIVER_PATH)) - .thenReturn(listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH)) - whenever { - pubkyService.saveContact( - CONTACT_KEY, - null, - listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH), - ) - }.thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH))) - - assertTrue(sut.prepareSavedContacts(listOf(CONTACT_KEY)).isSuccess) - clearInvocations(paykitSdkService, pubkyService) - - sut.startInitialLinkBurst(listOf(CONTACT_KEY), "test") + whenever(paykitSdkService.linkedPeers()).thenReturn(listOf(linkedPeer(CONTACT_KEY, LinkedPeerState.LINKING))) + sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() + advanceTimeBy(1_000) runCurrent() + clearInvocations(paykitSdkService) + + sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() advanceTimeBy(2_000) runCurrent() - - verifyBlocking(pubkyService) { - saveContact( - CONTACT_KEY, - null, - listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH), - ) - } - verifyBlocking(paykitSdkService) { ensureLinkWithPeer(CONTACT_KEY, SERVER_RECEIVER_PATH) } - verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } - sut.closeAndClear() - } - - @Test - fun `initial link burst does not recreate a contact deleted during discovery`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) - whenever(paykitSdkService.contactRecord(CONTACT_KEY)) - .thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH)), null) - whenever { pubkyService.discoverRelevantReceiverPaths(CONTACT_KEY, PaykitReadLane.Bulk) } - .thenReturn(listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH)) - - sut.startInitialLinkBurst(listOf(CONTACT_KEY), "test") + verify(paykitSdkService, never()).ensureLinkWithPeer(CONTACT_KEY) + advanceTimeBy(1_000) runCurrent() - - verify(paykitSdkService, times(2)).contactRecord(CONTACT_KEY) - verifyBlocking(pubkyService, never()) { - saveContact( - CONTACT_KEY, - null, - listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH), - ) - } + verify(paykitSdkService).ensureLinkWithPeer(CONTACT_KEY) sut.closeAndClear() } @Test - fun `restarting initial link burst replaces saved contact keys`() = test { + fun `private retries stop after linking and delivering pending messages`() = test { settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) - - sut.startInitialLinkBurst(listOf(CONTACT_KEY), "test") + whenever(paykitSdkService.linkedPeers()).thenReturn(listOf(linkedPeer(CONTACT_KEY, LinkedPeerState.LINKING))) + sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() runCurrent() - clearInvocations(pubkyService) - sut.startInitialLinkBurst(listOf(OTHER_CONTACT_KEY), "test") - runCurrent() - clearInvocations(pubkyService) + whenever(paykitSdkService.linkedPeers()).thenReturn(listOf(linkedPeer(CONTACT_KEY, LinkedPeerState.LINKED))) + whenever(paykitSdkService.pendingOutboundPrivateCounterparties()).thenReturn(listOf(CONTACT_KEY)) + clearInvocations(paykitSdkService) advanceTimeBy(2_000) runCurrent() + verify(paykitSdkService, atLeast(1)).processOutboundPrivateMessages(CONTACT_KEY) - verifyBlocking(pubkyService) { discoverRelevantReceiverPaths(OTHER_CONTACT_KEY, PaykitReadLane.Bulk) } - verifyBlocking(pubkyService, never()) { discoverRelevantReceiverPaths(CONTACT_KEY, PaykitReadLane.Bulk) } + whenever(paykitSdkService.pendingOutboundPrivateCounterparties()).thenReturn(emptyList()) + advanceTimeBy(2_000) + runCurrent() + clearInvocations(paykitSdkService) + advanceTimeBy(120_000) + runCurrent() + verify(paykitSdkService, never()).ensureLinkWithPeer(any(), any(), any()) + verify(paykitSdkService, never()).receivePrivateMessages(any()) + verify(paykitSdkService, never()).receivePrivateMessagesFromLinkedPeers() sut.closeAndClear() } @Test - fun `endpoint cleanup cancels scheduled link publication before local state is cleared`() = test { + fun `endpoint cleanup cancels pending link retries before local state is cleared`() = test { settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) - sut.startInitialLinkBurst(listOf(CONTACT_KEY), "test") + whenever(paykitSdkService.linkedPeers()).thenReturn(listOf(linkedPeer(CONTACT_KEY, LinkedPeerState.LINKING))) + sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() runCurrent() + whenever(paykitSdkService.linkedPeers()).thenReturn(listOf(linkedPeer(CONTACT_KEY, LinkedPeerState.LINKED))) assertTrue(sut.removePublishedEndpointsForCleanup("test").isSuccess) clearInvocations(pubkyService, paykitSdkService) advanceTimeBy(30_000) runCurrent() - verifyBlocking(pubkyService, never()) { discoverRelevantReceiverPaths(any(), any()) } + verify(paykitSdkService, never()).ensureLinkWithPeer(CONTACT_KEY) verifyBlocking(paykitSdkService, never()) { syncPrivatePaymentListsWithReservations(any(), any()) } sut.closeAndClear() } @Test - fun `restarting initial link burst with no contacts cancels retries`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) - - sut.startInitialLinkBurst(listOf(CONTACT_KEY), "test") - runCurrent() - clearInvocations(pubkyService) - - sut.startInitialLinkBurst(emptyList(), "test") - advanceTimeBy(30_000) - runCurrent() - - verifyBlocking(pubkyService, never()) { discoverRelevantReceiverPaths(any(), any()) } - sut.closeAndClear() - } - - @Test - fun `prepareSavedContacts clears receiver paths that are no longer eligible`() = test { + fun `prepareSavedContacts defers reservations while link preparation is unavailable`() = test { settingsData.value = SettingsData( sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false, publicPaykitOnchainEnabled = true, ) - whenever { paykitSdkService.contactRecord(CONTACT_KEY) } - .thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH))) - whenever { addressReservationRepo.currentOrRotatedAddress(CONTACT_KEY, SERVER_RECEIVER_PATH) } - .thenReturn(Result.success(OTHER_PRIVATE_ADDRESS)) - whenever { paykitSdkService.privateReceiverPathSelection(eq(CONTACT_KEY), any(), any()) } - .thenReturn(privateReceiverPathSelection(listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH))) - .thenReturn(privateReceiverPathSelection(listOf(WALLET_RECEIVER_PATH))) - whenever { paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any()) }.thenAnswer { - privateListDeliveryReportForUpdates(it.getArgument(0)) + whenever(paykitSdkService.ensureLinkWithPeer(CONTACT_KEY)).thenAnswer { + throw PaykitException.Transport("offline", "Unavailable homeserver") } - sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) - clearInvocations(paykitSdkService) val result = sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) assertTrue(result.isSuccess, result.exceptionOrNull().toString()) - val captor = argumentCaptor>() - verifyBlocking(paykitSdkService) { syncPrivatePaymentListsWithReservations(captor.capture(), eq(false)) } - val updatesByPath = captor.firstValue.associateBy { it.counterpartyReceiverPath } - assertEquals(1, updatesByPath.getValue(WALLET_RECEIVER_PATH).reservations.size) - assertTrue(updatesByPath.getValue(SERVER_RECEIVER_PATH).reservations.isEmpty()) - assertEquals( - setOf(WALLET_RECEIVER_PATH), - cacheData.value.contacts.getValue(CONTACT_KEY).publishedPrivatePaymentReceiverPaths, - ) + verify(paykitSdkService, never()).syncPrivatePaymentListsWithReservations(any(), any()) + verify(addressReservationRepo, never()).currentOrRotatedAddress(any()) } @Test - fun `prepareSavedContacts preserves receiver paths when marker lookup fails`() = test { + fun `private message drain keeps retrying while link is still pending`() = test { settingsData.value = SettingsData( sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false, publicPaykitOnchainEnabled = true, ) - whenever { paykitSdkService.contactRecord(CONTACT_KEY) } - .thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH))) - whenever { addressReservationRepo.currentOrRotatedAddress(CONTACT_KEY, SERVER_RECEIVER_PATH) } - .thenReturn(Result.success(OTHER_PRIVATE_ADDRESS)) - whenever { paykitSdkService.privateReceiverPathSelection(eq(CONTACT_KEY), any(), any()) } - .thenReturn(privateReceiverPathSelection(listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH))) - .thenReturn( - privateReceiverPathSelection( - publishableReceiverPaths = listOf(WALLET_RECEIVER_PATH), - cleanupProtectedReceiverPaths = listOf(SERVER_RECEIVER_PATH), - error = PrivatePaykitTestAppError("marker unavailable"), - ), - ) - whenever { paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any()) }.thenAnswer { - privateListDeliveryReportForUpdates(it.getArgument(0)) - } + whenever { paykitSdkService.linkedPeers() } + .thenReturn(listOf(linkedPeer(CONTACT_KEY, LinkedPeerState.LINKING))) - sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) - clearInvocations(paykitSdkService) val result = sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) assertTrue(result.isSuccess, result.exceptionOrNull().toString()) - val captor = argumentCaptor>() - verifyBlocking(paykitSdkService) { syncPrivatePaymentListsWithReservations(captor.capture(), eq(false)) } - assertEquals(listOf(WALLET_RECEIVER_PATH), captor.firstValue.map { it.counterpartyReceiverPath }) - assertEquals( - setOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH), - cacheData.value.contacts.getValue(CONTACT_KEY).publishedPrivatePaymentReceiverPaths, - ) + advanceTimeBy(257_000) + runCurrent() + sut.closeAndClear() + + verifyBlocking(paykitSdkService, atLeast(8)) { ensureLinkWithPeer(CONTACT_KEY) } + verify(paykitSdkService, atLeast(1)).linkedPeers(Priority.Background) + verify(paykitSdkService, atLeast(1)).ensureLinkWithPeer(CONTACT_KEY, priority = Priority.Background) + verify(paykitSdkService, atLeast(1)).pendingOutboundPrivateCounterparties(Priority.Background) + verify(paykitSdkService, never()).processOutboundPrivateMessages(any()) + verify(paykitSdkService, never()).receivePrivateMessages(any()) + verify(paykitSdkService, never()).processPendingPrivateMessages() + verify(paykitSdkService, never()).receivePrivateMessagesFromLinkedPeers() } @Test - fun `immediate preparation fails when every marker lookup fails`() = test { - settingsData.value = SettingsData( - sharesPrivatePaykitEndpoints = true, - publicPaykitLightningEnabled = false, - publicPaykitOnchainEnabled = true, - ) - whenever { paykitSdkService.privateReceiverPathSelection(eq(CONTACT_KEY), any(), any()) } - .thenReturn( - privateReceiverPathSelection( - publishableReceiverPaths = emptyList(), - cleanupProtectedReceiverPaths = listOf(WALLET_RECEIVER_PATH), - error = PrivatePaykitTestAppError("marker unavailable"), - ), + fun `private message drain normalizes targets and skips unrelated blocked peers`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) + var state = LinkedPeerState.LINKING + whenever(paykitSdkService.linkedPeers()).thenAnswer { + listOf( + linkedPeer(OTHER_CONTACT_KEY.removePrefix("pubky"), LinkedPeerState.LINKED), + linkedPeer(CONTACT_KEY.removePrefix("pubky"), state), + linkedPeer(CONTACT_KEY, state), ) + } + whenever(paykitSdkService.pendingOutboundPrivateCounterparties()).thenReturn( + listOf(OTHER_CONTACT_KEY.removePrefix("pubky"), CONTACT_KEY.removePrefix("pubky"), CONTACT_KEY), + ) + whenever(paykitSdkService.processPendingPrivateMessages()).doSuspendableAnswer { awaitCancellation() } + whenever(paykitSdkService.receivePrivateMessagesFromLinkedPeers()).doSuspendableAnswer { awaitCancellation() } + whenever(paykitSdkService.processOutboundPrivateMessages(OTHER_CONTACT_KEY)) + .doSuspendableAnswer { awaitCancellation() } + whenever(paykitSdkService.receivePrivateMessages(OTHER_CONTACT_KEY)).doSuspendableAnswer { awaitCancellation() } + whenever(paykitSdkService.ensureLinkWithPeer(CONTACT_KEY)).thenAnswer { + state = LinkedPeerState.LINKED + LinkedPeerHandshakeReport(CONTACT_KEY, state, 1uL, null) + } - val result = sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) + val preparation = async { sut.prepareSavedContacts(listOf(CONTACT_KEY.removePrefix("pubky"), CONTACT_KEY)) } + runCurrent() + try { + assertTrue(preparation.isCompleted) + preparation.await().getOrThrow() + verify(paykitSdkService).processOutboundPrivateMessages(CONTACT_KEY) + verify(paykitSdkService).receivePrivateMessages(CONTACT_KEY) + verify(paykitSdkService, never()).processOutboundPrivateMessages(OTHER_CONTACT_KEY) + verify(paykitSdkService, never()).receivePrivateMessages(OTHER_CONTACT_KEY) + verify(paykitSdkService, never()).processPendingPrivateMessages() + verify(paykitSdkService, never()).receivePrivateMessagesFromLinkedPeers() + } finally { + preparation.cancel() + sut.closeAndClear() + } + } - assertTrue(result.isFailure) - verifyBlocking(paykitSdkService, never()) { syncPrivatePaymentListsWithReservations(any(), any()) } + @Test + fun `overlapping link preparation retries after failure or cancellation`() = test { + for (cancel in listOf(false, true)) { + val completion = CompletableDeferred() + var state = LinkedPeerState.LINKING + clearInvocations(paykitSdkService) + whenever(paykitSdkService.linkedPeers(any())).thenAnswer { listOf(linkedPeer(CONTACT_KEY, state)) } + whenever(paykitSdkService.ensureLinkWithPeer(any(), any(), eq(Priority.Ordered))).doSuspendableAnswer { + completion.await() + throw PaykitException.Transport("offline", "Unavailable homeserver") + } + val first = async { sut.prepareSavedContacts(listOf(CONTACT_KEY)) } + runCurrent() + val second = async { sut.prepareSavedContacts(listOf(CONTACT_KEY)) } + try { + runCurrent() + verify(paykitSdkService).ensureLinkWithPeer(CONTACT_KEY) + assertTrue(second.isCompleted) + second.await().getOrThrow() + if (cancel) first.cancel() else completion.complete(Unit) + runCurrent() + if (!cancel) first.await().getOrThrow() + whenever(paykitSdkService.ensureLinkWithPeer(any(), any(), eq(Priority.Ordered))).thenAnswer { + state = LinkedPeerState.LINKED + LinkedPeerHandshakeReport(CONTACT_KEY, state, 1uL, null) + } + advanceTimeBy(1_000) + runCurrent() + verify(paykitSdkService, times(2)).ensureLinkWithPeer(CONTACT_KEY) + } finally { + first.cancel() + second.cancel() + sut.closeAndClear() + } + } } @Test - fun `immediate preparation keeps valid contacts when another marker lookup fails`() = test { - settingsData.value = SettingsData( - sharesPrivatePaykitEndpoints = true, - publicPaykitLightningEnabled = false, - publicPaykitOnchainEnabled = true, - ) - whenever { addressReservationRepo.currentOrRotatedAddress(OTHER_CONTACT_KEY, WALLET_RECEIVER_PATH) } - .thenReturn(Result.success(OTHER_PRIVATE_ADDRESS)) - whenever { paykitSdkService.privateReceiverPathSelection(eq(CONTACT_KEY), any(), any()) } - .thenReturn( - privateReceiverPathSelection( - publishableReceiverPaths = emptyList(), - cleanupProtectedReceiverPaths = listOf(WALLET_RECEIVER_PATH), - error = PrivatePaykitTestAppError("marker unavailable"), - ), - ) - whenever { paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any()) }.thenAnswer { - privateListDeliveryReportForUpdates(it.getArgument(0)) + fun `invalidated link preparation cannot release new preparation`() = test { + val oldCompletion = CompletableDeferred() + val newCompletion = CompletableDeferred() + var advances = 0 + whenever(paykitSdkService.ensureLinkWithPeer(any(), any(), eq(Priority.Ordered))).doSuspendableAnswer { + advances += 1 + when (advances) { + 1 -> oldCompletion.await() + 2 -> newCompletion.await() + } + LinkedPeerHandshakeReport(CONTACT_KEY, LinkedPeerState.LINKED, 1uL, null) + } + val old = async { sut.prepareSavedContacts(listOf(CONTACT_KEY)) } + runCurrent() + assertEquals(1, advances) + sut.beginProfileDeletion() + sut.endProfileDeletion() + val current = async { sut.prepareSavedContacts(listOf(CONTACT_KEY)) } + try { + runCurrent() + assertEquals(2, advances) + oldCompletion.complete(Unit) + old.await().getOrThrow() + sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() + assertEquals(2, advances) + newCompletion.complete(Unit) + current.await().getOrThrow() + } finally { + old.cancel() + current.cancel() + sut.closeAndClear() } + } - val result = sut.prepareSavedContacts( - listOf(CONTACT_KEY, OTHER_CONTACT_KEY), - requireImmediatePublication = true, + @Test + fun `private message drain and retries do not wait for linked peer advancement`() = test { + val keys = listOf(CONTACT_KEY, OTHER_CONTACT_KEY) + whenever(paykitSdkService.linkedPeers()).thenReturn( + keys.map { linkedPeer(it.removePrefix("pubky"), LinkedPeerState.LINKED) }, ) + whenever(paykitSdkService.pendingOutboundPrivateCounterparties()).thenReturn(keys) + whenever(paykitSdkService.ensureLinkWithPeer(any(), any(), eq(Priority.Ordered))).doSuspendableAnswer { + awaitCancellation() + } - assertTrue(result.isSuccess, result.exceptionOrNull().toString()) - val captor = argumentCaptor>() - verifyBlocking(paykitSdkService) { syncPrivatePaymentListsWithReservations(captor.capture(), eq(false)) } - assertEquals(listOf(OTHER_CONTACT_KEY), captor.firstValue.map { it.counterparty }) + val preparation = async { sut.prepareSavedContacts(keys) } + try { + runCurrent() + assertTrue(preparation.isCompleted) + preparation.await().getOrThrow() + advanceTimeBy(1_000) + runCurrent() + + verify(paykitSdkService, never()).ensureLinkWithPeer(any(), any(), any()) + keys.forEach { + verify(paykitSdkService, times(2)).processOutboundPrivateMessages(it) + verify(paykitSdkService, times(2)).receivePrivateMessages(it) + } + } finally { + preparation.cancel() + sut.closeAndClear() + } } @Test - fun `prepareSavedContacts succeeds when link preparation fails but SDK queues reservations`() = test { - settingsData.value = SettingsData( - sharesPrivatePaykitEndpoints = true, - publicPaykitLightningEnabled = false, - publicPaykitOnchainEnabled = true, - ) - whenever { paykitSdkService.ensureLinkWithPeer(CONTACT_KEY, WALLET_RECEIVER_PATH) }.thenAnswer { - throw PrivatePaykitTestAppError("still linking") + fun `private message drain advances recovery detected during send on next retry`() = test { + var state = LinkedPeerState.LINKED + var sends = 0 + whenever(paykitSdkService.linkedPeers()).thenAnswer { listOf(linkedPeer(CONTACT_KEY, state)) } + whenever(paykitSdkService.pendingOutboundPrivateCounterparties()).thenReturn(listOf(CONTACT_KEY)) + whenever(paykitSdkService.ensureLinkWithPeer(CONTACT_KEY)).thenAnswer { + assertEquals(LinkedPeerState.RECOVERY_REQUIRED, state) + state = LinkedPeerState.LINKED + LinkedPeerHandshakeReport(CONTACT_KEY, state, 1uL, null) + } + whenever(paykitSdkService.processOutboundPrivateMessages(CONTACT_KEY)).thenAnswer { + sends += 1 + if (sends == 1) { + state = LinkedPeerState.RECOVERY_REQUIRED + throw PaykitException.Transport("recovery", "Peer requires recovery") + } + mock() } - val result = sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) - - assertTrue(result.isSuccess, result.exceptionOrNull().toString()) - verifyBlocking(paykitSdkService) { syncPrivatePaymentListsWithReservations(any(), eq(false)) } - assertEquals( - setOf(WALLET_RECEIVER_PATH), - cacheData.value.contacts.getValue(CONTACT_KEY).publishedPrivatePaymentReceiverPaths, - ) + try { + sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() + verify(paykitSdkService, never()).ensureLinkWithPeer(any(), any(), any()) + verify(paykitSdkService, never()).receivePrivateMessages(any()) + advanceTimeBy(1_000) + runCurrent() + + verify(paykitSdkService).ensureLinkWithPeer(CONTACT_KEY) + verify(paykitSdkService, times(2)).processOutboundPrivateMessages(CONTACT_KEY) + verify(paykitSdkService).receivePrivateMessages(CONTACT_KEY) + } finally { + sut.closeAndClear() + } } @Test - fun `prepareSavedContacts reads linked peers once for multiple contacts`() = test { - settingsData.value = SettingsData( - sharesPrivatePaykitEndpoints = true, - publicPaykitLightningEnabled = false, - publicPaykitOnchainEnabled = true, - ) - whenever { paykitSdkService.privateReceiverPathSelection(any(), any(), any()) }.thenReturn( - privateReceiverPathSelection( - publishableReceiverPaths = emptyList(), - linkableReceiverPaths = emptyList(), - ), - ) + fun `private message drain isolates send and receive failures per peer`() = test { + val keys = listOf(CONTACT_KEY, OTHER_CONTACT_KEY) + whenever(paykitSdkService.linkedPeers()).thenReturn(keys.map { linkedPeer(it, LinkedPeerState.LINKED) }) + whenever(paykitSdkService.pendingOutboundPrivateCounterparties()).thenReturn(keys) + val failure = PaykitException.Transport("offline", "Unavailable homeserver") + whenever(paykitSdkService.processOutboundPrivateMessages(CONTACT_KEY)).thenAnswer { throw failure } + whenever(paykitSdkService.receivePrivateMessages(CONTACT_KEY)).thenAnswer { throw failure } - val result = sut.prepareSavedContacts(listOf(CONTACT_KEY, OTHER_CONTACT_KEY)) + sut.prepareSavedContacts(keys).getOrThrow() - assertTrue(result.isSuccess, result.exceptionOrNull().toString()) - verifyBlocking(paykitSdkService, times(1)) { linkedPeers() } + verify(paykitSdkService).processOutboundPrivateMessages(OTHER_CONTACT_KEY) + verify(paykitSdkService).receivePrivateMessages(OTHER_CONTACT_KEY) + verify(paykitSdkService, never()).processPendingPrivateMessages() + verify(paykitSdkService, never()).receivePrivateMessagesFromLinkedPeers() + sut.closeAndClear() } @Test - fun `private message drain keeps retrying while link is still pending`() = test { - settingsData.value = SettingsData( - sharesPrivatePaykitEndpoints = true, - publicPaykitLightningEnabled = false, - publicPaykitOnchainEnabled = true, - ) - whenever { paykitSdkService.linkedPeers() } - .thenReturn(listOf(linkedPeer(CONTACT_KEY, LinkedPeerState.LINKING))) - - val result = sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) + fun `private message drain stops after cancellation or preparation invalidation`() = test { + val keys = listOf(CONTACT_KEY, OTHER_CONTACT_KEY) + whenever(paykitSdkService.linkedPeers()).thenReturn(keys.map { linkedPeer(it, LinkedPeerState.LINKED) }) + whenever(paykitSdkService.pendingOutboundPrivateCounterparties()).thenReturn(keys) + for (cancel in listOf(false, true)) { + val releaseSend = CompletableDeferred() + whenever(paykitSdkService.processOutboundPrivateMessages(CONTACT_KEY)).doSuspendableAnswer { + releaseSend.await() + mock() + } + val preparation = async { sut.prepareSavedContacts(keys) } + runCurrent() + verify(paykitSdkService).processOutboundPrivateMessages(CONTACT_KEY) + if (cancel) preparation.cancel() else sut.closeAndClear().getOrThrow() + releaseSend.complete(Unit) + if (cancel) { + assertFailsWith { preparation.await() } + } else { + preparation.await().getOrThrow() + } + verify(paykitSdkService, never()).processOutboundPrivateMessages(OTHER_CONTACT_KEY) + verify(paykitSdkService, never()).receivePrivateMessages(any()) + sut.closeAndClear().getOrThrow() + clearInvocations(paykitSdkService) + } + } - assertTrue(result.isSuccess, result.exceptionOrNull().toString()) - advanceTimeBy(257_000) - runCurrent() + @Test + fun `private message drain processes outbound queued during advancement without a linked peer`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) + whenever(paykitSdkService.linkedPeers()).thenReturn(listOf(linkedPeer(CONTACT_KEY, LinkedPeerState.LINKING))) + var pendingOutbound = emptyList() + whenever(paykitSdkService.pendingOutboundPrivateCounterparties()).thenAnswer { pendingOutbound } + whenever(paykitSdkService.ensureLinkWithPeer(CONTACT_KEY)).thenAnswer { + pendingOutbound = listOf(CONTACT_KEY) + LinkedPeerHandshakeReport(CONTACT_KEY, LinkedPeerState.LINKING, 1uL, null) + } - verifyBlocking(paykitSdkService, atLeast(8)) { ensureLinkWithPeer(CONTACT_KEY, WALLET_RECEIVER_PATH) } + sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() sut.closeAndClear() + + verify(paykitSdkService).processOutboundPrivateMessages(CONTACT_KEY) + verify(paykitSdkService, never()).receivePrivateMessages(any()) + verify(paykitSdkService, never()).receivePrivateMessagesFromLinkedPeers() } @Test @@ -658,56 +753,242 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { val captor = argumentCaptor>() verifyBlocking(paykitSdkService) { syncPrivatePaymentListsWithReservations(captor.capture(), eq(false)) } - val reservation = captor.firstValue.single().reservations.single() - assertEquals(MethodId.Bolt11.rawValue, reservation.identifier) - assertEquals(PublicPaykitRepo.serializePayload(PRIVATE_BOLT11), reservation.payload) - assertEquals("090909", reservation.attribution["payment_hash"]) + val reservation = captor.firstValue.single().reservations.single() + assertEquals(MethodId.Bolt11.rawValue, reservation.identifier) + assertEquals(PublicPaykitRepo.serializePayload(PRIVATE_BOLT11), reservation.payload) + assertEquals("090909", reservation.attribution["payment_hash"]) + } + + @Test + fun `publication skips contacts without Paykit and cleanup has nothing to withdraw`() = test { + settingsData.value = SettingsData( + sharesPrivatePaykitEndpoints = true, + publicPaykitLightningEnabled = false, + publicPaykitOnchainEnabled = true, + ) + whenever { paykitSdkService.ensureLinkWithPeer(CONTACT_KEY) } + .thenAnswer { throw PaykitException.NotFound("not_found", "No App Registry") } + + val publication = sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) + val cleanup = sut.disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) + + assertTrue(publication.isSuccess, publication.exceptionOrNull().toString()) + assertTrue(cleanup.isSuccess, cleanup.exceptionOrNull().toString()) + verifyBlocking(addressReservationRepo, never()) { currentOrRotatedAddress(any()) } + verifyBlocking(paykitSdkService, never()) { syncPrivatePaymentListsWithReservations(any(), any()) } + verifyBlocking(paykitSdkService, never()) { clearPrivatePaymentLists(any()) } + } + + @Test + fun `full cleanup discovers remote peers and retries failed discovery without local state`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) + val recoveringPublicKey = "pubky6rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + var failLookup = true + var linked = false + var canAdvance = false + val pendingOutbound = mutableSetOf(OTHER_CONTACT_KEY, recoveringPublicKey) + whenever(paykitSdkService.linkedPeers()).thenAnswer { + if (failLookup) throw AppError("Peer lookup unavailable") + listOf( + linkedPeer(CONTACT_KEY, LinkedPeerState.LINKED), + linkedPeer(OTHER_CONTACT_KEY, if (linked) LinkedPeerState.LINKED else LinkedPeerState.LINKING), + linkedPeer( + recoveringPublicKey, + if (linked) LinkedPeerState.LINKED else LinkedPeerState.RECOVERY_REQUIRED, + ), + ) + } + whenever(paykitSdkService.ensureLinkWithPeer(any(), any(), eq(Priority.Ordered))).thenAnswer { + linked = canAdvance + LinkedPeerHandshakeReport( + it.getArgument(0), + if (linked) LinkedPeerState.LINKED else LinkedPeerState.LINKING, + 1uL, + null, + ) + } + whenever(paykitSdkService.pendingOutboundPrivateCounterparties()).thenAnswer { + if (linked) pendingOutbound.toList() else emptyList() + } + whenever(paykitSdkService.processOutboundPrivateMessages(any())).thenAnswer { + pendingOutbound.remove(it.getArgument(0)) + OutboundPrivateSendReport(emptyList(), emptyList(), emptyList(), emptyList(), emptyList()) + } + + assertTrue(sut.disableSharingAndPruneUnsavedContactState(emptyList()).isFailure) + assertTrue(cacheData.value.contacts.isEmpty()) + assertTrue(cacheData.value.cleanupPending) + verify(paykitSdkService, never()).clearPrivatePaymentLists(any()) + + failLookup = false + assertTrue(sut.retryPendingEndpointRemoval(emptyList()).isFailure) + assertTrue(cacheData.value.cleanupPending) + verify(publicPaykitRepo, never()).syncPaykitApp() + + canAdvance = true + sut.retryPendingEndpointRemoval(emptyList()).getOrThrow() + + verify(paykitSdkService, times(2)).clearPrivatePaymentLists( + listOf(CONTACT_KEY, OTHER_CONTACT_KEY, recoveringPublicKey), + ) + verify(paykitSdkService, times(2)).ensureLinkWithPeer(OTHER_CONTACT_KEY) + verify(paykitSdkService, times(2)).ensureLinkWithPeer(recoveringPublicKey) + assertTrue(pendingOutbound.isEmpty()) + assertFalse(cacheData.value.cleanupPending) + verify(publicPaykitRepo).syncPaykitApp() + } + + @Test + fun `full cleanup preserves registry failures and retries with or without contacts`() = test { + val cleanups = listOf Result>( + { sut.disableSharingAndPruneUnsavedContactState(emptyList()) }, + { sut.retryPendingEndpointRemoval(emptyList()) }, + { sut.removePublishedEndpointsForCleanup("test") }, + ) + val failure = AppError("Registry unavailable") + for (cleanup in cleanups) { + for (hasContacts in listOf(false, true)) { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) + cacheData.value = PrivatePaykitCacheData( + contacts = if (hasContacts) mapOf(CONTACT_KEY to cachedPublishedContact()) else emptyMap(), + cleanupPending = true, + ) + whenever(paykitSdkService.linkedPeers()).thenReturn( + if (hasContacts) listOf(linkedPeer(CONTACT_KEY, LinkedPeerState.LINKED)) else emptyList(), + ) + whenever(publicPaykitRepo.syncPaykitApp()).thenReturn(Result.failure(failure), Result.success(Unit)) + clearInvocations(publicPaykitRepo) + sut = createSut() + + assertEquals(failure, cleanup().exceptionOrNull()) + assertTrue(cacheData.value.cleanupPending) + assertTrue(settingsData.value.publicPaykitCleanupPending) + verify(publicPaykitRepo).syncPaykitApp() + clearInvocations(publicPaykitRepo) + + cleanup().getOrThrow() + + assertFalse(cacheData.value.cleanupPending) + assertTrue(cacheData.value.contacts.isEmpty()) + verify(publicPaykitRepo).syncPaykitApp() + + clearInvocations(paykitSdkService, publicPaykitRepo) + sut.retryPendingEndpointRemoval(emptyList()).getOrThrow() + verifyNoInteractions(paykitSdkService, publicPaykitRepo) + } + } + } + + @Test + fun `cancelled registry synchronization leaves private cleanup pending`() = test { + cacheData.value = PrivatePaykitCacheData(contacts = mapOf(CONTACT_KEY to cachedPublishedContact())) + sut = createSut() + whenever(publicPaykitRepo.syncPaykitApp()).doSuspendableAnswer { awaitCancellation() } + + val cleanup = async { sut.disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) } + runCurrent() + try { + verify(publicPaykitRepo).syncPaykitApp() + assertTrue(cacheData.value.cleanupPending) + } finally { + cleanup.cancel() + } + + assertFailsWith { cleanup.await() } + assertTrue(cacheData.value.cleanupPending) + } + + @Test + fun `disabled cleanup uses existing capability and retries withdrawal and registry failures`() = test { + val publicRepo = PublicPaykitRepo( + ioDispatcher = testDispatcher, + pubkyRepo = mock(), + walletRepo = walletRepo, + lightningRepo = lightningRepo, + coreService = coreService, + paykitSdkService = paykitSdkService, + settingsStore = settingsStore, + clock = clock, + ) + for (failureStage in listOf("withdraw", "disable capability")) { + cacheData.value = PrivatePaykitCacheData( + contacts = mapOf(CONTACT_KEY to PrivatePaykitContactCacheData(hasPublishedPrivatePaymentList = true)), + ) + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) + sut = createSut(publicRepo) + var capability = true + var failed = false + var registryCalls = 0 + doSuspendableAnswer { + registryCalls++ + val enabled = it.getArgument(0) + assertFalse(enabled, "Cleanup must not enable private payments") + if (!failed && failureStage == "disable capability") { + failed = true + throw AppError("Registration unavailable") + } + capability = enabled + }.whenever(paykitSdkService).syncPaykitApp(any(), eq(Priority.Ordered)) + doSuspendableAnswer { + assertTrue(capability, "Withdrawal requires the private capability") + if (!failed && failureStage == "withdraw") { + failed = true + throw AppError("Delivery unavailable") + } + privateListDeliveryReport() + }.whenever(paykitSdkService).clearPrivatePaymentLists(listOf(CONTACT_KEY)) + + val result = sut.disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) + + assertTrue(result.isFailure, failureStage) + assertTrue(failed, failureStage) + assertTrue(cacheData.value.cleanupPending, failureStage) + assertTrue(capability, failureStage) + assertTrue(settingsData.value.publicPaykitCleanupPending, failureStage) + assertEquals(if (failureStage == "withdraw") 0 else 1, registryCalls) + + sut.retryPendingEndpointRemoval(listOf(CONTACT_KEY)).getOrThrow() + + assertFalse(capability, failureStage) + assertFalse(cacheData.value.cleanupPending, failureStage) + assertFalse(cacheData.value.contacts[CONTACT_KEY]?.hasPublishedPrivatePaymentList == true, failureStage) + assertEquals(if (failureStage == "withdraw") 1 else 2, registryCalls) + } + } + + @Test + fun `failed deleted contact cleanup does not enable private payments`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) + val failure = AppError("Peer lookup unavailable") + whenever(paykitSdkService.linkedPeers()).thenAnswer { throw failure } + + val result = sut.removeSavedContact(CONTACT_KEY) + + assertEquals(failure, result.exceptionOrNull()) + verifyBlocking(publicPaykitRepo, never()) { syncPaykitApp(anyOrNull()) } + assertTrue(settingsData.value.publicPaykitCleanupPending) + assertTrue(CONTACT_KEY in cacheData.value.deletedContactCleanupPendingPublicKeys) } @Test - fun `received wallet invoice rotation preserves server receiver invoice`() = test { - settingsData.value = SettingsData( - sharesPrivatePaykitEndpoints = true, - publicPaykitLightningEnabled = true, - publicPaykitOnchainEnabled = false, - ) - whenever(lightningRepo.canReceive()).thenReturn(true) - whenever { paykitSdkService.contactRecord(CONTACT_KEY) } - .thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH))) - whenever { - lightningRepo.createInvoice( - amountSats = null, - description = "", - expirySeconds = PRIVATE_BOLT11_EXPIRY_SECONDS, - ) - }.thenReturn( - Result.success(PRIVATE_BOLT11), - Result.success(SERVER_PRIVATE_BOLT11), - Result.success(ROTATED_PRIVATE_BOLT11), + fun `deleted contact cleanup retries registry update after withdrawal`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) + cacheData.value = PrivatePaykitCacheData(contacts = mapOf(CONTACT_KEY to cachedPublishedContact())) + sut = createSut() + whenever(publicPaykitRepo.syncPaykitApp()).thenReturn( + Result.failure(AppError("Registry unavailable")), + Result.success(Unit), ) - whenever(coreService.decode(PRIVATE_BOLT11)) - .thenReturn(Scanner.Lightning(lightningInvoice(PRIVATE_BOLT11, byteArrayOf(1, 1, 1)))) - whenever(coreService.decode(SERVER_PRIVATE_BOLT11)) - .thenReturn(Scanner.Lightning(lightningInvoice(SERVER_PRIVATE_BOLT11, byteArrayOf(2, 2, 2)))) - whenever(coreService.decode(ROTATED_PRIVATE_BOLT11)) - .thenReturn(Scanner.Lightning(lightningInvoice(ROTATED_PRIVATE_BOLT11, byteArrayOf(3, 3, 3)))) - sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true).getOrThrow() - val settledPayment = mock { - on { id } doReturn "010101" - on { kind } doReturn mock() - on { direction } doReturn PaymentDirection.INBOUND - on { status } doReturn PaymentStatus.SUCCEEDED - } - whenever(lightningRepo.getPayments()).thenReturn(Result.success(listOf(settledPayment))) + assertTrue(sut.removeSavedContact(CONTACT_KEY).isFailure) + assertTrue(settingsData.value.publicPaykitCleanupPending) + assertTrue(CONTACT_KEY in cacheData.value.deletedContactCleanupPendingPublicKeys) - val result = sut.handleReceivedPayment("010101") + sut.retryPendingEndpointRemoval(emptyList()).getOrThrow() - assertTrue(result.isSuccess, result.exceptionOrNull().toString()) - val invoices = cacheData.value.contacts.getValue(CONTACT_KEY).localInvoicesByReceiverPath - assertEquals(ROTATED_PRIVATE_BOLT11, invoices.getValue(WALLET_RECEIVER_PATH).bolt11) - assertEquals(SERVER_PRIVATE_BOLT11, invoices.getValue(SERVER_RECEIVER_PATH).bolt11) - sut.closeAndClear() + assertFalse(CONTACT_KEY in cacheData.value.deletedContactCleanupPendingPublicKeys) + verifyBlocking(publicPaykitRepo, never()) { syncPaykitApp(true) } + verifyBlocking(publicPaykitRepo, times(2)) { syncPaykitApp() } } @Test @@ -722,26 +1003,27 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { val result = sut.disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) assertTrue(result.isSuccess) - verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } + verifyBlocking(paykitSdkService) { clearPrivatePaymentLists(listOf(CONTACT_KEY)) } + verify(publicPaykitRepo).syncPaykitApp() assertTrue(cacheData.value.contacts.isEmpty()) } @Test - fun `disable sharing defers unavailable endpoint cleanup`() = test { + fun `disable sharing reports unavailable endpoint cleanup and retains retry state`() = test { settingsData.value = SettingsData( sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false, publicPaykitOnchainEnabled = true, ) sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true).getOrThrow() - whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) }.thenReturn( + settingsData.value = settingsData.value.copy(sharesPrivatePaykitEndpoints = false) + whenever { paykitSdkService.clearPrivatePaymentLists(listOf(CONTACT_KEY)) }.thenReturn( privateListDeliveryReport( failedToQueue = listOf( PrivatePaymentListSyncChange( counterparty = CONTACT_KEY, - counterpartyReceiverPath = WALLET_RECEIVER_PATH, outboundMessageId = null, - error = "failed", + error = mock(), ), ), ), @@ -749,8 +1031,10 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { val result = sut.disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) - assertTrue(result.isSuccess, result.exceptionOrNull().toString()) + assertEquals(PrivatePaykitError.PrivateUnavailable, result.exceptionOrNull()) assertTrue(cacheData.value.cleanupPending) + assertTrue(cacheData.value.contacts.getValue(CONTACT_KEY).hasPublishedPrivatePaymentList) + verifyBlocking(addressReservationRepo, never()) { clearContactAssignments(excludingPublicKeys = any()) } } @Test @@ -781,7 +1065,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.retryPendingEndpointRemoval(listOf(CONTACT_KEY)).getOrThrow() - verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } + verifyBlocking(paykitSdkService) { clearPrivatePaymentLists(listOf(CONTACT_KEY)) } assertFalse(cacheData.value.cleanupPending) } @@ -803,7 +1087,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(result.isFailure) assertTrue(cacheData.value.cleanupPending) assertTrue( - cacheData.value.contacts.values.all { it.publishedPrivatePaymentReceiverPaths.isEmpty() }, + cacheData.value.contacts.values.all { !it.hasPublishedPrivatePaymentList }, ) } @@ -815,14 +1099,13 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { publicPaykitOnchainEnabled = true, ) sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) - whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) }.thenReturn( + whenever { paykitSdkService.clearPrivatePaymentLists(listOf(CONTACT_KEY)) }.thenReturn( privateListDeliveryReport( failedToQueue = listOf( PrivatePaymentListSyncChange( counterparty = CONTACT_KEY, - counterpartyReceiverPath = WALLET_RECEIVER_PATH, outboundMessageId = null, - error = "failed", + error = mock(), ), ), ), @@ -842,123 +1125,27 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { publicPaykitOnchainEnabled = true, ) sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) - whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } + whenever { paykitSdkService.clearPrivatePaymentLists(listOf(CONTACT_KEY)) } .thenReturn(privateListDeliveryReport(clearedCounterparties = listOf(CONTACT_KEY))) - val pendingReceiver = mock() - whenever(pendingReceiver.counterparty).thenReturn(CONTACT_KEY) - whenever(pendingReceiver.counterpartyReceiverPath).thenReturn(WALLET_RECEIVER_PATH) whenever { paykitSdkService.pendingOutboundPrivateCounterparties() } - .thenReturn(listOf(pendingReceiver)) + .thenReturn(listOf(CONTACT_KEY)) val result = sut.removePublishedEndpointsForCleanup("test") assertTrue(result.isFailure) assertTrue(cacheData.value.cleanupPending) assertEquals( - setOf(WALLET_RECEIVER_PATH), - cacheData.value.contacts.getValue(CONTACT_KEY).publishedPrivatePaymentReceiverPaths, + true, + cacheData.value.contacts.getValue(CONTACT_KEY).hasPublishedPrivatePaymentList, ) sut.closeAndClear() } - @Test - fun `cleanup keeps publication state when any saved receiver cleanup fails`() = test { - settingsData.value = SettingsData( - sharesPrivatePaykitEndpoints = true, - publicPaykitLightningEnabled = false, - publicPaykitOnchainEnabled = true, - ) - whenever { paykitSdkService.contactRecord(CONTACT_KEY) } - .thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH))) - whenever { addressReservationRepo.currentOrRotatedAddress(CONTACT_KEY, SERVER_RECEIVER_PATH) } - .thenReturn(Result.success(OTHER_PRIVATE_ADDRESS)) - whenever { paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any()) }.thenAnswer { - privateListDeliveryReportForUpdates(it.getArgument(0)) - } - sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) - assertEquals( - setOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH), - cacheData.value.contacts.getValue(CONTACT_KEY).publishedPrivatePaymentReceiverPaths, - ) - whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } - .thenReturn(privateListDeliveryReport(clearedCounterparties = listOf(CONTACT_KEY))) - whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY, SERVER_RECEIVER_PATH) }.thenReturn( - privateListDeliveryReport( - failedToQueue = listOf( - PrivatePaymentListSyncChange( - counterparty = CONTACT_KEY, - counterpartyReceiverPath = SERVER_RECEIVER_PATH, - outboundMessageId = null, - error = "failed", - ), - ), - ), - ) - - val result = sut.removePublishedEndpointsForCleanup("test") - - assertTrue(result.isFailure) - assertEquals(true, cacheData.value.cleanupPending) - assertEquals( - setOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH), - cacheData.value.contacts.getValue(CONTACT_KEY).publishedPrivatePaymentReceiverPaths, - ) - } - - @Test - fun `cleanup keeps pending state when linked receiver inspection fails`() = test { - settingsData.value = SettingsData( - sharesPrivatePaykitEndpoints = true, - publicPaykitLightningEnabled = false, - publicPaykitOnchainEnabled = true, - ) - sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true).getOrThrow() - whenever { paykitSdkService.linkedPeers() } - .thenThrow(IllegalStateException("link inspection failed")) - - val result = sut.removePublishedEndpointsForCleanup("test") - - assertTrue(result.isFailure) - assertTrue(cacheData.value.cleanupPending) - verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } - assertEquals( - setOf(WALLET_RECEIVER_PATH), - cacheData.value.contacts.getValue(CONTACT_KEY).publishedPrivatePaymentReceiverPaths, - ) - } - - @Test - fun `cleanup retries linked receiver inspection once for the batch`() = test { - cacheData.value = PrivatePaykitCacheData( - contacts = mapOf( - CONTACT_KEY to cachedPublishedContact(WALLET_RECEIVER_PATH), - OTHER_CONTACT_KEY to cachedPublishedContact(SERVER_RECEIVER_PATH), - ), - ) - sut = createSut() - var linkedPeerReads = 0 - whenever { paykitSdkService.linkedPeers() }.thenAnswer { - linkedPeerReads += 1 - if (linkedPeerReads <= 2) error("link inspection failed") - emptyList() - } - - val result = sut.removePublishedEndpointsForCleanup("test") - - assertTrue(result.isFailure) - verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } - verifyBlocking(paykitSdkService) { clearPrivatePaymentList(OTHER_CONTACT_KEY, SERVER_RECEIVER_PATH) } - assertTrue(CONTACT_KEY in cacheData.value.contacts) - assertTrue(OTHER_CONTACT_KEY in cacheData.value.contacts) - assertTrue(cacheData.value.cleanupPending) - assertEquals(3, linkedPeerReads) - } - @Test fun `invalid deleted contact key is dropped from cleanup state`() = test { val invalidPublicKey = "not-a-pubky" cacheData.value = PrivatePaykitCacheData( - contacts = mapOf(invalidPublicKey to cachedPublishedContact(WALLET_RECEIVER_PATH)), + contacts = mapOf(invalidPublicKey to cachedPublishedContact()), deletedContactCleanupPendingPublicKeys = setOf(invalidPublicKey), ) sut = createSut() @@ -968,53 +1155,67 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(result.isSuccess, result.exceptionOrNull().toString()) assertTrue(cacheData.value.contacts.isEmpty()) assertTrue(cacheData.value.deletedContactCleanupPendingPublicKeys.isEmpty()) - verifyBlocking(paykitSdkService, never()) { clearPrivatePaymentList(any(), any()) } + verifyBlocking(paykitSdkService, never()) { clearPrivatePaymentLists(any()) } } @Test - fun `cleanup uses linked receiver paths when contact record is gone`() = test { - settingsData.value = SettingsData( - sharesPrivatePaykitEndpoints = true, - publicPaykitLightningEnabled = false, - publicPaykitOnchainEnabled = true, - ) - whenever { paykitSdkService.contactRecord(CONTACT_KEY) }.thenReturn(null) - whenever { paykitSdkService.linkedPeers() } - .thenReturn(listOf(linkedPeer(CONTACT_KEY, LinkedPeerState.LINKED))) - sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) + fun `cleanup skips the drain when cleared contacts have no pending work`() = test { + cacheData.value = PrivatePaykitCacheData(contacts = mapOf(CONTACT_KEY to cachedPublishedContact())) + sut = createSut() + whenever(paykitSdkService.linkedPeers()).thenReturn(listOf(linkedPeer(CONTACT_KEY, LinkedPeerState.LINKED))) + whenever(paykitSdkService.pendingOutboundPrivateCounterparties()).thenReturn(listOf(OTHER_CONTACT_KEY)) val result = sut.removePublishedEndpointsForCleanup("test") assertTrue(result.isSuccess, result.exceptionOrNull().toString()) - verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } - verifyBlocking(paykitSdkService, never()) { clearPrivatePaymentList(CONTACT_KEY, SERVER_RECEIVER_PATH) } + verify(paykitSdkService).clearPrivatePaymentLists(listOf(CONTACT_KEY)) + verify(publicPaykitRepo).syncPaykitApp() + verify(paykitSdkService, never()).ensureLinkWithPeer(any(), any(), any()) + verify(paykitSdkService, never()).processOutboundPrivateMessages(any()) + verify(paykitSdkService, never()).receivePrivateMessages(any()) + verify(paykitSdkService, never()).processPendingPrivateMessages() + verify(paykitSdkService, never()).receivePrivateMessagesFromLinkedPeers() + assertTrue(cacheData.value.contacts.isEmpty()) + assertFalse(cacheData.value.cleanupPending) } @Test - fun `cleanup drains all contacts in one batch`() = test { + fun `cleanup drains only pending peers using normalized SDK keys`() = test { cacheData.value = PrivatePaykitCacheData( contacts = mapOf( - CONTACT_KEY to cachedPublishedContact(WALLET_RECEIVER_PATH), - OTHER_CONTACT_KEY to cachedPublishedContact(SERVER_RECEIVER_PATH), + CONTACT_KEY to cachedPublishedContact(), + OTHER_CONTACT_KEY to cachedPublishedContact(), ), ) sut = createSut() - whenever { paykitSdkService.linkedPeers() }.thenReturn( + whenever(paykitSdkService.linkedPeers()).thenReturn( listOf( - linkedPeer(CONTACT_KEY, LinkedPeerState.LINKED), - linkedPeer(OTHER_CONTACT_KEY, LinkedPeerState.LINKED, SERVER_RECEIVER_PATH), + linkedPeer(CONTACT_KEY.removePrefix("pubky"), LinkedPeerState.LINKED), + linkedPeer(OTHER_CONTACT_KEY.removePrefix("pubky"), LinkedPeerState.LINKED), ), ) + sut.prepareSavedContacts(listOf(CONTACT_KEY, OTHER_CONTACT_KEY)).getOrThrow() + clearInvocations(paykitSdkService) + val pendingKeys = listOf(CONTACT_KEY.removePrefix("pubky")) + whenever(paykitSdkService.pendingOutboundPrivateCounterparties()) + .thenReturn(pendingKeys, pendingKeys, emptyList()) val result = sut.removePublishedEndpointsForCleanup("test") assertTrue(result.isSuccess, result.exceptionOrNull().toString()) - verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } - verifyBlocking(paykitSdkService) { clearPrivatePaymentList(OTHER_CONTACT_KEY, SERVER_RECEIVER_PATH) } - verifyBlocking(paykitSdkService, times(2)) { linkedPeers() } - verifyBlocking(paykitSdkService, times(1)) { pendingOutboundPrivateCounterparties() } - verifyBlocking(paykitSdkService, times(2)) { processPendingPrivateMessages() } - verifyBlocking(paykitSdkService, times(2)) { receivePrivateMessagesFromLinkedPeers() } + verifyBlocking(paykitSdkService) { clearPrivatePaymentLists(listOf(CONTACT_KEY, OTHER_CONTACT_KEY)) } + verify(paykitSdkService, never()).ensureLinkWithPeer(any(), any(), any()) + verifyBlocking(paykitSdkService, atLeast(1)) { linkedPeers() } + verifyBlocking(paykitSdkService, times(3)) { pendingOutboundPrivateCounterparties() } + verify(paykitSdkService, times(3)).pendingOutboundPrivateCounterparties(Priority.Ordered) + verify(paykitSdkService, never()).pendingOutboundPrivateCounterparties(Priority.Background) + verify(paykitSdkService, never()).linkedPeers(Priority.Background) + verify(paykitSdkService).processOutboundPrivateMessages(CONTACT_KEY) + verify(paykitSdkService).receivePrivateMessages(CONTACT_KEY) + verify(paykitSdkService, never()).processOutboundPrivateMessages(OTHER_CONTACT_KEY) + verify(paykitSdkService, never()).receivePrivateMessages(OTHER_CONTACT_KEY) + verify(paykitSdkService, never()).processPendingPrivateMessages() + verify(paykitSdkService, never()).receivePrivateMessagesFromLinkedPeers() assertTrue(cacheData.value.contacts.isEmpty()) } @@ -1022,8 +1223,8 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `deleted contact retry cleans all pending contacts in one batch`() = test { cacheData.value = PrivatePaykitCacheData( contacts = mapOf( - CONTACT_KEY to cachedPublishedContact(WALLET_RECEIVER_PATH), - OTHER_CONTACT_KEY to cachedPublishedContact(SERVER_RECEIVER_PATH), + CONTACT_KEY to cachedPublishedContact(), + OTHER_CONTACT_KEY to cachedPublishedContact(), ), deletedContactCleanupPendingPublicKeys = setOf(CONTACT_KEY, OTHER_CONTACT_KEY), ) @@ -1032,9 +1233,8 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { val result = sut.retryPendingEndpointRemoval(emptyList()) assertTrue(result.isSuccess, result.exceptionOrNull().toString()) - verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } - verifyBlocking(paykitSdkService) { clearPrivatePaymentList(OTHER_CONTACT_KEY, SERVER_RECEIVER_PATH) } - verifyBlocking(paykitSdkService, times(2)) { linkedPeers() } + verifyBlocking(paykitSdkService) { clearPrivatePaymentLists(listOf(CONTACT_KEY, OTHER_CONTACT_KEY)) } + verifyBlocking(paykitSdkService, atLeast(1)) { linkedPeers() } assertTrue(cacheData.value.contacts.isEmpty()) assertTrue(cacheData.value.deletedContactCleanupPendingPublicKeys.isEmpty()) } @@ -1043,13 +1243,12 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `cleanup retains the batch when drain inspection fails`() = test { cacheData.value = PrivatePaykitCacheData( contacts = mapOf( - CONTACT_KEY to cachedPublishedContact(WALLET_RECEIVER_PATH), - OTHER_CONTACT_KEY to cachedPublishedContact(SERVER_RECEIVER_PATH), + CONTACT_KEY to cachedPublishedContact(), + OTHER_CONTACT_KEY to cachedPublishedContact(), ), ) sut = createSut() whenever { paykitSdkService.linkedPeers() } - .thenReturn(emptyList()) .thenThrow(IllegalStateException("drain inspection failed")) val result = sut.removePublishedEndpointsForCleanup("test") @@ -1063,23 +1262,22 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `cleanup retains endpoint cache updated during remote removal`() = test { cacheData.value = PrivatePaykitCacheData( - contacts = mapOf(CONTACT_KEY to cachedPublishedContact(WALLET_RECEIVER_PATH)), + contacts = mapOf(CONTACT_KEY to cachedPublishedContact()), ) sut = createSut() val cleanupStarted = CompletableDeferred() val resumeCleanup = CompletableDeferred() - whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } + whenever { paykitSdkService.clearPrivatePaymentLists(listOf(CONTACT_KEY)) } .doSuspendableAnswer { cleanupStarted.complete(Unit) resumeCleanup.await() privateListDeliveryReport(clearedCounterparties = listOf(CONTACT_KEY)) } whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment( + paykitSdkService.prepareAndResolvePrivatePaymentRequest( eq(CONTACT_KEY), - eq(SERVER_RECEIVER_PATH), + eq("request-id"), eq(null), - any(), ) }.thenReturn(resolution(resolvedEndpoint(MethodId.P2wpkh, PRIVATE_ADDRESS), version = 7uL)) whenever(coreService.isAddressUsed(PRIVATE_ADDRESS)).thenReturn(false) @@ -1098,32 +1296,33 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `cleanup isolates a failed contact while clearing successful contacts`() = test { - cacheData.value = PrivatePaykitCacheData( - contacts = mapOf( - CONTACT_KEY to cachedPublishedContact(WALLET_RECEIVER_PATH), - OTHER_CONTACT_KEY to cachedPublishedContact(SERVER_RECEIVER_PATH), - ), - ) - sut = createSut() - whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) }.thenReturn( - privateListDeliveryReport( - failedToQueue = listOf( - PrivatePaymentListSyncChange( - counterparty = CONTACT_KEY, - counterpartyReceiverPath = WALLET_RECEIVER_PATH, - outboundMessageId = null, - error = "failed", - ), + for (failQueue in listOf(true, false)) { + cacheData.value = PrivatePaykitCacheData( + contacts = mapOf( + CONTACT_KEY to cachedPublishedContact(), + OTHER_CONTACT_KEY to cachedPublishedContact(), ), - ), - ) + ) + sut = createSut() + whenever(paykitSdkService.clearPrivatePaymentLists(listOf(CONTACT_KEY, OTHER_CONTACT_KEY))).thenReturn( + privateListDeliveryReport( + clearedCounterparties = listOf(OTHER_CONTACT_KEY), + failedToQueue = if (failQueue) listOf(privateListSyncChange(CONTACT_KEY)) else emptyList(), + failedToDeliver = if (failQueue) { + emptyList() + } else { + listOf(PrivatePaymentListDeliveryFailure(CONTACT_KEY.removePrefix("pubky"), null, null, mock())) + }, + ), + ) - val result = sut.removePublishedEndpointsForCleanup("test") + val result = sut.removePublishedEndpointsForCleanup("test") - assertTrue(result.isFailure) - assertTrue(CONTACT_KEY in cacheData.value.contacts) - assertTrue(OTHER_CONTACT_KEY !in cacheData.value.contacts) - assertTrue(cacheData.value.cleanupPending) + assertTrue(result.isFailure) + assertTrue(CONTACT_KEY in cacheData.value.contacts) + assertTrue(OTHER_CONTACT_KEY !in cacheData.value.contacts) + assertTrue(cacheData.value.cleanupPending) + } } @Test @@ -1133,9 +1332,9 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { publicPaykitLightningEnabled = false, publicPaykitOnchainEnabled = true, ) - whenever { addressReservationRepo.currentOrRotatedAddress(CONTACT_KEY, WALLET_RECEIVER_PATH) } + whenever { addressReservationRepo.currentOrRotatedAddress(CONTACT_KEY) } .thenReturn(Result.failure(PrivatePaykitTestAppError("address unavailable"))) - whenever { addressReservationRepo.currentOrRotatedAddress(OTHER_CONTACT_KEY, WALLET_RECEIVER_PATH) } + whenever { addressReservationRepo.currentOrRotatedAddress(OTHER_CONTACT_KEY) } .thenReturn(Result.success(OTHER_PRIVATE_ADDRESS)) whenever { paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any()) }.thenReturn( privateListDeliveryReport(queuedCounterparties = listOf(OTHER_CONTACT_KEY)), @@ -1145,13 +1344,13 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(result.isSuccess) assertEquals( - setOf(WALLET_RECEIVER_PATH), - cacheData.value.contacts.getValue(OTHER_CONTACT_KEY).publishedPrivatePaymentReceiverPaths, + true, + cacheData.value.contacts.getValue(OTHER_CONTACT_KEY).hasPublishedPrivatePaymentList, ) } @Test - fun `prepareSavedContacts continues when another contact record cannot be read`() = test { + fun `prepareSavedContacts does not require a cached contact record`() = test { settingsData.value = SettingsData( sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false, @@ -1159,7 +1358,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) whenever { paykitSdkService.contactRecord(CONTACT_KEY) } .thenThrow(IllegalStateException("contact unavailable")) - whenever { addressReservationRepo.currentOrRotatedAddress(OTHER_CONTACT_KEY, WALLET_RECEIVER_PATH) } + whenever { addressReservationRepo.currentOrRotatedAddress(OTHER_CONTACT_KEY) } .thenReturn(Result.success(OTHER_PRIVATE_ADDRESS)) whenever { paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any()) }.thenAnswer { privateListDeliveryReportForUpdates(it.getArgument(0)) @@ -1170,11 +1369,11 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(result.isSuccess) val captor = argumentCaptor>() verifyBlocking(paykitSdkService) { syncPrivatePaymentListsWithReservations(captor.capture(), eq(false)) } - assertEquals(listOf(OTHER_CONTACT_KEY), captor.firstValue.map { it.counterparty }) + assertEquals(listOf(CONTACT_KEY, OTHER_CONTACT_KEY), captor.firstValue.map { it.counterparty }) } @Test - fun `prepareSavedContacts preserves cleanup markers while saving publication state`() = test { + fun `prepareSavedContacts does not publish while cleanup is pending`() = test { settingsData.value = SettingsData( sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false, @@ -1187,9 +1386,168 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { val result = sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) - assertTrue(result.isSuccess) + assertTrue(result.isFailure) assertEquals(true, cacheData.value.cleanupPending) assertEquals(setOf(OTHER_CONTACT_KEY), cacheData.value.deletedContactCleanupPendingPublicKeys) + verify(paykitSdkService, never()).syncPrivatePaymentListsWithReservations(any(), any()) + } + + @Test + fun `enabling returns before contact linking and repeated preparation is coalesced`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false) + val linkStarted = CompletableDeferred() + val resumeLink = CompletableDeferred() + whenever(paykitSdkService.ensureLinkWithPeer(CONTACT_KEY)) doSuspendableAnswer { + linkStarted.complete(Unit) + resumeLink.await() + LinkedPeerHandshakeReport(CONTACT_KEY, LinkedPeerState.LINKED, 1uL, null) + } + + assertTrue(sut.enableSharingAndPrepareSavedContacts(listOf(CONTACT_KEY)).isSuccess) + runCurrent() + assertTrue(linkStarted.isCompleted) + repeat(3) { sut.scheduleSavedContactPreparation(listOf(CONTACT_KEY)).getOrThrow() } + val preparation = async { sut.awaitContactPreparation() } + runCurrent() + assertFalse(preparation.isCompleted) + resumeLink.complete(Unit) + runCurrent() + preparation.await() + + verify(paykitSdkService).ensureLinkWithPeer(CONTACT_KEY) + verify(paykitSdkService).syncPrivatePaymentListsWithReservations(any(), eq(false)) + sut.closeAndClear() + } + + @Test + fun `profile deletion stops preparation and avoids repeated withdrawal`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false) + sut.beginProfileDeletion() + sut.scheduleSavedContactPreparation(listOf(CONTACT_KEY, OTHER_CONTACT_KEY)).getOrThrow() + runCurrent() + verify(paykitSdkService, never()).ensureLinkWithPeer(any(), any(), any()) + + sut.removeSavedContacts(listOf(CONTACT_KEY, OTHER_CONTACT_KEY)).getOrThrow() + verify(paykitSdkService, never()).clearPrivatePaymentLists(any()) + verify(addressReservationRepo).removeContactAssignments(setOf(CONTACT_KEY, OTHER_CONTACT_KEY)) + + sut.endProfileDeletion() + sut.scheduleSavedContactPreparation(listOf(CONTACT_KEY)).getOrThrow() + runCurrent() + verify(paykitSdkService).ensureLinkWithPeer(CONTACT_KEY) + sut.closeAndClear() + } + + @Test + fun `cleanup stops a stalled preparation before later contacts are visited`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false) + val linkStarted = CompletableDeferred() + val resumeLink = CompletableDeferred() + whenever(paykitSdkService.ensureLinkWithPeer(CONTACT_KEY)) doSuspendableAnswer { + linkStarted.complete(Unit) + resumeLink.await() + LinkedPeerHandshakeReport(CONTACT_KEY, LinkedPeerState.LINKED, 1uL, null) + } + val publication = async { + sut.prepareSavedContacts(listOf(CONTACT_KEY, OTHER_CONTACT_KEY), requireImmediatePublication = true) + } + runCurrent() + assertTrue(linkStarted.isCompleted) + + sut.removePublishedEndpointsForCleanup("test").getOrThrow() + resumeLink.complete(Unit) + runCurrent() + + assertTrue(publication.await().isFailure) + verify(paykitSdkService, never()).ensureLinkWithPeer(OTHER_CONTACT_KEY) + verify(paykitSdkService, never()).syncPrivatePaymentListsWithReservations(any(), any()) + sut.closeAndClear() + } + + @Test + fun `preparation drops contacts removed while linking`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false) + val resumeLink = CompletableDeferred() + whenever(paykitSdkService.ensureLinkWithPeer(CONTACT_KEY)) doSuspendableAnswer { + resumeLink.await() + LinkedPeerHandshakeReport(CONTACT_KEY, LinkedPeerState.LINKED, 1uL, null) + } + sut.scheduleSavedContactPreparation(listOf(CONTACT_KEY)).getOrThrow() + runCurrent() + sut.removeSavedContact(CONTACT_KEY).getOrThrow() + resumeLink.complete(Unit) + runCurrent() + + verify(addressReservationRepo, never()).currentOrRotatedAddress(CONTACT_KEY) + verify(paykitSdkService, never()).syncPrivatePaymentListsWithReservations(any(), any()) + sut.closeAndClear() + } + + @Test + fun `unavailable contacts are retried after a cooldown`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false) + whenever(paykitSdkService.ensureLinkWithPeer(CONTACT_KEY)) + .thenAnswer { throw PaykitException.NotFound("not_found", "No App Registry") } + repeat(3) { sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() } + verify(paykitSdkService).ensureLinkWithPeer(CONTACT_KEY) + + whenever(clock.now()).thenReturn(Instant.fromEpochSeconds(NOW_SECONDS + 301)) + sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() + verify(paykitSdkService, times(2)).ensureLinkWithPeer(CONTACT_KEY) + sut.closeAndClear() + } + + @Test + fun `transport failures only defer contacts without a handshake after advancement`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false) + var state: LinkedPeerState? = null + var nextState: LinkedPeerState? = null + whenever(paykitSdkService.linkedPeers()).thenAnswer { + state?.let { listOf(linkedPeer(CONTACT_KEY, it)) } ?: emptyList() + } + whenever(paykitSdkService.ensureLinkWithPeer(CONTACT_KEY)).thenAnswer { + state = nextState + throw PaykitException.Transport("offline", "Unavailable homeserver") + } + val states = listOf( + null, + LinkedPeerState.NOT_LINKED, + LinkedPeerState.LINKING, + LinkedPeerState.RECOVERY_REQUIRED, + ) + for (stateAfterFailure in states) { + sut = createSut() + state = null + nextState = null + + sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() + nextState = stateAfterFailure + sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true).getOrThrow() + advanceTimeBy(1_000) + runCurrent() + sut.closeAndClear() + + val hasNoHandshake = stateAfterFailure == null || stateAfterFailure == LinkedPeerState.NOT_LINKED + val expectedAttempts = if (hasNoHandshake) 2 else 3 + verify(paykitSdkService, times(expectedAttempts)).ensureLinkWithPeer(CONTACT_KEY) + clearInvocations(paykitSdkService) + } + } + + @Test + fun `removed contacts do not retain unavailable link cooldown`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false) + whenever(paykitSdkService.ensureLinkWithPeer(CONTACT_KEY)) + .thenAnswer { throw PaykitException.NotFound("not_found", "No App Registry") } + + sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() + sut.removeSavedContact(CONTACT_KEY).getOrThrow() + sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() + sut.prepareSavedContacts(emptyList()).getOrThrow() + sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() + sut.closeAndClear() + + verify(paykitSdkService, times(3)).ensureLinkWithPeer(CONTACT_KEY) } @Test @@ -1205,7 +1563,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `beginSavedContactPayment uses public resolution while Noise link is not established`() = test { sut.prepareSavedContacts(listOf(CONTACT_KEY)) whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, null) }.thenReturn( resolution( status = PrivatePaymentResolutionStatus.NO_ENDPOINT, @@ -1226,7 +1584,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.prepareSavedContacts(listOf(CONTACT_KEY)) whenever(paykitSdkService.hasPrivatePaymentAccess()).thenReturn(false) whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, null) }.thenReturn(resolution(resolvedEndpoint(MethodId.Bolt11, PRIVATE_BOLT11), version = 7uL)) whenever(coreService.decode(PRIVATE_BOLT11)) .thenReturn(Scanner.Lightning(lightningInvoice(PRIVATE_BOLT11, byteArrayOf(9, 9, 9)))) @@ -1236,7 +1594,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals( PublicPaykitPaymentResult.Opened( paymentRequest = PRIVATE_BOLT11, - privatePaymentContext = PrivatePaykitPaymentContext(WALLET_RECEIVER_PATH, 7uL), + privatePaymentContext = PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to "bitkit"), 7uL), ), result, ) @@ -1244,13 +1602,15 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `beginSavedContactPayment does not wait for the local endpoint publish`() = test { + fun `beginSavedContactPayment resolves before starting local endpoint publication`() = test { settingsData.value = SettingsData( sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false, publicPaykitOnchainEnabled = true, ) sut.prepareSavedContacts(listOf(CONTACT_KEY)) + clearInvocations(paykitSdkService) + val releaseResolution = CompletableDeferred() val publishStarted = CompletableDeferred() val stalledPublish = CompletableDeferred() whenever { paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any()) }.doSuspendableAnswer { @@ -1258,43 +1618,24 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { stalledPublish.await() privateListDeliveryReport(queuedCounterparties = listOf(CONTACT_KEY)) } - whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) - }.thenReturn(resolution(resolvedEndpoint(MethodId.Bolt11, PRIVATE_BOLT11), version = 7uL)) + whenever(paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, null)).doSuspendableAnswer { + releaseResolution.await() + resolution(resolvedEndpoint(MethodId.Bolt11, PRIVATE_BOLT11), version = 7uL) + } whenever(coreService.decode(PRIVATE_BOLT11)) .thenReturn(Scanner.Lightning(lightningInvoice(PRIVATE_BOLT11, byteArrayOf(9, 9, 9)))) - val result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() + val payment = async { sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() } + runCurrent() + verify(paykitSdkService, never()).syncPrivatePaymentListsWithReservations(any(), any()) + releaseResolution.complete(Unit) + val result = payment.await() assertIs(result) publishStarted.await() stalledPublish.complete(Unit) } - @Test - fun `beginSavedContactPayment reads the paid contact's receivers on the interactive lane`() = test { - settingsData.value = SettingsData( - sharesPrivatePaykitEndpoints = true, - publicPaykitLightningEnabled = false, - publicPaykitOnchainEnabled = true, - ) - sut.prepareSavedContacts(listOf(CONTACT_KEY)) - clearInvocations(paykitSdkService, pubkyService) - whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) - }.thenReturn(resolution(resolvedEndpoint(MethodId.Bolt11, PRIVATE_BOLT11), version = 7uL)) - whenever(coreService.decode(PRIVATE_BOLT11)) - .thenReturn(Scanner.Lightning(lightningInvoice(PRIVATE_BOLT11, byteArrayOf(9, 9, 9)))) - - sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() - advanceUntilIdle() - - verifyBlocking(pubkyService) { discoverRelevantReceiverPaths(CONTACT_KEY, PaykitReadLane.Interactive) } - verifyBlocking(paykitSdkService) { - privateReceiverPathSelection(eq(CONTACT_KEY), any(), eq(PaykitReadLane.Interactive)) - } - } - @Test fun `beginSavedContactPayment runs one endpoint publish per contact at a time`() = test { settingsData.value = SettingsData( @@ -1313,7 +1654,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { privateListDeliveryReport(queuedCounterparties = listOf(CONTACT_KEY)) } whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, null) }.thenReturn(resolution(resolvedEndpoint(MethodId.Bolt11, PRIVATE_BOLT11), version = 7uL)) whenever(coreService.decode(PRIVATE_BOLT11)) .thenReturn(Scanner.Lightning(lightningInvoice(PRIVATE_BOLT11, byteArrayOf(9, 9, 9)))) @@ -1332,7 +1673,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `beginSavedContactPayment opens private endpoint with its list version`() = test { sut.prepareSavedContacts(listOf(CONTACT_KEY)) whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, null) }.thenReturn(resolution(resolvedEndpoint(MethodId.Bolt11, PRIVATE_BOLT11), version = 7uL)) whenever(coreService.decode(PRIVATE_BOLT11)) .thenReturn(Scanner.Lightning(lightningInvoice(PRIVATE_BOLT11, byteArrayOf(9, 9, 9)))) @@ -1342,7 +1683,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals( PublicPaykitPaymentResult.Opened( paymentRequest = PRIVATE_BOLT11, - privatePaymentContext = PrivatePaykitPaymentContext(WALLET_RECEIVER_PATH, 7uL), + privatePaymentContext = PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to "bitkit"), 7uL), ), result, ) @@ -1353,7 +1694,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `beginSavedContactPayment never falls back while linked recovery is pending`() = test { sut.prepareSavedContacts(listOf(CONTACT_KEY)) whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, null) }.thenReturn( resolution( status = PrivatePaymentResolutionStatus.NO_ENDPOINT, @@ -1373,11 +1714,10 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `beginPaymentRequest waits for a linking peer before opening cached details`() = test { val request = paymentRequest() whenever( - paykitSdkService.prepareAndResolvePrivateContactPayment( + paykitSdkService.prepareAndResolvePrivatePaymentRequest( eq(CONTACT_KEY), - eq(SERVER_RECEIVER_PATH), + eq("request-id"), eq(null), - any(), ) ).thenReturn( resolution( @@ -1401,7 +1741,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals( PublicPaykitPaymentResult.Opened( paymentRequest = SERVER_PRIVATE_BOLT11, - privatePaymentContext = PrivatePaykitPaymentContext(SERVER_RECEIVER_PATH, 7uL), + privatePaymentContext = PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to "bitkit"), 7uL), ), opened, ) @@ -1412,11 +1752,10 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `beginPaymentRequest rejects cached details when the peer is not linked`() = test { val request = paymentRequest() whenever( - paykitSdkService.prepareAndResolvePrivateContactPayment( + paykitSdkService.prepareAndResolvePrivatePaymentRequest( eq(CONTACT_KEY), - eq(SERVER_RECEIVER_PATH), + eq("request-id"), eq(null), - any(), ) ).thenReturn( resolution( @@ -1437,11 +1776,10 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `beginPaymentRequest keeps typed recovery failures pending`() = test { val request = paymentRequest() whenever( - paykitSdkService.prepareAndResolvePrivateContactPayment( + paykitSdkService.prepareAndResolvePrivatePaymentRequest( eq(CONTACT_KEY), - eq(SERVER_RECEIVER_PATH), + eq("request-id"), eq(null), - any(), ) ).doSuspendableAnswer { throw PaykitException.RecoveryRequired("recovery_required", "Handshake is in progress") @@ -1457,7 +1795,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `beginSavedContactPayment retries a newer private list without public fallback`() = test { sut.prepareSavedContacts(listOf(CONTACT_KEY)) whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, null) }.thenReturn( resolution( status = PrivatePaymentResolutionStatus.WAITING_FOR_UPDATED_PAYMENT_LIST, @@ -1475,12 +1813,12 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals( PublicPaykitPaymentResult.Opened( paymentRequest = PRIVATE_BOLT11, - privatePaymentContext = PrivatePaykitPaymentContext(WALLET_RECEIVER_PATH, 7uL), + privatePaymentContext = PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to "bitkit"), 7uL), ), result, ) verifyBlocking(paykitSdkService, times(2)) { - prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) + prepareAndResolvePrivateContactPayment(CONTACT_KEY, null) } verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } } @@ -1489,7 +1827,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `beginSavedContactPayment only falls back after failure when Noise link is absent`() = test { sut.prepareSavedContacts(listOf(CONTACT_KEY)) whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, null) }.thenThrow(IllegalStateException("private unavailable")) val result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() @@ -1502,7 +1840,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `beginSavedContactPayment propagates failure when Noise link exists`() = test { sut.prepareSavedContacts(listOf(CONTACT_KEY)) whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, null) }.thenThrow(IllegalStateException("private unavailable")) whenever(paykitSdkService.linkedPeers()) .thenReturn(listOf(linkedPeer(CONTACT_KEY, LinkedPeerState.LINKED))) @@ -1515,14 +1853,14 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `consumePrivatePaymentList persists version clears list and rejects reuse`() = test { - val context = PrivatePaykitPaymentContext(WALLET_RECEIVER_PATH, 7uL) + val context = PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to "bitkit"), 7uL) sut.consumePrivatePaymentList(CONTACT_KEY, context).getOrThrow() assertEquals( 7uL, cacheData.value.contacts.getValue(CONTACT_KEY) - .consumedPrivatePaymentListVersionsByReceiverPath[WALLET_RECEIVER_PATH], + .consumedPrivatePaymentListVersion, ) assertFailsWith { sut.consumePrivatePaymentList(CONTACT_KEY, context).getOrThrow() @@ -1531,16 +1869,15 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `releasePrivatePaymentList makes matching version reusable without clearing newer consumption`() = test { - val releasedContext = PrivatePaykitPaymentContext(WALLET_RECEIVER_PATH, 7uL) - val newerContext = PrivatePaykitPaymentContext(WALLET_RECEIVER_PATH, 8uL) + val releasedContext = PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to "bitkit"), 7uL) + val newerContext = PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to "bitkit"), 8uL) sut.consumePrivatePaymentList(CONTACT_KEY, releasedContext).getOrThrow() sut.releasePrivatePaymentList(CONTACT_KEY, releasedContext).getOrThrow() assertNull( cacheData.value.contacts[CONTACT_KEY] - ?.consumedPrivatePaymentListVersionsByReceiverPath - ?.get(WALLET_RECEIVER_PATH), + ?.consumedPrivatePaymentListVersion, ) sut.consumePrivatePaymentList(CONTACT_KEY, releasedContext).getOrThrow() sut.consumePrivatePaymentList(CONTACT_KEY, newerContext).getOrThrow() @@ -1550,7 +1887,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals( 8uL, cacheData.value.contacts.getValue(CONTACT_KEY) - .consumedPrivatePaymentListVersionsByReceiverPath[WALLET_RECEIVER_PATH], + .consumedPrivatePaymentListVersion, ) } @@ -1559,10 +1896,10 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.prepareSavedContacts(listOf(CONTACT_KEY)) sut.consumePrivatePaymentList( CONTACT_KEY, - PrivatePaykitPaymentContext(WALLET_RECEIVER_PATH, 7uL), + PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to "bitkit"), 7uL), ).getOrThrow() whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, 7uL) + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, 7uL) }.thenReturn( resolution( status = PrivatePaymentResolutionStatus.WAITING_FOR_UPDATED_PAYMENT_LIST, @@ -1575,7 +1912,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() verifyBlocking(paykitSdkService, times(4)) { - prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, 7uL) + prepareAndResolvePrivateContactPayment(CONTACT_KEY, 7uL) } } @@ -1583,7 +1920,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `beginSavedContactPayment does not fall back when private resolution is cancelled`() = test { sut.prepareSavedContacts(listOf(CONTACT_KEY)) whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, null) }.thenThrow(CancellationException("cancelled")) assertFailsWith { @@ -1596,11 +1933,10 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `beginPaymentRequestWaitingForUpdatedList retries a newer private list`() = test { val request = paymentRequest() whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment( + paykitSdkService.prepareAndResolvePrivatePaymentRequest( eq(CONTACT_KEY), - eq(SERVER_RECEIVER_PATH), + eq("request-id"), eq(null), - any(), ) }.thenReturn( resolution( @@ -1619,16 +1955,15 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals( PublicPaykitPaymentResult.Opened( paymentRequest = SERVER_PRIVATE_BOLT11, - privatePaymentContext = PrivatePaykitPaymentContext(SERVER_RECEIVER_PATH, 7uL), + privatePaymentContext = PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to "bitkit"), 7uL), ), result, ) verifyBlocking(paykitSdkService, times(2)) { - prepareAndResolvePrivateContactPayment( + prepareAndResolvePrivatePaymentRequest( eq(CONTACT_KEY), - eq(SERVER_RECEIVER_PATH), + eq("request-id"), eq(null), - any(), ) } } @@ -1637,11 +1972,10 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `beginPaymentRequest resolves only accepted private endpoints with the requested amount`() = test { val request = paymentRequest(acceptedEndpointIdentifiers = listOf(MethodId.Bolt11.rawValue)) whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment( + paykitSdkService.prepareAndResolvePrivatePaymentRequest( eq(CONTACT_KEY), - eq(SERVER_RECEIVER_PATH), + eq("request-id"), eq(null), - any(), ) }.thenReturn( resolution( @@ -1658,21 +1992,17 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals( PublicPaykitPaymentResult.Opened( paymentRequest = PRIVATE_BOLT11, - privatePaymentContext = PrivatePaykitPaymentContext(SERVER_RECEIVER_PATH, 7uL), + privatePaymentContext = PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to "bitkit"), 7uL), ), result, ) - val amountCaptor = argumentCaptor() verifyBlocking(paykitSdkService) { - prepareAndResolvePrivateContactPayment( + prepareAndResolvePrivatePaymentRequest( eq(CONTACT_KEY), - eq(SERVER_RECEIVER_PATH), + eq("request-id"), eq(null), - amountCaptor.capture(), ) } - assertEquals("0.000025", amountCaptor.firstValue.value) - assertEquals("btc", amountCaptor.firstValue.asset) verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } } @@ -1681,11 +2011,10 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest() whenever(paykitSdkService.hasPrivatePaymentAccess()).thenReturn(false) whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment( + paykitSdkService.prepareAndResolvePrivatePaymentRequest( eq(CONTACT_KEY), - eq(SERVER_RECEIVER_PATH), + eq("request-id"), eq(null), - any(), ) }.thenReturn( resolution( @@ -1701,7 +2030,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals( PublicPaykitPaymentResult.Opened( paymentRequest = SERVER_PRIVATE_BOLT11, - privatePaymentContext = PrivatePaykitPaymentContext(SERVER_RECEIVER_PATH, 7uL), + privatePaymentContext = PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to "bitkit"), 7uL), ), result, ) @@ -1709,18 +2038,102 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `beginPaymentRequest rechecks expiration after private resolution`() = test { - val request = paymentRequest() - whenever(clock.now()).thenReturn( - Instant.fromEpochSeconds(NOW_SECONDS), - Instant.fromEpochSeconds(NOW_SECONDS + 61), + fun `bound requests keep their own addresses after contact list updates without consuming the list`() = test { + sut.prepareSavedContacts(listOf(CONTACT_KEY)) + sut.consumePrivatePaymentList(CONTACT_KEY, PrivatePaykitPaymentContext(emptyMap(), 7uL)).getOrThrow() + whenever( + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, 7uL), + ).thenReturn(resolution(resolvedEndpoint(MethodId.P2wpkh, "latest-address"), version = 8uL)) + whenever(coreService.isAddressUsed(any())).thenReturn(false) + sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() + val cachedEndpoints = cacheData.value.contacts.getValue(CONTACT_KEY).remoteEndpoints + val addresses = mapOf("invoice-a" to PRIVATE_ADDRESS, "invoice-b" to OTHER_PRIVATE_ADDRESS) + whenever( + paykitSdkService.prepareAndResolvePrivatePaymentRequest(eq(CONTACT_KEY), any(), eq(7uL)), + ).thenAnswer { + resolution(resolvedEndpoint(MethodId.P2wpkh, addresses.getValue(it.getArgument(1))), version = null) + } + + for (id in listOf("invoice-a", "invoice-b", "invoice-a")) { + val request = paymentRequest(listOf(MethodId.P2wpkh.rawValue)).copy(paymentRequestId = id) + val result = sut.beginPaymentRequest(request).getOrThrow() + val context = PrivatePaykitPaymentContext(mapOf(MethodId.P2wpkh.rawValue to "bitkit"), null) + assertEquals(PublicPaykitPaymentResult.Opened(addresses.getValue(id), context), result) + sut.consumePrivatePaymentList(CONTACT_KEY, context).getOrThrow() + sut.releasePrivatePaymentList(CONTACT_KEY, context).getOrThrow() + assertEquals(7uL, cacheData.value.contacts.getValue(CONTACT_KEY).consumedPrivatePaymentListVersion) + assertEquals(cachedEndpoints, cacheData.value.contacts.getValue(CONTACT_KEY).remoteEndpoints) + } + verifyBlocking(paykitSdkService, times(1)) { prepareAndResolvePrivateContactPayment(CONTACT_KEY, 7uL) } + verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } + } + + @Test + fun `bound request with no payable candidate never falls back to contact or public endpoints`() = test { + whenever { + paykitSdkService.prepareAndResolvePrivatePaymentRequest(CONTACT_KEY, "request-id", null) + }.thenReturn(resolution(version = null, linkState = LinkedPeerState.LINKED)) + + assertEquals(PublicPaykitPaymentResult.NoEndpoint, sut.beginPaymentRequest(paymentRequest()).getOrThrow()) + verifyBlocking(paykitSdkService, never()) { prepareAndResolvePrivateContactPayment(any(), any(), any()) } + verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } + } + + @Test + fun `bound request keeps wallet address usage validation`() = test { + whenever { + paykitSdkService.prepareAndResolvePrivatePaymentRequest(CONTACT_KEY, "request-id", null) + }.thenReturn(resolution(resolvedEndpoint(MethodId.P2wpkh, PRIVATE_ADDRESS), version = null)) + whenever(coreService.isAddressUsed(PRIVATE_ADDRESS)).thenReturn(true) + + val request = paymentRequest(listOf(MethodId.P2wpkh.rawValue)) + assertEquals(PublicPaykitPaymentResult.NotOpened, sut.beginPaymentRequest(request).getOrThrow()) + verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } + } + + @Test + fun `recurring request can reuse its fixed onchain destination but not a consumed private list`() = test { + val endpoint = resolvedEndpoint(MethodId.P2wpkh, PRIVATE_ADDRESS) + whenever( + paykitSdkService.prepareAndResolvePrivatePaymentRequest(CONTACT_KEY, "request-id", null), + ).thenReturn(resolution(endpoint, version = null)) + whenever(coreService.isAddressUsed(PRIVATE_ADDRESS)).thenReturn(false) + val request = paymentRequest(listOf(MethodId.P2wpkh.rawValue)).copy( + lifecycleState = PaymentRequestLifecycleState.ACTIVE_RECURRING, + billingPeriod = PaykitBillingPeriod( + Instant.parse("2027-01-01T08:00:00Z"), + Instant.parse("2027-02-01T08:00:00Z"), + ), + ) + val expected = PublicPaykitPaymentResult.Opened( + PRIVATE_ADDRESS, + PrivatePaykitPaymentContext(mapOf(MethodId.P2wpkh.rawValue to "bitkit"), null), ) + assertEquals(expected, sut.beginPaymentRequest(request).getOrThrow()) + + whenever(coreService.isAddressUsed(PRIVATE_ADDRESS)).thenReturn(true) + val nextPeriod = request.copy( + billingPeriod = PaykitBillingPeriod( + Instant.parse("2027-02-01T08:00:00Z"), + Instant.parse("2027-03-01T08:00:00Z"), + ), + ) + assertEquals(expected, sut.beginPaymentRequest(nextPeriod).getOrThrow()) + + whenever( + paykitSdkService.prepareAndResolvePrivatePaymentRequest(CONTACT_KEY, "request-id", null), + ).thenReturn(resolution(endpoint, version = 7uL)) + assertEquals(PublicPaykitPaymentResult.NotOpened, sut.beginPaymentRequest(nextPeriod).getOrThrow()) + verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } + } + + @Test + fun `beginPaymentRequest rechecks proposal and payment deadlines after private resolution`() = test { whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment( + paykitSdkService.prepareAndResolvePrivatePaymentRequest( eq(CONTACT_KEY), - eq(SERVER_RECEIVER_PATH), + eq("request-id"), eq(null), - any(), ) }.thenReturn( resolution( @@ -1731,8 +2144,22 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(coreService.decode(SERVER_PRIVATE_BOLT11)) .thenReturn(Scanner.Lightning(lightningInvoice(SERVER_PRIVATE_BOLT11, byteArrayOf(8, 8, 8)))) - assertFailsWith { - sut.beginPaymentRequest(request).getOrThrow() + val requests = listOf( + paymentRequest(), + paymentRequest().copy( + lifecycleState = PaymentRequestLifecycleState.ACCEPTED, + expiresAt = Instant.fromEpochSeconds(NOW_SECONDS - 1), + paymentDeadlineAt = Instant.fromEpochSeconds(NOW_SECONDS + 60), + ), + ) + requests.forEach { request -> + whenever(clock.now()).thenReturn( + Instant.fromEpochSeconds(NOW_SECONDS), + Instant.fromEpochSeconds(NOW_SECONDS + 61), + ) + assertFailsWith { + sut.beginPaymentRequest(request).getOrThrow() + } } verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } } @@ -1743,7 +2170,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.exportBackupState()).thenReturn(backup) sut.consumePrivatePaymentList( CONTACT_KEY, - PrivatePaykitPaymentContext(WALLET_RECEIVER_PATH, 7uL), + PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to "bitkit"), 7uL), ).getOrThrow() val snapshot = sut.backupSnapshot().getOrThrow() @@ -1753,12 +2180,12 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals( 7uL, cacheData.value.contacts.getValue(CONTACT_KEY) - .consumedPrivatePaymentListVersionsByReceiverPath[WALLET_RECEIVER_PATH], + .consumedPrivatePaymentListVersion, ) - verifyBlocking(paykitSdkService) { restoreBackupState(backup) } + verifyBlocking(paykitSdkService) { retainRecoveryBackup(backup) } } - private fun createSut() = PrivatePaykitRepo( + private fun createSut(publicPaykitRepo: PublicPaykitRepo = this.publicPaykitRepo) = PrivatePaykitRepo( ioDispatcher = testDispatcher, paykitSdkService = paykitSdkService, pubkyService = pubkyService, @@ -1803,6 +2230,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { return PaykitResolvedPaymentEndpoint( identifier = methodId.rawValue, payload = PublicPaykitRepo.serializePayload(value), + appId = "bitkit", ) } @@ -1811,7 +2239,6 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) = PaykitPaymentRequest( paymentRequestId = "request-id", counterparty = CONTACT_KEY, - counterpartyReceiverPath = SERVER_RECEIVER_PATH, amountValue = "0.000025", amountSats = 2_500uL, expiresAt = Instant.fromEpochSeconds(NOW_SECONDS + 60), @@ -1822,11 +2249,11 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { queuedCounterparties: List = emptyList(), clearedCounterparties: List = emptyList(), failedToQueue: List = emptyList(), + failedToDeliver: List = emptyList(), ) = PrivatePaymentListDeliveryReport( queued = queuedCounterparties.map { PrivatePaymentListSyncChange( counterparty = it, - counterpartyReceiverPath = WALLET_RECEIVER_PATH, outboundMessageId = null, error = null, ) @@ -1834,29 +2261,16 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { cleared = clearedCounterparties.map { PrivatePaymentListSyncChange( counterparty = it, - counterpartyReceiverPath = WALLET_RECEIVER_PATH, outboundMessageId = null, error = null, ) }, failedToQueue = failedToQueue, - failedToDeliver = emptyList(), - ) - - private fun cachedPublishedContact(receiverPath: String) = PrivatePaykitContactCacheData( - publishedPrivatePaymentReceiverPaths = setOf(receiverPath), + failedToDeliver = failedToDeliver, ) - private fun privateReceiverPathSelection( - publishableReceiverPaths: List, - linkableReceiverPaths: List = publishableReceiverPaths, - cleanupProtectedReceiverPaths: List = emptyList(), - error: Throwable? = null, - ) = PaykitPrivateReceiverPathSelection( - linkableReceiverPaths = linkableReceiverPaths, - publishableReceiverPaths = publishableReceiverPaths, - cleanupProtectedReceiverPaths = cleanupProtectedReceiverPaths, - error = error, + private fun cachedPublishedContact() = PrivatePaykitContactCacheData( + hasPublishedPrivatePaymentList = true, ) private fun privateListDeliveryReportForUpdates( @@ -1864,34 +2278,30 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) = PrivatePaymentListDeliveryReport( queued = updates .filter { it.reservations.isNotEmpty() } - .map { privateListSyncChange(it.counterparty, it.counterpartyReceiverPath) }, + .map { privateListSyncChange(it.counterparty) }, cleared = updates .filter { it.reservations.isEmpty() } - .map { privateListSyncChange(it.counterparty, it.counterpartyReceiverPath) }, + .map { privateListSyncChange(it.counterparty) }, failedToQueue = emptyList(), failedToDeliver = emptyList(), ) private fun privateListSyncChange( counterparty: String, - receiverPath: String, ) = PrivatePaymentListSyncChange( counterparty = counterparty, - counterpartyReceiverPath = receiverPath, outboundMessageId = null, error = null, ) - private fun contactRecord(publicKey: String, receiverPaths: List) = ContactRecord( + private fun contactRecord(publicKey: String) = ContactRecord( publicKey = publicKey, - receiverPaths = receiverPaths, label = null, profile = null, profileFetchedAt = null, createdAt = "2026-01-01T00:00:00Z", updatedAt = "2026-01-01T00:00:00Z", publicContactMarkerStatus = PublicationStatus.NOT_PUBLISHED, - publicContactMarkerReceiverPath = null, publicContactPublishedAt = null, publicContactRemovedAt = null, publicContactLastError = null, @@ -1900,10 +2310,8 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { private fun linkedPeer( publicKey: String, state: LinkedPeerState, - receiverPath: String = WALLET_RECEIVER_PATH, ) = LinkedPeerRecord( counterparty = publicKey, - counterpartyReceiverPath = receiverPath, state = state, lastSyncAt = null, lastPrivateReceiveAt = null, diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index d9de9b8642..8bb9963573 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -4,12 +4,13 @@ import app.cash.turbine.test import coil3.ImageLoader import coil3.disk.DiskCache import coil3.memory.MemoryCache -import com.synonym.paykit.ContactProfileResolution -import com.synonym.paykit.ContactProfileSource import com.synonym.paykit.ContactRecord +import com.synonym.paykit.ContactUpdate import com.synonym.paykit.PaykitException import com.synonym.paykit.PaykitProfile import com.synonym.paykit.PaykitSdk +import com.synonym.paykit.ProfileResolution +import com.synonym.paykit.ProfileSource import com.synonym.paykit.PubkyAuthCompanionClaim import com.synonym.paykit.PubkySessionBootstrapResult import com.synonym.paykit.PublicationStatus @@ -54,7 +55,6 @@ import org.mockito.kotlin.atLeastOnce import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.eq -import org.mockito.kotlin.isNull import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.reset @@ -74,6 +74,7 @@ import to.bitkit.data.sharedpubky.SharedPubkyClient import to.bitkit.data.sharedpubky.SharedPubkyContract import to.bitkit.ext.runSuspendCatching import to.bitkit.models.PubkyAuthClaim +import to.bitkit.models.PubkyAuthClaim.Item import to.bitkit.models.PubkyAuthRequest import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkySessionBackupKind @@ -93,6 +94,7 @@ import kotlin.test.assertSame import kotlin.test.assertTrue import kotlin.time.Clock import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime import kotlin.time.Instant import com.synonym.paykit.PubkyProfile as SdkPubkyProfile @@ -151,6 +153,9 @@ class PubkyRepoTest : BaseUnitTest() { Unit } whenever(pubkyService.contactRecords()).thenReturn(emptyList()) + whenever { pubkyService.initializeAndImportSession(any()) }.doSuspendableAnswer { + runSuspendCatching { pubkyService.importSession(it.getArgument(0)) } + } whenever { settingsStore.update(any()) }.thenAnswer { val transform = it.getArgument<(SettingsData) -> SettingsData>(0) settingsFlow.value = transform(settingsFlow.value) @@ -193,52 +198,41 @@ class PubkyRepoTest : BaseUnitTest() { PubkyProfile.placeholder(VALID_CONTACT_KEY_A).copy(name = "Alice"), PubkyProfile.placeholder(VALID_CONTACT_KEY_B).copy(name = "Bob"), ) - for (profile in profiles) { - whenever( - pubkyService.saveContact( - profile.publicKey, - profile.name, - restorePrivateConnection = true, - expectedIdentity = VALID_SELF_KEY, - ), - ).thenReturn(mock()) - } + val updates = profiles.map { ContactUpdate(it.publicKey, it.name) } + whenever(pubkyService.saveContacts(eq(updates), eq(VALID_SELF_KEY), any())).thenReturn(emptyList()) whenever(pubkyService.resolveContactProfile(any(), any(), any(), anyOrNull())) .thenAnswer { throw TestAppError("Offline") } - whenever(pubkyService.discoverRelevantReceiverPaths(any(), any())).thenAnswer { throw TestAppError("Offline") } val result = sut.importContacts(profiles + profiles) assertTrue(result.isSuccess) assertEquals(profiles, sut.contacts.value) - for (profile in profiles) { - verify(pubkyService).saveContact( - profile.publicKey, - profile.name, - restorePrivateConnection = true, - expectedIdentity = VALID_SELF_KEY, - ) - } + assertTrue(sut.importContacts(profiles).isSuccess) + verify(pubkyService).saveContacts(eq(updates), eq(VALID_SELF_KEY), any()) + verify(pubkyService, never()).saveContact(any(), anyOrNull(), any(), anyOrNull(), anyOrNull()) verify(pubkyService, never()).resolveContactProfile(any(), any(), any(), anyOrNull()) - verify(pubkyService, never()).discoverRelevantReceiverPaths(any(), any()) } @Test - fun `failed import keeps successful contacts and retry saves only missing contacts`() = test { + fun `failed batch keeps existing contacts and retries all selected missing contacts`() = test { authenticateForTesting(publicKey = VALID_SELF_KEY) val alice = PubkyProfile.placeholder(VALID_CONTACT_KEY_A).copy(name = "Alice") val bob = PubkyProfile.placeholder(VALID_CONTACT_KEY_B).copy(name = "Bob") - whenever(pubkyService.saveContact(alice.publicKey, alice.name, null, true, VALID_SELF_KEY)) - .thenReturn(mock()) - whenever(pubkyService.saveContact(bob.publicKey, bob.name, null, true, VALID_SELF_KEY)) - .thenAnswer { throw TestAppError("Storage unavailable") }.thenReturn(mock()) - - assertTrue(sut.importContacts(listOf(alice, bob)).isFailure) + val carol = PubkyProfile.placeholder("pubky8${VALID_CONTACT_KEY_A.removePrefix("pubky").drop(1)}") + .copy(name = "Carol") + val existingUpdate = listOf(ContactUpdate(alice.publicKey, alice.name)) + whenever(pubkyService.saveContacts(eq(existingUpdate), eq(VALID_SELF_KEY), any())).thenReturn(emptyList()) + assertTrue(sut.importContacts(listOf(alice)).isSuccess) + val updates = listOf(bob, carol).map { ContactUpdate(it.publicKey, it.name) } + whenever(pubkyService.saveContacts(eq(updates), eq(VALID_SELF_KEY), any())) + .thenAnswer { throw TestAppError("Storage unavailable") }.thenReturn(emptyList()) + + assertTrue(sut.importContacts(listOf(alice, bob, carol)).isFailure) assertEquals(listOf(alice), sut.contacts.value) - assertTrue(sut.importContacts(listOf(alice, bob)).isSuccess) - assertEquals(listOf(alice, bob), sut.contacts.value) - verify(pubkyService).saveContact(alice.publicKey, alice.name, null, true, VALID_SELF_KEY) - verify(pubkyService, times(2)).saveContact(bob.publicKey, bob.name, null, true, VALID_SELF_KEY) + assertTrue(sut.importContacts(listOf(alice, bob, carol)).isSuccess) + assertEquals(listOf(alice, bob, carol), sut.contacts.value) + verify(pubkyService).saveContacts(eq(existingUpdate), eq(VALID_SELF_KEY), any()) + verify(pubkyService, times(2)).saveContacts(eq(updates), eq(VALID_SELF_KEY), any()) } @Test @@ -254,16 +248,15 @@ class PubkyRepoTest : BaseUnitTest() { val alice = PubkyProfile.placeholder(VALID_CONTACT_KEY_B).copy(name = "Alice") whenever(pubkyService.getContacts(VALID_CONTACT_KEY_A)).thenReturn(ownKeys + VALID_CONTACT_KEY_B) stubFollowLookup(VALID_CONTACT_KEY_B, "Alice") - whenever(pubkyService.saveContact(alice.publicKey, alice.name, null, true, VALID_CONTACT_KEY_A)) - .thenReturn(mock()) - whenever(pubkyService.saveContact(VALID_CONTACT_KEY_A, ownProfile.name, null, true, VALID_CONTACT_KEY_A)) - .thenAnswer { throw TestAppError("Cannot save own identity") } + val updates = listOf(ContactUpdate(alice.publicKey, alice.name)) + whenever(pubkyService.saveContacts(eq(updates), eq(VALID_CONTACT_KEY_A), any())).thenReturn(emptyList()) assertTrue(sut.prepareImport().isSuccess) assertEquals(ownProfile, sut.pendingImportProfile.value) assertEquals(listOf(alice), sut.pendingImportContacts.value) assertTrue(sut.importContacts(sut.pendingImportContacts.value).isSuccess) assertEquals(listOf(alice), sut.contacts.value) + verify(pubkyService).saveContacts(eq(updates), eq(VALID_CONTACT_KEY_A), any()) whenever(pubkyService.getContacts(VALID_CONTACT_KEY_A)).thenReturn(ownKeys) @@ -452,27 +445,35 @@ class PubkyRepoTest : BaseUnitTest() { @Test fun `approveAuthWithCompanionClaim forwards exact claim identifiers and capability`() = test { - val authUrl = "pubkyauth://signin?x-bitkit-claim=watch-only-account-v1" val clientId = "paykit.test" val secretKey = "local_secret" - val payload = ByteArray(84) { it.toByte() } whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(secretKey) - val result = sut.approveAuthWithCompanionClaim(authUrl, clientId, payload) - - assertTrue(result.isSuccess) - verifyBlocking(pubkyService) { - approveAuthWithCompanionClaim( - authUrl = authUrl, - expectedCapabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES, - approvedClientId = clientId, - secretKeyHex = secretKey, - claim = PubkyAuthCompanionClaim( - queryParameter = PubkyAuthClaim.QUERY_PARAMETER, - claimType = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue, - unsignedPayload = payload, - ), - ) + val selections = listOf( + PubkyAuthClaim(Item.PAYKIT_ACCESS_V1), + PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1), + PubkyAuthClaim(Item.PAYKIT_ACCESS_V1, Item.WATCH_ONLY_ACCOUNT_V1), + requireNotNull(PubkyAuthClaim.fromWireValue("watch-only-account-v1.paykit-access-v1")), + ) + for (claimType in selections) { + val payload = if (claimType.includesWatchOnlyAccount) ByteArray(84).apply { this[0] = 1 } else byteArrayOf() + val authUrl = "pubkyauth://signin?x-bitkit-claim=${claimType.wireValue}" + val result = sut.approveAuthWithCompanionClaim(authUrl, clientId, payload) + + assertTrue(result.isSuccess) + verifyBlocking(pubkyService) { + approveAuthWithCompanionClaim( + authUrl = authUrl, + expectedCapabilities = PubkyAuthClaim.REQUIRED_CAPABILITIES, + approvedClientId = clientId, + secretKeyHex = secretKey, + claim = PubkyAuthCompanionClaim( + queryParameter = PubkyAuthClaim.QUERY_PARAMETER, + claimType = claimType.wireValue, + unsignedPayload = payload, + ), + ) + } } } @@ -849,6 +850,74 @@ class PubkyRepoTest : BaseUnitTest() { verify(pubkyService, times(2)).resolveContactProfile(VALID_SELF_KEY, true) } + @Test + fun `overlapping profile loads share success or failure and allow a later refresh`() = test { + listOf(false, true).forEachCase({ "fails=$it" }) { fails -> + resetForCase() + authenticateForTesting(publicKey = VALID_SELF_KEY) + val finishLoad = CompletableDeferred() + whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)).doSuspendableAnswer { + finishLoad.await() + if (fails) throw TestAppError("Offline") + createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile(name = "Loaded")) + } + clearInvocations(pubkyService) + + val loads = List(3) { async { sut.loadProfile() } } + assertTrue(sut.isLoadingProfile.value) + finishLoad.complete(Unit) + loads.awaitAll() + + val attempts = if (fails) 2 else 1 + verify(pubkyService, times(attempts)).resolveContactProfile(VALID_SELF_KEY, true) + assertFalse(sut.isLoadingProfile.value) + sut.loadProfile() + verify(pubkyService, times(attempts * 2)).resolveContactProfile(VALID_SELF_KEY, true) + } + } + + @Test + fun `profile load after a save does not reuse an outdated in-flight read`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + val finishLoad = CompletableDeferred() + whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)).doSuspendableAnswer { + finishLoad.await() + createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile(name = "Loaded")) + } + clearInvocations(pubkyService) + + val first = async { sut.loadProfile() } + assertTrue(saveNewProfile().isSuccess) + val next = async { sut.loadProfile() } + finishLoad.complete(Unit) + first.await() + next.await() + + verify(pubkyService, times(2)).resolveContactProfile(VALID_SELF_KEY, true) + assertEquals("Loaded", sut.profile.value?.name) + } + + @Test + fun `cancelled profile load leaves queued refresh available`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + val finishLoad = CompletableDeferred() + whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)).doSuspendableAnswer { + finishLoad.await() + createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile(name = "Loaded")) + } + clearInvocations(pubkyService) + + val first = launch { sut.loadProfile() } + val next = async { sut.loadProfile() } + first.cancelAndJoin() + finishLoad.complete(Unit) + next.await() + + verify(pubkyService, times(2)).resolveContactProfile(VALID_SELF_KEY, true) + assertEquals("Loaded", sut.profile.value?.name) + assertFalse(sut.isLoadingProfile.value) + } + @Test fun `loadProfile should return early when no public key`() = test { sut.loadProfile() @@ -1183,6 +1252,58 @@ class PubkyRepoTest : BaseUnitTest() { } } + @Test + fun `deleteProfile stops public profile reads before deleting contacts`() = test { + authenticateForTesting() + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("test_secret") + val records = listOf( + createContactRecord(VALID_CONTACT_KEY_A, label = "Alice"), + createContactRecord(VALID_CONTACT_KEY_B, label = "Bob"), + ) + whenever(pubkyService.contactRecords()).thenReturn(records) + whenever(pubkyService.removeContacts(records.map { it.publicKey })).thenReturn(records) + val bulkCancelled = CompletableDeferred() + val screenCancelled = CompletableDeferred() + whenever(pubkyService.resolveContactProfile(VALID_CONTACT_KEY_A, true, PaykitReadLane.Bulk)) + .doSuspendableAnswer { awaitCancellation() } + whenever(pubkyService.resolveContactProfile(VALID_CONTACT_KEY_B, true, PaykitReadLane.Bulk)) + .doSuspendableAnswer { + try { + awaitCancellation() + } finally { + bulkCancelled.complete(Unit) + } + } + whenever(pubkyService.resolveContactProfile(VALID_CONTACT_KEY_A, true, PaykitReadLane.Interactive)) + .doSuspendableAnswer { + try { + awaitCancellation() + } finally { + screenCancelled.complete(Unit) + } + } + sut.loadContacts() + val screenLookup = async { sut.resolvePendingContactProfile(VALID_CONTACT_KEY_A) } + val deletionStarted = CompletableDeferred() + val finishDeletion = CompletableDeferred() + whenever(pubkyService.contactRecords()).doSuspendableAnswer { + deletionStarted.complete(Unit) + finishDeletion.await() + records + } + val deletion = async { sut.deleteProfile() } + deletionStarted.await() + bulkCancelled.await() + screenCancelled.await() + assertFalse(deletion.isCompleted) + finishDeletion.complete(Unit) + deletion.await().getOrThrow() + screenLookup.await() + assertTrue(sut.contacts.value.isEmpty()) + verify(pubkyService).removeContacts(records.map { it.publicKey }) + verify(pubkyService, never()).removeContact(any()) + } + @Test fun `deleteProfile should fail when signOut fails`() = test { authenticateForTesting() @@ -1414,7 +1535,7 @@ class PubkyRepoTest : BaseUnitTest() { fun `a follow lookup times out only after holding a read slot that long and then stays a placeholder`() = test { authenticateForTesting(publicKey = VALID_SELF_KEY) val sdk = mock() - delegateProfileReadsTo(PaykitSdkService(mock(), mock(), mock()) { sdk }) + delegateProfileReadsTo(PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk }) val firstRound = List(PUBLIC_READ_SLOTS) { "pubkyfirst-follow-$it" } val secondRound = List(PUBLIC_READ_SLOTS) { "pubkysecond-follow-$it" } val queued = "pubkyqueued-follow" @@ -1423,7 +1544,7 @@ class PubkyRepoTest : BaseUnitTest() { val firstRoundDone = CompletableDeferred() val secondRoundDone = CompletableDeferred() var stuckReadCancelled = false - whenever(sdk.resolveContactProfile(any(), any(), any())).doSuspendableAnswer { + whenever(sdk.resolveProfile(any(), any())).doSuspendableAnswer { val key = it.getArgument(0) when (key) { in firstRound -> firstRoundDone.await() @@ -1462,12 +1583,12 @@ class PubkyRepoTest : BaseUnitTest() { fun `a follow lookup that timed out gives its read slot to a later read`() = test { authenticateForTesting(publicKey = VALID_SELF_KEY) val sdk = mock() - delegateProfileReadsTo(PaykitSdkService(mock(), mock(), mock()) { sdk }) + delegateProfileReadsTo(PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk }) val stuckFollows = List(MORE_FOLLOWS_THAN_READ_SLOTS) { "pubkystuck-follow-$it" } whenever(pubkyService.getContacts(VALID_SELF_KEY)).thenReturn(stuckFollows) var startedReads = 0 var cancelledReads = 0 - whenever(sdk.resolveContactProfile(any(), any(), any())).doSuspendableAnswer { + whenever(sdk.resolveProfile(any(), any())).doSuspendableAnswer { val key = it.getArgument(0) if (key == VALID_CONTACT_KEY_A) { return@doSuspendableAnswer createResolution(key, paykitProfile = createPaykitProfile("Alice")) @@ -2194,6 +2315,7 @@ class PubkyRepoTest : BaseUnitTest() { val imported = CompletableDeferred() whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("saved_session") whenever(pubkyService.importSession("saved_session")).doSuspendableAnswer { imported.await() } + clearInvocations(pubkyService) val repo = createSut() val cancelledWaiter = async { repo.awaitInitialization() } val waiter = async { repo.awaitInitialization() } @@ -2205,6 +2327,8 @@ class PubkyRepoTest : BaseUnitTest() { waiter.await() assertEquals(VALID_SELF_KEY, repo.publicKey.value) + verify(pubkyService, never()).initialize() + verify(pubkyService).initializeAndImportSession("saved_session") verify(pubkyService).importSession("saved_session") } @@ -2244,9 +2368,9 @@ class PubkyRepoTest : BaseUnitTest() { @Test fun `initialize should flag session restoration failure when service startup fails with identity error`() = test { whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("saved_session") - whenever(pubkyService.initialize()).thenAnswer { + doSuspendableAnswer { throw AppError(PaykitException.Identity("identity_error", "Missing capabilities")) - } + }.whenever(pubkyService).initializeAndImportSession("saved_session") val repo = createSut() repo.awaitInitialization() @@ -2262,9 +2386,9 @@ class PubkyRepoTest : BaseUnitTest() { fun `initialize should not flag session restoration failure when service startup fails with non-identity error`() = test { whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("saved_session") - whenever(pubkyService.initialize()).thenAnswer { + doSuspendableAnswer { throw AppError(PaykitException.Storage("storage_error", "Corrupted state")) - } + }.whenever(pubkyService).initializeAndImportSession("saved_session") val repo = createSut() repo.awaitInitialization() @@ -2305,6 +2429,185 @@ class PubkyRepoTest : BaseUnitTest() { assertTrue(sut.isAuthenticated.value) } + @Test + fun `initialize preserves saved session on temporary failures`() = test { + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("saved_session") + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn("local_secret") + val failures = listOf( + PaykitException.ConcurrentUpdate("concurrent_update", "Locked"), + PaykitException.SharedStateBusy("shared_state_busy", "Pending write"), + PaykitException.Transport("transport_error", "Offline"), + ) + var currentFailure: PaykitException? = null + whenever(pubkyService.importSession("saved_session")).thenAnswer { + currentFailure?.let { throw AppError(it) } + VALID_SELF_KEY + } + for (failure in failures) { + currentFailure = failure + sut = createSut() + sut.awaitInitialization() + assertFalse(sut.isAuthenticated.value) + assertFalse(sut.sessionRestorationFailed.value) + verify(pubkyService, never()).signIn(any()) + verify(keychain, never()).delete(Keychain.Key.PAYKIT_SESSION.name) + } + verify(pubkyService, times(failures.size)).importSession("saved_session") + + currentFailure = null + assertTrue(sut.restoreSessionIfNeeded()) + assertTrue(sut.isAuthenticated.value) + } + + @Test + fun `temporary initial import result does not report an expired session`() = test { + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("saved_session") + whenever(pubkyService.initializeAndImportSession("saved_session")).thenReturn( + Result.failure(AppError(PaykitException.ConcurrentUpdate("concurrent_update", "Locked"))), + ) + val repo = createSut() + + repo.awaitInitialization() + + assertFalse(repo.sessionRestorationFailed.value) + assertFalse(repo.isAuthenticated.value) + verify(pubkyService, never()).signIn(any()) + verify(keychain, never()).delete(Keychain.Key.PAYKIT_SESSION.name) + } + + @Test + fun `deferred restoration recovers without another foreground caller`() = test { + val restore = stubSavedSessionRestore() + restore.answer = { throw PaykitException.SharedStateBusy("shared_state_busy", "Locked") } + sut.initialize() + clearInvocations(pubkyService, keychain) + + val retry = launch { sut.retryDeferredSessionRestoration() } + restore.answer = { VALID_SELF_KEY } + advanceTimeBy(5.seconds) + runCurrent() + retry.join() + + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + assertFalse(sut.sessionRestorationFailed.value) + verify(pubkyService).importSession("saved_session") + verify(pubkyService).contactRecords() + verify(keychain, never()).delete(any()) + } + + @Test + fun `deferred restoration bounds repeated failures and permits a later caller`() = test { + val restore = stubSavedSessionRestore() + restore.answer = { throw PaykitException.SharedStateBusy("shared_state_busy", "Locked") } + sut.initialize() + clearInvocations(pubkyService) + + sut.retryDeferredSessionRestoration() + + verify(pubkyService, times(8)).importSession("saved_session") + assertNull(sut.publicKey.value) + assertFalse(sut.sessionRestorationFailed.value) + restore.answer = { VALID_SELF_KEY } + assertTrue(sut.restoreSessionIfNeeded()) + } + + @Test + fun `deferred restoration shares in-flight work with foreground callers`() = test { + val restore = stubSavedSessionRestore() + restore.answer = { throw PaykitException.SharedStateBusy("shared_state_busy", "Locked") } + sut.initialize() + val finishRestore = CompletableDeferred() + restore.answer = { + finishRestore.await() + VALID_SELF_KEY + } + clearInvocations(pubkyService) + + val retry = launch { sut.retryDeferredSessionRestoration() } + advanceTimeBy(5.seconds) + runCurrent() + val duplicate = launch { sut.retryDeferredSessionRestoration() } + val foreground = async { sut.awaitIdentityReady() } + assertFalse(foreground.isCompleted) + finishRestore.complete(Unit) + + assertEquals(PubkyIdentityReadiness.Ready, foreground.await()) + retry.join() + duplicate.join() + verify(pubkyService).importSession("saved_session") + verify(pubkyService).contactRecords() + } + + @Test + fun `deferred restoration stops when the identity is removed or replaced`() = test { + listOf("wipe", "sign out", "replace").forEachCase({ it }) { action -> + resetForCase() + val restore = stubSavedSessionRestore() + restore.answer = { throw PaykitException.SharedStateBusy("shared_state_busy", "Locked") } + sut.initialize() + val retry = launch { sut.retryDeferredSessionRestoration() } + + when (action) { + "wipe" -> sut.wipeLocalState() + "sign out" -> sut.signOut().getOrThrow() + "replace" -> authenticateForTesting(VALID_CONTACT_KEY_A, "other_session") + } + clearInvocations(pubkyService) + advanceUntilIdle() + retry.join() + + verify(pubkyService, never()).importSession(any()) + assertEquals(VALID_CONTACT_KEY_A.takeIf { action == "replace" }, sut.publicKey.value) + } + } + + @Test + fun `deferred restoration stops on permanent failure and preserves credentials`() = test { + val restore = stubSavedSessionRestore() + restore.answer = { throw PaykitException.SharedStateBusy("shared_state_busy", "Locked") } + sut.initialize() + restore.answer = { throw TestAppError("Revoked") } + clearInvocations(pubkyService, keychain) + + sut.retryDeferredSessionRestoration() + + verify(pubkyService).importSession("saved_session") + verify(keychain, never()).delete(any()) + assertNull(sut.publicKey.value) + } + + @Test + fun `cancelling deferred restoration cancels admission but lets active SDK work finish`() = test { + val restore = stubSavedSessionRestore() + restore.answer = { throw PaykitException.SharedStateBusy("shared_state_busy", "Locked") } + sut.initialize() + val finishRestore = CompletableDeferred() + var completedSdkWork = false + restore.answer = { + withContext(NonCancellable) { + finishRestore.await() + completedSdkWork = true + } + VALID_SELF_KEY + } + clearInvocations(pubkyService) + + val active = launch { sut.retryDeferredSessionRestoration() } + advanceTimeBy(5.seconds) + runCurrent() + val queued = launch { sut.retryDeferredSessionRestoration() } + queued.cancelAndJoin() + active.cancel() + assertFalse(active.isCompleted) + finishRestore.complete(Unit) + active.join() + + assertTrue(completedSdkWork) + advanceUntilIdle() + verify(pubkyService).importSession("saved_session") + verify(keychain, never()).delete(Keychain.Key.PAYKIT_SESSION.name) + } + @Test fun `initialize should complete contacts load after contact fetch failure`() = test { val session = "saved_session" @@ -2367,7 +2670,8 @@ class PubkyRepoTest : BaseUnitTest() { whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)) .thenReturn(createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile(name = "Ring User"))) - sut.initialize() + sut = createSut() + sut.awaitInitialization() assertEquals(VALID_SELF_KEY, sut.publicKey.value) assertFalse(sut.sessionRestorationFailed.value) @@ -2448,11 +2752,10 @@ class PubkyRepoTest : BaseUnitTest() { fun `restoration retry recovers service startup failure and skips an active session`() = test { whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("saved_session") var isOnline = false - whenever(pubkyService.initialize()).thenAnswer { + doSuspendableAnswer { if (!isOnline) throw TestAppError("Offline") - Unit - } - whenever(pubkyService.importSession("saved_session")).thenReturn(VALID_SELF_KEY) + Result.success(VALID_SELF_KEY) + }.whenever(pubkyService).initializeAndImportSession("saved_session") val repo = createSut() repo.awaitInitialization() assertNull(repo.publicKey.value) @@ -2463,6 +2766,7 @@ class PubkyRepoTest : BaseUnitTest() { clearInvocations(pubkyService) repo.restoreSessionIfNeeded() + verify(pubkyService, never()).initializeAndImportSession(any()) verify(pubkyService, never()).importSession(any()) verify(pubkyService, never()).signIn(any()) } @@ -2531,6 +2835,86 @@ class PubkyRepoTest : BaseUnitTest() { verify(pubkyService, times(1)).importSession("saved_session") } + @Test + fun `overlapping failed restoration retries share the attempt and allow a later retry`() = test { + val restore = stubSavedSessionRestore() + sut.initialize() + val finishRetry = CompletableDeferred() + restore.answer = { + finishRetry.await() + throw PaykitException.ConcurrentUpdate("concurrent_update", "Locked") + } + clearInvocations(pubkyService) + + val retry = async { sut.restoreSessionIfNeeded() } + val readiness = async { sut.awaitIdentityReady() } + val otherRetry = async { sut.restoreSessionIfNeeded() } + finishRetry.complete(Unit) + + assertFalse(retry.await()) + assertFalse(otherRetry.await()) + assertEquals(PubkyIdentityReadiness.Unavailable, readiness.await()) + verify(pubkyService, times(1)).importSession("saved_session") + + restore.answer = { VALID_SELF_KEY } + assertTrue(sut.restoreSessionIfNeeded()) + verify(pubkyService, times(2)).importSession("saved_session") + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + } + + @Test + fun `cancelled restoration leaves a queued identity retry available`() = test { + val restore = stubSavedSessionRestore() + sut.initialize() + val finishRetry = CompletableDeferred() + restore.answer = { + finishRetry.await() + VALID_SELF_KEY + } + clearInvocations(pubkyService) + val retry = launch { sut.restoreSessionIfNeeded() } + val readiness = async { sut.awaitIdentityReady() } + + restore.answer = { VALID_SELF_KEY } + retry.cancelAndJoin() + + assertEquals(PubkyIdentityReadiness.Ready, readiness.await()) + verify(pubkyService, times(2)).importSession("saved_session") + } + + @Test + fun `session restoration loads contacts without waiting for the own profile`() = test { + listOf("initialize", "restore", "readiness").forEachCase({ it }) { entry -> + resetForCase() + val restore = stubSavedSessionRestore() + restore.answer = { VALID_SELF_KEY } + val finishProfile = CompletableDeferred() + whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)).doSuspendableAnswer { + finishProfile.await() + createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile()) + } + var contactsLoaded = false + whenever(pubkyService.contactRecords()).thenAnswer { + contactsLoaded = true + emptyList() + } + + val restoration = async { + when (entry) { + "initialize" -> sut.initialize() + "restore" -> sut.restoreSessionIfNeeded() + else -> sut.awaitIdentityReady() + } + } + val loadedWhileProfilePending = contactsLoaded + finishProfile.complete(Unit) + restoration.await() + + assertTrue(loadedWhileProfilePending) + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + } + } + @Test fun `awaitIdentityReady retries a failed startup restore without waiting for contacts`() = test { val restore = stubSavedSessionRestore() @@ -2879,71 +3263,13 @@ class PubkyRepoTest : BaseUnitTest() { fun `addContact should canonicalize key before persistence`() = test { authenticateForTesting() val profile = PubkyProfile.placeholder(NON_CANONICAL_CONTACT_KEY_A) - whenever { pubkyService.discoverRelevantReceiverPaths(VALID_CONTACT_KEY_A) }.thenReturn(emptyList()) val result = sut.addContact(NON_CANONICAL_CONTACT_KEY_A, existingProfile = profile) assertTrue(result.isSuccess) assertEquals(VALID_CONTACT_KEY_A, sut.contacts.value.single().publicKey) verifyBlocking(pubkyService) { - saveContact(VALID_CONTACT_KEY_A, profile.name, emptyList(), restorePrivateConnection = true) - } - } - - @Test - fun `refreshContactReceiverPaths should update saved contact receiver paths`() = test { - authenticateForTesting() - val contact = PubkyProfile( - publicKey = VALID_CONTACT_KEY_B, - name = "Alice", - bio = "", - imageUrl = null, - links = emptyList(), - tags = emptyList(), - status = null, - ) - sut.addContact(VALID_CONTACT_KEY_B, existingProfile = contact) - clearInvocations(pubkyService) - whenever(pubkyService.discoverRelevantReceiverPaths(VALID_CONTACT_KEY_B)) - .thenReturn(listOf("bitkit/wallet", "bitkit/server")) - - val result = sut.refreshContactReceiverPaths(VALID_CONTACT_KEY_B) - - assertTrue(result.isSuccess) - verifyBlocking(pubkyService) { - saveContact( - VALID_CONTACT_KEY_B, - "Alice", - listOf("bitkit/wallet", "bitkit/server"), - ) - } - } - - @Test - fun `refreshContactReceiverPaths should preserve a loaded noncanonical key`() = test { - authenticateForTesting() - whenever(pubkyService.contactRecords()).thenReturn( - listOf( - createContactRecord( - publicKey = NON_CANONICAL_CONTACT_KEY_A, - profile = createPaykitProfile("Alice"), - ), - ), - ) - sut.loadContacts() - clearInvocations(pubkyService) - whenever(pubkyService.discoverRelevantReceiverPaths(NON_CANONICAL_CONTACT_KEY_A)) - .thenReturn(listOf("bitkit/wallet", "bitkit/server")) - - val result = sut.refreshContactReceiverPaths(NON_CANONICAL_CONTACT_KEY_A) - - assertTrue(result.isSuccess) - verifyBlocking(pubkyService) { - saveContact( - NON_CANONICAL_CONTACT_KEY_A, - "Alice", - listOf("bitkit/wallet", "bitkit/server"), - ) + saveContact(VALID_CONTACT_KEY_A, profile.name, restorePrivateConnection = true) } } @@ -3057,11 +3383,11 @@ class PubkyRepoTest : BaseUnitTest() { val alice = PubkyProfile.placeholder(VALID_CONTACT_KEY_A).copy(name = "Alice") val saveStarted = CompletableDeferred() val finishSave = CompletableDeferred() - whenever(pubkyService.saveContact(eq(VALID_CONTACT_KEY_A), any(), anyOrNull(), any(), any(), anyOrNull())) + whenever(pubkyService.saveContacts(any(), eq(VALID_SELF_KEY), any())) .doSuspendableAnswer { saveStarted.complete(Unit) finishSave.await() - createContactRecord(VALID_CONTACT_KEY_A) + listOf(createContactRecord(VALID_CONTACT_KEY_A)) } assertFalse(sut.isImportingContacts.value) val caller = launch { sut.importContacts(listOf(alice)) } @@ -3076,40 +3402,44 @@ class PubkyRepoTest : BaseUnitTest() { } @Test - fun `importContacts stops saving quietly once the identity changes`() = test { + fun `importContacts discards a completed batch after the same identity signs in again`() = test { authenticateForTesting(publicKey = VALID_SELF_KEY) val alice = PubkyProfile.placeholder(VALID_CONTACT_KEY_A).copy(name = "Alice") val bob = PubkyProfile.placeholder(VALID_CONTACT_KEY_B).copy(name = "Bob") val saveStarted = CompletableDeferred() val finishSave = CompletableDeferred() - whenever(pubkyService.saveContact(eq(VALID_CONTACT_KEY_A), any(), anyOrNull(), any(), any(), anyOrNull())) + var isStillCurrent: (() -> Boolean)? = null + whenever(pubkyService.saveContacts(any(), eq(VALID_SELF_KEY), any())) .doSuspendableAnswer { + isStillCurrent = it.getArgument(2) saveStarted.complete(Unit) finishSave.await() - createContactRecord(VALID_CONTACT_KEY_A) + listOf(createContactRecord(VALID_CONTACT_KEY_A), createContactRecord(VALID_CONTACT_KEY_B)) } val import = async { sut.importContacts(listOf(alice, bob)) } saveStarted.await() + assertEquals(true, isStillCurrent?.invoke()) sut.wipeLocalState() + authenticateForTesting(publicKey = VALID_SELF_KEY) + assertEquals(false, isStillCurrent?.invoke()) finishSave.complete(Unit) assertTrue(import.await().isFailure) - verifyBlocking(pubkyService, never()) { - saveContact(eq(VALID_CONTACT_KEY_B), any(), anyOrNull(), any(), any(), anyOrNull()) - } + verify(pubkyService).saveContacts(any(), eq(VALID_SELF_KEY), any()) assertTrue(sut.contacts.value.isEmpty()) assertFalse(sut.isImportingContacts.value) assertNull(sut.contactImportFailure.value) + assertEquals(0L, sut.contactImportVersion.value) } @Test fun `importContacts clears the pending import only once it succeeds`() = test { authenticateForTesting(publicKey = VALID_SELF_KEY) whenever(pubkyService.getContacts(VALID_SELF_KEY)).thenReturn(listOf(VALID_CONTACT_KEY_A)) - whenever(pubkyService.saveContact(eq(VALID_CONTACT_KEY_A), any(), anyOrNull(), any(), any(), anyOrNull())) + whenever(pubkyService.saveContacts(any(), eq(VALID_SELF_KEY), any())) .thenAnswer { throw TestAppError("Storage unavailable") } - .thenReturn(createContactRecord(VALID_CONTACT_KEY_A)) + .thenReturn(listOf(createContactRecord(VALID_CONTACT_KEY_A))) assertTrue(sut.prepareImport().isSuccess) assertEquals(1, sut.pendingImportContacts.value.size) @@ -3132,7 +3462,7 @@ class PubkyRepoTest : BaseUnitTest() { whenever(pubkyService.getContacts(VALID_SELF_KEY)).thenReturn(listOf(VALID_CONTACT_KEY_A)) val saveStarted = CompletableDeferred() val failSave = CompletableDeferred() - whenever(pubkyService.saveContact(eq(VALID_CONTACT_KEY_A), any(), anyOrNull(), any(), any(), anyOrNull())) + whenever(pubkyService.saveContacts(any(), eq(VALID_SELF_KEY), any())) .doSuspendableAnswer { saveStarted.complete(Unit) failSave.await() @@ -3171,7 +3501,7 @@ class PubkyRepoTest : BaseUnitTest() { createContactRecord(VALID_CONTACT_KEY_B, profile = createPaykitProfile("Bob")), ), ) - val lookup = CompletableDeferred() + val lookup = CompletableDeferred() whenever(pubkyService.resolveContactProfile(VALID_CONTACT_KEY_A, true, PaykitReadLane.Bulk)) .doSuspendableAnswer { lookup.await() } val loadVersion = sut.contactsLoadVersion.value @@ -3199,7 +3529,7 @@ class PubkyRepoTest : BaseUnitTest() { whenever(pubkyService.getContacts(VALID_SELF_KEY)).thenReturn(listOf(VALID_CONTACT_KEY_A)) stubFollowLookup(VALID_CONTACT_KEY_A, "Alice") assertTrue(sut.prepareImport().isSuccess) - val lookup = CompletableDeferred() + val lookup = CompletableDeferred() whenever(pubkyService.resolveContactProfile(VALID_CONTACT_KEY_A, true, PaykitReadLane.Bulk)) .doSuspendableAnswer { lookup.await() } whenever(pubkyService.contactRecords()).thenReturn(listOf(createContactRecord(VALID_CONTACT_KEY_A))) @@ -3268,7 +3598,7 @@ class PubkyRepoTest : BaseUnitTest() { whenever(pubkyService.getContacts(VALID_SELF_KEY)).thenReturn(listOf(VALID_CONTACT_KEY_A)) stubFollowLookup(VALID_CONTACT_KEY_A, "Alice") assertTrue(sut.prepareImport().isSuccess) - val lookup = CompletableDeferred() + val lookup = CompletableDeferred() whenever(pubkyService.resolveContactProfile(VALID_CONTACT_KEY_A, true, PaykitReadLane.Bulk)) .doSuspendableAnswer { lookup.await() } whenever(pubkyService.contactRecords()).thenReturn(listOf(createContactRecord(VALID_CONTACT_KEY_A))) @@ -3327,7 +3657,7 @@ class PubkyRepoTest : BaseUnitTest() { assertFalse(sut.isCurrent(signIn)) assertEquals(PubkyContactError.SignInChanged, result.exceptionOrNull()) - verify(pubkyService, never()).saveContact(any(), anyOrNull(), anyOrNull(), any(), anyOrNull(), anyOrNull()) + verify(pubkyService, never()).saveContact(any(), anyOrNull(), any(), anyOrNull(), anyOrNull()) assertEquals(emptyMap(), store.data.contactProfileOverrides) assertTrue(sut.isCurrent(checkNotNull(sut.currentSignIn()))) } @@ -3347,7 +3677,7 @@ class PubkyRepoTest : BaseUnitTest() { assertEquals(PubkyContactError.SignInChanged, result.exceptionOrNull()) assertFalse(sut.isCurrent(signIn)) - verify(pubkyService, never()).saveContact(any(), anyOrNull(), anyOrNull(), any(), anyOrNull(), anyOrNull()) + verify(pubkyService, never()).saveContact(any(), anyOrNull(), any(), anyOrNull(), anyOrNull()) assertEquals(emptyMap(), store.data.contactProfileOverrides) assertEquals(VALID_SELF_KEY, sut.publicKey.value) } @@ -3365,7 +3695,7 @@ class PubkyRepoTest : BaseUnitTest() { assertEquals(PubkyContactError.SignInChanged, result.exceptionOrNull()) assertFalse(sut.isCurrent(signIn)) - verify(pubkyService, never()).saveContact(any(), anyOrNull(), anyOrNull(), any(), anyOrNull(), anyOrNull()) + verify(pubkyService, never()).saveContact(any(), anyOrNull(), any(), anyOrNull(), anyOrNull()) assertEquals(emptyMap(), store.data.contactProfileOverrides) assertTrue(sut.isCurrent(checkNotNull(sut.currentSignIn()))) } @@ -3389,11 +3719,11 @@ class PubkyRepoTest : BaseUnitTest() { queuedSaveRan.await() } var sdkSaves = 0 - whenever(pubkyService.saveContact(any(), anyOrNull(), anyOrNull(), any(), anyOrNull(), anyOrNull())) + whenever(pubkyService.saveContact(any(), anyOrNull(), any(), anyOrNull(), anyOrNull())) .doSuspendableAnswer { sessionInstalled.await() try { - val isStillCurrent = it.getArgument<(() -> Boolean)?>(5) + val isStillCurrent = it.getArgument<(() -> Boolean)?>(4) if (isStillCurrent?.invoke() == false) throw AppError(PubkyContactError.SignInChanged) sdkSaves++ createContactRecord(VALID_CONTACT_KEY_A, "Alice") @@ -3449,7 +3779,6 @@ class PubkyRepoTest : BaseUnitTest() { verify(pubkyService).saveContact( eq(VALID_CONTACT_KEY_A), eq("Alice"), - isNull(), eq(false), eq(VALID_SELF_KEY), any(), @@ -3474,7 +3803,7 @@ class PubkyRepoTest : BaseUnitTest() { val result = sut.updateContact(signIn, VALID_CONTACT_KEY_A, "Alice", "", null, emptyList(), listOf("Friend")) assertEquals(PubkyContactError.SignInChanged, result.exceptionOrNull()) - verify(pubkyService, never()).saveContact(any(), anyOrNull(), anyOrNull(), any(), anyOrNull(), anyOrNull()) + verify(pubkyService, never()).saveContact(any(), anyOrNull(), any(), anyOrNull(), anyOrNull()) assertEquals(emptyMap(), store.data.contactProfileOverrides) } @@ -3494,7 +3823,6 @@ class PubkyRepoTest : BaseUnitTest() { verify(pubkyService).saveContact( eq(VALID_CONTACT_KEY_A), eq("Alice"), - isNull(), eq(false), eq(VALID_SELF_KEY), any(), @@ -3510,7 +3838,7 @@ class PubkyRepoTest : BaseUnitTest() { whenever(pubkyService.contactRecords()).thenReturn(listOf(createContactRecord(VALID_CONTACT_KEY_A, "Saved"))) whenever(pubkyService.resolveContactProfile(VALID_CONTACT_KEY_A, true, PaykitReadLane.Bulk)) .doSuspendableAnswer { awaitCancellation() } - val lookup = CompletableDeferred() + val lookup = CompletableDeferred() whenever(pubkyService.resolveContactProfile(VALID_CONTACT_KEY_A, true, PaykitReadLane.Interactive)) .doSuspendableAnswer { lookup.await() } sut.loadContacts() @@ -3566,7 +3894,7 @@ class PubkyRepoTest : BaseUnitTest() { queuedLookup = currentCoroutineContext().job awaitCancellation() } - val otherLookup = CompletableDeferred() + val otherLookup = CompletableDeferred() whenever(pubkyService.resolveContactProfile(VALID_CONTACT_KEY_B, true, PaykitReadLane.Bulk)) .doSuspendableAnswer { otherLookup.await() } whenever(pubkyService.resolveContactProfile(VALID_CONTACT_KEY_A, true, PaykitReadLane.Interactive)) @@ -3592,7 +3920,7 @@ class PubkyRepoTest : BaseUnitTest() { fun `a background lookup taken over by a screen lookup applies no result`() = test { authenticateForTesting() whenever(pubkyService.contactRecords()).thenReturn(listOf(createContactRecord(VALID_CONTACT_KEY_A, "Saved"))) - val backgroundLookup = CompletableDeferred() + val backgroundLookup = CompletableDeferred() whenever(pubkyService.resolveContactProfile(VALID_CONTACT_KEY_A, true, PaykitReadLane.Bulk)) .doSuspendableAnswer { withContext(NonCancellable) { backgroundLookup.await() } } whenever(pubkyService.resolveContactProfile(VALID_CONTACT_KEY_A, true, PaykitReadLane.Interactive)) @@ -3632,7 +3960,7 @@ class PubkyRepoTest : BaseUnitTest() { whenever(pubkyService.contactRecords()).thenReturn(listOf(createContactRecord(VALID_CONTACT_KEY_A, "Saved"))) whenever(pubkyService.resolveContactProfile(VALID_CONTACT_KEY_A, true, PaykitReadLane.Bulk)) .doSuspendableAnswer { awaitCancellation() } - val lookup = CompletableDeferred() + val lookup = CompletableDeferred() whenever(pubkyService.resolveContactProfile(VALID_CONTACT_KEY_A, true, PaykitReadLane.Interactive)) .doSuspendableAnswer { withContext(NonCancellable) { lookup.await() } } sut.loadContacts() @@ -3654,7 +3982,7 @@ class PubkyRepoTest : BaseUnitTest() { whenever(pubkyService.contactRecords()).thenReturn(listOf(createContactRecord(VALID_CONTACT_KEY_A, "Saved"))) whenever(pubkyService.resolveContactProfile(VALID_CONTACT_KEY_A, true, PaykitReadLane.Bulk)) .doSuspendableAnswer { awaitCancellation() } - val lookup = CompletableDeferred() + val lookup = CompletableDeferred() whenever(pubkyService.resolveContactProfile(VALID_CONTACT_KEY_A, true, PaykitReadLane.Interactive)) .doSuspendableAnswer { lookup.await() } sut.loadContacts() @@ -3768,7 +4096,7 @@ class PubkyRepoTest : BaseUnitTest() { fun `repeated contact loads share one background profile refresh`() = test { authenticateForTesting() whenever(pubkyService.contactRecords()).thenReturn(listOf(createContactRecord(VALID_CONTACT_KEY_A))) - val lookup = CompletableDeferred() + val lookup = CompletableDeferred() whenever(pubkyService.resolveContactProfile(VALID_CONTACT_KEY_A, true, PaykitReadLane.Bulk)) .doSuspendableAnswer { lookup.await() } @@ -4107,14 +4435,12 @@ class PubkyRepoTest : BaseUnitTest() { profile: PaykitProfile? = null, ) = ContactRecord( publicKey = publicKey, - receiverPaths = listOf("bitkit/wallet"), label = label, profile = profile, profileFetchedAt = null, createdAt = "2026-01-01T00:00:00Z", updatedAt = "2026-01-01T00:00:00Z", publicContactMarkerStatus = PublicationStatus.NOT_PUBLISHED, - publicContactMarkerReceiverPath = null, publicContactPublishedAt = null, publicContactRemovedAt = null, publicContactLastError = null, @@ -4124,12 +4450,12 @@ class PubkyRepoTest : BaseUnitTest() { publicKey: String, paykitProfile: PaykitProfile? = null, pubkyProfile: SdkPubkyProfile? = null, - ) = ContactProfileResolution( + ) = ProfileResolution( publicKey = publicKey, source = if (paykitProfile != null) { - ContactProfileSource.PAYKIT_PROFILE + ProfileSource.PAYKIT_PROFILE } else { - ContactProfileSource.PUBKY_PROFILE + ProfileSource.PUBKY_PROFILE }, displayName = paykitProfile?.displayName ?: pubkyProfile?.name, imageUri = paykitProfile?.imageUri ?: pubkyProfile?.image, diff --git a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt index faed638630..0c55c000ca 100644 --- a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt @@ -6,25 +6,33 @@ import com.synonym.bitkitcore.Scanner import com.synonym.paykit.EndpointSyncChange import com.synonym.paykit.EndpointSyncReport import com.synonym.paykit.PublicationStatus +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.MutableStateFlow import org.junit.After import org.junit.Before import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.clearInvocations +import org.mockito.kotlin.eq +import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock import org.mockito.kotlin.never +import org.mockito.kotlin.times import org.mockito.kotlin.verifyBlocking +import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.services.CoreService import to.bitkit.services.PaykitPublicContactPaymentResolution -import to.bitkit.services.PaykitReceiverPaths import to.bitkit.services.PaykitResolvedPaymentEndpoint +import to.bitkit.services.PaykitSdkOperationLock.Priority import to.bitkit.services.PaykitSdkService import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlin.time.Clock import kotlin.time.Duration.Companion.hours @@ -48,6 +56,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { private val clock = mock() private val publicKey = MutableStateFlow("pubkyself") + private val isRestoringSession = MutableStateFlow(false) private val walletState = MutableStateFlow(WalletState()) private val settingsFlow = MutableStateFlow(SettingsData()) @@ -58,9 +67,11 @@ class PublicPaykitRepoTest : BaseUnitTest() { sut = createRepo() settingsFlow.value = SettingsData() publicKey.value = "pubkyself" + isRestoringSession.value = false walletState.value = WalletState() whenever(pubkyRepo.publicKey).thenReturn(publicKey) + whenever(pubkyRepo.isRestoringSession).thenReturn(isRestoringSession) whenever(walletRepo.walletState).thenReturn(walletState) whenever(settingsStore.data).thenReturn(settingsFlow) whenever(clock.now()).thenReturn(Instant.fromEpochMilliseconds(NOW_MILLIS)) @@ -78,6 +89,17 @@ class PublicPaykitRepoTest : BaseUnitTest() { PublicPaykitRepo.lightningRouteHintsValidator = null } + @Test + fun `private capability follows sharing preference`() = test { + for (enabled in listOf(false, true)) { + settingsFlow.value = settingsFlow.value.copy(sharesPrivatePaykitEndpoints = enabled) + sut.syncPaykitApp().getOrThrow() + } + val capabilities = argumentCaptor() + verifyBlocking(paykitSdkService, times(2)) { syncPaykitApp(capabilities.capture(), eq(Priority.Ordered)) } + assertEquals(listOf(false, true), capabilities.allValues) + } + @Test fun `syncCurrentPublishedEndpoints configures SDK public endpoints`() = test { walletState.value = WalletState(onchainAddress = "bc1ptest") @@ -97,6 +119,33 @@ class PublicPaykitRepoTest : BaseUnitTest() { ) } + @Test + fun `syncCurrentPublishedEndpoints waits only for session restoration and remains cancellable`() = test { + isRestoringSession.value = true + publicKey.value = null + walletState.value = WalletState(onchainAddress = "bc1ptest") + settingsFlow.value = SettingsData(publicPaykitLightningEnabled = false, publicPaykitOnchainEnabled = true) + + val cancelledPublication = async { sut.syncCurrentPublishedEndpoints() } + assertFalse(cancelledPublication.isCompleted) + cancelledPublication.cancelAndJoin() + assertTrue(cancelledPublication.isCancelled) + verifyNoInteractions(paykitSdkService) + + val publication = async { sut.syncCurrentPublishedEndpoints() } + assertFalse(publication.isCompleted) + verifyNoInteractions(paykitSdkService) + publicKey.value = "pubkyself" + isRestoringSession.value = false + + publication.await().getOrThrow() + verifyBlocking(paykitSdkService) { syncPaykitApp(privatePaymentsEnabled = false, priority = Priority.Ordered) } + verifyBlocking(paykitSdkService) { syncPublicEndpoints(any()) } + verifyBlocking(pubkyRepo, never()) { currentPublicKey() } + verifyBlocking(pubkyRepo, never()) { awaitInitialization() } + verifyBlocking(pubkyRepo, never()) { initialize() } + } + @Test fun `syncPublishedEndpoints creates reusable onchain endpoint when cached address is blank`() = test { settingsFlow.value = SettingsData( @@ -120,49 +169,59 @@ class PublicPaykitRepoTest : BaseUnitTest() { } @Test - fun `syncPublishedEndpoints does not publish endpoints when receiver marker publish fails`() = test { + fun `syncPublishedEndpoints does not publish endpoints when app registration fails`() = test { settingsFlow.value = SettingsData( publicPaykitLightningEnabled = false, publicPaykitOnchainEnabled = true, ) walletState.value = WalletState(onchainAddress = "bc1ptest") - val markerError = RuntimeException("marker failed") - whenever { paykitSdkService.syncLocalReceiverMarker(isDiscoverable = true) } - .thenThrow(markerError) + val appError = RuntimeException("app registration failed") + whenever { paykitSdkService.syncPaykitApp(privatePaymentsEnabled = false, priority = Priority.Ordered) } + .thenThrow(appError) val error = sut.syncPublishedEndpoints(publish = true).exceptionOrNull() - assertEquals(markerError, error) + assertEquals(appError, error) verifyBlocking(paykitSdkService, never()) { syncPublicEndpoints(any()) } } @Test fun `syncPublishedEndpoints remove clears SDK public endpoints and metadata`() = test { - settingsFlow.value = SettingsData( - publicPaykitBolt11 = "lnbc1old", - publicPaykitBolt11PaymentHash = "010203", - publicPaykitBolt11ExpiresAtMillis = freshExpiryMillis(), - publicPaykitCleanupPending = true, - ) - - val result = sut.syncPublishedEndpoints(publish = false) + for (priority in listOf(Priority.Ordered, Priority.Interactive)) { + clearInvocations(paykitSdkService) + settingsFlow.value = SettingsData( + publicPaykitBolt11 = "lnbc1old", + publicPaykitBolt11PaymentHash = "010203", + publicPaykitBolt11ExpiresAtMillis = freshExpiryMillis(), + publicPaykitCleanupPending = true, + ) - assertTrue(result.isSuccess) - assertEquals("", settingsFlow.value.publicPaykitBolt11) - assertEquals(false, settingsFlow.value.publicPaykitCleanupPending) - verifyBlocking(paykitSdkService) { syncPublicEndpoints(emptyList()) } + val result = if (priority == Priority.Ordered) { + sut.syncPublishedEndpoints(publish = false) + } else { + sut.syncPublishedEndpoints(publish = false, appSyncPriority = priority) + } + + assertTrue(result.isSuccess) + assertEquals("", settingsFlow.value.publicPaykitBolt11) + assertEquals(false, settingsFlow.value.publicPaykitCleanupPending) + inOrder(paykitSdkService) { + verify(paykitSdkService).syncPublicEndpoints(emptyList()) + verify(paykitSdkService).syncPaykitApp(privatePaymentsEnabled = false, priority = priority) + } + } } @Test - fun `syncPublishedEndpoints remove keeps cleanup pending when receiver marker removal fails`() = test { + fun `syncPublishedEndpoints remove keeps cleanup pending when app capability update fails`() = test { settingsFlow.value = SettingsData(publicPaykitCleanupPending = true) - val markerError = RuntimeException("marker failed") - whenever { paykitSdkService.syncLocalReceiverMarker(isDiscoverable = false) } - .thenThrow(markerError) + val appError = RuntimeException("app registration failed") + whenever { paykitSdkService.syncPaykitApp(privatePaymentsEnabled = false, priority = Priority.Ordered) } + .thenThrow(appError) val error = sut.syncPublishedEndpoints(publish = false).exceptionOrNull() - assertEquals(markerError, error) + assertEquals(appError, error) assertTrue(settingsFlow.value.publicPaykitCleanupPending) verifyBlocking(paykitSdkService) { syncPublicEndpoints(emptyList()) } } @@ -170,14 +229,22 @@ class PublicPaykitRepoTest : BaseUnitTest() { @Test fun `syncPublishedEndpoints remove preserves both cleanup failures`() = test { val endpointError = RuntimeException("endpoint failed") - val markerError = RuntimeException("marker failed") + val appError = RuntimeException("app registration failed") whenever { paykitSdkService.syncPublicEndpoints(emptyList()) }.thenThrow(endpointError) - whenever { paykitSdkService.syncLocalReceiverMarker(isDiscoverable = false) }.thenThrow(markerError) + whenever { paykitSdkService.syncPaykitApp(privatePaymentsEnabled = false, priority = Priority.Interactive) } + .thenThrow(appError) - val error = sut.syncPublishedEndpoints(publish = false).exceptionOrNull() + val error = sut.syncPublishedEndpoints( + publish = false, + appSyncPriority = Priority.Interactive, + ).exceptionOrNull() assertEquals(endpointError, error) - assertEquals(listOf(markerError), error?.suppressedExceptions) + assertEquals(listOf(appError), error?.suppressedExceptions) + inOrder(paykitSdkService) { + verify(paykitSdkService).syncPublicEndpoints(emptyList()) + verify(paykitSdkService).syncPaykitApp(privatePaymentsEnabled = false, priority = Priority.Interactive) + } } @Test @@ -214,7 +281,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { @Test fun `beginPayment opens SDK resolved public endpoint`() = test { whenever { - paykitSdkService.resolvePublicContactPayment("pubkycontact", PaykitReceiverPaths.WALLET) + paykitSdkService.resolvePublicContactPayment("pubkycontact") }.thenReturn( resolution( resolvedEndpoint( @@ -231,6 +298,27 @@ class PublicPaykitRepoTest : BaseUnitTest() { assertEquals(PublicPaykitPaymentResult.Opened(PUBLIC_BOLT11), result) } + @Test + fun `beginPayment retains payable alternatives regardless of app order`() = test { + val unavailableInvoice = "lnbcrt1unavailable" + val endpoints = listOf( + resolvedEndpoint(MethodId.Bolt11, unavailableInvoice), + resolvedEndpoint(MethodId.Bolt11, PUBLIC_BOLT11).copy(appId = "another-wallet"), + ) + whenever(coreService.decode(unavailableInvoice)).thenThrow(IllegalArgumentException("Invalid invoice")) + whenever(coreService.decode(PUBLIC_BOLT11)) + .thenReturn(Scanner.Lightning(lightningInvoice(PUBLIC_BOLT11, byteArrayOf(4, 5, 6)))) + + for (ordered in listOf(endpoints, endpoints.reversed())) { + whenever { paykitSdkService.resolvePublicContactPayment("pubkycontact") } + .thenReturn(resolution(*ordered.toTypedArray())) + + val result = sut.beginPayment("pubkycontact").getOrThrow() + + assertEquals(PublicPaykitPaymentResult.Opened(PUBLIC_BOLT11), result) + } + } + @Test fun `payment launch results have reason specific incoming request failures`() { assertEquals( @@ -282,6 +370,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { value: String, ): PaykitResolvedPaymentEndpoint { return PaykitResolvedPaymentEndpoint( + appId = "bitkit", identifier = methodId.rawValue, payload = PublicPaykitRepo.serializePayload(value), ) diff --git a/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountClaimCodecTest.kt b/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountClaimCodecTest.kt index dfcee0ac5e..c4bb58bf0d 100644 --- a/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountClaimCodecTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountClaimCodecTest.kt @@ -1,6 +1,14 @@ package to.bitkit.repositories +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import org.junit.Test +import to.bitkit.ext.fromHex +import to.bitkit.models.PubkyAuthClaim +import to.bitkit.models.PubkyAuthClaim.Item +import to.bitkit.models.PubkyAuthClaimCodec import to.bitkit.models.WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE import to.bitkit.models.WatchOnlyAccountRecord import to.bitkit.models.WatchOnlyAccountSetupState @@ -10,9 +18,92 @@ import kotlin.test.assertEquals import kotlin.test.assertFailsWith class WatchOnlyAccountClaimCodecTest { + @Test + fun `combined companion claim matches the canonical server fixture`() { + val fixture = requireNotNull(javaClass.getResourceAsStream("/bitkit-combined-claim-v1.json")) + .bufferedReader().use { Json.parseToJsonElement(it.readText()).jsonObject } + val claimType = PubkyAuthClaim(Item.PAYKIT_ACCESS_V1, Item.WATCH_ONLY_ACCOUNT_V1) + val accountPayload = WatchOnlyAccountClaimCodec.encode( + account(fixture.getValue("account_index").jsonPrimitive.int, TESTNET_TPUB), + ) { fixture.getValue("serialized_xpub_hex").jsonPrimitive.content.fromHex() } + val payload = PubkyAuthClaimCodec.encode( + claim = claimType, + accountPayload = accountPayload, + generation = fixture.getValue("key_generation").jsonPrimitive.content.toULong(), + secret = fixture.getValue("paykit_secret_hex").jsonPrimitive.content.fromHex(), + ) + + assertEquals(fixture.getValue("query_parameter").jsonPrimitive.content, PubkyAuthClaim.QUERY_PARAMETER) + assertEquals(fixture.getValue("claim_type").jsonPrimitive.content, claimType.wireValue) + assertEquals( + fixture.getValue("capabilities").jsonPrimitive.content, + PubkyAuthClaim.REQUIRED_CAPABILITIES, + ) + assertEquals(124, payload.size) + assertContentEquals(fixture.getValue("unsigned_payload_hex").jsonPrimitive.content.fromHex(), payload) + assertContentEquals( + payload, + PubkyAuthClaimCodec.encode( + claim = requireNotNull(PubkyAuthClaim.fromWireValue("watch-only-account-v1.paykit-access-v1")), + accountPayload = accountPayload, + generation = fixture.getValue("key_generation").jsonPrimitive.content.toULong(), + secret = fixture.getValue("paykit_secret_hex").jsonPrimitive.content.fromHex(), + ), + ) + } + + @Test + fun `companion claims match shared fixed bytes`() { + val accountPayload = WatchOnlyAccountClaimCodec.encode(account(42, TESTNET_TPUB)) { + TESTNET_SERIALIZED_HEX.fromHex() + } + val fixtures = listOf( + PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1) to WATCH_ONLY_HEX, + PubkyAuthClaim(Item.PAYKIT_ACCESS_V1) to PAYKIT_HEX, + PubkyAuthClaim(Item.PAYKIT_ACCESS_V1, Item.WATCH_ONLY_ACCOUNT_V1) to COMBINED_HEX, + requireNotNull(PubkyAuthClaim.fromWireValue("watch-only-account-v1.paykit-access-v1")) to COMBINED_HEX, + ) + for ((claim, expected) in fixtures) { + val payload = PubkyAuthClaimCodec.encode( + claim = claim, + accountPayload = if (claim.includesWatchOnlyAccount) accountPayload else byteArrayOf(), + generation = if (claim.includesPaykitAccess) 3uL else null, + secret = if (claim.includesPaykitAccess) ByteArray(32) { 7 } else null, + ) + assertContentEquals(expected.fromHex(), payload, claim.wireValue) + } + } + + @Test + fun `companion claims reject mismatched account or key payloads`() { + val accountPayload = WATCH_ONLY_HEX.fromHex() + val key = ByteArray(32) { 7 } + assertFailsWith { + PubkyAuthClaimCodec.encode(PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1), accountPayload, 3uL, key) + } + for (payload in listOf(byteArrayOf(), COMBINED_HEX.fromHex(), ByteArray(84))) { + assertFailsWith { + PubkyAuthClaimCodec.encode(PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1), payload) + } + } + assertFailsWith { + PubkyAuthClaimCodec.encode(PubkyAuthClaim(Item.PAYKIT_ACCESS_V1), accountPayload, 3uL, key) + } + for (generation in listOf(null, 0uL)) { + assertFailsWith { + PubkyAuthClaimCodec.encode(PubkyAuthClaim(Item.PAYKIT_ACCESS_V1), byteArrayOf(), generation, key) + } + } + for (secret in listOf(null, ByteArray(31), ByteArray(33))) { + assertFailsWith { + PubkyAuthClaimCodec.encode(PubkyAuthClaim(Item.PAYKIT_ACCESS_V1), byteArrayOf(), 3uL, secret) + } + } + } + @Test fun `unsigned claim contains exact account metadata`() { - val rawXpub = TESTNET_SERIALIZED_HEX.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + val rawXpub = TESTNET_SERIALIZED_HEX.fromHex() val account = account(accountIndex = 42, xpub = TESTNET_TPUB) val payload = WatchOnlyAccountClaimCodec.encode(account) { xpub -> @@ -53,6 +144,15 @@ class WatchOnlyAccountClaimCodecTest { ) private companion object { + const val WATCH_ONLY_HEX = + "010000002a00043587cf03caafd489800000004b5fcc4a5fe210d9fba6616b4db1d025237dd7f035101f11f562401bc7104699" + + "02e0bf22b51a6a49e0b149b995670d0ed9bb1fd99417748bacefba88fae655572d" + const val PAYKIT_HEX = + "0100000000000000030707070707070707070707070707070707070707070707070707070707070707" + const val COMBINED_HEX = + "010000002a00043587cf03caafd489800000004b5fcc4a5fe210d9fba6616b4db1d025237dd7f035101f11f562401bc7104699" + + "02e0bf22b51a6a49e0b149b995670d0ed9bb1fd99417748bacefba88fae655572d" + + "00000000000000030707070707070707070707070707070707070707070707070707070707070707" const val TESTNET_TPUB = "tpubDDWohsp5dx2iMJ9N7iHbgAEDhH4BJB9NWW1fEW3yA3AFNDREmpzteCXNqppMLUmKFY5q5e3" + "PXtS5CuqWCQbYcGhpPqYAgQSYdwknW9J6sQv" diff --git a/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountRepoTest.kt index e30318410f..697b64fbc6 100644 --- a/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountRepoTest.kt @@ -41,6 +41,47 @@ import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class WatchOnlyAccountRepoTest : BaseUnitTest() { + @Test + fun `explicit watch-only request allocates a new account instead of reusing an active account`() = test { + val active = account() + val store = mock() + val lightningService = mock() + val node = mock() + whenever(store.data).thenReturn(flowOf(WatchOnlyAccountData(accounts = listOf(active)))) + whenever(store.load()).thenReturn(listOf(active)) + whenever(store.reserveAccountIndex(any(), any())).thenReturn(2) + whenever(lightningService.node).thenReturn(node) + whenever(node.exportOnchainWalletAccountXpub(AddressType.NATIVE_SEGWIT, 2u)).thenReturn(TEST_XPUB_ALTERNATE) + + val prepared = repository(store, lightningService).prepareUnsignedClaim( + "pubkyauth://signin?secret=new&x-bitkit-claim=watch-only-account-v1", + "New service account", + ) + + assertNotEquals(active.id, prepared.account.id) + assertEquals(2, prepared.account.accountIndex) + assertEquals(TEST_XPUB_ALTERNATE, prepared.account.xpub) + assertEquals(WatchOnlyAccountSetupState.PendingDelivery, prepared.account.setupState) + assertFalse(prepared.account.isTrackingEnabled) + verify(store).save(listOf(active, prepared.account)) + verify(node, never()).addOnchainWalletAccount(any(), any(), any()) + } + + @Test + fun `authorization rejects an active account before tracking`() = test { + val active = account() + val store = mock() + val lightningService = mock() + whenever(store.data).thenReturn(flowOf(WatchOnlyAccountData(accounts = listOf(active)))) + whenever(store.load()).thenReturn(listOf(active)) + + assertFailsWith { + repository(store, lightningService).beginAuthorization(active.id) + } + verify(lightningService, never()).node + verify(store, never()).update(any()) + } + @Test fun `current wallet accounts exclude records from other wallets`() = test { val currentWalletAccount = account().copy(walletIndex = 1) diff --git a/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt b/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt new file mode 100644 index 0000000000..95f5ee04ec --- /dev/null +++ b/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt @@ -0,0 +1,549 @@ +package to.bitkit.services + +import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.AddressType +import com.synonym.bitkitcore.LightningActivity +import com.synonym.bitkitcore.OnchainActivity +import com.synonym.bitkitcore.PaymentState +import com.synonym.bitkitcore.PaymentType +import com.synonym.bitkitcore.TransactionDetails +import com.synonym.bitkitcore.TxOutput +import com.synonym.bitkitcore.getActivities +import com.synonym.bitkitcore.getTransactionDetails +import com.synonym.bitkitcore.updateActivity +import com.synonym.bitkitcore.upsertActivity +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import org.junit.Test +import org.lightningdevkit.ldknode.ConfirmationStatus +import org.lightningdevkit.ldknode.OnchainWalletAccount +import org.lightningdevkit.ldknode.PaymentDetails +import org.lightningdevkit.ldknode.PaymentDirection +import org.lightningdevkit.ldknode.PaymentKind +import org.lightningdevkit.ldknode.PaymentStatus +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.async.ServiceQueue +import to.bitkit.data.AppCacheData +import to.bitkit.data.CacheStore +import to.bitkit.data.PrivatePaykitCacheData +import to.bitkit.data.PrivatePaykitCacheStore +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore +import to.bitkit.ext.create +import to.bitkit.ext.scopedActivityId +import to.bitkit.repositories.PaykitPaymentRequestRepo +import to.bitkit.repositories.PaykitReceivedPaymentContacts +import to.bitkit.repositories.PaykitReceivedPaymentContactsTest.Companion.BUYER +import to.bitkit.repositories.PaykitReceivedPaymentContactsTest.Companion.OTHER_BUYER +import to.bitkit.repositories.PrivatePaykitAddressReservationRepo +import to.bitkit.repositories.PrivatePaykitContactResolver +import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.AppError +import javax.inject.Provider +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.lightningdevkit.ldknode.AddressType as LdkAddressType +import org.lightningdevkit.ldknode.TransactionDetails as LdkTransactionDetails +import org.lightningdevkit.ldknode.TxOutput as LdkTxOutput + +class ActivityServicePaykitContactsTest : BaseUnitTest() { + private val contacts = mock() + private val requestRepo = mock() + private val reservations = mock() + private val privateCacheStore = mock() + private val cacheStore = mock() + private val cacheData = MutableStateFlow(AppCacheData(onchainAddress = "wallet-address")) + private val settingsStore = mock() + private val lightningService = mock() + private val coreService = mock() + private val resolver by lazy { + PrivatePaykitContactResolver( + ioDispatcher = testDispatcher, + cacheStore = privateCacheStore, + addressReservationRepo = Provider { reservations }, + paymentRequestRepo = Provider { requestRepo }, + ) + } + private val sut by lazy { + ActivityService(coreService, cacheStore, lightningService, settingsStore, Provider { resolver }) + } + private var rows = listOf() + private var details: TransactionDetails? = null + private val updates = mutableListOf() + private val reservationVersion = MutableStateFlow(0L) + private var activityReads = 0 + private var detailReads = 0 + + @Test + fun `backfill matches receiving address in outputs and preserves all other onchain metadata`() = coreTest { + val original = onchain(address = "request-address") + rows = listOf(Activity.Onchain(original)) + val outputs = listOf(output("request-address"), output("change-address")) + details = mock { on { this.outputs }.thenReturn(outputs) } + sharedAddresses("request-address" to BUYER) + + assertTrue(sut.backfillPaykitContacts()) + + assertEquals(listOf(Activity.Onchain(original.copy(contact = BUYER))), updates) + verify(contacts).contactsForAddresses(listOf("request-address", "change-address")) + } + + @Test + fun `backfill matches lightning hash when invoice text is missing and preserves metadata`() = coreTest { + val original = lightning() + rows = listOf(Activity.Lightning(original)) + whenever(contacts.contactsForPaymentHash(original.id)).thenReturn(setOf("buyer")) + + assertTrue(sut.backfillPaykitContacts()) + + assertEquals(listOf(Activity.Lightning(original.copy(contact = "buyer"))), updates) + } + + @Test + fun `backfill skips explicit contacts outgoing and failed received activities`() = coreTest { + rows = listOf( + Activity.Onchain(onchain().copy(contact = "explicit")), + Activity.Onchain(onchain().copy(txType = PaymentType.SENT)), + Activity.Lightning(lightning().copy(contact = "explicit")), + Activity.Lightning(lightning().copy(txType = PaymentType.SENT)), + Activity.Lightning(lightning().copy(status = PaymentState.FAILED)), + ) + whenever(contacts.contactsForPaymentHash(any())).thenReturn(setOf("buyer")) + whenever(contacts.contactsForAddresses(any())).thenReturn(setOf("buyer")) + + assertFalse(sut.backfillPaykitContacts()) + assertTrue(updates.isEmpty()) + } + + @Test + fun `backfill respects a manually detached contact`() = coreTest { + val original = lightning() + rows = listOf(Activity.Lightning(original)) + whenever(contacts.contactsForPaymentHash(original.id)).thenReturn(setOf(BUYER)) + cacheData.value = cacheData.value.copy( + detachedActivityContacts = setOf(scopedActivityId(original.walletId, original.id)), + ) + + assertFalse(sut.backfillPaykitContacts()) + assertTrue(updates.isEmpty()) + } + + @Test + fun `backfill preserves a manual edit during the final cache read`() = coreTest { + val original = lightning() + val edited = Activity.Lightning(original.copy(contact = OTHER_BUYER, message = "manual note")) + rows = listOf(Activity.Lightning(original)) + whenever(contacts.contactsForPaymentHash(original.id)).thenReturn(setOf(BUYER)) + var cacheReads = 0 + whenever(cacheStore.data).thenReturn( + flow { + if (++cacheReads == 2) { + sut.update(original.id, edited) + rows = listOf(edited) + } + emit(cacheData.value) + }, + ) + + assertFalse(sut.backfillPaykitContacts()) + assertEquals(listOf(edited), updates) + assertFalse(sut.backfillPaykitContacts()) + assertEquals(listOf(edited), updates) + } + + @Test + fun `backfill refuses ambiguity across stored receiving address and other outputs`() = coreTest { + rows = listOf(Activity.Onchain(onchain(address = "request-address"))) + val outputs = listOf(output("request-address"), output("other-request-address")) + details = mock { on { this.outputs }.thenReturn(outputs) } + sharedAddresses("request-address" to BUYER, "other-request-address" to OTHER_BUYER) + + assertFalse(sut.backfillPaykitContacts()) + assertTrue(updates.isEmpty()) + } + + @Test + fun `backfill waits for complete cached outputs even when stored address matches`() = coreTest { + rows = listOf(Activity.Onchain(onchain(address = "request-address"))) + sharedAddresses("request-address" to BUYER) + + assertFalse(sut.backfillPaykitContacts()) + details = mock { on { outputs }.thenReturn(emptyList()) } + assertFalse(sut.backfillPaykitContacts()) + + val outputs = listOf(output("other-request-address")) + details = mock { on { this.outputs }.thenReturn(outputs) } + assertFalse(sut.backfillPaykitContacts()) + assertTrue(updates.isEmpty()) + } + + @Test + fun `backfill refuses stale identity snapshot before write`() = coreTest { + rows = listOf(Activity.Lightning(lightning())) + whenever(contacts.contactsForPaymentHash(any())).thenAnswer { + whenever(requestRepo.receivedPaymentContacts).thenReturn(PaykitReceivedPaymentContacts.Empty) + setOf("buyer") + } + + assertFalse(sut.backfillPaykitContacts()) + assertTrue(updates.isEmpty()) + } + + @Test + fun `backfill remains idempotent after persisted activity is reopened`() = coreTest { + rows = listOf(Activity.Lightning(lightning())) + whenever(contacts.contactsForPaymentHash(any())).thenReturn(setOf("buyer")) + assertTrue(sut.backfillPaykitContacts()) + rows = updates.toList() + updates.clear() + + assertFalse(sut.backfillPaykitContacts()) + assertTrue(updates.isEmpty()) + } + + @Test + fun `backfill rejects a request that only matches another output`() = coreTest { + for (address in listOf("wallet-address", "unknown")) { + rows = listOf(Activity.Onchain(onchain(address))) + val outputs = listOf(output("wallet-address"), output("request-address")) + details = mock { on { this.outputs }.thenReturn(outputs) } + sharedAddresses("request-address" to BUYER) + + assertFalse(sut.backfillPaykitContacts()) + } + assertTrue(updates.isEmpty()) + } + + @Test + fun `backfill includes local reservations in ambiguity checks`() = coreTest { + rows = listOf(Activity.Onchain(onchain("request-address"))) + val outputs = listOf(output("request-address"), output("reserved-address")) + details = mock { on { this.outputs }.thenReturn(outputs) } + sharedAddresses("request-address" to BUYER) + whenever(reservations.contactPublicKeyForReservedAddress("reserved-address")).thenReturn(OTHER_BUYER) + + assertFalse(sut.backfillPaykitContacts()) + assertTrue(updates.isEmpty()) + } + + @Test + fun `completed backfill skips unchanged inputs until new activity arrives`() = coreTest { + rows = listOf(Activity.Onchain(onchain("wallet-address"))) + val outputs = listOf(output("wallet-address")) + details = mock { on { this.outputs }.thenReturn(outputs) } + + assertFalse(sut.backfillPaykitContacts()) + assertFalse(sut.backfillPaykitContacts()) + assertEquals(1, activityReads) + assertEquals(1, detailReads) + + val received = Activity.Lightning(lightning()) + sut.upsert(received) + rows = listOf(received) + updates.clear() + whenever(contacts.contactsForPaymentHash(any())).thenReturn(setOf(BUYER)) + + assertTrue(sut.backfillPaykitContacts()) + assertEquals(BUYER, (updates.single() as Activity.Lightning).v1.contact) + assertEquals(2, activityReads) + } + + @Test + fun `reservation changes invalidate an ambiguous cached result`() = coreTest { + rows = listOf(Activity.Onchain(onchain("request-address"))) + val outputs = listOf(output("request-address"), output("reserved-address")) + details = mock { on { this.outputs }.thenReturn(outputs) } + sharedAddresses("request-address" to BUYER) + whenever(reservations.contactPublicKeyForReservedAddress("reserved-address")).thenReturn(OTHER_BUYER) + assertFalse(sut.backfillPaykitContacts()) + assertFalse(sut.backfillPaykitContacts()) + assertEquals(1, activityReads) + + whenever(reservations.contactPublicKeyForReservedAddress("reserved-address")).thenReturn(null) + reservationVersion.value++ + assertTrue(sut.backfillPaykitContacts()) + assertEquals(BUYER, (updates.single() as Activity.Onchain).v1.contact) + assertEquals(2, activityReads) + } + + @Test + fun `incomplete details and failed reservation lookups retry until a scan completes`() = coreTest { + rows = listOf(Activity.Onchain(onchain("request-address")), Activity.Lightning(lightning())) + sharedAddresses("request-address" to BUYER) + assertFalse(sut.backfillPaykitContacts()) + assertFalse(sut.backfillPaykitContacts()) + val outputs = listOf(output("request-address")) + details = mock { on { this.outputs }.thenReturn(outputs) } + whenever(reservations.contactPublicKeyForReservedAddress(any())) + .thenThrow(IllegalStateException("unavailable")).thenReturn(null) + assertFailsWith { sut.backfillPaykitContacts() } + + assertTrue(sut.backfillPaykitContacts()) + assertFalse(sut.backfillPaykitContacts()) + assertEquals(4, activityReads) + assertEquals(4, detailReads) + assertEquals(BUYER, (updates.single() as Activity.Onchain).v1.contact) + } + + @Test + fun `updated transaction details invalidate a completed cached scan`() = coreTest { + rows = listOf(Activity.Onchain(onchain("request-address"))) + val ambiguousOutputs = listOf(output("request-address"), output("other-request-address")) + details = mock { on { outputs }.thenReturn(ambiguousOutputs) } + sharedAddresses("request-address" to BUYER, "other-request-address" to OTHER_BUYER) + assertFalse(sut.backfillPaykitContacts()) + + val resolvedOutputs = listOf(output("request-address")) + details = mock { on { outputs }.thenReturn(resolvedOutputs) } + sut.handleOnchainTransactionConfirmed("transaction", mock()) + assertTrue(sut.backfillPaykitContacts()) + assertEquals(2, activityReads) + } + + @Test + fun `identity generation invalidates equal contact snapshots`() = coreTest { + rows = listOf(Activity.Lightning(lightning())) + assertFalse(sut.backfillPaykitContacts()) + whenever(requestRepo.receivedPaymentContactsGeneration).thenReturn(1L) + whenever(contacts.contactsForPaymentHash(any())).thenReturn(setOf(BUYER)) + + assertTrue(sut.backfillPaykitContacts()) + assertEquals(2, activityReads) + } + + @Test + fun `reservation mutation during backfill cannot cache or write stale attribution`() = coreTest { + rows = listOf(Activity.Onchain(onchain("request-address"))) + val outputs = listOf(output("request-address")) + details = mock { on { this.outputs }.thenReturn(outputs) } + sharedAddresses("request-address" to BUYER) + whenever(reservations.contactPublicKeyForReservedAddress(any())).thenAnswer { + reservationVersion.value++ + null + } + + assertFalse(sut.backfillPaykitContacts()) + assertTrue(updates.isEmpty()) + whenever(reservations.contactPublicKeyForReservedAddress(any())).thenReturn(null) + assertTrue(sut.backfillPaykitContacts()) + assertEquals(2, activityReads) + } + + @Test + fun `live received payment rejects a request matching an unrelated output`() = coreTest { + sharedAddresses("request-address" to BUYER) + + receive("wallet-address", "request-address") + + val row = (updates.single() as Activity.Onchain).v1 + assertEquals("wallet-address", row.address) + assertNull(row.contact) + } + + @Test + fun `live received payment attributes a positively matched wallet destination`() = coreTest { + sharedAddresses("wallet-address" to BUYER) + + receive("wallet-address", "unrelated-address") + + assertEquals(BUYER, (updates.single() as Activity.Onchain).v1.contact) + } + + @Test + fun `live received payment rejects conflicting request outputs`() = coreTest { + sharedAddresses("wallet-address" to BUYER, "request-address" to OTHER_BUYER) + + receive("wallet-address", "request-address") + + assertNull((updates.single() as Activity.Onchain).v1.contact) + } + + @Test + fun `shared request destination alone cannot resolve the receiving address`() = coreTest { + sharedAddresses("request-address" to BUYER) + + receive("request-address") + + val row = (updates.single() as Activity.Onchain).v1 + assertEquals("Loading...", row.address) + assertNull(row.contact) + } + + @Test + fun `local reservation can resolve and attribute a received destination`() = coreTest { + whenever(reservations.contactPublicKeyForReservedAddress("reserved-address")).thenReturn(BUYER) + + receive("reserved-address") + + val row = (updates.single() as Activity.Onchain).v1 + assertEquals("reserved-address", row.address) + assertEquals(BUYER, row.contact) + } + + @Test + fun `registered companion account resolves request destination with scoped search indexes`() = coreTest { + sharedAddresses("server-address" to BUYER) + cacheData.value = cacheData.value.copy( + addressSearchLastUsedReceiveIndexes = mapOf("nativeSegwit" to 600), + ) + whenever(lightningService.listOnchainWalletAccounts()).thenReturn( + listOf(OnchainWalletAccount(LdkAddressType.NATIVE_SEGWIT, 5u)), + ) + whenever(lightningService.addressInfosForType(AddressType.P2WPKH, false, 200, 200, 5u)) + .thenReturn(listOf(AddressDerivationInfo("server-address", 203))) + + receive("change-address", "server-address") + + val row = (updates.single() as Activity.Onchain).v1 + assertEquals("server-address", row.address) + assertEquals(BUYER, row.contact) + assertEquals( + mapOf("nativeSegwit" to 600, "nativeSegwit:account:5" to 203), + cacheData.value.addressSearchLastUsedReceiveIndexes, + ) + } + + @Test + fun `companion account search advances from the matched index through the next window`() = coreTest { + whenever(lightningService.listOnchainWalletAccounts()).thenReturn( + listOf(OnchainWalletAccount(LdkAddressType.NATIVE_SEGWIT, 5u)), + ) + for (index in listOf(999, 1999)) { + val address = "server-address-$index" + sharedAddresses(address to BUYER) + whenever(lightningService.addressInfosForType(AddressType.P2WPKH, false, index - 199, 200, 5u)) + .thenReturn(listOf(AddressDerivationInfo(address, index))) + + receive("change-address", address) + + val row = (updates.last() as Activity.Onchain).v1 + assertEquals(address, row.address) + assertEquals(BUYER, row.contact) + assertEquals(index, cacheData.value.addressSearchLastUsedReceiveIndexes["nativeSegwit:account:5"]) + } + } + + @Test + fun `companion account search keeps its own bounded receive and change windows`() = coreTest { + cacheData.value = cacheData.value.copy( + addressSearchLastUsedReceiveIndexes = mapOf("nativeSegwit" to 600), + addressSearchLastUsedChangeIndexes = mapOf("nativeSegwit:account:5" to 200), + ) + whenever(lightningService.listOnchainWalletAccounts()).thenReturn( + listOf(OnchainWalletAccount(LdkAddressType.NATIVE_SEGWIT, 5u)), + ) + + receive("unowned-address") + + verify(lightningService).addressInfosForType(AddressType.P2WPKH, false, 800, 200, 5u) + verify(lightningService, never()).addressInfosForType(AddressType.P2WPKH, false, 1000, 200, 5u) + verify(lightningService).addressInfosForType(AddressType.P2WPKH, true, 1000, 200, 5u) + verify(lightningService).addressInfosForType(AddressType.P2WPKH, true, 1200, 1, 5u) + verify(lightningService, never()).addressInfosForType(AddressType.P2WPKH, true, 1400, 200, 5u) + } + + @Test + fun `payment sync uses stored outputs for receiving address attribution`() = coreTest { + val outputs = listOf(output("wallet-address"), output("unrelated-address")) + details = mock { on { this.outputs }.thenReturn(outputs) } + sharedAddresses("wallet-address" to BUYER) + + sut.syncLdkNodePaymentsToActivities(listOf(payment())) + + assertEquals(BUYER, (updates.single() as Activity.Onchain).v1.contact) + } + + private suspend fun receive(vararg addresses: String) { + whenever(lightningService.listPayments()).thenReturn(listOf(payment())) + sut.handleOnchainTransactionReceived( + "transaction", + LdkTransactionDetails( + amountSats = 15_000, + inputs = emptyList(), + outputs = addresses.mapIndexed { index, address -> + LdkTxOutput("", "", address, 15_000, index.toUInt()) + }, + ), + ) + } + + private fun payment() = PaymentDetails( + id = "received", + kind = PaymentKind.Onchain("transaction", ConfirmationStatus.Unconfirmed), + amountMsat = 15_000_000uL, + feePaidMsat = 0uL, + direction = PaymentDirection.INBOUND, + status = PaymentStatus.SUCCEEDED, + latestUpdateTimestamp = 100uL, + ) + + private fun coreTest(block: suspend () -> Unit) = test { + whenever(requestRepo.receivedPaymentContacts).thenReturn(contacts) + whenever(reservations.attributionVersion).thenAnswer { reservationVersion.value } + whenever(privateCacheStore.data).thenReturn(flowOf(PrivatePaykitCacheData())) + whenever(cacheStore.data).thenReturn(cacheData) + whenever(cacheStore.update(any())).thenAnswer { + cacheData.value = it.getArgument<(AppCacheData) -> AppCacheData>(0)(cacheData.value) + } + whenever(settingsStore.data).thenReturn(flowOf(SettingsData())) + whenever(coreService.activity).thenReturn(mock()) + whenever(lightningService.listOnchainWalletAccounts()).thenReturn(emptyList()) + whenever(lightningService.addressInfosForType(any(), any(), any(), any(), any())).thenReturn(emptyList()) + ServiceQueue.CORE.background { + mockStatic(Class.forName("com.synonym.bitkitcore.Bitkitcore_androidKt")).use { native -> + native.`when`> { + getActivities( + anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), + anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull() + ) + }.thenAnswer { + activityReads++ + rows + } + native.`when` { getTransactionDetails(any(), any()) }.thenAnswer { + detailReads++ + details + } + native.`when` { updateActivity(any(), any()) }.thenAnswer { + updates.add(it.arguments[1] as Activity) + null + } + native.`when` { upsertActivity(any()) }.thenAnswer { + updates.add(it.arguments[0] as Activity) + null + } + block() + } + } + } + + private fun sharedAddresses(vararg values: Pair) { + val byAddress = values.toMap() + whenever(contacts.contactsForAddresses(any())).thenAnswer { + it.getArgument>(0).mapNotNull(byAddress::get).toSet() + } + } + + private fun output(address: String): TxOutput = mock { on { scriptpubkeyAddress }.thenReturn(address) } + + private fun onchain(address: String = "address") = OnchainActivity.create( + id = "received", txId = "transaction", txType = PaymentType.RECEIVED, address = address, + value = 15_000uL, fee = 1uL, timestamp = 100uL, confirmed = true, seenAt = 101uL, + ) + + private fun lightning() = LightningActivity.create( + id = "payment-hash", txType = PaymentType.RECEIVED, status = PaymentState.SUCCEEDED, + value = 15_000uL, invoice = "No invoice", timestamp = 100uL, fee = 1uL, message = "existing note", + preimage = "preimage", seenAt = 101uL, + ) +} diff --git a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt index 14ecdf1b26..b2f0137724 100644 --- a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt @@ -15,8 +15,10 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder +import org.lightningdevkit.ldknode.AddressInfo import org.lightningdevkit.ldknode.AddressType import org.lightningdevkit.ldknode.Event +import org.lightningdevkit.ldknode.KeychainKind import org.lightningdevkit.ldknode.Node import org.lightningdevkit.ldknode.NodeException import org.lightningdevkit.ldknode.NodeStatus @@ -51,6 +53,7 @@ import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Duration.Companion.milliseconds +import com.synonym.bitkitcore.AddressType as BitkitAddressType private const val VERIFY_TIMEOUT_MS = 2_000L private const val RELEASE_GATE_PROBE_MS = 200L @@ -98,6 +101,33 @@ class LightningServiceTest : BaseUnitTest() { assertFalse(sut.canReceive()) } + @Test + fun `address derivation forwards companion account and keychain without changing account zero`() = test { + val onchain = mock() + whenever(node.onchainPayment()).thenReturn(onchain) + val address = AddressInfo(201u, "derived-address", KeychainKind.INTERNAL) + whenever(onchain.addressInfosForAccount(AddressType.NATIVE_SEGWIT, 5u, KeychainKind.INTERNAL, 200u, 200u)) + .thenReturn(listOf(address)) + whenever(onchain.addressInfosForAccount(AddressType.NATIVE_SEGWIT, 0u, KeychainKind.EXTERNAL, 0u, 200u)) + .thenReturn(listOf(address)) + + val companion = sut.addressInfosForType(BitkitAddressType.P2WPKH, true, 200, 200, accountIndex = 5u) + val primary = sut.addressInfosForType(BitkitAddressType.P2WPKH, false, 0, 200) + + assertEquals(listOf(AddressDerivationInfo("derived-address", 201)), companion) + assertEquals(companion, primary) + verify(onchain).addressInfosForAccount(AddressType.NATIVE_SEGWIT, 5u, KeychainKind.INTERNAL, 200u, 200u) + verify(onchain).addressInfosForAccount(AddressType.NATIVE_SEGWIT, 0u, KeychainKind.EXTERNAL, 0u, 200u) + } + + @Test + fun `listOnchainWalletAccounts returns registered accounts from LDK`() = test { + val accounts = listOf(OnchainWalletAccount(AddressType.NATIVE_SEGWIT, 5u)) + whenever(node.listOnchainWalletAccounts()).thenReturn(accounts) + + assertEquals(accounts, sut.listOnchainWalletAccounts()) + } + @Test fun `canReceive returns true when channel is usable`() { val usableChannel = createChannelDetails().copy( diff --git a/app/src/test/java/to/bitkit/services/PaykitBackupStateTrackingTest.kt b/app/src/test/java/to/bitkit/services/PaykitBackupStateTrackingTest.kt index 408ab4e501..1206a918a6 100644 --- a/app/src/test/java/to/bitkit/services/PaykitBackupStateTrackingTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitBackupStateTrackingTest.kt @@ -8,8 +8,10 @@ import kotlinx.coroutines.yield import org.junit.Test import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError +import kotlin.coroutines.cancellation.CancellationException import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertNull import kotlin.test.assertSame import kotlin.test.assertTrue @@ -39,11 +41,15 @@ class PaykitBackupStateTrackingTest : BaseUnitTest() { @Test fun `partial failure marks changed state and preserves operation error`() = test { var revision = "before" + var reads = 0 var changes = 0 val failure = AppError("Operation failed") val thrown = assertFailsWith { withPaykitBackupStateTracking( - readRevision = { revision }, + readRevision = { + reads++ + revision + }, onChange = { changes++ }, ) { revision = "after" @@ -53,19 +59,26 @@ class PaykitBackupStateTrackingTest : BaseUnitTest() { assertSame(failure, thrown) assertEquals(1, changes) + assertEquals(1, reads) } @Test fun `cancellation after mutation completes backup tracking`() = test { var revision = "before" var changes = 0 + var reads = 0 + var snapshot: PaykitBackupStateSnapshot? = PaykitBackupStateSnapshot("state", "before") val mutated = CompletableDeferred() val job = launch { withPaykitBackupStateTracking( readRevision = { + reads++ yield() revision }, + readStateRevision = { "state" }, + cachedSnapshot = snapshot, + onSnapshot = { snapshot = it }, onChange = { changes++ }, ) { revision = "after" @@ -79,5 +92,185 @@ class PaykitBackupStateTrackingTest : BaseUnitTest() { assertTrue(job.isCancelled) assertEquals("after", revision) assertEquals(1, changes) + assertEquals(0, reads) + assertNull(snapshot) + } + + @Test + fun `unchanged operations reuse the backup fingerprint`() = test { + var snapshot: PaykitBackupStateSnapshot? = null + var reads = 0 + var changes = 0 + repeat(3) { + withPaykitBackupStateTracking( + readRevision = { + reads++ + "content" + }, + readStateRevision = { "state" }, + readObservedSnapshot = { PaykitBackupStateSnapshot("state", "content") }, + cachedSnapshot = snapshot, + onSnapshot = { snapshot = it }, + onChange = { changes++ }, + ) {} + } + assertEquals(1, reads) + assertEquals(0, changes) + assertEquals(PaykitBackupStateSnapshot("state", "content"), snapshot) + } + + @Test + fun `changed state revisions still compare backup content`() = test { + for ((cachedState, finalContent, expectedReads) in listOf( + Triple("before", "content", 1), + Triple("before", "changed", 1), + Triple("stale", "changed", 1), + )) { + var state = "before" + var content = "content" + var reads = 0 + var changes = 0 + var snapshot: PaykitBackupStateSnapshot? = null + withPaykitBackupStateTracking( + readRevision = { + reads++ + content + }, + readStateRevision = { state }, + cachedSnapshot = PaykitBackupStateSnapshot(cachedState, "content"), + onSnapshot = { snapshot = it }, + onChange = { changes++ }, + ) { + state = "after" + content = finalContent + } + assertEquals(expectedReads, reads) + assertEquals(if (finalContent == "content") 0 else 1, changes) + assertEquals(PaykitBackupStateSnapshot("after", finalContent), snapshot) + } + } + + @Test + fun `paired observations avoid fingerprint reads and retain the cached change baseline`() = test { + val before = PaykitBackupStateSnapshot("before", "content") + val changed = PaykitBackupStateSnapshot("changed", "changed") + for ((initial, final, expectedChanges) in listOf( + Triple(before, before.copy(stateRevision = "leased"), 0), + Triple(before, changed, 1), + Triple(changed, changed, 1), + )) { + var observed = initial + var snapshot: PaykitBackupStateSnapshot? = PaykitBackupStateSnapshot("cached", "content") + var reads = 0 + var changes = 0 + val result = withPaykitBackupStateTracking( + readRevision = { + reads++ + observed.backupRevision + }, + readStateRevision = { observed.stateRevision }, + readObservedSnapshot = { observed }, + cachedSnapshot = snapshot, + onSnapshot = { snapshot = it }, + onChange = { changes++ }, + ) { + observed = final + "result" + } + + assertEquals("result", result) + assertEquals(0, reads) + assertEquals(expectedChanges, changes) + assertEquals(observed, snapshot) + } + } + + @Test + fun `unavailable or mismatched observations fall back without losing known backup changes`() = test { + val observations: List<() -> PaykitBackupStateSnapshot?> = listOf( + { null }, + { PaykitBackupStateSnapshot("unrelated", "changed") }, + { throw AppError("Unavailable observation") }, + ) + for (readObservedSnapshot in observations) { + val state = "before" + var snapshot: PaykitBackupStateSnapshot? = PaykitBackupStateSnapshot("cached", "content") + var reads = 0 + var changes = 0 + withPaykitBackupStateTracking( + readRevision = { + reads++ + "changed" + }, + readStateRevision = { state }, + readObservedSnapshot = readObservedSnapshot, + cachedSnapshot = snapshot, + onSnapshot = { snapshot = it }, + onChange = { changes++ }, + ) {} + + assertEquals(1, reads) + assertEquals(1, changes) + assertEquals(PaykitBackupStateSnapshot("before", "changed"), snapshot) + } + } + + @Test + fun `cleared snapshots require a fresh baseline despite matching historical observations`() = test { + var observed = PaykitBackupStateSnapshot("old-key", "old-content") + var state = observed.stateRevision + var snapshot: PaykitBackupStateSnapshot? = null + var reads = 0 + var changes = 0 + withPaykitBackupStateTracking( + readRevision = { + reads++ + state = "current" + "current-content" + }, + readStateRevision = { state }, + readObservedSnapshot = { observed }, + cachedSnapshot = snapshot, + onSnapshot = { snapshot = it }, + onChange = { changes++ }, + ) { + state = "after" + observed = PaykitBackupStateSnapshot(state, "current-content") + } + + assertEquals(1, reads) + assertEquals(0, changes) + assertEquals(observed, snapshot) + } + + @Test + fun `failed operations invalidate unchanged snapshots without reading remote state`() = test { + for (failure in listOf(AppError("Write outcome unknown"), CancellationException("Cancelled"))) { + var snapshot: PaykitBackupStateSnapshot? = PaykitBackupStateSnapshot("state", "before") + var reads = 0 + var observedReads = 0 + var changes = 0 + val thrown = assertFailsWith(failure::class) { + withPaykitBackupStateTracking( + readRevision = { + reads++ + "before" + }, + readStateRevision = { "state" }, + readObservedSnapshot = { + observedReads++ + PaykitBackupStateSnapshot("state", "before") + }, + cachedSnapshot = snapshot, + onSnapshot = { snapshot = it }, + onChange = { changes++ }, + ) { throw failure } + } + assertSame(failure, thrown) + assertEquals(0, reads) + assertEquals(0, observedReads) + assertEquals(1, changes) + assertNull(snapshot) + } } } diff --git a/app/src/test/java/to/bitkit/services/PaykitKeyGenerationTest.kt b/app/src/test/java/to/bitkit/services/PaykitKeyGenerationTest.kt new file mode 100644 index 0000000000..88d71dcff6 --- /dev/null +++ b/app/src/test/java/to/bitkit/services/PaykitKeyGenerationTest.kt @@ -0,0 +1,237 @@ +package to.bitkit.services + +import com.synonym.paykit.IdentityStatus +import com.synonym.paykit.ObservedBackupStateRevision +import com.synonym.paykit.PaykitAppRegistry +import com.synonym.paykit.PaykitException +import com.synonym.paykit.PaykitIdentitySecretKey +import com.synonym.paykit.PaykitSdk +import com.synonym.paykit.PubkyIdentityCapability +import com.synonym.paykit.PubkyLocalSecretKey +import com.synonym.paykit.PubkySessionAccess +import com.synonym.paykit.pubkyPublicKeyFromSecret +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.mockito.Mockito.mockConstruction +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.any +import org.mockito.kotlin.inOrder +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.data.keychain.Keychain +import to.bitkit.ext.toHex +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertSame + +class PaykitKeyGenerationTest { + companion object { + /** Fixture identity for generation-scoped storage keys. */ + private const val RING_PUBKY = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + } + + @Test + fun `authorization derives the registry generation and persists its identity scoped floor`() = runTest { + for (registryGeneration in listOf(null, 7uL)) { + val generation = registryGeneration ?: 1uL + val registry = registryGeneration?.let { + mock { on { keyGeneration }.thenReturn(generation) } + } + val sdk = mock() + whenever(sdk.paykitAppRegistry(RING_PUBKY)).thenReturn(registry) + val keychain = mock() + val storageKey = "${Keychain.Key.PAYKIT_KEY_GENERATION.name}:$RING_PUBKY" + whenever(keychain.loadString(storageKey)).thenReturn(null, generation.toString()) + val key = mock() + val bytes = ByteArray(32) { 1 } + val service = PaykitSdkService(mock(), keychain, mock(), settingsStore = mock()) { sdk } + + mockStatic(Class.forName("com.synonym.paykit.Paykit_androidKt")).use { native -> + native.`when` { pubkyPublicKeyFromSecret(any()) }.thenReturn(RING_PUBKY) + mockConstruction(PubkyLocalSecretKey::class.java) { root, context -> + assertContentEquals(bytes, context.arguments().single() as ByteArray) + whenever(root.derivePaykitIdentitySecretKey(generation)).thenReturn(key) + }.use { roots -> + repeat(2) { + assertSame(key, service.paykitKeyForAuthorization(bytes.toHex())) + } + assertEquals(2, roots.constructed().size) + roots.constructed().forEach { verify(it).derivePaykitIdentitySecretKey(generation) } + verify(keychain).upsertString(storageKey, generation.toString()) + verify(sdk, times(2)).paykitAppRegistry(RING_PUBKY) + } + } + } + } + + @Test + fun `cached keys refresh after remote rotation and reject registry rollback`() = runTest { + val initialRegistry = mock { on { keyGeneration }.thenReturn(2uL) } + val rotatedRegistry = mock { on { keyGeneration }.thenReturn(3uL) } + val sdk = mock() + whenever(sdk.paykitAppRegistry(RING_PUBKY)) + .thenReturn(initialRegistry, rotatedRegistry, initialRegistry, null) + val status = IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE) + val staleKey = PaykitException.Identity("identity_error", "Shared state requires a newer key") + whenever(sdk.identityStatus()).thenReturn(status, status).thenThrow(staleKey).thenReturn(status) + .thenThrow(staleKey) + val keychain = mock() + val storageKey = "${Keychain.Key.PAYKIT_KEY_GENERATION.name}:$RING_PUBKY" + var savedGeneration = "2" + whenever(keychain.loadString(storageKey)).thenAnswer { savedGeneration } + whenever(keychain.upsertString(storageKey, "3")).thenAnswer { savedGeneration = "3" } + val root = mock() + val initialKey = mock { on { keyGeneration() }.thenReturn(2uL) } + val rotatedKey = mock { on { keyGeneration() }.thenReturn(3uL) } + whenever(root.derivePaykitIdentitySecretKey(2uL)).thenReturn(initialKey) + whenever(root.derivePaykitIdentitySecretKey(3uL)).thenReturn(rotatedKey) + + mockStatic(Class.forName("com.synonym.paykit.Paykit_androidKt")).use { native -> + native.`when` { pubkyPublicKeyFromSecret(root) }.thenReturn(RING_PUBKY) + mockConstruction(PaykitSdkSessionProvider::class.java) { provider, _ -> + whenever(provider.loadLocalSecretKey()).thenReturn(root) + }.use { providers -> + val service = PaykitSdkService(mock(), keychain, mock(), settingsStore = mock()) { sdk } + val provider = providers.constructed().single() + repeat(2) { assertEquals(RING_PUBKY, service.currentPublicKey()) } + verify(sdk).paykitAppRegistry(RING_PUBKY) + assertSame(staleKey, assertFailsWith { service.currentPublicKey() }) + assertEquals(RING_PUBKY, service.currentPublicKey()) + assertSame(staleKey, assertFailsWith { service.currentPublicKey() }) + repeat(2) { + val error = assertFailsWith { service.currentPublicKey() } + assertEquals("The Paykit App Registry has an older key generation", error.message) + } + + inOrder(provider, keychain, root) { + verify(root).derivePaykitIdentitySecretKey(2uL) + verify(provider).setPaykitIdentitySecretKey(initialKey) + verify(keychain).upsertString(storageKey, "3") + verify(root).derivePaykitIdentitySecretKey(3uL) + verify(provider).setPaykitIdentitySecretKey(rotatedKey) + } + verify(root, times(2)).derivePaykitIdentitySecretKey(any()) + verify(keychain).upsertString(any(), any()) + verify(sdk, times(5)).identityStatus() + verify(provider, times(2)).setPaykitIdentitySecretKey(any()) + } + } + } + + @Test + fun `key changes discard backup snapshots before reusing historical observations`() = runTest { + for (reset in listOf("generation", "identity", "missing root")) { + var publicKey = RING_PUBKY + var generation = 1uL + val registry = mock() + whenever(registry.keyGeneration).thenReturn(generation) + val sdk = mock() + whenever(sdk.paykitAppRegistry(any())).thenReturn(registry) + whenever(sdk.stateRevision()).thenReturn("state") + whenever(sdk.observedBackupStateRevision()).thenReturn(ObservedBackupStateRevision("state", "backup")) + whenever(sdk.backupStateRevision()).thenReturn("backup") + whenever(sdk.processPendingPrivateMessages()).thenReturn(emptyList()) + val keychain = mock() + whenever(keychain.loadString(any())).thenAnswer { generation.toString() } + val root = mock() + val key = mock() + whenever(key.keyGeneration()).thenReturn(generation) + whenever(root.derivePaykitIdentitySecretKey(any())).thenReturn(key) + + mockStatic(Class.forName("com.synonym.paykit.Paykit_androidKt")).use { native -> + native.`when` { pubkyPublicKeyFromSecret(root) }.thenAnswer { publicKey } + mockConstruction(PaykitSdkSessionProvider::class.java) { provider, _ -> + whenever(provider.loadLocalSecretKey()).thenReturn(root) + }.use { providers -> + val service = PaykitSdkService(mock(), keychain, mock(), settingsStore = mock()) { sdk } + repeat(2) { service.processPendingPrivateMessages() } + verify(sdk).backupStateRevision() + + when (reset) { + "generation" -> { + generation = 2uL + whenever(registry.keyGeneration).thenReturn(generation) + whenever(key.keyGeneration()).thenReturn(generation) + } + "identity" -> publicKey = "other-identity" + else -> whenever(providers.constructed().single().loadLocalSecretKey()).thenReturn(null) + } + repeat(2) { service.processPendingPrivateMessages() } + + verify(sdk, times(2)).backupStateRevision() + verify(sdk, times(if (reset == "missing root") 1 else 2)).paykitAppRegistry(any()) + assertEquals(0L, service.backupStateVersion.value) + } + } + } + } + + @Test + fun `session key rotation rebuilds cached session access with the refreshed key`() { + val keychain = mock() + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("saved-session") + val initialKey = mock { on { keyGeneration() }.thenReturn(2uL) } + val rotatedKey = mock { on { keyGeneration() }.thenReturn(3uL) } + val initialAccess = mock() + whenever(initialAccess.exportSessionSecret()).thenReturn("saved-session") + whenever(initialAccess.exportPaykitIdentitySecretKey()).thenReturn(initialKey) + val provider = PaykitSdkSessionProvider(keychain, mock()) + provider.setLiveSessionAccess(initialAccess) + provider.setPaykitIdentitySecretKey(initialKey) + assertSame(initialAccess, provider.loadSessionAccess()) + + mockConstruction(PubkySessionAccess::class.java) { access, context -> + assertEquals(BitkitPaykitSdkConfig.clientId, context.arguments()[0]) + assertEquals("saved-session", context.arguments()[1]) + assertSame(rotatedKey, context.arguments()[3]) + whenever(access.exportSessionSecret()).thenReturn("saved-session") + }.use { sessions -> + provider.setPaykitIdentitySecretKey(rotatedKey) + val refreshedAccess = provider.loadSessionAccess() + assertSame(sessions.constructed().single(), refreshedAccess) + assertSame(refreshedAccess, provider.loadSessionAccess()) + assertEquals(1, sessions.constructed().size) + } + } + + @Test + fun `best effort backup identity failures invalidate the cached session key`() = runTest { + val sdk = mock() + val initialRegistry = mock { on { keyGeneration }.thenReturn(1uL) } + val rotatedRegistry = mock { on { keyGeneration }.thenReturn(2uL) } + whenever(sdk.paykitAppRegistry(RING_PUBKY)).thenReturn(initialRegistry, rotatedRegistry) + whenever(sdk.identityStatus()) + .thenReturn(IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + whenever(sdk.stateRevision()).thenReturn("state") + whenever(sdk.backupStateRevision()).thenThrow(PaykitException.Identity("identity_error", "Stale key")) + whenever(sdk.processPendingPrivateMessages()).thenReturn(emptyList()) + val keychain = mock() + val storageKey = "${Keychain.Key.PAYKIT_KEY_GENERATION.name}:$RING_PUBKY" + whenever(keychain.loadString(storageKey)).thenReturn("1") + val root = mock() + val initialKey = mock { on { keyGeneration() }.thenReturn(1uL) } + val rotatedKey = mock { on { keyGeneration() }.thenReturn(2uL) } + whenever(root.derivePaykitIdentitySecretKey(1uL)).thenReturn(initialKey) + whenever(root.derivePaykitIdentitySecretKey(2uL)).thenReturn(rotatedKey) + + mockStatic(Class.forName("com.synonym.paykit.Paykit_androidKt")).use { native -> + native.`when` { pubkyPublicKeyFromSecret(root) }.thenReturn(RING_PUBKY) + mockConstruction(PaykitSdkSessionProvider::class.java) { provider, _ -> + whenever(provider.loadLocalSecretKey()).thenReturn(root) + }.use { providers -> + val service = PaykitSdkService(mock(), keychain, mock(), settingsStore = mock()) { sdk } + assertEquals(RING_PUBKY, service.currentPublicKey()) + service.processPendingPrivateMessages() + verify(sdk).paykitAppRegistry(RING_PUBKY) + + assertEquals(RING_PUBKY, service.currentPublicKey()) + verify(sdk, times(2)).paykitAppRegistry(RING_PUBKY) + verify(providers.constructed().single()).setPaykitIdentitySecretKey(rotatedKey) + verify(keychain).upsertString(storageKey, "2") + } + } + } +} diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkOperationLockTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkOperationLockTest.kt index 809f88ab53..d5c96eab32 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkOperationLockTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkOperationLockTest.kt @@ -5,9 +5,11 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Test +import to.bitkit.services.PaykitSdkOperationLock.Priority import kotlin.coroutines.cancellation.CancellationException import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -16,6 +18,96 @@ import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class PaykitSdkOperationLockTest { + @Test + fun `interactive work overtakes only queued publication with bounded fairness`() = runTest { + val lock = PaykitSdkOperationLock() + val release = CompletableDeferred() + val events = mutableListOf() + val active = launch { + lock.withLock(Priority.Background) { + events.add("active") + release.await() + } + } + runCurrent() + val priorities = listOf( + Priority.Background, + Priority.Background, + Priority.Interactive, + Priority.Interactive, + Priority.Interactive, + Priority.Interactive, + ) + val queued = priorities.mapIndexed { index, priority -> + launch { lock.withLock(priority) { events.add(index.toString()) } }.also { runCurrent() } + } + assertEquals(listOf("active"), events) + release.complete(Unit) + active.join() + queued.forEach { it.join() } + assertEquals(listOf("active", "2", "3", "4", "0", "5", "1"), events) + lock.withLock {} + } + + @Test + fun `interactive work cannot cross an ordered operation`() = runTest { + val lock = PaykitSdkOperationLock() + val release = CompletableDeferred() + val active = launch { lock.withLock { release.await() } } + runCurrent() + val events = mutableListOf() + val priorities = listOf( + Priority.Background, + Priority.Interactive, + Priority.Ordered, + Priority.Background, + Priority.Interactive, + ) + val queued = priorities.mapIndexed { index, priority -> + launch { lock.withLock(priority) { events.add(index) } }.also { runCurrent() } + } + release.complete(Unit) + active.join() + queued.forEach { it.join() } + assertEquals(listOf(1, 0, 2, 4, 3), events) + } + + @Test + fun `cancellation after priority handoff releases the lock to publication`() = runTest { + val lock = PaykitSdkOperationLock() + val release = CompletableDeferred() + val active = launch(UnconfinedTestDispatcher(testScheduler)) { lock.withLock { release.await() } } + val background = async { lock.withLock(Priority.Background) { "published" } } + val interactive = async { lock.withLock(Priority.Interactive) { error("cancelled operation ran") } } + runCurrent() + + release.complete(Unit) + assertTrue(active.isCompleted) + interactive.cancel() + assertFailsWith { interactive.await() } + assertEquals("published", background.await()) + lock.withLock {} + } + + @Test + fun `public reads do not block mutation but reject results across wallet wipe`() = runTest { + val lock = PaykitSdkOperationLock() + val releaseRead = CompletableDeferred() + val read = async { + assertFailsWith { + lock.withoutLock { releaseRead.await() } + } + } + runCurrent() + lock.withLock { } + lock.withWalletWipe { + assertEquals("cleanup", lock.withoutLock { "cleanup" }) + } + releaseRead.complete(Unit) + assertEquals("wallet_wipe_in_progress", read.await().code) + assertEquals("fresh", lock.withoutLock { "fresh" }) + } + @Test fun `wipe drains active work rejects queued work and permits cleanup and fresh work`() = runTest { val lock = PaykitSdkOperationLock() @@ -30,8 +122,10 @@ class PaykitSdkOperationLockTest { } } runCurrent() - val queued = async { - assertFailsWith { lock.withLock { events.add("stale") } } + val queued = listOf(Priority.Background, Priority.Interactive).map { priority -> + async { + assertFailsWith { lock.withLock(priority) { events.add("stale") } } + } } runCurrent() val wipe = launch { @@ -47,7 +141,7 @@ class PaykitSdkOperationLockTest { releaseActive.complete(Unit) runCurrent() assertTrue(wipeStarted.isCompleted) - assertEquals("wallet_wipe_in_progress", queued.await().code) + queued.forEach { assertEquals("wallet_wipe_in_progress", it.await().code) } releaseWipe.complete(Unit) active.join() wipe.join() @@ -116,12 +210,14 @@ class PaykitSdkOperationLockTest { val release = CompletableDeferred() val active = launch { lock.withLock { release.await() } } runCurrent() - val queued = async { lock.withLock { error("cancelled operation ran") } } + val queued = Priority.entries.map { priority -> + async { lock.withLock(priority) { error("cancelled operation ran") } } + } runCurrent() - queued.cancel() + queued.forEach { it.cancel() } release.complete(Unit) active.join() - assertFailsWith { queued.await() } + queued.forEach { assertFailsWith { it.await() } } lock.withLock {} } } diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index 48f34c4bd3..4dc4fc28f5 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -1,15 +1,19 @@ package to.bitkit.services -import com.synonym.paykit.ContactProfileResolution import com.synonym.paykit.ContactRecord -import com.synonym.paykit.EncryptedLinkRecoveryMarkerPolicy -import com.synonym.paykit.EndpointManagementScope +import com.synonym.paykit.ContactUpdate import com.synonym.paykit.IdentityStatus +import com.synonym.paykit.LinkedPeerHandshakeReport import com.synonym.paykit.LinkedPeerRecord import com.synonym.paykit.LinkedPeerState +import com.synonym.paykit.ObservedBackupStateRevision +import com.synonym.paykit.OutboundPrivateCounterpartySendReport +import com.synonym.paykit.OutboundPrivateSendReport +import com.synonym.paykit.PaykitApp +import com.synonym.paykit.PaykitAppCapabilities +import com.synonym.paykit.PaykitAppRegistry import com.synonym.paykit.PaykitException -import com.synonym.paykit.PaykitReceiverCapabilities -import com.synonym.paykit.PaykitReceiverMarker +import com.synonym.paykit.PaykitIdentitySecretKey import com.synonym.paykit.PaykitSdk import com.synonym.paykit.PaymentRequestLifecycleState import com.synonym.paykit.PaymentRequestLocalRole @@ -17,13 +21,22 @@ import com.synonym.paykit.PaymentRequestRecord import com.synonym.paykit.PaymentRequestRecurrence import com.synonym.paykit.PaymentRequestTerms import com.synonym.paykit.PrivatePaymentListDeliveryReport +import com.synonym.paykit.PrivatePaymentListReservationUpdateInput +import com.synonym.paykit.PrivatePaymentListSyncChange +import com.synonym.paykit.PrivateStreamCounterpartyIntakeReport +import com.synonym.paykit.PrivateStreamIntakeReport +import com.synonym.paykit.ProfileResolution +import com.synonym.paykit.PubkyAuthCompanionClaim import com.synonym.paykit.PubkyClientConfig +import com.synonym.paykit.PubkyIdentityCapability import com.synonym.paykit.PubkyLocalSecretKey import com.synonym.paykit.PubkySessionAccess import com.synonym.paykit.PubkySessionBootstrap import com.synonym.paykit.PubkySessionBootstrapResult +import com.synonym.paykit.PublicContactPaymentResolution import com.synonym.paykit.PublicContactSharingPolicy -import com.synonym.paykit.ReceiverNoiseSecretKey +import com.synonym.paykit.PublicPaymentResolutionStatus +import com.synonym.paykit.paykitAuthorizerSessionCapabilities import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Deferred @@ -31,6 +44,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceTimeBy @@ -38,8 +52,8 @@ import kotlinx.coroutines.test.currentTime import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Test +import org.mockito.Mockito.mockStatic import org.mockito.kotlin.any -import org.mockito.kotlin.atLeastOnce import org.mockito.kotlin.description import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doReturn @@ -49,17 +63,23 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever import to.bitkit.data.PubkyStore import to.bitkit.data.PubkyStoreData +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain import to.bitkit.data.keychain.KeychainError import to.bitkit.data.sharedpubky.SharedPubkyClient -import to.bitkit.ext.fromHex +import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.toHex +import to.bitkit.models.PubkyAuthClaim +import to.bitkit.models.PubkyAuthClaim.Item import to.bitkit.models.PubkyAuthRequestError import to.bitkit.models.PubkyProfileData import to.bitkit.repositories.PubkyContactError +import to.bitkit.services.PaykitSdkOperationLock.Priority import to.bitkit.test.forEachCase import to.bitkit.utils.AppError import kotlin.coroutines.cancellation.CancellationException @@ -83,6 +103,874 @@ class PaykitSdkServiceTest { private val READ_TIMEOUT = 10.seconds } + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun `request discovery completes while unrelated SDK work is blocked`() = runTest { + val sdk = mock() + val releaseContacts = CompletableDeferred() + whenever { sdk.contactRecords() }.doSuspendableAnswer { + releaseContacts.await() + emptyList() + } + whenever { sdk.paykitAppRegistry(RING_PUBKY) }.thenReturn( + PaykitAppRegistry( + 1u, + null, + listOf(PaykitApp("bitkit", "Bitkit", PaykitAppCapabilities(true, true, false, true))), + null, + emptyMap(), + ), + ) + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + val contacts = async { service.contactRecords() } + runCurrent() + val discovery = async { service.canReceivePaymentRequests(RING_PUBKY) } + runCurrent() + + try { + assertTrue(discovery.isCompleted) + assertFalse(contacts.isCompleted) + assertEquals(true, discovery.await()) + } finally { + releaseContacts.complete(Unit) + } + contacts.await() + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun `request discovery discards a result from a replaced runtime`() = runTest { + val sdk = mock() + val releaseLookup = CompletableDeferred() + whenever { sdk.paykitAppRegistry(RING_PUBKY) }.doSuspendableAnswer { + releaseLookup.await() + null + } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + val discovery = async { service.canReceivePaymentRequests(RING_PUBKY) } + runCurrent() + service.clearState() + releaseLookup.complete(Unit) + + assertNull(discovery.await()) + assertEquals(false, service.canReceivePaymentRequests(RING_PUBKY)) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun `request discovery bounds registry lookup and propagates cancellation`() = runTest { + val sdk = mock() + whenever { sdk.paykitAppRegistry(RING_PUBKY) }.doSuspendableAnswer { awaitCancellation() } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + val discovery = async { service.canReceivePaymentRequests(RING_PUBKY) } + advanceTimeBy(5.seconds.inWholeMilliseconds) + runCurrent() + assertNull(discovery.await()) + + val cancelled = async { service.canReceivePaymentRequests(RING_PUBKY) } + runCurrent() + cancelled.cancel() + assertFailsWith { cancelled.await() } + doReturn(null).whenever(sdk).paykitAppRegistry(RING_PUBKY) + assertEquals(false, service.canReceivePaymentRequests(RING_PUBKY)) + } + + @Test + fun `private link calls advance once and allow pending handshakes to resume`() = runTest { + val sdk = mock() + whenever(sdk.stateRevision()).thenReturn("state") + whenever(sdk.backupStateRevision()).thenReturn("backup") + whenever(sdk.ensureLinkWithPeer(any(), any())).thenReturn( + LinkedPeerHandshakeReport(RING_PUBKY, LinkedPeerState.LINKING, 1uL, null), + LinkedPeerHandshakeReport(RING_PUBKY, LinkedPeerState.LINKED, 1uL, null), + ) + val pending = PaykitException.RecoveryRequired("recovery_required", "Handshake pending") + whenever(sdk.prepareAndResolvePrivateContactPayment(RING_PUBKY, null, null, 1u)).thenThrow(pending) + whenever(sdk.prepareAndResolvePrivatePaymentRequest(RING_PUBKY, "request", null, 1u)).thenThrow(pending) + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + + assertEquals(LinkedPeerState.LINKING, service.ensureLinkWithPeer(RING_PUBKY).state) + assertEquals(LinkedPeerState.LINKED, service.ensureLinkWithPeer(RING_PUBKY).state) + assertSame( + pending, + assertFailsWith { + service.prepareAndResolvePrivateContactPayment(RING_PUBKY, null) + }, + ) + assertSame( + pending, + assertFailsWith { + service.prepareAndResolvePrivatePaymentRequest(RING_PUBKY, "request", null) + }, + ) + verify(sdk, times(2)).ensureLinkWithPeer(RING_PUBKY, 1u) + verify(sdk).prepareAndResolvePrivateContactPayment(RING_PUBKY, null, null, 1u) + verify(sdk).prepareAndResolvePrivatePaymentRequest(RING_PUBKY, "request", null, 1u) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun `scoped private receive holds mutation lock and tracks backup changes`() = runTest { + for (cancelActive in listOf(false, true)) { + val sdk = mock() + val report = mock() + val releaseReceive = CompletableDeferred() + var revision = "before" + whenever(sdk.stateRevision()).thenAnswer { revision } + whenever { sdk.backupStateRevision() }.thenAnswer { revision } + whenever { sdk.receivePrivateMessages(RING_PUBKY) }.doSuspendableAnswer { + releaseReceive.await() + revision = "received" + report + } + whenever { sdk.contactRecords() }.thenReturn(emptyList()) + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + val receive = async { service.receivePrivateMessages(RING_PUBKY) } + runCurrent() + val contacts = async { service.contactRecords() } + runCurrent() + val cancelledQueued = async { service.processPendingPrivateMessages() } + runCurrent() + cancelledQueued.cancel() + if (cancelActive) receive.cancel() + runCurrent() + try { + assertFalse(receive.isCompleted) + assertFalse(contacts.isCompleted) + verify(sdk, never()).contactRecords() + } finally { + releaseReceive.complete(Unit) + } + + if (cancelActive) { + assertFailsWith { receive.await() } + } else { + assertSame(report, receive.await()) + } + assertEquals(emptyList(), contacts.await()) + assertFailsWith { cancelledQueued.await() } + assertEquals("received", revision) + assertEquals(1L, service.backupStateVersion.value) + verify(sdk).receivePrivateMessages(RING_PUBKY) + verify(sdk, never()).receivePrivateMessagesFromLinkedPeers() + verify(sdk, never()).processPendingPrivateMessages() + } + } + + @Test + fun `cancelled private operation finishes the active SDK call before releasing the queue`() = runTest { + val operations = listOf Any?>( + { prepareAndResolvePrivateContactPayment(RING_PUBKY, null) }, + { prepareAndResolvePrivatePaymentRequest(RING_PUBKY, "request", null) }, + { processOutboundPrivateMessages(RING_PUBKY, Priority.Interactive) }, + { processPendingPrivateMessages(Priority.Background) }, + { receivePrivateMessagesFromLinkedPeers(Priority.Background) }, + ) + for (operation in operations) { + val sdk = mock() + val release = CompletableDeferred() + var finished = false + whenever { sdk.prepareAndResolvePrivateContactPayment(RING_PUBKY, null, null, 1u) } + .doSuspendableAnswer { + release.await() + finished = true + mock() + } + whenever { sdk.prepareAndResolvePrivatePaymentRequest(RING_PUBKY, "request", null, 1u) } + .doSuspendableAnswer { + release.await() + finished = true + mock() + } + whenever { sdk.processOutboundPrivateMessages(RING_PUBKY) }.doSuspendableAnswer { + release.await() + finished = true + mock() + } + whenever { sdk.processPendingPrivateMessages() }.doSuspendableAnswer { + release.await() + finished = true + emptyList() + } + whenever { sdk.receivePrivateMessagesFromLinkedPeers() }.doSuspendableAnswer { + release.await() + finished = true + emptyList() + } + whenever { sdk.contactRecords() }.thenAnswer { + assertTrue(finished) + emptyList() + } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + val preparation = async { service.operation() } + runCurrent() + val next = async { service.contactRecords() } + preparation.cancel() + runCurrent() + assertFalse(preparation.isCompleted) + assertFalse(next.isCompleted) + assertFalse(finished) + release.complete(Unit) + + assertFailsWith { preparation.await() } + assertEquals(emptyList(), next.await()) + assertTrue(finished) + assertEquals(1L, service.backupStateVersion.value) + } + } + + @Test + fun `queued background work yields to foreground app sync while ordered work remains a barrier`() = runTest { + val operations = listOf Any?>( + { processPendingPrivateMessages(it) }, + { receivePrivateMessagesFromLinkedPeers(it) }, + { ensureLinkWithPeer(RING_PUBKY, priority = it) }, + ) + for (operation in operations) { + val priorities = listOf(Priority.Background, Priority.Ordered) + .flatMap { messages -> listOf(Priority.Ordered, Priority.Interactive).map { messages to it } } + for ((messagePriority, appPriority) in priorities) { + val sdk = mock() + val release = CompletableDeferred() + val events = mutableListOf() + whenever { sdk.contactRecords() }.doSuspendableAnswer { + release.await() + events += "active completed" + emptyList() + } + whenever { sdk.processPendingPrivateMessages() }.thenAnswer { + events += "messages" + emptyList() + } + whenever { sdk.receivePrivateMessagesFromLinkedPeers() }.thenAnswer { + events += "messages" + emptyList() + } + whenever { sdk.ensureLinkWithPeer(RING_PUBKY, 1u) }.thenAnswer { + events += "messages" + LinkedPeerHandshakeReport(RING_PUBKY, LinkedPeerState.LINKING, 1uL, null) + } + whenever { sdk.identityStatus() } + .thenReturn(IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + whenever { sdk.publishPaykitApp(any(), any()) }.thenAnswer { + events += "publication" + mock() + } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + val active = async { service.contactRecords() } + runCurrent() + val messages = async { service.operation(messagePriority) } + val publication = async { + service.syncPaykitApp(privatePaymentsEnabled = false, priority = appPriority) + } + runCurrent() + assertTrue(events.isEmpty()) + release.complete(Unit) + awaitAll(active, messages, publication) + val queued = if (messagePriority == Priority.Background && appPriority == Priority.Interactive) { + listOf("publication", "messages") + } else { + listOf("messages", "publication") + } + assertEquals(listOf("active completed") + queued, events) + verify(sdk).publishPaykitApp("Bitkit", PaykitAppCapabilities(false, true, false, true)) + } + } + } + + @Test + fun `foreground request work overtakes queued routine reads but not active work`() = runTest { + val operations = listOf Any?>( + { identityStatus(Priority.Interactive) }, + { linkedPeers(Priority.Interactive) }, + { allPaymentRequests(null, Priority.Interactive) }, + { processOutboundPrivateMessages(RING_PUBKY, Priority.Interactive) }, + ) + for (operation in operations) { + val sdk = mock() + val release = CompletableDeferred() + val events = mutableListOf() + whenever { sdk.contactRecords() }.doSuspendableAnswer { + release.await() + events += "active completed" + emptyList() + } + whenever { sdk.pendingOutboundPrivateCounterparties() }.thenAnswer { + events += "background" + emptyList() + } + whenever { sdk.identityStatus() }.thenAnswer { + events += "interactive" + IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE) + } + whenever { sdk.linkedPeers() }.thenAnswer { + events += "interactive" + emptyList() + } + whenever { sdk.listPaymentRequests(any()) }.thenAnswer { + events += "interactive" + emptyList() + } + whenever { sdk.processOutboundPrivateMessages(RING_PUBKY) }.thenAnswer { + events += "interactive" + mock() + } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + val active = async { service.contactRecords() } + runCurrent() + val background = async { service.pendingOutboundPrivateCounterparties(Priority.Background) } + val cancelled = async { service.operation() } + runCurrent() + cancelled.cancel() + val foreground = async { service.operation() } + runCurrent() + assertTrue(events.isEmpty()) + release.complete(Unit) + active.await() + foreground.await() + background.await() + assertFailsWith { cancelled.await() } + assertEquals(listOf("active completed", "interactive", "background"), events) + } + } + + @Test + fun `foreground identity read cannot overtake queued signout`() = runTest { + val sdk = mock() + val release = CompletableDeferred() + val events = mutableListOf() + val signedOut = IdentityStatus(null, PubkyIdentityCapability.SIGNED_OUT) + whenever { sdk.contactRecords() }.doSuspendableAnswer { + release.await() + emptyList() + } + whenever { sdk.pendingOutboundPrivateCounterparties() }.thenAnswer { + events += "background" + emptyList() + } + whenever { sdk.signOut() }.thenAnswer { + events += "signout" + signedOut + } + whenever { sdk.identityStatus() }.thenAnswer { + events += "identity" + signedOut + } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + val active = async { service.contactRecords() } + runCurrent() + val background = async { service.pendingOutboundPrivateCounterparties(Priority.Background) } + val signout = async { service.signOut() } + val identity = async { service.identityStatus(Priority.Interactive) } + runCurrent() + release.complete(Unit) + active.await() + background.await() + signout.await() + assertEquals(signedOut, identity.await()) + assertEquals(listOf("background", "signout", "identity"), events) + } + + @Test + fun `payment mutations overtake background work without crossing signout barriers`() = runTest { + val payments = listOf Any?>( + { claimPaymentRequestForExecution(RING_PUBKY, "request") }, + { acceptPaymentRequest(RING_PUBKY, "request") }, + ) + for ((index, payment) in payments.withIndex()) { + for (withBarrier in listOf(false, true)) { + val sdk = mock() + val release = CompletableDeferred() + val events = mutableListOf() + whenever { sdk.contactRecords() }.doSuspendableAnswer { + release.await() + emptyList() + } + whenever { sdk.pendingOutboundPrivateCounterparties() }.thenAnswer { + events += "background" + emptyList() + } + whenever { sdk.signOut() }.thenAnswer { + events += "signout" + IdentityStatus(null, PubkyIdentityCapability.SIGNED_OUT) + } + whenever { sdk.claimPaymentRequestForExecution(RING_PUBKY, "request") }.thenAnswer { + events += "payment" + mock() + } + whenever { sdk.claimAndAcceptPaymentRequest(RING_PUBKY, "request") }.thenAnswer { + events += "payment" + mock() + } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + val active = async { service.contactRecords() } + runCurrent() + val background = async { service.pendingOutboundPrivateCounterparties(Priority.Background) } + val barrier = if (withBarrier) async { service.signOut() } else null + val foreground = async { service.payment() } + runCurrent() + assertTrue(events.isEmpty()) + + release.complete(Unit) + active.await() + background.await() + barrier?.await() + foreground.await() + + assertEquals( + if (withBarrier) listOf("background", "signout", "payment") else listOf("payment", "background"), + events, + ) + verify(sdk, times(if (index == 0) 1 else 0)).claimPaymentRequestForExecution(RING_PUBKY, "request") + verify(sdk, times(if (index == 1) 1 else 0)).claimAndAcceptPaymentRequest(RING_PUBKY, "request") + verify(sdk, never()).acceptPaymentRequest(any(), any()) + } + } + } + + @Test + fun `queued backup rechecks submission state and releases the queue while deferred`() = runTest { + for (cancelBackup in listOf(false, true)) { + val sdk = mock() + val release = CompletableDeferred() + whenever { sdk.contactRecords() }.doSuspendableAnswer { + release.await() + emptyList() + } + whenever { sdk.exportBackupString() }.thenReturn("pending-write-backup") + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + val active = async { service.contactRecords() } + runCurrent() + val backup = async { service.exportBackupState() } + runCurrent() + service.setPaymentSubmissionActive(true) + release.complete(Unit) + active.await() + runCurrent() + + assertFalse(backup.isCompleted) + verify(sdk, never()).exportBackupString() + service.linkedPeers(Priority.Interactive) + if (cancelBackup) { + backup.cancel() + assertFailsWith { backup.await() } + } + service.setPaymentSubmissionActive(false) + runCurrent() + if (!cancelBackup) assertEquals("pending-write-backup", backup.await()) + verify(sdk, times(if (cancelBackup) 0 else 1)).exportBackupString() + } + } + + @Test + fun `deferred backup cannot export a replaced identity or wiped wallet`() = runTest { + val resets = listOf Unit>( + { clearState() }, + { signOut() }, + { withWalletWipe {} }, + ) + for (reset in resets) { + val sdk = mock() + whenever { sdk.exportBackupString() }.thenReturn("replacement-backup") + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + service.setPaymentSubmissionActive(true) + val backup = async { runSuspendCatching { service.exportBackupState() } } + runCurrent() + + service.reset() + service.setPaymentSubmissionActive(false) + + assertTrue(backup.await().isFailure) + verify(sdk, never()).exportBackupString() + } + } + + @Test + fun `queued peer delivery rechecks submission and releases the queue while deferred`() = runTest { + for (cancelDelivery in listOf(false, true)) { + val sdk = mock() + val release = CompletableDeferred() + val report = mock() + whenever { sdk.contactRecords() }.doSuspendableAnswer { + release.await() + emptyList() + } + whenever { sdk.identityStatus() } + .thenReturn(IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + whenever { sdk.processOutboundPrivateMessages(RING_PUBKY) }.thenReturn(report) + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + val active = async { service.contactRecords() } + runCurrent() + val delivery = async { + service.processOutboundPrivateMessages(RING_PUBKY, Priority.Background, RING_PUBKY) + } + runCurrent() + service.setPaymentSubmissionActive(true) + release.complete(Unit) + active.await() + runCurrent() + + assertFalse(delivery.isCompleted) + verify(sdk, never()).identityStatus() + verify(sdk, never()).processOutboundPrivateMessages(any()) + service.linkedPeers(Priority.Interactive) + if (cancelDelivery) { + delivery.cancel() + assertFailsWith { delivery.await() } + } + service.setPaymentSubmissionActive(false) + runCurrent() + if (!cancelDelivery) assertSame(report, delivery.await()) + verify(sdk, times(if (cancelDelivery) 0 else 1)).processOutboundPrivateMessages(RING_PUBKY) + } + } + + @Test + fun `peer delivery defers when submission starts during its identity check`() = runTest { + val sdk = mock() + val checkingIdentity = CompletableDeferred() + val releaseIdentity = CompletableDeferred() + val report = mock() + whenever { sdk.identityStatus() }.doSuspendableAnswer { + checkingIdentity.complete(Unit) + releaseIdentity.await() + IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE) + } + whenever { sdk.processOutboundPrivateMessages(RING_PUBKY) }.thenReturn(report) + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + val delivery = async { + service.processOutboundPrivateMessages(RING_PUBKY, Priority.Background, RING_PUBKY) + } + checkingIdentity.await() + service.setPaymentSubmissionActive(true) + releaseIdentity.complete(Unit) + runCurrent() + + assertFalse(delivery.isCompleted) + verify(sdk, never()).processOutboundPrivateMessages(any()) + service.linkedPeers(Priority.Interactive) + service.setPaymentSubmissionActive(false) + assertSame(report, delivery.await()) + verify(sdk).processOutboundPrivateMessages(RING_PUBKY) + } + + @Test + fun `deferred peer delivery cannot cross a runtime replacement or wallet wipe`() = runTest { + val resets = listOf Unit>( + { clearState() }, + { signOut() }, + { withWalletWipe {} }, + ) + for (reset in resets) { + val sdk = mock() + whenever { sdk.identityStatus() } + .thenReturn(IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + whenever { sdk.processOutboundPrivateMessages(RING_PUBKY) }.thenReturn(mock()) + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + service.setPaymentSubmissionActive(true) + val delivery = async { + runSuspendCatching { + service.processOutboundPrivateMessages(RING_PUBKY, Priority.Background, RING_PUBKY) + } + } + runCurrent() + + service.reset() + service.setPaymentSubmissionActive(false) + + assertTrue(delivery.await().isFailure) + verify(sdk, never()).processOutboundPrivateMessages(any()) + } + } + + @Test + fun `foreground peer delivery remains available during payment submission`() = runTest { + for (priority in listOf(Priority.Ordered, Priority.Interactive)) { + val sdk = mock() + val report = mock() + whenever { sdk.identityStatus() } + .thenReturn(IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + whenever { sdk.processOutboundPrivateMessages(RING_PUBKY) }.thenReturn(report) + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + service.setPaymentSubmissionActive(true) + + assertSame(report, service.processOutboundPrivateMessages(RING_PUBKY, priority, RING_PUBKY)) + verify(sdk).processOutboundPrivateMessages(RING_PUBKY) + } + } + + @Test + fun `deferred peer delivery checks identity inside the queue`() = runTest { + val sdk = mock() + val release = CompletableDeferred() + whenever { sdk.contactRecords() }.doSuspendableAnswer { + release.await() + emptyList() + } + whenever { sdk.identityStatus() }.thenReturn(IdentityStatus(null, PubkyIdentityCapability.SIGNED_OUT)) + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + val active = async { service.contactRecords() } + runCurrent() + val delivery = async { + runSuspendCatching { service.processOutboundPrivateMessages(RING_PUBKY, Priority.Background, RING_PUBKY) } + } + runCurrent() + release.complete(Unit) + active.await() + + assertIs(delivery.await().exceptionOrNull()) + verify(sdk, never()).processOutboundPrivateMessages(any()) + } + + @Test + fun `cancelling active acceptance waits for its durable call and preserves backup notification`() = runTest { + val sdk = mock() + val releaseAcceptance = CompletableDeferred() + var acceptanceFinished = false + whenever { sdk.claimAndAcceptPaymentRequest(RING_PUBKY, "request") }.doSuspendableAnswer { + releaseAcceptance.await() + acceptanceFinished = true + mock() + } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + val acceptance = async { service.acceptPaymentRequest(RING_PUBKY, "request") } + runCurrent() + acceptance.cancel() + runCurrent() + assertFalse(acceptance.isCompleted) + releaseAcceptance.complete(Unit) + + assertFailsWith { acceptance.await() } + assertTrue(acceptanceFinished) + verify(sdk).claimAndAcceptPaymentRequest(RING_PUBKY, "request") + verify(sdk, never()).claimPaymentRequestForExecution(any(), any()) + verify(sdk, never()).acceptPaymentRequest(any(), any()) + assertEquals(1L, service.backupStateVersion.value) + } + + @Test + fun `cancelling the initial backup read completes it without starting the mutation`() = runTest { + val sdk = mock() + val releaseRead = CompletableDeferred() + var readFinished = false + whenever { sdk.backupStateRevision() }.doSuspendableAnswer { + releaseRead.await() + readFinished = true + "backup" + } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + val receive = async { service.receivePrivateMessages(RING_PUBKY) } + runCurrent() + receive.cancel() + runCurrent() + assertFalse(receive.isCompleted) + releaseRead.complete(Unit) + + assertFailsWith { receive.await() } + assertTrue(readFinished) + verify(sdk, never()).receivePrivateMessages(any()) + assertEquals(0L, service.backupStateVersion.value) + } + + @Test + fun `identity failure during cancellation invalidates the cached backup snapshot`() = runTest { + val sdk = mock() + whenever(sdk.stateRevision()).thenReturn("state") + whenever(sdk.observedBackupStateRevision()).thenReturn(ObservedBackupStateRevision("state", "backup")) + whenever { sdk.backupStateRevision() }.thenReturn("backup") + whenever { sdk.processPendingPrivateMessages() }.thenReturn(emptyList()) + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + service.processPendingPrivateMessages() + + val releaseRead = CompletableDeferred() + whenever { sdk.identityStatus() }.doSuspendableAnswer { + releaseRead.await() + throw PaykitException.Identity("identity_error", "Identity changed") + } + val read = async { service.identityStatus() } + runCurrent() + read.cancel() + releaseRead.complete(Unit) + assertFailsWith { read.await() } + + service.processPendingPrivateMessages() + + verify(sdk, times(2)).backupStateRevision() + } + + @Test + fun `initialization can be retried after shared state contention`() = runTest { + val failure = PaykitException.ConcurrentUpdate("concurrent_update", "Resource locked") + val sdk = mock() + whenever(sdk.initialize()).thenThrow(failure).thenReturn(mock()) + whenever(sdk.contactRecords()).thenReturn(emptyList()) + val service = PaykitSdkService( + mock(), + mock(), + mock(), + ioDispatcher = StandardTestDispatcher(testScheduler), + platformInitializer = {}, + settingsStore = mock(), + sdkFactory = { sdk }, + ) + + assertSame(failure, assertFailsWith { service.initialize() }) + assertSame(failure, assertFailsWith { service.contactRecords() }) + service.initialize() + assertEquals(emptyList(), service.contactRecords()) + } + + @Test + fun `saved session setup activates once before admitting public reads`() = runTest { + val keychain = mock() + val store = mock() + whenever(store.data).thenReturn(flowOf(PubkyStoreData())) + val settings = mock() + whenever(settings.data).thenReturn(flowOf(SettingsData(sharesPrivatePaykitEndpoints = false))) + val bootstrap = mock() + val imported = CompletableDeferred() + whenever(bootstrap.importSession("saved-session", null, "capabilities")) + .doSuspendableAnswer { imported.await() } + val sdk = mock() + val initialized = CompletableDeferred() + whenever(sdk.initialize()).doSuspendableAnswer { initialized.await() } + var handlesCreated = 0 + val service = PaykitSdkService( + mock(), + keychain, + store, + bootstrapFactory = { bootstrap }, + ioDispatcher = StandardTestDispatcher(testScheduler), + platformInitializer = {}, + settingsStore = settings, + ) { + handlesCreated++ + sdk + } + mockStatic(Class.forName("com.synonym.paykit.Paykit_androidKt")).use { native -> + native.`when` { paykitAuthorizerSessionCapabilities() }.thenReturn("capabilities") + val restoration = async { service.initializeAndImportSession("saved-session") } + val read = async { service.resolveContactProfile(RING_PUBKY, true) } + runCurrent() + assertFalse(read.isCompleted) + assertEquals(0, handlesCreated) + + val access = localSessionAccess(ByteArray(32) { 1 }) + val result = PubkySessionBootstrapResult(access, RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE) + imported.complete(result) + runCurrent() + assertFalse(read.isCompleted) + verify(sdk, never()).resolveProfile(any(), any()) + initialized.complete(IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + + assertSame(result, restoration.await().getOrThrow()) + assertNull(read.await()) + service.initialize() + assertEquals(1, handlesCreated) + inOrder(keychain, sdk) { + verify(keychain).upsertString(Keychain.Key.PAYKIT_SESSION.name, "new-session") + verify(sdk).initialize() + verify(sdk).publishPaykitNoiseKeyAuthorization() + verify(sdk).publishPaykitApp("Bitkit", PaykitAppCapabilities(false, true, false, true)) + verify(sdk).resolveProfile(RING_PUBKY, true) + } + verify(sdk, times(1)).initialize() + verify(sdk, times(1)).publishPaykitApp(any(), any()) + } + } + + @Test + fun `failed saved session import retains credentials and permits setup retry`() = runTest { + val failures = listOf( + PaykitException.Identity("identity_error", "Expired session"), + PaykitException.Transport("transport_error", "Offline"), + CancellationException("Cancelled"), + ) + for (failure in failures) { + val keychain = mock() + val bootstrap = mock() + whenever(bootstrap.importSession("saved-session", null, "capabilities")).thenThrow(failure) + val sdk = mock() + whenever(sdk.contactRecords()).thenReturn(emptyList()) + val service = PaykitSdkService( + mock(), + keychain, + mock(), + bootstrapFactory = { bootstrap }, + ioDispatcher = StandardTestDispatcher(testScheduler), + platformInitializer = {}, + settingsStore = mock(), + ) { sdk } + + mockStatic(Class.forName("com.synonym.paykit.Paykit_androidKt")).use { native -> + native.`when` { paykitAuthorizerSessionCapabilities() }.thenReturn("capabilities") + if (failure is CancellationException) { + assertFailsWith { service.initializeAndImportSession("saved-session") } + assertFailsWith { service.contactRecords() } + service.initialize() + verify(sdk).initialize() + } else { + assertSame(failure, service.initializeAndImportSession("saved-session").exceptionOrNull()) + verify(sdk, never()).initialize() + } + } + assertEquals(emptyList(), service.contactRecords()) + verify(keychain, never()).delete(any()) + verify(keychain, never()).upsertString(any(), any()) + } + } + + @Test + fun `session activation conflict returns without fallback and retry completes authorization`() = runTest { + for (failAuthorization in listOf(false, true)) { + val keychain = mock() + val store = mock() + whenever(store.data).thenReturn(flowOf(PubkyStoreData())) + val settings = mock() + whenever(settings.data).thenReturn(flowOf(SettingsData(sharesPrivatePaykitEndpoints = false))) + val bootstrap = mock() + val result = PubkySessionBootstrapResult( + localSessionAccess(ByteArray(32) { 1 }), + RING_PUBKY, + PubkyIdentityCapability.PRIVATE_LINK_CAPABLE, + ) + whenever { bootstrap.importSession("saved-session", null, "capabilities") }.thenReturn(result) + whenever { bootstrap.importSession("new-session", null, "capabilities") }.thenReturn(result) + val sdk = mock() + val failure = PaykitException.ConcurrentUpdate("concurrent_update", "Resource locked") + val status = IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE) + whenever { sdk.initialize() }.thenReturn(status) + if (failAuthorization) { + whenever { sdk.publishPaykitNoiseKeyAuthorization() }.thenThrow(failure).thenReturn(mock()) + } else { + whenever { sdk.initialize() }.thenThrow(failure).thenReturn(status) + } + val service = PaykitSdkService( + mock(), + keychain, + store, + bootstrapFactory = { bootstrap }, + ioDispatcher = StandardTestDispatcher(testScheduler), + platformInitializer = {}, + settingsStore = settings, + ) { sdk } + + mockStatic(Class.forName("com.synonym.paykit.Paykit_androidKt")).use { native -> + native.`when` { paykitAuthorizerSessionCapabilities() }.thenReturn("capabilities") + assertSame(failure, service.initializeAndImportSession("saved-session").exceptionOrNull()) + verify(sdk, times(1)).initialize() + verify(sdk, never()).publishPaykitApp(any(), any()) + verify(keychain).upsertString(Keychain.Key.PAYKIT_SESSION.name, "new-session") + verify(keychain, never()).delete(any()) + + assertSame(result, service.importSession("new-session")) + verify(sdk, times(2)).initialize() + verify(sdk, times(if (failAuthorization) 2 else 1)).publishPaykitNoiseKeyAuthorization() + verify(sdk).publishPaykitApp("Bitkit", PaykitAppCapabilities(false, true, false, true)) + } + } + } + @Test fun `wallet wipe drains initialization before cleanup and allows fresh work`() = runTest { val sdk = mock() @@ -104,6 +992,7 @@ class PaykitSdkServiceTest { } } }, + settingsStore = mock(), sdkFactory = { sdk }, ) service.initialize() @@ -115,21 +1004,28 @@ class PaykitSdkServiceTest { } @Test - fun `wallet wipe discards runtime handles before and after cleanup`() = runTest { + fun `wallet wipe discards handles and backup fingerprints even when cleanup fails`() = runTest { val sdk = mock() - whenever(sdk.contactRecords()).thenReturn(emptyList()) + whenever(sdk.processPendingPrivateMessages()).thenReturn(emptyList()) + whenever(sdk.stateRevision()).thenReturn("state") + whenever(sdk.observedBackupStateRevision()).thenReturn(ObservedBackupStateRevision("state", "backup")) + whenever(sdk.backupStateRevision()).thenReturn("backup") var handlesCreated = 0 - val service = PaykitSdkService(mock(), mock(), mock()) { + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { handlesCreated++ sdk } - service.contactRecords() - service.withWalletWipe { - service.contactRecords() - assertEquals(2, handlesCreated) + repeat(2) { service.processPendingPrivateMessages() } + assertFailsWith { + service.withWalletWipe { + service.processPendingPrivateMessages() + assertEquals(2, handlesCreated) + throw AppError("Cleanup failed") + } } - service.contactRecords() + service.processPendingPrivateMessages() assertEquals(3, handlesCreated) + verify(sdk, times(3)).backupStateRevision() } @Test @@ -150,26 +1046,136 @@ class PaykitSdkServiceTest { assertEquals("healthy", paykitStorageCallback("state_save_failed") { "healthy" }) } + @Test + fun `payer ownership follows execution claims and released actionable work`() { + data class Case( + val state: PaymentRequestLifecycleState, + val payer: String?, + val claim: String?, + val hasProof: Boolean = false, + val visible: Boolean, + ) + val cases = listOf( + Case(PaymentRequestLifecycleState.PROPOSED, null, null, visible = true), + Case(PaymentRequestLifecycleState.ACCEPTED, "other", "bitkit", visible = true), + Case(PaymentRequestLifecycleState.ACCEPTED, "bitkit", "other", visible = false), + Case(PaymentRequestLifecycleState.ACCEPTED, "other", null, visible = true), + Case(PaymentRequestLifecycleState.ACCEPTED, "other", null, hasProof = true, visible = false), + Case(PaymentRequestLifecycleState.ACTIVE_RECURRING, "other", null, hasProof = true, visible = true), + Case(PaymentRequestLifecycleState.ACTIVE_RECURRING, "bitkit", "other", visible = false), + Case(PaymentRequestLifecycleState.PROOF_SUBMITTED, "other", "bitkit", hasProof = true, visible = true), + Case(PaymentRequestLifecycleState.PROOF_SUBMITTED, "other", null, hasProof = true, visible = false), + Case(PaymentRequestLifecycleState.CANCELED, "other", null, visible = false), + Case(PaymentRequestLifecycleState.CANCELED, "bitkit", null, visible = true), + Case(PaymentRequestLifecycleState.PROPOSAL_EXPIRED, null, null, visible = true), + ) + cases.forEach { case -> + val record = mock() + whenever(record.localRole).thenReturn(PaymentRequestLocalRole.PAYER) + whenever(record.state).thenReturn(case.state) + whenever(record.payerAppId).thenReturn(case.payer) + whenever(record.executionClaimAppId).thenReturn(case.claim) + whenever(record.paymentProofs).thenReturn(if (case.hasProof) listOf(mock()) else emptyList()) + + assertEquals(case.visible, isBitkitPaymentRequest(record), case.toString()) + } + } + + @Test + fun `all payment requests retain server payee records while payment API remains app scoped`() = runTest { + val server = mock { + on { localRole }.thenReturn(PaymentRequestLocalRole.PAYEE) + on { proposalAppId }.thenReturn("marketplace") + } + val bitkit = mock { + on { localRole }.thenReturn(PaymentRequestLocalRole.PAYEE) + on { proposalAppId }.thenReturn("bitkit") + } + val sdk = mock() + whenever( + sdk.identityStatus() + ).thenReturn(IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + whenever(sdk.listPaymentRequests(any())).thenReturn(listOf(server, bitkit)) + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + + assertEquals(listOf(server, bitkit), service.allPaymentRequests(RING_PUBKY)) + assertEquals(listOf(bitkit), service.paymentRequests()) + } + + @Test + fun `all payment requests reject a different active identity before listing`() = runTest { + val sdk = mock() + whenever( + sdk.identityStatus() + ).thenReturn(IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + + assertFailsWith { + service.allPaymentRequests("a3mduedw686dysw8ndr5c1dyry5h8k6i8hzbbmx9gf9k43zq4s9o") + } + verify(sdk, never()).listPaymentRequests(any()) + } + + @Test + fun `companion approval rejects mismatched requests before accessing keys or transport`() = runTest { + val watchOnly = PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1) + val claim = PubkyAuthCompanionClaim( + queryParameter = PubkyAuthClaim.QUERY_PARAMETER, + claimType = watchOnly.wireValue, + unsignedPayload = ByteArray(84).apply { this[0] = 1 }, + ) + val url = "pubkyauth://signin?x-bitkit-claim=${watchOnly.wireValue}" + val invalidRequests = listOf( + "pubkyauth://signin" to claim, + "$url&x-bitkit-claim=${watchOnly.wireValue}" to claim, + "pubkyauth://signin?x-bitkit-claim=unknown" to claim, + "$url." to claim, + "$url.${watchOnly.wireValue}" to claim, + "pubkyauth://signin?x-bitkit-claim=paykit-access-and-watch-only-account-v1" to claim, + "pubkyauth://signin?x-bitkit-claim=paykit-access-v1.watch-only-account-v1" to + claim.copy(claimType = "watch-only-account-v1.paykit-access-v1"), + "pubkyauth://signin?x-bitkit-claim=watch-only-account-v1.paykit-access-v1" to + claim.copy(claimType = "paykit-access-v1.watch-only-account-v1"), + url to claim.copy(queryParameter = "other-claim"), + url to claim.copy(claimType = PubkyAuthClaim(Item.PAYKIT_ACCESS_V1).wireValue), + url to claim.copy(unsignedPayload = ByteArray(124)), + "pubkyauth://signin?x-bitkit-claim=paykit-access-v1" to + claim.copy(claimType = PubkyAuthClaim(Item.PAYKIT_ACCESS_V1).wireValue), + ) + for ((authUrl, companion) in invalidRequests) { + val keychain = mock() + val sdk = mock() + val service = PaykitSdkService(mock(), keychain, mock(), settingsStore = mock()) { sdk } + assertTrue( + runSuspendCatching { + service.approveAuthWithCompanionClaim( + authUrl, + PubkyAuthClaim.REQUIRED_CAPABILITIES, + "test", + "secret", + companion, + ) + }.isFailure, + ) + verifyNoInteractions(keychain, sdk) + } + } + @Test fun `registered identity activation persists credentials or clears partial activation`() = runTest { - for (failure in listOf(null, "session", "secret", "initialize", "cancel")) { + for (failure in listOf(null, "session", "secret", "initialize", "authorize", "cancel")) { val keychain = mock() val blocking = mock() whenever(keychain.accessBlocking(any())).doAnswer { it.getArgument Any?>(0).invoke(blocking) } val bytes = ByteArray(32) { 1 } - whenever(blocking.load(Keychain.Key.PAYKIT_RECEIVER_NOISE_SECRET_KEY.name)).thenReturn(bytes) val sdk = mock() whenever(sdk.contactRecords()).thenReturn(emptyList()) - val access = mock() - val secret = mock() - val noise = mock() - whenever(secret.exportBytes()).thenReturn(bytes) - whenever(noise.exportBytes()).thenReturn(bytes) - whenever(access.exportSessionSecret()).thenReturn("new-session") - whenever(access.exportLocalSecretKey()).thenReturn(secret) - whenever(access.exportReceiverNoiseSecretKey()).thenReturn(noise) + whenever(sdk.initialize()).thenReturn( + IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE), + ) + val access = localSessionAccess(bytes) val error = if (failure == "cancel") { CancellationException("cancelled") } else { @@ -181,15 +1187,18 @@ class PaykitSdkServiceTest { "secret" -> whenever(keychain.upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, bytes.toHex())) .thenThrow(error) "initialize", "cancel" -> whenever(sdk.initialize()).thenThrow(error) + "authorize" -> whenever(sdk.publishPaykitNoiseKeyAuthorization()).thenThrow(error) } var handlesCreated = 0 val store = mock() whenever(store.data).thenReturn(flowOf(PubkyStoreData())) - val service = PaykitSdkService(mock(), keychain, store) { + val settingsStore = mock() + whenever(settingsStore.data).thenReturn(flowOf(SettingsData(sharesPrivatePaykitEndpoints = false))) + val service = PaykitSdkService(mock(), keychain, store, settingsStore = settingsStore) { handlesCreated++ sdk } - val result = PubkySessionBootstrapResult(access, "pubky_test") + val result = PubkySessionBootstrapResult(access, "pubky_test", PubkyIdentityCapability.PRIVATE_LINK_CAPABLE) if (failure == null) { service.activateRegisteredIdentity(result) @@ -197,14 +1206,16 @@ class PaykitSdkServiceTest { verify(keychain).upsertString(Keychain.Key.PAYKIT_SESSION.name, "new-session") verify(keychain).upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, bytes.toHex()) verify(sdk).initialize() + verify(sdk).publishPaykitNoiseKeyAuthorization() + verify(sdk).publishPaykitApp("Bitkit", PaykitAppCapabilities(false, true, false, true)) } + verify(sdk, never()).identityStatus() verify(blocking, never()).delete(any()) } else { val thrown = assertFailsWith(error::class) { service.activateRegisteredIdentity(result) } - assertEquals(error, thrown) + if (failure == "cancel") assertEquals(error.message, thrown.message) else assertSame(error, thrown) verify(blocking).delete(Keychain.Key.PAYKIT_SESSION.name) verify(blocking).delete(Keychain.Key.PUBKY_SECRET_KEY.name) - verify(keychain, atLeastOnce()).delete(Keychain.Key.PAYKIT_SDK_STATE.name) val handlesBeforeReload = handlesCreated service.contactRecords() assertEquals(handlesBeforeReload + 1, handlesCreated) @@ -213,27 +1224,30 @@ class PaykitSdkServiceTest { } @Test - fun `deletion blocks all known receivers before removing the contact`() = runTest { - for (failBlock in listOf(false, true)) { + fun `deletion blocks the identity before removing the contact`() = runTest { + for ((state, failBlock) in listOf( + LinkedPeerState.LINKED to false, + LinkedPeerState.LINKED to true, + null to false, + null to true, + )) { val sdk = mock() whenever(sdk.paymentRequests()).thenReturn(emptyList()) - val contact = mock { on { receiverPaths } doReturn listOf(PaykitReceiverPaths.WALLET) } + val contact = mock() whenever(sdk.contactRecord(RING_PUBKY)).thenReturn(contact) - val peer = contactPeer(PaykitReceiverPaths.SERVER, LinkedPeerState.LINKED) - whenever(sdk.linkedPeers()).thenReturn(listOf(peer)) + whenever(sdk.linkedPeers()).thenReturn(listOfNotNull(state?.let { contactPeer(it) })) if (failBlock) { - whenever(sdk.blockPeer(RING_PUBKY, PaykitReceiverPaths.SERVER)) + whenever(sdk.blockPeer(RING_PUBKY)) .thenThrow(IllegalStateException("storage failure")) } - val service = PaykitSdkService(mock(), mock(), mock()) { sdk } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } if (failBlock) { assertFailsWith { service.removeContact(RING_PUBKY) } verify(sdk, never()).removeContact(any()) } else { service.removeContact(RING_PUBKY) inOrder(sdk) { - verify(sdk).blockPeer(RING_PUBKY, PaykitReceiverPaths.WALLET) - verify(sdk).blockPeer(RING_PUBKY, PaykitReceiverPaths.SERVER) + verify(sdk).blockPeer(RING_PUBKY) verify(sdk).removeContact(RING_PUBKY) } } @@ -258,10 +1272,12 @@ class PaykitSdkServiceTest { } whenever(sdk.linkedPeers()).thenReturn(emptyList()) whenever(sdk.paymentRequests()).thenReturn(listOf(request)) - val service = PaykitSdkService(mock(), mock(), mock()) { sdk } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } assertFailsWith { service.removeContact(RING_PUBKY) } - verify(sdk, never()).blockPeer(any(), any()) + assertTrue(service.removeContacts(listOf(RING_PUBKY)).isEmpty()) + verify(sdk, never()).blockPeer(any()) verify(sdk, never()).removeContact(any()) + verify(sdk, never()).removeContactsAndBlockPeers(any()) if (endsNaturally) { whenever(recurrence.endsAt).thenReturn("2026-02-01T00:00:00Z") } else { @@ -269,27 +1285,38 @@ class PaykitSdkServiceTest { } service.removeContact(RING_PUBKY) verify(sdk).removeContact(RING_PUBKY) + val removed = listOf(mock()) + whenever(sdk.removeContactsAndBlockPeers(listOf(RING_PUBKY))).thenReturn(removed) + assertEquals(removed, service.removeContacts(listOf(RING_PUBKY))) } } @Test - fun `identity lookup failure preserves stored state and stops activation`() = runTest { + fun `unreadable profile cache stops activation`() = runTest { for (error in listOf( PaykitException.Identity("identity_error", "restore Pubky grant session from platform provider"), PaykitException.Storage("storage_error", "unavailable"), )) { val keychain = mock() val sdk = mock() - whenever(sdk.identityStatus()).thenThrow(error) - val service = PaykitSdkService(mock(), keychain, mock()) { sdk } + val store = mock() + whenever(store.data).thenReturn(flow { throw error }) + val access = mock() + whenever(access.exportSessionSecret()).thenReturn("new-session") + val service = PaykitSdkService(mock(), keychain, store, settingsStore = mock()) { sdk } val thrown = assertFailsWith { - service.activateRegisteredIdentity(PubkySessionBootstrapResult(mock(), "pubky_test")) + service.activateRegisteredIdentity( + PubkySessionBootstrapResult( + access, + "pubky_test", + PubkyIdentityCapability.PRIVATE_LINK_CAPABLE + ) + ) } assertEquals(error, thrown) - verify(keychain, never()).delete(any()) - verify(keychain, never()).upsertString(any(), any()) + verify(sdk, never()).initialize() } } @@ -299,10 +1326,10 @@ class PaykitSdkServiceTest { val sdk = mock() whenever(sdk.paymentRequests()).thenReturn(emptyList()) whenever(sdk.linkedPeers()).thenReturn( - listOf(contactPeer(PaykitReceiverPaths.SERVER, LinkedPeerState.LINKED)), + listOf(contactPeer(LinkedPeerState.LINKED)), ) val withdrawal = whenever( - sdk.clearPrivatePaymentListAndProcessOutbound(RING_PUBKY, PaykitReceiverPaths.SERVER), + sdk.clearPrivatePaymentListAndProcessOutbound(RING_PUBKY), ) if (failWithdrawal) { withdrawal.thenThrow(IllegalStateException("network unavailable")) @@ -311,11 +1338,11 @@ class PaykitSdkServiceTest { PrivatePaymentListDeliveryReport(emptyList(), emptyList(), emptyList(), emptyList()), ) } - val service = PaykitSdkService(mock(), mock(), mock()) { sdk } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } service.removeContact(RING_PUBKY) inOrder(sdk) { - verify(sdk).clearPrivatePaymentListAndProcessOutbound(RING_PUBKY, PaykitReceiverPaths.SERVER) - verify(sdk).blockPeer(RING_PUBKY, PaykitReceiverPaths.SERVER) + verify(sdk).clearPrivatePaymentListAndProcessOutbound(RING_PUBKY) + verify(sdk).blockPeer(RING_PUBKY) verify(sdk).removeContact(RING_PUBKY) } } @@ -327,9 +1354,10 @@ class PaykitSdkServiceTest { val differentKey = "5" + originalKey.drop(1) for ((previousKey, cachedOwner, resetFails) in identityCacheCases(originalKey, differentKey)) { val keychain = mock() - stubReceiverNoiseSecret(keychain) val sdk = mock() - whenever(sdk.identityStatus()).thenReturn(IdentityStatus(previousKey, false)) + whenever( + sdk.identityStatus() + ).thenReturn(IdentityStatus(previousKey, PubkyIdentityCapability.PUBLIC_ONLY)) val originalCache = PubkyStoreData( ownerPublicKey = cachedOwner, cachedName = "Original profile", @@ -347,13 +1375,17 @@ class PaykitSdkServiceTest { val bootstrap = mock() whenever(bootstrap.republishIdentity(any())).thenReturn(true) val access = mock() - val noise = mock() - whenever(noise.exportBytes()).thenReturn(ByteArray(32) { 1 }) + val noise = mock() whenever(access.exportSessionSecret()).thenReturn("new-session") - whenever(access.exportReceiverNoiseSecretKey()).thenReturn(noise) - val service = PaykitSdkService(mock(), keychain, store, { bootstrap }) { sdk } - - val result = PubkySessionBootstrapResult(access, "pubky$originalKey") + whenever(access.exportPaykitIdentitySecretKey()).thenReturn(noise) + val service = PaykitSdkService(mock(), keychain, store, { bootstrap }, settingsStore = mock()) { sdk } + + val result = + PubkySessionBootstrapResult( + access, + "pubky$originalKey", + PubkyIdentityCapability.PRIVATE_LINK_CAPABLE + ) if (resetFails) { assertEquals(resetError, assertFailsWith { service.activateRegisteredIdentity(result) }) verify(sdk, never()).initialize() @@ -362,7 +1394,7 @@ class PaykitSdkServiceTest { } service.activateRegisteredIdentity(result) - if (previousKey == differentKey || cachedOwner == differentKey) { + if (cachedOwner == differentKey) { assertEquals(PubkyStoreData(), cache) inOrder(store, sdk) { verify(store).reset() @@ -378,11 +1410,11 @@ class PaykitSdkServiceTest { @Test fun `public reads and locked operations do not wait for each other`() = runTest { val sdk = mock() - val service = PaykitSdkService(mock(), mock(), mock()) { sdk } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } whenever(sdk.contactRecords()).thenReturn(emptyList()) service.contactRecords() - val readGate = CompletableDeferred() - whenever(sdk.resolveContactProfile(RING_PUBKY, PaykitReceiverPaths.WALLET, true)) + val readGate = CompletableDeferred() + whenever(sdk.resolveProfile(RING_PUBKY, true)) .doSuspendableAnswer { readGate.await() } val read = async { service.resolveContactProfile(RING_PUBKY, allowPubkyProfileFallback = true) } @@ -394,19 +1426,73 @@ class PaykitSdkServiceTest { val lockedGate = CompletableDeferred>() whenever(sdk.contactRecords()).doSuspendableAnswer { lockedGate.await() } - whenever(sdk.fetchPubkyFollows(RING_PUBKY)).thenReturn(listOf("follow")) + whenever(sdk.fetchPubkyFollows(RING_PUBKY, 10_000u)).thenReturn(listOf("follow")) + whenever(sdk.resolvePublicContactPayment(RING_PUBKY, null)).thenReturn( + PublicContactPaymentResolution(PublicPaymentResolutionStatus.NO_ENDPOINT, emptyList(), emptyList()), + ) val locked = async { service.contactRecords() } runCurrent() assertEquals(listOf("follow"), service.fetchPubkyFollows(RING_PUBKY)) + assertTrue(service.resolvePublicContactPayment(RING_PUBKY).payableEndpoints.isEmpty()) assertFalse(locked.isCompleted) lockedGate.complete(emptyList()) assertEquals(emptyList(), locked.await()) } + @Test + fun `public payment resolution waits for setup`() = runTest { + val sdk = mock() + whenever(sdk.resolvePublicContactPayment(RING_PUBKY, null)).thenReturn( + PublicContactPaymentResolution(PublicPaymentResolutionStatus.NO_ENDPOINT, emptyList(), emptyList()), + ) + val service = PaykitSdkService( + mock(), + mock(), + mock(), + ioDispatcher = StandardTestDispatcher(testScheduler), + platformInitializer = {}, + settingsStore = mock(), + sdkFactory = { sdk }, + ) + val read = async { service.resolvePublicContactPayment(RING_PUBKY) } + runCurrent() + assertFalse(read.isCompleted) + verify(sdk, never()).resolvePublicContactPayment(RING_PUBKY, null) + service.initialize() + assertTrue(read.await().payableEndpoints.isEmpty()) + } + + @Test + fun `public payment resolution rejects interruption and allows fresh reads`() = runTest { + for (interruption in listOf("reset", "wipe", "cancel")) { + val sdk = mock() + val gate = CompletableDeferred() + whenever(sdk.resolvePublicContactPayment(RING_PUBKY, null)).doSuspendableAnswer { + gate.await() + PublicContactPaymentResolution(PublicPaymentResolutionStatus.NO_ENDPOINT, emptyList(), emptyList()) + } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + val read = async { runSuspendCatching { service.resolvePublicContactPayment(RING_PUBKY) } } + runCurrent() + when (interruption) { + "reset" -> service.clearState() + "wipe" -> service.withWalletWipe {} + "cancel" -> read.cancel() + } + gate.complete(Unit) + if (interruption == "cancel") { + assertFailsWith { read.await() } + } else { + assertTrue(read.await().isFailure) + } + assertTrue(service.resolvePublicContactPayment(RING_PUBKY).payableEndpoints.isEmpty()) + } + } + @Test fun `public reads run at most six at a time`() = runTest { val sdk = mock() - val service = PaykitSdkService(mock(), mock(), mock()) { sdk } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } whenever(sdk.contactRecords()).thenReturn(emptyList()) service.contactRecords() val gate = CompletableDeferred() @@ -433,22 +1519,37 @@ class PaykitSdkServiceTest { fun `public reads started without an sdk instance wait for the operation lock then run concurrently`() = runTest { val keychain = mock() val lockedGate = CompletableDeferred() - whenever(keychain.delete(Keychain.Key.PAYKIT_SDK_STATE.name)).doSuspendableAnswer { lockedGate.await() } + whenever(keychain.upsertString(Keychain.Key.PAYKIT_SESSION.name, "session")) + .doSuspendableAnswer { lockedGate.await() } + val access = mock() + whenever(access.exportSessionSecret()).thenReturn("session") + val store = mock() + whenever(store.data).thenReturn(flowOf(PubkyStoreData())) val sdk = mock() val readGate = CompletableDeferred() var active = 0 - whenever(sdk.fetchPubkyFollows(RING_PUBKY)).doSuspendableAnswer { + whenever(sdk.fetchPubkyFollows(RING_PUBKY, 10_000u)).doSuspendableAnswer { active++ readGate.await() listOf("follow") } var handlesCreated = 0 - val service = PaykitSdkService(mock(), keychain, mock()) { + val service = PaykitSdkService( + mock(), + keychain, + store, + bootstrapFactory = { mock() }, + settingsStore = mock(), + ) { handlesCreated++ sdk } - val locked = async { service.clearState() } + val locked = async { + service.activateRegisteredIdentity( + PubkySessionBootstrapResult(access, RING_PUBKY, PubkyIdentityCapability.PUBLIC_ONLY), + ) + } runCurrent() val reads = List(3) { async { service.fetchPubkyFollows(RING_PUBKY) } } runCurrent() @@ -465,71 +1566,16 @@ class PaykitSdkServiceTest { locked.await() } - @Test - fun `receiver reads run outside the operation lock with their filtering intact`() = runTest { - val sdk = mock() - val service = PaykitSdkService(mock(), mock(), mock()) { sdk } - whenever(sdk.contactRecords()).thenReturn(emptyList()) - service.contactRecords() - whenever(sdk.paykitReceiverPaths(RING_PUBKY)) - .thenReturn(listOf(PaykitReceiverPaths.WALLET, PaykitReceiverPaths.SERVER, "other/path")) - whenever(sdk.paykitReceiverMarker(RING_PUBKY, PaykitReceiverPaths.WALLET)) - .thenReturn(receiverMarker(PaykitReceiverPaths.WALLET, paymentRequests = true, outgoingPayments = true)) - whenever(sdk.paykitReceiverMarker(RING_PUBKY, PaykitReceiverPaths.SERVER)) - .thenReturn(receiverMarker(PaykitReceiverPaths.SERVER, paymentRequests = false, outgoingPayments = false)) - val lockedGate = CompletableDeferred>() - whenever(sdk.contactRecords()).doSuspendableAnswer { lockedGate.await() } - val locked = async { service.contactRecords() } - runCurrent() - - assertEquals( - listOf(PaykitReceiverPaths.WALLET, PaykitReceiverPaths.SERVER), - service.discoverRelevantReceiverPaths(RING_PUBKY, PaykitReadLane.Bulk), - ) - assertEquals( - listOf(PaykitReceiverPaths.WALLET), - service.paymentRequestReceiverPaths(RING_PUBKY, PaykitReadLane.Bulk), - ) - val selection = - service.privateReceiverPathSelection(RING_PUBKY, listOf(PaykitReceiverPaths.SERVER), PaykitReadLane.Bulk) - assertEquals(listOf(PaykitReceiverPaths.WALLET, PaykitReceiverPaths.SERVER), selection.linkableReceiverPaths) - assertEquals(listOf(PaykitReceiverPaths.WALLET), selection.publishableReceiverPaths) - assertEquals(emptyList(), selection.cleanupProtectedReceiverPaths) - assertNull(selection.error) - assertFalse(locked.isCompleted) - - lockedGate.complete(emptyList()) - assertEquals(emptyList(), locked.await()) - } - - @Test - fun `private receiver selection protects a path whose marker read fails`() = runTest { - val sdk = mock() - val service = PaykitSdkService(mock(), mock(), mock()) { sdk } - val failure = AppError("Marker unavailable") - whenever(sdk.paykitReceiverMarker(RING_PUBKY, PaykitReceiverPaths.WALLET)) - .thenReturn(receiverMarker(PaykitReceiverPaths.WALLET, paymentRequests = true, outgoingPayments = true)) - whenever(sdk.paykitReceiverMarker(RING_PUBKY, PaykitReceiverPaths.SERVER)).thenAnswer { throw failure } - - val selection = - service.privateReceiverPathSelection(RING_PUBKY, listOf(PaykitReceiverPaths.SERVER), PaykitReadLane.Bulk) - - assertEquals(listOf(PaykitReceiverPaths.WALLET), selection.linkableReceiverPaths) - assertEquals(listOf(PaykitReceiverPaths.WALLET), selection.publishableReceiverPaths) - assertEquals(listOf(PaykitReceiverPaths.SERVER), selection.cleanupProtectedReceiverPaths) - assertSame(failure, selection.error) - } - @Test fun `bulk reads leave two read permits to interactive reads`() = runTest { val sdk = mock() - val service = PaykitSdkService(mock(), mock(), mock()) { sdk } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } whenever(sdk.contactRecords()).thenReturn(emptyList()) service.contactRecords() val gate = CompletableDeferred() var bulkActive = 0 var interactiveActive = 0 - whenever(sdk.resolveContactProfile(any(), any(), any())).doSuspendableAnswer { + whenever(sdk.resolveProfile(any(), any())).doSuspendableAnswer { bulkActive++ gate.await() bulkActive-- @@ -556,26 +1602,26 @@ class PaykitSdkServiceTest { gate.complete(Unit) bulkReads.awaitAll() interactiveReads.awaitAll() - verify(sdk, times(10)).resolveContactProfile(any(), any(), any()) + verify(sdk, times(10)).resolveProfile(any(), any()) verify(sdk, times(3)).fetchPubkyFileBounded(FILE_URI, 1uL) } @Test fun `bulk reads start in request order and a cancelled one frees both permits`() = runTest { val sdk = mock() - val service = PaykitSdkService(mock(), mock(), mock()) { sdk } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } whenever(sdk.contactRecords()).thenReturn(emptyList()) service.contactRecords() val started = mutableListOf() val gates = List(6) { CompletableDeferred() } - whenever(sdk.paykitReceiverPaths(any())).doSuspendableAnswer { + whenever(sdk.resolveProfile(any(), any())).doSuspendableAnswer { val key = it.getArgument(0) started += key gates[key.removePrefix(RING_PUBKY).toInt()].await() - emptyList() + null } val reads = List(6) { - async { service.discoverRelevantReceiverPaths("$RING_PUBKY$it", PaykitReadLane.Bulk) } + async { service.resolveContactProfile("$RING_PUBKY$it", true, PaykitReadLane.Bulk) } } runCurrent() assertEquals(List(4) { "$RING_PUBKY$it" }, started) @@ -667,10 +1713,10 @@ class PaykitSdkServiceTest { @Test fun `a read timeout counts only the time the read holds its slot and fails with its own error`() = runTest { val sdk = mock() - val service = PaykitSdkService(mock(), mock(), mock()) { sdk } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } val busy = List(6) { "$RING_PUBKY-busy-$it" } val freeSlots = CompletableDeferred() - whenever(sdk.resolveContactProfile(any(), any(), any())).doSuspendableAnswer { + whenever(sdk.resolveProfile(any(), any())).doSuspendableAnswer { when (it.getArgument(0)) { in busy -> freeSlots.await() "$RING_PUBKY-stuck" -> awaitCancellation() @@ -702,7 +1748,6 @@ class PaykitSdkServiceTest { fun `activation returns while identity publication runs and approval republish joins it until the cap`() = runTest { listOf("publication finishes" to true, "cap" to false).forEachCase({ it.first }) { (case, gateOpens) -> val keychain = mock() - stubReceiverNoiseSecret(keychain) val store = mock() whenever(store.data).thenReturn(flowOf(PubkyStoreData())) val publicationGate = CompletableDeferred() @@ -712,10 +1757,7 @@ class PaykitSdkServiceTest { publicationGate.await().also { published = true } } val access = mock() - val noise = mock() - whenever(noise.exportBytes()).thenReturn(ByteArray(32) { 1 }) whenever(access.exportSessionSecret()).thenReturn("new-session") - whenever(access.exportReceiverNoiseSecretKey()).thenReturn(noise) val sdk = mock() val service = PaykitSdkService( context = mock(), @@ -723,12 +1765,19 @@ class PaykitSdkServiceTest { pubkyStore = store, bootstrapFactory = { bootstrap }, ioDispatcher = StandardTestDispatcher(testScheduler), + settingsStore = mock(), sdkFactory = { sdk }, ) val activationStart = currentTime val activation = async { - service.activateRegisteredIdentity(PubkySessionBootstrapResult(access, "pubky$RING_PUBKY")) + service.activateRegisteredIdentity( + PubkySessionBootstrapResult( + access, + "pubky$RING_PUBKY", + PubkyIdentityCapability.PRIVATE_LINK_CAPABLE + ) + ) } runCurrent() @@ -756,90 +1805,180 @@ class PaykitSdkServiceTest { } @Test - fun `only explicit readd unblocks every saved private receiver`() = runTest { + fun `only explicit readd restores private connections`() = runTest { for (restoreConnection in listOf(false, true)) { val sdk = mock() - whenever(sdk.saveContact(any())).thenReturn(mock()) - whenever(sdk.linkedPeers()).thenReturn( - listOf( - contactPeer(PaykitReceiverPaths.WALLET, LinkedPeerState.BLOCKED), - contactPeer(PaykitReceiverPaths.SERVER, LinkedPeerState.BLOCKED), - ), - ) - val service = PaykitSdkService(mock(), mock(), mock()) { sdk } + val record = mock() + whenever(sdk.contactRecord(RING_PUBKY)).thenReturn(record) + whenever(sdk.saveContact(any())).thenReturn(record) + whenever(sdk.saveContactsAndUnblockPeers(any())).thenReturn(listOf(record)) + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + + assertSame(record, service.saveContact(RING_PUBKY, "Contact", restorePrivateConnection = restoreConnection)) + if (restoreConnection) { - service.saveContact(RING_PUBKY, "Contact", restorePrivateConnection = true) - verify(sdk).unblockPeer(RING_PUBKY, PaykitReceiverPaths.WALLET) - verify(sdk).unblockPeer(RING_PUBKY, PaykitReceiverPaths.SERVER) - } else { - assertFailsWith { service.saveContact(RING_PUBKY, "Contact") } + verify(sdk).saveContactsAndUnblockPeers(listOf(ContactUpdate(RING_PUBKY, "Contact"))) + verify(sdk, never()).contactRecord(any()) verify(sdk, never()).saveContact(any()) - verify(sdk, never()).unblockPeer(any(), any()) + } else { + verify(sdk).saveContact(ContactUpdate(RING_PUBKY, "Contact")) + verify(sdk, never()).saveContactsAndUnblockPeers(any()) } + verify(sdk, never()).linkedPeers() + verify(sdk, never()).unblockPeer(any()) + } + } + + @Test + fun `editing a removed contact does not restore it`() = runTest { + val sdk = mock() + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + + assertFailsWith { service.saveContact(RING_PUBKY, "Contact") } + + verify(sdk, never()).saveContact(any()) + verify(sdk, never()).saveContactsAndUnblockPeers(any()) + } + + @Test + fun `bulk contact restore uses one SDK mutation and tracks its backup change`() = runTest { + val sdk = mock() + val updates = listOf(ContactUpdate("pubky$RING_PUBKY", "Contact")) + val records = listOf(mock()) + var revision = "before" + whenever(sdk.stateRevision()).thenAnswer { revision } + whenever(sdk.backupStateRevision()).thenAnswer { revision } + whenever(sdk.saveContactsAndUnblockPeers(updates)).thenAnswer { + revision = "saved" + records } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + + assertEquals(emptyList(), service.saveContacts(emptyList())) + verifyNoInteractions(sdk) + assertSame(records, service.saveContacts(updates)) + + verify(sdk).saveContactsAndUnblockPeers(updates) + verify(sdk, never()).linkedPeers() + verify(sdk, never()).unblockPeer(any()) + verify(sdk, never()).saveContacts(any()) + verify(sdk, never()).saveContact(any()) + verify(sdk, never()).contactRecord(any()) + verify(sdk, never()).blockPeer(any()) + assertEquals(1L, service.backupStateVersion.value) } @Test - fun `failed private connection restoration leaves contact creation retryable`() = runTest { - for (failurePoint in listOf("lookup", "unblock", "save", "cancel")) { + fun `failed contact restoration permits retry without compensating writes`() = runTest { + for (bulk in listOf(false, true)) { val sdk = mock() - val peers = listOf( - contactPeer(PaykitReceiverPaths.WALLET, LinkedPeerState.BLOCKED), - contactPeer(PaykitReceiverPaths.SERVER, LinkedPeerState.BLOCKED), - ) - val failure = if (failurePoint == "cancel") { - CancellationException("cancelled") + val updates = listOf(ContactUpdate(RING_PUBKY, "Contact")) + val record = mock() + val failure = IllegalStateException("storage failure") + whenever(sdk.saveContactsAndUnblockPeers(updates)).thenThrow(failure).thenReturn(listOf(record)) + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + suspend fun save() = if (bulk) { + service.saveContacts(updates) } else { - IllegalStateException("storage failure") + listOf(service.saveContact(RING_PUBKY, "Contact", restorePrivateConnection = true)) } - whenever(sdk.linkedPeers()).thenReturn(peers) - whenever(sdk.saveContact(any())).thenReturn(mock()) - when (failurePoint) { - "lookup" -> whenever(sdk.linkedPeers()).thenThrow(failure).thenReturn(peers) - "unblock", "cancel" -> { - whenever(sdk.unblockPeer(RING_PUBKY, PaykitReceiverPaths.SERVER)) - .thenThrow(failure).thenReturn(peers.last().copy(state = LinkedPeerState.NOT_LINKED)) - } - "save" -> whenever(sdk.saveContact(any())).thenThrow(failure).thenReturn(mock()) + + assertSame(failure, assertFailsWith { save() }) + assertEquals(listOf(record), save()) + + verify(sdk, times(2)).saveContactsAndUnblockPeers(updates) + verify(sdk, never()).blockPeer(any()) + verify(sdk, never()).unblockPeer(any()) + } + } + + @Test + fun `queued bulk save rejects changed identity ended sign-in and cancellation`() = runTest { + for (change in listOf("identity", "sign-in", "cancel")) { + val sdk = mock() + val identity = "pubky$RING_PUBKY" + var currentIdentity = identity + var isCurrent = true + val releaseRead = CompletableDeferred() + whenever(sdk.identityStatus()).thenAnswer { + IdentityStatus(currentIdentity, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE) } - val service = PaykitSdkService(mock(), mock(), mock()) { sdk } - val thrown = assertFailsWith { - service.saveContact(RING_PUBKY, "Contact", restorePrivateConnection = true) + whenever(sdk.contactRecords()).doSuspendableAnswer { + releaseRead.await() + emptyList() } - assertSame(failure, thrown) - if (failurePoint == "lookup") { - verify(sdk, never()).blockPeer(any(), any()) - } else { - verify(sdk).blockPeer(RING_PUBKY, PaykitReceiverPaths.WALLET) - verify(sdk).blockPeer(RING_PUBKY, PaykitReceiverPaths.SERVER) + whenever(sdk.saveContactsAndUnblockPeers(any())).thenReturn(emptyList()) + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + val read = async { service.contactRecords() } + runCurrent() + val updates = listOf(ContactUpdate("pubky5${RING_PUBKY.drop(1)}", "Contact")) + val save = async { + runSuspendCatching { service.saveContacts(updates, identity) { isCurrent } } + } + runCurrent() + assertFalse(save.isCompleted) + when (change) { + "identity" -> currentIdentity = "pubky8${RING_PUBKY.drop(1)}" + "sign-in" -> isCurrent = false + "cancel" -> { + save.cancel() + runCurrent() + assertTrue(save.isCompleted) + } + } + releaseRead.complete(Unit) + read.await() + + when (change) { + "identity" -> assertIs(save.await().exceptionOrNull()) + "sign-in" -> assertSame(PubkyContactError.SignInChanged, save.await().exceptionOrNull()) + "cancel" -> assertFailsWith { save.await() } } - service.saveContact(RING_PUBKY, "Contact", restorePrivateConnection = true) - verify(sdk, atLeastOnce()).saveContact(any()) + verify(sdk, never()).saveContactsAndUnblockPeers(any()) + service.saveContacts(updates, currentIdentity) { true } + verify(sdk).saveContactsAndUnblockPeers(updates) } } @Test - fun `private connection restoration preserves failures from rollback`() = runTest { - val sdk = mock() - val peers = listOf( - contactPeer(PaykitReceiverPaths.WALLET, LinkedPeerState.BLOCKED), - contactPeer(PaykitReceiverPaths.SERVER, LinkedPeerState.BLOCKED), - ) - val restorationFailure = IllegalStateException("save failed") - val rollbackFailure = IllegalStateException("block failed") - whenever(sdk.linkedPeers()).thenReturn(peers) - whenever(sdk.saveContact(any())).thenThrow(restorationFailure) - whenever(sdk.blockPeer(RING_PUBKY, PaykitReceiverPaths.WALLET)).thenThrow(rollbackFailure) - val service = PaykitSdkService(mock(), mock(), mock()) { sdk } - - val thrown = assertFailsWith { - service.saveContact(RING_PUBKY, "Contact", restorePrivateConnection = true) + fun `cancelled contact restore completes its atomic mutation before releasing the queue`() = runTest { + for (bulk in listOf(false, true)) { + val sdk = mock() + val releaseSave = CompletableDeferred() + val events = mutableListOf() + whenever(sdk.saveContactsAndUnblockPeers(any())).doSuspendableAnswer { + releaseSave.await() + events += "saved" + listOf(mock()) + } + whenever(sdk.contactRecords()).thenAnswer { + events += "next" + emptyList() + } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + val updates = listOf(ContactUpdate(RING_PUBKY, "Contact")) + val save = async { + if (bulk) { + service.saveContacts(updates) + } else { + service.saveContact(RING_PUBKY, "Contact", restorePrivateConnection = true) + } + } + runCurrent() + save.cancel() + val next = async { service.contactRecords() } + runCurrent() + assertEquals(emptyList(), events) + assertFalse(next.isCompleted) + releaseSave.complete(Unit) + + assertFailsWith { save.await() } + next.await() + assertEquals(listOf("saved", "next"), events) + verify(sdk).saveContactsAndUnblockPeers(updates) + verify(sdk, never()).blockPeer(any()) + assertEquals(1L, service.backupStateVersion.value) } - - assertSame(restorationFailure, thrown) - assertEquals(listOf(rollbackFailure), thrown.suppressed.toList()) - verify(sdk).blockPeer(RING_PUBKY, PaykitReceiverPaths.WALLET) - verify(sdk).blockPeer(RING_PUBKY, PaykitReceiverPaths.SERVER) } @Test @@ -848,36 +1987,38 @@ class PaykitSdkServiceTest { val newIdentity = "pubky5${RING_PUBKY.drop(1)}" val contactKey = "pubky8${RING_PUBKY.drop(1)}" val keychain = mock() - stubReceiverNoiseSecret(keychain) val identityChangeGate = CompletableDeferred() - whenever(keychain.delete(Keychain.Key.PAYKIT_SDK_STATE.name)).doSuspendableAnswer { identityChangeGate.await() } + whenever(keychain.upsertString(Keychain.Key.PAYKIT_SESSION.name, "new-session")) + .doSuspendableAnswer { identityChangeGate.await() } val store = mock() whenever(store.data).thenReturn(flowOf(PubkyStoreData())) - val bootstrap = mock() - whenever(bootstrap.republishIdentity(any())).thenReturn(true) val access = mock() - val noise = mock() - whenever(noise.exportBytes()).thenReturn(ByteArray(32) { 1 }) whenever(access.exportSessionSecret()).thenReturn("new-session") - whenever(access.exportReceiverNoiseSecretKey()).thenReturn(noise) val originalSdk = mock() - whenever(originalSdk.identityStatus()).thenReturn(IdentityStatus(originalIdentity, true)) + whenever( + originalSdk.identityStatus() + ).thenReturn(IdentityStatus(originalIdentity, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) val newSdk = mock() - whenever(newSdk.identityStatus()).thenReturn(IdentityStatus(newIdentity, true)) - whenever(newSdk.linkedPeers()).thenReturn(emptyList()) - whenever(newSdk.saveContact(any())).thenReturn(mock()) + whenever( + newSdk.identityStatus() + ).thenReturn(IdentityStatus(newIdentity, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + whenever(newSdk.saveContactsAndUnblockPeers(any())).thenReturn(listOf(mock())) val handles = ArrayDeque(listOf(originalSdk, newSdk)) val service = PaykitSdkService( context = mock(), keychain = keychain, pubkyStore = store, - bootstrapFactory = { bootstrap }, + bootstrapFactory = { mock() }, ioDispatcher = StandardTestDispatcher(testScheduler), + settingsStore = mock(), sdkFactory = { handles.removeFirst() }, ) + assertEquals(originalIdentity, service.identityStatus()?.publicKey) val identityChange = async { - service.activateRegisteredIdentity(PubkySessionBootstrapResult(access, newIdentity)) + service.activateRegisteredIdentity( + PubkySessionBootstrapResult(access, newIdentity, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE), + ) } runCurrent() val save = async { @@ -896,9 +2037,9 @@ class PaykitSdkServiceTest { identityChange.await() save.await() - verify(newSdk, never()).saveContact(any()) + verify(newSdk, never()).saveContactsAndUnblockPeers(any()) service.saveContact(contactKey, "Contact", restorePrivateConnection = true, expectedIdentity = newIdentity) - verify(newSdk).saveContact(any()) + verify(newSdk).saveContactsAndUnblockPeers(any()) } /** @@ -910,34 +2051,33 @@ class PaykitSdkServiceTest { val identity = "pubky$RING_PUBKY" val contactKey = "pubky8${RING_PUBKY.drop(1)}" val keychain = mock() - stubReceiverNoiseSecret(keychain) val sessionChangeGate = CompletableDeferred() whenever(keychain.upsertString(Keychain.Key.PAYKIT_SESSION.name, "new-session")) .doSuspendableAnswer { sessionChangeGate.await() } val store = mock() whenever(store.data).thenReturn(flowOf(PubkyStoreData())) - val bootstrap = mock() - whenever(bootstrap.republishIdentity(any())).thenReturn(true) val access = mock() - val noise = mock() - whenever(noise.exportBytes()).thenReturn(ByteArray(32) { 1 }) whenever(access.exportSessionSecret()).thenReturn("new-session") - whenever(access.exportReceiverNoiseSecretKey()).thenReturn(noise) val originalSdk = mock() - whenever(originalSdk.identityStatus()).thenReturn(IdentityStatus(identity, true)) + whenever( + originalSdk.identityStatus() + ).thenReturn(IdentityStatus(identity, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) val newSdk = mock() - whenever(newSdk.identityStatus()).thenReturn(IdentityStatus(identity, true)) - whenever(newSdk.linkedPeers()).thenReturn(emptyList()) - whenever(newSdk.saveContact(any())).thenReturn(mock()) + whenever( + newSdk.identityStatus() + ).thenReturn(IdentityStatus(identity, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + whenever(newSdk.saveContactsAndUnblockPeers(any())).thenReturn(listOf(mock())) val handles = ArrayDeque(listOf(originalSdk, newSdk)) val service = PaykitSdkService( context = mock(), keychain = keychain, pubkyStore = store, - bootstrapFactory = { bootstrap }, + bootstrapFactory = { mock() }, ioDispatcher = StandardTestDispatcher(testScheduler), + settingsStore = mock(), sdkFactory = { handles.removeFirst() }, ) + assertEquals(identity, service.identityStatus()?.publicKey) suspend fun save(isStillCurrent: () -> Boolean) = service.saveContact( contactKey, "Contact", @@ -948,7 +2088,9 @@ class PaykitSdkServiceTest { var isSignInCurrent = true val sessionChange = async { - service.activateRegisteredIdentity(PubkySessionBootstrapResult(access, identity)) + service.activateRegisteredIdentity( + PubkySessionBootstrapResult(access, identity, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE), + ) } runCurrent() val queuedSave = async { assertFailsWith { save { isSignInCurrent } } } @@ -959,18 +2101,178 @@ class PaykitSdkServiceTest { sessionChange.await() queuedSave.await() - verify(newSdk, never()).saveContact(any()) + verify(newSdk, never()).saveContactsAndUnblockPeers(any()) save { true } - verify(newSdk).saveContact(any()) + verify(newSdk).saveContactsAndUnblockPeers(any()) } @Test fun `blocked peer cleanup does not attempt network delivery`() = runTest { val sdk = mock() - whenever(sdk.linkedPeers()).thenReturn(listOf(contactPeer(PaykitReceiverPaths.SERVER, LinkedPeerState.BLOCKED))) - val service = PaykitSdkService(mock(), mock(), mock()) { sdk } - assertNull(service.clearPrivatePaymentList(RING_PUBKY, PaykitReceiverPaths.SERVER)) - verify(sdk, never()).clearPrivatePaymentListAndProcessOutbound(any(), any()) + whenever(sdk.linkedPeers()).thenReturn(listOf(contactPeer(LinkedPeerState.BLOCKED))) + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + assertNull(service.clearPrivatePaymentLists(listOf(RING_PUBKY))) + verify(sdk, never()).syncPrivatePaymentListsWithReservationsAndProcessOutbound(any(), any()) + } + + @Test + fun `disabled private capability does not queue withdrawal`() = runTest { + val sdk = mock() + val capabilities = PaykitAppCapabilities(false, true, false, true) + whenever(sdk.linkedPeers()).thenReturn(listOf(contactPeer(LinkedPeerState.LINKED))) + whenever(sdk.identityStatus()).thenReturn( + IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE), + ) + whenever(sdk.paykitAppRegistry(RING_PUBKY)).thenReturn( + PaykitAppRegistry(1u, null, listOf(PaykitApp("bitkit", "Bitkit", capabilities)), null, emptyMap()), + ) + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + + assertNull(service.clearPrivatePaymentLists(listOf(RING_PUBKY))) + + verify(sdk, never()).syncPrivatePaymentListsWithReservationsAndProcessOutbound(any(), any()) + verify(sdk, never()).publishPaykitApp(any(), any()) + } + + @Test + fun `withdrawal batches preflight and delegates repeated empty lists`() = runTest { + val sdk = mock() + val other = "pubky5rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + val blocked = "pubky6rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + val blockedPeer = contactPeer(LinkedPeerState.BLOCKED).copy(counterparty = blocked) + val capabilities = PaykitAppCapabilities(true, true, false, true) + whenever(sdk.linkedPeers()).thenReturn(listOf(blockedPeer)) + whenever(sdk.identityStatus()).thenReturn( + IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE), + ) + whenever(sdk.paykitAppRegistry(RING_PUBKY)).thenReturn( + PaykitAppRegistry(1u, null, listOf(PaykitApp("bitkit", "Bitkit", capabilities)), null, emptyMap()), + ) + val report = PrivatePaymentListDeliveryReport(emptyList(), emptyList(), emptyList(), emptyList()) + val updates = listOf(RING_PUBKY, other).map { PrivatePaymentListReservationUpdateInput(it, emptyList()) } + whenever(sdk.syncPrivatePaymentListsWithReservationsAndProcessOutbound(updates, false)).thenReturn(report) + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + + assertNull(service.clearPrivatePaymentLists(emptyList())) + verifyNoInteractions(sdk) + val recreatedService = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + for (client in listOf(service, service, recreatedService)) { + assertEquals(report, client.clearPrivatePaymentLists(listOf(RING_PUBKY, other, blocked))) + } + + verify(sdk, times(3)).linkedPeers() + verify(sdk, times(3)).identityStatus() + verify(sdk, times(3)).paykitAppRegistry(RING_PUBKY) + verify(sdk, times(3)).syncPrivatePaymentListsWithReservationsAndProcessOutbound(updates, false) + verify(sdk, never()).clearPrivatePaymentListAndProcessOutbound(any()) + } + + @Test + fun `withdrawal recovers peer before queueing and retains recovery failure`() = runTest { + val sdk = mock() + val peer = contactPeer(LinkedPeerState.RECOVERY_REQUIRED) + val other = "pubky5rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + whenever(sdk.linkedPeers()).thenReturn( + listOf(peer, contactPeer(LinkedPeerState.LINKED).copy(counterparty = other)), + ) + val failure = AppError("Peer recovery unavailable") + var failRecovery = true + whenever(sdk.ensureLinkWithPeer(RING_PUBKY, 1u)).thenAnswer { + if (failRecovery) throw failure + LinkedPeerHandshakeReport(RING_PUBKY, LinkedPeerState.LINKING, 1uL, null) + } + val keys = listOf(RING_PUBKY, other) + val updates = keys.map { PrivatePaymentListReservationUpdateInput(it, emptyList()) } + whenever(sdk.syncPrivatePaymentListsWithReservationsAndProcessOutbound(updates, false)).thenAnswer { + PrivatePaymentListDeliveryReport( + queued = emptyList(), + cleared = (if (failRecovery) listOf(other) else keys).map { + PrivatePaymentListSyncChange(it, 1uL, null) + }, + failedToQueue = if (failRecovery) { + listOf(PrivatePaymentListSyncChange(RING_PUBKY, null, null)) + } else { + emptyList() + }, + failedToDeliver = emptyList(), + ) + } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + + val pending = service.clearPrivatePaymentLists(keys) + assertEquals(listOf(RING_PUBKY), pending?.failedToQueue?.map { it.counterparty }) + assertEquals(listOf(other), pending?.cleared?.map { it.counterparty }) + + failRecovery = false + val complete = service.clearPrivatePaymentLists(keys) + assertTrue(complete?.failedToQueue?.isEmpty() == true) + assertEquals(keys, complete?.cleared?.map { it.counterparty }) + inOrder(sdk) { + verify(sdk).ensureLinkWithPeer(RING_PUBKY, 1u) + verify(sdk).syncPrivatePaymentListsWithReservationsAndProcessOutbound(updates, false) + verify(sdk).ensureLinkWithPeer(RING_PUBKY, 1u) + verify(sdk).syncPrivatePaymentListsWithReservationsAndProcessOutbound(updates, false) + } + verify(sdk, never()).ensureLinkWithPeer(other, 1u) + verify(sdk, never()).unblockPeer(any()) + } + + @Test + fun `initialization publishes the saved private sharing preference`() = runTest { + for (enabled in listOf(false, true)) { + val sdk = mock() + val settingsStore = mock() + whenever(settingsStore.data).thenReturn(flowOf(SettingsData(sharesPrivatePaykitEndpoints = enabled))) + whenever(sdk.initialize()).thenReturn( + IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE), + ) + val service = PaykitSdkService( + mock(), + mock(), + mock(), + ioDispatcher = StandardTestDispatcher(testScheduler), + platformInitializer = {}, + settingsStore = settingsStore, + ) { sdk } + + service.initialize() + + verify(sdk).publishPaykitApp("Bitkit", PaykitAppCapabilities(enabled, true, false, true)) + verify(sdk, never()).identityStatus() + } + } + + @Test + fun `initialization without private access does not publish the app`() = runTest { + for (capability in listOf(PubkyIdentityCapability.SIGNED_OUT, PubkyIdentityCapability.PUBLIC_ONLY)) { + val sdk = mock() + whenever(sdk.initialize()).thenReturn(IdentityStatus(RING_PUBKY, capability)) + val service = PaykitSdkService( + mock(), + mock(), + mock(), + ioDispatcher = StandardTestDispatcher(testScheduler), + platformInitializer = {}, + settingsStore = mock(), + ) { sdk } + + service.initialize() + + verify(sdk, never()).publishPaykitApp(any(), any()) + verify(sdk, never()).identityStatus() + } + } + + private fun localSessionAccess(bytes: ByteArray): PubkySessionAccess { + val access = mock() + val secret = mock() + val noise = mock() + whenever(secret.exportBytes()).thenReturn(bytes) + whenever(noise.exportBytes()).thenReturn(bytes) + whenever(access.exportSessionSecret()).thenReturn("new-session") + whenever(access.exportLocalSecretKey()).thenReturn(secret) + whenever(access.exportPaykitIdentitySecretKey()).thenReturn(noise) + return access } private suspend fun gatedReadService( @@ -989,28 +2291,15 @@ class PaykitSdkServiceTest { byteArrayOf(1) } } - whenever { sdk.resolveContactProfile(any(), any(), any()) }.doSuspendableAnswer { + whenever { sdk.resolveProfile(any(), any()) }.doSuspendableAnswer { read(it.getArgument(0)) null } - return PaykitSdkService(mock(), mock(), mock()) { sdk }.also { it.contactRecords() } + return PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk }.also { it.contactRecords() } } - private fun receiverMarker(path: String, paymentRequests: Boolean, outgoingPayments: Boolean) = - PaykitReceiverMarker( - receiverPath = path, - capabilities = PaykitReceiverCapabilities( - privatePayments = true, - paymentRequests = paymentRequests, - receipts = false, - outgoingPayments = outgoingPayments, - ), - noisePublicKey = "noise", - ) - - private fun contactPeer(path: String, state: LinkedPeerState) = LinkedPeerRecord( + private fun contactPeer(state: LinkedPeerState) = LinkedPeerRecord( counterparty = RING_PUBKY, - counterpartyReceiverPath = path, state = state, lastSyncAt = null, lastPrivateReceiveAt = null, @@ -1029,11 +2318,9 @@ class PaykitSdkServiceTest { ) @Test - fun `config scopes public endpoint sync to Bitkit managed endpoints`() { - assertEquals(BitkitPaykitSdkConfig.profileNamespace, BitkitPaykitSdkConfig.clientId) - assertEquals(EndpointManagementScope.MANAGED_ONLY, BitkitPaykitSdkConfig.endpointManagementScope) - assertEquals(PublicContactSharingPolicy.LOCAL_ONLY, BitkitPaykitSdkConfig.publicContactSharing) - assertEquals(EncryptedLinkRecoveryMarkerPolicy.ENABLED, BitkitPaykitSdkConfig.encryptedLinkRecoveryMarkers) + fun `config keeps contacts private`() { + assertEquals("staging.bitkit.to", BitkitPaykitSdkConfig.clientId) + assertEquals(PublicContactSharingPolicy.PRIVATE_ONLY, BitkitPaykitSdkConfig.publicContactSharing) } @Test @@ -1070,86 +2357,6 @@ class PaykitSdkServiceTest { } } - @Test - fun `receiver noise derivation matches cross platform vector`() { - val seed = ( - "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e534955" + - "31f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04" - ).fromHex() - - val key = PaykitReceiverNoiseKeyDerivation.derive( - seed = seed, - network = "bitcoin", - receiverPath = "bitkit/wallet", - ) - - assertEquals("500f4799bbb2d02103e3b74b365ddb478a3187333c053fa9eb62f4052ba6a327", key.toHex()) - } - - @Test - fun `receiver noise key is persisted and reused`() { - var persistedBytes: ByteArray? = null - val derivedBytes = ByteArray(32) { 7 } - val store = keyStore( - loadBytes = { persistedBytes }, - upsertBytes = { persistedBytes = it.copyOf() }, - deriveBytes = { derivedBytes }, - ) - - val first = store.loadOrDeriveBytes() - val second = store.loadOrDeriveBytes() - val restored = keyStore( - loadBytes = { persistedBytes }, - deriveBytes = { derivedBytes }, - ).loadOrDeriveBytes() - - assertContentEquals(first, persistedBytes) - assertContentEquals(first, second) - assertContentEquals(first, restored) - assertEquals("PAYKIT_RECEIVER_NOISE_SECRET_KEY", Keychain.Key.PAYKIT_RECEIVER_NOISE_SECRET_KEY.name) - } - - @Test - fun `receiver noise key loads for external session without wallet seed`() { - val persistedBytes = ByteArray(32) { 7 } - val store = keyStore( - loadBytes = { persistedBytes }, - deriveBytes = { throw AppError("wallet seed unavailable") }, - ) - - assertContentEquals(persistedBytes, store.loadOrDeriveBytes()) - } - - @Test - fun `receiver noise key cannot be replaced`() { - val store = keyStore( - loadBytes = { ByteArray(32) { 1 } }, - deriveBytes = { ByteArray(32) { 1 } }, - ) - - assertFailsWith { - store.persistBytes(ByteArray(32) { 2 }) - } - } - - @Test - fun `receiver noise key follows wallet replacement after keychain wipe`() { - var persistedBytes: ByteArray? = null - var derivedBytes = ByteArray(32) { 1 } - val store = keyStore( - loadBytes = { persistedBytes }, - upsertBytes = { persistedBytes = it.copyOf() }, - deriveBytes = { derivedBytes }, - ) - store.loadOrDeriveBytes() - - persistedBytes = null - derivedBytes = ByteArray(32) { 2 } - - assertContentEquals(derivedBytes, store.loadOrDeriveBytes()) - assertContentEquals(derivedBytes, persistedBytes) - } - @Test fun `external session retains private payment access`() { val keychain = mock() @@ -1215,21 +2422,6 @@ class PaykitSdkServiceTest { ) } - private fun keyStore( - loadBytes: () -> ByteArray?, - upsertBytes: (ByteArray) -> Unit = {}, - deriveBytes: () -> ByteArray, - ) = PaykitReceiverNoiseKeyStore(loadBytes, upsertBytes, deriveBytes) - - private fun stubReceiverNoiseSecret(keychain: Keychain) { - val blocking = mock() - whenever(keychain.accessBlocking(any())).doAnswer { - it.getArgument Any?>(0).invoke(blocking) - } - whenever(blocking.load(Keychain.Key.PAYKIT_RECEIVER_NOISE_SECRET_KEY.name)) - .thenReturn(ByteArray(32) { 1 }) - } - private fun identityCacheCases(originalKey: String, differentKey: String) = listOf( Triple(originalKey, null, false), Triple("pubky$originalKey", null, false), @@ -1238,6 +2430,6 @@ class PaykitSdkServiceTest { Triple(null, originalKey, false), Triple(null, differentKey, false), Triple(originalKey, differentKey, false), - Triple(differentKey, null, true), + Triple(originalKey, differentKey, true), ) } diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceWipeTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceWipeTest.kt index 351095c8da..b47e5c3958 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceWipeTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceWipeTest.kt @@ -1,27 +1,22 @@ package to.bitkit.services +import com.synonym.paykit.IdentityStatus import com.synonym.paykit.PaykitException import com.synonym.paykit.PaykitSdk -import com.synonym.paykit.PaykitSdkConfig +import com.synonym.paykit.PubkyIdentityCapability import com.synonym.paykit.PubkySessionAccess import com.synonym.paykit.PubkySessionBootstrap import com.synonym.paykit.PubkySessionBootstrapResult -import com.synonym.paykit.ReceiverNoiseSecretKey -import com.synonym.paykit.SdkStateBlob -import com.synonym.paykit.SdkStateBlobSnapshot -import com.synonym.paykit.SdkStateBlobStore -import com.synonym.paykit.decodeSdkStateBlobSnapshot -import com.synonym.paykit.defaultConfig -import com.synonym.paykit.encodeSdkStateBlobSnapshot -import com.synonym.paykit.requiredSessionCapabilities +import com.synonym.paykit.paykitAuthorizerSessionCapabilities import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Test -import org.mockito.Mockito.mockConstruction import org.mockito.Mockito.mockStatic import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull @@ -38,6 +33,7 @@ import to.bitkit.data.PubkyStore import to.bitkit.data.PubkyStoreData import to.bitkit.data.keychain.Keychain import to.bitkit.data.keychain.KeychainError +import kotlin.coroutines.cancellation.CancellationException import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse @@ -49,59 +45,77 @@ class PaykitSdkServiceWipeTest { private const val RING_PUBKY = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" } - private val sdkConfig = PaykitSdkConfig( - receiverPath = PaykitReceiverPaths.WALLET, - profileNamespace = BitkitPaykitSdkConfig.profileNamespace, - endpointManagementScope = BitkitPaykitSdkConfig.endpointManagementScope, - encryptedLinkRecoveryMarkers = BitkitPaykitSdkConfig.encryptedLinkRecoveryMarkers, - publicContactSharing = BitkitPaykitSdkConfig.publicContactSharing, - peerLinkOperationLeaseTimeoutSecs = 1uL, - outboundPrivateSendLeaseTimeoutSecs = 1uL, - outboundPrivateRetryBackoffSecs = 1uL, - ) - @Test - fun `state storage callbacks translate keychain failures and recover on retry`() { - val keychain = mock() - val blocking = mock() - whenever(keychain.accessBlocking(any())).doAnswer { - it.getArgument Any?>(0).invoke(blocking) + @OptIn(ExperimentalCoroutinesApi::class) + fun `wipe drains a cancelled active identity read and rejects queued work`() = runTest { + val sdk = mock() + val releaseRead = CompletableDeferred() + val events = mutableListOf() + whenever { sdk.identityStatus() }.doSuspendableAnswer { + releaseRead.await() + events += "read finished" + IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE) } - val service = PaykitSdkService(mock(), keychain, mock()) { mock() } - val store = PaykitSdkService::class.java.getDeclaredField("stateStore") - .apply { isAccessible = true }.get(service) as SdkStateBlobStore - val key = Keychain.Key.PAYKIT_SDK_STATE.name - whenever(blocking.load(key)).thenAnswer { throw KeychainError.FailedToLoad(key) }.thenReturn(null) + whenever { sdk.contactRecords() }.thenReturn(emptyList()) + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + val read = async { service.identityStatus() } + runCurrent() + val queued = async { assertFailsWith { service.contactRecords() } } + runCurrent() + read.cancel() + val wipe = async { service.withWalletWipe { events += "wipe" } } + runCurrent() + assertFalse(read.isCompleted) + assertFalse(wipe.isCompleted) + assertEquals(emptyList(), events) + releaseRead.complete(Unit) - assertEquals("state_load_failed", assertFailsWith { store.loadStateBlob() }.code) - assertNull(store.loadStateBlob()) + assertFailsWith { read.await() } + assertEquals("wallet_wipe_in_progress", queued.await().code) + wipe.await() + assertEquals(listOf("read finished", "wipe"), events) + verify(sdk, never()).contactRecords() + assertEquals(emptyList(), service.contactRecords()) + } - val blob = mock() - val encoded = byteArrayOf(1, 2, 3) - lateinit var snapshot: SdkStateBlobSnapshot - mockStatic(Class.forName("com.synonym.paykit.Paykit_androidKt")).use { native -> - native.`when` { encodeSdkStateBlobSnapshot(any()) }.thenAnswer { - snapshot = it.getArgument(0) - encoded + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun `cancelled active session teardown finishes and discards its runtime before fresh work`() = runTest { + for (forget in listOf(false, true)) { + val sdk = mock() + val freshSdk = mock() + val release = CompletableDeferred() + val signedOut = IdentityStatus(null, PubkyIdentityCapability.SIGNED_OUT) + whenever { sdk.signOut() }.doSuspendableAnswer { + release.await() + signedOut + } + whenever { sdk.forgetSessionAccess() }.doSuspendableAnswer { + release.await() + signedOut + } + whenever { freshSdk.contactRecords() }.thenReturn(emptyList()) + var handlesCreated = 0 + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { + if (handlesCreated++ == 0) sdk else freshSdk } - native.`when` { decodeSdkStateBlobSnapshot(encoded) }.thenAnswer { snapshot } - whenever(blocking.upsert(key, encoded)).thenAnswer { throw KeychainError.FailedToSave(key) }.thenAnswer { - whenever(blocking.load(key)).thenReturn(encoded) + val teardown = async { + if (forget) service.forgetSessionAccess() else service.signOut() } + runCurrent() + val next = async { service.contactRecords() } + teardown.cancel() + runCurrent() + assertFalse(teardown.isCompleted) + assertFalse(next.isCompleted) + release.complete(Unit) - assertEquals( - "state_save_failed", - assertFailsWith { store.saveStateBlobAtomically(blob, null) }.code, - ) - assertNull(store.loadStateBlob()) - val revision = store.saveStateBlobAtomically(blob, null) - val restored = store.loadStateBlob() - assertSame(blob, restored?.blob) - assertEquals(revision, restored?.revision) - assertEquals( - "revision_conflict", - assertFailsWith { store.saveStateBlobAtomically(blob, null) }.code, - ) + assertFailsWith { teardown.await() } + assertEquals(emptyList(), next.await()) + assertEquals(2, handlesCreated) + assertEquals(1L, service.backupStateVersion.value) + verify(sdk, never()).contactRecords() + verify(freshSdk).contactRecords() } } @@ -140,21 +154,17 @@ class PaykitSdkServiceWipeTest { @Test fun `wallet wipe drains identity bootstrap and persistence and rejects queued imports`() = runTest { val keychain = mock() - stubReceiverNoiseSecret(keychain) val sdk = mock() val bootstrap = mock() val access = mock() - val noise = mock() - whenever(noise.exportBytes()).thenReturn(ByteArray(32) { 1 }) - whenever(access.exportReceiverNoiseSecretKey()).thenReturn(noise) whenever(access.exportSessionSecret()).thenReturn("new-session") - val result = PubkySessionBootstrapResult(access, RING_PUBKY) + val result = PubkySessionBootstrapResult(access, RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE) val bootstrapStarted = CompletableDeferred() val releaseBootstrap = CompletableDeferred() val persistenceStarted = CompletableDeferred() val releasePersistence = CompletableDeferred() val events = mutableListOf() - whenever(bootstrap.importSession(eq("active"), anyOrNull(), any(), any())).doSuspendableAnswer { + whenever(bootstrap.importSession(eq("active"), anyOrNull(), any())).doSuspendableAnswer { events.add("bootstrap") bootstrapStarted.complete(Unit) releaseBootstrap.await() @@ -169,35 +179,39 @@ class PaykitSdkServiceWipeTest { } val store = mock { on { data } doReturn flowOf(PubkyStoreData()) } val dispatcher = StandardTestDispatcher(testScheduler) - val service = PaykitSdkService(mock(), keychain, store, { bootstrap }, dispatcher) { sdk } + val service = PaykitSdkService( + mock(), + keychain, + store, + { bootstrap }, + dispatcher, + settingsStore = mock(), + ) { sdk } mockStatic(Class.forName("com.synonym.paykit.Paykit_androidKt")).use { native -> - native.`when` { defaultConfig(PaykitReceiverPaths.WALLET) }.thenReturn(sdkConfig) - native.`when` { requiredSessionCapabilities(any()) }.thenReturn("capabilities") - mockConstruction(ReceiverNoiseSecretKey::class.java).use { - val active = async(start = CoroutineStart.UNDISPATCHED) { service.importSession("active") } - bootstrapStarted.await() - val queued = async(start = CoroutineStart.UNDISPATCHED) { - assertFailsWith { service.importSession("queued") } - } - val wipe = async(start = CoroutineStart.UNDISPATCHED) { - service.withWalletWipe { events.add("cleanup") } - } - assertFalse(wipe.isCompleted) - assertEquals(listOf("bootstrap"), events) + native.`when` { paykitAuthorizerSessionCapabilities() }.thenReturn("capabilities") + val active = async(start = CoroutineStart.UNDISPATCHED) { service.importSession("active") } + bootstrapStarted.await() + val queued = async(start = CoroutineStart.UNDISPATCHED) { + assertFailsWith { service.importSession("queued") } + } + val wipe = async(start = CoroutineStart.UNDISPATCHED) { + service.withWalletWipe { events.add("cleanup") } + } + assertFalse(wipe.isCompleted) + assertEquals(listOf("bootstrap"), events) - releaseBootstrap.complete(Unit) - persistenceStarted.await() - assertFalse(wipe.isCompleted) - assertEquals(listOf("bootstrap"), events) - releasePersistence.complete(Unit) + releaseBootstrap.complete(Unit) + persistenceStarted.await() + assertFalse(wipe.isCompleted) + assertEquals(listOf("bootstrap"), events) + releasePersistence.complete(Unit) - assertSame(result, active.await()) - assertEquals("wallet_wipe_in_progress", queued.await().code) - wipe.await() - assertEquals(listOf("bootstrap", "persisted", "cleanup"), events) - verify(bootstrap, never()).importSession(eq("queued"), anyOrNull(), any(), any()) - } + assertSame(result, active.await()) + assertEquals("wallet_wipe_in_progress", queued.await().code) + wipe.await() + assertEquals(listOf("bootstrap", "persisted", "cleanup"), events) + verify(bootstrap, never()).importSession(eq("queued"), anyOrNull(), any()) } } @@ -205,16 +219,31 @@ class PaykitSdkServiceWipeTest { fun `public read waiting to build an sdk when a wipe starts fails without building one`() = runTest { val keychain = mock() val releaseLocked = CompletableDeferred() - whenever(keychain.delete(Keychain.Key.PAYKIT_SDK_STATE.name)).doSuspendableAnswer { releaseLocked.await() } + whenever(keychain.upsertString(Keychain.Key.PAYKIT_SESSION.name, "session")) + .doSuspendableAnswer { releaseLocked.await() } + val access = mock() + whenever(access.exportSessionSecret()).thenReturn("session") + val store = mock() + whenever(store.data).thenReturn(flowOf(PubkyStoreData())) val sdk = mock() - whenever(sdk.fetchPubkyFollows(RING_PUBKY)).thenReturn(listOf("follow")) + whenever(sdk.fetchPubkyFollows(RING_PUBKY, 10_000u)).thenReturn(listOf("follow")) var handlesCreated = 0 - val service = PaykitSdkService(mock(), keychain, mock()) { + val service = PaykitSdkService( + mock(), + keychain, + store, + bootstrapFactory = { mock() }, + settingsStore = mock(), + ) { handlesCreated++ sdk } - val locked = async(start = CoroutineStart.UNDISPATCHED) { service.clearState() } + val locked = async(start = CoroutineStart.UNDISPATCHED) { + service.activateRegisteredIdentity( + PubkySessionBootstrapResult(access, RING_PUBKY, PubkyIdentityCapability.PUBLIC_ONLY), + ) + } val read = async(start = CoroutineStart.UNDISPATCHED) { assertFailsWith { service.fetchPubkyFollows(RING_PUBKY) } } @@ -223,18 +252,18 @@ class PaykitSdkServiceWipeTest { locked.await() assertEquals("wallet_wipe_in_progress", read.await().code) - assertEquals(0, wipe.await()) - assertEquals(0, handlesCreated) - verify(sdk, never()).fetchPubkyFollows(any()) + assertEquals(1, wipe.await()) + assertEquals(1, handlesCreated) + verify(sdk, never()).fetchPubkyFollows(any(), any()) } @Test fun `public reads during a wipe are rejected except for the wipe itself`() = runTest { val sdk = mock() whenever(sdk.contactRecords()).thenReturn(emptyList()) - whenever(sdk.fetchPubkyFollows(RING_PUBKY)).thenReturn(listOf("follow")) + whenever(sdk.fetchPubkyFollows(RING_PUBKY, 10_000u)).thenReturn(listOf("follow")) var handlesCreated = 0 - val service = PaykitSdkService(mock(), mock(), mock()) { + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { handlesCreated++ sdk } @@ -253,7 +282,7 @@ class PaykitSdkServiceWipeTest { val rejected = assertFailsWith { service.fetchPubkyFollows(RING_PUBKY) } assertEquals("wallet_wipe_in_progress", rejected.code) - verify(sdk, times(1)).fetchPubkyFollows(RING_PUBKY) + verify(sdk, times(1)).fetchPubkyFollows(RING_PUBKY, 10_000u) assertEquals(2, handlesCreated) releaseWipe.complete(Unit) @@ -267,8 +296,8 @@ class PaykitSdkServiceWipeTest { val sdk = mock() whenever(sdk.contactRecords()).thenReturn(emptyList()) val releaseRead = CompletableDeferred>() - whenever(sdk.fetchPubkyFollows(RING_PUBKY)).doSuspendableAnswer { releaseRead.await() } - val service = PaykitSdkService(mock(), mock(), mock()) { sdk } + whenever(sdk.fetchPubkyFollows(RING_PUBKY, 10_000u)).doSuspendableAnswer { releaseRead.await() } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } service.contactRecords() val read = async(start = CoroutineStart.UNDISPATCHED) { assertFailsWith { service.fetchPubkyFollows(RING_PUBKY) } @@ -280,13 +309,4 @@ class PaykitSdkServiceWipeTest { assertEquals("wallet_wipe_in_progress", read.await().code) } - - private fun stubReceiverNoiseSecret(keychain: Keychain) { - val blocking = mock() - whenever(keychain.accessBlocking(any())).doAnswer { - it.getArgument Any?>(0).invoke(blocking) - } - whenever(blocking.load(Keychain.Key.PAYKIT_RECEIVER_NOISE_SECRET_KEY.name)) - .thenReturn(ByteArray(32) { 1 }) - } } diff --git a/app/src/test/java/to/bitkit/services/PaymentDeadlineSubmissionTest.kt b/app/src/test/java/to/bitkit/services/PaymentDeadlineSubmissionTest.kt new file mode 100644 index 0000000000..5f70b0462d --- /dev/null +++ b/app/src/test/java/to/bitkit/services/PaymentDeadlineSubmissionTest.kt @@ -0,0 +1,120 @@ +package to.bitkit.services + +import com.synonym.bitkitcore.onchainBroadcastRawTx +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.withContext +import org.junit.Test +import org.lightningdevkit.ldknode.Node +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.mock +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever +import to.bitkit.async.ServiceQueue +import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.AppError +import to.bitkit.utils.ServiceError +import kotlin.coroutines.CoroutineContext +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertSame +import kotlin.time.Clock +import kotlin.time.Duration.Companion.nanoseconds +import kotlin.time.Instant + +@OptIn(ExperimentalCoroutinesApi::class) +class PaymentDeadlineSubmissionTest : BaseUnitTest() { + private val deadline = Instant.parse("2026-10-06T12:00:00.123456789Z") + + @Test + fun `onchain deadline is rechecked after waiting in the LDK queue`() = test { + val node = mock() + var now = deadline - 1.nanoseconds + val service = lightningService( + node, + object : Clock { + override fun now() = now + }, + StandardTestDispatcher(testScheduler), + ) + + for (isMax in listOf(false, true)) { + now = deadline - 1.nanoseconds + val result = async { + runCatching { service.send("address", 1uL, 1uL, isMaxAmount = isMax, paymentDeadlineAt = deadline) } + } + now = deadline + 1.nanoseconds + runCurrent() + + assertIs(result.await().exceptionOrNull()?.cause) + } + verifyNoInteractions(node) + } + + @Test + fun `onchain submission permits the exact deadline`() = test { + val node = mock() + val submitted = IllegalStateException("reached node") + whenever(node.onchainPayment()).thenThrow(submitted) + val service = lightningService( + node, + object : Clock { + override fun now() = deadline + }, + testDispatcher, + ) + + val error = runCatching { + service.send("address", 1uL, 1uL, paymentDeadlineAt = deadline) + }.exceptionOrNull() + + assertSame(submitted, assertIs(error).cause) + } + + @Test + fun `hardware broadcast checks inclusive deadline inside the CORE queue`() = test { + var now = deadline + val service = TrezorService( + mock(), + mock(), + object : Clock { + override fun now() = now + }, + ) + withContext(ServiceQueue.CORE.queueContext) { + mockStatic(Class.forName("com.synonym.bitkitcore.Bitkitcore_androidKt")).use { native -> + native.`when` { + runBlocking { onchainBroadcastRawTx("signed-tx", "electrum") } + }.thenReturn("txid") + assertEquals("txid", service.broadcastRawTx("signed-tx", "electrum", deadline)) + now = deadline + 1.nanoseconds + + val error = runCatching { service.broadcastRawTx("signed-tx", "electrum", deadline) }.exceptionOrNull() + + assertIs(error?.cause) + native.verify { runBlocking { onchainBroadcastRawTx("signed-tx", "electrum") } } + native.verifyNoMoreInteractions() + } + } + } + + private fun lightningService( + node: Node, + clock: Clock, + queue: CoroutineContext, + ) = LightningService( + bgDispatcher = testDispatcher, + ioDispatcher = testDispatcher, + keychain = mock(), + vssStoreIdProvider = mock(), + settingsStore = mock(), + watchOnlyAccountStore = mock(), + loggerLdk = mock(), + watchOnlyAccountLifecycleCoordinator = WatchOnlyAccountLifecycleCoordinator(), + ldkQueue = queue, + clock = clock, + ).also { it.node = node } +} diff --git a/app/src/test/java/to/bitkit/services/PubkyIdentityRepublishTest.kt b/app/src/test/java/to/bitkit/services/PubkyIdentityRepublishTest.kt index 0a4dba4272..a039759e4c 100644 --- a/app/src/test/java/to/bitkit/services/PubkyIdentityRepublishTest.kt +++ b/app/src/test/java/to/bitkit/services/PubkyIdentityRepublishTest.kt @@ -43,6 +43,7 @@ class PubkyIdentityRepublishTest { mock(), { bootstrap }, StandardTestDispatcher(testScheduler), + settingsStore = mock(), ) { mock() } service.republishIdentityIfNeeded(publicKey, now = 2_592_000_000) @@ -73,6 +74,7 @@ class PubkyIdentityRepublishTest { mock(), { bootstrap }, StandardTestDispatcher(testScheduler), + settingsStore = mock(), ) { mock() } service.republishIdentityIfNeeded(publicKey, now = 0) @@ -100,6 +102,7 @@ class PubkyIdentityRepublishTest { context = mock(), keychain = mock(), pubkyStore = mock(), + settingsStore = mock(), bootstrapFactory = { factories++ bootstrap @@ -129,6 +132,7 @@ class PubkyIdentityRepublishTest { context = mock(), keychain = mock(), pubkyStore = mock(), + settingsStore = mock(), bootstrapFactory = { bootstrap }, ioDispatcher = StandardTestDispatcher(testScheduler), sdkFactory = { mock() }, @@ -153,6 +157,7 @@ class PubkyIdentityRepublishTest { mock(), { bootstrap }, StandardTestDispatcher(testScheduler), + settingsStore = mock(), ) { sdk } val otherKey = publicKey.dropLast(1) + "y" @@ -176,6 +181,7 @@ class PubkyIdentityRepublishTest { mock(), { bootstrap }, StandardTestDispatcher(testScheduler), + settingsStore = mock(), ) { mock() } val first = async { service.republishIdentityIfNeeded(publicKey, now = 0) } runCurrent() @@ -198,6 +204,7 @@ class PubkyIdentityRepublishTest { mock(), { bootstrap }, StandardTestDispatcher(testScheduler), + settingsStore = mock(), ) { mock() } val approval = async { service.republishIdentityIfNeeded(publicKey, now = 0) } @@ -228,6 +235,7 @@ class PubkyIdentityRepublishTest { mock(), { bootstrap }, StandardTestDispatcher(testScheduler), + settingsStore = mock(), ) { mock() } service.republishIdentityIfNeeded(publicKey, now = 0) @@ -262,6 +270,7 @@ class PubkyIdentityRepublishTest { mock(), { bootstrap }, StandardTestDispatcher(testScheduler), + settingsStore = mock(), ) { mock() } var continued = false val cancelledCaller = async { diff --git a/app/src/test/java/to/bitkit/services/PubkyServiceTest.kt b/app/src/test/java/to/bitkit/services/PubkyServiceTest.kt index 56e500c832..e068c3676f 100644 --- a/app/src/test/java/to/bitkit/services/PubkyServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PubkyServiceTest.kt @@ -47,12 +47,10 @@ class PubkyServiceTest : BaseUnitTest() { whenever(paykit.resolveContactProfile("pubky-test", true)).doSuspendableAnswer(pending) whenever(paykit.fetchFile("pubky://pubky-test/avatar", 1uL)).doSuspendableAnswer(pending) whenever(paykit.fetchPubkyFollows("pubky-test")).doSuspendableAnswer(pending) - whenever(paykit.discoverRelevantReceiverPaths("pubky-test", PaykitReadLane.Bulk)).doSuspendableAnswer(pending) val reads = listOf Unit>( { sut.resolveContactProfile("pubky-test", allowPubkyProfileFallback = true) }, { sut.fetchFile("pubky://pubky-test/avatar", 1uL) }, { sut.getContacts("pubky-test") }, - { sut.discoverRelevantReceiverPaths("pubky-test", PaykitReadLane.Bulk) }, ) for (read in reads) { diff --git a/app/src/test/java/to/bitkit/ui/screens/contacts/AddContactViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/contacts/AddContactViewModelTest.kt index 6ad1eb9c06..9fa6a397b2 100644 --- a/app/src/test/java/to/bitkit/ui/screens/contacts/AddContactViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/contacts/AddContactViewModelTest.kt @@ -16,7 +16,7 @@ import to.bitkit.repositories.PubkyContactError import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.test.BaseUnitTest -import to.bitkit.usecases.RefreshContactPaykitReceiversUseCase +import to.bitkit.usecases.RefreshContactPaykitLinkUseCase import kotlin.test.assertEquals import kotlin.test.assertNull @@ -25,7 +25,7 @@ class AddContactViewModelTest : BaseUnitTest() { private val context: Context = mock() private val pubkyRepo: PubkyRepo = mock() private val publicPaykitRepo: PublicPaykitRepo = mock() - private val refreshContactPaykitReceivers = mock() + private val refreshContactPaykitLink = mock() @Test fun `self add failure should show dedicated error`() = test { @@ -58,14 +58,14 @@ class AddContactViewModelTest : BaseUnitTest() { whenever(context.getString(R.string.contacts__add_error_existing)).thenReturn("existing contact") whenever(pubkyRepo.fetchContactProfile(any())) .thenReturn(Result.failure(PubkyContactError.AlreadyExists)) - whenever { refreshContactPaykitReceivers(TEST_PUBLIC_KEY) }.thenReturn(Result.success(Unit)) + whenever { refreshContactPaykitLink(TEST_PUBLIC_KEY) }.thenReturn(Result.success(Unit)) val sut = createSut() advanceUntilIdle() assertEquals("existing contact", sut.uiState.value.error) assertNull(sut.uiState.value.fetchedProfile) - verify(refreshContactPaykitReceivers).invoke(TEST_PUBLIC_KEY) + verify(refreshContactPaykitLink).invoke(TEST_PUBLIC_KEY) } @Test @@ -103,7 +103,7 @@ class AddContactViewModelTest : BaseUnitTest() { context = context, pubkyRepo = pubkyRepo, publicPaykitRepo = publicPaykitRepo, - refreshContactPaykitReceivers = refreshContactPaykitReceivers, + refreshContactPaykitLink = refreshContactPaykitLink, savedStateHandle = SavedStateHandle(mapOf("publicKey" to publicKey)), ) } diff --git a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt index 7a503e204d..9cc61274d9 100644 --- a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt @@ -54,7 +54,7 @@ class ContactDetailViewModelTest : BaseUnitTest() { } private val signIn = PubkySignIn(publicKey = "pubkyowner", generation = 0) private val eligibleTargets = MutableStateFlow>(emptyList()) - private val target = PaykitPaymentRequestTarget(TEST_PUBLIC_KEY, "bitkit/wallet") + private val target = PaykitPaymentRequestTarget(TEST_PUBLIC_KEY) private val openedPayment = PublicPaykitPaymentResult.Opened( paymentRequest = "bitcoin:bcrt1qtest", privatePaymentContext = null, diff --git a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactSaveSessionChangeTest.kt b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactSaveSessionChangeTest.kt index 1eb7bf3853..148b447c0f 100644 --- a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactSaveSessionChangeTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactSaveSessionChangeTest.kt @@ -3,9 +3,9 @@ package to.bitkit.ui.screens.contacts import android.content.Context import androidx.lifecycle.SavedStateHandle import coil3.ImageLoader -import com.synonym.paykit.ContactProfileResolution -import com.synonym.paykit.ContactProfileSource import com.synonym.paykit.ContactRecord +import com.synonym.paykit.ProfileResolution +import com.synonym.paykit.ProfileSource import com.synonym.paykit.PublicationStatus import io.ktor.client.HttpClient import kotlinx.coroutines.CompletableDeferred @@ -108,8 +108,8 @@ class ContactSaveSessionChangeTest : BaseUnitTest() { sdkIdentity = null Unit } - whenever { pubkyService.saveContact(any(), anyOrNull(), anyOrNull(), any(), anyOrNull(), anyOrNull()) } - .doSuspendableAnswer { fakeSdkSave(it.getArgument(0), it.getArgument(4), it.getArgument(5)) } + whenever { pubkyService.saveContact(any(), anyOrNull(), any(), anyOrNull(), anyOrNull()) } + .doSuspendableAnswer { fakeSdkSave(it.getArgument(0), it.getArgument(3), it.getArgument(4)) } whenever { pubkyService.resolveContactProfile(CONTACT, true, PaykitReadLane.Bulk, null) } .doSuspendableAnswer { awaitCancellation() } whenever(paykitPaymentRequestRepo.eligibleTargets).thenReturn(MutableStateFlow(emptyList())) @@ -331,7 +331,6 @@ class ContactSaveSessionChangeTest : BaseUnitTest() { private fun contactRecord(label: String?, name: String? = null) = ContactRecord( publicKey = CONTACT, - receiverPaths = listOf("bitkit/wallet"), label = label, profile = name?.let { PubkyProfile( @@ -348,7 +347,6 @@ class ContactSaveSessionChangeTest : BaseUnitTest() { createdAt = "2026-01-01T00:00:00Z", updatedAt = "2026-01-01T00:00:00Z", publicContactMarkerStatus = PublicationStatus.NOT_PUBLISHED, - publicContactMarkerReceiverPath = null, publicContactPublishedAt = null, publicContactRemovedAt = null, publicContactLastError = null, @@ -358,9 +356,9 @@ class ContactSaveSessionChangeTest : BaseUnitTest() { private fun ownerResolution(owner: String) = pubkyResolution(owner, "Owner") - private fun pubkyResolution(publicKey: String, name: String) = ContactProfileResolution( + private fun pubkyResolution(publicKey: String, name: String) = ProfileResolution( publicKey = publicKey, - source = ContactProfileSource.PUBKY_PROFILE, + source = ProfileSource.PUBKY_PROFILE, displayName = name, imageUri = null, paykitProfile = null, diff --git a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactsViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactsViewModelTest.kt index bcc767c8dc..3a1dd94475 100644 --- a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactsViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactsViewModelTest.kt @@ -11,7 +11,7 @@ import org.mockito.kotlin.whenever import to.bitkit.models.PubkyProfile import to.bitkit.repositories.PubkyRepo import to.bitkit.test.BaseUnitTest -import to.bitkit.usecases.RefreshContactPaykitReceiversUseCase +import to.bitkit.usecases.RefreshContactPaykitLinkUseCase import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -19,7 +19,7 @@ import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class ContactsViewModelTest : BaseUnitTest() { private val pubkyRepo: PubkyRepo = mock() - private val refreshContactPaykitReceivers: RefreshContactPaykitReceiversUseCase = mock() + private val refreshContactPaykitLink: RefreshContactPaykitLinkUseCase = mock() private val contacts = MutableStateFlow>(emptyList()) private val isLoadingContacts = MutableStateFlow(false) private val contactsLoadVersion = MutableStateFlow(0L) @@ -37,7 +37,7 @@ class ContactsViewModelTest : BaseUnitTest() { @Test fun `full screen loading shows only until the saved records first load`() = test { - val sut = ContactsViewModel(pubkyRepo, refreshContactPaykitReceivers) + val sut = ContactsViewModel(pubkyRepo, refreshContactPaykitLink) backgroundScope.launch { sut.uiState.collect {} } isLoadingContacts.value = true advanceUntilIdle() diff --git a/app/src/test/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestPresentationTest.kt b/app/src/test/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestPresentationTest.kt index c65284712b..29d06c1924 100644 --- a/app/src/test/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestPresentationTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestPresentationTest.kt @@ -71,7 +71,6 @@ class PaymentRequestPresentationTest { ) = PaykitPaymentRequest( paymentRequestId = "request-id", counterparty = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", - counterpartyReceiverPath = "bitkit/server", amountValue = "0.000025", amountSats = 2_500uL, expiresAt = null, diff --git a/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModelTest.kt index 4a1184dddd..d25a5abecd 100644 --- a/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModelTest.kt @@ -23,10 +23,12 @@ import org.mockito.kotlin.never import org.mockito.kotlin.same import org.mockito.kotlin.times import org.mockito.kotlin.verifyBlocking +import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever import to.bitkit.R import to.bitkit.models.PreparedWatchOnlyAccountClaim import to.bitkit.models.PubkyAuthClaim +import to.bitkit.models.PubkyAuthClaim.Item import to.bitkit.models.PubkyAuthPermission import to.bitkit.models.PubkyAuthRequest import to.bitkit.models.PubkyProfile @@ -41,6 +43,7 @@ import to.bitkit.utils.AppError import kotlin.test.assertEquals @OptIn(ExperimentalCoroutinesApi::class) +@Suppress("LargeClass") class PubkyAuthApprovalViewModelTest : BaseUnitTest() { private val clientId = "paykit.test" private val context: Context = mock() @@ -72,6 +75,135 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { assertEquals(ApprovalState.Loading, sut.uiState.value.state) } + @Test + fun `Paykit-only reconnect never prepares or tracks a wallet account`() = test { + val capabilities = PubkyAuthClaim.REQUIRED_CAPABILITIES + val authUrl = "pubkyauth://signin?x-bitkit-claim=paykit-access-v1" + whenever(pubkyRepo.parseAuthUrl(authUrl)).thenReturn( + Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim(Item.PAYKIT_ACCESS_V1))), + ) + whenever(pubkyRepo.approveAuthWithCompanionClaim(eq(authUrl), eq(clientId), argThat { isEmpty() })) + .thenReturn(Result.success(Unit)) + val sut = createSut() + + sut.load(authUrl) + advanceUntilIdle() + assertEquals(ApprovalState.Authorize, sut.uiState.value.state) + sut.confirmAuthorize(authUrl) + advanceUntilIdle() + + assertEquals(ApprovalState.Success, sut.uiState.value.state) + verifyBlocking(pubkyRepo) { approveAuthWithCompanionClaim(eq(authUrl), eq(clientId), argThat { isEmpty() }) } + verifyBlocking(pubkyRepo, never()) { approveAuth(any(), any(), any()) } + verifyNoInteractions(watchOnlyAccountRepo) + } + + @Test + fun `combined authorization prepares and activates a new watch-only account`() = test { + val authUrl = "pubkyauth://signin?x-bitkit-claim=paykit-access-v1.watch-only-account-v1" + val request = authRequest( + authUrl, + PubkyAuthClaim.REQUIRED_CAPABILITIES, + PubkyAuthClaim(Item.PAYKIT_ACCESS_V1, Item.WATCH_ONLY_ACCOUNT_V1), + ) + val pending = watchOnlyAccount() + val prepared = PreparedWatchOnlyAccountClaim(pending, ByteArray(84)) + whenever(pubkyRepo.parseAuthUrl(authUrl)).thenReturn(Result.success(request)) + whenever(watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, "paykit server")).thenReturn(prepared) + whenever(watchOnlyAccountRepo.beginAuthorization(pending.id)).thenReturn(false) + whenever(pubkyRepo.approveAuthWithCompanionClaim(authUrl, clientId, prepared.payload)) + .thenReturn(Result.success(Unit)) + val sut = createSut() + + mockStatic(Log::class.java).use { + sut.load(authUrl) + advanceUntilIdle() + assertEquals(ApprovalState.WatchOnlyConsent, sut.uiState.value.state) + sut.approveWatchOnlyConsent(authUrl) + sut.confirmAuthorize(authUrl) + advanceUntilIdle() + } + + assertEquals(ApprovalState.Success, sut.uiState.value.state) + verifyBlocking(watchOnlyAccountRepo) { prepareUnsignedClaim(authUrl, "paykit server") } + verifyBlocking(watchOnlyAccountRepo, never()) { cancelAuthorization(any(), any()) } + verifyBlocking(watchOnlyAccountRepo) { markActive(pending.id) } + } + + @Test + fun `canceling local auth does not allocate or change a watch-only account`() = test { + val authUrl = "pubkyauth://signin?x-bitkit-claim=watch-only-account-v1" + whenever(pubkyRepo.parseAuthUrl(authUrl)).thenReturn( + Result.success( + authRequest( + authUrl, + PubkyAuthClaim.REQUIRED_CAPABILITIES, + PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1), + ), + ), + ) + val sut = createSut() + sut.load(authUrl) + advanceUntilIdle() + sut.approveWatchOnlyConsent(authUrl) + sut.requestAuthorize(authUrl) + advanceUntilIdle() + sut.cancelLocalAuth(authUrl) + sut.dismiss() + advanceUntilIdle() + + verifyNoInteractions(watchOnlyAccountRepo) + verifyBlocking(pubkyRepo, never()) { approveAuthWithCompanionClaim(any(), any(), any()) } + } + + @Test + fun `authorization rejects a claim type that differs from the displayed consent`() = test { + val authUrl = "pubkyauth://signin?x-bitkit-claim=paykit-access-v1" + val shown = authRequest( + authUrl, + PubkyAuthClaim.REQUIRED_CAPABILITIES, + PubkyAuthClaim(Item.PAYKIT_ACCESS_V1), + ) + whenever(pubkyRepo.parseAuthUrl(authUrl)).thenReturn( + Result.success(shown), + Result.success(shown.copy(bitkitClaim = PubkyAuthClaim(Item.PAYKIT_ACCESS_V1, Item.WATCH_ONLY_ACCOUNT_V1))), + ) + val sut = createSut() + mockStatic(Log::class.java).use { + sut.load(authUrl) + advanceUntilIdle() + sut.confirmAuthorize(authUrl) + advanceUntilIdle() + } + assertEquals(ApprovalState.Authorize, sut.uiState.value.state) + verifyNoInteractions(watchOnlyAccountRepo) + verifyBlocking(pubkyRepo, never()) { approveAuthWithCompanionClaim(any(), any(), any()) } + } + + @Test + fun `authorization rejects item order changed after consent`() = test { + val claim = PubkyAuthClaim(Item.PAYKIT_ACCESS_V1, Item.WATCH_ONLY_ACCOUNT_V1) + val authUrl = "pubkyauth://signin?x-bitkit-claim=${claim.wireValue}" + val shown = authRequest(authUrl, PubkyAuthClaim.REQUIRED_CAPABILITIES, claim) + whenever(pubkyRepo.parseAuthUrl(authUrl)).thenReturn( + Result.success(shown), + Result.success( + shown.copy(bitkitClaim = PubkyAuthClaim.fromWireValue("watch-only-account-v1.paykit-access-v1")), + ), + ) + val sut = createSut() + mockStatic(Log::class.java).use { + sut.load(authUrl) + advanceUntilIdle() + sut.approveWatchOnlyConsent(authUrl) + sut.confirmAuthorize(authUrl) + advanceUntilIdle() + } + assertEquals(ApprovalState.Authorize, sut.uiState.value.state) + verifyBlocking(watchOnlyAccountRepo, never()) { prepareUnsignedClaim(any(), any()) } + verifyBlocking(pubkyRepo, never()) { approveAuthWithCompanionClaim(any(), any(), any()) } + } + @Test fun `auth display public key omits pubky prefix`() { assertEquals("3rsd...w5xg", pubkyAuthDisplayPublicKey("pubky3rsd123456789w5xg")) @@ -294,13 +426,13 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { @Test fun `load exposes watch-only account claim for approval`() = test { - val authUrl = "pubkyauth://signin?caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" + val authUrl = "pubkyauth://signin?caps=${PubkyAuthClaim.REQUIRED_CAPABILITIES}" whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( Result.success( authRequest( authUrl = authUrl, - capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES, - bitkitClaim = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1, + capabilities = PubkyAuthClaim.REQUIRED_CAPABILITIES, + bitkitClaim = PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1), ), ), ) @@ -310,7 +442,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { advanceUntilIdle() assertEquals(ApprovalState.WatchOnlyConsent, sut.uiState.value.state) - assertEquals(PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1, sut.uiState.value.bitkitClaim) + assertEquals(PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1), sut.uiState.value.bitkitClaim) sut.confirmAuthorize(authUrl) advanceUntilIdle() @@ -331,15 +463,15 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { } @Test - fun `watch-only authorization uses combined companion approval`() = test { - val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" - val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES + fun `watch-only authorization delivers the account companion claim`() = test { + val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.REQUIRED_CAPABILITIES}" + val capabilities = PubkyAuthClaim.REQUIRED_CAPABILITIES val prepared = PreparedWatchOnlyAccountClaim( account = watchOnlyAccount(), payload = ByteArray(84), ) whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( - Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1)), + Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1))), ) whenever { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, "paykit server") }.thenReturn(prepared) whenever { watchOnlyAccountRepo.beginAuthorization(prepared.account.id) }.thenReturn(false) @@ -366,14 +498,14 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { @Test fun `duplicate confirmations start one companion authorization`() = test { - val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" - val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES + val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.REQUIRED_CAPABILITIES}" + val capabilities = PubkyAuthClaim.REQUIRED_CAPABILITIES val prepared = PreparedWatchOnlyAccountClaim( account = watchOnlyAccount(), payload = ByteArray(84), ) whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( - Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1)), + Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1))), ) whenever { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, "paykit server") }.thenReturn(prepared) whenever { watchOnlyAccountRepo.beginAuthorization(prepared.account.id) }.thenReturn(false) @@ -399,8 +531,8 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { @Test fun `switching requests and reopening during companion approval does not start another authorization`() = test { - val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" - val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES + val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.REQUIRED_CAPABILITIES}" + val capabilities = PubkyAuthClaim.REQUIRED_CAPABILITIES val secondAuthUrl = "pubkyauth://signin?caps=/pub/second/:rw" val secondCapabilities = "/pub/second/:rw" val prepared = PreparedWatchOnlyAccountClaim( @@ -409,7 +541,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { ) val approvalResult = CompletableDeferred>() whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( - Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1)), + Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1))), ) whenever { pubkyRepo.parseAuthUrl(secondAuthUrl) }.thenReturn( Result.success(authRequest(secondAuthUrl, secondCapabilities)), @@ -456,8 +588,8 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { @Test fun `wrapped post-delivery authorization failure keeps account authorizing for retry`() = test { - val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" - val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES + val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.REQUIRED_CAPABILITIES}" + val capabilities = PubkyAuthClaim.REQUIRED_CAPABILITIES val prepared = PreparedWatchOnlyAccountClaim( account = watchOnlyAccount(), payload = ByteArray(84), @@ -466,7 +598,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { "AuthToken delivery failed" ) whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( - Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1)), + Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1))), ) whenever { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, "paykit server") }.thenReturn(prepared) whenever { watchOnlyAccountRepo.beginAuthorization(prepared.account.id) }.thenReturn(false) @@ -488,14 +620,14 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { @Test fun `companion delivery failure does not approve normal auth or activate account`() = test { - val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" - val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES + val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.REQUIRED_CAPABILITIES}" + val capabilities = PubkyAuthClaim.REQUIRED_CAPABILITIES val prepared = PreparedWatchOnlyAccountClaim( account = watchOnlyAccount(), payload = ByteArray(84), ) whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( - Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1)), + Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1))), ) whenever { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, "paykit server") }.thenReturn(prepared) whenever { watchOnlyAccountRepo.beginAuthorization(prepared.account.id) }.thenReturn(false) @@ -518,8 +650,8 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { @Test fun `retry delivery failure keeps a previously delivered account authorizing`() = test { - val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" - val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES + val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.REQUIRED_CAPABILITIES}" + val capabilities = PubkyAuthClaim.REQUIRED_CAPABILITIES val prepared = PreparedWatchOnlyAccountClaim( account = watchOnlyAccount().copy( isTrackingEnabled = true, @@ -528,7 +660,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { payload = ByteArray(84), ) whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( - Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1)), + Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1))), ) whenever { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, "paykit server") }.thenReturn(prepared) whenever { watchOnlyAccountRepo.beginAuthorization(prepared.account.id) }.thenReturn(true) @@ -552,7 +684,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { @Test fun `tracking preparation failure unloads account without attempting approval`() = test { - val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" + val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.REQUIRED_CAPABILITIES}" val prepared = PreparedWatchOnlyAccountClaim( account = watchOnlyAccount(), payload = ByteArray(84), @@ -561,8 +693,8 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { Result.success( authRequest( authUrl, - PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES, - PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1, + PubkyAuthClaim.REQUIRED_CAPABILITIES, + PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1), ), ), ) @@ -585,7 +717,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { @Test fun `tracking failure uses the current authorizing disposition`() = test { - val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" + val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.REQUIRED_CAPABILITIES}" val prepared = PreparedWatchOnlyAccountClaim( account = watchOnlyAccount(), payload = ByteArray(84), @@ -594,8 +726,8 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { Result.success( authRequest( authUrl, - PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES, - PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1, + PubkyAuthClaim.REQUIRED_CAPABILITIES, + PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1), ), ), ) @@ -623,7 +755,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { @Test fun `retry after process restart reuses account and repeats authorization`() = test { - val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" + val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.REQUIRED_CAPABILITIES}" val prepared = PreparedWatchOnlyAccountClaim( account = watchOnlyAccount(), payload = ByteArray(84), @@ -638,8 +770,8 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { Result.success( authRequest( authUrl, - PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES, - PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1, + PubkyAuthClaim.REQUIRED_CAPABILITIES, + PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1), ), ), ) @@ -694,7 +826,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { clientId = clientId, relay = "https://httprelay.pubky.app/inbox/", capabilities = capabilities, - permissions = listOf(PubkyAuthPermission(path = "/pub/paykit/v0/bitkit/server/", accessLevel = "rw")), + permissions = listOf(PubkyAuthPermission(path = "/pub/paykit/", accessLevel = "rw")), serviceNames = listOf("paykit"), bitkitClaim = bitkitClaim, homeserverPublicKey = if (PubkyAuthRequest.isSignupUrl(authUrl)) "homeserver" else null, diff --git a/app/src/test/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt b/app/src/test/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt index 804aa4a369..41a8f61c45 100644 --- a/app/src/test/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt @@ -359,7 +359,6 @@ class SubscriptionsScreenTest { ) = PaykitSubscription( paymentRequestId = "subscription", counterparty = "pubkypayee", - counterpartyReceiverPath = "bitkit/server", amountValue = "0.001", amountSats = amountSats, note = "Subscription", diff --git a/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt index e0f7626985..7c5da8ba80 100644 --- a/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt @@ -269,14 +269,14 @@ class TrezorViewModelTest : BaseUnitTest() { sut.broadcastSignedTx() advanceUntilIdle() - verify(trezorRepo, never()).broadcastRawTx(any()) + verify(trezorRepo, never()).broadcastRawTx(any(), org.mockito.kotlin.anyOrNull()) } @Test fun `broadcastSignedTx should not restore signed step after reset`() = test { loadSignedTx() val broadcastResult = CompletableDeferred>() - whenever(trezorRepo.broadcastRawTx(any())) + whenever(trezorRepo.broadcastRawTx(any(), org.mockito.kotlin.anyOrNull())) .doSuspendableAnswer { broadcastResult.await() } sut.broadcastSignedTx() @@ -300,7 +300,7 @@ class TrezorViewModelTest : BaseUnitTest() { val broadcastResults = ArrayDeque( listOf(firstBroadcastResult, secondBroadcastResult) ) - whenever(trezorRepo.broadcastRawTx(any())) + whenever(trezorRepo.broadcastRawTx(any(), org.mockito.kotlin.anyOrNull())) .doSuspendableAnswer { broadcastResults.removeFirst().await() } sut.broadcastSignedTx() diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt index e048ac043a..2df5004f01 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt @@ -10,10 +10,12 @@ import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.withTimeout import org.junit.Before import org.junit.Test import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -30,16 +32,21 @@ import to.bitkit.models.Toast import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.HwWalletMismatchError import to.bitkit.repositories.HwWalletRepo +import to.bitkit.repositories.PaykitPaymentRequestId import to.bitkit.repositories.PreActivityMetadataRepo import to.bitkit.services.ActivityService import to.bitkit.services.CoreService import to.bitkit.test.BaseUnitTest import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.utils.AppError +import to.bitkit.utils.ServiceError import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue +import kotlin.time.Clock import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant @OptIn(ExperimentalCoroutinesApi::class) class HwSendViewModelTest : BaseUnitTest() { @@ -52,6 +59,7 @@ class HwSendViewModelTest : BaseUnitTest() { private val activityRepo = mock() private lateinit var sut: HwSendViewModel + private var now = Instant.parse("2026-10-06T11:59:59Z") @Before fun setUp() { @@ -63,49 +71,20 @@ class HwSendViewModelTest : BaseUnitTest() { preActivityMetadataRepo = preActivityMetadataRepo, coreService = coreService, activityRepo = activityRepo, + clock = object : Clock { + override fun now() = now + }, ) } @Test fun `signing reconnects and retries once after THP channel failure`() = test { - val funding = HwFundingTransaction( - psbt = "psbt", - miningFeeSats = 1_000uL, - feeRate = 2.0f, - totalSpent = 26_000uL, - satsPerVByte = 2uL, - ) - val signedTx = HwFundingSignedTx( - serializedTx = "rawtx", - miningFeeSats = funding.miningFeeSats, - feeRate = 2uL, - totalSpent = funding.totalSpent, - ) - val broadcast = HwFundingBroadcastResult( - txId = "txid", - miningFeeSats = signedTx.miningFeeSats, - feeRate = signedTx.feeRate, - totalSpent = signedTx.totalSpent, - ) - whenever(hwWalletRepo.needsPassphrase(WALLET_ID)).thenReturn(false) - whenever(hwWalletRepo.ensureConnected(WALLET_ID)).thenReturn(Result.success(connectedDevice())) - whenever(hwWalletRepo.composeFundingTransaction(WALLET_ID, ADDRESS, AMOUNT_SATS, SATS_PER_VBYTE)) - .thenReturn(Result.success(funding)) + val (funding, signedTx, broadcast) = stubSuccessfulPayment() whenever(hwWalletRepo.signFunding(WALLET_ID, funding)).thenReturn( Result.failure(TrezorException.ProtocolException("THP decryption error: aead::Error")), Result.success(signedTx), ) - whenever(hwWalletRepo.broadcastFunding(signedTx)).thenReturn(Result.success(broadcast)) - - sut.signAndBroadcast( - HwSendRequest( - walletId = WALLET_ID, - address = ADDRESS, - amountSats = AMOUNT_SATS, - satsPerVByte = SATS_PER_VBYTE, - tags = emptyList(), - ) - ) + sut.signAndBroadcast(request()) advanceUntilIdle() verify(hwWalletRepo, times(2)).ensureConnected(WALLET_ID) @@ -163,6 +142,54 @@ class HwSendViewModelTest : BaseUnitTest() { assertFalse(sut.uiState.value.hasPendingBroadcast) } + @Test + fun `invalid transaction releases the attempt only without an earlier uncertain broadcast`() = test { + whenever(context.getString(any())).thenReturn("message") + val fixture = stubSuccessfulPayment() + for (hadPriorAttempt in listOf(false, true)) { + val attempts = mutableListOf() + if (hadPriorAttempt) { + whenever(hwWalletRepo.broadcastFunding(fixture.signedTx)) + .thenReturn(Result.failure(BroadcastException.ElectrumException("offline"))) + sut.signAndBroadcast(request(), onBroadcastAttemptChanged = { attempts += it }) + advanceUntilIdle() + } + whenever(hwWalletRepo.broadcastFunding(fixture.signedTx)) + .thenReturn(Result.failure(AppError(BroadcastException.InvalidTransaction("invalid transaction")))) + + sut.signAndBroadcast(request(), onBroadcastAttemptChanged = { attempts += it }) + advanceUntilIdle() + + assertEquals(hadPriorAttempt, attempts.last()) + assertEquals(hadPriorAttempt, sut.uiState.value.hasPendingBroadcast) + assertTrue(sut.uiState.value.canLeave) + sut.cancel() + advanceUntilIdle() + } + } + + @Test + fun `unclassified broadcast failures retain the signed transaction for retry`() = test { + whenever(context.getString(any())).thenReturn("message") + val fixture = stubSuccessfulPayment() + whenever(hwWalletRepo.broadcastFunding(fixture.signedTx)).thenReturn( + Result.failure(AppError("broadcast outcome unknown")), + Result.success(fixture.broadcast), + ) + val attempts = mutableListOf() + sut.signAndBroadcast(request(), onBroadcastAttemptChanged = { attempts += it }) + advanceUntilIdle() + + assertEquals(listOf(true), attempts) + assertTrue(sut.uiState.value.hasPendingBroadcast) + + sut.signAndBroadcast(request(), onBroadcastAttemptChanged = { attempts += it }) + advanceUntilIdle() + + verify(hwWalletRepo).signFunding(WALLET_ID, fixture.funding) + verify(hwWalletRepo, times(2)).broadcastFunding(fixture.signedTx) + } + @Test fun `denied retry keeps the signed transaction after a failed broadcast`() = test { whenever(context.getString(any())).thenReturn("message") @@ -230,7 +257,9 @@ class HwSendViewModelTest : BaseUnitTest() { advanceUntilIdle() assertTrue(sut.uiState.value.isPassphraseRequired) - sut.submitPassphrase(request(), "hidden wallet", prepareContactPayment, authorizeContactPayment) + sut.submitPassphrase(WALLET_ID, "hidden wallet") { + sut.signAndBroadcast(request(), prepareContactPayment, authorizeContactPayment) + } advanceUntilIdle() assertEquals(1, preparationCalls) @@ -277,7 +306,7 @@ class HwSendViewModelTest : BaseUnitTest() { assertEquals("Payment timed out", toasts.single().description) assertFalse(sut.uiState.value.isSigning) verify(hwWalletRepo, never()).signFunding(any(), any()) - verify(hwWalletRepo, never()).broadcastFunding(any()) + verify(hwWalletRepo, never()).broadcastFunding(any(), anyOrNull()) } @Test @@ -445,7 +474,7 @@ class HwSendViewModelTest : BaseUnitTest() { verify(hwWalletRepo).disconnectStaleSession(WALLET_ID) verify(hwWalletRepo, never()).composeFundingTransaction(any(), any(), any(), any()) verify(hwWalletRepo, never()).signFunding(any(), any()) - verify(hwWalletRepo, never()).broadcastFunding(any()) + verify(hwWalletRepo, never()).broadcastFunding(any(), anyOrNull()) assertEquals(HwSendUiState(), sut.uiState.value) sut.signAndBroadcast(request()) @@ -454,6 +483,206 @@ class HwSendViewModelTest : BaseUnitTest() { verify(hwWalletRepo).broadcastFunding(fixture.signedTx) } + @Test + fun `expiry in broadcast queue releases only a never submitted attempt`() = test { + val fixture = stubSuccessfulPayment() + val deadline = Instant.parse("2026-10-06T12:00:00Z") + whenever(hwWalletRepo.broadcastFunding(fixture.signedTx, deadline)) + .thenReturn(Result.failure(AppError(ServiceError.PaymentDeadlineExpired()))) + val attempts = mutableListOf() + val broadcastAttempts = mutableListOf() + + sut.signAndBroadcast( + request().copy(paymentDeadlineAt = deadline), + authorizeContactPayment = { + attempts += it + true + }, + onPaymentDeadlineExpired = { attempts += it }, + onBroadcastAttemptChanged = { broadcastAttempts += it }, + ) + advanceUntilIdle() + + assertEquals(listOf(false, false), attempts) + assertEquals(listOf(true, false), broadcastAttempts) + assertFalse(sut.uiState.value.isBroadcastUnresolved) + assertFalse(sut.uiState.value.isSigning) + sut.cancel() + assertFalse(sut.uiState.value.hasPendingBroadcast) + } + + @Test + fun `expiry during rebroadcast retains the earlier attempt without blocking dismissal`() = test { + whenever(context.getString(any())).thenReturn("message") + val fixture = stubSuccessfulPayment() + val deadline = Instant.parse("2026-10-06T12:00:00Z") + whenever(hwWalletRepo.broadcastFunding(fixture.signedTx, deadline)).thenReturn( + Result.failure(BroadcastException.ElectrumException("connection failed")), + Result.failure(AppError(ServiceError.PaymentDeadlineExpired())), + ) + val attempts = mutableListOf() + val broadcastAttempts = mutableListOf() + val authorize: suspend (Boolean) -> Boolean = { + attempts += it + attempts.size < 3 + } + val request = request().copy(paymentDeadlineAt = deadline) + + sut.signAndBroadcast( + request, + authorizeContactPayment = authorize, + onPaymentDeadlineExpired = { attempts += it }, + onBroadcastAttemptChanged = { broadcastAttempts += it }, + ) + advanceUntilIdle() + sut.signAndBroadcast( + request, + authorizeContactPayment = authorize, + onPaymentDeadlineExpired = { attempts += it }, + onBroadcastAttemptChanged = { broadcastAttempts += it }, + ) + advanceUntilIdle() + + assertEquals(listOf(false, true, true), attempts) + assertEquals(listOf(true, true, true), broadcastAttempts) + assertFalse(sut.uiState.value.isBroadcastUnresolved) + assertTrue(sut.uiState.value.hasPendingBroadcast) + assertTrue(sut.uiState.value.canLeave) + assertFalse(sut.uiState.value.isSigning) + sut.cancel() + assertFalse(sut.uiState.value.hasPendingBroadcast) + assertTrue(sut.uiState.value.canLeave) + verify(hwWalletRepo).signFunding(WALLET_ID, fixture.funding) + } + + @Test + fun `confirmed payment resolves an expired hardware retry without rebroadcasting`() = test { + whenever(context.getString(any())).thenReturn("message") + val fixture = stubSuccessfulPayment() + val deadline = Instant.parse("2026-10-06T12:00:00Z") + whenever(hwWalletRepo.broadcastFunding(fixture.signedTx, deadline)) + .thenReturn(Result.failure(BroadcastException.ElectrumException("connection failed"))) + val requestId = PaykitPaymentRequestId("request", "counterparty") + val request = request().copy(paymentDeadlineAt = deadline, paymentRequestId = requestId) + var authorizations = 0 + var expiredPriorAttempt: Boolean? = null + val authorize: suspend (Boolean) -> Boolean = { + authorizations++ + true + } + sut.signAndBroadcast(request, authorizeContactPayment = authorize) + advanceUntilIdle() + now = deadline + 1.seconds + + sut.signAndBroadcast( + request, + authorizeContactPayment = authorize, + onPaymentDeadlineExpired = { expiredPriorAttempt = it }, + ) + advanceUntilIdle() + + assertEquals(1, authorizations) + assertEquals(true, expiredPriorAttempt) + assertFalse(sut.uiState.value.isBroadcastUnresolved) + assertTrue(sut.uiState.value.hasPendingBroadcast) + assertFalse(sut.uiState.value.isSigning) + assertTrue(sut.uiState.value.canLeave) + + sut.resolveBroadcast(WALLET_ID, requestId, fixture.broadcast.txId) + advanceUntilIdle() + + assertEquals(HwSendResult(WALLET_ID, fixture.broadcast.txId, AMOUNT_SATS), sut.results.first()) + verify(activityService).createSentOnchainActivityFromSendResult( + txid = fixture.broadcast.txId, + address = ADDRESS, + amount = AMOUNT_SATS, + fee = fixture.signedTx.miningFeeSats, + feeRate = fixture.signedTx.feeRate, + isTransfer = false, + channelId = null, + walletId = WALLET_ID, + ) + sut.completeBroadcast() + assertTrue(sut.uiState.value.canLeave) + assertFalse(sut.uiState.value.hasPendingBroadcast) + verify(hwWalletRepo).broadcastFunding(fixture.signedTx, deadline) + } + + @Test + fun `payment resolution ignores a different request or wallet and stops an active retry`() = test { + whenever(context.getString(any())).thenReturn("message") + val fixture = stubSuccessfulPayment() + whenever(hwWalletRepo.broadcastFunding(fixture.signedTx)) + .thenReturn(Result.failure(BroadcastException.ElectrumException("connection failed"))) + val requestId = PaykitPaymentRequestId("request", "counterparty") + val request = request().copy(paymentRequestId = requestId) + sut.signAndBroadcast(request) + advanceUntilIdle() + + val authorization = CompletableDeferred() + sut.signAndBroadcast(request, authorizeContactPayment = { authorization.await() }) + runCurrent() + sut.resolveBroadcast("other-wallet", requestId, fixture.broadcast.txId) + sut.resolveBroadcast(WALLET_ID, requestId.copy(paymentRequestId = "other-request"), fixture.broadcast.txId) + runCurrent() + verify(activityRepo, never()).notifyPaymentActivityChanged() + assertTrue(sut.uiState.value.isSigning) + + sut.resolveBroadcast(WALLET_ID, requestId, fixture.broadcast.txId) + advanceUntilIdle() + authorization.complete(true) + advanceUntilIdle() + + assertEquals(HwSendResult(WALLET_ID, fixture.broadcast.txId, AMOUNT_SATS), sut.results.first()) + verify(hwWalletRepo).broadcastFunding(fixture.signedTx) + sut.completeBroadcast() + assertTrue(sut.uiState.value.canLeave) + } + + @Test + fun `payment resolution cannot complete a hardware send before broadcast was attempted`() = test { + stubSuccessfulPayment() + val requestId = PaykitPaymentRequestId("request", "counterparty") + sut.signAndBroadcast(request().copy(paymentRequestId = requestId), authorizeContactPayment = { false }) + advanceUntilIdle() + + assertFalse(sut.resolveBroadcast(WALLET_ID, requestId, "txid")) + advanceUntilIdle() + + verify(hwWalletRepo, never()).broadcastFunding(any(), any()) + verify(activityRepo, never()).notifyPaymentActivityChanged() + assertTrue(sut.uiState.value.hasPendingBroadcast) + assertTrue(sut.uiState.value.canLeave) + } + + @Test + fun `expiry during retry authorization retains the earlier attempt without blocking dismissal`() = test { + whenever(context.getString(any())).thenReturn("message") + val fixture = stubSuccessfulPayment() + val deadline = Instant.parse("2026-10-06T12:00:00Z") + whenever(hwWalletRepo.broadcastFunding(fixture.signedTx, deadline)) + .thenReturn(Result.failure(BroadcastException.ElectrumException("connection failed"))) + val request = request().copy(paymentDeadlineAt = deadline) + sut.signAndBroadcast(request) + advanceUntilIdle() + + sut.signAndBroadcast(request, authorizeContactPayment = { + assertTrue(it) + now = deadline + 1.seconds + false + }) + advanceUntilIdle() + + assertFalse(sut.uiState.value.isBroadcastUnresolved) + assertTrue(sut.uiState.value.hasPendingBroadcast) + assertFalse(sut.uiState.value.isSigning) + assertTrue(sut.uiState.value.canLeave) + sut.cancel() + assertFalse(sut.uiState.value.hasPendingBroadcast) + assertTrue(sut.uiState.value.canLeave) + verify(hwWalletRepo).broadcastFunding(fixture.signedTx, deadline) + } + private suspend fun stubSuccessfulPayment(): PaymentFixture { val funding = HwFundingTransaction( psbt = "psbt", diff --git a/app/src/test/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/RefreshContactPaykitLinkUseCaseTest.kt similarity index 51% rename from app/src/test/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCaseTest.kt rename to app/src/test/java/to/bitkit/usecases/RefreshContactPaykitLinkUseCaseTest.kt index 052a91a001..d0e0f94233 100644 --- a/app/src/test/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/RefreshContactPaykitLinkUseCaseTest.kt @@ -3,9 +3,7 @@ package to.bitkit.usecases import kotlinx.coroutines.flow.MutableStateFlow import org.junit.Before import org.junit.Test -import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock -import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.models.PubkyProfile @@ -15,7 +13,7 @@ import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals import kotlin.test.assertTrue -class RefreshContactPaykitReceiversUseCaseTest : BaseUnitTest() { +class RefreshContactPaykitLinkUseCaseTest : BaseUnitTest() { private val pubkyRepo = mock() private val privatePaykitRepo = mock() private val contactKeys = listOf("pubky-alice", "pubky-bob") @@ -32,7 +30,7 @@ class RefreshContactPaykitReceiversUseCaseTest : BaseUnitTest() { }, ) - private val sut = RefreshContactPaykitReceiversUseCase( + private val sut = RefreshContactPaykitLinkUseCase( ioDispatcher = testDispatcher, pubkyRepo = pubkyRepo, privatePaykitRepo = privatePaykitRepo, @@ -44,31 +42,24 @@ class RefreshContactPaykitReceiversUseCaseTest : BaseUnitTest() { } @Test - fun `refreshes receiver paths before publishing the contact`() = test { - whenever { pubkyRepo.refreshContactReceiverPaths(contactKeys.last()) }.thenReturn(Result.success(Unit)) - whenever { - privatePaykitRepo.refreshSavedContactEndpoints(contactKeys.last(), contactKeys) - }.thenReturn(Result.success(Unit)) + fun `refreshes contact endpoints with saved contacts`() = test { + whenever(privatePaykitRepo.refreshSavedContactEndpoints(contactKeys.last(), contactKeys)) + .thenReturn(Result.success(Unit)) val result = sut(contactKeys.last()) assertTrue(result.isSuccess) - inOrder(pubkyRepo, privatePaykitRepo).apply { - verify(pubkyRepo).refreshContactReceiverPaths(contactKeys.last()) - verify(privatePaykitRepo).refreshSavedContactEndpoints(contactKeys.last(), contactKeys) - verify(privatePaykitRepo).startInitialLinkBurst(contactKeys, "contact receiver refresh") - } + verify(privatePaykitRepo).refreshSavedContactEndpoints(contactKeys.last(), contactKeys) } @Test - fun `stops when receiver discovery fails`() = test { - val error = IllegalStateException("Discovery failed") - whenever { pubkyRepo.refreshContactReceiverPaths(contactKeys.last()) }.thenReturn(Result.failure(error)) + fun `returns endpoint refresh failure`() = test { + val error = IllegalStateException("Endpoint refresh failed") + whenever(privatePaykitRepo.refreshSavedContactEndpoints(contactKeys.last(), contactKeys)) + .thenReturn(Result.failure(error)) val result = sut(contactKeys.last()) assertEquals(error, result.exceptionOrNull()) - verify(privatePaykitRepo, never()).refreshSavedContactEndpoints(contactKeys.last(), contactKeys) - verify(privatePaykitRepo, never()).startInitialLinkBurst(contactKeys, "contact receiver refresh") } } diff --git a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt index 13e102b7b8..fc7f2d144b 100644 --- a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt @@ -61,7 +61,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { private val privatePaykitAddressReservationRepo = mock() private val firebaseMessaging = mock() private val migrationService = mock() - private val paykitSdkService = PaykitSdkService(mock(), keychain, mock()) { mock() } + private val paykitSdkService = PaykitSdkService(mock(), keychain, mock(), settingsStore = mock()) { mock() } private val privatePaykitRepoProvider = Provider { privatePaykitRepo } private lateinit var sut: WipeWalletUseCase diff --git a/app/src/test/java/to/bitkit/utils/CryptoTest.kt b/app/src/test/java/to/bitkit/utils/CryptoTest.kt index 483cf67fad..f1e9ff416a 100644 --- a/app/src/test/java/to/bitkit/utils/CryptoTest.kt +++ b/app/src/test/java/to/bitkit/utils/CryptoTest.kt @@ -14,6 +14,7 @@ import java.security.Provider import java.security.Security import kotlin.test.assertContentEquals import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertIs import kotlin.test.assertSame import kotlin.test.assertTrue @@ -41,6 +42,36 @@ class CryptoTest { baselineProvider?.let { Security.insertProviderAt(it, baselinePosition) } } + @Test + fun `it should preserve registered providers while using its own provider`() { + val originalProviders = Security.getProviders() + val originalProvider = Security.getProvider("BC") + val originalPosition = originalProviders.indexOf(originalProvider) + 1 + val platformProvider = object : Provider("BC", 1.0, "Test platform provider") {} + + try { + Security.removeProvider("BC") + Security.insertProviderAt(platformProvider, 1) + val providersBefore = Security.getProviders() + + val crypto = Crypto() + val keys = crypto.generateKeyPair() + assertContentEquals(keys.publicKey, crypto.getPublicKey(keys.privateKey)) + val secret = crypto.generateSharedSecret(keys.privateKey, keys.publicKey.toHex(), derivationName) + val plaintext = "Provider preservation".toByteArray() + val encrypted = crypto.encrypt(plaintext, secret) + assertContentEquals(plaintext, crypto.decrypt(encrypted, secret)) + + assertSame(platformProvider, Security.getProvider("BC")) + val providersAfter = Security.getProviders() + assertEquals(providersBefore.size, providersAfter.size) + providersBefore.forEachIndexed { index, provider -> assertSame(provider, providersAfter[index]) } + } finally { + Security.removeProvider("BC") + if (originalProvider != null) Security.insertProviderAt(originalProvider, originalPosition) + } + } + @Test fun `it should generate valid shared secret from keypair`() { val (privateKey, publicKey) = sut.generateKeyPair() @@ -74,6 +105,8 @@ class CryptoTest { // Step 4: Server encrypts data using the shared secret val dataToEncrypt = "Hello from the server!" val encrypted = sut.encrypt(dataToEncrypt.toByteArray(), serverSecret) + assertEquals(12, encrypted.iv.size) + assertEquals(16, encrypted.tag.size) val response = EncryptedNotification( cipher = encrypted.cipher.toBase64(), iv = encrypted.iv.toHex(), @@ -128,6 +161,14 @@ class CryptoTest { val value = sut.decrypt(encryptedPayload, sharedHash) assertEquals(decryptedPayload, value.decodeToString()) + + val invalidTag = encryptedPayload.tag.copyOf().apply { this[0] = (this[0].toInt() xor 1).toByte() } + assertFailsWith { + sut.decrypt(encryptedPayload.copy(tag = invalidTag), sharedHash) + } + assertFailsWith { + sut.decrypt(encryptedPayload.copy(tag = encryptedPayload.tag.copyOf(15)), sharedHash) + } } @Test diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 408dd78910..f9200c346b 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -28,12 +28,14 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.async import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.cancel import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf @@ -118,6 +120,7 @@ import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.BlocktankState import to.bitkit.repositories.ConnectivityRepo import to.bitkit.repositories.ConnectivityState +import to.bitkit.repositories.ContactPaymentSettingsRepo import to.bitkit.repositories.CurrencyRepo import to.bitkit.repositories.HealthRepo import to.bitkit.repositories.HwWalletRepo @@ -136,6 +139,7 @@ import to.bitkit.repositories.PaykitPaymentRequestDiagnostics import to.bitkit.repositories.PaykitPaymentRequestDraft import to.bitkit.repositories.PaykitPaymentRequestError import to.bitkit.repositories.PaykitPaymentRequestId +import to.bitkit.repositories.PaykitPaymentRequestRefreshMode import to.bitkit.repositories.PaykitPaymentRequestRepo import to.bitkit.repositories.PaykitPaymentRequestTarget import to.bitkit.repositories.PaykitRecurrenceUnit @@ -170,6 +174,7 @@ import to.bitkit.services.AppUpdaterService import to.bitkit.services.CoreService import to.bitkit.services.MigrationService import to.bitkit.services.NodeServiceFgState +import to.bitkit.services.PaykitSdkOperationLock.Priority import to.bitkit.services.PubkyService import to.bitkit.test.BaseUnitTest import to.bitkit.ui.Routes @@ -182,8 +187,9 @@ import to.bitkit.ui.sheets.hardware.HardwareRoute import to.bitkit.ui.theme.TRANSITION_SCREEN_MS import to.bitkit.ui.utils.ScreenDeepLinks import to.bitkit.usecases.FormatMoneyValue -import to.bitkit.usecases.RefreshContactPaykitReceiversUseCase +import to.bitkit.usecases.RefreshContactPaykitLinkUseCase import to.bitkit.utils.AppError +import to.bitkit.utils.ServiceError import to.bitkit.utils.timedsheets.TimedSheetManager import java.math.BigDecimal import java.net.URLEncoder @@ -195,6 +201,7 @@ import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Clock +import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime import kotlin.time.Instant @@ -242,7 +249,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val samRockRepo = mock() private val widgetsRepo = mock() private val formatMoneyValue = mock() - private val refreshContactPaykitReceivers = mock() + private val refreshContactPaykitLink = mock() private val clipboardManager = mock() private val toastManager = mock() private val toastState = MutableStateFlow(null) @@ -254,6 +261,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val needsPairingCode = MutableStateFlow(false) private val pairingCodeRequestId = MutableStateFlow(null) private val settingsData = MutableStateFlow(SettingsData()) + private val contactPaymentSettingsRepo by lazy { + ContactPaymentSettingsRepo(settingsStore, publicPaykitRepo, privatePaykitRepo, pubkyRepo, testDispatcher) + } private val isRecoveryMode = MutableStateFlow(false) private val isPaykitEnabled = MutableStateFlow(false) private val walletState = MutableStateFlow(WalletState()) @@ -267,6 +277,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val paykitPaymentRequestHistory = MutableStateFlow>(emptyList()) private val paykitSubscriptions = MutableStateFlow>(emptyList()) private val onchainPaymentResolutions = MutableStateFlow>(emptyList()) + private val proofStateVersion = MutableStateFlow(0L) + private val paymentSubmissionActive = MutableStateFlow(false) private val surfacedPaykitPaymentRequestIds = mutableSetOf() private val testPublicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xy" private val nonCanonicalTestPublicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" @@ -410,8 +422,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { if (pubkyPublicKey.value != null) PubkyIdentityReadiness.Ready else PubkyIdentityReadiness.Missing } whenever(pubkyRepo.contacts).thenReturn(pubkyContacts) - whenever { refreshContactPaykitReceivers(any()) }.thenReturn(Result.success(Unit)) - whenever { publicPaykitRepo.syncLocalReceiverMarker(anyOrNull(), anyOrNull()) } + whenever { refreshContactPaykitLink(any()) }.thenReturn(Result.success(Unit)) + whenever { publicPaykitRepo.syncPaykitApp(anyOrNull()) } .thenReturn(Result.success(Unit)) whenever(pubkyRepo.contactsLoadVersion).thenReturn(pubkyContactsLoadVersion) whenever(pubkyRepo.contactsLoadCompletionVersion).thenReturn(pubkyContactsLoadCompletionVersion) @@ -421,7 +433,17 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(paykitPaymentRequestRepo.automaticSubscriptionProposals()).thenReturn(emptyList()) whenever(paykitPaymentRequestRepo.eligibleTargets).thenReturn(MutableStateFlow(emptyList())) whenever(paykitPaymentRequestRepo.isCreatingRequest).thenReturn(MutableStateFlow(false)) + whenever(paykitPaymentRequestRepo.isPaymentSubmissionActive).thenReturn(paymentSubmissionActive) + whenever(paykitPaymentRequestRepo.setPaymentSubmissionActive(any())).thenAnswer { + paymentSubmissionActive.value = it.getArgument(0) + Unit + } whenever { paykitPaymentRequestRepo.refreshEligibleTargets(any(), any()) }.thenReturn(Result.success(Unit)) + whenever { paykitPaymentRequestRepo.refreshAfterStateChange(any()) }.thenReturn(Result.success(Unit)) + whenever { paykitPaymentRequestRepo.isSubscriptionNotificationHandled(any(), any()) }.thenReturn(false) + whenever { paykitPaymentRequestRepo.refresh(any(), any()) }.doSuspendableAnswer { + paykitPaymentRequestRepo.refresh(it.getArgument(0)) + } whenever(paykitPaymentRequestRepo.automaticPendingRequests()).thenAnswer { pendingPaykitPaymentRequests.value.filterNot { it.id in surfacedPaykitPaymentRequestIds } } @@ -435,8 +457,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } whenever(paykitPaymentRequestRepo.isPending(any())).thenReturn(true) whenever { paykitPaymentRequestRepo.ensurePaymentAllowed(any()) }.thenReturn(Result.success(Unit)) - whenever { lightningRepo.payInvoice(any(), anyOrNull(), any()) }.doSuspendableAnswer { - if (it.getArgument Boolean>(2)()) { + whenever { paykitPaymentRequestRepo.claimForPayment(any()) }.thenReturn(Result.success(Unit)) + whenever { lightningRepo.payInvoice(any(), anyOrNull(), anyOrNull(), any()) }.doSuspendableAnswer { + if (it.getArgument Boolean>(3)()) { lightningRepo.payInvoice(it.getArgument(0), it.getArgument(1)) } else { Result.failure(PaymentAbortedBeforeSend()) @@ -445,16 +468,16 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(paykitPaymentRequestRepo.isExpired(any())).thenReturn(false) whenever(paykitPaymentRequestRepo.isProcessing(any())).thenReturn(false) whenever(paykitPaymentProofRepo.onchainPaymentResolutions).thenReturn(onchainPaymentResolutions) - whenever { paykitPaymentProofRepo.prepare(any(), any(), any()) }.thenReturn(Result.success(Unit)) + whenever(paykitPaymentProofRepo.paymentRequestStateChanges(any())).thenReturn(proofStateVersion.drop(1).map { }) + whenever { paykitPaymentProofRepo.prepare(any(), any(), any(), any()) }.thenReturn(Result.success(Unit)) whenever { - paykitPaymentProofRepo.associateLightningPayment(any(), any(), any()) + paykitPaymentProofRepo.associateLightningPayment(any(), any(), any(), eq("bitkit")) }.thenReturn(Result.success(Unit)) whenever { paykitPaymentProofRepo.markOnchainPaymentStarted(any(), any(), any()) }.thenReturn(Result.success(Unit)) whenever { activityRepo.setContact(any(), any(), any(), any()) }.thenReturn(Result.success(Unit)) - whenever(privatePaykitRepo.initialLinkBurstStarted).thenReturn(MutableSharedFlow()) - whenever { privatePaykitRepo.prepareSavedContacts(any>(), any()) } + whenever { privatePaykitRepo.scheduleSavedContactPreparation(any>()) } .thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.pruneUnsavedContactState(any>()) } .thenReturn(Result.success(Unit)) @@ -467,6 +490,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever { privatePaykitRepo.disableSharingAndPruneUnsavedContactState(any>()) } .thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.removeSavedContact(any()) }.thenReturn(Result.success(Unit)) + whenever { privatePaykitRepo.removeSavedContacts(any()) }.thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.reconcileReceivedPayments() }.thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.handleOnchainActivity(any>()) } .thenReturn(Result.success(Unit)) @@ -489,8 +513,6 @@ class AppViewModelSendFlowTest : BaseUnitTest() { true } } - whenever { privatePaykitRepo.contactPublicKeyForPrivateOnchainAddresses(any>()) } - .thenReturn(null) whenever { privatePaykitRepo.discardRemoteLightningEndpoints(any(), any()) } .thenReturn(Result.success(Unit)) whenever(currencyRepo.convertSatsToFiat(any(), anyOrNull())) @@ -542,10 +564,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { nodeServiceFgState = nodeServiceFgState, publicPaykitRepo = publicPaykitRepo, privatePaykitRepo = privatePaykitRepo, + contactPaymentSettingsRepo = contactPaymentSettingsRepo, paykitPaymentRequestRepo = paykitPaymentRequestRepo, paykitPaymentProofRepo = paykitPaymentProofRepo, paykitPaymentRequestDiagnostics = paykitPaymentRequestDiagnostics, - refreshContactPaykitReceivers = refreshContactPaykitReceivers, + refreshContactPaykitLink = refreshContactPaykitLink, samRockRepo = samRockRepo, appUpdateSheet = mock(), backupSheet = mock(), @@ -555,6 +578,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { formatMoneyValue = formatMoneyValue, widgetsRepo = widgetsRepo, pubkyRepo = pubkyRepo, + timeSource = testDispatcher.scheduler.timeSource, ) private suspend fun emitNodeEvent( @@ -708,7 +732,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `network restoration republishes identity and resumes ten second polling`() = test { enablePaykitUi() pubkyPublicKey.value = testPublicKey - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) sut.startPaykitPaymentRequestPolling() try { advanceTimeBy(30.seconds.inWholeMilliseconds) @@ -730,38 +754,278 @@ class AppViewModelSendFlowTest : BaseUnitTest() { clearInvocations(paykitPaymentRequestRepo) advanceTimeBy(10.seconds.inWholeMilliseconds - 1) runCurrent() - verify(paykitPaymentRequestRepo, never()).refresh() + verify(paykitPaymentRequestRepo, never()).refresh(any()) advanceTimeBy(1) runCurrent() - verify(paykitPaymentRequestRepo).refresh() + verify(paykitPaymentRequestRepo).refresh(any()) } finally { sut.stopPaykitPaymentRequestPolling() } } @Test - fun `identity republish follows maintenance intervals instead of each payment request poll`() = test { + fun `outbound maintenance and identity republish follow maintenance intervals`() = test { enablePaykitUi() pubkyPublicKey.value = testPublicKey - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) sut.startPaykitPaymentRequestPolling() try { + advanceTimeBy(30.seconds.inWholeMilliseconds) runCurrent() - for (interval in listOf(30.seconds, 60.seconds, 120.seconds, 120.seconds)) { - clearInvocations(pubkyRepo) + verify(paykitPaymentRequestRepo, atLeast(2)).refresh(PaykitPaymentRequestRefreshMode.FULL) + for (interval in listOf(60.seconds, 60.seconds, 60.seconds)) { + clearInvocations(pubkyRepo, paykitPaymentRequestRepo) advanceTimeBy(interval.inWholeMilliseconds - 1) runCurrent() verify(pubkyRepo, never()).republishIdentityIfNeeded() + verify(paykitPaymentRequestRepo, never()).refresh(PaykitPaymentRequestRefreshMode.FULL) + verify(paykitPaymentRequestRepo, atLeast(1)).refresh(PaykitPaymentRequestRefreshMode.INBOX) advanceTimeBy(1) runCurrent() verify(pubkyRepo).republishIdentityIfNeeded() + verify(paykitPaymentRequestRepo).refresh(PaykitPaymentRequestRefreshMode.FULL) } } finally { sut.stopPaykitPaymentRequestPolling() } } + @Test + fun `slow inbox refresh counts toward maintenance deadline`() = test { + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + whenever(paykitPaymentRequestRepo.refresh(PaykitPaymentRequestRefreshMode.FULL)) + .thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(PaykitPaymentRequestRefreshMode.INBOX)).doSuspendableAnswer { + kotlinx.coroutines.delay(25.seconds) + Result.success(Unit) + } + sut.startPaykitPaymentRequestPolling() + try { + runCurrent() + clearInvocations(pubkyRepo) + advanceTimeBy(44.seconds.inWholeMilliseconds) + runCurrent() + verify(pubkyRepo, never()).republishIdentityIfNeeded() + + advanceTimeBy(1.seconds.inWholeMilliseconds) + runCurrent() + verify(pubkyRepo).republishIdentityIfNeeded() + } finally { + sut.stopPaykitPaymentRequestPolling() + } + } + + @Test + fun `maintenance discovers targets and refreshes inbox without joining all contact preparation`() = test { + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + sut.setIsAuthenticated(true) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) + sut.startPaykitPaymentRequestPolling() + val prepared = CompletableDeferred() + try { + runCurrent() + whenever(privatePaykitRepo.awaitContactPreparation()).doSuspendableAnswer { prepared.await() } + advanceTimeBy(30.seconds.inWholeMilliseconds - 1) + runCurrent() + clearInvocations(paykitPaymentRequestRepo, privatePaykitRepo) + + advanceTimeBy(1) + runCurrent() + assertFalse(prepared.isCompleted) + verify(privatePaykitRepo, never()).awaitContactPreparation() + verify(paykitPaymentRequestRepo).refresh(PaykitPaymentRequestRefreshMode.FULL) + verify(paykitPaymentRequestRepo).refreshEligibleTargets(any(), eq(true)) + inOrder(paykitPaymentRequestRepo, privatePaykitRepo) { + verify(paykitPaymentRequestRepo).refresh(PaykitPaymentRequestRefreshMode.FULL, Priority.Background) + verify(privatePaykitRepo).refreshKnownSavedContactEndpoints("payment request polling") + } + } finally { + prepared.complete(Unit) + sut.stopPaykitPaymentRequestPolling() + } + } + + @Test + fun `maintenance defers routine contact preparation while incoming request is displayed`() = test { + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + sut.setIsAuthenticated(true) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) + val request = paymentRequest() + val bolt11 = "lnbcrt1maintenance" + stubLightningScan(bolt11, 0u) + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + val resolution = CompletableDeferred>() + whenever(privatePaykitRepo.beginPaymentRequest(request)).doSuspendableAnswer { resolution.await() } + sut.startPaykitPaymentRequestPolling() + try { + runCurrent() + whenever(paykitPaymentRequestRepo.refresh(PaykitPaymentRequestRefreshMode.FULL)).doSuspendableAnswer { + pendingPaykitPaymentRequests.value = listOf(request) + Result.success(Unit) + } + clearInvocations(privatePaykitRepo) + advanceTimeBy(30.seconds.inWholeMilliseconds) + runCurrent() + assertEquals(request, (sut.currentSheet.value as? Sheet.Send)?.preparingRequest) + verify(privatePaykitRepo, never()).refreshKnownSavedContactEndpoints("payment request polling") + verify(paykitPaymentRequestRepo, never()).markPresented(any()) + + resolution.complete( + Result.success(PublicPaykitPaymentResult.Opened(bolt11, privatePaymentContext(7uL))), + ) + runCurrent() + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + verify(privatePaykitRepo, never()).refreshKnownSavedContactEndpoints("payment request polling") + sut.onSheetVisible(sut.currentSheet.value) + runCurrent() + sut.hideSheet() + runCurrent() + advanceTimeBy(60.seconds.inWholeMilliseconds) + runCurrent() + verify(privatePaykitRepo).refreshKnownSavedContactEndpoints("payment request polling") + } finally { + sut.stopPaykitPaymentRequestPolling() + } + } + + @Test + fun `maintenance does not schedule contact preparation for identity replaced during intake`() = test { + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + sut.setIsAuthenticated(true) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) + sut.startPaykitPaymentRequestPolling() + try { + runCurrent() + whenever(paykitPaymentRequestRepo.refresh(PaykitPaymentRequestRefreshMode.FULL)).doSuspendableAnswer { + pubkyPublicKey.value = null + Result.success(Unit) + } + clearInvocations(privatePaykitRepo) + advanceTimeBy(30.seconds.inWholeMilliseconds) + runCurrent() + verify(privatePaykitRepo, never()).refreshKnownSavedContactEndpoints("payment request polling") + } finally { + sut.stopPaykitPaymentRequestPolling() + } + } + + @Test + fun `deferred session recovery runs once while foreground online and enabled`() = test { + enablePaykitUi() + connectivityState.value = ConnectivityState.DISCONNECTED + var activeRetries = 0 + var attempts = 0 + whenever(pubkyRepo.retryDeferredSessionRestoration()).doSuspendableAnswer { + attempts++ + activeRetries++ + try { + awaitCancellation() + } finally { + activeRetries-- + } + } + + sut.startPaykitPaymentRequestPolling() + assertEquals(0, attempts) + connectivityState.value = ConnectivityState.CONNECTED + runCurrent() + assertEquals(1, attempts) + assertEquals(1, activeRetries) + sut.startPaykitPaymentRequestPolling() + assertEquals(1, attempts) + + connectivityState.value = ConnectivityState.DISCONNECTED + runCurrent() + assertEquals(0, activeRetries) + connectivityState.value = ConnectivityState.CONNECTED + runCurrent() + assertEquals(2, attempts) + isPaykitEnabled.value = false + runCurrent() + assertEquals(0, activeRetries) + + isPaykitEnabled.value = true + runCurrent() + assertEquals(3, attempts) + sut.stopPaykitPaymentRequestPolling() + runCurrent() + assertEquals(0, activeRetries) + clearInvocations(pubkyRepo) + connectivityState.value = ConnectivityState.DISCONNECTED + connectivityState.value = ConnectivityState.CONNECTED + runCurrent() + verify(pubkyRepo, never()).retryDeferredSessionRestoration() + } + + @Test + fun `foreground maintenance retries a missing session until restored`() = test { + enablePaykitUi() + pubkyPublicKey.value = null + var attempts = 0 + whenever(pubkyRepo.restoreSessionIfNeeded()).thenAnswer { + if (++attempts == 2) pubkyPublicKey.value = testPublicKey + Unit + } + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) + clearInvocations(pubkyRepo, paykitPaymentRequestRepo) + + sut.startPaykitPaymentRequestPolling() + try { + advanceTimeBy(30.seconds.inWholeMilliseconds) + runCurrent() + verify(pubkyRepo).restoreSessionIfNeeded() + verify(paykitPaymentRequestRepo, never()).refresh(any()) + + advanceTimeBy(60.seconds.inWholeMilliseconds) + runCurrent() + verify(pubkyRepo, times(2)).restoreSessionIfNeeded() + verify(paykitPaymentRequestRepo).refresh(any()) + } finally { + sut.stopPaykitPaymentRequestPolling() + } + } + + @Test + fun `foreground maintenance retries pending cleanup until it succeeds`() = test { + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + settingsData.value = SettingsData(publicPaykitCleanupPending = true) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) + whenever(publicPaykitRepo.syncPublishedEndpoints(publish = false)).thenReturn( + Result.failure(AppError("Registry unavailable")), + Result.success(Unit), + ) + + sut.startPaykitPaymentRequestPolling() + try { + advanceTimeBy(30.seconds.inWholeMilliseconds) + runCurrent() + verify(publicPaykitRepo).syncPublishedEndpoints(publish = false) + verify(privatePaykitRepo).retryPendingEndpointRemoval(emptyList()) + assertTrue(settingsData.value.publicPaykitCleanupPending) + + advanceTimeBy(60.seconds.inWholeMilliseconds) + runCurrent() + verify(publicPaykitRepo, times(2)).syncPublishedEndpoints(publish = false) + verify(privatePaykitRepo, times(2)).retryPendingEndpointRemoval(emptyList()) + assertFalse(settingsData.value.publicPaykitCleanupPending) + + advanceTimeBy(60.seconds.inWholeMilliseconds) + runCurrent() + verify(publicPaykitRepo, times(2)).syncPublishedEndpoints(publish = false) + verify(privatePaykitRepo, times(3)).retryPendingEndpointRemoval(emptyList()) + assertFalse(settingsData.value.sharesPublicPaykitEndpoints) + assertFalse(settingsData.value.sharesPrivatePaykitEndpoints) + } finally { + sut.stopPaykitPaymentRequestPolling() + } + } + @Test fun `offline periodic polling skips inbox and maintenance`() = test { enablePaykitUi() @@ -781,7 +1045,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceTimeBy(210.seconds.inWholeMilliseconds) runCurrent() verify(pubkyRepo, never()).republishIdentityIfNeeded() - verify(paykitPaymentRequestRepo, never()).refresh() + verify(paykitPaymentRequestRepo, never()).refresh(any()) + verify(pubkyRepo, never()).restoreSessionIfNeeded() verify(paykitPaymentRequestRepo, never()).refreshEligibleTargets(any(), any()) verify(paykitPaymentProofRepo, never()).reconcile() verify(privatePaykitRepo, never()).refreshKnownSavedContactEndpoints("payment request polling") @@ -812,7 +1077,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `payment requests refresh promptly without repeating maintenance on each poll`() = test { isPaykitEnabled.value = true pubkyPublicKey.value = testPublicKey - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) runCurrent() clearInvocations(paykitPaymentRequestRepo) @@ -820,28 +1085,34 @@ class AppViewModelSendFlowTest : BaseUnitTest() { try { runCurrent() - verify(paykitPaymentRequestRepo).refresh() - clearInvocations(paykitPaymentRequestRepo) + verify(paykitPaymentRequestRepo).refresh(PaykitPaymentRequestRefreshMode.FULL) + clearInvocations(paykitPaymentRequestRepo, paykitPaymentProofRepo) - advanceTimeBy(30.seconds.inWholeMilliseconds) + advanceTimeBy(29.seconds.inWholeMilliseconds) runCurrent() + verify(paykitPaymentRequestRepo, atLeast(1)).refresh(PaykitPaymentRequestRefreshMode.INBOX) + verify(paykitPaymentRequestRepo, never()).refresh(PaykitPaymentRequestRefreshMode.FULL) + verify(paykitPaymentRequestRepo, never()).refreshEligibleTargets(any(), any()) + verify(paykitPaymentProofRepo, never()).reconcile() - verify(paykitPaymentRequestRepo, atLeast(2)).refresh() - clearInvocations(paykitPaymentRequestRepo) - clearInvocations(privatePaykitRepo, paykitPaymentProofRepo) + advanceTimeBy(1.seconds.inWholeMilliseconds) + runCurrent() + + verify(paykitPaymentRequestRepo, atLeast(2)).refresh(any()) + clearInvocations(paykitPaymentRequestRepo, privatePaykitRepo, paykitPaymentProofRepo) advanceTimeBy(9.seconds.inWholeMilliseconds) runCurrent() - verify(paykitPaymentRequestRepo, never()).refresh() + verify(paykitPaymentRequestRepo, never()).refresh(any()) - val request = paymentRequest() - whenever(paykitPaymentRequestRepo.refresh()).doSuspendableAnswer { + val request = paymentRequest().also { surfacedPaykitPaymentRequestIds += it.id } + whenever(paykitPaymentRequestRepo.refresh(any())).doSuspendableAnswer { pendingPaykitPaymentRequests.value = listOf(request) Result.success(Unit) } advanceTimeBy(1.seconds.inWholeMilliseconds) runCurrent() - verify(paykitPaymentRequestRepo).refresh() + verify(paykitPaymentRequestRepo).refresh(any()) verify(privatePaykitRepo, never()).refreshKnownSavedContactEndpoints(any(), any()) verify(paykitPaymentProofRepo, never()).reconcile() verify(paykitPaymentRequestRepo, never()).refreshEligibleTargets(any(), eq(true)) @@ -850,10 +1121,10 @@ class AppViewModelSendFlowTest : BaseUnitTest() { clearInvocations(paykitPaymentRequestRepo) advanceTimeBy(10.seconds.inWholeMilliseconds - 1) runCurrent() - verify(paykitPaymentRequestRepo, never()).refresh() + verify(paykitPaymentRequestRepo, never()).refresh(any()) advanceTimeBy(1) runCurrent() - verify(paykitPaymentRequestRepo).refresh() + verify(paykitPaymentRequestRepo).refresh(any()) } verify(privatePaykitRepo).refreshKnownSavedContactEndpoints(any(), any()) verify(paykitPaymentProofRepo).reconcile() @@ -861,8 +1132,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceTimeBy(120.seconds.inWholeMilliseconds) runCurrent() - verify(privatePaykitRepo, times(2)).refreshKnownSavedContactEndpoints(any(), any()) - verify(paykitPaymentProofRepo, times(2)).reconcile() + verify(privatePaykitRepo, times(3)).refreshKnownSavedContactEndpoints(any(), any()) + verify(paykitPaymentProofRepo, times(3)).reconcile() } finally { sut.stopPaykitPaymentRequestPolling() } @@ -871,19 +1142,19 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceTimeBy(120.seconds.inWholeMilliseconds) runCurrent() - verify(paykitPaymentRequestRepo, never()).refresh() + verify(paykitPaymentRequestRepo, never()).refresh(any()) } @Test fun `failed inbox checks keep ten second cadence`() = test { enablePaykitUi() pubkyPublicKey.value = testPublicKey - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) sut.startPaykitPaymentRequestPolling() try { advanceTimeBy(30.seconds.inWholeMilliseconds) runCurrent() - whenever(paykitPaymentRequestRepo.refresh()).thenReturn( + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn( Result.failure(IllegalStateException("transport failure")), ) @@ -891,29 +1162,29 @@ class AppViewModelSendFlowTest : BaseUnitTest() { clearInvocations(paykitPaymentRequestRepo) advanceTimeBy(10.seconds.inWholeMilliseconds - 1) runCurrent() - verify(paykitPaymentRequestRepo, never()).refresh() + verify(paykitPaymentRequestRepo, never()).refresh(any()) advanceTimeBy(1) runCurrent() - verify(paykitPaymentRequestRepo).refresh() + verify(paykitPaymentRequestRepo).refresh(any()) } - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) clearInvocations(paykitPaymentRequestRepo) advanceTimeBy(10.seconds.inWholeMilliseconds) runCurrent() - verify(paykitPaymentRequestRepo).refresh() + verify(paykitPaymentRequestRepo).refresh(any()) } finally { sut.stopPaykitPaymentRequestPolling() } } @Test - fun `payment request waiting for a newer private list is retried after backoff`() = test { + fun `payment request keeps its preparing sheet through backoff until ready`() = test { sut.setIsAuthenticated(true) val request = paymentRequest() val bolt11 = "lnbcrt1updatedpaymentrequest" - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 8uL) - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + val privateContext = privatePaymentContext(8uL) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.beginPaymentRequest(request)).thenReturn( Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList), Result.success( @@ -933,19 +1204,28 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.onHomeResumed() runCurrent() - assertNull(sut.currentSheet.value) + val preparingSheet = sut.currentSheet.value + assertEquals(request, (preparingSheet as? Sheet.Send)?.preparingRequest) verify(privatePaykitRepo).beginPaymentRequest(request) clearInvocations(privatePaykitRepo) - advanceTimeBy(1.seconds.inWholeMilliseconds) - runCurrent() - verify(privatePaykitRepo, never()).beginPaymentRequest(request) + sut.currentSheet.test { + assertEquals(preparingSheet, awaitItem()) + advanceTimeBy(1.seconds.inWholeMilliseconds) + runCurrent() + verify(privatePaykitRepo, never()).beginPaymentRequest(request) + assertTrue(sut.currentSheet.value === preparingSheet) + expectNoEvents() - advanceTimeBy(1.seconds.inWholeMilliseconds) - runCurrent() + advanceTimeBy(1.seconds.inWholeMilliseconds) + runCurrent() + assertEquals(Sheet.Send(SendRoute.Confirm), awaitItem()) + expectNoEvents() + } verify(privatePaykitRepo).beginPaymentRequest(request) assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + verify(paykitPaymentRequestRepo, never()).markPresented(request) } @Test @@ -956,29 +1236,227 @@ class AppViewModelSendFlowTest : BaseUnitTest() { runCurrent() val request = paymentRequest() val bolt11 = "lnbcrt1newpendingrequest" - whenever(privatePaykitRepo.beginPaymentRequest(request)).thenReturn( - Result.success( - PublicPaykitPaymentResult.Opened( - paymentRequest = bolt11, - privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 8uL), - ), - ), - ) + val resolution = CompletableDeferred>() + whenever(privatePaykitRepo.beginPaymentRequest(request)).doSuspendableAnswer { resolution.await() } stubLightningScan(bolt11 = bolt11, amountSats = 0u) + val decoded = CompletableDeferred() + whenever(coreService.decode(bolt11)).doSuspendableAnswer { decoded.await() } balanceState.value = BalanceState(maxSendLightningSats = 100_000u) clearInvocations(paykitPaymentRequestRepo) pendingPaykitPaymentRequests.value = listOf(request) runCurrent() + assertEquals(request, (sut.currentSheet.value as? Sheet.Send)?.preparingRequest) + val preparingSheet = sut.currentSheet.value + sut.onSheetVisible(preparingSheet) + assertFalse(sut.sendUiState.value.isAmountInputValid) + verify(paykitPaymentRequestRepo, never()).markPresented(any()) + pendingPaykitPaymentRequests.value = listOf(request.copy(paymentRequestId = "later-request"), request) + runCurrent() + assertEquals(request, (sut.currentSheet.value as? Sheet.Send)?.preparingRequest) + + sut.currentSheet.test { + assertEquals(preparingSheet, awaitItem()) + resolution.complete( + Result.success( + PublicPaykitPaymentResult.Opened( + paymentRequest = bolt11, + privatePaymentContext = privatePaymentContext(version = 8uL), + ), + ), + ) + runCurrent() + assertTrue(sut.currentSheet.value === preparingSheet) + assertFalse(sut.sendUiState.value.isAmountInputValid) + sut.onSheetVisible(preparingSheet) + verify(paykitPaymentRequestRepo, never()).markPresented(any()) + expectNoEvents() + decoded.complete(Scanner.Lightning(lightningInvoice(bolt11, 0u))) + runCurrent() + assertEquals(Sheet.Send(SendRoute.Confirm), awaitItem()) + expectNoEvents() + } + + assertNull((sut.currentSheet.value as? Sheet.Send)?.preparingRequest) assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) assertEquals(request.id, sut.sendUiState.value.incomingPaymentRequestId) verify(privatePaykitRepo).beginPaymentRequest(request) - verify(paykitPaymentRequestRepo, never()).refresh() + verify(paykitPaymentRequestRepo, never()).refresh(any()) + sut.onSheetVisible(preparingSheet) + verify(paykitPaymentRequestRepo, never()).markPresented(any()) + sut.onSheetVisible(sut.currentSheet.value) + runCurrent() + verify(paykitPaymentRequestRepo).markPresented(request) + } + + @Test + fun `closing preparation keeps request pending and manual reopen available`() = test { + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + sut.setIsAuthenticated(true) + runCurrent() + val request = paymentRequest() + val bolt11 = "lnbcrt1closedpreparation" + val resolution = CompletableDeferred>() + whenever(privatePaykitRepo.beginPaymentRequest(request)).doSuspendableAnswer { resolution.await() } + stubLightningScan(bolt11, 0u) + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + pendingPaykitPaymentRequests.value = listOf(request) + runCurrent() + sut.hideSheet() + runCurrent() + resolution.complete( + Result.success(PublicPaykitPaymentResult.Opened(bolt11, privatePaymentContext(7uL))), + ) + runCurrent() + + assertNull(sut.currentSheet.value) + assertEquals(listOf(request), pendingPaykitPaymentRequests.value) + verify(coreService, never()).decode(bolt11) + verify(paykitPaymentRequestRepo, never()).markPresented(request) + sut.openIncomingPaymentRequest(request.id) + runCurrent() + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + verify(privatePaykitRepo, times(2)).beginPaymentRequest(request) + } + + @Test + fun `closing or changing identity during wallet preparation ignores late decode`() = test { + enablePaykitUi() + for (changeIdentity in listOf(false, true)) { + pubkyPublicKey.value = testPublicKey + sut.setIsAuthenticated(true) + runCurrent() + val request = paymentRequest().copy(paymentRequestId = "wallet-$changeIdentity") + val bolt11 = "lnbcrt1latewallet$changeIdentity" + val decoded = CompletableDeferred() + stubLightningScan(bolt11, 0u) + whenever(coreService.decode(bolt11)).doSuspendableAnswer { + withContext(NonCancellable) { decoded.await() } + } + whenever(privatePaykitRepo.beginPaymentRequest(request)).thenReturn( + Result.success(PublicPaykitPaymentResult.Opened(bolt11, privatePaymentContext(7uL))), + ) + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + pendingPaykitPaymentRequests.value = listOf(request) + runCurrent() + verify(coreService).decode(bolt11) + assertEquals(request, (sut.currentSheet.value as? Sheet.Send)?.preparingRequest) + + if (changeIdentity) pubkyPublicKey.value = null else sut.hideSheet() + runCurrent() + decoded.complete(Scanner.Lightning(lightningInvoice(bolt11, 0u))) + runCurrent() + + assertNull(sut.currentSheet.value, "changeIdentity=$changeIdentity") + assertFalse(sut.sendUiState.value.isAmountInputValid) + verify(paykitPaymentRequestRepo, never()).markPresented(request) + pendingPaykitPaymentRequests.value = emptyList() + runCurrent() + } + } + + @Test + fun `closing preparation advances the next request and identity change resets suppression`() = test { + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + sut.setIsAuthenticated(true) + runCurrent() + val first = paymentRequest() + val second = first.copy(paymentRequestId = "next-request") + val firstResolution = CompletableDeferred>() + val secondResolution = CompletableDeferred>() + whenever(privatePaykitRepo.beginPaymentRequest(first)).doSuspendableAnswer { firstResolution.await() } + whenever(privatePaykitRepo.beginPaymentRequest(second)).doSuspendableAnswer { secondResolution.await() } + pendingPaykitPaymentRequests.value = listOf(first, second) + runCurrent() + sut.hideSheet() + firstResolution.complete(Result.success(PublicPaykitPaymentResult.NotOpened)) + runCurrent() + assertEquals(second, (sut.currentSheet.value as? Sheet.Send)?.preparingRequest) + verify(paykitPaymentRequestRepo, never()).markPresented(any()) + pubkyPublicKey.value = null + runCurrent() + secondResolution.complete(Result.success(PublicPaykitPaymentResult.NotOpened)) + runCurrent() + assertNull(sut.currentSheet.value) + pubkyPublicKey.value = testPublicKey + runCurrent() + verify(privatePaykitRepo, times(2)).beginPaymentRequest(first) + verify(paykitPaymentRequestRepo, never()).markPresented(any()) + pendingPaykitPaymentRequests.value = emptyList() + sut.stopPaykitPaymentRequestPolling() + runCurrent() + } + + @Test + fun `overlay postpones incoming confirmation until it closes`() = test { + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + sut.setIsAuthenticated(true) + sut.setPaymentRequestOverlayVisible(true) + runCurrent() + val request = paymentRequest() + val resolution = CompletableDeferred>() + whenever(privatePaykitRepo.beginPaymentRequest(request)).doSuspendableAnswer { resolution.await() } + pendingPaykitPaymentRequests.value = listOf(request) + runCurrent() + assertNull(sut.currentSheet.value) + verify(privatePaykitRepo, never()).beginPaymentRequest(request) + + sut.setPaymentRequestOverlayVisible(false) + runCurrent() + assertEquals(request, (sut.currentSheet.value as? Sheet.Send)?.preparingRequest) + sut.setPaymentRequestOverlayVisible(true) + resolution.complete(Result.success(PublicPaykitPaymentResult.NotOpened)) + runCurrent() + assertNull(sut.currentSheet.value) + verify(privatePaykitRepo).beginPaymentRequest(request) + verify(paykitPaymentRequestRepo, never()).markPresented(request) + pendingPaykitPaymentRequests.value = emptyList() + sut.stopPaykitPaymentRequestPolling() + runCurrent() } @Test - fun `request arriving during another presentation is not left waiting for refresh`() = test { + fun `preparing request metadata clears without completing presentation`() = test { + val invalidations: List Unit>> = listOf( + "background" to { sut.stopPaykitPaymentRequestPolling() }, + "lock" to { sut.setIsAuthenticated(false) }, + "sheet" to { sut.showPaymentRequests() }, + "identity" to { pubkyPublicKey.value = null }, + "pending" to { pendingPaykitPaymentRequests.value = emptyList() }, + "overlay" to { sut.setPaymentRequestOverlayVisible(true) }, + ) + enablePaykitUi() + for ((id, invalidate) in invalidations) { + sut.startPaykitPaymentRequestPolling() + sut.setPaymentRequestOverlayVisible(false) + pubkyPublicKey.value = testPublicKey + sut.setIsAuthenticated(true) + val request = paymentRequest().copy(paymentRequestId = id) + val resolution = CompletableDeferred>() + whenever(privatePaykitRepo.beginPaymentRequest(request)).doSuspendableAnswer { resolution.await() } + pendingPaykitPaymentRequests.value = listOf(request) + runCurrent() + assertEquals(request, (sut.currentSheet.value as? Sheet.Send)?.preparingRequest, id) + + invalidate() + runCurrent() + assertNull((sut.currentSheet.value as? Sheet.Send)?.preparingRequest, id) + pendingPaykitPaymentRequests.value = emptyList() + resolution.complete(Result.success(PublicPaykitPaymentResult.NotOpened)) + runCurrent() + sut.hideSheet() + sut.stopPaykitPaymentRequestPolling() + runCurrent() + } + verify(paykitPaymentRequestRepo, never()).markPresented(any()) + } + + @Test + fun `request arriving during preparation opens after the owned sheet closes`() = test { enablePaykitUi() pubkyPublicKey.value = testPublicKey sut.setIsAuthenticated(true) @@ -1007,6 +1485,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { finishFirstResolution.complete(Unit) runCurrent() + assertEquals(firstRequest, (sut.currentSheet.value as? Sheet.Send)?.preparingRequest) + verify(privatePaykitRepo, never()).beginPaymentRequest(nextRequest) + sut.hideSheet() + runCurrent() + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) assertEquals(nextRequest.id, sut.sendUiState.value.incomingPaymentRequestId) verify(privatePaykitRepo).beginPaymentRequest(nextRequest) @@ -1021,7 +1504,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { Result.success( PublicPaykitPaymentResult.Opened( paymentRequest = bolt11, - privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 8uL), + privatePaymentContext = privatePaymentContext(8uL), ), ), ) @@ -1048,7 +1531,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.setIsAuthenticated(true) val request = paymentRequest() val bolt11 = "lnbcrt1updatedmanualrequest" - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 8uL) + val privateContext = privatePaymentContext(8uL) whenever(privatePaykitRepo.beginPaymentRequest(request)).thenReturn( Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList), Result.success( @@ -1068,10 +1551,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.showPaymentRequests() sut.openIncomingPaymentRequestWithTags(request.id, listOf("Lunch")) + assertEquals(request.id, sut.requestedPaymentRequestId.value) advanceTimeBy(TRANSITION_SCREEN_MS) runCurrent() - assertNull(sut.currentSheet.value) + assertEquals(request, (sut.currentSheet.value as? Sheet.Send)?.preparingRequest) verify(privatePaykitRepo).beginPaymentRequest(request) advanceTimeBy(2.seconds.inWholeMilliseconds) @@ -1081,10 +1565,14 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(request, activeContactPaymentContext()?.incomingPaymentRequest) assertEquals(listOf("Lunch"), sut.sendUiState.value.selectedTags) verify(privatePaykitRepo, times(2)).beginPaymentRequest(request) + + sut.onSheetVisible(sut.currentSheet.value) + runCurrent() + assertNull(sut.requestedPaymentRequestId.value) } @Test - fun `private link recovery releases manual request actions`() = test { + fun `private link recovery closes preparation without automatic reopen`() = test { sut.setIsAuthenticated(true) val request = paymentRequest() whenever(context.getString(R.string.wallet__payment_request)).thenReturn("Payment Request") @@ -1094,64 +1582,103 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) whenever(paykitPaymentRequestRepo.dismiss(request)).thenReturn(Result.success(Unit)) pendingPaykitPaymentRequests.value = listOf(request) - surfacedPaykitPaymentRequestIds += request.id enablePaykitUi() pubkyPublicKey.value = testPublicKey runCurrent() - sut.showPaymentRequests() - sut.openIncomingPaymentRequest(request.id) - advanceTimeBy(TRANSITION_SCREEN_MS) - runCurrent() + sut.startPaykitPaymentRequestPolling() + try { + advanceTimeBy(120.seconds.inWholeMilliseconds) + runCurrent() + assertNull(sut.currentSheet.value) + verify(privatePaykitRepo).beginPaymentRequest(request) + verify(paykitPaymentRequestRepo, never()).markPresented(request) - assertEquals(Sheet.PaymentRequests, sut.currentSheet.value) - verify(privatePaykitRepo).beginPaymentRequest(request) - verify(paykitPaymentRequestRepo, never()).markPresented(request) + sut.showPaymentRequests() + sut.openIncomingPaymentRequest(request.id) + advanceTimeBy(TRANSITION_SCREEN_MS) + runCurrent() + + assertEquals(Sheet.PaymentRequests, sut.currentSheet.value) + verify(privatePaykitRepo, times(2)).beginPaymentRequest(request) + verify(paykitPaymentRequestRepo, never()).markPresented(request) - sut.openIncomingPaymentRequest(request.id) - advanceTimeBy(TRANSITION_SCREEN_MS) - runCurrent() - sut.dismissIncomingPaymentRequest(request).getOrThrow() + sut.openIncomingPaymentRequest(request.id) + advanceTimeBy(TRANSITION_SCREEN_MS) + runCurrent() + sut.dismissIncomingPaymentRequest(request).getOrThrow() - verify(privatePaykitRepo, times(2)).beginPaymentRequest(request) - verify(paykitPaymentRequestRepo).dismiss(request) - verify(paykitPaymentRequestDiagnostics, times(2)).logPresentationRejection( - request.counterparty, - IncomingPaykitPaymentRequestFailureReason.PaymentDetailsPending, - ) - verify(toastManager, times(2)).enqueue(check { assertEquals("Try again", it.description) }) + verify(privatePaykitRepo, times(3)).beginPaymentRequest(request) + verify(paykitPaymentRequestRepo).dismiss(request) + verify(paykitPaymentRequestDiagnostics, times(3)).logPresentationRejection( + request.counterparty, + IncomingPaykitPaymentRequestFailureReason.PaymentDetailsPending, + ) + verify(toastManager, times(2)).enqueue(check { assertEquals("Try again", it.description) }) + } finally { + sut.stopPaykitPaymentRequestPolling() + } } @Test - fun `unaffordable request releases manual presentation state`() = test { + fun `unaffordable request suppresses automatic preparation while presentation completion waits`() = test { + sut.setIsAuthenticated(true) + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + runCurrent() val request = paymentRequest() - whenever { paykitPaymentRequestRepo.dismiss(request) }.thenReturn(Result.success(Unit)) + val bolt11 = "lnbcrt1unaffordablerequest" + val finishMark = CompletableDeferred() + var markingCancelled = false + var allowPreparation = true + whenever(paykitPaymentRequestRepo.markPresented(any())).doSuspendableAnswer { + try { + finishMark.await() + surfacedPaykitPaymentRequestIds += it.getArgument(0).id + true + } finally { + markingCancelled = !finishMark.isCompleted + } + } + whenever(privatePaykitRepo.beginPaymentRequest(any())).doSuspendableAnswer { + if (!allowPreparation) awaitCancellation() + allowPreparation = false + Result.success(PublicPaykitPaymentResult.Opened(bolt11, privatePaymentContext(7uL))) + } + stubLightningScan(bolt11, request.amountSats) + whenever(lightningRepo.canSend(request.amountSats)).thenReturn(false) + whenever(formatMoneyValue(any())).thenReturn("2,500 sats") pendingPaykitPaymentRequests.value = listOf(request) - surfacedPaykitPaymentRequestIds += request.id - setActiveContactPaymentContext( - publicKey = testPublicKey, - privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 7uL), - incomingPaymentRequest = request, - ) - setRequestedPaymentRequestId(request.id) + runCurrent() - sut.clearActiveContactPaymentContext(retryIncomingRequest = false) + pendingPaykitPaymentRequests.value = listOf(request.copy(note = "Updated request")) runCurrent() assertNull(activeContactPaymentContext()) + assertNull(sut.requestedPaymentRequestId.value) verify(paykitPaymentRequestRepo).markPresented(request) + verify(privatePaykitRepo).beginPaymentRequest(request) + verify(toastManager).enqueue(check { assertEquals("InsufficientSpendingToast", it.testTag) }) + verify(paykitPaymentRequestRepo, never()).accept(any()) + verify(privatePaykitRepo, never()).consumePrivatePaymentList(any(), any()) - sut.dismissIncomingPaymentRequest(request) + whenever(lightningRepo.canSend(request.amountSats)).thenReturn(true) + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + allowPreparation = true + sut.openIncomingPaymentRequest(request.id) + advanceTimeBy(TRANSITION_SCREEN_MS) runCurrent() - assertTrue(sut.rejectingPaymentRequestIds.value.isEmpty()) - verify(paykitPaymentRequestRepo).dismiss(request) - verify(paykitPaymentRequestRepo, never()).accept(any()) - verify(privatePaykitRepo, never()).consumePrivatePaymentList(any(), any()) + assertTrue(sut.currentSheet.value is Sheet.Send) + verify(privatePaykitRepo, times(2)).beginPaymentRequest(any()) - advanceTimeBy(30.seconds.inWholeMilliseconds) + pendingPaykitPaymentRequests.value = emptyList() + pubkyPublicKey.value = null runCurrent() - verify(privatePaykitRepo, never()).beginPaymentRequest(request) + assertTrue(markingCancelled) + finishMark.complete(Unit) + runCurrent() + assertTrue(surfacedPaykitPaymentRequestIds.isEmpty()) } @Test @@ -1194,7 +1721,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { Result.success( PublicPaykitPaymentResult.Opened( paymentRequest = bolt11, - privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 8uL), + privatePaymentContext = privatePaymentContext(8uL), ), ), ) @@ -1209,7 +1736,6 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val subscriptionId = PaykitSubscriptionId( request.paymentRequestId, request.counterparty, - request.counterpartyReceiverPath, ) sut.showSheet(Sheet.Subscription(SubscriptionRoute.Review(subscriptionId))) sut.openIncomingPaymentRequest(request.id) @@ -1233,7 +1759,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { endsAt = Instant.parse("2026-09-01T12:00:00Z"), ), ) - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.beginPaymentRequest(targetRequest)).thenReturn( Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList) ) @@ -1243,13 +1769,17 @@ class AppViewModelSendFlowTest : BaseUnitTest() { isPaykitEnabled.value = true pubkyPublicKey.value = testPublicKey runCurrent() - clearInvocations(privatePaykitRepo) + clearInvocations(privatePaykitRepo, paykitPaymentRequestRepo, paykitPaymentProofRepo) val pendingProposal = mock() whenever(paykitPaymentRequestRepo.automaticSubscriptionProposals()).thenReturn(listOf(pendingProposal)) sut.onPaykitSubscriptionNotificationTapped(testPublicKey, targetRequest.id) runCurrent() + verify(paykitPaymentRequestRepo).refresh(PaykitPaymentRequestRefreshMode.STORED) + verify(paykitPaymentRequestRepo, never()).refresh(PaykitPaymentRequestRefreshMode.FULL) + verify(paykitPaymentProofRepo, never()).reconcile() + verify(privatePaykitRepo, never()).awaitContactPreparation() verify(privatePaykitRepo).beginPaymentRequest(targetRequest) verify(privatePaykitRepo, never()).beginPaymentRequest(otherRequest) } @@ -1257,6 +1787,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `subscription notification target survives initial identity activation`() = test { sut.setIsAuthenticated(true) + whenever(pubkyRepo.hasIdentity()).thenReturn(true) val otherRequest = paymentRequest().copy(paymentRequestId = "other-subscription") val targetRequest = paymentRequest().copy( paymentRequestId = "target-subscription", @@ -1265,7 +1796,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { endsAt = Instant.parse("2026-09-01T12:00:00Z"), ), ) - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.beginPaymentRequest(targetRequest)).thenReturn( Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList) ) @@ -1275,6 +1806,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { isPaykitEnabled.value = true sut.onPaykitSubscriptionNotificationTapped(testPublicKey, targetRequest.id) + assertEquals(targetRequest.id, sut.requestedPaymentRequestId.value) runCurrent() pubkyContactsLoadVersion.value = 1L pubkyPublicKey.value = testPublicKey @@ -1285,18 +1817,211 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `subscription notification for another identity is ignored`() = test { + fun `subscription notification target survives failed and temporarily excluding refreshes`() = test { + sut.setIsAuthenticated(true) + whenever(pubkyRepo.hasIdentity()).thenReturn(true) val targetRequest = paymentRequest().copy( + paymentRequestId = "target-subscription", + billingPeriod = PaykitBillingPeriod( + startsAt = Instant.parse("2026-08-25T12:00:00Z"), + endsAt = Instant.parse("2026-09-01T12:00:00Z"), + ), + ) + val otherRequest = targetRequest.copy(paymentRequestId = "other-subscription") + surfacedPaykitPaymentRequestIds += targetRequest.id + surfacedPaykitPaymentRequestIds += otherRequest.id + whenever(paykitPaymentRequestRepo.refreshAfterStateChange(PaykitPaymentRequestRefreshMode.FULL)) + .thenReturn(Result.failure(PaykitPaymentRequestError.RequestUnavailable)) + whenever(privatePaykitRepo.beginPaymentRequest(targetRequest)).thenReturn( + Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList) + ) + isPaykitEnabled.value = true + sut.onPaykitSubscriptionNotificationTapped(testPublicKey, targetRequest.id) + runCurrent() + clearInvocations(toastManager, privatePaykitRepo, paykitPaymentRequestRepo) + + pubkyContactsLoadVersion.value = 1L + pubkyPublicKey.value = testPublicKey + runCurrent() + + verify(paykitPaymentRequestRepo).refreshAfterStateChange(PaykitPaymentRequestRefreshMode.FULL) + assertEquals(targetRequest.id, sut.requestedPaymentRequestId.value) + assertNull(sut.currentSheet.value) + verify(privatePaykitRepo, never()).beginPaymentRequest(any()) + verify(paykitPaymentRequestRepo, never()).markPresented(any()) + verify(toastManager, never()).enqueue(any()) + + whenever(paykitPaymentRequestRepo.refresh(PaykitPaymentRequestRefreshMode.FULL)).doSuspendableAnswer { + pendingPaykitPaymentRequests.value = listOf(otherRequest) + Result.success(Unit) + } + sut.startPaykitPaymentRequestPolling() + try { + runCurrent() + verify(paykitPaymentRequestRepo).isSubscriptionNotificationHandled(targetRequest.id, testPublicKey) + assertEquals(targetRequest.id, sut.requestedPaymentRequestId.value) + assertNull(sut.currentSheet.value) + verify(privatePaykitRepo, never()).beginPaymentRequest(any()) + verify(toastManager, never()).enqueue(any()) + + sut.stopPaykitPaymentRequestPolling() + whenever(paykitPaymentRequestRepo.refresh(PaykitPaymentRequestRefreshMode.FULL)).doSuspendableAnswer { + pendingPaykitPaymentRequests.value = listOf(otherRequest, targetRequest) + Result.success(Unit) + } + sut.startPaykitPaymentRequestPolling() + runCurrent() + verify(privatePaykitRepo).beginPaymentRequest(targetRequest) + verify(privatePaykitRepo, never()).beginPaymentRequest(otherRequest) + } finally { + sut.stopPaykitPaymentRequestPolling() + } + } + + @Test + fun `missing subscription reminder allows manual payment retries and resumes after dismissal`() = test { + sut.setIsAuthenticated(true) + val reminder = paymentRequest().copy( + paymentRequestId = "subscription-reminder", billingPeriod = PaykitBillingPeriod( startsAt = Instant.parse("2026-08-25T12:00:00Z"), endsAt = Instant.parse("2026-09-01T12:00:00Z"), ), ) + val request = paymentRequest() + val anotherRequest = request.copy(paymentRequestId = "another-request") + val preparation = CompletableDeferred>() + whenever(privatePaykitRepo.beginPaymentRequest(request)).doSuspendableAnswer { preparation.await() } + whenever(privatePaykitRepo.beginPaymentRequest(reminder)).thenReturn( + Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList), + ) + val bolt11 = "lnbcrt1manualrequest" + stubLightningScan(bolt11 = bolt11, amountSats = 0u) + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + pendingPaykitPaymentRequests.value = listOf(request, anotherRequest) + surfacedPaykitPaymentRequestIds += listOf(request.id, anotherRequest.id, reminder.id) + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + runCurrent() + + sut.onPaykitSubscriptionNotificationTapped(testPublicKey, reminder.id) + runCurrent() + sut.showPaymentRequests() + sut.openIncomingPaymentRequest(request.id) + advanceTimeBy(TRANSITION_SCREEN_MS) + runCurrent() + assertEquals(request.id, sut.requestedPaymentRequestId.value) + verify(privatePaykitRepo).beginPaymentRequest(request) + + sut.openIncomingPaymentRequest(anotherRequest.id) + runCurrent() + assertEquals(request.id, sut.requestedPaymentRequestId.value) + verify(privatePaykitRepo, never()).beginPaymentRequest(anotherRequest) + preparation.complete(Result.success(PublicPaykitPaymentResult.Opened(bolt11, privatePaymentContext(8uL)))) + runCurrent() + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + sut.onSheetVisible(sut.currentSheet.value) + runCurrent() + assertNull(sut.requestedPaymentRequestId.value) + + pendingPaykitPaymentRequests.value = listOf(request, anotherRequest, reminder) + runCurrent() + sut.retryIncomingPaymentRequest(request.id) + advanceTimeBy(TRANSITION_SCREEN_MS) + runCurrent() + assertEquals(request, activeContactPaymentContext()?.incomingPaymentRequest) + verify(privatePaykitRepo, times(2)).beginPaymentRequest(request) + verify(privatePaykitRepo, never()).beginPaymentRequest(reminder) + sut.onSheetVisible(sut.currentSheet.value) + sut.hideSheet() + runCurrent() + verify(privatePaykitRepo).beginPaymentRequest(reminder) + } + + @Test + fun `identity change discards a reminder deferred by manual payment`() = test { + sut.setIsAuthenticated(true) + val reminderId = paymentRequest().id.copy(billingPeriodStartsAt = "2026-08-25T12:00:00Z") + val request = paymentRequest().copy(paymentRequestId = "manual-request") + val preparation = CompletableDeferred>() + whenever(privatePaykitRepo.beginPaymentRequest(request)).doSuspendableAnswer { preparation.await() } + pendingPaykitPaymentRequests.value = listOf(request) + surfacedPaykitPaymentRequestIds += request.id + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + runCurrent() + sut.onPaykitSubscriptionNotificationTapped(testPublicKey, reminderId) + runCurrent() + sut.showPaymentRequests() + sut.openIncomingPaymentRequest(request.id) + advanceTimeBy(TRANSITION_SCREEN_MS) + runCurrent() + verify(privatePaykitRepo).beginPaymentRequest(request) + + pubkyPublicKey.value = "pubky${"a".repeat(52)}" + runCurrent() + preparation.complete(Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList)) + runCurrent() + assertNull(sut.requestedPaymentRequestId.value) + assertNull(sut.currentSheet.value) pubkyPublicKey.value = testPublicKey + runCurrent() + assertNull(sut.requestedPaymentRequestId.value) + } + + @Test + fun `subscription notification clears only the target checked after refresh`() = test { + sut.setIsAuthenticated(true) + isPaykitEnabled.value = true + pubkyContactsLoadVersion.value = 1L + pubkyPublicKey.value = testPublicKey + runCurrent() + val targetId = paymentRequest().id.copy(billingPeriodStartsAt = "2026-08-25T12:00:00Z") + val otherId = targetId.copy(billingPeriodStartsAt = "2026-09-01T12:00:00Z") + val terminalCheck = CompletableDeferred() + whenever(paykitPaymentRequestRepo.refresh(PaykitPaymentRequestRefreshMode.STORED)) + .thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.isSubscriptionNotificationHandled(targetId, testPublicKey)) + .doSuspendableAnswer { terminalCheck.await() } + + sut.onPaykitSubscriptionNotificationTapped(testPublicKey, targetId) + runCurrent() + assertEquals(targetId, sut.requestedPaymentRequestId.value) + sut.onPaykitSubscriptionNotificationTapped(testPublicKey, otherId) + runCurrent() + terminalCheck.complete(true) + runCurrent() + assertEquals(otherId, sut.requestedPaymentRequestId.value) + + whenever(paykitPaymentRequestRepo.isSubscriptionNotificationHandled(otherId, testPublicKey)).thenReturn(true) + sut.onPaykitSubscriptionNotificationTapped(testPublicKey, otherId) + runCurrent() + assertNull(sut.requestedPaymentRequestId.value) + verify(privatePaykitRepo, never()).beginPaymentRequest(any()) + verify(paykitPaymentRequestRepo, never()).markPresented(any()) + } + + @Test + fun `subscription notification is ignored when unavailable or for another identity`() = test { + val targetRequest = paymentRequest().copy( + billingPeriod = PaykitBillingPeriod( + startsAt = Instant.parse("2026-08-25T12:00:00Z"), + endsAt = Instant.parse("2026-09-01T12:00:00Z"), + ), + ) + for ((enabled, identity) in listOf(false to testPublicKey, true to null, true to "pubky${"a".repeat(52)}")) { + isPaykitEnabled.value = enabled + pubkyPublicKey.value = identity + runCurrent() + clearInvocations(privatePaykitRepo, paykitPaymentRequestRepo) - sut.onPaykitSubscriptionNotificationTapped("pubky${"a".repeat(52)}", targetRequest.id) + sut.onPaykitSubscriptionNotificationTapped(testPublicKey, targetRequest.id) + runCurrent() - verify(privatePaykitRepo, never()).beginPaymentRequest(targetRequest) + assertNull(sut.requestedPaymentRequestId.value) + verify(paykitPaymentRequestRepo, never()).refresh(any()) + verify(privatePaykitRepo, never()).beginPaymentRequest(targetRequest) + } } @Test @@ -1327,6 +2052,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(Sheet.PaymentRequests, sut.currentSheet.value) verify(paykitPaymentRequestRepo).markPresented(request) verify(privatePaykitRepo, times(15)).beginPaymentRequest(request) + assertNull(sut.requestedPaymentRequestId.value) verify(paykitPaymentRequestDiagnostics, times(15)).logPresentationRejection( request.counterparty, IncomingPaykitPaymentRequestFailureReason.PaymentDetailsPending, @@ -1707,9 +2433,12 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.openIncomingPaymentRequest(request.id) advanceTimeBy(TRANSITION_SCREEN_MS) resolutionStarted.await() + runCurrent() + assertEquals(request, (sut.currentSheet.value as? Sheet.Send)?.preparingRequest) whenever(paykitPaymentRequestRepo.isExpired(request)).thenReturn(true) pendingPaykitPaymentRequests.value = emptyList() runCurrent() + assertNull((sut.currentSheet.value as? Sheet.Send)?.preparingRequest) finishResolution.complete(Unit) runCurrent() @@ -1877,7 +2606,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val latestActivationStarted = CompletableDeferred() val finishLatestActivation = CompletableDeferred() sut.setIsAuthenticated(true) - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.beginPaymentRequest(request)).thenReturn( Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList) ) @@ -1928,7 +2657,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val finishClear = CompletableDeferred() runCurrent() sut.setIsAuthenticated(true) - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.beginPaymentRequest(request)).thenReturn( Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList) ) @@ -1975,7 +2704,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { Result.success( PublicPaykitPaymentResult.Opened( paymentRequest = automaticInvoice, - privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 7uL), + privatePaymentContext = privatePaymentContext(7uL), ), ) } @@ -1989,8 +2718,12 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.onHomeResumed() automaticResolutionStarted.await() + runCurrent() + assertEquals(automaticRequest, (sut.currentSheet.value as? Sheet.Send)?.preparingRequest) sut.showPaymentRequests() sut.openIncomingPaymentRequest(manualRequest.id) + runCurrent() + assertNull((sut.currentSheet.value as? Sheet.Send)?.preparingRequest) resumeAutomaticResolution.complete(Unit) advanceTimeBy(TRANSITION_SCREEN_MS) runCurrent() @@ -2002,9 +2735,10 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `unresolvable automatic request falls back to low frequency retries`() = test { + fun `unresolvable automatic request keeps one sheet until closed`() = test { + sut.setIsAuthenticated(true) val request = paymentRequest() - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.beginPaymentRequest(request)) .thenReturn(Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList)) pendingPaykitPaymentRequests.value = listOf(request) @@ -2013,14 +2747,31 @@ class AppViewModelSendFlowTest : BaseUnitTest() { runCurrent() sut.startPaykitPaymentRequestPolling() - advanceTimeBy(30.seconds.inWholeMilliseconds) runCurrent() - verify(privatePaykitRepo, times(15)).beginPaymentRequest(request) + val preparingSheet = sut.currentSheet.value + assertEquals(request, (preparingSheet as? Sheet.Send)?.preparingRequest) + sut.currentSheet.test { + assertEquals(preparingSheet, awaitItem()) + advanceTimeBy(30.seconds.inWholeMilliseconds) + runCurrent() + verify(privatePaykitRepo, times(15)).beginPaymentRequest(request) + assertTrue(sut.currentSheet.value === preparingSheet) + expectNoEvents() + + advanceTimeBy(120.seconds.inWholeMilliseconds) + runCurrent() + verify(privatePaykitRepo, times(16)).beginPaymentRequest(request) + assertTrue(sut.currentSheet.value === preparingSheet) + expectNoEvents() + } + sut.hideSheet() + runCurrent() advanceTimeBy(120.seconds.inWholeMilliseconds) runCurrent() - + assertNull(sut.currentSheet.value) verify(privatePaykitRepo, times(16)).beginPaymentRequest(request) + verify(paykitPaymentRequestRepo, never()).markPresented(request) sut.showPaymentRequests() sut.openIncomingPaymentRequest(request.id) @@ -2030,6 +2781,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { runCurrent() verify(privatePaykitRepo, times(18)).beginPaymentRequest(request) + assertEquals(request, (sut.currentSheet.value as? Sheet.Send)?.preparingRequest) sut.stopPaykitPaymentRequestPolling() } @@ -2041,7 +2793,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pendingPaykitPaymentRequests.value = listOf(pendingRequest) isPaykitEnabled.value = true pubkyPublicKey.value = testPublicKey - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) runCurrent() sut.startPaykitPaymentRequestPolling() @@ -2068,7 +2820,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pendingPaykitPaymentRequests.value = listOf(firstRequest, secondRequest) enablePaykitUi() pubkyPublicKey.value = testPublicKey - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) sut.onHomeResumed() sut.currentSheet.first { @@ -2103,7 +2855,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pendingPaykitPaymentRequests.value = listOf(request) isPaykitEnabled.value = true pubkyPublicKey.value = testPublicKey - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) sut.onHomeResumed() runCurrent() @@ -2121,13 +2873,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { setActiveContactPaymentContext(manualContext.publicKey) PublicPaykitPaymentResult.Opened( paymentRequest = "lnbcrt1incoming", - privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 7uL), + privatePaymentContext = privatePaymentContext(7uL), ) } pendingPaykitPaymentRequests.value = listOf(request) isPaykitEnabled.value = true pubkyPublicKey.value = testPublicKey - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) sut.startPaykitPaymentRequestPolling() advanceTimeBy(30.seconds.inWholeMilliseconds) @@ -2154,7 +2906,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { Result.success( PublicPaykitPaymentResult.Opened( paymentRequest = requestInvoice, - privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 7uL), + privatePaymentContext = privatePaymentContext(7uL), ), ) } @@ -2168,7 +2920,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pendingPaykitPaymentRequests.value = listOf(request) isPaykitEnabled.value = true pubkyPublicKey.value = testPublicKey - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) sut.onHomeResumed() resolutionStarted.await() @@ -2215,7 +2967,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pendingPaykitPaymentRequests.value = listOf(request) enablePaykitUi() pubkyPublicKey.value = testPublicKey - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) sut.onHomeResumed() requestScanStarted.await() @@ -2223,6 +2975,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.currentSheet.first { it is Sheet.Send } sut.sendUiState.first { it.addressInput == replacementInvoice } + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) assertFalse(sut.sendUiState.value.isPaymentRequest) assertNull(activeContactPaymentContext()) assertEquals(replacementInvoice, sut.sendUiState.value.addressInput) @@ -2374,7 +3127,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { Result.success( PublicPaykitPaymentResult.Opened( paymentRequest = requestInvoice, - privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 7uL), + privatePaymentContext = privatePaymentContext(7uL), ), ) } @@ -2388,7 +3141,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pendingPaykitPaymentRequests.value = listOf(request) enablePaykitUi() pubkyPublicKey.value = testPublicKey - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) sut.onHomeResumed() resolutionStarted.await() @@ -2429,7 +3182,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pendingPaykitPaymentRequests.value = listOf(request) isPaykitEnabled.value = true pubkyPublicKey.value = testPublicKey - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) sut.onHomeResumed() runCurrent() @@ -2463,7 +3216,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pendingPaykitPaymentRequests.value = listOf(request) enablePaykitUi() pubkyPublicKey.value = testPublicKey - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) sut.onHomeResumed() runCurrent() @@ -2483,7 +3236,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `unavailable request does not starve a later payable request`() = test { + fun `closing unavailable request preparation admits a later payable request`() = test { + sut.setIsAuthenticated(true) val unavailableRequest = paymentRequest() val payableRequest = unavailableRequest.copy(paymentRequestId = "payable-request") val bolt11 = "lnbcrt1payablerequest" @@ -2495,17 +3249,24 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pendingPaykitPaymentRequests.value = listOf(unavailableRequest, payableRequest) isPaykitEnabled.value = true pubkyPublicKey.value = testPublicKey - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) sut.startPaykitPaymentRequestPolling() - advanceTimeBy(30.seconds.inWholeMilliseconds) - runCurrent() - sut.stopPaykitPaymentRequestPolling() + try { + advanceTimeBy(30.seconds.inWholeMilliseconds) + runCurrent() + assertEquals(unavailableRequest, (sut.currentSheet.value as? Sheet.Send)?.preparingRequest) + verify(privatePaykitRepo, never()).beginPaymentRequest(payableRequest) + sut.hideSheet() + runCurrent() - verify(privatePaykitRepo).beginPaymentRequest(unavailableRequest) - verify(privatePaykitRepo).beginPaymentRequest(payableRequest) - assertEquals(payableRequest, activeContactPaymentContext()?.incomingPaymentRequest) - assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + verify(privatePaykitRepo, atLeast(1)).beginPaymentRequest(unavailableRequest) + verify(privatePaykitRepo).beginPaymentRequest(payableRequest) + assertEquals(payableRequest, activeContactPaymentContext()?.incomingPaymentRequest) + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + } finally { + sut.stopPaykitPaymentRequestPolling() + } } @Test @@ -2513,7 +3274,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val expiredRequest = paymentRequest() val payableRequest = expiredRequest.copy(paymentRequestId = "payable-request") val bolt11 = "lnbcrt1payableafterexpired" - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) var payableAttempts = 0 whenever(privatePaykitRepo.beginPaymentRequest(expiredRequest)) .thenReturn(Result.failure(PaykitPaymentRequestError.RequestExpired)) @@ -2532,7 +3293,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pendingPaykitPaymentRequests.value = listOf(expiredRequest, payableRequest) isPaykitEnabled.value = true pubkyPublicKey.value = testPublicKey - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) sut.startPaykitPaymentRequestPolling() advanceTimeBy(30.seconds.inWholeMilliseconds) @@ -2558,7 +3319,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pendingPaykitPaymentRequests.value = listOf(request) isPaykitEnabled.value = true pubkyPublicKey.value = testPublicKey - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) sut.startPaykitPaymentRequestPolling() advanceTimeBy(30.seconds.inWholeMilliseconds) @@ -2566,6 +3327,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.stopPaykitPaymentRequestPolling() assertFalse(isPresentingPaymentRequest()) + assertNull((sut.currentSheet.value as? Sheet.Send)?.preparingRequest) } @Test @@ -3064,7 +3826,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() } - verify(refreshContactPaykitReceivers).invoke(testPublicKey) + verify(refreshContactPaykitLink).invoke(testPublicKey) } @Test @@ -3134,7 +3896,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { expectNoEvents() } verify(pubkyRepo, never()).loadContacts() - verify(refreshContactPaykitReceivers).invoke(testPublicKey) + verify(refreshContactPaykitLink).invoke(testPublicKey) verify(coreService, never()).decode(any()) } @@ -3158,7 +3920,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() } verify(pubkyRepo).loadContacts() - verify(refreshContactPaykitReceivers).invoke(testPublicKey) + verify(refreshContactPaykitLink).invoke(testPublicKey) verify(coreService, never()).decode(any()) } @@ -3185,7 +3947,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() } verify(pubkyRepo).loadContacts() - verify(refreshContactPaykitReceivers).invoke(testPublicKey) + verify(refreshContactPaykitLink).invoke(testPublicKey) verify(coreService, never()).decode(any()) } @@ -3873,7 +4635,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { stubLightningScan(bolt11 = bolt11, amountSats = 0u) whenever(lightningRepo.canSend(request.amountSats)).thenReturn(true) stubOpenedPaymentRequest(request, bolt11) - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) pendingPaykitPaymentRequests.value = listOf(request) sut.onHomeResumed() @@ -3892,7 +4654,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pendingPaykitPaymentRequests.value = listOf(request) enablePaykitUi() pubkyPublicKey.value = testPublicKey - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) stubOpenedPaymentRequest(request, signupAuthUrl) sut.onHomeResumed() @@ -4935,11 +5697,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `received onchain payment preserves a replacement receive sheet`() = test { val processingStarted = CompletableDeferred() val resumeProcessing = CompletableDeferred() - whenever(privatePaykitRepo.contactPublicKeyForPrivateOnchainAddresses(any>())) + whenever(privatePaykitRepo.handleOnchainActivity(any>())) .doSuspendableAnswer { processingStarted.complete(Unit) resumeProcessing.await() - null + Result.success(Unit) } val settledAddress = "bcrt1qsettled" walletState.value = WalletState(onchainAddress = settledAddress) @@ -6221,13 +6983,19 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `incoming payment request opens the existing confirm flow with its fixed amount`() = test { val request = paymentRequest() val bolt11 = "lnbcrt1paymentrequest" + val finishMark = CompletableDeferred() + whenever(paykitPaymentRequestRepo.markPresented(any())).doSuspendableAnswer { + finishMark.await() + surfacedPaykitPaymentRequestIds += request.id + true + } enablePaykitUi() pubkyPublicKey.value = testPublicKey balanceState.value = BalanceState(maxSendLightningSats = 100_000u) stubLightningScan(bolt11 = bolt11, amountSats = 0u) whenever(lightningRepo.canSend(request.amountSats)).thenReturn(true) val privateContext = stubOpenedPaymentRequest(request, bolt11) - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) pendingPaykitPaymentRequests.value = listOf(request) sut.startPaykitPaymentRequestPolling() @@ -6247,6 +7015,15 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.onSheetVisible(sut.currentSheet.value) runCurrent() + sut.hideSheet() + advanceTimeBy(3.seconds.inWholeMilliseconds) + runCurrent() + + assertNull(sut.currentSheet.value) + verify(privatePaykitRepo).beginPaymentRequest(request) + assertFalse(request.id in surfacedPaykitPaymentRequestIds) + finishMark.complete(Unit) + runCurrent() assertTrue(request.id in surfacedPaykitPaymentRequestIds) } @@ -6254,7 +7031,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `incoming onchain payment request has a valid fixed amount`() = test { val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) whenever { coreService.decode(REGTEST_ADDRESS) }.thenReturn( Scanner.OnChain( @@ -6299,7 +7076,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `outgoing payment request creation continues after its caller returns`() = test { val request = paymentRequest().copy(counterparty = "pubkyrecipient") - val target = PaykitPaymentRequestTarget(request.counterparty, request.counterpartyReceiverPath) + val target = PaykitPaymentRequestTarget(request.counterparty) val draft = PaykitPaymentRequestDraft( amountSats = request.amountSats, note = "Lunch", @@ -6326,7 +7103,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `committed outgoing request closes inactive identity flow and shows queued feedback`() = test { val request = paymentRequest().copy(counterparty = "pubkyrecipient") - val target = PaykitPaymentRequestTarget(request.counterparty, request.counterpartyReceiverPath) + val target = PaykitPaymentRequestTarget(request.counterparty) val draft = PaykitPaymentRequestDraft( amountSats = request.amountSats, note = "Lunch", @@ -6355,7 +7132,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `inactive identity completion preserves a replacement sheet`() = test { val request = paymentRequest().copy(counterparty = "pubkyrecipient") - val target = PaykitPaymentRequestTarget(request.counterparty, request.counterpartyReceiverPath) + val target = PaykitPaymentRequestTarget(request.counterparty) val draft = PaykitPaymentRequestDraft( amountSats = request.amountSats, note = "Lunch", @@ -6397,7 +7174,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { balanceState.value = BalanceState(maxSendLightningSats = 100_000u) stubLightningScan(bolt11 = bolt11, amountSats = 0u) stubOpenedPaymentRequest(request, bolt11) - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) pendingPaykitPaymentRequests.value = listOf(request) sut.startPaykitPaymentRequestPolling() @@ -6424,7 +7201,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `duplicate payment request confirmation submits only once`() = test { val address = "bcrt1qpaymentrequest" val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) @@ -6461,8 +7238,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + paymentDeadlineAt = anyOrNull(), ) - verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue) + verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue, "bitkit") } @Test @@ -6474,7 +7252,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.openContactPayment( paymentRequest = bolt11, publicKey = testPublicKey, - privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 7uL), + privatePaymentContext = privatePaymentContext(7uL), incomingPaymentRequest = request, ) advanceUntilIdle() @@ -6510,7 +7288,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `private onchain contact payment consumes private list before send`() = test { val address = "bcrt1qprivatecontact" val contactKey = "pubkycontact" - val privateContext = PrivatePaykitPaymentContext("bitkit/wallet", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) stubSuccessfulOnchainSend(address, 1000u) whenever(privatePaykitRepo.consumePrivatePaymentList(contactKey, privateContext)) @@ -6534,7 +7312,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `incoming payment request consumes its private list before it is accepted`() = test { val address = "bcrt1qpaymentrequest" val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) @@ -6554,21 +7332,25 @@ class AppViewModelSendFlowTest : BaseUnitTest() { confirmCurrentPayment() inOrder(paykitPaymentProofRepo, privatePaykitRepo, paykitPaymentRequestRepo).apply { - verify(paykitPaymentProofRepo).prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain) + verify( + paykitPaymentProofRepo + ).prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain) verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) verify(paykitPaymentRequestRepo).accept(request) } verify(privatePaykitRepo, never()).releasePrivatePaymentList(any(), any()) - verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue) + verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue, "bitkit") } @Test - fun `proof preparation failure does not block incoming onchain payment`() = test { + fun `proof preparation failure blocks incoming onchain payment`() = test { val address = "bcrt1qpaymentrequest" val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) - whenever(paykitPaymentProofRepo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain)) + whenever( + paykitPaymentProofRepo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain) + ) .thenReturn(Result.failure(IllegalStateException("proof unavailable"))) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) @@ -6587,9 +7369,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { confirmCurrentPayment() - verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) - verify(paykitPaymentRequestRepo).accept(request) - verify(lightningRepo).sendOnChain( + verify(privatePaykitRepo, never()).consumePrivatePaymentList(testPublicKey, privateContext) + verify(paykitPaymentRequestRepo, never()).accept(request) + verify(lightningRepo, never()).sendOnChain( address = any(), sats = any(), speed = anyOrNull(), @@ -6601,17 +7383,30 @@ class AppViewModelSendFlowTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + paymentDeadlineAt = anyOrNull(), ) verify(paykitPaymentProofRepo, never()).markOnchainPaymentStarted(any(), any(), any()) } @Test - fun `uncertain onchain outcome without prepared proof keeps private payment details consumed`() = test { + fun `execution claim failure blocks versionless request without consuming private details`() = test { val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(null) + pubkyPublicKey.value = testPublicKey + enablePaykitUi() + sut.showSheet(Sheet.Send(SendRoute.Confirm)) + runCurrent() + val refreshStarted = CompletableDeferred() + val finishRefresh = CompletableDeferred() + whenever(paykitPaymentRequestRepo.refreshAfterStateChange(PaykitPaymentRequestRefreshMode.STORED)) + .doSuspendableAnswer { + refreshStarted.complete(Unit) + finishRefresh.await() + Result.success(Unit) + } balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) - whenever(paykitPaymentProofRepo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain)) - .thenReturn(Result.failure(IllegalStateException("proof unavailable"))) + whenever(paykitPaymentRequestRepo.claimForPayment(request)) + .thenReturn(Result.failure(IllegalStateException("request claimed by another app"))) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) @@ -6631,29 +7426,43 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ), ) - sut.sendEffect.test { - confirmCurrentPayment() + confirmCurrentPayment() - assertTrue(awaitItem() is SendEffect.NavigateToPending) - } + refreshStarted.await() + assertNull(sut.currentSheet.value) + assertFalse(finishRefresh.isCompleted) + verify(paykitPaymentRequestRepo).refreshAfterStateChange(PaykitPaymentRequestRefreshMode.STORED) + verify(paykitPaymentProofRepo, never()).reconcile() + verify(paykitPaymentRequestRepo, never()).refresh(PaykitPaymentRequestRefreshMode.FULL) + finishRefresh.complete(Unit) + advanceUntilIdle() + verify(privatePaykitRepo, never()).consumePrivatePaymentList(any(), any()) + verify(paykitPaymentRequestRepo, never()).accept(request) verify(paykitPaymentProofRepo, never()).failOnchainPayment(any()) - verify(paykitPaymentProofRepo, never()).cancelPreparation(any()) + verify(paykitPaymentProofRepo).cancelPreparation(request) verify(privatePaykitRepo, never()).releasePrivatePaymentList(any(), any()) } @Test - fun `proof association failure does not block incoming lightning payment`() = test { + fun `proof association failure blocks incoming lightning payment`() = test { val request = paymentRequest() val bolt11 = "lnbcrt1paymentrequest" val paymentHash = "010203" - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendLightningSats = 100_000u) - whenever(paykitPaymentProofRepo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning)) + whenever( + paykitPaymentProofRepo.prepare( + request, + MethodId.Bolt11.rawValue, + "bitkit", + PaykitPaymentProofKind.Lightning + ) + ) .doSuspendableAnswer { setSendState(sut.sendUiState.value.copy(decodedInvoice = lightningInvoice(bolt11, request.amountSats))) Result.success(Unit) } - whenever { paykitPaymentProofRepo.associateLightningPayment(any(), any(), any()) } + whenever { paykitPaymentProofRepo.associateLightningPayment(any(), any(), any(), eq("bitkit")) } .thenReturn(Result.failure(IllegalStateException("proof unavailable"))) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) @@ -6672,10 +7481,14 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.setSendEvent(SendEvent.PayConfirmed) advanceUntilIdle() - verify(paykitPaymentProofRepo).prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning) - verify(paykitPaymentProofRepo).associateLightningPayment(request, paymentHash, MethodId.Bolt11.rawValue) + verify( + paykitPaymentProofRepo + ).prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning) + verify( + paykitPaymentProofRepo + ).associateLightningPayment(request, paymentHash, MethodId.Bolt11.rawValue, "bitkit") verify(paykitPaymentProofRepo).cancelPreparation(request) - verify(lightningRepo).payInvoice(bolt11 = bolt11, sats = null) + verify(lightningRepo, never()).payInvoice(bolt11 = bolt11, sats = null) } @Test @@ -6684,14 +7497,17 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val request = paymentRequest() val bolt11 = "lnbcrt1paymentrequest" val paymentHash = "010203" - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendLightningSats = 100_000u) whenever( - paykitPaymentProofRepo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning), + paykitPaymentProofRepo.prepare( + request, + MethodId.Bolt11.rawValue, + "bitkit", + PaykitPaymentProofKind.Lightning + ), ).doSuspendableAnswer { - setSendState( - sut.sendUiState.value.copy(decodedInvoice = lightningInvoice(bolt11, request.amountSats)), - ) + setSendState(sut.sendUiState.value.copy(decodedInvoice = lightningInvoice(bolt11, request.amountSats))) if (preparationSucceeds) { Result.success(Unit) } else { @@ -6701,7 +7517,14 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) - whenever(paykitPaymentProofRepo.associateLightningPayment(request, paymentHash, MethodId.Bolt11.rawValue)) + whenever( + paykitPaymentProofRepo.associateLightningPayment( + request, + paymentHash, + MethodId.Bolt11.rawValue, + "bitkit" + ) + ) .doSuspendableAnswer { whenever(paykitPaymentRequestRepo.ensurePaymentAllowed(request)) .thenReturn(Result.failure(PaykitPaymentRequestError.RequestUnavailable)) @@ -6718,10 +7541,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) sut.setSendEvent(SendEvent.PayConfirmed) advanceUntilIdle() - verify(paykitPaymentRequestRepo).accept(request) + verify(paykitPaymentRequestRepo, times(if (preparationSucceeds) 1 else 0)).accept(request) verify(lightningRepo, never()).payInvoice(any(), anyOrNull()) - verify(paykitPaymentProofRepo).failLightningPayment(paymentHash) - verify(privatePaykitRepo).releasePrivatePaymentList(testPublicKey, privateContext) + verify(paykitPaymentProofRepo, times(if (preparationSucceeds) 1 else 0)).failLightningPayment(paymentHash) + verify( + privatePaykitRepo, + times(if (preparationSucceeds) 1 else 0) + ).releasePrivatePaymentList(testPublicKey, privateContext) clearInvocations(lightningRepo, paykitPaymentProofRepo, paykitPaymentRequestRepo, privatePaykitRepo) } } @@ -6731,9 +7557,16 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val request = paymentRequest() val bolt11 = "lnbcrt1pendingrequest" val invoicePaymentHash = "010203" - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendLightningSats = 100_000u) - whenever(paykitPaymentProofRepo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning)) + whenever( + paykitPaymentProofRepo.prepare( + request, + MethodId.Bolt11.rawValue, + "bitkit", + PaykitPaymentProofKind.Lightning + ) + ) .doSuspendableAnswer { setSendState(sut.sendUiState.value.copy(decodedInvoice = lightningInvoice(bolt11, request.amountSats))) Result.success(Unit) @@ -6757,13 +7590,16 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() inOrder(paykitPaymentProofRepo, privatePaykitRepo, paykitPaymentRequestRepo, lightningRepo).apply { - verify(paykitPaymentProofRepo).prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning) + verify( + paykitPaymentProofRepo + ).prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning) verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) verify(paykitPaymentRequestRepo).accept(request) verify(paykitPaymentProofRepo).associateLightningPayment( request, invoicePaymentHash, MethodId.Bolt11.rawValue, + "bitkit", ) verify(lightningRepo).payInvoice(bolt11 = bolt11, sats = null) } @@ -6778,9 +7614,16 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val bolt11 = "lnbcrt1failedrequest" val invoicePaymentHash = "010203" val error = NodeException.PaymentSendingFailed("no route") - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendLightningSats = 100_000u) - whenever(paykitPaymentProofRepo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning)) + whenever( + paykitPaymentProofRepo.prepare( + request, + MethodId.Bolt11.rawValue, + "bitkit", + PaykitPaymentProofKind.Lightning + ) + ) .doSuspendableAnswer { setSendState(sut.sendUiState.value.copy(decodedInvoice = lightningInvoice(bolt11, request.amountSats))) Result.success(Unit) @@ -6805,13 +7648,16 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() inOrder(paykitPaymentProofRepo, privatePaykitRepo, paykitPaymentRequestRepo, lightningRepo).apply { - verify(paykitPaymentProofRepo).prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning) + verify( + paykitPaymentProofRepo + ).prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning) verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) verify(paykitPaymentRequestRepo).accept(request) verify(paykitPaymentProofRepo).associateLightningPayment( request, invoicePaymentHash, MethodId.Bolt11.rawValue, + "bitkit", ) verify(lightningRepo).payInvoice(bolt11 = bolt11, sats = null) verify(paykitPaymentProofRepo).failLightningPayment(invoicePaymentHash, error) @@ -6821,9 +7667,14 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `failed LNURL request callback releases preparation and retry reopens request`() = test { - val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + @Suppress("LongMethod") + fun `failed LNURL request callback reopens accepted request after proposal expiry`() = test { + val request = paymentRequest().copy( + lifecycleState = PaymentRequestLifecycleState.ACCEPTED, + expiresAt = Clock.System.now() - 1.seconds, + paymentDeadlineAt = Clock.System.now() + 1.hours, + ) + val privateContext = privatePaymentContext(7uL) val lnurl = LnurlPayData( uri = "lnurl1failedrequest", callback = "https://example.com/callback", @@ -6866,12 +7717,16 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(privatePaykitRepo).releasePrivatePaymentList(testPublicKey, privateContext) verify(paykitPaymentProofRepo).cancelPreparation(request) verify(lightningRepo, never()).payInvoice(any(), anyOrNull()) + inOrder(paykitPaymentRequestRepo, lightningRepo).apply { + verify(paykitPaymentRequestRepo).accept(request) + verify(lightningRepo).fetchLnurlInvoice(lnurl, lnurl.callbackAmountMsats(request.amountSats), null) + } verify(toastManager, never()).enqueue(any()) pendingPaykitPaymentRequests.value = emptyList() stubOpenedPaymentRequest(request, lnurl.uri) whenever(coreService.decode(lnurl.uri)).thenReturn(Scanner.LnurlPay(lnurl)) - whenever(paykitPaymentRequestRepo.refresh()).doSuspendableAnswer { + whenever(paykitPaymentRequestRepo.refreshAfterStateChange()).doSuspendableAnswer { pendingPaykitPaymentRequests.value = listOf(request) Result.success(Unit) } @@ -6879,7 +7734,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.retryIncomingPaymentRequest(request.id) advanceUntilIdle() - verify(paykitPaymentRequestRepo).refresh() + verify(paykitPaymentRequestRepo).refreshAfterStateChange() verify(privatePaykitRepo, atLeast(1)).beginPaymentRequest(request) assertEquals(request.id, sut.sendUiState.value.incomingPaymentRequestId) assertTrue(sut.currentSheet.value is Sheet.Send) @@ -6891,9 +7746,16 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val bolt11 = "lnbcrt1pendingrequest" val paymentHash = "010203" val error = NodeException.PersistenceFailed("io") - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendLightningSats = 100_000u) - whenever(paykitPaymentProofRepo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning)) + whenever( + paykitPaymentProofRepo.prepare( + request, + MethodId.Bolt11.rawValue, + "bitkit", + PaykitPaymentProofKind.Lightning + ) + ) .doSuspendableAnswer { setSendState(sut.sendUiState.value.copy(decodedInvoice = lightningInvoice(bolt11, request.amountSats))) Result.success(Unit) @@ -6929,9 +7791,16 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `in flight proof blocks another payment before consuming private details`() = test { val request = paymentRequest() val bolt11 = "lnbcrt1paymentrequest" - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendLightningSats = 100_000u) - whenever(paykitPaymentProofRepo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning)) + whenever( + paykitPaymentProofRepo.prepare( + request, + MethodId.Bolt11.rawValue, + "bitkit", + PaykitPaymentProofKind.Lightning + ) + ) .thenReturn(Result.failure(PaykitPaymentRequestError.OperationInProgress)) setActiveContactPaymentContext(testPublicKey, privateContext, request) setSendState( @@ -6946,7 +7815,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.setSendEvent(SendEvent.PayConfirmed) advanceUntilIdle() - verify(paykitPaymentProofRepo).prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning) + verify( + paykitPaymentProofRepo + ).prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning) verify(privatePaykitRepo, never()).consumePrivatePaymentList(any(), any()) verify(paykitPaymentRequestRepo, never()).accept(any()) verify(lightningRepo, never()).payInvoice(any(), anyOrNull()) @@ -6956,8 +7827,10 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `in flight proof blocks switching to a hardware payment`() = test { val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) - whenever(paykitPaymentProofRepo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain)) + val privateContext = privatePaymentContext(7uL) + whenever( + paykitPaymentProofRepo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain) + ) .thenReturn(Result.failure(PaykitPaymentRequestError.OperationInProgress)) setActiveContactPaymentContext(testPublicKey, privateContext, request) setSendState( @@ -6974,7 +7847,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `hardware payment request releases private details when acceptance fails`() = test { val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) whenever(paykitPaymentRequestRepo.accept(request)) .thenReturn(Result.failure(IllegalStateException("accept failed"))) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) @@ -7001,7 +7874,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `hardware payment request releases private details when proof start fails`() = test { val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) @@ -7033,7 +7906,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `approved hardware payment request preparation is idempotent`() = test { val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) @@ -7053,7 +7926,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertTrue(sut.prepareHardwareContactPayment()) inOrder(paykitPaymentProofRepo, privatePaykitRepo, paykitPaymentRequestRepo).apply { - verify(paykitPaymentProofRepo).prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain) + verify( + paykitPaymentProofRepo + ).prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain) verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) verify(paykitPaymentRequestRepo).accept(request) verify(paykitPaymentProofRepo).markOnchainPaymentStarted( @@ -7072,9 +7947,37 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `hardware retry denial keeps the started proof until cancellation`() = test { + fun `hardware queue expiry reports failure without repeating SDK authorization`() = test { + whenever(context.getString(R.string.common__error)).thenReturn("Error") + val request = paymentRequest().copy(paymentDeadlineAt = Instant.parse("2026-10-06T12:00:00Z")) + val privateContext = privatePaymentContext(7uL) + val sheet = Sheet.Send(SendRoute.HardwareSign) + for (priorAttempt in listOf(false, true)) { + clearInvocations(paykitPaymentProofRepo, privatePaykitRepo, paykitPaymentRequestRepo, toastManager) + setActiveContactPaymentContext(testPublicKey, privateContext, request) + sut.showSheet(sheet) + + sut.onHardwarePaymentDeadlineExpired(priorAttempt) + runCurrent() + + verify(paykitPaymentRequestRepo, never()).ensurePaymentAllowed(any()) + verify(toastManager).enqueue(any()) + if (priorAttempt) { + assertEquals(sheet, sut.currentSheet.value) + verify(paykitPaymentProofRepo, never()).failOnchainPayment(any()) + verify(privatePaykitRepo, never()).releasePrivatePaymentList(any(), any()) + } else { + assertNull(sut.currentSheet.value) + verify(paykitPaymentProofRepo).failOnchainPayment(request) + verify(privatePaykitRepo).releasePrivatePaymentList(testPublicKey, privateContext) + } + } + } + + @Test + fun `hardware retry denial keeps the started proof after cancellation`() = test { val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) whenever(context.getString(R.string.common__error)).thenReturn("Error") whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(paykitPaymentRequestRepo.ensurePaymentAllowed(request)) @@ -7103,6 +8006,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() assertTrue(sut.prepareHardwareContactPayment()) + sut.onHardwareBroadcastAttemptChanged(true) sut.sendEffect.test { assertFalse(sut.authorizeHardwareContactPayment(hasAttemptedBroadcast = true)) expectNoEvents() @@ -7115,14 +8019,22 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.onHardwareSignCancelled() advanceUntilIdle() - verify(paykitPaymentProofRepo).failOnchainPayment(request) + verify(paykitPaymentProofRepo, never()).failOnchainPayment(request) verify(privatePaykitRepo, never()).releasePrivatePaymentList(any(), any()) + + whenever( + paykitPaymentProofRepo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain), + ).thenReturn(Result.failure(PaykitPaymentRequestError.OperationInProgress)) + assertFalse(sut.prepareHardwareContactPayment()) + sut.onHardwarePaymentDeadlineExpired(hasAttemptedBroadcast = false) + advanceUntilIdle() + verify(paykitPaymentProofRepo, never()).failOnchainPayment(request) } @Test fun `cancelling hardware signing fails the started payment proof`() = test { val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) @@ -7147,9 +8059,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `dismissing hardware signing fails the started payment proof`() = test { + fun `dismissing hardware signing preserves only unresolved attempted payment proofs`() = test { val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) @@ -7164,22 +8076,30 @@ class AppViewModelSendFlowTest : BaseUnitTest() { hardwareWalletId = "hardware-wallet", ) ) - sut.showSheet(Sheet.Send(SendRoute.HardwareSign)) - advanceUntilIdle() - assertTrue(sut.prepareHardwareContactPayment()) + for (attemptChanges in listOf(listOf(true), emptyList(), listOf(true, false))) { + setActiveContactPaymentContext(testPublicKey, privateContext, request) + sut.showSheet(Sheet.Send(SendRoute.HardwareSign)) + advanceUntilIdle() + assertTrue(sut.prepareHardwareContactPayment()) + clearInvocations(paykitPaymentProofRepo) + attemptChanges.forEach(sut::onHardwareBroadcastAttemptChanged) - sut.hideSheet() - advanceUntilIdle() + sut.hideSheet() + advanceUntilIdle() - verify(paykitPaymentProofRepo).failOnchainPayment(request) + verify(paykitPaymentProofRepo, times(if (attemptChanges.lastOrNull() == true) 0 else 1)) + .failOnchainPayment(request) + } verify(privatePaykitRepo, never()).releasePrivatePaymentList(any(), any()) } @Test - fun `proof preparation failure does not block hardware payment request`() = test { + fun `proof preparation failure blocks hardware payment request`() = test { val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) - whenever(paykitPaymentProofRepo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain)) + val privateContext = privatePaymentContext(7uL) + whenever( + paykitPaymentProofRepo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain) + ) .thenReturn(Result.failure(IllegalStateException("proof unavailable"))) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) @@ -7195,23 +8115,23 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ), ) - assertTrue(sut.prepareHardwareContactPayment()) + assertFalse(sut.prepareHardwareContactPayment()) - verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) - verify(paykitPaymentRequestRepo).accept(request) + verify(privatePaykitRepo, never()).consumePrivatePaymentList(testPublicKey, privateContext) + verify(paykitPaymentRequestRepo, never()).accept(request) verify(paykitPaymentProofRepo, never()).markOnchainPaymentStarted(any(), any(), any()) } @Test fun `hardware payment request completes proof in background after broadcast`() = test { val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) val completionStarted = CompletableDeferred() val finishCompletion = CompletableDeferred() whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) - whenever(paykitPaymentProofRepo.completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue)) + whenever(paykitPaymentProofRepo.completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue, "bitkit")) .doSuspendableAnswer { completionStarted.complete(Unit) finishCompletion.await() @@ -7236,13 +8156,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { finishCompletion.complete(Unit) advanceUntilIdle() - verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue) + verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue, "bitkit") verify(privatePaykitRepo, never()).releasePrivatePaymentList(any(), any()) } @Test fun `private hardware contact preparation is idempotent`() = test { - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) setActiveContactPaymentContext(testPublicKey, privateContext) @@ -7253,6 +8173,44 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) } + @Test + fun `hardware submission pauses background work only during the attempt`() = test { + setActiveContactPaymentContext(testPublicKey, privatePaymentContext(7uL), paymentRequest()) + sut.onHardwarePaymentSubmissionChanged(true) + assertTrue(paymentSubmissionActive.value) + sut.onHardwarePaymentSubmissionChanged(false) + assertFalse(paymentSubmissionActive.value) + sut.onHardwarePaymentSubmissionChanged(true) + assertTrue(paymentSubmissionActive.value) + sut.onHardwareSignCancelled() + runCurrent() + assertFalse(paymentSubmissionActive.value) + verify(paykitPaymentProofRepo, never()).failOnchainPayment(any()) + } + + @Test + fun `proof changes coalesce while payment submission is active`() = test { + pubkyPublicKey.value = testPublicKey + enablePaykitUi() + runCurrent() + clearInvocations(paykitPaymentRequestRepo) + paymentSubmissionActive.value = true + repeat(3) { + proofStateVersion.value++ + runCurrent() + } + verify(paykitPaymentRequestRepo, never()).refreshAfterStateChange(any()) + + paymentSubmissionActive.value = false + runCurrent() + verify(paykitPaymentRequestRepo).refreshAfterStateChange(PaykitPaymentRequestRefreshMode.STORED) + paymentSubmissionActive.value = true + runCurrent() + paymentSubmissionActive.value = false + runCurrent() + verify(paykitPaymentRequestRepo, times(1)).refreshAfterStateChange(any()) + } + @Test fun `paid recurring request refreshes subscription state immediately`() = test { val address = "bcrt1qrecurringpaymentrequest" @@ -7262,12 +8220,20 @@ class AppViewModelSendFlowTest : BaseUnitTest() { endsAt = Instant.parse("2026-08-31T00:00:00Z"), ) ) - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) pubkyPublicKey.value = testPublicKey enablePaykitUi() balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) + val completionStarted = CompletableDeferred() + val finishCompletion = CompletableDeferred() + whenever(paykitPaymentProofRepo.completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue, "bitkit")) + .doSuspendableAnswer { + proofStateVersion.value += 1 + completionStarted.complete(Unit) + finishCompletion.await() + } whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) stubSuccessfulOnchainSend(address, request.amountSats) @@ -7284,8 +8250,14 @@ class AppViewModelSendFlowTest : BaseUnitTest() { confirmCurrentPayment() - verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue) - verify(paykitPaymentRequestRepo).refresh() + completionStarted.await() + verify(paykitPaymentRequestRepo).refreshAfterStateChange(PaykitPaymentRequestRefreshMode.STORED) + verify(paykitPaymentProofRepo, never()).reconcile() + verify(paykitPaymentRequestRepo, never()).refresh(PaykitPaymentRequestRefreshMode.FULL) + finishCompletion.complete(Unit) + advanceUntilIdle() + verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue, "bitkit") + verify(paykitPaymentRequestRepo).refresh(PaykitPaymentRequestRefreshMode.FULL) } @Test @@ -7418,8 +8390,15 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `initial subscription retry keeps the send sheet presented`() = test { val request = paymentRequest() - pendingPaykitPaymentRequests.value = listOf(request) - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + runCurrent() + val refreshed = CompletableDeferred() + whenever(paykitPaymentRequestRepo.refreshAfterStateChange()).doSuspendableAnswer { + refreshed.await() + pendingPaykitPaymentRequests.value = listOf(request) + Result.success(Unit) + } whenever(privatePaykitRepo.beginPaymentRequestWaitingForUpdatedList(request)).thenReturn( Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList) ) @@ -7443,7 +8422,12 @@ class AppViewModelSendFlowTest : BaseUnitTest() { runCurrent() assertTrue(sut.currentSheet.value is Sheet.Send) + verify(privatePaykitRepo, never()).beginPaymentRequestWaitingForUpdatedList(any()) + assertTrue(sut.isRetryingInitialSubscriptionPayment.value) + refreshed.complete(Unit) advanceUntilIdle() + verify(paykitPaymentRequestRepo).refreshAfterStateChange() + verify(privatePaykitRepo).beginPaymentRequestWaitingForUpdatedList(request) assertTrue(sut.currentSheet.value is Sheet.Send) assertFalse(sut.isRetryingInitialSubscriptionPayment.value) } @@ -7498,7 +8482,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `onchain payment failure before send attempt releases private payment details`() = test { val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) @@ -7534,7 +8518,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `onchain authorization denial after proof starts releases private payment details`() = test { val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) val address = "bcrt1qauthorizationdenied" balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) @@ -7573,20 +8557,21 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(paykitPaymentProofRepo).failOnchainPayment(request) verify(privatePaykitRepo).releasePrivatePaymentList(testPublicKey, privateContext) verify(paykitPaymentProofRepo).cancelPreparation(request) - verify(paykitPaymentProofRepo, never()).completeOnchainPayment(any(), any(), any()) + verify(paykitPaymentProofRepo, never()).completeOnchainPayment(any(), any(), any(), eq("bitkit")) } @Test fun `definite onchain failure after send attempt releases private payment details`() = test { for (error in listOf( NodeException.InvalidAddress("invalid address"), + AppError(ServiceError.PaymentDeadlineExpired()), NodeException.InsufficientFunds("insufficient funds"), NodeException.WalletOperationFailed("wallet"), NodeException.PersistenceFailed("io"), )) { clearInvocations(paykitPaymentProofRepo, privatePaykitRepo) val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) @@ -7626,7 +8611,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `uncertain onchain failure resolves the matching pending payment`() = test { val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) pubkyPublicKey.value = testPublicKey runCurrent() balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) @@ -7685,33 +8670,83 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `cold onchain proof resolution restores contact correlation without opening success`() = test { + fun `hardware resolution survives collector reattachment until completion`() = test { val request = paymentRequest() val transactionId = "ef".repeat(32) pubkyPublicKey.value = testPublicKey runCurrent() + setSendState( + SendUiState( + hardwareWalletId = "hardware-wallet", + amount = request.amountSats, + incomingPaymentRequestId = request.id, + ) + ) + sut.showSheet(Sheet.Send(SendRoute.HardwareSign)) + + onchainPaymentResolutions.value = listOf( + PaykitOnchainPaymentProofResolution("other-identity", request.id, transactionId), + ) + runCurrent() + assertNull(sut.sendUiState.value.resolvedHardwarePaymentTxId) + verify(paykitPaymentProofRepo, never()).consumeOnchainPaymentResolution(any()) onchainPaymentResolutions.value = listOf( PaykitOnchainPaymentProofResolution( testPublicKey, - request.id, + request.id.copy(paymentRequestId = "other-request"), transactionId, ), ) runCurrent() + assertNull(sut.sendUiState.value.resolvedHardwarePaymentTxId) - verify(paykitPaymentProofRepo).consumeOnchainPaymentResolution(any()) - verify(activityRepo).setContact( - contactPublicKey = request.counterparty, - forPaymentId = transactionId, - syncLdkPayments = false, + onchainPaymentResolutions.value = listOf( + PaykitOnchainPaymentProofResolution(testPublicKey, request.id, transactionId, "hardware-wallet"), ) + runCurrent() + repeat(2) { + sut.sendUiState.test { + assertEquals(transactionId, awaitItem().resolvedHardwarePaymentTxId) + } + } + verify(activityRepo).setContact(testPublicKey, transactionId, false, "hardware-wallet") + sut.completeHardwareContactPayment("other-transaction") + assertEquals(transactionId, sut.sendUiState.value.resolvedHardwarePaymentTxId) + sut.completeHardwareContactPayment(transactionId) + assertNull(sut.sendUiState.value.resolvedHardwarePaymentTxId) + verify(paykitPaymentProofRepo, never()).failOnchainPayment(any()) + verify(paykitPaymentProofRepo, never()).cancelPreparation(any()) + } + + @Test + fun `cold onchain proof resolution restores contact correlation without opening success`() = test { + val request = paymentRequest() + val transactionId = "ef".repeat(32) + pubkyPublicKey.value = testPublicKey + runCurrent() + + for (walletId in listOf(WalletScope.default, "hardware-wallet")) { + onchainPaymentResolutions.value = listOf( + PaykitOnchainPaymentProofResolution(testPublicKey, request.id, transactionId, walletId), + ) + runCurrent() + + verify(activityRepo).setContact( + contactPublicKey = request.counterparty, + forPaymentId = transactionId, + syncLdkPayments = false, + walletId = walletId, + ) + } + verify(paykitPaymentProofRepo, times(2)).consumeOnchainPaymentResolution(any()) assertNull(sut.successSendUiState.value.paymentHashOrTxId) } @Test fun `unrelated uncertain onchain resolution does not hijack another send`() = test { val request = paymentRequest() + val privateContext = PrivatePaykitPaymentContext(mapOf(MethodId.P2wpkh.rawValue to "bitkit"), 7uL) pubkyPublicKey.value = testPublicKey runCurrent() val replacementRequest = paymentRequest().copy(paymentRequestId = "replacement-request") @@ -7722,7 +8757,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sats = request.amountSats, result = Result.failure(IllegalStateException("outcome unknown")), ) - setActiveContactPaymentContext(testPublicKey, incomingPaymentRequest = request) + whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) + .thenReturn(Result.success(Unit)) + setActiveContactPaymentContext(testPublicKey, privateContext, request) setSendState( SendUiState( address = "bcrt1quncertainreplacement", @@ -7767,7 +8804,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `post broadcast bookkeeping failure still completes payment proof`() = test { val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) @@ -7791,7 +8828,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { confirmCurrentPayment() - verify(paykitPaymentProofRepo).completeOnchainPayment(request, "broadcast-txid", MethodId.P2wpkh.rawValue) + verify( + paykitPaymentProofRepo + ).completeOnchainPayment(request, "broadcast-txid", MethodId.P2wpkh.rawValue, "bitkit") verify(paykitPaymentProofRepo, never()).failOnchainPayment(any()) verify(privatePaykitRepo, never()).releasePrivatePaymentList(any(), any()) } @@ -7800,7 +8839,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `incoming payment request is not accepted when private list consumption fails`() = test { val address = "bcrt1qpaymentrequest" val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.failure(IllegalStateException("Payment list already consumed"))) @@ -7830,6 +8869,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + paymentDeadlineAt = anyOrNull(), ) } @@ -7837,7 +8877,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `incoming payment request releases private details when acceptance fails`() = test { val address = "bcrt1qpaymentrequest" val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) @@ -7870,6 +8910,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + paymentDeadlineAt = anyOrNull(), ) } @@ -7877,7 +8918,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `incoming payment request rejects mismatched fixed invoice amount before acceptance`() = test { val request = paymentRequest() val bolt11 = "lnbcrt1mismatchedrequest" - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) setActiveContactPaymentContext(testPublicKey, privateContext, request) setSendState( SendUiState( @@ -7902,7 +8943,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `incoming payment request rejects changed onchain amount before acceptance`() = test { val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) setActiveContactPaymentContext(testPublicKey, privateContext, request) setSendState( SendUiState( @@ -7930,6 +8971,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + paymentDeadlineAt = anyOrNull(), ) } @@ -7937,7 +8979,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `incoming payment request rejects changed amount for amountless invoice`() = test { val request = paymentRequest() val bolt11 = "lnbcrt1changedrequest" - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) setActiveContactPaymentContext(testPublicKey, privateContext, request) setSendState( SendUiState( @@ -7956,11 +8998,57 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(lightningRepo, never()).payInvoice(any(), anyOrNull()) } + @Test + fun `payment deadline crossed during LNURL callback prevents Lightning submission`() = test { + val request = paymentRequest().copy(paymentDeadlineAt = Instant.parse("2026-10-06T12:00:00Z")) + val privateContext = privatePaymentContext(7uL) + val lnurl = LnurlPayData( + uri = "lnurl1deadline", + callback = "https://example.com/callback", + minSendable = 1_000uL, + maxSendable = 100_000_000uL, + metadataStr = "[]", + commentAllowed = null, + allowsNostr = false, + nostrPubkey = null, + ) + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + whenever(context.getString(R.string.common__error)).thenReturn("Error") + whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) + whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) + .thenReturn(Result.success(Unit)) + whenever(lightningRepo.fetchLnurlInvoice(lnurl, lnurl.callbackAmountMsats(request.amountSats), null)) + .doSuspendableAnswer { + whenever(paykitPaymentRequestRepo.ensurePaymentAllowed(request)) + .thenReturn(Result.failure(PaykitPaymentRequestError.RequestExpired)) + Result.success(lightningInvoice("lnbcrt1deadline", request.amountSats)) + } + setActiveContactPaymentContext(testPublicKey, privateContext, request) + setSendState( + SendUiState( + address = lnurl.uri, + amount = request.amountSats, + payMethod = SendMethod.LIGHTNING, + lnurl = LnurlParams.LnurlPay(lnurl), + isPaymentRequest = true, + ), + ) + + sut.setSendEvent(SendEvent.PayConfirmed) + advanceUntilIdle() + + verify(paykitPaymentProofRepo).failLightningPayment("010203") + verify(paykitPaymentProofRepo).cancelPreparation(request) + verify(privatePaykitRepo).releasePrivatePaymentList(testPublicKey, privateContext) + verify(lightningRepo, never()).payInvoice(any(), anyOrNull()) + verify(lightningRepo).payInvoice(any(), anyOrNull(), eq(request.paymentDeadlineAt), any()) + } + @Test fun `expired incoming payment request is not submitted`() = test { val address = "bcrt1qexpiredrequest" val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val privateContext = privatePaymentContext(7uL) whenever(paykitPaymentRequestRepo.isPending(request)).thenReturn(false) setActiveContactPaymentContext(testPublicKey, privateContext, request) setSendState( @@ -7989,6 +9077,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + paymentDeadlineAt = anyOrNull(), ) } @@ -8014,7 +9103,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val bolt11 = "lnbcrt1privatecontact" val paymentHash = "payment_hash" val contactKey = "pubkycontact" - val privateContext = PrivatePaykitPaymentContext("bitkit/wallet", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendLightningSats = 100_000u) whenever(lightningRepo.payInvoice(bolt11 = bolt11, sats = null)).thenReturn(Result.success(paymentHash)) whenever(privatePaykitRepo.consumePrivatePaymentList(contactKey, privateContext)) @@ -8049,7 +9138,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val bolt11 = "lnbcrt1pending" val paymentHash = "pending_hash" val contactKey = "pubkycontact" - val privateContext = PrivatePaykitPaymentContext("bitkit/wallet", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendLightningSats = 100_000u) whenever(lightningRepo.payInvoice(bolt11 = bolt11, sats = null)) .thenReturn(Result.failure(PaymentPendingException(paymentHash))) @@ -8075,7 +9164,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `private lightning duplicate payment consumes private list`() = test { val bolt11 = "lnbcrt1duplicate" val contactKey = "pubkycontact" - val privateContext = PrivatePaykitPaymentContext("bitkit/wallet", 7uL) + val privateContext = privatePaymentContext(7uL) balanceState.value = BalanceState(maxSendLightningSats = 100_000u) whenever(lightningRepo.payInvoice(bolt11 = bolt11, sats = null)) .thenReturn(Result.failure(AppError("DuplicatePayment"))) @@ -8418,15 +9507,49 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pubkyPublicKey.value = testPublicKey advanceUntilIdle() - verify(publicPaykitRepo).syncLocalReceiverMarker() - verify(privatePaykitRepo, never()).prepareSavedContacts(any>(), any()) + verify(publicPaykitRepo).syncPaykitApp() + verify(privatePaykitRepo, never()).scheduleSavedContactPreparation(any>()) verify(privatePaykitRepo, never()).pruneUnsavedContactState(any>()) + val prepared = CompletableDeferred() + whenever(privatePaykitRepo.awaitContactPreparation()).doSuspendableAnswer { prepared.await() } + clearInvocations(paykitPaymentRequestRepo, publicPaykitRepo) pubkyContactsLoadVersion.value = 1L + runCurrent() + assertFalse(prepared.isCompleted) + verify(privatePaykitRepo, never()).awaitContactPreparation() advanceUntilIdle() - verify(privatePaykitRepo).prepareSavedContacts(any>(), any()) + verify(privatePaykitRepo).scheduleSavedContactPreparation(any>()) verify(privatePaykitRepo).pruneUnsavedContactState(any>()) + verify(paykitPaymentRequestRepo).refreshEligibleTargets(any(), eq(true)) + verify(publicPaykitRepo, never()).syncPaykitApp() + } + + @Test + fun `contact sync retries a failed app registration without repeating a successful one`() = test { + enablePaykitUi() + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) + advanceUntilIdle() + clearInvocations(publicPaykitRepo) + whenever(publicPaykitRepo.syncPaykitApp()).thenReturn( + Result.failure(IllegalStateException("network unavailable")), + Result.success(Unit), + ) + + pubkyPublicKey.value = testPublicKey + advanceUntilIdle() + verify(publicPaykitRepo).syncPaykitApp() + + pubkyContactsLoadVersion.value = 1L + advanceUntilIdle() + verify(publicPaykitRepo, times(2)).syncPaykitApp() + + val contact = PubkyProfile.placeholder("pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg") + pubkyContacts.value = listOf(contact) + advanceUntilIdle() + verify(publicPaykitRepo, times(2)).syncPaykitApp() + verify(privatePaykitRepo).scheduleSavedContactPreparation(setOf(contact.publicKey)) } @Test @@ -8438,14 +9561,14 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pubkyContacts.value = listOf(contact) pubkyContactsLoadVersion.value = 1L advanceUntilIdle() - verify(privatePaykitRepo).prepareSavedContacts(setOf(contact.publicKey), false) + verify(privatePaykitRepo).scheduleSavedContactPreparation(setOf(contact.publicKey)) clearInvocations(privatePaykitRepo) pubkyContacts.value = listOf(contact.copy(name = "Bob", imageUrl = "pubky://avatar")) advanceUntilIdle() verify(privatePaykitRepo, never()).prepareSavedContacts(any>(), any()) - verify(privatePaykitRepo, never()).startInitialLinkBurst(any(), any()) + verify(privatePaykitRepo, never()).scheduleSavedContactPreparation(any()) } @Test @@ -8465,15 +9588,117 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pubkyContacts.value = listOf(contact) pubkyContactsLoadVersion.value = 1L advanceUntilIdle() - clearInvocations(privatePaykitRepo) + clearInvocations(privatePaykitRepo, paykitPaymentRequestRepo) pubkyContacts.value = emptyList() pubkyContactsLoadVersion.value = 2L advanceUntilIdle() - verify(privatePaykitRepo).removeSavedContact(contact.publicKey) - verify(privatePaykitRepo).prepareSavedContacts(emptySet(), false) + verify(privatePaykitRepo).removeSavedContacts(setOf(contact.publicKey)) + verify(privatePaykitRepo).scheduleSavedContactPreparation(emptySet()) verify(privatePaykitRepo).pruneUnsavedContactState(emptySet()) + inOrder(privatePaykitRepo, paykitPaymentRequestRepo).apply { + verify(privatePaykitRepo).removeSavedContacts(setOf(contact.publicKey)) + verify(paykitPaymentRequestRepo).refreshAfterStateChange() + } + verify(paykitPaymentRequestRepo, never()).refresh(any()) + } + + @Test + fun `private Paykit endpoint cleanup refreshes requests after state changes`() = test { + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + advanceUntilIdle() + clearInvocations(privatePaykitRepo, paykitPaymentRequestRepo) + + sut.refreshPrivatePaykitEndpoints() + advanceUntilIdle() + + inOrder(privatePaykitRepo, paykitPaymentRequestRepo).apply { + verify(privatePaykitRepo).retryPendingEndpointRemoval(any()) + verify(paykitPaymentRequestRepo).refreshAfterStateChange() + } + verify(paykitPaymentRequestRepo, never()).refresh(any()) + } + + @Test + fun `private Paykit refresh skips cleanup throughout sharing disable`() = test { + settingsData.value = SettingsData( + sharesPublicPaykitEndpoints = true, + sharesPrivatePaykitEndpoints = true, + publicPaykitCleanupPending = true, + ) + val privateCleanup = CompletableDeferred() + val publicCleanup = CompletableDeferred() + whenever(privatePaykitRepo.disableSharingAndPruneUnsavedContactState(any>())) + .doSuspendableAnswer { + privateCleanup.await() + Result.success(Unit) + } + whenever(publicPaykitRepo.syncPublishedEndpoints(publish = false, appSyncPriority = Priority.Interactive)) + .doSuspendableAnswer { + publicCleanup.await() + Result.success(Unit) + } + + val disable = async { contactPaymentSettingsRepo.setEnabled(false) } + runCurrent() + assertFalse(settingsData.value.sharesPublicPaykitEndpoints) + assertFalse(settingsData.value.sharesPrivatePaykitEndpoints) + sut.refreshPrivatePaykitEndpoints() + runCurrent() + + verify(privatePaykitRepo, never()).retryPendingEndpointRemoval(any>()) + verify(publicPaykitRepo, never()).syncPublishedEndpoints(publish = false) + assertTrue(settingsData.value.publicPaykitCleanupPending) + + privateCleanup.complete(Unit) + runCurrent() + sut.refreshPrivatePaykitEndpoints() + runCurrent() + + verify(privatePaykitRepo, never()).retryPendingEndpointRemoval(any>()) + verify(publicPaykitRepo, times(1)).syncPublishedEndpoints( + publish = false, + appSyncPriority = Priority.Interactive, + ) + assertTrue(settingsData.value.publicPaykitCleanupPending) + publicCleanup.complete(Unit) + assertTrue(disable.await().isSuccess) + } + + @Test + fun `private Paykit refresh coalesces private and public cleanup`() = test { + settingsData.value = SettingsData(publicPaykitCleanupPending = true) + val privateCleanup = CompletableDeferred() + val publicCleanup = CompletableDeferred() + whenever(privatePaykitRepo.retryPendingEndpointRemoval(any>())).doSuspendableAnswer { + privateCleanup.await() + Result.success(Unit) + } + whenever(publicPaykitRepo.syncPublishedEndpoints(publish = false)).doSuspendableAnswer { + publicCleanup.await() + Result.success(Unit) + } + + sut.refreshPrivatePaykitEndpoints() + runCurrent() + sut.refreshPrivatePaykitEndpoints() + runCurrent() + + verify(privatePaykitRepo, times(1)).retryPendingEndpointRemoval(emptyList()) + verify(publicPaykitRepo, never()).syncPublishedEndpoints(publish = false) + privateCleanup.complete(Unit) + runCurrent() + sut.refreshPrivatePaykitEndpoints() + runCurrent() + + verify(privatePaykitRepo, times(1)).retryPendingEndpointRemoval(emptyList()) + verify(publicPaykitRepo, times(1)).syncPublishedEndpoints(publish = false) + assertTrue(settingsData.value.publicPaykitCleanupPending) + publicCleanup.complete(Unit) + runCurrent() + assertFalse(settingsData.value.publicPaykitCleanupPending) } @Test @@ -8519,7 +9744,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() verify(publicPaykitRepo).syncPublishedEndpoints(publish = false) - verify(publicPaykitRepo, never()).syncLocalReceiverMarker() + verify(publicPaykitRepo, never()).syncPaykitApp() assertFalse(settingsData.value.publicPaykitCleanupPending) verify(privatePaykitRepo).retryPendingEndpointRemoval(emptyList()) } @@ -8533,7 +9758,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.refreshPrivatePaykitEndpoints() advanceUntilIdle() - verify(publicPaykitRepo).syncLocalReceiverMarker() + verify(publicPaykitRepo).syncPaykitApp() } private suspend fun TestScope.confirmCurrentPayment() { @@ -8567,6 +9792,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + paymentDeadlineAt = anyOrNull(), ) }.doSuspendableAnswer { invocation -> kotlin.check(invocation.getArgument(0) == address) @@ -8666,7 +9892,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { paymentRequest: String, privateListIndex: ULong = 7uL, ): PrivatePaykitPaymentContext { - val privateContext = PrivatePaykitPaymentContext("bitkit/server", privateListIndex) + val privateContext = privatePaymentContext(privateListIndex) whenever { privatePaykitRepo.beginPaymentRequest(request) }.thenReturn( Result.success( PublicPaykitPaymentResult.Opened( @@ -8809,12 +10035,6 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) } - private fun setRequestedPaymentRequestId(id: PaykitPaymentRequestId?) { - val field = AppViewModel::class.java.getDeclaredField("requestedPaymentRequestId") - field.isAccessible = true - field.set(sut, id) - } - private fun activeContactPaymentContext(): ContactPaymentContext? { val field = AppViewModel::class.java.getDeclaredField("activeContactPaymentContext") field.isAccessible = true @@ -8877,10 +10097,18 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fundingBalanceSats = fundingBalanceSats, ) + private fun privatePaymentContext(version: ULong?) = PrivatePaykitPaymentContext( + paymentAppsByEndpoint = mapOf( + MethodId.P2wpkh.rawValue to "bitkit", + MethodId.Bolt11.rawValue to "bitkit", + MethodId.Lnurl.rawValue to "bitkit", + ), + paymentListVersion = version, + ) + private fun paymentRequest() = PaykitPaymentRequest( paymentRequestId = "request-id", counterparty = testPublicKey, - counterpartyReceiverPath = "bitkit/server", amountValue = "0.000025", amountSats = 2_500uL, expiresAt = null, @@ -8890,7 +10118,6 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private fun subscriptionStartingAt(startsAt: Instant) = PaykitSubscription( paymentRequestId = "subscription-id", counterparty = testPublicKey, - counterpartyReceiverPath = "bitkit/server", amountValue = "0.000025", amountSats = 2_500uL, note = "Weekly coffee", diff --git a/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt index 56382133aa..c1c99a8e70 100644 --- a/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt @@ -25,6 +25,7 @@ import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.repositories.WidgetsRepo +import to.bitkit.services.PaykitSdkOperationLock.Priority import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError import kotlin.test.assertEquals @@ -79,10 +80,21 @@ class SettingsViewModelTest : BaseUnitTest() { whenever(pubkyRepo.isAuthenticated).thenReturn(MutableStateFlow(false)) whenever(pubkyRepo.identityExists).thenReturn(MutableStateFlow(false)) whenever(pubkyRepo.contacts).thenReturn(contacts) - whenever { publicPaykitRepo.syncPublishedEndpoints(publish = false) }.thenReturn(Result.success(Unit)) - whenever { publicPaykitRepo.syncLocalReceiverMarker(anyOrNull(), anyOrNull()) }.thenReturn(Result.success(Unit)) + whenever { publicPaykitRepo.syncPublishedEndpoints(publish = false, appSyncPriority = Priority.Interactive) } + .thenReturn(Result.success(Unit)) + whenever { publicPaykitRepo.syncPaykitApp(anyOrNull()) }.thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.disableSharingAndPruneUnsavedContactState(any>()) } .thenReturn(Result.success(Unit)) + val sharingRepo = ContactPaymentSettingsRepo( + settingsStore, + publicPaykitRepo, + privatePaykitRepo, + pubkyRepo, + testDispatcher, + ) + whenever { contactPaymentSettingsRepo.disablePaykit() }.doSuspendableAnswer { + sharingRepo.disablePaykit() + } sut = createViewModel() } @@ -111,13 +123,13 @@ class SettingsViewModelTest : BaseUnitTest() { assertEquals("", settings.publicPaykitBolt11) assertEquals("", settings.publicPaykitBolt11PaymentHash) assertEquals(0L, settings.publicPaykitBolt11ExpiresAtMillis) - verify(publicPaykitRepo).syncPublishedEndpoints(publish = false) + verify(publicPaykitRepo).syncPublishedEndpoints(publish = false, appSyncPriority = Priority.Interactive) verify(privatePaykitRepo).disableSharingAndPruneUnsavedContactState(contacts.value.map { it.publicKey }) } @Test fun `disabling Paykit keeps public cleanup pending when public removal fails`() = test { - whenever { publicPaykitRepo.syncPublishedEndpoints(publish = false) } + whenever { publicPaykitRepo.syncPublishedEndpoints(publish = false, appSyncPriority = Priority.Interactive) } .thenReturn(Result.failure(SettingsViewModelTestError("cleanup failed"))) isPaykitEnabled.value = true settingsData.value = SettingsData( @@ -144,8 +156,11 @@ class SettingsViewModelTest : BaseUnitTest() { advanceUntilIdle() assertFalse(settingsData.value.publicPaykitCleanupPending) - verify(publicPaykitRepo, never()).syncPublishedEndpoints(publish = false) - verify(publicPaykitRepo).syncLocalReceiverMarker(publicSharingEnabled = false, privateSharingEnabled = false) + verify(publicPaykitRepo, never()).syncPublishedEndpoints( + publish = false, + appSyncPriority = Priority.Interactive, + ) + verify(publicPaykitRepo).syncPaykitApp(privateSharingEnabled = false) verify(privatePaykitRepo).disableSharingAndPruneUnsavedContactState(contacts.value.map { it.publicKey }) } @@ -153,8 +168,7 @@ class SettingsViewModelTest : BaseUnitTest() { fun `disabling Paykit with private-only state keeps cleanup pending when marker removal fails`() = test { clearInvocations(publicPaykitRepo) whenever { - publicPaykitRepo.syncLocalReceiverMarker( - publicSharingEnabled = false, + publicPaykitRepo.syncPaykitApp( privateSharingEnabled = false, ) } @@ -168,8 +182,11 @@ class SettingsViewModelTest : BaseUnitTest() { advanceUntilIdle() assertTrue(settingsData.value.publicPaykitCleanupPending) - verify(publicPaykitRepo, never()).syncPublishedEndpoints(publish = false) - verify(publicPaykitRepo).syncLocalReceiverMarker(publicSharingEnabled = false, privateSharingEnabled = false) + verify(publicPaykitRepo, never()).syncPublishedEndpoints( + publish = false, + appSyncPriority = Priority.Interactive, + ) + verify(publicPaykitRepo).syncPaykitApp(privateSharingEnabled = false) verify(privatePaykitRepo).disableSharingAndPruneUnsavedContactState(contacts.value.map { it.publicKey }) } @@ -194,7 +211,7 @@ class SettingsViewModelTest : BaseUnitTest() { assertFalse(settings.sharesPrivatePaykitEndpoints) assertFalse(settings.publicPaykitCleanupPending) assertEquals("", settings.publicPaykitBolt11) - verify(publicPaykitRepo).syncPublishedEndpoints(publish = false) + verify(publicPaykitRepo).syncPublishedEndpoints(publish = false, appSyncPriority = Priority.Interactive) verify(privatePaykitRepo).disableSharingAndPruneUnsavedContactState(contacts.value.map { it.publicKey }) verify(settingsStore, never()).setIsPaykitEnabled(any()) } @@ -254,8 +271,6 @@ class SettingsViewModelTest : BaseUnitTest() { settingsStore = settingsStore, pubkyRepo = pubkyRepo, contactPaymentSettingsRepo = contactPaymentSettingsRepo, - publicPaykitRepo = publicPaykitRepo, - privatePaykitRepo = privatePaykitRepo, widgetsStore = widgetsStore, widgetsRepo = widgetsRepo, ) diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index beee2fe9f3..547a69035c 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -1111,7 +1111,7 @@ class TransferViewModelTest : BaseUnitTest() { // totalInput 100000 - feeSat 99000 - normalFee 500 = 500 dust (< Defaults.dustLimit) whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())) .thenReturn(Result.success(500uL)) - stubSendOnChainSuccess() + stubSendOnChain() var fundingPaidEmitted = false backgroundScope.launch { sut.transferEffects.collect { effect -> @@ -1147,6 +1147,7 @@ class TransferViewModelTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + paymentDeadlineAt = anyOrNull(), ) verify(cacheStore).addPaidOrder(eq(order.id), eq(TXID)) verify(blocktankRepo, times(1)).createOrder(eq(order.clientBalanceSat), eq(order.lspBalanceSat), any()) @@ -1164,7 +1165,7 @@ class TransferViewModelTest : BaseUnitTest() { }.thenReturn(Result.success(selected)) whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())) .thenReturn(Result.success(2_830uL)) - stubSendOnChainSuccess() + stubSendOnChain() quoteOrder(order) @@ -1185,6 +1186,7 @@ class TransferViewModelTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + paymentDeadlineAt = anyOrNull(), ) verify(lightningRepo, never()).sendOnChain( address = any(), @@ -1198,6 +1200,7 @@ class TransferViewModelTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + paymentDeadlineAt = anyOrNull(), ) verify(cacheStore).addPaidOrder(eq(order.id), eq(TXID)) } @@ -1215,21 +1218,7 @@ class TransferViewModelTest : BaseUnitTest() { // 100000 - 98000 - 1000 = 1000, above dust → fixed send; drain would still cover order. whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())) .thenReturn(Result.success(1_000uL)) - whenever( - lightningRepo.sendOnChain( - any(), - any(), - any(), - anyOrNull(), - anyOrNull(), - any(), - anyOrNull(), - any(), - any(), - any(), - any(), - ), - ).thenReturn(Result.failure(AppError("Coin selection failed"))) + stubSendOnChain(Result.failure(AppError("Coin selection failed"))) quoteOrder(order) @@ -1250,6 +1239,7 @@ class TransferViewModelTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + paymentDeadlineAt = anyOrNull(), ) verify(lightningRepo, never()).sendOnChain( address = any(), @@ -1263,6 +1253,7 @@ class TransferViewModelTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + paymentDeadlineAt = anyOrNull(), ) verify(cacheStore, never()).addPaidOrder(any(), any()) } @@ -1276,7 +1267,7 @@ class TransferViewModelTest : BaseUnitTest() { stubSpendableBalances(110_000uL) whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())) .thenReturn(Result.success(1_000uL)) - stubSendOnChainSuccess() + stubSendOnChain() prepareConfirm() sut.onTransferToSpendingConfirm() @@ -1306,21 +1297,7 @@ class TransferViewModelTest : BaseUnitTest() { }.thenReturn(Result.success(listOf(stubUtxo(110_000u)))) whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())) .thenReturn(Result.success(1_000uL)) - whenever( - lightningRepo.sendOnChain( - any(), - any(), - any(), - anyOrNull(), - anyOrNull(), - any(), - anyOrNull(), - any(), - any(), - any(), - any(), - ), - ).thenReturn(Result.failure(AppError("Coin selection failed")), Result.success(TXID)) + stubSendOnChain(Result.failure(AppError("Coin selection failed")), Result.success(TXID)) quoteOrder(order) prepareConfirm() @@ -1342,7 +1319,7 @@ class TransferViewModelTest : BaseUnitTest() { ) stubSpendableBalances(spendable = 110_000uL) stubSingleUtxoFunding(miningFee = 1_000uL) - stubSendOnChainSuccess() + stubSendOnChain() stubFeesChangedStrings() val toasts = collectToasts() quoteOrder(estimate) @@ -1374,7 +1351,7 @@ class TransferViewModelTest : BaseUnitTest() { val order = spendingOrder(feeSat = 98_000uL) stubSpendableBalances(spendable = 110_000uL) stubSingleUtxoFunding(miningFee = 1_000uL) - stubSendOnChainSuccess() + stubSendOnChain() val toasts = collectToasts() quoteOrder(order) prepareConfirm() @@ -1393,7 +1370,7 @@ class TransferViewModelTest : BaseUnitTest() { val order = spendingOrder(feeSat = 98_000uL) stubSpendableBalances(spendable = 110_000uL) stubSingleUtxoFunding(miningFee = 2_000uL) - stubSendOnChainSuccess() + stubSendOnChain() val toasts = collectToasts() quoteOrder(order) prepareConfirm() @@ -1414,7 +1391,7 @@ class TransferViewModelTest : BaseUnitTest() { val order = spendingOrder(feeSat = 98_000uL) stubSpendableBalances(spendable = 110_000uL) stubSingleUtxoFunding(miningFee = 1_000uL) - stubSendOnChainSuccess() + stubSendOnChain() stubFeesChangedStrings() val toasts = collectToasts() quoteOrder(order) @@ -1450,7 +1427,7 @@ class TransferViewModelTest : BaseUnitTest() { val creation = CompletableDeferred>() stubSpendableBalances(spendable = 110_000uL) stubSingleUtxoFunding(miningFee = 1_000uL) - stubSendOnChainSuccess() + stubSendOnChain() stubFeesChangedStrings() val toasts = collectToasts() quoteOrder(order) @@ -1484,7 +1461,7 @@ class TransferViewModelTest : BaseUnitTest() { whenever(currencyRepo.convertSatsToFiat(eq(1_500L), anyOrNull())).thenReturn(Result.success(increase)) stubSpendableBalances(spendable = 110_000uL) stubSingleUtxoFunding(miningFee = 1_000uL) - stubSendOnChainSuccess() + stubSendOnChain() stubFeesChangedStrings() val toasts = collectToasts() quoteOrder(order) @@ -1547,7 +1524,7 @@ class TransferViewModelTest : BaseUnitTest() { val laterRates = FeeRates(fast = 50u, mid = 30u, slow = 10u) stubSpendableBalances(spendable = 110_000uL) stubSingleUtxoFunding(miningFee = 1_000uL) - stubSendOnChainSuccess() + stubSendOnChain() stubFeesChangedStrings() whenever { lightningRepo.getFeeRates() }.thenReturn(Result.success(heldRates)) val toasts = collectToasts() @@ -1576,6 +1553,7 @@ class TransferViewModelTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + paymentDeadlineAt = anyOrNull(), ) } @@ -1588,7 +1566,7 @@ class TransferViewModelTest : BaseUnitTest() { ) stubSpendableBalances(spendable = 98_500uL) stubSingleUtxoFunding(miningFee = 500uL) - stubSendOnChainSuccess() + stubSendOnChain() stubFeesChangedStrings() val toasts = collectToasts() quoteOrder(estimate) @@ -1614,7 +1592,7 @@ class TransferViewModelTest : BaseUnitTest() { val order = spendingOrder(feeSat = 98_000uL) stubSpendableBalances(spendable = 110_000uL) stubSingleUtxoFunding(miningFee = 1_000uL) - stubSendOnChainSuccess() + stubSendOnChain() val toasts = collectToasts() quoteOrder(order) prepareConfirm() @@ -1650,7 +1628,7 @@ class TransferViewModelTest : BaseUnitTest() { ) stubSpendableBalances(spendable = 110_000uL) stubSingleUtxoFunding(miningFee = 1_000uL) - stubSendOnChainSuccess() + stubSendOnChain() val toasts = collectToasts() quoteOrder(estimate) whenever(blocktankRepo.createOrder(any(), any(), any())).thenReturn(Result.success(createdOrder)) @@ -1680,7 +1658,7 @@ class TransferViewModelTest : BaseUnitTest() { }.thenReturn(Result.success(listOf(stubUtxo(110_000uL)))) whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())) .thenReturn(Result.success(1_000uL)) - stubSendOnChainSuccess() + stubSendOnChain() quoteOrder(estimate) whenever(blocktankRepo.createOrder(any(), any(), any())) .thenReturn(Result.success(firstOrder), Result.success(nextOrder)) @@ -1708,7 +1686,7 @@ class TransferViewModelTest : BaseUnitTest() { stubSpendableBalances(spendable = 110_000uL) whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())) .thenReturn(Result.success(1_000uL)) - stubSendOnChainSuccess() + stubSendOnChain() quoteOrder(paidOrder) prepareConfirm() @@ -1728,7 +1706,7 @@ class TransferViewModelTest : BaseUnitTest() { fun `onTransferToSpendingConfirm stays on the confirm step when the order cannot be created`() = test { val order = spendingOrder(feeSat = 98_000uL) stubSpendableBalances(spendable = 110_000u) - stubSendOnChainSuccess() + stubSendOnChain() val toasts = mutableListOf() val toastJob = launch { ToastEventBus.events.collect { toasts.add(it) } } quoteOrder(order) @@ -1757,6 +1735,7 @@ class TransferViewModelTest : BaseUnitTest() { any(), any(), any(), + anyOrNull(), ) verify(cacheStore, never()).addPaidOrder(any(), any()) } @@ -1770,21 +1749,7 @@ class TransferViewModelTest : BaseUnitTest() { }.thenReturn(Result.success(listOf(stubUtxo(110_000u)))) whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())) .thenReturn(Result.success(1_000uL)) - whenever( - lightningRepo.sendOnChain( - any(), - any(), - any(), - anyOrNull(), - anyOrNull(), - any(), - anyOrNull(), - any(), - any(), - any(), - any(), - ), - ).thenReturn(Result.failure(AppError("Coin selection failed"))) + stubSendOnChain(Result.failure(AppError("Coin selection failed"))) quoteOrder(order) prepareConfirm() sut.onTransferToSpendingConfirm() @@ -1903,7 +1868,7 @@ class TransferViewModelTest : BaseUnitTest() { stubSpendableBalances(spendable = ON_CHAIN_BALANCE) whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())) .thenReturn(Result.success(1_000uL)) - stubSendOnChainSuccess() + stubSendOnChain() quoteOrder(paidOrder) whenever(blocktankRepo.createOrder(any(), any(), any())) .thenReturn(Result.success(paidOrder), Result.success(nextOrder)) @@ -1929,6 +1894,7 @@ class TransferViewModelTest : BaseUnitTest() { any(), any(), any(), + anyOrNull(), ) verify(blocktankRepo, times(2)).createOrder(any(), any(), any()) } @@ -2153,7 +2119,7 @@ class TransferViewModelTest : BaseUnitTest() { assertEquals(PASSPHRASE_MISMATCH, toasts.single().description) verify(hwWalletRepo, never()).signFunding(any(), any()) - verify(hwWalletRepo, never()).broadcastFunding(any()) + verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) } @Test @@ -2214,7 +2180,7 @@ class TransferViewModelTest : BaseUnitTest() { verify(hwWalletRepo).ensureConnected(HARDWARE_WALLET_ID) verify(hwWalletRepo, never()).composeFundingTransaction(any(), any(), any(), any()) verify(hwWalletRepo, never()).signFunding(any(), any()) - verify(hwWalletRepo, never()).broadcastFunding(any()) + verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) } @Test @@ -2238,7 +2204,7 @@ class TransferViewModelTest : BaseUnitTest() { verify(hwWalletRepo).disconnectStaleSession(HARDWARE_WALLET_ID) verify(hwWalletRepo, never()).composeFundingTransaction(any(), any(), any(), any()) verify(hwWalletRepo, never()).signFunding(any(), any()) - verify(hwWalletRepo, never()).broadcastFunding(any()) + verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) } @Test @@ -2541,7 +2507,7 @@ class TransferViewModelTest : BaseUnitTest() { assertEquals(CONNECTION_ISSUE_TITLE, toasts.single().title) assertEquals(CONNECTION_ISSUE_DESCRIPTION, toasts.single().description) verify(hwWalletRepo, never()).signFunding(any(), any()) - verify(hwWalletRepo, never()).broadcastFunding(any()) + verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) verify(cacheStore, never()).addPaidOrder(any(), any()) } @@ -3245,6 +3211,7 @@ class TransferViewModelTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + paymentDeadlineAt = anyOrNull(), ) } @@ -3269,7 +3236,7 @@ class TransferViewModelTest : BaseUnitTest() { val order = spendingOrder(feeSat = 98_000uL) stubSpendableBalances(spendable = 110_000uL) stubSingleUtxoFunding(miningFee = 1_000uL) - stubSendOnChainSuccess() + stubSendOnChain() stubFeesChangedStrings() val toasts = collectToasts() quoteOrder(order) @@ -3379,8 +3346,11 @@ class TransferViewModelTest : BaseUnitTest() { valueSats = valueSats, ) - private suspend fun stubSendOnChainSuccess() { - whenever( + private suspend fun stubSendOnChain( + result: Result = Result.success(TXID), + nextResult: Result? = null, + ) { + val stubbing = whenever( lightningRepo.sendOnChain( any(), any(), @@ -3393,8 +3363,10 @@ class TransferViewModelTest : BaseUnitTest() { any(), any(), any(), + anyOrNull(), ), - ).thenReturn(Result.success(TXID)) + ).thenReturn(result) + nextResult?.let { stubbing.thenReturn(it) } } private companion object { diff --git a/app/src/test/resources/bitkit-combined-claim-v1.json b/app/src/test/resources/bitkit-combined-claim-v1.json new file mode 100644 index 0000000000..8fd50f23fb --- /dev/null +++ b/app/src/test/resources/bitkit-combined-claim-v1.json @@ -0,0 +1,10 @@ +{ + "query_parameter": "x-bitkit-claim", + "claim_type": "paykit-access-v1.watch-only-account-v1", + "capabilities": "/pub/paykit/:rw", + "account_index": 16909060, + "key_generation": 72623859790382856, + "serialized_xpub_hex": "090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909", + "paykit_secret_hex": "0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b", + "unsigned_payload_hex": "01010203040009090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090909090901020304050607080b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b" +} diff --git a/changelog.d/next/1401.changed.md b/changelog.d/next/1401.changed.md new file mode 100644 index 0000000000..19698e9c2b --- /dev/null +++ b/changelog.d/next/1401.changed.md @@ -0,0 +1 @@ +Share Paykit contacts and payment state with authorized apps while keeping wallet keys private, restore contacts when re-importing an identity, prepare incoming payments in the payment sheet, support one-time requests with absolute payment deadlines, prioritize payment submission over background synchronization, distinguish temporary restoration failures from expired sessions, preserve subscription reminders through retries, and label received payments with their Paykit contact. diff --git a/docs/paykit-issuer-interoperability.md b/docs/paykit-issuer-interoperability.md index 1f30e14df5..3c12fdc3f1 100644 --- a/docs/paykit-issuer-interoperability.md +++ b/docs/paykit-issuer-interoperability.md @@ -11,14 +11,16 @@ An actionable request must satisfy all of these requirements: - The amount asset is exactly lowercase `btc`. - The amount is a positive decimal Bitcoin value with at most eight significant fractional digits and no more than `18,446,744,073,709,551` satoshis. -- The request is a one-time proposal: the local role is payer, lifecycle state is proposed, and recurrence is absent. -- The proposal expiration is absent or is a valid future ISO 8601 timestamp. -- `paymentDeadline` is absent. Bitkit does not yet enforce actual-payment deadlines. +- The request is one-time: the local role is payer, its actionable lifecycle state is proposed or accepted, and recurrence is absent. +- The proposal expiration is absent or is a valid ISO 8601 timestamp. It must be in the future while the request is proposed; it does not prevent payment after acceptance. +- `paymentDeadline` is absent or uses the absolute `At` form with a valid UTC ISO 8601 timestamp ending in `Z` that has not passed. The deadline is inclusive: payment is allowed at the exact deadline instant. - `acceptedPaymentEndpointIdentifiers` retains at least one identifier supported on the wallet's current network. Bitkit filters `acceptedPaymentEndpointIdentifiers` in issuer order, removes duplicates after their first occurrence, and drops unknown or wrong-network identifiers. The request remains actionable when at least one identifier survives. -Requests with a payment deadline remain visible in history but are unavailable for payment. This restriction is separate from proposal expiration, which controls acceptance. +Bitkit enforces absolute one-time payment deadlines during payment preparation and before submission, including retries. Requests whose valid absolute deadline has passed remain visible in history but are unavailable for payment. This is separate from proposal expiration, which controls acceptance. + +Malformed deadlines and relative deadline forms are not actionable. Recurring requests are outside this one-time contract. ### Endpoint identifiers @@ -74,7 +76,7 @@ After this shape check, Bitkit validates that the value is usable: an on-chain a ## Delivery prerequisites -The issuer and wallet must be linked Paykit peers on the same receiver path before Bitkit polls the request. The issuer must advertise a usable endpoint for at least one identifier retained from the request. A request that fails the request gate is not presented; a request whose endpoint cannot be resolved is deferred until usable payment details arrive. +The issuer and wallet identities must be linked Paykit peers before Bitkit polls the request. Requests may require a specific payment App; Bitkit uses the SDK's request-specific resolver and preserves the selected App in its proof. The issuer can include exact `paymentEndpoints` in the Payment Request or supply current endpoints resolved for the requested App. At least one endpoint must be usable for an identifier retained from the request; embedded endpoints do not require separate advertisement. A request that fails the request gate is not presented; a request whose endpoint cannot be resolved is deferred until usable payment details arrive. ## Contract fixtures diff --git a/docs/pubky-auth-companion-claims.md b/docs/pubky-auth-companion-claims.md new file mode 100644 index 0000000000..a9003836f9 --- /dev/null +++ b/docs/pubky-auth-companion-claims.md @@ -0,0 +1,62 @@ +# Bitkit Pubky Auth companion claims + +Bitkit can authorize a Pubky session and share Paykit access, a watch-only Bitcoin account, or both. The request explicitly selects the material to share; homeserver write permissions alone never imply key export. + +Bitkit's own session includes the Paykit authorizer scope. On identity activation it publishes the identity-signed Noise key before advertising private Paykit capabilities. Sessions granted to other apps keep the normal Paykit scope; they cannot replace this authorization record. + +## Request + +- The Pubky Auth URL includes one `x-bitkit-claim` query parameter containing a dot-separated list of independent items: `paykit-access-v1` and `watch-only-account-v1`. Each item requests only its corresponding permission and material. +- Request builders emit Paykit first when requesting both: `x-bitkit-claim=paykit-access-v1.watch-only-account-v1`. Paykit Server requests both items for initial setup and only `paykit-access-v1` for reconnect. +- Either item order is accepted. Bitkit preserves the exact received list string as the SDK's `claim_type`; it must not be reordered before signing or relay delivery. +- The capability is `/pub/paykit/:rw`. +- Empty, unknown, or duplicate items, mismatched selections, and duplicate companion query parameters are rejected. Ordinary Pubky Auth without a companion claim shares only the session, not a Paykit access key or watch-only account. +- Explicit watch-only approval creates a fresh native-SegWit account. Account indexes begin at `1`, increase monotonically, and are never recycled. Retrying the same logical auth request reuses its incomplete account even if query parameters are reordered. +- Bitkit automatically names the account from the requesting service. The user can rename it later. The local name is not disclosed in the claim. +- Paykit-only reconnect leaves local watch-only accounts unchanged. The server retains its existing xpub, account index, and allocation state without requesting the account again; it binds the reconnect to the expected authenticated Pubky identity. +- Paykit-only approval neither allocates nor loads a Bitcoin account. It opens authorization directly, explaining private Paykit data and messages without watch-only or content-earning UI. + +## Claim payload + +The watch-only unsigned payload is exactly 84 bytes: + +| Offset | Size | Value | +| --- | ---: | --- | +| 0 | 1 | Claim version, `0x01` | +| 1 | 4 | BIP account index, unsigned big-endian | +| 5 | 1 | Address type, `0x00` for native SegWit | +| 6 | 78 | Base58Check-decoded extended public key, including its 4-byte version | + +The Paykit-only unsigned payload is exactly 41 bytes: version `1`, an 8-byte nonzero unsigned big-endian key generation, and the 32-byte Paykit access key. Requesting both items produces exactly 124 bytes: the 84-byte watch-only payload followed by that generation and key, without another version byte. This payload order is fixed regardless of the requested item order. + +Bitkit passes the exact item list as `claim_type` and the payload to Paykit's `approveAuthWithCompanionClaim` API. Paykit appends a 64-byte Ed25519 signature, encrypts the signed claim, delivers it on the companion relay channel, and only then approves normal Pubky Auth. The signed sizes are respectively 148, 105, and 188 bytes. + +For requests including Paykit access, Bitkit derives the Paykit secret from its local or shared Pubky identity secret and the current App Registry generation. A locally recorded generation prevents rollback. The recipient derives the separate Noise and shared-state encryption keys from the delegated secret. Neither the Pubky root secret nor Bitcoin spending keys are shared. Watch-only requests do not derive or send a Paykit secret. + +The signature binds the UTF-8 prefix `x-bitkit-claim|`, the exact received item list and trailing `|`, the SHA256 digest of the decoded auth request secret, and the complete unsigned claim bytes, concatenated in that order. Changing the list order changes both the signature domain and relay channel even though the unsigned payload is identical. Each selection requires its exact payload length: 84 bytes for watch-only, 41 bytes for Paykit access, and 124 bytes for both. + +`decoded_auth_request_secret` is the raw 32-byte value produced by base64url-no-pad decoding the URL's `secret` parameter, not UTF-8 text. + +The server verifies the signature with the creator's Pubky Ed25519 public key from the authenticated session. Binding the signature to the request secret prevents a valid signed claim from being moved to a different request; possession of the relay secret alone is insufficient to substitute an attacker's xpub. + +## Delivery and lifecycle + +- The normal AuthToken channel is `base_relay/{base64url_no_pad(BLAKE3(secret))}`. +- The companion channel is `base_relay/{base64url_no_pad(BLAKE3(UTF8(claim_type || "|") || secret))}`. +- Paykit encrypts the complete signed claim on the companion channel with the auth request secret using XSalsa20-Poly1305. The SDK owns transport and cryptography. +- Paykit delivers the claim before approving the normal Pubky Auth token, avoiding a session that was authorized without its required account claim. +- Bitkit persists the account before delivery and reuses the same account index and unsigned xpub payload when retrying an incomplete setup. Each attempt may create new encrypted relay messages; delivery is not guaranteed exactly once. +- Bitkit durably marks and loads a new incomplete account as authorizing before calling Paykit. Successful approval marks it active and leaves tracking enabled. An initial preparation or companion-delivery failure returns it to pending and unloads it again. +- Account registration and address revelation finish before authorization. LDK's periodic sync fetches transaction history; a full wallet sync is not a prerequisite for delivering the authorization. +- If Paykit reports that companion delivery succeeded but normal AuthToken delivery failed, Bitkit leaves the account authorizing and tracked. The same conservative state is retained if local activation persistence fails after Paykit returns success. Retrying reruns the combined Paykit approval with the same account and xpub payload; retry failures keep the account tracked so Bitkit does not lose visibility into addresses the server may already have derived. +- Disabling tracking unloads the account from LDK Node at runtime. It does not delete persisted wallet state, the xpub, or the server session. +- Enabled active or authorizing accounts are configured before LDK Node starts. Electrum full scans use a batch size of `100` and stop gap of `1000`. +- Bitkit pre-reveals external receive indexes `0...999` for each tracked account. LDK then maintains a rolling stop-gap window: the first address with transaction history must be at or below index `999`, and after activity at index `n`, the next active index must be at or below `n + 1000` so there are never `1000` consecutive inactive addresses. +- On startup and before app-driven sync, Bitkit reconciles persisted account state with LDK and restores the pre-revealed range. Accounts removed by a backup remain scheduled for unload until reconciliation succeeds, allowing transient failures to retry safely. +- Account metadata and monotonic allocation state are included in the existing encrypted wallet backup and use the same JSON field names on iOS and Android. + +## Shared fixtures and consent + +The canonical server fixture `bitkit-combined-claim-v1.json`, also included in Android test resources, defines the combined wire layout and exact bytes. + +The shared UI identifiers are `PubkyAuthPaykitAccess` for private Paykit consent and `PubkyAuthWatchOnlyConsent` for the original wallet introduction. Requested Paykit access appears only on final authorization. Journey specifications cover visible consent and cancellation separately from fixture-backed delivery and Paykit-only reconnect. diff --git a/docs/pubky.md b/docs/pubky.md index 78afe35afd..a6b66350f5 100644 --- a/docs/pubky.md +++ b/docs/pubky.md @@ -27,13 +27,24 @@ Delegates Pubky operations to `PaykitSdkService`, which uses: - **paykit-ffi** (`com.synonym:paykit-android`) — session management, auth approval, profile/contact resolution, and bounded file fetching - `fetchPubkyProfile()`, `fetchPubkyFollows()`, `resolveContactProfile()`, `fetchPubkyFileBounded()` -- **bitkit-core** (`com.synonym:bitkit-core-android`) — mnemonic-to-seed conversion for receiver noise-key derivation - - `mnemonicToSeed()` + +Paykit derives the delegated Paykit key from the active Pubky identity secret and the App Registry's current key generation. Authorized apps share encrypted Pubky-hosted Paykit state; Bitkit retains wallet-owned address reservations and pending payment proofs locally. + +The Android dependency is `com.synonym:paykit-android` from GitHub Packages, pinned in `gradle/libs.versions.toml`. Paykit is excluded from Maven-local resolution. Companion authorization and key-sharing consent are described in [Pubky Auth companion claims](pubky-auth-companion-claims.md). Session, state, key and publishing calls are serialized by `PaykitSdkService`'s operation lock. The public reads — `fetchFile()` (`fetchPubkyFileBounded()`), `fetchPubkyProfile()`, `fetchPubkyFollows()`, `resolveContactProfile()`, and the receiver reads `discoverRelevantReceiverPaths()`, `privateReceiverPathSelection()` and `paymentRequestReceiverPaths()` (`paykitReceiverPaths()` and `paykitReceiverMarker()`) — run outside that lock, at most 6 at once, and are cancelled with their caller. Only unauthenticated public reads may use that path. Reading or saving a contact record stays under the lock, so a caller that discovers receiver paths and then saves them takes the lock only for the save. A wallet wipe still applies to the public reads: one that needs an SDK instance builds it under the lock, one that starts during the wipe fails the way a locked call does, and one that the wipe overtakes fails instead of returning its result, while the wipe's own cleanup can still read. Each public read names a lane. An interactive read, for something the user is looking at or waiting on — the user's own profile, avatars and other files, Pubky Ring choice rows, the follow lookups of `prepareImport()` that an adopted Ring row waits on, a single contact opened from Add Contact or the contact screen, and the receiver reads for one contact the user pays or sends a payment request to — takes one of the 6 read slots. A bulk read — the contacts list's background profile refresh, private sync's receiver discovery and marker reads, and the payment request target refresh over all saved contacts — first takes one of 4 bulk slots and then a read slot, so bulk work never holds more than 4 read slots and at least 2 stay free for interactive reads. A freed read slot goes to the oldest waiting interactive read before any waiting bulk read, also one already queued for a read slot, so a lookup the user waits on never waits behind bulk reads that asked for a slot first. Within each lane reads are first come, first served, and a cancelled read gives back the slots it holds. A caller can give `resolveContactProfile()` a timeout, which counts only while the read holds its read slot; a read that runs out is cancelled, gives back its slots and fails with `PaykitReadTimeoutError`, an ordinary error rather than a cancellation. +### Received Payment Attribution + +Shared Payment Requests can identify a received payment's payer even when another authorized app +created the request. Attribution uses the request's immutable, accepted Bitcoin destinations, not +current contact endpoints or unverified payment proofs. Bitkit validates the network, checks every +known transaction output or the received Lightning payment hash, and leaves ambiguous matches +unlabelled. A successful shared-state refresh backfills only incoming activity with no contact; +existing labels and notes remain unchanged. Snapshots are scoped to the active Pubky identity. + ## Repository Layer (`PubkyRepo`) Manages session lifecycle, identity adoption, and profile data. Singleton scoped. diff --git a/docs/watch-only-account-claim-v1.md b/docs/watch-only-account-claim-v1.md deleted file mode 100644 index 2d9e9446ac..0000000000 --- a/docs/watch-only-account-claim-v1.md +++ /dev/null @@ -1,52 +0,0 @@ -# Bitkit watch-only account claim v1 - -This document records the client contract implemented by Bitkit iOS and Android for Paykit Server setup requests. - -## Request - -- The Pubky Auth URL includes `x-bitkit-claim=watch-only-account-v1`. -- The exact capabilities are `/pub/paykit/v0/bitkit/server/:rw` and `/pub/paykit/v0/private/bitkit/server/:rw`. -- Missing, unknown, mismatched, or duplicate companion-claim parameters are rejected. -- Every distinct auth request creates a fresh native-SegWit account, beginning at BIP84 account index `1`. Account indexes increase monotonically and are never reused. Retrying the same logical auth request reuses its incomplete account even if query parameters are reordered. -- Bitkit automatically names the account from the requesting service. The user can rename it later. The local name is not disclosed in the claim. - -## Claim payload - -Bitkit serializes this exact 84-byte unsigned payload: - -| Offset | Size | Value | -| --- | ---: | --- | -| 0 | 1 | Claim version, `0x01` | -| 1 | 4 | BIP account index, unsigned big-endian | -| 5 | 1 | Address type, `0x00` for native SegWit | -| 6 | 78 | Base58Check-decoded extended public key, including its 4-byte version | - -Bitkit passes the payload to Paykit's `approveAuthWithCompanionClaim` API. Paykit appends a 64-byte Ed25519 signature, encrypts the resulting 148-byte claim, delivers it on the companion relay channel, and only then approves normal Pubky Auth. - -The signature input is the byte concatenation: - -```text -UTF8("x-bitkit-claim|watch-only-account-v1|") -|| SHA256(decoded_auth_request_secret) -|| claim_bytes[0..<84] -``` - -`decoded_auth_request_secret` is the raw 32-byte value produced by base64url-no-pad decoding the URL's `secret` parameter, not UTF-8 text. - -The server verifies the signature with the creator's Pubky Ed25519 public key from the authenticated session. Binding the signature to the request secret prevents a valid signed claim from being moved to a different request; possession of the relay secret alone is insufficient to substitute an attacker's xpub. - -## Delivery and lifecycle - -- The normal AuthToken channel is `base_relay/{base64url_no_pad(BLAKE3(secret))}`. -- The companion channel is `base_relay/{base64url_no_pad(BLAKE3(ASCII("watch-only-account-v1|") || secret))}`. -- Paykit encrypts the complete 148-byte signed claim on the companion channel with the auth request secret using XSalsa20-Poly1305. -- Paykit delivers the claim before approving the normal Pubky Auth token, avoiding a session that was authorized without its required account claim. -- Bitkit persists the account before delivery and reuses the same account index and unsigned xpub payload when retrying an incomplete setup. Each attempt may create new encrypted relay messages; delivery is not guaranteed exactly once. -- Bitkit durably marks and loads an incomplete account as authorizing before calling Paykit. Successful combined approval marks it active and leaves tracking enabled. An initial preparation or companion-delivery failure returns it to pending and unloads it again. -- Account registration and address revelation finish before authorization. LDK's periodic sync fetches transaction history; a full wallet sync is not a prerequisite for delivering the authorization. -- If Paykit reports that companion delivery succeeded but normal AuthToken delivery failed, Bitkit leaves the account authorizing and tracked. The same conservative state is retained if local activation persistence fails after Paykit returns success. Retrying reruns the combined Paykit approval with the same account and xpub payload; retry failures keep the account tracked so Bitkit does not lose visibility into addresses the server may already have derived. -- Disabling tracking unloads the account from LDK Node at runtime. It does not delete persisted wallet state, the xpub, or the server session. -- Enabled active or authorizing accounts are configured before LDK Node starts. Electrum full scans use a batch size of `100` and stop gap of `1000`. -- Bitkit pre-reveals external receive indexes `0...999` for each tracked account. LDK then maintains a rolling stop-gap window: the first address with transaction history must be at or below index `999`, and after activity at index `n`, the next active index must be at or below `n + 1000` so there are never `1000` consecutive inactive addresses. -- On startup and before app-driven sync, Bitkit reconciles persisted account state with LDK and restores the pre-revealed range. Accounts removed by a backup remain scheduled for unload until reconciliation succeeds, allowing transient failures to retry safely. -- Account metadata and monotonic allocation state are included in the existing encrypted wallet backup and use the same JSON field names on iOS and Android. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c3a4f08a16..b2e471418a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,7 +22,7 @@ appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" } biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" } bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.5.18" } -paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc56" } +paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc69" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" } diff --git a/journeys/README.md b/journeys/README.md index 8992b4e576..cd35017baa 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -127,7 +127,7 @@ journey PR, which is what made this file conflict on every merge. | A hardware wallet to pair, watch and sign with | the deterministic Trezor emulator from `bitkit-docker` over the Bridge transport, with the USB attach intent injected by `adb`; USB enumeration, permission grants, the OS picker and BLE are not simulated — [hardware-wallet](hardware-wallet/README.md) | | Push notifications to a backgrounded or killed app | an FCM push from a CJIT order paid through `./lsp`, read back with `adb shell dumpsys notification` — [cjit-notifications](cjit-notifications/README.md) | | The OS notification-permission dialog | an API 33+ target, reset with `adb shell pm revoke to.bitkit.dev android.permission.POST_NOTIFICATIONS` — [notification-permission](notification-permission/README.md) | -| An incoming Payment Request from a linked issuer | the fixture issuer, saved as a contact and linked on receiver path `bitkit/server` — [payment-requests](payment-requests/README.md) | +| An incoming Payment Request from a linked issuer | the fixture issuer, saved as a contact and linked as identities — [payment-requests](payment-requests/README.md) | | Two linked Bitkit wallets for a subscription lifecycle | a second Bitkit instance linked to the first, so a proposal can be reviewed and accepted — [subscriptions](subscriptions) | | A Pubky identity and a two-wallet marketplace purchase | the integration fixture runtime: Pubky testnet, Paykit Server, regtest bitcoind and Fulcrum — [pubky-marketplace](pubky-marketplace/README.md) | | LNURL pay, withdraw, channel and auth, and Lightning Addresses | the `bitkit-docker` `lnurl-server` on local regtest, with the app started by `just run docker`, which builds with `E2E=true` and forwards its ports over `adb reverse`; it issues memo invoices, so a check that needs a description-hash invoice needs another endpoint — [lnurl](lnurl) | @@ -152,7 +152,8 @@ Known differences in the corpus, as of the iOS port (synonymdev/bitkit-ios#691): | `hardware-wallet/receive-onchain.xml`, `hardware-wallet/send-onchain.xml` | not ported | | `activity/date-range-rapid-month-taps.xml` | not ported — iOS has no activity journey suite, and the rapid month tap behaviour was not checked there | | `coin-selection/manual-coin-selection.xml` | not ported — iOS has the screen (`SendUtxoSelectionView`) but no accessibility identifiers on it yet | -| `payment-requests/requested-resolution-failure.xml` | not ported | +| `payment-requests/requested-resolution-failure.xml` | same file and journey name; both platforms keep the preparing confirmation open across retries and stop automatic retries when it closes. Native progress and request-row identifiers differ. Android's subscription-reminder preparation has no intermediate Send sheet; see `paykit-clock-changes.md`. | +| `payment-requests/request-summary.xml` | same file and journey name; Android additionally checks the open Sent receipt's live lifecycle subtitle after payment. iOS keeps a static note/date receipt without that subtitle, so those checks are not ported. | | `node-lifecycle/cancelled-node-restart.xml` | not ported — the routes run through Android's LDK Debug and Rapid-Gossip-Sync screens and assert on Android app-log lines | | `restore-wallet/paste-seed-fragment.xml` | not ported — the iOS Restore screen still has the 12/24-only paste guard, so the behaviour does not exist there yet | | `send/own-invoice-guard.xml` | not ported — iOS has no own-invoice guard | diff --git a/journeys/contacts/README.md b/journeys/contacts/README.md index 3e7abb48c3..9776491a11 100644 --- a/journeys/contacts/README.md +++ b/journeys/contacts/README.md @@ -1,7 +1,49 @@ # Contacts +`delete-newly-saved-contact.xml` checks deletion directly from Contact Saved and adding that +contact again. It is mirrored on iOS. Deleted contact screens must not remain in Back history. + +`contact-payment-sharing.xml` checks disabling sharing and keeping it off after returning to +Settings. The same journey is available on iOS. + +Network and storage fault injection are outside journey-runner capabilities. With contact sharing +on, make one private-list withdrawal fail and let the following public endpoint or app-registry +update fail as well. Turn off contact payments. The app must report the failure, keep the toggle off, +and retain both cleanup jobs without republishing cleared private lists. Restore connectivity and +foreground the app. Cleanup must finish while sharing stays off. Repeat with only the trailing +public endpoint or app-registry update failing. + +Hold withdrawal in progress and foreground the app. It must not start another cleanup. Request +sharing on again before withdrawal finishes: publication must wait until the earlier cleanup ends, +then leave sharing on. Repeat while a foreground cleanup is already running. + Import journeys require a disposable identity with a known following list. They save local Bitkit contacts; payment sharing remains a separate step. +Continue waits for public payment setup, not private linking with every imported contact. +Private preparation runs in the background. Repeat Import All with a large following list containing +unavailable profiles, then delete a contact while preparation is running. It must not be republished +after deletion. Unavailable private-link lookups are retried after five minutes rather than on each refresh. + For Import All, include the identity's own key in the following list. Repeat with a spelling that changes only the final z-base32 padding bits, which still represents the same 32-byte key. The preview friend count and saved contacts exclude that identity, while the other follows import normally. Carry the same self-follow checks and padding-alias repeat in the matching iOS journey. -Network and storage fault injection are outside journey-runner capabilities. Manually disable connectivity after the preview has loaded: importing the prepared contacts must still finish. Simulate a failed local save: stay on import, preserve successful saves, and retry only missing contacts without claiming complete success. On Android, a failed Continue on the payment-sharing screen should offer recovery guidance and leave saved contacts intact. +Network and storage fault injection are outside journey-runner capabilities. After the preview has +loaded, verify that importing its prepared contacts does not repeat profile lookups; saving shared +contact state still requires Pubky storage access. Simulate a failed contact batch: stay on import, +preserve previously saved contacts, and retry the entire unsaved selection without claiming partial +success. Duplicate selections and contacts already saved are skipped. Payment sharing remains a +separate step. On Android, a failed Continue on the payment-sharing screen should offer recovery +guidance and leave saved contacts intact. + +## Foreground wait isolation + +Hold an unrelated contact's background preparation in progress, then open a saved, linked contact +and request or pay it. The selected contact must be eligible for its own lookup before the full +contact scan finishes. Hold its public capability lookup separately: this public read must not +retain the shared-state operation queue used by payment resolution, withdrawal, and wallet backup. +Identity, link, and request execution checks still run and may wait for shared-state access. + +Retry private messages for one contact while other contacts have pending outbound work. Only the +selected retry contacts should be sent to or read from in that drain. Repeat during sharing OFF, +with one withdrawal failing: OFF remains immediate, cleanup remains pending on failure, and no new +publication starts. Record action-to-result time separately from SDK lock and network waits; these +fault-injection checks do not establish staging latency or a guaranteed completion deadline. diff --git a/journeys/contacts/contact-payment-sharing.xml b/journeys/contacts/contact-payment-sharing.xml new file mode 100644 index 0000000000..b183c9ef75 --- /dev/null +++ b/journeys/contacts/contact-payment-sharing.xml @@ -0,0 +1,20 @@ + + + Verifies the contact-payment preference stays off when leaving and returning to Settings. + Requires an authenticated disposable Pubky identity with contact payments enabled and a saved, + linked contact. Cleanup failure injection is a manual check because network and storage fault + injection are not journey capabilities, as documented in README.md. Use only one active + install of this wallet. + + + Open the menu and tap Settings (id "DrawerSettings") + Open the General tab (id "Tab-general") + Verify the contact payments toggle (id "ContactPaymentsToggle") is on + Tap the contact payments toggle (id "ContactPaymentsToggle") + Wait for the update to finish and verify the contact payments toggle is off + Return to the wallet home screen + Open the menu and tap Settings (id "DrawerSettings") + Open the General tab (id "Tab-general") + Verify the contact payments toggle (id "ContactPaymentsToggle") is still off + + diff --git a/journeys/contacts/delete-newly-saved-contact.xml b/journeys/contacts/delete-newly-saved-contact.xml new file mode 100644 index 0000000000..9a8aefdf66 --- /dev/null +++ b/journeys/contacts/delete-newly-saved-contact.xml @@ -0,0 +1,13 @@ + + + Requires an authenticated disposable identity and a valid contact not already saved. + The contact must have no active subscription. No payment is sent. + + + Open Contacts and add the contact by its Pubky key + On the Contact Saved screen, tap Delete and confirm + Verify Contacts appears and the deleted contact is absent, with no empty contact screen + Navigate Back and verify the deleted contact screen is not restored + Add the same contact again and verify its saved details appear + + diff --git a/journeys/contacts/import-all-contacts.xml b/journeys/contacts/import-all-contacts.xml index aedbcadb9d..1633f47e66 100644 --- a/journeys/contacts/import-all-contacts.xml +++ b/journeys/contacts/import-all-contacts.xml @@ -5,12 +5,16 @@ Include the identity's own key in the following list. Repeat with a spelling of that key that changes only the final z-base32 padding bits. Repeat with a large list (for example 62 contacts). + Repeat after deleting this profile and selecting the same Ring identity again. + Measure Import All to Let Your Contacts Pay You and check logs for a terminal error + or an uncertain-write safety wait. Note the friend count shown in the import preview Verify the friend count excludes your own profile Tap Import All Verify the import finishes and the Let Your Contacts Pay You screen appears + Tap Continue and verify the screen finishes without waiting for every contact to connect Return Home and open Contacts from the menu Verify the prepared contacts, including Pubky-only profiles, are present with their preview names Verify your own profile is absent from Contacts diff --git a/journeys/paykit-clock-changes.md b/journeys/paykit-clock-changes.md index 2fd22c8c6a..3a8cfcb46b 100644 --- a/journeys/paykit-clock-changes.md +++ b/journeys/paykit-clock-changes.md @@ -22,6 +22,10 @@ Create a fresh wallet and a matching Pubky identity, save a contact, and link a 3. Schedule a reminder, then move the clock backward before it is due. It must not announce that payment is due while the device's current time is before that billing boundary. 4. Restore the correct clock and verify reminders still work. On Android, WorkManager delivery is best effort and may be delayed by retry backoff or OS scheduling; this check does not require exact delivery to the second. 5. While a payment request is temporarily unavailable and presentation is retrying, move the clock forward and backward. Retry intervals should remain short, while actual payment expiry and approval continue to use absolute timestamps. +6. Tap a due subscription reminder during background contact preparation, then repeat with Bitkit closed before the tap. Verify the exact unpaid billing period is presented after authentication and unlock. The notification refresh must read shared state without starting proof reconciliation or private-message maintenance. Record tap-to-sheet timing separately from identity activation, authentication, background preparation, and any SDK lock wait. Startup and foreground maintenance run independently and can still delay the cold-launch case. + +7. Repeat the cold-launch reminder while the startup request refresh fails before the stored request snapshot loads. Keep the reminder pending through the failure. If another request is available, open it manually: preparation and payment must remain usable, and a second Pay must not replace the active payment. Dismiss it, restore connectivity, and allow a later refresh to open the reminder's exact due period without tapping the reminder again. Clear a stale reminder only after a successful snapshot confirms that period was handled or the subscription is inactive. Switching identities must discard the previous identity's pending reminder. +8. Open a due reminder whose payment cannot be prepared. Terminal feedback must end that reminder's presentation without an automatic reopen cycle. Repeat with an insufficient-balance or pending-private-link result. The period must remain manually reopenable while pending. Android shows the preparing Send sheet only for one-time requests; use `payment-requests/requested-resolution-failure.xml` to verify that sheet stays open across retries and that closing it prevents automatic reopening. These steps describe the remaining manual verification. Unit tests cover injected restoration failures, state preservation, retry timing, UTC recurrence, and notification scheduling; they do not replace a live Ring-session clock-change test. @@ -34,5 +38,8 @@ Network fault injection is not provided by the journey capability table. Use a d 3. Re-enable connectivity while leaving Bitkit open. Verify that the same identity and contact list recover without signing out or restarting the app when the saved session or adopted Ring credential is still available. If the Ring credential is unavailable, select the same identity again from the normal identity choice flow. 4. Repeat the failed startup and restore connectivity while Bitkit is backgrounded. Return to the foreground from a profile/contact screen and verify the same recovery. Resume must work from any screen, not only Home. 5. Start adopting a Ring identity while recovery is pending. Verify that automatic restoration does not replace the selected identity. Explicit sign-out or wallet reset must not be undone by a pending restoration. +6. While online, use an isolated shared-state lock holder to make a saved-session restore return temporarily unavailable. Keep Bitkit in the foreground without navigating, reconnecting, or resuming it. Release the held lock during the short retry window and verify recovery without another action. Android makes up to eight retries with a five-second delay between completed attempts; SDK work and safety waits can take additional time. After this window, the existing maintenance and foreground/network triggers remain available. Backgrounding the app or disabling Paykit must stop queued retries, without interrupting an active SDK write. Repeat with a permanent credentials failure and verify it does not enter this short retry loop. Both platforms retry automatically on connectivity restoration and app resume. A valid saved session must recover without a new authorization. Android can also recover with an available adopted Ring credential. + +The controlled lock-holder check needs a separate fixture and is not established by the unit tests for retry scheduling. diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index f6c0b4074f..a96332a4ce 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -6,7 +6,7 @@ Cover incoming Paykit Payment Requests from a linked issuer. The issuer contract ## Setup -Run Bitkit against regtest with Paykit UI enabled. Authenticate a Pubky identity, save the fixture issuer as a contact, link it on receiver path `bitkit/server`, and give the wallet enough on-chain balance to pay 100,000 sats. The fixture issuer must be able to publish a Paykit endpoint and send a one-time Payment Request to that linked peer. The `bitkit/server` path belongs to the third-party fixture issuer; use `bitkit/wallet` when another Bitkit instance is the issuer. +Run Bitkit against regtest with Paykit UI enabled. Authenticate a Pubky identity, save and link the fixture issuer as a contact, and give the wallet enough on-chain balance to pay 100,000 sats. The fixture issuer must be able to publish a Paykit endpoint and send a one-time Payment Request to that linked peer. Its App ID is `paykit-server`; Bitkit uses `bitkit`. The accepted journey uses: @@ -18,12 +18,37 @@ The accepted journey uses: Rejected fixture shapes stay in unit tests because Bitkit intentionally does not present requests that fail the contract gate. -`request-summary.xml` uses a second Bitkit instance as the requester instead of the fixture issuer: both instances are authenticated Pubky identities, saved as each other's contacts and linked on receiver path `bitkit/wallet`, and the payer holds enough balance to pay 21,000 sats. +`request-summary.xml` uses a second Bitkit instance as the requester instead of the fixture issuer: both instances are authenticated Pubky identities, saved as each other's contacts and linked, and the payer holds enough balance to pay 21,000 sats. +Its Android-only Sent receipt checks leave the 5,000 sats request open through payment and the +existing foreground synchronization. A protocol proof changes the note-free subtitle to +"Proof submitted"; a nonblank note still takes precedence. This is not inferred from a chain +payment. iOS keeps a static note/date receipt with no lifecycle subtitle, so these receipt-status +steps do not apply there. `contact-request-or-pay.xml` uses the same two-instance setup and starts from the payer's Contact Detail screen, opened through the `bitkit://contact` deeplink. Its timing step assumes the payer has been running for about a minute: right after launch, the Paykit session restore and link refresh hold the SDK and can push the Pay step well past the budget. `definite-pre-broadcast-retry.xml` uses the linked fixture issuer and the local regtest LNURL server. Configure its LNURL-pay metadata endpoint normally, but make its invoice callback fail the first request and succeed after it is switched back to the healthy response. Do not republish the Paykit payment list between attempts. This makes the first send fail before Lightning dispatch and proves that the same private payment details can be opened and paid on retry. +## Foreground synchronization + +Bitkit checks the private inbox and shared request state every 10 seconds while foregrounded and online. +Outbound retries, endpoint publication, and target discovery also run on startup, +explicit refreshes, and maintenance rounds after 30 seconds, then every 60 seconds. +Maintenance uses elapsed time, including slow requests; polling remains serialized and does not +start catch-up rounds. Incoming messages wait for the next poll plus synchronization time. + +## Accepting install + +Acceptance intent is saved before the remote operation and included in wallet backups. +An interrupted response is reconciled against the shared request before payment. Restoring +the wallet restores its pending acceptances; this does not support running the same wallet +on multiple devices concurrently. + +`accepted-device-ownership.xml` extends the failing LNURL fixture to two separate installs sharing +one Pubky identity and App ID. Only the accepting install may retry after restart; the other keeps +the accepted request in history without payment controls. Confirm acceptance in shared request +state after the callback failure, before testing the second install. + ## Reference evidence The source wallet-leg run completed this path on regtest on 2026-08-22: Bitkit presented the incoming request, opened the on-chain payment, broadcast it, and confirmed transaction @@ -57,12 +82,49 @@ That run established the issuer shapes captured by the fixture: lowercase `btc`, `delete-contact-with-active-subscription.xml` requires an accepted open-ended payer subscription. It verifies that deletion explains why the contact must stay saved until the subscription ends, then that canceling, deleting, and readding does not revive it. No new payment is sent. Both contact-deletion journeys are mirrored on iOS and Android. +## Hardware broadcast recovery (manual) + +This requires a funded disposable regtest hardware wallet, a linked issuer with an absolute +payment deadline, and fault injection that can drop a successful broadcast response and hold +wallet reconciliation or its UI delivery. These controls are not journey capabilities; record +this test as blocked, not passed, when they are unavailable. Do not change the device clock. + +For definite hardware failures, inject Core's `InvalidHex` or `InvalidTransaction` before +the first network submission. Leave the signing screen and reopen the unpaid request; it must +remain payable. Repeat after an earlier uncertain submission: the proof must stay pending and +a fresh payment must remain blocked. Electrum and unclassified errors are not proof of rejection. + +1. Submit before the deadline, forward the signed transaction to the regtest node, and drop + only its response. Record the node's transaction ID and keep wallet reconciliation paused. +2. Let the payment deadline pass and retry. Verify that no additional broadcast occurs, the + signed transaction and started proof remain retained, and an unknown outcome is not treated + as a definite failure that allows another payment. +3. Release reconciliation for that transaction in the same hardware wallet. Verify that the + existing send screen reaches normal success, pending progress clears, and navigation is + available again. Check the wallet's activity and issuer proof against the recorded transaction; + no additional broadcast or duplicate activity may be created. +4. Repeat with a new request, pausing UI delivery after the matching resolution is retained. + Recreate the Activity without killing the process before releasing delivery. Verify the same + completion and navigation result; consuming the global proof event must not lose completion. +5. Deliver a resolution for a different identity, request or hardware wallet before the matching + one. Verify it cannot complete the pending send, then release the matching result and verify + normal completion. Capture redacted logs and UI evidence for each boundary. +6. Repeat with a new request, failing the broadcast without forwarding the transaction to the + node. After the deadline passes, retry and verify there is no additional broadcast. Back and + sheet dismissal must work once the attempt stops; the started proof must remain for + reconciliation. Leaving must not turn the uncertain payment into a new payable attempt. + ## Payment deadline history -`payment-deadline-history.xml` covers rc56 requests with actual-payment deadlines. +`absolute-payment-deadline.xml` covers one-time requests with absolute payment deadlines, +including acceptance before proposal expiry, payment after proposal expiry, and a deadline +crossed while payment preparation waits. The deadline is inclusive and separate from proposal +expiry. A sent payment's proof can still be delivered after its payment deadline. + +`payment-deadline-history.xml` covers expired one-time requests and recurring payment deadlines. Bitkit keeps their lifecycle and paid-period history, and subscription cancellation, -but does not accept them, offer payments, or schedule payment reminders. The journey -requires a controlled rc56 peer to prepare the accepted and paid records; repository +but does not offer expired payments or support recurring payment deadlines. The journey +requires a controlled shared-runtime peer to prepare the accepted and paid records; repository tests cover these states without sending funds. On Android, unpaid history rows show lifecycle labels, while paid rows show subscription names, notes, or dates. Active subscriptions are opened from Overview. The journeys record each fixture's payment @@ -72,3 +134,9 @@ subscription must have no end date so cancellation is available. The proposal re must explain that its payment details are unsupported and offer no Subscribe control. `automatic-presentation.xml` uses the same two-wallet setup and leaves the payer in the foreground. `confirmation-controls.xml` checks Android's fixed amount and confirmation footer on a compact screen with large text; it is not ported to iOS because the confirmation layout is different there. + +Incoming preparation uses the existing Send confirmation sheet with saved sender, amount and note. +Its payment control stays disabled and loading until fresh resolution and wallet validation finish. +Closing during preparation leaves the request pending and suppresses automatic reopening for the +current identity's app session; Pay from the request list or details explicitly retries it. +An unfunded wallet can verify loading followed by native rejection, not an enabled payment control. diff --git a/journeys/payment-requests/absolute-payment-deadline.xml b/journeys/payment-requests/absolute-payment-deadline.xml new file mode 100644 index 0000000000..d340c96749 --- /dev/null +++ b/journeys/payment-requests/absolute-payment-deadline.xml @@ -0,0 +1,27 @@ + + + Verifies one-time BTC requests with absolute At payment deadlines from a controlled Paykit issuer. + Use disposable linked regtest identities, a funded test wallet, and a controlled LNURL invoice callback. + Proposal expiry is the acceptance deadline, not the actual-payment deadline. Absolute payment deadlines + are inclusive UTC timestamps and may include fractional seconds. Recurring PeriodStart deadlines remain + unsupported. Record each request id and the issuer's UTC deadline without changing the device clock. + Lost broadcast responses require the separate hardware recovery manual test in this suite's README; + running this journey does not verify that fault-injection scenario. + + + Have the issuer send a one-time request with a future absolute payment deadline containing fractional seconds and a shorter proposal expiry + Open the request and verify its amount and payment review are available before either deadline + Configure the invoice callback to fail, then swipe to pay before proposal expiry so acceptance completes but no payment is dispatched + Wait until proposal expiry has passed while the payment deadline is still in the future + Restore the callback, tap Try Again (testTag "Retry"), and verify the accepted request returns to payment review + Swipe to pay before the payment deadline and verify payment succeeds + Have the issuer send another request with a short future absolute payment deadline and open its payment review + Delay the invoice callback, swipe to pay before the deadline, and release the callback only after the deadline + Verify no new Lightning payment is dispatched and the expired request cannot be paid again + Repeat with a new on-chain request, waiting past its payment deadline during fee confirmation or PIN entry, and verify no transaction is submitted + Repeat with a hardware wallet, waiting past its payment deadline during signing, and verify an expired-payment error is shown without broadcasting + Have the issuer send a request whose absolute payment deadline is already past and verify no Pay action is offered + Keep proof delivery unavailable for a payment sent before its deadline, restore delivery after the deadline, and verify reconciliation still delivers its proof without sending another payment + Restart Bitkit and verify completed and expired request history remains visible + + diff --git a/journeys/payment-requests/accepted-device-ownership.xml b/journeys/payment-requests/accepted-device-ownership.xml new file mode 100644 index 0000000000..b0c949b4e0 --- /dev/null +++ b/journeys/payment-requests/accepted-device-ownership.xml @@ -0,0 +1,21 @@ + + + Requires two separate Bitkit installs A and B authenticated as the same Pubky identity, + both using App ID bitkit, and the linked LNURL fixture from definite-pre-broadcast-retry.xml. + This covers one-time requests only. Do not copy app-private storage between installs. + + + Configure the fixture LNURL-pay invoice callback to fail before Lightning dispatch + Have the issuer send a proposed one-time Payment Request for 21,000 sats and record its request id + Open the request payment review on both installs before either install swipes to send + On install A swipe the send control (testTag "GRAB") and verify the failure screen (testTag "SendFailure") + Using the fixture's shared-runtime request inspection, verify the recorded request is ACCEPTED with no payment proof and no wallet dispatch; stop if acceptance has not committed + Configure the same LNURL-pay invoice callback to succeed without publishing a new Paykit payment list + On install B swipe the stale review's send control and verify that no wallet payment is dispatched + Restart install B and wait for Paykit synchronization; verify the request is not automatically presented or offered as payable + Open Payment Requests on install B and verify the recorded request remains in history without a Pay action + Restart install A and wait for Paykit synchronization + Open Payment Requests on install A and pay the recorded request + Verify the payment success screen (testTag "SendSuccess") and exactly one wallet payment to the issuer + + diff --git a/journeys/payment-requests/automatic-presentation.xml b/journeys/payment-requests/automatic-presentation.xml index 47b5009cdd..cc7ea79edc 100644 --- a/journeys/payment-requests/automatic-presentation.xml +++ b/journeys/payment-requests/automatic-presentation.xml @@ -1,20 +1,33 @@ - Verifies that a newly received request opens automatically in the foreground, waits for another sheet to close, and does not reopen a request the payer already reviewed. Requires two Bitkit instances saved as each other's contacts and linked on receiver path "bitkit/wallet", with the payer funded for 21,000 sats; see README.md. + Verifies that a newly received request opens automatically in the foreground, waits for another sheet to close, and does not reopen a request the payer already reviewed. Requires two Bitkit instances with separate Pubky identities, saved as each other's contacts and linked, with the payer funded for 21,000 sats; see README.md. On the payer, leave Bitkit unlocked and in the foreground on Home On the requester, send the payer contact a Payment Request for 21,000 sats with the note "First request" - On the payer, verify Payment Request confirmation (testTag "PaymentRequestConfirm") appears automatically with 21,000 sats and For shows "First request" + While the payer prepares the request, verify the existing Payment Request confirmation sheet (id "PaymentRequestConfirm") shows the requester's contact name, 21,000 sats and "First request", with the payment swipe control (id "GRAB") disabled and showing progress + Close the confirmation while preparation is still pending + Allow preparation and another private-message sync to finish; verify the request does not reopen automatically + Open Payment Requests, verify "First request" is still pending, and tap Pay on it + Verify the existing Payment Request confirmation sheet shows the same sender, amount and note with its payment control disabled and showing progress + Wait for fresh validation and wallet preparation to finish; verify that the same sheet remains open and its payment swipe control becomes available without dismissing and reopening the sheet Close the confirmation without paying On the payer, open Receive and leave its sheet open On the requester, send the payer contact another Payment Request for 5,000 sats with the note "Second request" Wait for the requester's send operation to finish and for the payer to synchronize its pending requests On the payer, verify Receive stays open without being replaced by confirmation + Verify no preparing payment sheet or confirmation covers Receive Close Receive - Verify Payment Request confirmation (testTag "PaymentRequestConfirm") appears automatically with 5,000 sats and For shows "Second request" + Verify Payment Request confirmation (id "PaymentRequestConfirm") appears automatically with 5,000 sats and For shows "Second request" Close the confirmation without paying Open Receive and close it again Verify Home remains visible and neither reviewed request reopens automatically + On the payer, open the Home menu (id "HeaderMenu") before the next request arrives + On the requester, send a Payment Request above the payer's available balance with the note "Unaffordable request" + Wait for the payer to synchronize the request and verify no preparing payment sheet or confirmation covers the open drawer + Close the drawer and verify the existing Payment Request confirmation sheet shows the request metadata with its payment control disabled and showing progress + On the payer, verify an insufficient-balance toast appears once and the preparing confirmation closes without enabling payment + Allow another pending-request sync, then open Receive and close it; verify the unaffordable request does not automatically prepare again or repeat its toast + Open Payment Requests and tap Pay on "Unaffordable request"; verify this explicit retry shows the insufficient-balance toast again diff --git a/journeys/payment-requests/contact-request-or-pay.xml b/journeys/payment-requests/contact-request-or-pay.xml index 575a09769f..11d800076a 100644 --- a/journeys/payment-requests/contact-request-or-pay.xml +++ b/journeys/payment-requests/contact-request-or-pay.xml @@ -1,6 +1,6 @@ - Verifies that Pay on a linked contact offers Request or Pay, that paying shows progress on the sheet until the amount screen opens within a few seconds, and that Request opens the Payment Request amount screen. Requires two Bitkit instances saved as each other's contacts and linked on receiver path "bitkit/wallet", with the payer funded; see README.md. Let the payer run for a minute after launch before starting, so start-up Paykit work does not dominate the timings. Open the contact with adb shell am start -a android.intent.action.VIEW -d "bitkit://contact?pubky=<requester-public-key>" to.bitkit.dev. + Verifies that Pay on a linked contact offers Request or Pay, that paying shows progress on the sheet until the amount screen opens within a few seconds, and that Request opens the Payment Request amount screen. Requires two Bitkit instances saved as each other's contacts and linked as identities, with the payer funded; see README.md. Let the payer run for a minute after launch before starting, so start-up Paykit work does not dominate the timings. Open the contact with adb shell am start -a android.intent.action.VIEW -d "bitkit://contact?pubky=<requester-public-key>" to.bitkit.dev. Open bitkit://contact?pubky=<requester-public-key> on the payer diff --git a/journeys/payment-requests/definite-pre-broadcast-retry.xml b/journeys/payment-requests/definite-pre-broadcast-retry.xml index c59c7d0f13..5412e76ec3 100644 --- a/journeys/payment-requests/definite-pre-broadcast-retry.xml +++ b/journeys/payment-requests/definite-pre-broadcast-retry.xml @@ -12,7 +12,7 @@ Swipe the send control (testTag "GRAB") Verify the send failure screen (testTag "SendFailure") appears Configure the same LNURL-pay invoice callback to succeed without publishing a new Paykit payment list - Tap Try Again (testTag "Retry") + Tap Try Again (testTag "Retry") without waiting for the next foreground request poll Verify the LNURL payment review appears again with 21,000 sats (testTag "ReviewAmount-primary" or "ReviewAmount-secondary", depending on the primary display setting) Swipe the send control (testTag "GRAB") Verify the payment success screen (testTag "SendSuccess") appears diff --git a/journeys/payment-requests/delete-and-readd-contact.xml b/journeys/payment-requests/delete-and-readd-contact.xml index e740907019..ce519ff239 100644 --- a/journeys/payment-requests/delete-and-readd-contact.xml +++ b/journeys/payment-requests/delete-and-readd-contact.xml @@ -1,6 +1,6 @@ - Verifies that deleting a contact stops incoming private Payment Requests until the user explicitly adds the contact again. Requires two authenticated Bitkit instances saved as each other's contacts and linked on receiver path "bitkit/wallet". The payer must have no active subscription with the requester. No payment needs to be sent. + Verifies that deleting a contact stops incoming private Payment Requests until the user explicitly adds the contact again. Requires two authenticated Bitkit instances saved as each other's contacts and linked as identities. The payer must have no active subscription with the requester. No payment needs to be sent. On the requester, send the payer contact a Payment Request for 5,000 sats with the note "Before deletion" @@ -8,13 +8,17 @@ On the payer, open Contacts, open the requester's contact, choose Delete Contact, and confirm deletion Verify the requester is absent from Contacts when the deletion confirmation appears Leave Contacts and immediately reopen it, wait for the list refresh to finish, and verify the deleted contact does not reappear - Open Payment Requests and wait for the request list to refresh, and verify "Before deletion" is no longer listed + Open Payment Requests immediately after deletion, wait for the request list to refresh, and verify "Before deletion" is no longer listed without waiting for another foreground polling interval On the requester, send another Payment Request to the payer with the note "After deletion" On the payer, return Home and wait for two foreground request polling intervals Verify no Payment Request confirmation appears for "After deletion" and no payable request from the deleted contact appears in Payment Requests Restart the payer app, return Home, and wait for two foreground request polling intervals Verify requests from the deleted contact remain unavailable for payment + Move the requester app to the background On the payer, explicitly add the requester's Pubky key as a contact again + While the requester remains in the background, return Home, reopen Contacts, and verify the saved contact remains visible + Keep the payer in the foreground for two minutes, then background and foreground it twice while the requester remains in the background + Bring the requester app back to the foreground Keep both apps in the foreground until the private connection is established again On the requester, send a new Payment Request with the note "After readd" On the payer, open "After readd" from Payment Requests and verify its Payment Request confirmation shows the saved contact name diff --git a/journeys/payment-requests/issuer-interoperability.xml b/journeys/payment-requests/issuer-interoperability.xml index bfe6b3ad8a..a2fa009c47 100644 --- a/journeys/payment-requests/issuer-interoperability.xml +++ b/journeys/payment-requests/issuer-interoperability.xml @@ -1,9 +1,9 @@ - Verifies that the canonical accepted regtest issuer fixture reaches Bitkit and opens the payment confirmation flow. Requires the saved and linked server fixture plus the funded regtest wallet described in README.md. The canonical fixture uses "bitkit/server"; a Bitkit app acting as issuer uses its negotiated "bitkit/wallet" path instead. Rejected shapes are covered by the shared fixture unit tests because Bitkit intentionally does not present them. + Verifies that the canonical accepted regtest issuer fixture reaches Bitkit and opens the payment confirmation flow. Requires the saved and linked server fixture plus the funded regtest wallet described in README.md. The fixture uses App ID "paykit-server"; Bitkit uses "bitkit". Rejected shapes are covered by the shared fixture unit tests because Bitkit intentionally does not present them. - Launch the E2E Bitkit app with Paykit UI enabled and the fixture issuer saved as a contact and linked on receiver path "bitkit/server" + Launch the E2E Bitkit app with Paykit UI enabled and the fixture issuer saved as a contact and linked as identities Have the issuer publish a current regtest P2WPKH address under identifier "btc-regtest-p2wpkh" with JSON payload {"value":"<current address>"} Have the issuer send proposed one-time Payment Request "71300000-0000-4000-8000-000000000001" for amount "0.001", asset "btc", and accepted identifier "btc-regtest-p2wpkh" Verify the Payment Request confirmation screen (testTag "PaymentRequestConfirm") appears with 100,000 sats @@ -17,7 +17,9 @@ Tap the pending Payment Requests bell (testTag "PaymentRequestsBell") Verify the incoming Payment Requests sheet (testTag "PaymentRequestsSheet") appears Tap Pay (testTag "PaymentRequestPay-71300000-0000-4000-8000-000000000001") + Verify the existing Payment Request confirmation sheet shows 100,000 sats with its payment swipe control (id "GRAB") disabled and showing progress while the request is prepared Verify the Payment Request confirmation screen (testTag "PaymentRequestConfirm") shows 100,000 sats + Wait for fresh validation and wallet preparation to finish; verify the same sheet stays open and its payment swipe control stops showing progress Tap Show details (testTag "SendConfirmToggleDetails") Verify the recipient (testTag "ReviewContactRecipient") is the saved fixture issuer contact diff --git a/journeys/payment-requests/payment-deadline-history.xml b/journeys/payment-requests/payment-deadline-history.xml index 1ba8b15b3d..945923a61b 100644 --- a/journeys/payment-requests/payment-deadline-history.xml +++ b/journeys/payment-requests/payment-deadline-history.xml @@ -1,11 +1,11 @@ - Requests with actual-payment deadlines are visible but cannot be paid by this version of Bitkit. - Use a disposable Paykit test identity linked to a controlled rc56 peer. Prepare one-time BTC - requests with deadlines in proposed, accepted, rejected, canceled and proof-submitted states, + One-time requests past their actual-payment deadlines and unsupported recurring deadlines remain visible in history. + Use a disposable Paykit test identity linked to a controlled shared-runtime peer. Prepare one-time BTC + requests with expired absolute deadlines in proposed, accepted, rejected, canceled and proof-submitted states, plus an incoming, accepted monthly BTC subscription with no end date, a period-start deadline, one paid period and one unpaid period. Acceptance and proof submission must be prepared through the controlled - client because Bitkit intentionally cannot accept these terms. Use valid regtest payment proofs. + client to retain these expired and unsupported fixture states. Use valid regtest payment proofs. Do not reset or replace a funded wallet to prepare this fixture. On Android, unpaid history rows show status labels, while paid rows show subscription names, notes or dates. Record each payment request id. diff --git a/journeys/payment-requests/request-summary.xml b/journeys/payment-requests/request-summary.xml index c4da618fdf..0ccf8d31d1 100644 --- a/journeys/payment-requests/request-summary.xml +++ b/journeys/payment-requests/request-summary.xml @@ -1,18 +1,23 @@ - Verifies that the collapsed Payment Request confirmation shows who the request is from and what it is for, keeps the note in the details, and shows "Not specified" in For when the request has no note. Requires two Bitkit instances saved as each other's contacts and linked on receiver path "bitkit/wallet", with the payer funded to cover 21,000 sats; see README.md. + Verifies that the collapsed Payment Request confirmation shows who the request is from and what it is for, keeps the note in the details, and shows "Not specified" in For when the request has no note. Requires two Bitkit instances saved as each other's contacts and linked as identities, with the payer funded to cover 21,000 sats; see README.md. Android also checks that the requester's open Sent receipt follows protocol history when no note is present. iOS keeps a static note/date receipt and does not display this lifecycle subtitle. On the requester, send the payer contact a Payment Request for 21,000 sats with the note "Lunch last week" + On the requester, verify the Sent receipt shows "Lunch last week", then close it with OK On the payer, verify the Payment Request confirmation screen (testTag "PaymentRequestConfirm") appears with 21,000 sats without opening the pending Payment Requests bell Verify From (testTag "PaymentRequestFrom") shows the requester's contact name Verify For (testTag "PaymentRequestFor") shows "Lunch last week" Tap Show details (testTag "SendConfirmToggleDetails") - Verify From (testTag "PaymentRequestFrom") and For (testTag "PaymentRequestFor") are no longer shown, and the recipient (testTag "ReviewContactRecipient") under Contact is the requester's contact + Verify From (testTag "PaymentRequestFrom") and For (testTag "PaymentRequestFor") are hidden in details, and the recipient (testTag "ReviewContactRecipient") under Contact is the requester's contact Verify the invoice note (testTag "PaymentRequestInvoiceNote") shows "Lunch last week" Close the Payment Request confirmation screen without paying On the requester, send the payer contact a Payment Request for 5,000 sats with no note + On the requester, keep the Sent receipt open and verify it shows "Waiting for payment" once the request is delivered On the payer, verify the Payment Request confirmation screen (testTag "PaymentRequestConfirm") appears automatically with 5,000 sats Verify From (testTag "PaymentRequestFrom") shows the requester's contact name and For (testTag "PaymentRequestFor") shows "Not specified" + On the payer, wait for preparation to finish and swipe to pay the 5,000 sats request + On the requester, close any received-payment sheet covering the Sent receipt, leaving the receipt open underneath + Keep the requester foregrounded through its existing Paykit synchronization; after the protocol payment proof arrives, verify the same Sent receipt shows "Proof submitted" instead of "Waiting for payment", while its PAYMENT REQUESTED heading remains unchanged diff --git a/journeys/payment-requests/requested-resolution-failure.xml b/journeys/payment-requests/requested-resolution-failure.xml index 4f42853c2e..696ef00de2 100644 --- a/journeys/payment-requests/requested-resolution-failure.xml +++ b/journeys/payment-requests/requested-resolution-failure.xml @@ -2,7 +2,7 @@ Verifies a user explicitly opening an incoming Payment Request receives localized terminal feedback after resolution retries exhaust, while the request remains available for another - attempt. + attempt. Dev settings must be available for the final availability check. Precondition: onboarded dev wallet with Paykit UI enabled, a profile, and one linked saved contact. Seed exactly one proposed incoming Payment Request from that contact with a known @@ -13,9 +13,17 @@ Verify the incoming request row (testTag "PaymentRequestRow-<payment-request-id>") is visible Tap Pay (testTag "PaymentRequestPay-<payment-request-id>") + Verify the existing confirmation sheet (id "PaymentRequestConfirm") shows the saved sender, amount and note, with its payment control loading and disabled (id "PaymentRequestPreparing") + Verify the same preparing confirmation stays open across resolution retries without dismissing and reopening Wait up to 35 seconds for the terminal error toast (testTag "PaymentRequestUnavailableToast") + Verify progress (id "PaymentRequestPreparing") is no longer visible Verify the toast title is "Payment Request" and its description is "The payment request is no longer available." Verify Payment Requests (testTag "PaymentRequestsScreen") remains visible Verify the incoming request row (testTag "PaymentRequestRow-<payment-request-id>") remains visible for a later retry + Open the incoming request row and tap Pay in its details (id "PaymentRequestDetailsPay") + Verify the existing confirmation sheet (id "PaymentRequestConfirm") opens over the details, with its payment control loading and disabled (id "PaymentRequestPreparing") + Close the preparing confirmation between resolution retries and verify the underlying details remain available and the request does not immediately reopen or retry automatically + Navigate to Dev settings and turn off Enable Paykit UI (id "PaykitUiToggle") + Return to the wallet home screen and verify progress (id "PaymentRequestPreparing") is absent and no payment sheet opens diff --git a/journeys/profile/README.md b/journeys/profile/README.md index bd94f21dcf..6b9b25ff20 100644 --- a/journeys/profile/README.md +++ b/journeys/profile/README.md @@ -4,6 +4,8 @@ Delayed or failed session restoration requires network fault injection, which is not a journey-runner capability; see the PR manual checks. +For a saved identity, inject a temporary import failure (`concurrent_update`, `shared_state_busy`, or a transport failure) during cold start. Verify that no "Session expired" toast appears, the saved credentials remain, and recovery succeeds after the temporary failure ends. A genuinely invalid session must still produce the existing expiry feedback. + For the reported background-resume case, verify returning directly to Profile with the process still alive, then repeat after the OS recreates the process. A delay in session recovery must keep the saved identity on the profile loading/retry screen rather than show profile onboarding. Also check both Contacts entry points during delayed or failed session restoration: the drawer and Continue on the Contacts intro. They must keep a saved identity on the recovery screen, including when identity lookup is still pending. A wallet without a saved identity must still reach onboarding. diff --git a/journeys/profile/delete-profile.xml b/journeys/profile/delete-profile.xml index 8f073e915d..14d27866f1 100644 --- a/journeys/profile/delete-profile.xml +++ b/journeys/profile/delete-profile.xml @@ -3,6 +3,8 @@ Precondition: an onboarded disposable test wallet with Paykit enabled and a profile created through Bitkit. The profile and its contacts will be deleted; do not use a personal identity. Record the sequence so progress can be checked even when deletion completes quickly. + Repeat with 62 imported contacts, once after preparation settles and once immediately after import. + Measure confirmation to onboarding and check logs for any uncertain-write safety wait. During deletion, check the indicator on Delete Profile. Save stays disabled with its label visible. diff --git a/journeys/pubky-marketplace/README.md b/journeys/pubky-marketplace/README.md index 60e837a9ac..e5da87d9b9 100644 --- a/journeys/pubky-marketplace/README.md +++ b/journeys/pubky-marketplace/README.md @@ -1,18 +1,35 @@ # Pubky marketplace wallet leg -This suite covers the two-wallet Bitkit leg of a Pubky marketplace purchase: a seller grants a -watch-only account claim, a linked buyer receives the resulting Payment Request, and the buyer pays +This suite covers the two-wallet Bitkit leg of a Pubky marketplace purchase: a seller grants +Paykit access and a watch-only account, a linked buyer receives the Payment Request, and the buyer pays the request on regtest through confirmation. It does not cover marketplace browsing, Locks content delivery, fiat payment, or Hypercolor. +## Companion consent and reconnect + +- `paykit-only-approval.xml` checks Paykit-only consent and cancellation without account creation. +- `paykit-reconnect.xml` checks Paykit-only reconnect consent and cancellation without changing the existing service account. + +These visible consent and cancel checks require valid auth URL fixtures but do not authorize a +network session. The reconnect journey additionally needs a known active, tracked account in the +isolated current wallet. Do not manufacture that fixture from real wallet data. + +With a live fixture, also approve each request: verify Paykit-only delivers exactly 41 unsigned +bytes and does not change account allocation or tracking. Initial combined setup delivers +124 unsigned bytes and creates a new account. Paykit-only reconnect delivers 41 unsigned bytes +with a nondecreasing Paykit generation; the server retains its xpub, account index, and allocation state. +Repeat reconnect with a rejected authorization and a cancelled local authentication prompt; +neither may change or unload the existing account. Watch-only requests deliver exactly 84 unsigned +bytes and no Paykit secret. Unknown, duplicate, mismatched, or wrong-sized claims must fail closed. + ## Required integration fixture runtime The journey needs a controlled integration fixture runtime. It must provide: - A fresh Pubky testnet or isolated staging namespace reachable by both wallets. -- A Paykit Server including the canonical request behavior from merged upstream - [`pubky/paykit-server#2`](https://github.com/pubky/paykit-server/pull/2), plus a `/setup` flow whose - auth URL carries `x-bitkit-claim=watch-only-account-v1`. +- A Paykit Server using the same shared-runtime SDK and the + [companion-claim contract](../../docs/pubky-auth-companion-claims.md), including a `/setup` auth + URL whose payload requests `x-bitkit-claim=paykit-access-v1.watch-only-account-v1`. - A regtest bitcoind and Electrum/Fulcrum endpoint on the same chain. Configure the endpoint in both wallets before their first launch so neither wallet retains a taller foreign regtest tip. - A clean seller wallet, a separate clean funded buyer wallet, and the seller Pubky public key. @@ -33,43 +50,18 @@ adb -s reverse tcp:15412 tcp:15412 ``` After creating each wallet, choose **Create profile with Bitkit** to create a Bitkit-generated Pubky -identity in each wallet. Do not import the identity with Pubky Ring; an imported identity cannot -approve the fixture setup auth URL. +identity in each wallet, or use a Ring identity whose root secret is available to Bitkit. -The request and endpoint must satisfy the issuer contract from Android issue -[#1208](https://github.com/synonymdev/bitkit-android/issues/1208): lowercase `btc`, a +The request and endpoint must satisfy the +[issuer contract](../../docs/paykit-issuer-interoperability.md): lowercase `btc`, a network-correct `btc-regtest-*` endpoint identifier, and a JSON endpoint payload with a non-empty string `value`. The fixture must keep watch-only account material and spending authority separate. Evidence must show the claimed account xpub and account index while omitting wallet seed material and tokens. -Use the pinned -[`BitcoinErrorLog/pubky-marketplace/payments-env`](https://github.com/BitcoinErrorLog/pubky-marketplace/tree/ed03a32ecfe02deab40ad10ae1bac7fa18465c10/payments-env) -runtime as the marketplace driver and Locks harness. Fixture commit `ed03a32e` pins Paykit Server -source `867fc883` and verifies the canonical lowercase asset, network-correct endpoint identifier, -JSON value payload, and initial private-link retry behavior. Its `scripts/verify.sh` proves the -Locks, Paykit, Pubky, bitcoind, and Fulcrum protocol path. The seller wallet fills the -companion-auth role and the buyer wallet fills the reader role; the other fixture roles remain -unchanged. - -## Required app changes - -The full journey depends on the sibling work from the parent epic: - -- [#1208](https://github.com/synonymdev/bitkit-android/issues/1208) defines the issuer interop - contract. -- [#1209](https://github.com/synonymdev/bitkit-android/issues/1209) adds reason-specific parse - diagnostics and terminal feedback when an open-time Pay retry is exhausted. -- [#1210](https://github.com/synonymdev/bitkit-android/issues/1210) owns the approved Payment Request - intake policy. This journey does not implement or widen that policy. -- [#1211](https://github.com/synonymdev/bitkit-android/issues/1211) prevents an Electrum-rejected - broadcast from reaching `SendSuccess`. -- [#1218](https://github.com/synonymdev/bitkit-android/issues/1218) tracks the incoming on-chain - request swipe requirement. The behavior is supplied by merged - [#1178](https://github.com/synonymdev/bitkit-android/pull/1178) at `9698dea4`. - -The linked-contact prerequisite is existing Paykit behavior: the buyer must save the seller before -Bitkit's private-message poll can receive the request. The seller must also save the buyer when the +The seller wallet authorizes the server; the buyer wallet receives and pays the request. +The buyer must save the seller before Bitkit's private-message poll can receive the request. +The seller must also save the buyer when the fixture exercises bilateral private delivery. ## Periodic payout detection @@ -90,8 +82,8 @@ Keep these artifacts at each boundary: | Boundary | Bitkit evidence | Fixture evidence | | --- | --- | --- | -| Watch-only claim | `PubkyAuthWatchOnlyConsent`, `PubkyAuthWatchOnlyApprove`, `PubkyAuthAuthorize`, and `PubkyAuthOK` snapshots | Setup completion and the claimed xpub/account index, with no spending key | -| Contact payments | `ContactPaymentsToggle` snapshots from both wallets | Public receiver markers for both wallet identities | +| Combined claim | `PubkyAuthWatchOnlyConsent`, `PubkyAuthPaykitAccess`, `PubkyAuthAuthorize`, and `PubkyAuthOK` snapshots | Setup completion and the claimed xpub/account index, with no spending key | +| Contact payments | `ContactPaymentsToggle` snapshots from both wallets | Public App Registry entries for both wallet identities | | Linked buyer | `Contact_` snapshot | Seller and buyer peer-link state | | Incoming request | `PaymentRequestsSheet` and `PaymentRequestRow-` snapshots showing seller, amount, and note when present | Delivery record and exact Payment Request id | | Payment approval | `PaymentRequestPay-`, `ReviewAmount`, and `ReviewContactRecipient` snapshots | Derived regtest address and expected amount | @@ -99,70 +91,4 @@ Keep these artifacts at each boundary: | Confirmation | Confirmed buyer activity snapshot | Transaction id at one or more confirmations and completed purchase status | `SendSuccess` is evidence of backend acceptance, not confirmation. The fixture's chain and purchase -status are the confirmation authority. `PaymentRequestPay-` is shared with the -iOS counterpart supplied by [`bitkit-ios#721`](https://github.com/synonymdev/bitkit-ios/pull/721). - -## Release provenance - -The watch-only claim entered the repository in `6f3134e1`, and the incoming Payment Request surface -entered in `4ea3dd16` and `7385376d`. No shipped Android release or tag contains both surfaces as of -2026-09-02. The first intended shipped release is the open `2.6.0` milestone; record its final tag -here when it ships. - -## Acceptance run from 2026-09-02 - -The initial successful payment replay used an isolated runtime including upstream Paykit Server -merge `867fc883` and a pre-merge copy of the incoming-request swipe behavior now supplied by merged -[#1178](https://github.com/synonymdev/bitkit-android/pull/1178). The installed E2E APK had SHA-256 -`cb494d870a255b96e3e289cbe7e659aa89fff1987308502b2fd55f121a0b0c42`, used the local backend at -`10.0.2.2`, and targeted homeserver -`8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo`. A deployed seller completed the unchanged -watch-only consent, approval, authorization, companion-claim delivery, and `/setup` completion -path. The fixture verifier passed with lowercase `btc`, endpoint identifier -`btc-regtest-p2wpkh`, and a JSON string `value`. - -Fresh Android buyer `pubkycecq8ssqnfgfwifioj7djoutnupmpjgomobistnz34zd9d5yyn4o` linked seller -`pubkyhbn4tahj71yzpmtarz5amtqqf5fmicdd7rs8ao448tzaujdapfiy`; both wallets saved the reciprocal -contact and enabled contact payments. The buyer received 1,000,000 sats at -`bcrt1q6mkkp26tu8zm4g78d58uksmvv3una04dryp7s0` in transaction -`3b48b259cd9aec8817b902a44c1609738268880cb16f25179ab87dea21a81a11`, confirmed at height -16,397 in block `5ad00a6d1d9e0e5a2348a255eae5bc83d507a759a4e9f377567af92fa3bacef6`. - -The fixture delivered Locks bundle `1GC8SBDYB2HHA2E0NZ51ZVEZX4`, server invoice -`92fdee57-6a46-40f2-9714-8a0e68d7e60e`, Payment Request -`ad1a8463-59e0-4cf8-b037-99ffb9d5b6ca`, and event -`4282bad8-270d-4e5c-a5f9-bca52d1583dd`. Android displayed the incoming Seller row for 15,000 -sats with an absent note, opened the payment review, resolved -`bcrt1qkuajc36azmaf9kk9ndwy9rdttl6vdlqwtvgyg5`, preserved the amount and Seller recipient, and -enabled the confirmation slider with a 141-sat fee. - -One swipe broadcast transaction `a3e2801d8cf3afa6a461a30fa38bc46680602311a7728b97e5d88f3767f3d9d2`. -The frozen zero-confirmation boundary contained only that mempool transaction, whose output zero -paid exactly 15,000 sats to the derived address; Paykit reported -`detected/0/amount_matched=true` and Locks remained pending. The fixture then mined exactly one -block. Android synced height 16,398 and showed a confirmed Seller activity with a 15,000-sat -payment and 141-sat fee. The transaction confirmed in block -`5f2a356cace276a17e0759d8d34e7c196c852b4b299901e592552fb8f8f0bb19`, Paykit reported -`confirmed/1/amount_matched=true`, the Locks bundle completed, and the paid request remained as -history without Pay or Dismiss actions. - -The selector-specific acceptance replay used Android buyer `emulator-5560` and seller iOS simulator -`B379B7A4-715A-427F-8CB6-A6479BC73050`. It ran a pre-merge integration build containing the journey -selectors and the incoming-request swipe behavior now supplied by merged #1178. The built and -device-installed APKs both had SHA-256 -`8d62881da626a6f6eab1e243c05079305ebd3efd2f9abb820a1a6b55d6454cf4`. The retained Buyer profile -was synced at height 16,398 with 984,859 sats before the fixture created Locks bundle -`J9AKW3TNASDM6MJB0SNE08RJ0M`, server invoice `8d810705-6eeb-46f6-9c57-dd3bc4bdc0cf`, Payment -Request `b7f67854-b052-4eed-a6e7-e1ac41c31a7a`, event -`cec538cb-57f5-4c89-9d80-7584699af1ec`, and payment reference -`94f212c9-ed8c-4b85-9b0a-e9eea09f0a73`. - -Android exposed the exact `PaymentRequestRow-b7f67854-b052-4eed-a6e7-e1ac41c31a7a` and -`PaymentRequestPay-b7f67854-b052-4eed-a6e7-e1ac41c31a7a` selectors, then preserved the 15,000-sat -amount, Seller recipient, and 141-sat fee with an enabled confirmation slider. One swipe broadcast -transaction `3dacd7591e0e9d1b7eab62f814f86017c41c1a1b8dda839a79b7244d9b20f997`, whose output zero paid -exactly 15,000 sats to `bcrt1qxluk70usejytg7ehgdhpsq657eshhmr8lmkcsv`. The frozen -zero-confirmation boundary contained that single mempool transaction. The fixture mined exactly one -block, `7cabfa65673a0472d70c0b1f217e93d6114e040a342381a446f9a50987d18f30`, at height 16,399. Paykit -reported `confirmed/1/amount_matched=true`, the Locks bundle completed, Android showed the Seller -payment as confirmed, and the paid request remained in history without Pay or Dismiss actions. +status are the confirmation authority. `PaymentRequestPay-` is shared with iOS. diff --git a/journeys/pubky-marketplace/paykit-only-approval.xml b/journeys/pubky-marketplace/paykit-only-approval.xml new file mode 100644 index 0000000000..06f77fa5fa --- /dev/null +++ b/journeys/pubky-marketplace/paykit-only-approval.xml @@ -0,0 +1,19 @@ + + + Verifies explicit Paykit-only consent and cancellation without allocating a Bitcoin account. + Precondition: an isolated wallet with a Bitkit-generated Pubky identity or an available Ring + root secret. The fixture supplies a valid fresh auth URL with exactly /pub/paykit/:rw and + x-bitkit-claim=paykit-access-v1. Consent and cancellation require no live authorization relay. + Successful delivery is a separate fixture-backed check described in the suite README. + + + Record the current wallet's watch-only account names, derivation paths, and tracking settings + Open the fixture Paykit-only auth URL in the wallet + Verify the authorization screen shows exactly /pub/paykit with READ, WRITE access + Verify Paykit access (id "PubkyAuthPaykitAccess") describes access to private Paykit data and sending Paykit messages, without wallet spending keys. + Verify watch-only consent (id "PubkyAuthWatchOnlyConsent") is not shown + Tap Cancel on the authorization screen + Verify the authorization sheet is dismissed + Verify the current wallet's watch-only accounts and tracking settings are unchanged + + diff --git a/journeys/pubky-marketplace/paykit-reconnect.xml b/journeys/pubky-marketplace/paykit-reconnect.xml new file mode 100644 index 0000000000..259599f0d5 --- /dev/null +++ b/journeys/pubky-marketplace/paykit-reconnect.xml @@ -0,0 +1,18 @@ + + + Verifies Paykit-only reconnect consent and cancellation without a new watch-only account. + Precondition: an isolated wallet with one known service account that is active and tracked, + and a valid fresh pubkyauth://signin URL with exactly /pub/paykit/:rw and + x-bitkit-claim=paykit-access-v1. The cancel steps need no live authorization relay. + Successful reconnect is a separate fixture-backed README check. + + + Record the known service account's name, derivation path, xpub, and tracking setting + Open the fixture Paykit-only reconnect auth URL in the wallet + Verify the authorization screen is visible without watch-only consent + Verify Paykit access (testTag "PubkyAuthPaykitAccess") describes private Paykit data and messages without sharing identity or spending keys + Tap Cancel on the authorization screen + Verify the authorization sheet is dismissed + Verify the known service account's name, derivation path, xpub, and tracking setting are unchanged and no new account was created + + diff --git a/journeys/pubky-marketplace/wallet-leg.xml b/journeys/pubky-marketplace/wallet-leg.xml index b174d4e03b..87d7dabb61 100644 --- a/journeys/pubky-marketplace/wallet-leg.xml +++ b/journeys/pubky-marketplace/wallet-leg.xml @@ -1,23 +1,24 @@ - Verifies a Bitkit seller grants a watch-only account claim and a separate linked Bitkit buyer + Verifies a Bitkit seller grants Paykit access and a watch-only account claim and a separate linked Bitkit buyer receives, approves, pays, and confirms the resulting marketplace Payment Request on regtest. Precondition: two clean wallets and the integration fixture runtime described in this suite's README. Configure the fixture Electrum endpoint and Android emulator port mappings before either wallet's first launch. After creating both wallets, create a Bitkit-generated Pubky identity - in each wallet. Pubky Ring-imported identities cannot approve the fixture setup auth URL. Start + in each wallet, or use a Ring identity whose root secret is available to Bitkit. Start with the seller wallet open and the fixture's fresh setup auth URL available. Open the fixture setup auth URL in the seller wallet Verify the watch-only consent screen (testTag "PubkyAuthWatchOnlyConsent") is visible Capture the watch-only consent evidence, then tap Approve (testTag "PubkyAuthWatchOnlyApprove") - Verify the authorization screen shows exactly /pub/paykit/v0/bitkit/server and /pub/paykit/v0/private/bitkit/server, each with READ, WRITE access + Verify the authorization screen shows exactly /pub/paykit with READ, WRITE access + Verify Paykit access (testTag "PubkyAuthPaykitAccess") describes private Paykit data and messages without sharing identity or spending keys Tap Authorize (testTag "PubkyAuthAuthorize") Verify authorization succeeds (testTag "PubkyAuthOK") Tap OK (testTag "PubkyAuthOK") - Verify the fixture completes setup with the seller account xpub and no spending authority + Verify the fixture completes setup with the seller account xpub and generation-bound Paykit key, but no Pubky root secret or spending authority Open General Settings in both wallets and verify contact payments (testTag "ContactPaymentsToggle") are enabled Open Contacts in the buyer wallet and save the fixture's seller public key Verify the seller contact (testTag "Contact_<seller-public-key>") is visible @@ -45,8 +46,12 @@ Wait for periodic synchronization to detect the payout while the seller app remains active If the seller's received-transaction sheet appears, capture it and tap its button (testTag "ReceivedTransactionButton") to dismiss it Verify the seller savings balance (testTag "ActivitySavings") increased by the fixture payout amount and the latest received activity is visible + Verify the received activity identifies the buyer contact after shared Paykit synchronization, including after reopening the app Tap the seller's latest received activity (testTag "ActivityShort-0"), then tap transaction details (testTag "ActivityTxDetails") Verify the transaction id (testTag "TXID") matches the fixture transaction Capture the final activity, transaction id, confirmation height, and completed purchase evidence + Return to the received activity details and tap Detach + Wait for Paykit synchronization, then restart the seller app and reopen the received activity + Verify the activity no longer identifies the buyer and offers Assign instead of Detach diff --git a/journeys/subscriptions/README.md b/journeys/subscriptions/README.md index b8287d1873..ddda3e5f61 100644 --- a/journeys/subscriptions/README.md +++ b/journeys/subscriptions/README.md @@ -7,7 +7,7 @@ Payments tab inside Subscriptions is covered here too, because it shares the scr ## Setup Run Bitkit against regtest with Paykit UI enabled on two instances that have each other saved as -contacts and linked on receiver path `bitkit/wallet`. One instance plays the creator, the other the +contacts with an established Paykit Encrypted Link. One instance plays the creator, the other the payer. Fund the payer with enough on-chain or spending balance to cover the subscription amount when the proposal is accepted with payment due on acceptance. diff --git a/journeys/subscriptions/cancellation-during-confirmation.xml b/journeys/subscriptions/cancellation-during-confirmation.xml new file mode 100644 index 0000000000..2b5a6fa5f1 --- /dev/null +++ b/journeys/subscriptions/cancellation-during-confirmation.xml @@ -0,0 +1,16 @@ + + + Requires a linked regtest issuer and a payer funded for a 5,000-sat on-chain payment. + The issuer can cancel an accepted recurring request with a fixed P2WPKH endpoint and no + payment deadline. Verifies cancellation while confirmation is open. Cancellation after payment + preparation or during an authorization lookup is covered by unit tests. + + + Have the issuer propose a monthly subscription for 5,000 sats with no payment deadline and an explicit regtest P2WPKH endpoint + Accept the proposal in Bitkit and open its unpaid period to the send confirmation screen without confirming payment + Have the issuer cancel the subscription and keep Bitkit foregrounded until the payer's request state or logs confirm receipt of the cancellation + If the send confirmation is still open, try to confirm payment and verify it is refused + Verify no transaction to the request's receiving address was broadcast and no payment proof was created for the period + Return to Subscriptions and verify the canceled subscription offers no action to pay its unpaid period + + diff --git a/journeys/subscriptions/create-and-propose.xml b/journeys/subscriptions/create-and-propose.xml index 382dda8e6f..5c590f8dc9 100644 --- a/journeys/subscriptions/create-and-propose.xml +++ b/journeys/subscriptions/create-and-propose.xml @@ -2,10 +2,11 @@ Creator side. Builds a subscription from the Subscriptions overview and proposes it to a saved, linked contact, ending on the sent confirmation and the new row in the CREATED section. Requires - the two linked Bitkit instances described in README.md. + the two linked Bitkit instances described in README.md. Also checks rejection of a proposal + that exceeds the transport message limit before sending a shorter proposal. - Launch the E2E Bitkit app with Paykit UI enabled and the payer instance saved as a contact linked on receiver path "bitkit/wallet" + Launch the E2E Bitkit app with Paykit UI enabled and the payer instance saved as a contact linked as identities Open the drawer menu and tap Subscriptions Verify the Subscriptions screen (testTag "SubscriptionsScreen") appears with the Overview tab (testTag "Tab-overview") selected Tap Create (testTag "SubscriptionCreate") @@ -13,14 +14,17 @@ Tap the amount pencil, enter "5000" on the keypad, and tap Continue Verify the amount reads 5,000 sats Type "Journey Sub" into the subscription name field (testTag "SubscriptionName") - Type a short description into the description field (testTag "SubscriptionDescription"), so the payer's detail screen offers More Info later + Enter 600 ASCII letters in the description field (testTag "SubscriptionDescription") Verify the frequency selector has "Monthly" selected Tap Choose Recipient (testTag "SubscriptionChooseRecipient"), now enabled Verify the recipient step lists the linked contact under CONTACTS and that Propose Subscription (testTag "SubscriptionPropose") is disabled Tap the linked contact row and verify it shows the selected checkmark Tap Propose Subscription (testTag "SubscriptionPropose") + Verify an error asks to shorten the subscription content and no sent confirmation appears + Tap Back to return to the subscription details and replace the description with "Monthly support" + Tap Choose Recipient, select the linked contact if needed, and tap Propose Subscription again Verify the sent confirmation (testTag "SubscriptionProposalSent") shows headline "Sent Proposal", the recipient contact, and the subscription name with 5,000 sats Tap OK - Verify a row for "Journey Sub" appears in the CREATED section with subtitle "Proposal sent" + Verify a row for "Journey Sub" appears in the CREATED section with subtitle "Proposal sent", not "Proposal queued" diff --git a/journeys/subscriptions/fixed-onchain-destination.xml b/journeys/subscriptions/fixed-onchain-destination.xml new file mode 100644 index 0000000000..1f44e720af --- /dev/null +++ b/journeys/subscriptions/fixed-onchain-destination.xml @@ -0,0 +1,17 @@ + + + Requires a linked regtest issuer that can propose a recurring Payment Request with an explicit + fixed P2WPKH endpoint, and a payer funded for two periods. Confirms that address reuse does not + block an unpaid period and that a paid period cannot be paid again. + + + Have the issuer propose a monthly subscription for 5,000 sats with no payment deadline, binding its regtest P2WPKH address in the request endpoints + Open the proposal in Bitkit, accept it, and complete the first on-chain payment + Wait for the first payment proof to appear in the shared request state and verify the first period appears as paid in subscription details + Advance the subscription clock offset to the next monthly period in Dev Settings, then refresh Subscriptions + Open the next unpaid period and verify the send review uses the same fixed receiving address + Complete the second payment and wait for its proof to appear in the shared request state + Refresh Subscriptions and verify both periods appear as paid with no action to pay either period again + Reset the subscription clock offset in Dev Settings + + diff --git a/journeys/subscriptions/review-and-subscribe.xml b/journeys/subscriptions/review-and-subscribe.xml index 496d56e946..6c7a50352e 100644 --- a/journeys/subscriptions/review-and-subscribe.xml +++ b/journeys/subscriptions/review-and-subscribe.xml @@ -18,6 +18,7 @@ If the first period was due on acceptance: complete the send flow that opens, then verify the Subscribed confirmation appears If no payment was due: verify the Subscribed confirmation appears directly, with no send flow Tap Close on the Subscribed confirmation + If a payment was made: verify its billing period is no longer offered with Pay or Dismiss, no Payment Requests sheet opens for it, and the home payment-request bell is absent when no other requests are pending Verify the sheet dismisses and "Journey Sub" moves from PROPOSALS into the ACTIVE section Tap the "Journey Sub" row and verify the detail shows STATUS "Active" and a RENEWS date one month ahead, with no year Only when a payment was made: verify the PAYMENTS section lists it titled "Journey Sub" with the payment date as its subtitle. With no payment yet, the section is absent. diff --git a/settings.gradle.kts b/settings.gradle.kts index 9790eeb9eb..b1aede9e15 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -36,7 +36,9 @@ plugins { dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { - mavenLocal() + mavenLocal { + content { excludeModule("com.synonym", "paykit-android") } + } google() mavenCentral() maven {