From dbb4b406c3e4d2910452378f6164ea069a774b01 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 30 Sep 2026 22:53:41 -0500 Subject: [PATCH 01/77] feat: share paykit state across apps --- .../CreatePaymentRequestScreenTest.kt | 2 - .../PaymentRequestsScreenTest.kt | 2 - .../CreateSubscriptionScreenTest.kt | 5 +- .../to/bitkit/data/PrivatePaykitStores.kt | 6 +- .../java/to/bitkit/data/keychain/Keychain.kt | 4 +- .../bitkit/models/PaykitPaymentStateBackup.kt | 3 + .../to/bitkit/models/PubkyAuthClaimCodec.kt | 47 + .../java/to/bitkit/models/PubkyAuthRequest.kt | 54 +- .../to/bitkit/repositories/ActivityRepo.kt | 6 + .../ContactPaymentSettingsRepo.kt | 13 +- .../repositories/PaykitPaymentProofRepo.kt | 33 +- .../repositories/PaykitPaymentRequestRepo.kt | 94 +- .../PaykitReceivedPaymentContacts.kt | 90 ++ .../bitkit/repositories/PaykitSubscription.kt | 7 +- ...PaykitSubscriptionNotificationScheduler.kt | 10 +- .../PrivatePaykitAddressReservationRepo.kt | 43 +- .../PrivatePaykitContactResolver.kt | 21 +- .../repositories/PrivatePaykitModels.kt | 26 +- .../bitkit/repositories/PrivatePaykitRepo.kt | 541 ++++------- .../java/to/bitkit/repositories/PubkyRepo.kt | 43 +- .../bitkit/repositories/PublicPaykitRepo.kt | 33 +- .../repositories/WatchOnlyAccountRepo.kt | 3 +- .../java/to/bitkit/services/CoreService.kt | 62 +- .../to/bitkit/services/PaykitSdkService.kt | 557 ++++-------- .../java/to/bitkit/services/PubkyService.kt | 19 +- app/src/main/java/to/bitkit/ui/ContentView.kt | 7 - .../main/java/to/bitkit/ui/MainActivity.kt | 2 - .../main/java/to/bitkit/ui/Notifications.kt | 1 - .../screens/contacts/AddContactViewModel.kt | 6 +- .../ui/screens/contacts/ContactsViewModel.kt | 6 +- .../CreatePaymentRequestScreen.kt | 4 +- .../paymentrequests/PaymentRequestsScreen.kt | 3 +- .../screens/profile/PubkyAuthApprovalSheet.kt | 26 +- .../profile/PubkyAuthApprovalViewModel.kt | 38 +- .../screens/wallets/receive/ReceiveSheet.kt | 6 +- ....kt => RefreshContactPaykitLinkUseCase.kt} | 9 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 100 +- .../to/bitkit/viewmodels/SettingsViewModel.kt | 14 +- app/src/main/res/values/strings.xml | 2 + .../models/PaykitPaymentStateBackupTest.kt | 2 +- .../to/bitkit/models/PubkyAuthRequestTest.kt | 150 ++- .../bitkit/repositories/ActivityRepoTest.kt | 17 + .../ContactPaymentSettingsRepoTest.kt | 14 +- .../repositories/PaykitIssuerInteropTest.kt | 6 +- .../PaykitPaymentProofRepoTest.kt | 138 +-- ...ykitPaymentRequestPresentationStoreTest.kt | 12 +- ...aykitPaymentRequestRepoSubscriptionTest.kt | 134 +-- .../PaykitPaymentRequestRepoTest.kt | 385 ++++---- .../PaykitReceivedPaymentContactsTest.kt | 224 +++++ ...itSubscriptionNotificationSchedulerTest.kt | 6 +- .../repositories/PaykitSubscriptionTest.kt | 4 +- ...PrivatePaykitAddressReservationRepoTest.kt | 17 - .../PrivatePaykitContactResolverTest.kt | 56 +- .../repositories/PrivatePaykitRepoTest.kt | 856 +++++------------- .../to/bitkit/repositories/PubkyRepoTest.kt | 117 +-- .../repositories/PublicPaykitRepoTest.kt | 73 +- .../WatchOnlyAccountClaimCodecTest.kt | 102 ++- .../repositories/WatchOnlyAccountRepoTest.kt | 41 + .../ActivityServicePaykitContactsTest.kt | 165 ++++ .../bitkit/services/PaykitSdkServiceTest.kt | 337 +++---- .../services/PaykitSdkServiceWipeTest.kt | 128 +-- .../contacts/AddContactViewModelTest.kt | 10 +- .../contacts/ContactDetailViewModelTest.kt | 2 +- .../PaymentRequestPresentationTest.kt | 1 - .../profile/PubkyAuthApprovalViewModelTest.kt | 198 +++- .../subscriptions/SubscriptionsScreenTest.kt | 1 - ...=> RefreshContactPaykitLinkUseCaseTest.kt} | 23 +- .../viewmodels/AppViewModelSendFlowTest.kt | 322 ++++--- .../viewmodels/SettingsViewModelTest.kt | 9 +- .../resources/bitkit-combined-claim-v1.json | 10 + .../next/paykit-shared-runtime.changed.md | 1 + docs/paykit-issuer-interoperability.md | 2 +- docs/pubky-auth-companion-claims.md | 60 ++ docs/pubky.md | 15 +- docs/watch-only-account-claim-v1.md | 52 -- gradle/libs.versions.toml | 2 +- journeys/README.md | 2 +- journeys/payment-requests/README.md | 8 +- .../contact-request-or-pay.xml | 2 +- .../delete-and-readd-contact.xml | 2 +- .../issuer-interoperability.xml | 4 +- .../payment-deadline-history.xml | 2 +- journeys/payment-requests/request-summary.xml | 2 +- journeys/pubky-marketplace/README.md | 105 +-- .../paykit-only-approval.xml | 19 + .../pubky-marketplace/paykit-reconnect.xml | 18 + journeys/pubky-marketplace/wallet-leg.xml | 10 +- journeys/subscriptions/README.md | 2 +- journeys/subscriptions/create-and-propose.xml | 2 +- settings.gradle.kts | 4 +- 90 files changed, 3077 insertions(+), 2745 deletions(-) create mode 100644 app/src/main/java/to/bitkit/models/PubkyAuthClaimCodec.kt create mode 100644 app/src/main/java/to/bitkit/repositories/PaykitReceivedPaymentContacts.kt rename app/src/main/java/to/bitkit/usecases/{RefreshContactPaykitReceiversUseCase.kt => RefreshContactPaykitLinkUseCase.kt} (75%) create mode 100644 app/src/test/java/to/bitkit/repositories/PaykitReceivedPaymentContactsTest.kt create mode 100644 app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt rename app/src/test/java/to/bitkit/usecases/{RefreshContactPaykitReceiversUseCaseTest.kt => RefreshContactPaykitLinkUseCaseTest.kt} (68%) create mode 100644 app/src/test/resources/bitkit-combined-claim-v1.json create mode 100644 changelog.d/next/paykit-shared-runtime.changed.md create mode 100644 docs/pubky-auth-companion-claims.md delete mode 100644 docs/watch-only-account-claim-v1.md create mode 100644 journeys/pubky-marketplace/paykit-only-approval.xml create mode 100644 journeys/pubky-marketplace/paykit-reconnect.xml 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..ef1fca7fc3 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 @@ -163,13 +163,11 @@ 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, 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..c8ede596a7 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 @@ -289,7 +289,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 +300,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/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..481fa0af58 100644 --- a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt +++ b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt @@ -232,8 +232,8 @@ 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_PRESENTED_PAYMENT_REQUESTS, diff --git a/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt b/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt index f5301f8419..b9429d238a 100644 --- a/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt +++ b/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt @@ -41,6 +41,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 +56,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 +72,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..20ee50dbff 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) diff --git a/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt b/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt index ec02d0eded..e21e6faa0d 100644 --- a/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt @@ -67,6 +67,10 @@ class ContactPaymentSettingsRepo @Inject constructor( ) } }.onFailure(error::addSuppressed) + if (!previous.sharesPrivatePaykitEndpoints) { + privatePaykitRepo.disableSharingAndPruneUnsavedContactState(contacts) + .onFailure(error::addSuppressed) + } publicPaykitRepo.syncPublishedEndpoints(publish = previous.sharesPublicPaykitEndpoints) .onFailure { error.addSuppressed(it) @@ -76,9 +80,6 @@ class ContactPaymentSettingsRepo @Inject constructor( privatePaykitRepo.enableSharingAndPrepareSavedContacts( publicKeys = contacts, ).onFailure(error::addSuppressed) - } else { - privatePaykitRepo.disableSharingAndPruneUnsavedContactState(contacts) - .onFailure(error::addSuppressed) } } @@ -100,12 +101,12 @@ 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) + .onFailure { publicCleanupError = it } + publicCleanupError?.let { error -> runSuspendCatching { settingsStore.update { settings -> diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index 7520856c17..a46552f1e4 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -4,6 +4,7 @@ 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.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -59,6 +60,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, @@ -136,11 +138,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 +160,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 +181,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,6 +266,7 @@ 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) @@ -270,7 +275,7 @@ class PaykitPaymentProofRepo @Inject constructor( val identity = currentIdentity() ?: return@withContext val fallbackProof = runSuspendCatching { - pendingProof(request, paymentEndpointIdentifier, PaykitPaymentProofKind.Onchain) + pendingProof(request, paymentEndpointIdentifier, paymentAppId, PaykitPaymentProofKind.Onchain) }.getOrNull() operationMutex.withLock { val completion = runSuspendCatching { @@ -289,7 +294,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(), @@ -367,8 +372,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 +400,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) } @@ -508,7 +514,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 +522,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, ) @@ -622,9 +628,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 +640,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/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index bf172a52d4..30d33dbd28 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -11,6 +11,7 @@ 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.delay @@ -46,8 +47,8 @@ import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.satsToMsat import to.bitkit.services.PaykitPaymentRequestProposalTerms import to.bitkit.services.PaykitPaymentRequestRecurrenceTerms -import to.bitkit.services.PaykitReceiverPaths 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 @@ -66,14 +67,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, @@ -110,7 +109,6 @@ data class PaykitPaymentRequest( get() = PaykitPaymentRequestId( paymentRequestId, counterparty, - counterpartyReceiverPath, billingPeriod?.startsAt?.toString(), ) @@ -131,8 +129,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 { @@ -198,7 +195,6 @@ enum class PaykitPaymentRequestDirection { Incoming, Outgoing } data class PaykitPaymentRequestTarget( val publicKey: String, - val receiverPath: String, ) data class PaykitPaymentRequestDraft( @@ -230,7 +226,7 @@ data class PaykitSubscriptionCreation( private data class PaykitPaymentRequestTargetContext( val savedPublicKeys: List, - val linkedReceiverPaths: Map>, + val linkedPublicKeys: Set, ) private data class PaykitPaymentRequestTargetDiscovery( @@ -295,6 +291,19 @@ class PaykitPaymentRequestRepo @Inject constructor( @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 + + private data class ReceivedContactsSnapshot( + val generation: Long, + val identity: String, + val contacts: PaykitReceivedPaymentContacts, + ) + @Volatile private var presentedRequestIds = emptySet() @@ -563,7 +572,6 @@ class PaykitPaymentRequestRepo @Inject constructor( ) val record = paykitSdkService.proposePaymentRequest( counterparty = target.publicKey, - counterpartyReceiverPath = target.receiverPath, proposal = proposal, expectedIdentity = expectedIdentity, ) @@ -637,7 +645,6 @@ class PaykitPaymentRequestRepo @Inject constructor( PaykitSubscriptionProposal.validate(proposal) val record = paykitSdkService.proposePaymentRequest( counterparty = target.publicKey, - counterpartyReceiverPath = target.receiverPath, proposal = proposal, expectedIdentity = expectedIdentity, ) @@ -732,7 +739,6 @@ class PaykitPaymentRequestRepo @Inject constructor( val messageId = record.proposalOutboundMessageId ?: return false return reports.any { PubkyPublicKeyFormat.matches(it.counterparty, record.counterparty) && - it.counterpartyReceiverPath == record.counterpartyReceiverPath && messageId in it.report?.sent.orEmpty() } } @@ -772,8 +778,7 @@ class PaykitPaymentRequestRepo @Inject constructor( 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 @@ -781,6 +786,13 @@ class PaykitPaymentRequestRepo @Inject constructor( } } + suspend fun claimForPayment(request: PaykitPaymentRequest): Result = withContext(ioDispatcher) { + runSuspendCatching { + paykitSdkService.claimPaymentRequestForExecution(request.counterparty, request.paymentRequestId) + Unit + } + } + suspend fun accept(request: PaykitPaymentRequest): Result = withContext(ioDispatcher) { runSuspendCatching { if (!request.requiresAcceptance) { @@ -795,7 +807,6 @@ class PaykitPaymentRequestRepo @Inject constructor( ensurePaymentAllowed(it).getOrThrow() paykitSdkService.acceptPaymentRequest( counterparty = it.counterparty, - counterpartyReceiverPath = it.counterpartyReceiverPath, paymentRequestId = it.paymentRequestId, ) }.getOrThrow() @@ -811,7 +822,6 @@ class PaykitPaymentRequestRepo @Inject constructor( ) { paykitSdkService.rejectPaymentRequest( counterparty = it.counterparty, - counterpartyReceiverPath = it.counterpartyReceiverPath, paymentRequestId = it.paymentRequestId, ) }.onFailure { @@ -831,7 +841,6 @@ class PaykitPaymentRequestRepo @Inject constructor( return updateRequest(request, PaymentRequestLifecycleState.CANCELED) { paykitSdkService.cancelPaymentRequest( counterparty = it.counterparty, - counterpartyReceiverPath = it.counterpartyReceiverPath, paymentRequestId = it.paymentRequestId, ) } @@ -849,7 +858,6 @@ class PaykitPaymentRequestRepo @Inject constructor( ?: throw PaykitPaymentRequestError.RequestUnavailable val record = paykitSdkService.acceptPaymentRequest( current.counterparty, - current.counterpartyReceiverPath, current.paymentRequestId, ) processPendingMessages() @@ -878,7 +886,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 = @@ -914,12 +922,14 @@ class PaykitPaymentRequestRepo @Inject constructor( paykitSdkService.receivePrivateMessagesFromLinkedPeers().also(::logIntakeFailures) val now = clock.now() val subscriptionNow = subscriptionClock.now() - val records = paykitSdkService.paymentRequests() + receivedContacts = null + val allRecords = paykitSdkService.allPaymentRequests(expectedIdentity) + val contacts = PaykitReceivedPaymentContacts.from(allRecords, Env.network) + val records = allRecords.filter(::isBitkitPaymentRequest) val blockedPeers = paykitSdkService.linkedPeers().filter { it.state == LinkedPeerState.BLOCKED } 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 @@ -934,8 +944,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() @@ -1018,6 +1027,7 @@ class PaykitPaymentRequestRepo @Inject constructor( val history = (recurringHistory + oneTimeHistory) .sortedByDescending { it.createdAt } if (!isCurrentState(generation, expectedIdentity) || expectedIdentity == null) return + receivedContacts = ReceivedContactsSnapshot(generation, expectedIdentity, contacts) val subscriptionStateChanged = subscriptionAcceptedAt != updatedSubscriptionAcceptedAt || dismissedSubscriptionPaymentIds != updatedDismissedPaymentIds @@ -1061,24 +1071,19 @@ class PaykitPaymentRequestRepo @Inject constructor( val identityStatus = paykitSdkService.identityStatus() 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() .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, ) } @@ -1089,9 +1094,9 @@ class PaykitPaymentRequestRepo @Inject constructor( var isComplete = true val failedPublicKeys = mutableSetOf() val targets = context.savedPublicKeys.mapNotNull { publicKey -> - val linked = context.linkedReceiverPaths[publicKey] ?: return@mapNotNull null + if (publicKey !in context.linkedPublicKeys) return@mapNotNull null val lookup = withTimeoutOrNull(TARGET_DISCOVERY_TIMEOUT) { - runSuspendCatching { paykitSdkService.paymentRequestReceiverPaths(publicKey) } + runSuspendCatching { paykitSdkService.canReceivePaymentRequests(publicKey) } } if (lookup == null) { isComplete = false @@ -1100,7 +1105,7 @@ class PaykitPaymentRequestRepo @Inject constructor( "Timed out inspecting payment request support for '${PubkyPublicKeyFormat.redacted(publicKey)}'", context = TAG, ) - return@mapNotNull previousTargets[publicKey]?.takeIf { it.receiverPath in linked } + return@mapNotNull previousTargets[publicKey] } val capable = lookup .onFailure { @@ -1113,14 +1118,13 @@ class PaykitPaymentRequestRepo @Inject constructor( ) } .getOrElse { - return@mapNotNull previousTargets[publicKey]?.takeIf { it.receiverPath in linked } + return@mapNotNull previousTargets[publicKey] } - val receiverPath = PaykitReceiverPaths.ordered.firstOrNull { it in linked && it in capable } - ?: run { - isComplete = false - return@mapNotNull null - } - PaykitPaymentRequestTarget(publicKey, receiverPath) + if (!capable) { + isComplete = false + return@mapNotNull null + } + PaykitPaymentRequestTarget(publicKey) } return PaykitPaymentRequestTargetDiscovery( targets = targets, @@ -1331,6 +1335,7 @@ class PaykitPaymentRequestRepo @Inject constructor( } private fun clearStateLocked() { + receivedContacts = null expirationJob?.cancel() expirationJob = null _pendingRequests.update { emptyList() } @@ -1481,7 +1486,6 @@ private fun PaymentRequestRecord.toPaykitPaymentRequest( ) = PaykitPaymentRequest( paymentRequestId = paymentRequestId, counterparty = counterparty, - counterpartyReceiverPath = counterpartyReceiverPath, amountValue = parsedTerms.terms.amount.value, amountSats = parsedTerms.amountSats, note = parsedTerms.terms.metadata.note(), @@ -1533,14 +1537,12 @@ private fun PaymentRequestRecord.toCreatedPaykitPaymentRequest( 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..1487aeb5e6 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PaykitReceivedPaymentContacts.kt @@ -0,0 +1,90 @@ +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 + +internal class PaykitReceivedPaymentContacts private constructor( + private val addresses: Map>, + private val paymentHashes: Map>, +) { + fun contactsForAddresses(values: Collection): Set = + values.flatMapTo(mutableSetOf()) { addresses[it].orEmpty() } + + fun contactsForPaymentHash(value: String): Set = paymentHashes[value.lowercase()].orEmpty() + + 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) + // Only immutable request destinations identify a payer; proofs and current lists do not. + val acceptedEndpoints = terms.paymentEndpoints.orEmpty() + .filterKeys(terms.acceptedPaymentEndpointIdentifiers::contains) + for ((identifier, payload) in acceptedEndpoints) { + val endpoint = PublicPaykitRepo.parseEndpoint(identifier, payload, network) ?: continue + val value = 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 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 + } + } +} diff --git a/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt b/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt index 34dcedb4ff..f29fc6ab01 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, @@ -328,7 +324,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/PrivatePaykitAddressReservationRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt index 4b889a8ee6..e57115f1fc 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( @@ -98,10 +83,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() @@ -170,7 +154,7 @@ class PrivatePaykitAddressReservationRepo @Inject constructor( assignments.firstOrNull { (_, assignment) -> assignment.addressType == addressType && (assignment.address == address || resolvedAddress(assignment).getOrNull() == address) - }?.first?.publicKeyFromAssignmentKey() + }?.first } suspend fun currentContactPublicKeyForReservedAddress(address: String): String? = withContext(ioDispatcher) { @@ -180,7 +164,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 +175,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() } @@ -211,18 +195,18 @@ class PrivatePaykitAddressReservationRepo @Inject constructor( val normalizedKey = normalizedPublicKey(publicKey) locked { current -> val hadAssignment = current.contactAssignments.keys.any { - it.publicKeyFromAssignmentKey() == normalizedKey + it == normalizedKey } val hadHistory = current.contactAssignmentHistory.keys.any { - it.publicKeyFromAssignmentKey() == normalizedKey + it == normalizedKey } if (!hadAssignment && !hadHistory) return@locked val next = current.copy( contactAssignments = current.contactAssignments.filterKeys { - it.publicKeyFromAssignmentKey() != normalizedKey + it != normalizedKey }, contactAssignmentHistory = current.contactAssignmentHistory.filterKeys { - it.publicKeyFromAssignmentKey() != normalizedKey + it != normalizedKey }, ) ledger = next @@ -236,10 +220,10 @@ class PrivatePaykitAddressReservationRepo @Inject constructor( locked { current -> val next = current.copy( contactAssignments = current.contactAssignments.filterKeys { - it.publicKeyFromAssignmentKey() in savedKeys + it in savedKeys }, contactAssignmentHistory = current.contactAssignmentHistory.filterKeys { - it.publicKeyFromAssignmentKey() in savedKeys + it in savedKeys }, ) if (next == current) return@locked @@ -392,11 +376,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..b302089f40 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,34 @@ 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 + 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? = withContext(ioDispatcher) { - addresses.firstNotNullOfOrNull { + val shared = receivedPaymentContacts + val contacts = addresses.mapNotNull { addressReservationRepo.get().contactPublicKeyForReservedAddress(it) } + if (receivedPaymentContacts !== shared) return@withContext null + (contacts + shared.contactsForAddresses(addresses)) + .mapNotNull(PubkyPublicKeyFormat::normalized).distinct().singleOrNull() } } 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 550f2e90a9..57cba64f4e 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -2,6 +2,7 @@ package to.bitkit.repositories import com.synonym.bitkitcore.Scanner 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 @@ -43,7 +44,6 @@ import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.services.CoreService import to.bitkit.services.PaykitPreparedPrivateContactPayment import to.bitkit.services.PaykitPrivateContactPaymentResolution -import to.bitkit.services.PaykitReceiverPaths import to.bitkit.services.PaykitSdkService import to.bitkit.services.PubkyService import to.bitkit.utils.Logger @@ -104,7 +104,7 @@ class PrivatePaykitRepo @Inject constructor( private val knownSavedContactKeys = mutableSetOf() 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) @@ -116,12 +116,12 @@ class PrivatePaykitRepo @Inject constructor( private data class PrivatePublicationPreparation( val updates: List, - val linkRetryKeys: List, + val linkRetryKeys: List, val firstError: Throwable?, ) private data class PrivateEndpointCleanupPreparation( - val clearedRetryKeys: List, + val clearedRetryKeys: List, val failedPublicKeys: Set, val firstError: Throwable?, ) @@ -131,20 +131,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) @@ -270,6 +260,9 @@ class PrivatePaykitRepo @Inject constructor( removePublishedEndpoints().getOrThrow() clearUnsavedContactState(savedPublicKeys).getOrThrow() updateContactSharingCleanupPending(false) + publicPaykitRepo.syncPaykitApp().onFailure { + updateContactSharingCleanupPending(true) + }.getOrThrow() } retryPendingDeletedContactEndpointRemoval(savedPublicKeys).getOrThrow() }.onFailure { @@ -309,6 +302,7 @@ class PrivatePaykitRepo @Inject constructor( suspend fun disableSharingAndPruneUnsavedContactState(savedPublicKeys: Collection): Result = withContext(serializedDispatcher) { runSuspendCatching { + updateContactSharingCleanupPending(true) val removalError = removePublishedEndpoints().exceptionOrNull() if (removalError != null) { updateContactSharingCleanupPending(true) @@ -322,6 +316,9 @@ class PrivatePaykitRepo @Inject constructor( clearUnsavedContactState(savedPublicKeys).getOrThrow() updateContactSharingCleanupPending(false) + publicPaykitRepo.syncPaykitApp().onFailure { + updateContactSharingCleanupPending(true) + }.getOrThrow() } } @@ -334,14 +331,15 @@ 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() + updateContactSharingCleanupPending(false) + publicPaykitRepo.syncPaykitApp().getOrThrow() + }.onFailure { + updateContactSharingCleanupPending(true) + Logger.warn("Failed to remove private Paykit endpoints during '$context'", it, context = TAG) + } } suspend fun closeAndClear(): Result = withContext(serializedDispatcher) { @@ -395,19 +393,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, ) @@ -422,15 +419,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, ) @@ -478,7 +475,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) } } @@ -516,7 +513,7 @@ 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 } } @@ -538,9 +535,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(), ) ) @@ -550,17 +545,17 @@ class PrivatePaykitRepo @Inject constructor( suspend fun restoreBackup(backup: String?): Result = withContext(serializedDispatcher) { runSuspendCatching { + val decoded = backup?.let { json.decodeFromString(it) } + decoded?.let { paykitSdkService.retainRecoveryBackup(it.sdkState) } clearPendingMessageDrainRetries() state = PrivatePaykitState() knownSavedContactKeys.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) @@ -574,44 +569,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 @@ -649,22 +638,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)}'", @@ -676,36 +671,42 @@ 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 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"} " + @@ -724,27 +725,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)}'", @@ -755,10 +754,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( @@ -839,113 +837,31 @@ class PrivatePaykitRepo @Inject constructor( forceRefreshLightning: Boolean, ): 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) } + val linkRetryKeys = mutableListOf() + for (publicKey in publicKeys.distinct()) { + val link = runSuspendCatching { paykitSdkService.ensureLinkWithPeer(publicKey) } + .onFailure { Logger.warn("Failed to prepare private Paykit link during '$reason'", it, context = TAG) } + if (link.exceptionOrNull() is PaykitException.NotFound) continue + linkRetryKeys += publicKey + runSuspendCatching { privatePaymentListUpdate(publicKey, forceRefreshLightning) } + .onSuccess { updates += it } .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) - 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(), - ) - } - - publicationReceiverPaths.forEach { receiverPath -> - runSuspendCatching { - privatePaymentListUpdate(publicKey, receiverPath, forceRefreshLightning) - }.onSuccess { - updates += it - hasPublicationUpdate = true - }.onFailure { firstError = firstError ?: it logPrivatePublicationPreparationFailure(publicKey, reason, it) } - } } - - if (!hasPublicationUpdate) firstError = firstError ?: receiverPathSelectionError - return PrivatePublicationPreparation(updates, linkRetryKeys.distinct(), firstError) + return PrivatePublicationPreparation(updates, linkRetryKeys, firstError) } private suspend fun prepareRelevantPrivateLinksIfAvailable(publicKeys: Collection, reason: String) { if (!hasPrivatePaymentAccessForCurrentProfile()) return - - val retryKeys = mutableListOf() - for (publicKey in publicKeys) { - val receiverPaths = runSuspendCatching { receiverPathsForSavedContact(publicKey) } - .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) - 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) } - } - - drainAndSchedulePrivateLinkRetries(reason, retryKeys.distinct()) - } - - private suspend fun preparePrivateLinks( - 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) - } - - return retryKeys + drainAndSchedulePrivateLinkRetries(reason, publicKeys.distinct()) } private suspend fun drainAndSchedulePrivateLinkRetries( reason: String, - retryKeys: Collection, + retryKeys: Collection, ) { if (retryKeys.isEmpty()) return @@ -958,15 +874,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) }, ) } @@ -989,18 +903,6 @@ 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, @@ -1008,14 +910,14 @@ class PrivatePaykitRepo @Inject constructor( report.failedToQueue.forEach { Logger.warn( "Failed to queue private Paykit endpoints for '${redacted(it.counterparty)}' during '$reason': " + - (it.error ?: "unknown error"), + (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, + it.error.redactedContext(), context = TAG, ) } @@ -1023,14 +925,13 @@ class PrivatePaykitRepo @Inject constructor( 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 } @@ -1045,32 +946,29 @@ class PrivatePaykitRepo @Inject constructor( 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(), + advancingLinksFor: List = emptyList(), ) { runSuspendCatching { advancingLinksFor.distinct().forEach { retryKey -> runSuspendCatching { paykitSdkService.ensureLinkWithPeer( - counterparty = retryKey.publicKey, - receiverPath = retryKey.receiverPath, + counterparty = retryKey, ) }.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, ) @@ -1087,7 +985,7 @@ class PrivatePaykitRepo @Inject constructor( private fun schedulePendingPrivateMessageDrainRetries( reason: String, - retryKeys: Collection, + retryKeys: Collection, ) { val retryKeys = retryKeys.toSet() if (retryKeys.isEmpty()) return @@ -1136,7 +1034,7 @@ class PrivatePaykitRepo @Inject constructor( } } - private suspend fun updatePendingMessageDrainRetryKeys(retryKeys: Collection) { + private suspend fun updatePendingMessageDrainRetryKeys(retryKeys: Collection) { val remainingKeys = pendingPrivateMessageDrainKeys(retryKeys) synchronized(pendingMessageDrainRetryLock) { pendingMessageDrainRetryKeys.removeAll(retryKeys.toSet()) @@ -1145,8 +1043,8 @@ class PrivatePaykitRepo @Inject constructor( } private suspend fun pendingPrivateMessageDrainKeys( - retryKeys: Collection, - ): Set { + retryKeys: Collection, + ): Set { val retryKeys = retryKeys.toSet() if (retryKeys.isEmpty()) return emptySet() @@ -1157,7 +1055,7 @@ class PrivatePaykitRepo @Inject constructor( } .mapNotNull { peer -> normalizedPublicKey(peer.counterparty)?.let { publicKey -> - PrivateMessageDrainRetryKey(publicKey, peer.counterpartyReceiverPath) to peer.state + publicKey to peer.state } } .toMap() @@ -1166,11 +1064,7 @@ class PrivatePaykitRepo @Inject constructor( 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 -> @@ -1191,21 +1085,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) } @@ -1215,7 +1106,6 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun buildLocalEndpoints( publicKey: String, - receiverPath: String, forceRefreshLightning: Boolean = false, ): Result> = withContext(serializedDispatcher) { runSuspendCatching { @@ -1224,7 +1114,6 @@ class PrivatePaykitRepo @Inject constructor( if (PublicPaykitRepo.isOnchainPaymentOptionEnabled(settings)) { val reservedAddress = addressReservationRepo.currentOrRotatedAddress( publicKey, - receiverPath, ).getOrThrow() walletRepo.refreshReusableReceiveAddressIfReserved().getOrThrow() endpoints += Endpoint( @@ -1237,7 +1126,6 @@ class PrivatePaykitRepo @Inject constructor( if (PublicPaykitRepo.isLightningPaymentOptionEnabled(settings) && lightningRepo.canReceive()) { currentOrRotatedInvoice( publicKey, - receiverPath, forceRefresh = forceRefreshLightning, ).onSuccess { invoice -> endpoints += Endpoint( @@ -1260,18 +1148,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 @@ -1284,7 +1171,7 @@ class PrivatePaykitRepo @Inject constructor( paymentHash = decoded.paymentHash.toHex(), expiresAt = expiresAt, ) - setLocalInvoice(publicKey, receiverPath, invoice) + setLocalInvoice(publicKey, invoice) persistState() invoice } @@ -1292,9 +1179,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 && @@ -1307,31 +1193,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, @@ -1339,12 +1222,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) { @@ -1354,10 +1237,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 { + val keys = ( + knownSavedContactKeys + ensureState().contacts.keys + pendingDeletedContactCleanupPublicKeys() + ).distinct() + removePublishedEndpointsLocked(keys).getOrThrow() + } } } @@ -1379,9 +1265,9 @@ class PrivatePaykitRepo @Inject constructor( if (normalizedKeys.isEmpty()) return@runSuspendCatching ensureState() + publicPaykitRepo.syncPaykitApp(privateSharingEnabled = true).getOrThrow() val cleanupStateByPublicKey = normalizedKeys.associateWith(::publishedEndpointCleanupState) - val linkedReceiverPathsSnapshot = linkedReceiverPathsSnapshot("private endpoint cleanup") - val preparation = clearPrivatePaymentLists(normalizedKeys, linkedReceiverPathsSnapshot) + val preparation = clearPrivatePaymentLists(normalizedKeys) val failedPublicKeys = preparation.failedPublicKeys.toMutableSet() var firstError = preparation.firstError @@ -1392,7 +1278,7 @@ class PrivatePaykitRepo @Inject constructor( ) val pendingRetryKeys = pendingPrivateMessageDrainKeys(preparation.clearedRetryKeys) if (pendingRetryKeys.isNotEmpty()) { - failedPublicKeys += pendingRetryKeys.map { it.publicKey } + failedPublicKeys += pendingRetryKeys firstError = firstError ?: PrivatePaykitError.PrivateUnavailable } } @@ -1410,41 +1296,39 @@ class PrivatePaykitRepo @Inject constructor( clearPublishedEndpointCache(normalizedKeys.filterNot { it in failedPublicKeys }) firstError?.let { throw it } + publicPaykitRepo.syncPaykitApp().getOrThrow() } private suspend fun clearPrivatePaymentLists( publicKeys: Collection, - linkedReceiverPathsSnapshot: LinkedReceiverPathsSnapshot, ): PrivateEndpointCleanupPreparation { - val failedPublicKeys = if (linkedReceiverPathsSnapshot.error == null) { - mutableSetOf() - } else { - publicKeys.toMutableSet() - } - val clearedRetryKeys = mutableListOf() - var firstError = linkedReceiverPathsSnapshot.error - + val linkedPublicKeys = paykitSdkService.linkedPeers() + .filter { it.state != LinkedPeerState.NOT_LINKED } + .mapNotNull { normalizedPublicKey(it.counterparty) } + .toSet() + val failedPublicKeys = mutableSetOf() + val clearedRetryKeys = mutableListOf() + var firstError: Throwable? = 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 + if (publicKey !in linkedPublicKeys && + state?.contacts?.get(publicKey)?.hasPublishedPrivatePaymentList != true + ) { + return@forEach + } + runSuspendCatching { + val report = paykitSdkService.clearPrivatePaymentList(publicKey) + ?: return@runSuspendCatching false + if (report.failedToQueue.isNotEmpty() || report.failedToDeliver.isNotEmpty()) { + throw PrivatePaykitError.PrivateUnavailable } + true + }.onSuccess { + if (it) clearedRetryKeys += publicKey + }.onFailure { + failedPublicKeys += publicKey + firstError = firstError ?: it } } - return PrivateEndpointCleanupPreparation(clearedRetryKeys, failedPublicKeys, firstError) } @@ -1454,8 +1338,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) } @@ -1485,42 +1369,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): List { - val record = paykitSdkService.contactRecord(publicKey) - val savedPaths = supportedReceiverPaths(record?.receiverPaths.orEmpty()) - - return runSuspendCatching { - val discoveredPaths = pubkyService.discoverRelevantReceiverPaths(publicKey) - 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, @@ -1542,57 +1395,6 @@ class PrivatePaykitRepo @Inject constructor( } } - 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 -> @@ -1604,11 +1406,6 @@ 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 { @@ -1716,7 +1513,7 @@ 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) { cacheStore.update { it.copy(cleanupPending = isPending) } @@ -1767,7 +1564,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) } @@ -1816,18 +1613,14 @@ 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 { diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 97da3d121b..8cdb5b6fd8 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -3,8 +3,8 @@ package to.bitkit.repositories import android.graphics.Bitmap import android.graphics.BitmapFactory import coil3.ImageLoader -import com.synonym.paykit.ContactProfileResolution import com.synonym.paykit.PaykitProfile +import com.synonym.paykit.ProfileResolution import com.synonym.paykit.PubkyAuthCompanionClaim import io.ktor.client.HttpClient import io.ktor.client.call.body @@ -46,13 +46,13 @@ 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 import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.PubkySessionBackupKind import to.bitkit.models.PubkySessionBackupV1 -import to.bitkit.services.PaykitReceiverPaths import to.bitkit.services.PubkyService import to.bitkit.utils.AppError import to.bitkit.utils.Logger @@ -902,7 +902,6 @@ class PubkyRepo @Inject constructor( pubkyService.saveContact( prefixedKey, profile.name, - relevantReceiverPaths(prefixedKey), restorePrivateConnection = true, ) updateContacts { current -> @@ -914,16 +913,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) - } - } - @Suppress("LongParameterList") suspend fun updateContact( publicKey: String, @@ -977,7 +966,6 @@ class PubkyRepo @Inject constructor( for (profile in profiles.distinctBy { it.publicKey }) { if (profile.publicKey in existing) continue runSuspendCatching { - // The preview already resolved this profile. Receiver discovery runs during contact refresh. pubkyService.saveContact(profile.publicKey, profile.name, restorePrivateConnection = true) imported.add(profile) existing.add(profile.publicKey) @@ -1139,17 +1127,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, ), ) @@ -1364,7 +1356,7 @@ class PubkyRepo @Inject constructor( } } - 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) @@ -1379,14 +1371,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) { val prefixedKey = profile.publicKey.ensurePubkyPrefix() val ownerPublicKey = requireNotNull(_publicKey.value) { "Pubky identity unavailable" } @@ -1531,20 +1515,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..1cc5007b10 100644 --- a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import org.lightningdevkit.ldknode.Bolt11Invoice import org.lightningdevkit.ldknode.Network +import to.bitkit.data.PrivatePaykitCacheStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.di.IoDispatcher @@ -19,7 +20,6 @@ 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.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) @@ -100,6 +100,7 @@ class PublicPaykitRepo @Inject constructor( private val coreService: CoreService, private val paykitSdkService: PaykitSdkService, private val settingsStore: SettingsStore, + private val privatePaykitCacheStore: PrivatePaykitCacheStore, private val clock: Clock, ) { companion object { @@ -213,18 +214,18 @@ class PublicPaykitRepo @Inject constructor( runSuspendCatching { if (!publish) { val endpointError = runSuspendCatching { removePublishedEndpoints() }.exceptionOrNull() - val markerError = syncLocalReceiverMarker(publicSharingEnabled = false).exceptionOrNull() + val appError = syncPaykitApp().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) } } @@ -240,7 +241,7 @@ class PublicPaykitRepo @Inject constructor( forceRefreshLightning = forceRefreshLightning, requireEndpoint = requireEndpoint, ) - syncLocalReceiverMarker(publicSharingEnabled = true).getOrThrow() + syncPaykitApp().getOrThrow() applyPublishedEndpoints(desired) settingsStore.update { it.copy(publicPaykitCleanupPending = false) } } @@ -263,25 +264,22 @@ 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 = withContext(ioDispatcher) { runSuspendCatching { val settings = settingsStore.data.first() - val publicSharing = publicSharingEnabled ?: settings.sharesPublicPaykitEndpoints val privateSharing = privateSharingEnabled ?: settings.sharesPrivatePaykitEndpoints - paykitSdkService.syncLocalReceiverMarker( - isDiscoverable = publicSharing || privateSharing, + val privateCache = privatePaykitCacheStore.data.first() + paykitSdkService.syncPaykitApp( + privatePaymentsEnabled = privateSharing || privateCache.cleanupPending || + privateCache.deletedContactCleanupPendingPublicKeys.isNotEmpty(), ) } } @@ -454,6 +452,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/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..bc798bdc73 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 @@ -567,6 +568,53 @@ class ActivityService( updateActivity(activityId = id, activity = activity) } + suspend fun backfillPaykitContacts(): Boolean = ServiceQueue.CORE.background { + val resolver = privatePaykitContactResolver.get() + val contacts = resolver.receivedPaymentContacts + if (contacts === PaykitReceivedPaymentContacts.Empty) 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 + for (activity in activities) { + if (resolver.receivedPaymentContacts !== contacts) break + val contact = when (activity) { + is Activity.Lightning -> receivedPaykitContact(activity.v1, contacts) + is Activity.Onchain -> receivedPaykitContact(activity.v1, contacts) + } + if (contact != null && resolver.receivedPaymentContacts === contacts) { + val updated = when (activity) { + is Activity.Lightning -> Activity.Lightning(activity.v1.copy(contact = contact)) + is Activity.Onchain -> Activity.Onchain(activity.v1.copy(contact = contact)) + } + updateActivity(activityId = activity.rawId(), activity = updated) + changed = true + } + } + changed + } + + 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 fun receivedPaykitContact(row: OnchainActivity, contacts: PaykitReceivedPaymentContacts): String? { + if (row.contact != null || row.txType != PaymentType.RECEIVED) return null + val details = getBitkitCoreTransactionDetails(walletId = row.walletId, txId = row.txId) ?: return null + if (details.outputs.isEmpty()) return null + val addresses = listOfNotNull(row.address) + details.outputs.mapNotNull { it.scriptpubkeyAddress } + return contacts.contactsForAddresses(addresses).singleOrNull() + } + suspend fun delete(id: String, walletId: String = defaultWalletId): Boolean = ServiceQueue.CORE.background { deleteActivityById(walletId = walletId, activityId = id) } @@ -784,7 +832,9 @@ 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 }?.let { + privatePaykitContactPublicKeyForReceivedInvoicePaymentHash(it.id, it.direction) + } val ln = if (existingActivity is Activity.Lightning) { existingActivity.v1.withPaymentUpdate( @@ -1202,8 +1252,14 @@ class ActivityService( val resolvedAddress = resolveAddressForInboundPayment(kind, payment, transactionDetails) 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 && + !transactionDetails?.outputs.isNullOrEmpty() + ) { + privatePaykitContactResolver.get().contactPublicKeyForPrivateOnchainAddresses( + listOfNotNull(resolvedAddress) + + transactionDetails.outputs.mapNotNull { it.scriptpubkeyAddress }, + ) } else { null } diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 59bada6697..5d91bce963 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -1,22 +1,19 @@ package to.bitkit.services import android.content.Context -import com.synonym.bitkitcore.mnemonicToSeed -import com.synonym.paykit.ContactProfileResolution 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.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 +23,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 +41,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,16 +54,10 @@ 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.pubkyPublicKeyFromSecret import com.synonym.paykit.pubkySecretKeyFromBip39Mnemonic @@ -97,6 +91,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 @@ -106,10 +103,6 @@ import to.bitkit.repositories.PubkyContactError import to.bitkit.repositories.PublicPaykitRepo 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 @@ -134,6 +127,7 @@ data class PaykitPublicContactPaymentResolution( ) data class PaykitResolvedPaymentEndpoint( + val appId: String, val identifier: String, val payload: String, ) @@ -155,22 +149,6 @@ data class PaykitPaymentRequestRecurrenceTerms( val endsAt: String? = null, ) -data class PaykitPrivateReceiverPathSelection( - val linkableReceiverPaths: List, - val publishableReceiverPaths: List, - val cleanupProtectedReceiverPaths: List, - val error: Throwable?, -) - -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() -} - @Singleton @Suppress("TooManyFunctions", "LargeClass") class PaykitSdkService @Inject constructor( @@ -180,7 +158,6 @@ class PaykitSdkService @Inject constructor( sharedPubky: SharedPubkyClient, @IoDispatcher ioDispatcher: CoroutineDispatcher, ) : BaseCoroutineScope(ioDispatcher, TAG) { - private val stateStore = PaykitSdkStateBlobStore(keychain) private val sessionProvider = PaykitSdkSessionProvider(keychain, sharedPubky) private val paymentAdapter = PaykitSdkPaymentAdapter() private val pubkyClientConfig by lazy { paykitPubkyClientConfig() } @@ -205,8 +182,7 @@ class PaykitSdkService @Inject constructor( private val _backupStateVersion = MutableStateFlow(0L) val backupStateVersion: StateFlow = _backupStateVersion.asStateFlow() private var sdkFactory: () -> PaykitSdk = { - PaykitSdk.withPaymentAdapterAndPubkyClientConfig( - stateStore = stateStore, + PaykitSdk.withPaymentAdapterAndPubkySharedStateAndClientConfig( sessionProvider = sessionProvider, paymentAdapter = paymentAdapter, config = paykitSdkConfig(), @@ -247,6 +223,7 @@ class PaykitSdkService @Inject constructor( platformInitializer() launch { republishIdentityIfNeeded() } operationLock.withLock { + refreshPaykitKey() var handle = handle() try { handle.initialize() @@ -267,7 +244,7 @@ class PaykitSdkService @Inject constructor( sessionProvider.resumeStoredSessionAccess() } } - publishReceiverMarkerIfLiveSessionAvailable(handle) + publishAppIfLiveSessionAvailable(handle) } isSetup.complete(Unit) } catch (t: Throwable) { @@ -324,33 +301,32 @@ class PaykitSdkService @Inject constructor( suspend fun currentPublicKey(): String? { isSetup.await() return operationLock.withLock { + refreshPaykitKey() val handle = handle() handle.identityStatus()?.publicKey?.let { return@withLock it } - handle.initialize().identity.publicKey + handle.initialize().publicKey } } suspend fun hasPrivatePaymentAccess(): Boolean { isSetup.await() return operationLock.withLock { - handle().identityStatus()?.liveSessionAvailable == true + refreshPaykitKey() + handle().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()), + requiredCapabilities = requiredSessionCapabilities(), ) activateBootstrapResult( result = result, - previousPublicKey = previousPublicKey, ) notifyBackupStateChanged() @@ -365,10 +341,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(), @@ -376,7 +350,6 @@ class PaykitSdkService @Inject constructor( activateBootstrapResult( result = result, - previousPublicKey = previousPublicKey, ) notifyBackupStateChanged() @@ -393,7 +366,6 @@ class PaykitSdkService @Inject constructor( return operationLock.withLock { bootstrap().signUp( localSecretKey = localSecretKey(secretKeyHex), - receiverNoiseSecretKey = sessionProvider.loadOrDeriveReceiverNoiseSecretKey(), homeserverPublicKey = homeserverPublicKey, signupCode = signupCode, requiredCapabilities = requiredCapabilities(), @@ -404,13 +376,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 { @@ -424,16 +393,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() @@ -466,11 +432,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(), + ), + ), ) } } @@ -485,7 +467,11 @@ class PaykitSdkService @Inject constructor( suspend fun publishPaykitProfile(profile: PaykitProfile): PaykitProfileRecord { isSetup.await() return operationLock.withLock { - handle().publishPaykitProfile(profile).also { + refreshPaykitKey() + val handle = handle() + val publicKey = requireNotNull(handle.identityStatus()?.publicKey) + val current = handle.fetchPaykitProfile(publicKey) + handle.publishPaykitProfile(profile, current?.revision).also { notifyBackupStateChanged() } } @@ -497,7 +483,7 @@ class PaykitSdkService @Inject constructor( if (expectedIdentity != null) { val identityStatus = 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" } } @@ -510,7 +496,10 @@ class PaykitSdkService @Inject constructor( suspend fun deletePaykitProfile() { isSetup.await() operationLock.withLock { - handle().deletePaykitProfile() + refreshPaykitKey() + val handle = handle() + val publicKey = requireNotNull(handle.identityStatus()?.publicKey) + handle.fetchPaykitProfile(publicKey)?.let { handle.deletePaykitProfile(it.revision) } notifyBackupStateChanged() } } @@ -525,13 +514,14 @@ class PaykitSdkService @Inject constructor( suspend fun fetchPubkyFollows(publicKey: String): List { isSetup.await() return operationLock.withLock { - handle().fetchPubkyFollows(publicKey) + handle().fetchPubkyFollows(publicKey, maxEntries = 1000u) } } suspend fun contactRecords(): List { isSetup.await() return operationLock.withLock { + refreshPaykitKey() handle().contactRecords() } } @@ -539,6 +529,7 @@ class PaykitSdkService @Inject constructor( suspend fun contactRecord(publicKey: String): ContactRecord? { isSetup.await() return operationLock.withLock { + refreshPaykitKey() handle().contactRecord(publicKey) } } @@ -546,7 +537,6 @@ class PaykitSdkService @Inject constructor( suspend fun saveContact( publicKey: String, label: String?, - receiverPaths: List? = null, restorePrivateConnection: Boolean = false, ): ContactRecord { isSetup.await() @@ -554,9 +544,7 @@ class PaykitSdkService @Inject constructor( withStateRevisionTracking { handle -> 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) + val update = ContactUpdate(publicKey, label) if (!restorePrivateConnection) return@withStateRevisionTracking handle.saveContact(update) val blockedPeers = handle.linkedPeers().filter { it.state == LinkedPeerState.BLOCKED && PubkyPublicKeyFormat.matches(it.counterparty, publicKey) @@ -570,12 +558,9 @@ class PaykitSdkService @Inject constructor( isSetup.await() return operationLock.withLock { withStateRevisionTracking { handle -> - val record = handle.contactRecord(publicKey) val peers = 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 endsAt = it.terms?.recurrence?.endsAt?.let { timestamp -> @@ -590,7 +575,6 @@ class PaykitSdkService @Inject constructor( runSuspendCatching { val report = handle.clearPrivatePaymentListAndProcessOutbound( publicKey, - peer.counterpartyReceiverPath, ) if (report.failedToQueue.isNotEmpty() || report.failedToDeliver.isNotEmpty()) { Logger.warn("Failed to withdraw private endpoints before contact deletion", context = TAG) @@ -599,7 +583,7 @@ class PaykitSdkService @Inject constructor( Logger.warn("Failed to withdraw private endpoints before contact deletion", it, context = TAG) } } - receiverPaths.forEach { handle.blockPeer(publicKey, it) } + peers.forEach { handle.blockPeer(publicKey) } handle.removeContact(publicKey) } } @@ -608,76 +592,24 @@ class PaykitSdkService @Inject constructor( suspend fun resolveContactProfile( publicKey: String, allowPubkyProfileFallback: Boolean, - ): ContactProfileResolution? { - isSetup.await() - return operationLock.withLock { - handle().resolveContactProfile(publicKey, PaykitReceiverPaths.WALLET, allowPubkyProfileFallback) - } - } - - suspend fun discoverRelevantReceiverPaths(publicKey: String): List { - isSetup.await() - return operationLock.withLock { - val handle = 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 privateReceiverPathSelection( - publicKey: String, - savedReceiverPaths: List, - ): PaykitPrivateReceiverPathSelection { + ): ProfileResolution? { isSetup.await() return operationLock.withLock { - val handle = 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, - ) + handle().resolveProfile(publicKey, allowPubkyProfileFallback) } } - suspend fun syncLocalReceiverMarker( - isDiscoverable: Boolean, - ) { + suspend fun syncPaykitApp(privatePaymentsEnabled: Boolean) { isSetup.await() operationLock.withLock { withStateRevisionTracking { handle -> - if (!isDiscoverable) { - handle.removePaykitReceiverMarker() - return@withStateRevisionTracking - } - - handle.publishPaykitReceiverMarker(receiverCapabilities(handle)) + val capabilities = appCapabilities(handle) + handle.publishPaykitApp( + displayName = "Bitkit", + capabilities = capabilities.copy( + privatePayments = capabilities.privatePayments && privatePaymentsEnabled + ), + ) } } } @@ -691,7 +623,7 @@ class PaykitSdkService @Inject constructor( } } - fun requiredCapabilities(): String = requiredSessionCapabilities(paykitSdkConfig()) + fun requiredCapabilities(): String = requiredSessionCapabilities() suspend fun syncPrivatePaymentListsWithReservations( updates: List, @@ -710,20 +642,18 @@ class PaykitSdkService @Inject constructor( suspend fun ensureLinkWithPeer( counterparty: String, - receiverPath: String, maxAdvanceSteps: UInt = 8u, ) = run { isSetup.await() operationLock.withLock { withStateRevisionTracking { handle -> - handle.ensureLinkWithPeer(counterparty, receiverPath, maxAdvanceSteps) + handle.ensureLinkWithPeer(counterparty, maxAdvanceSteps) } } } suspend fun clearPrivatePaymentList( counterparty: String, - receiverPath: String, ): PrivatePaymentListDeliveryReport? { isSetup.await() return operationLock.withLock { @@ -731,13 +661,12 @@ class PaykitSdkService @Inject constructor( if ( handle.linkedPeers().any { it.state == LinkedPeerState.BLOCKED && - PubkyPublicKeyFormat.matches(it.counterparty, counterparty) && - it.counterpartyReceiverPath == receiverPath + PubkyPublicKeyFormat.matches(it.counterparty, counterparty) } ) { return@withStateRevisionTracking null } - handle.clearPrivatePaymentListAndProcessOutbound(counterparty, receiverPath) + handle.clearPrivatePaymentListAndProcessOutbound(counterparty) } } } @@ -760,13 +689,21 @@ class PaykitSdkService @Inject constructor( } } - suspend fun paymentRequests(): List { + suspend fun paymentRequests(): List = allPaymentRequests().filter(::isBitkitPaymentRequest) + + suspend fun allPaymentRequests(expectedIdentity: String? = null): List { isSetup.await() return operationLock.withLock { - handle().listPaymentRequests( + refreshPaykitKey() + val handle = handle() + if (expectedIdentity != null) { + check(PubkyPublicKeyFormat.matches(handle.identityStatus()?.publicKey, expectedIdentity)) { + "Payment Request identity changed" + } + } + handle.listPaymentRequests( PaymentRequestFilter( counterparty = null, - counterpartyReceiverPath = null, localRole = null, states = emptyList(), recurring = null, @@ -779,23 +716,22 @@ class PaykitSdkService @Inject constructor( suspend fun identityStatus(): IdentityStatus? { isSetup.await() return operationLock.withLock { + refreshPaykitKey() handle().identityStatus() } } - suspend fun paymentRequestReceiverPaths(publicKey: String): List { + suspend fun canReceivePaymentRequests(publicKey: String): Boolean { isSetup.await() return operationLock.withLock { - val handle = handle() - handle.paykitReceiverPaths(publicKey) - .filter { it in PaykitReceiverPaths.supported } - .filter { handle.paykitReceiverMarker(publicKey, it)?.capabilities?.paymentRequests == true } + handle().paykitAppRegistry(publicKey)?.apps?.any { + it.capabilities.paymentRequests && it.capabilities.outgoingPayments + } == true } } suspend fun proposePaymentRequest( counterparty: String, - counterpartyReceiverPath: String, proposal: PaykitPaymentRequestProposalTerms, expectedIdentity: String, ): PaymentRequestRecord { @@ -804,7 +740,7 @@ class PaykitSdkService @Inject constructor( withStateRevisionTracking { handle -> val identityStatus = 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( @@ -821,33 +757,42 @@ class PaykitSdkService @Inject constructor( ) }, acceptedPaymentEndpointIdentifiers = proposal.acceptedPaymentEndpointIdentifiers, + paymentEndpoints = null, + requiredAppId = "bitkit", conversion = null, paymentDeadline = null, metadata = PrivateJsonObject(proposal.metadataJson), ) - handle.proposePaymentRequest(counterparty, counterpartyReceiverPath, terms) + handle.proposePaymentRequest(counterparty, terms) } } } suspend fun acceptPaymentRequest( counterparty: String, - counterpartyReceiverPath: String, paymentRequestId: String, ): PaymentRequestRecord { isSetup.await() return operationLock.withLock { withStateRevisionTracking { handle -> - handle.acceptPaymentRequest(counterparty, counterpartyReceiverPath, paymentRequestId) + handle.claimPaymentRequestForExecution(counterparty, paymentRequestId) + handle.acceptPaymentRequest(counterparty, paymentRequestId) } } } + suspend fun claimPaymentRequestForExecution(counterparty: String, paymentRequestId: String): PaymentRequestRecord { + isSetup.await() + return operationLock.withLock { + withStateRevisionTracking { it.claimPaymentRequestForExecution(counterparty, paymentRequestId) } + } + } + @Suppress("LongParameterList") suspend fun submitPaymentProof( counterparty: String, - counterpartyReceiverPath: String, paymentRequestId: String, + paymentAppId: String, paymentEndpointIdentifier: String, proofJson: String, billingPeriod: PaykitBillingPeriod? = null, @@ -857,10 +802,10 @@ class PaykitSdkService @Inject constructor( withStateRevisionTracking { handle -> handle.submitPaymentProof( counterparty, - counterpartyReceiverPath, paymentRequestId, PaymentProofSubmission( billingPeriod = billingPeriod?.sdkValue, + paymentAppId = paymentAppId, paymentEndpointIdentifier = paymentEndpointIdentifier, allowanceId = null, conversionQuoteId = null, @@ -873,28 +818,26 @@ class PaykitSdkService @Inject constructor( 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) + 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) + handle.cancelPaymentRequest(counterparty, paymentRequestId, reason) } } } @@ -902,20 +845,21 @@ class PaykitSdkService @Inject constructor( suspend fun linkedPeers(): List { isSetup.await() return operationLock.withLock { + refreshPaykitKey() handle().linkedPeers() } } - suspend fun pendingOutboundPrivateCounterparties(): List { + suspend fun pendingOutboundPrivateCounterparties(): List { isSetup.await() return operationLock.withLock { + refreshPaykitKey() handle().pendingOutboundPrivateCounterparties() } } suspend fun prepareAndResolvePrivateContactPayment( counterparty: String, - receiverPath: String, afterPrivatePaymentListVersion: ULong?, amount: PaymentAmountContext? = null, ): PaykitPreparedPrivateContactPayment { @@ -924,7 +868,6 @@ class PaykitSdkService @Inject constructor( withStateRevisionTracking { handle -> handle.prepareAndResolvePrivateContactPayment( counterparty = counterparty, - counterpartyReceiverPath = receiverPath, amount = amount, afterPrivatePaymentListVersion = afterPrivatePaymentListVersion, maxAdvanceSteps = 8u, @@ -937,13 +880,34 @@ class PaykitSdkService @Inject constructor( ) } + suspend fun prepareAndResolvePrivatePaymentRequest( + counterparty: String, + paymentRequestId: String, + afterPrivatePaymentListVersion: ULong?, + ): PaykitPreparedPrivateContactPayment { + isSetup.await() + val prepared = operationLock.withLock { + withStateRevisionTracking { + it.prepareAndResolvePrivatePaymentRequest( + counterparty, + paymentRequestId, + afterPrivatePaymentListVersion, + 8u, + ) + } + } + 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) + handle().resolvePublicContactPayment(counterparty, amount = null) } return resolution.toPaykitPublicContactPaymentResolution() } @@ -955,6 +919,7 @@ class PaykitSdkService @Inject constructor( privatePaymentListVersion = privatePaymentListVersion, payableEndpoints = payableEndpoints.map { PaykitResolvedPaymentEndpoint( + appId = it.appId, identifier = it.identifier, payload = it.target.payload.exportText(), ) @@ -965,6 +930,7 @@ class PaykitSdkService @Inject constructor( PaykitPublicContactPaymentResolution( payableEndpoints = payableEndpoints.map { PaykitResolvedPaymentEndpoint( + appId = it.appId, identifier = it.identifier, payload = it.target.payload.exportText(), ) @@ -974,18 +940,13 @@ class PaykitSdkService @Inject constructor( suspend fun exportBackupState(): String { isSetup.await() return operationLock.withLock { + refreshPaykitKey() handle().exportBackupString() } } - 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() { @@ -1004,9 +965,8 @@ class PaykitSdkService @Inject constructor( isSetup.await() operationLock.withLock { try { - withStateRevisionTracking { handle -> - handle.forgetSessionAccess() - } + handle().forgetSessionAccess() + notifyBackupStateChanged() } finally { resetRuntime() } @@ -1030,28 +990,33 @@ 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() { + val root = sessionProvider.loadLocalSecretKey() ?: return + sessionProvider.setPaykitIdentitySecretKey(paykitKey(root)) } - private suspend fun currentSdkStatePublicKeyLocked(): String? { - sessionProvider.suspendStoredSessionAccess() - return try { - handle().identityStatus()?.publicKey - } finally { - sessionProvider.resumeStoredSessionAccess() - } + 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)) @@ -1062,48 +1027,43 @@ 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) + publishAppIfLiveSessionAvailable(handle) republishIdentityIfNeeded(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) { runSuspendCatching { - val capabilities = receiverCapabilities(handle) + val capabilities = appCapabilities(handle) if (capabilities.privatePayments) { - handle.publishPaykitReceiverMarker(capabilities) + handle.publishPaykitApp("Bitkit", capabilities) } }.onFailure { - Logger.warn("Failed to publish Paykit receiver marker", it, context = TAG) + 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 suspend fun appCapabilities(handle: PaykitSdk): PaykitAppCapabilities { + val hasPrivatePaymentAccess = + handle.identityStatus()?.capability == PubkyIdentityCapability.PRIVATE_LINK_CAPABLE + return PaykitAppCapabilities( privatePayments = hasPrivatePaymentAccess, paymentRequests = hasPrivatePaymentAccess, receipts = false, @@ -1123,7 +1083,7 @@ class PaykitSdkService @Inject constructor( var failure: Throwable? = null return try { runSuspendCatching { - blockedPeers.forEach { handle.unblockPeer(it.counterparty, it.counterpartyReceiverPath) } + blockedPeers.forEach { handle.unblockPeer(it.counterparty) } handle.saveContact(update) }.onFailure { failure = it }.getOrThrow() } catch (error: CancellationException) { @@ -1134,7 +1094,7 @@ class PaykitSdkService @Inject constructor( withContext(NonCancellable) { blockedPeers.forEach { peer -> runSuspendCatching { - handle.blockPeer(peer.counterparty, peer.counterpartyReceiverPath) + handle.blockPeer(peer.counterparty) }.onFailure(restorationError::addSuppressed) } } @@ -1143,6 +1103,7 @@ class PaykitSdkService @Inject constructor( } private suspend fun withStateRevisionTracking(block: suspend (PaykitSdk) -> T): T { + refreshPaykitKey() val handle = handle() return withPaykitBackupStateTracking( readRevision = { handle.backupStateRevision() }, @@ -1171,18 +1132,6 @@ class PaykitSdkService @Inject constructor( sdk = 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" @@ -1215,6 +1164,16 @@ 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 +} + internal suspend fun withPaykitBackupStateTracking( readRevision: suspend () -> String, onChange: () -> Unit, @@ -1235,18 +1194,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, ) @@ -1270,61 +1222,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") { @@ -1342,8 +1256,8 @@ internal class PaykitSdkSessionProvider( clientId = BitkitPaykitSdkConfig.clientId, sessionSecret = sessionSecret, localSecretKey = loadLocalSecretKey(), - receiverNoiseSecretKey = loadOrDeriveReceiverNoiseSecretKey(), - ) + paykitIdentitySecretKey = paykitIdentitySecretKey, + ).also { liveSessionAccess = it } } } @@ -1388,48 +1302,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 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) + fun setPaykitIdentitySecretKey(key: PaykitIdentitySecretKey) = synchronized(lock) { + if ((paykitIdentitySecretKey?.keyGeneration() ?: 1uL) != key.keyGeneration()) { + liveSessionAccess = null } + paykitIdentitySecretKey = key } - - private fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray = - Mac.getInstance("HmacSHA256").run { - init(SecretKeySpec(key, "HmacSHA256")) - doFinal(data) - } } internal fun clearPubkySessionCredentials(deleteKeychainValue: (String) -> Unit) { @@ -1439,88 +1317,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 8cb0667ccc..d5a04d4766 100644 --- a/app/src/main/java/to/bitkit/services/PubkyService.kt +++ b/app/src/main/java/to/bitkit/services/PubkyService.kt @@ -1,10 +1,10 @@ package to.bitkit.services import com.synonym.bitkitcore.approvePubkyAuth -import com.synonym.paykit.ContactProfileResolution import com.synonym.paykit.ContactRecord 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.withTimeoutOrNull @@ -61,11 +61,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 } } @@ -201,10 +201,9 @@ class PubkyService @Inject constructor( suspend fun saveContact( publicKey: String, label: String?, - receiverPaths: List? = null, restorePrivateConnection: Boolean = false, ): ContactRecord = ServiceQueue.CORE.background { - paykitSdkService.saveContact(publicKey, label, receiverPaths, restorePrivateConnection) + paykitSdkService.saveContact(publicKey, label, restorePrivateConnection) } suspend fun removeContact(publicKey: String): ContactRecord? = ServiceQueue.CORE.background { @@ -214,14 +213,10 @@ class PubkyService @Inject constructor( suspend fun resolveContactProfile( publicKey: String, allowPubkyProfileFallback: Boolean, - ): ContactProfileResolution? = ServiceQueue.CORE.background { + ): ProfileResolution? = ServiceQueue.CORE.background { paykitSdkService.resolveContactProfile(publicKey, allowPubkyProfileFallback) } - suspend fun discoverRelevantReceiverPaths(publicKey: String): List = ServiceQueue.CORE.background { - paykitSdkService.discoverRelevantReceiverPaths(publicKey) - } - // endregion } diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index d0967bc607..db61bc3a9f 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -855,7 +855,6 @@ private fun RootNavHost( Routes.SubscriptionDetail( paymentRequestId = it.paymentRequestId, counterparty = it.counterparty, - counterpartyReceiverPath = it.counterpartyReceiverPath, ) ) }, @@ -872,7 +871,6 @@ private fun RootNavHost( id = PaykitSubscriptionId( paymentRequestId = route.paymentRequestId, counterparty = route.counterparty, - counterpartyReceiverPath = route.counterpartyReceiverPath, ), onBack = { navController.popBackStack() }, ) @@ -1432,7 +1430,6 @@ private fun NavGraphBuilder.contacts( Sheet.Receive( route = ReceiveRoute.PaymentRequestAmount( publicKey = it.publicKey, - receiverPath = it.receiverPath, ) ) ) @@ -2191,14 +2188,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, ) @@ -2526,14 +2521,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 037b5d21b3..0bf8778c73 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -307,13 +307,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/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 cfd1cb7439..f4e6283161 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("") @@ -70,7 +70,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..2b927c88fc 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 @@ -481,7 +481,7 @@ internal fun PaymentRequestRecipientContent( } items( items = recipients, - key = { (target, _) -> "${target.publicKey}|${target.receiverPath}" }, + key = { (target, _) -> target.publicKey }, ) { (target, contact) -> PubkyContactRow( profile = contact, @@ -592,13 +592,11 @@ 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, 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/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 b4036546fa..c40a2dd900 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 @@ -110,8 +110,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) } ) } @@ -195,7 +194,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 { @@ -541,7 +540,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/usecases/RefreshContactPaykitReceiversUseCase.kt b/app/src/main/java/to/bitkit/usecases/RefreshContactPaykitLinkUseCase.kt similarity index 75% rename from app/src/main/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCase.kt rename to app/src/main/java/to/bitkit/usecases/RefreshContactPaykitLinkUseCase.kt index be69e7623f..830993fc54 100644 --- a/app/src/main/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/RefreshContactPaykitLinkUseCase.kt @@ -10,24 +10,23 @@ 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") + privatePaykitRepo.startInitialLinkBurst(savedPublicKeys, "contact link 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/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 9d70a988d1..ee66d5c496 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -199,7 +199,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 @@ -259,7 +259,7 @@ class AppViewModel @Inject constructor( 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, @@ -741,7 +741,7 @@ class AppViewModel @Inject constructor( paykitPaymentRequestRepo.activate(state.publicKey) if (!PubkyPublicKeyFormat.matches(pubkyRepo.publicKey.value, state.publicKey)) return paymentRequestIdentity = state.publicKey - refreshPrivateOnlyPaykitReceiverMarker("contact sync") + refreshPrivateOnlyPaykitApp("contact sync") if (!state.contactsLoaded) return val removedKeys = lastPrivatePaykitContactKeys - state.contactKeys @@ -782,7 +782,7 @@ 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) @@ -876,6 +876,7 @@ class AppViewModel @Inject constructor( if (!isPaykitEnabled.value || pubkyRepo.publicKey.value == null || !walletRepo.walletExists()) return if (refreshMaintenance) paykitPaymentProofRepo.reconcile() paykitPaymentRequestRepo.refresh().onSuccess { + activityRepo.backfillPaykitContacts() presentNextIncomingPaykitPaymentRequest() } } @@ -1348,18 +1349,23 @@ class AppViewModel @Inject constructor( return shouldHideRequestSendSheet } - private suspend fun refreshPrivateOnlyPaykitReceiverMarker(reason: String) { + private suspend fun refreshPrivateOnlyPaykitApp(reason: String) { val settings = settingsStore.data.first() if (!settings.sharesPrivatePaykitEndpoints || settings.sharesPublicPaykitEndpoints) return if (pubkyRepo.publicKey.value == null) return - publicPaykitRepo.syncLocalReceiverMarker() + publicPaykitRepo.syncPaykitApp() .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) { + 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) { @@ -1375,11 +1381,6 @@ class AppViewModel @Inject constructor( Logger.warn("Failed to reconcile public Paykit state for '$reason'", it, context = TAG) } } - - privatePaykitRepo.retryPendingEndpointRemoval(contactKeys) - .onFailure { - Logger.warn("Failed to retry private Paykit endpoint removal for '$reason'", it, context = TAG) - } } @Suppress("CyclomaticComplexMethod") @@ -3061,7 +3062,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 } @@ -4039,12 +4040,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) @@ -4119,6 +4127,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 @@ -4136,7 +4145,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) @@ -4226,7 +4235,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 @@ -4322,10 +4333,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 @@ -4349,10 +4367,11 @@ class AppViewModel @Inject constructor( private suspend fun preparePaymentProof(request: PaykitPaymentRequest?): Result { if (request == null) return Result.success(null) - val preparation = paymentProofPreparation() + val preparation = runCatching { paymentProofPreparation() }.getOrElse { return Result.failure(it) } return paykitPaymentProofRepo.prepare( request = request, paymentEndpointIdentifier = preparation.endpointIdentifier, + paymentAppId = preparation.appId, kind = preparation.kind, ).map { request } } @@ -4360,23 +4379,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() } @@ -4393,7 +4419,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) { @@ -4402,8 +4432,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, ) } @@ -5248,8 +5281,8 @@ 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 false } val preparedPaymentProofRequest = proofPreparation.getOrNull() @@ -5291,10 +5324,12 @@ class AppViewModel @Inject constructor( } fun completeHardwareContactPayment(txId: String) { - val incomingPaymentRequest = synchronized(contactPaymentContextLock) { - activeContactPaymentContext?.incomingPaymentRequest + val context = synchronized(contactPaymentContextLock) { + preparedContactPaymentContext } - completeOnchainPaymentProofInBackground(incomingPaymentRequest, txId) + val request = context?.incomingPaymentRequest + val preparation = request?.let { paymentProofPreparation(context) } + completeOnchainPaymentProofInBackground(request, txId, preparation) } fun onHardwareSignCancelled() { @@ -6060,6 +6095,7 @@ data class ContactPaymentContext( private data class PaymentProofPreparation( val endpointIdentifier: String, + val appId: String, val kind: PaykitPaymentProofKind, ) diff --git a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt index 403c97d022..8ca40d853d 100644 --- a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt @@ -288,6 +288,11 @@ class SettingsViewModel @Inject constructor( private suspend fun removePaykitEndpoints(hadPublicPaykitState: Boolean, hadPrivatePaykitState: Boolean) { val contacts = pubkyRepo.contacts.value.map { it.publicKey } + privatePaykitRepo.disableSharingAndPruneUnsavedContactState(contacts) + .onFailure { + Logger.warn("Failed to remove private Paykit endpoints after disabling Paykit UI", it, context = TAG) + } + if (hadPublicPaykitState) { publicPaykitRepo.syncPublishedEndpoints(publish = false) .onSuccess { @@ -298,17 +303,12 @@ class SettingsViewModel @Inject constructor( Logger.warn("Failed to remove public Paykit endpoints after disabling Paykit UI", it, context = TAG) } } else if (hadPrivatePaykitState) { - publicPaykitRepo.syncLocalReceiverMarker(publicSharingEnabled = false, privateSharingEnabled = false) + publicPaykitRepo.syncPaykitApp(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) + Logger.warn("Failed to update Paykit app capabilities after disabling Paykit UI", it, context = TAG) } } - - privatePaykitRepo.disableSharingAndPruneUnsavedContactState(contacts) - .onFailure { - Logger.warn("Failed to remove private Paykit endpoints after disabling Paykit UI", it, context = TAG) - } } val isPinEnabled = settingsStore.data.map { it.isPinEnabled } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bc65f16c45..41669bb81d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -640,6 +640,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/models/PaykitPaymentStateBackupTest.kt b/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt index 912a5174ac..edc194cd23 100644 --- a/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt +++ b/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt @@ -18,7 +18,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() 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/ContactPaymentSettingsRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt index a386543212..b9efee5774 100644 --- a/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt @@ -7,6 +7,7 @@ import org.junit.Before import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify @@ -43,7 +44,7 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { Unit } whenever { publicPaykitRepo.syncPublishedEndpoints(any()) }.thenReturn(Result.success(Unit)) - whenever { publicPaykitRepo.syncLocalReceiverMarker(anyOrNull(), anyOrNull()) } + whenever { publicPaykitRepo.syncPaykitApp(anyOrNull()) } .thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.enableSharingAndPrepareSavedContacts(any>()) } .thenReturn(Result.success(Unit)) @@ -105,7 +106,10 @@ 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) + } } @Test @@ -134,8 +138,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) + } } @Test 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..c91bd32910 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -31,7 +31,6 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever 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 @@ -69,7 +68,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, com.synonym.paykit.PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(paykitSdkService.processPendingPrivateMessages()).thenReturn(emptyList()) whenever(onchainPaymentLookup.existingTransactionIds(any(), any(), any())).thenReturn(emptySet()) whenever(store.load()).thenAnswer { @@ -114,7 +115,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 +124,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 +136,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(), @@ -172,8 +173,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 +201,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 +211,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 +232,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 +244,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 +260,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 +268,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 +281,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 +304,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 +315,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) @@ -337,8 +344,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 +361,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 +389,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 +401,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 +412,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 +425,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) @@ -436,7 +443,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 +461,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 +492,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 +501,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 +513,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 +532,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 +549,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 +568,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 +586,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 +605,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 +645,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 +653,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,9 +678,9 @@ 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.prepare(secondRequest, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(secondRequest, ONCHAIN_ADDRESS).getOrThrow() repo.reconcile() @@ -700,7 +708,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 +725,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 +744,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,12 +763,12 @@ 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) @@ -781,7 +790,6 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) = PaykitPaymentRequest( paymentRequestId = paymentRequestId, counterparty = COUNTERPARTY, - counterpartyReceiverPath = PaykitReceiverPaths.WALLET, amountValue = "0.00001", amountSats = 1_000uL, expiresAt = null, @@ -793,7 +801,6 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { identity = LOCAL_IDENTITY, requestId = PaykitPaymentRequestId( counterparty = COUNTERPARTY, - counterpartyReceiverPath = PaykitReceiverPaths.WALLET, paymentRequestId = paymentRequestId, ), paymentEndpointIdentifier = MethodId.Bolt11.rawValue, @@ -801,6 +808,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentStarted = true, paymentIdentifier = PAYMENT_HASH, proofData = PREIMAGE, + paymentAppId = "bitkit", ) private fun paymentRequestRecord( @@ -808,7 +816,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 +832,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { conversion = null, paymentDeadline = null, metadata = mock(), + paymentEndpoints = null, + requiredAppId = "bitkit", ), acceptedEventId = null, acceptedOutboundStatus = null, @@ -839,5 +848,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..d16a5ea6c6 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStoreTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStoreTest.kt @@ -40,7 +40,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 +63,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")), @@ -102,8 +102,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 +139,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 9e2a460ae6..02bd56fdaa 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt @@ -16,6 +16,7 @@ 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 @@ -41,7 +42,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 @@ -94,7 +94,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat schedulerOriginMillis = testDispatcher.scheduler.currentTime whenever(paykitSdkService.processPendingPrivateMessages()).thenReturn(emptyList()) whenever(paykitSdkService.receivePrivateMessagesFromLinkedPeers()).thenReturn(emptyList()) - whenever(paykitSdkService.paymentRequests()).thenReturn(emptyList()) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(emptyList()) whenever(paykitSdkService.linkedPeers()).thenReturn(emptyList()) whenever(settingsStore.isPaykitEnabled).thenReturn(flowOf(true)) whenever(settingsStore.data).thenReturn(flowOf(SettingsData(sharesPrivatePaykitEndpoints = true))) @@ -134,7 +134,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat val metadata = mock { on { exportText() } doReturn metadataText } - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf( paymentRequestRecord( id = "recurring", @@ -173,7 +173,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, @@ -245,7 +245,6 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat verifyBlocking(paykitSdkService) { proposePaymentRequest( eq(COUNTERPARTY), - eq(PaykitReceiverPaths.SERVER), captured.capture(), eq(LOCAL_IDENTITY), ) @@ -293,13 +292,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(COUNTERPARTY)) - .thenReturn(listOf(PaykitReceiverPaths.SERVER)) + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)) + .thenReturn(true) listOf(null, byteArrayOf(0, 1, 2)).forEach { icon -> val result = sut.proposeSubscription( @@ -318,7 +319,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 +331,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 +346,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 +369,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 +394,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 +412,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 +430,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(COUNTERPARTY)) - .thenReturn(listOf(PaykitReceiverPaths.SERVER)) - whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)) + .thenReturn(true) + whenever( + paykitSdkService.identityStatus() + ).thenReturn(IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) sut.refresh().getOrThrow() sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() @@ -560,7 +561,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 +571,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 +598,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() @@ -615,7 +617,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 +644,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,12 +668,11 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat val requestId = PaykitPaymentRequestId( paymentRequestId = PAYMENT_REQUEST_ID, counterparty = COUNTERPARTY, - counterpartyReceiverPath = PaykitReceiverPaths.SERVER, billingPeriodStartsAt = "2027-01-01T08:00:00Z", ) whenever(paymentProofStore.completedRequestProofKindsAwaitingSubmission(LOCAL_IDENTITY)) .thenReturn(mapOf(requestId to PaykitPaymentProofKind.Onchain)) - whenever(paykitSdkService.paymentRequests()).thenReturn( + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( listOf( paymentRequestRecord( state = PaymentRequestLifecycleState.ACTIVE_RECURRING, @@ -700,7 +701,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 +726,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 +735,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat ), ) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.BLOCKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.BLOCKED)), ) sut.refresh().getOrThrow() @@ -757,7 +758,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, @@ -767,7 +768,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat ), ) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.BLOCKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.BLOCKED)), ) sut.refresh().getOrThrow() @@ -790,7 +791,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, @@ -800,7 +801,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat ), ) whenever(paykitSdkService.linkedPeers()).thenReturn( - listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.BLOCKED, PaykitReceiverPaths.SERVER)), + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.BLOCKED)), ) sut.refresh().getOrThrow() @@ -819,7 +820,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, @@ -851,11 +852,10 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat 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)), ) @@ -870,20 +870,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 @@ -896,11 +895,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) @@ -909,13 +907,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)), @@ -932,12 +930,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() @@ -962,7 +960,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() @@ -983,7 +981,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() @@ -997,7 +995,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", @@ -1011,7 +1009,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, @@ -1031,17 +1029,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(COUNTERPARTY)).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(COUNTERPARTY)).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, @@ -1070,7 +1068,6 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat paymentProofs: List = emptyList(), ) = PaymentRequestRecord( counterparty = COUNTERPARTY, - counterpartyReceiverPath = PaykitReceiverPaths.SERVER, paymentRequestId = id, localRole = role, state = state, @@ -1087,6 +1084,8 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat conversion = null, paymentDeadline = paymentDeadline, metadata = metadata, + paymentEndpoints = null, + requiredAppId = "bitkit", ), acceptedEventId = null, acceptedOutboundStatus = null, @@ -1101,14 +1100,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 657b72ac11..c959758f35 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -2,6 +2,10 @@ 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 @@ -17,6 +21,7 @@ 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.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async @@ -27,7 +32,9 @@ 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.doReturn @@ -41,8 +48,8 @@ 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.PaykitReceiverPaths import to.bitkit.services.PaykitSdkService import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals @@ -91,7 +98,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { schedulerOriginMillis = testDispatcher.scheduler.currentTime whenever(paykitSdkService.processPendingPrivateMessages()).thenReturn(emptyList()) whenever(paykitSdkService.receivePrivateMessagesFromLinkedPeers()).thenReturn(emptyList()) - whenever(paykitSdkService.paymentRequests()).thenReturn(emptyList()) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(emptyList()) whenever(paykitSdkService.linkedPeers()).thenReturn(emptyList()) whenever(settingsStore.isPaykitEnabled).thenReturn(flowOf(true)) whenever(settingsStore.data).thenReturn(flowOf(SettingsData(sharesPrivatePaykitEndpoints = true))) @@ -123,18 +130,52 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.clear() } + @Test + fun `shared app destinations stay out of request UI and clear on identity switch`() = test { + val address = PaykitReceivedPaymentContactsTest.ADDRESS + val record = paymentRequestRecord(role = PaymentRequestLocalRole.PAYEE).let { + it.copy( + proposalAppId = "marketplace", + terms = 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)) + + 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()) } @@ -142,11 +183,11 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `blocking an already presented accepted request prevents payment`() = test { 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()) } @@ -154,7 +195,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `accepted request checks blocking after waiting for synchronization`() = test { 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() @@ -167,7 +208,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() } @@ -186,7 +227,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() @@ -201,12 +242,11 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { 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, ), @@ -255,7 +295,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() @@ -263,22 +303,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")), ) @@ -289,7 +329,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())), ) @@ -300,7 +340,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"), @@ -321,7 +361,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, @@ -336,7 +375,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")), @@ -351,7 +390,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), @@ -406,11 +445,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() @@ -424,7 +462,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, @@ -440,7 +478,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() @@ -460,7 +498,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, @@ -485,11 +523,10 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `accept removes current request and delivers queued response`() = 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) @@ -508,11 +545,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) @@ -539,7 +575,7 @@ 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() @@ -552,7 +588,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) @@ -574,7 +610,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { resumeRefresh.await() emptyList() } - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(paymentRequestRecord())) whenever(presentationStore.load(SECOND_IDENTITY)).thenReturn(emptySet()) val refresh = async { sut.refresh() } @@ -594,18 +630,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(COUNTERPARTY)).thenReturn( - listOf(PaykitReceiverPaths.SERVER), + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)).thenReturn( + true, ) whenever( paykitSdkService.proposePaymentRequest( eq(COUNTERPARTY), - eq(PaykitReceiverPaths.SERVER), any(), eq(LOCAL_IDENTITY), ), @@ -613,7 +650,6 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentRequestRecord( role = PaymentRequestLocalRole.PAYEE, counterparty = COUNTERPARTY, - receiverPath = PaykitReceiverPaths.SERVER, ), ) val expiry = clock.now().plus(60.seconds) @@ -629,7 +665,6 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { verifyBlocking(paykitSdkService) { proposePaymentRequest( eq(COUNTERPARTY), - eq(PaykitReceiverPaths.SERVER), proposal.capture(), eq(LOCAL_IDENTITY), ) @@ -647,27 +682,28 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `proposal revalidates only the selected saved contact`() = test { - val target = PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER) + val target = PaykitPaymentRequestTarget(COUNTERPARTY) val stalledDiscovery = 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), - linkedPeer(SECOND_IDENTITY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER), + linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED), + linkedPeer(SECOND_IDENTITY, LinkedPeerState.LINKED), ), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).thenReturn( - listOf(PaykitReceiverPaths.SERVER), + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)).thenReturn( + true, ) - whenever(paykitSdkService.paymentRequestReceiverPaths(SECOND_IDENTITY)).doSuspendableAnswer { + whenever(paykitSdkService.canReceivePaymentRequests(SECOND_IDENTITY)).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, ), ) @@ -682,22 +718,24 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(proposal.isCompleted) proposal.await().getOrThrow() - verifyBlocking(paykitSdkService, never()) { paymentRequestReceiverPaths(SECOND_IDENTITY) } + verifyBlocking(paykitSdkService, never()) { canReceivePaymentRequests(SECOND_IDENTITY) } } @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(COUNTERPARTY)).thenReturn( - listOf(PaykitReceiverPaths.SERVER), + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)).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) @@ -726,64 +764,72 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @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()) } + verifyBlocking(paykitSdkService, never()) { canReceivePaymentRequests(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(COUNTERPARTY)).thenReturn( - listOf(PaykitReceiverPaths.SERVER), + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)).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(COUNTERPARTY) } + verifyBlocking(paykitSdkService, times(1)) { canReceivePaymentRequests(COUNTERPARTY) } } @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(COUNTERPARTY)).thenReturn( - listOf(PaykitReceiverPaths.SERVER), + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)).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 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(COUNTERPARTY)).thenReturn( - listOf(PaykitReceiverPaths.SERVER), + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)).thenReturn( + true, ) sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() @@ -795,12 +841,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(COUNTERPARTY)) - .thenReturn(listOf(PaykitReceiverPaths.SERVER), emptyList()) + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)) + .thenReturn(true, false) sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() val target = sut.refreshEligibleTarget(COUNTERPARTY).getOrThrow().target @@ -811,18 +859,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(COUNTERPARTY)) - .thenReturn(listOf(PaykitReceiverPaths.SERVER)) + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)) + .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) @@ -833,18 +883,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(COUNTERPARTY)).doSuspendableAnswer { + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)).doSuspendableAnswer { lookups += 1 when (lookups) { - 1 -> listOf(PaykitReceiverPaths.SERVER) + 1 -> true 2 -> { fullLookupStarted.complete(Unit) releaseFullLookup.await() - emptyList() + false } else -> error("marker unavailable") } @@ -866,20 +918,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(SECOND_IDENTITY)) - .thenReturn(listOf(PaykitReceiverPaths.SERVER)) + whenever(paykitSdkService.canReceivePaymentRequests(SECOND_IDENTITY)) + .thenReturn(true) val fullRefresh = async { sut.refreshEligibleTargets(listOf(COUNTERPARTY)) } fullLinkLookupStarted.await() @@ -888,7 +942,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(fullRefresh.await().isFailure) assertEquals( - listOf(PaykitPaymentRequestTarget(SECOND_IDENTITY, PaykitReceiverPaths.SERVER)), + listOf(PaykitPaymentRequestTarget(SECOND_IDENTITY)), sut.eligibleTargets.value, ) } @@ -898,16 +952,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(COUNTERPARTY)).doSuspendableAnswer { + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)).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)) } @@ -925,16 +981,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(COUNTERPARTY)).doSuspendableAnswer { + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)).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) } @@ -943,7 +1001,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) } @@ -953,22 +1011,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(COUNTERPARTY)).doSuspendableAnswer { + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)).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(SECOND_IDENTITY)) - .thenReturn(listOf(PaykitReceiverPaths.SERVER)) + whenever(paykitSdkService.canReceivePaymentRequests(SECOND_IDENTITY)) + .thenReturn(true) val fullRefresh = async { sut.refreshEligibleTargets(listOf(COUNTERPARTY, SECOND_IDENTITY)) } fullLookupStarted.await() @@ -977,19 +1037,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(COUNTERPARTY)).thenReturn( - listOf(PaykitReceiverPaths.SERVER), + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)).thenReturn( + true, ) sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() @@ -1001,55 +1063,61 @@ 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(COUNTERPARTY)) - .thenReturn(emptyList(), listOf(PaykitReceiverPaths.SERVER)) + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)) + .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(COUNTERPARTY) } + verifyBlocking(paykitSdkService, times(2)) { canReceivePaymentRequests(COUNTERPARTY) } } @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(COUNTERPARTY)) - .thenReturn(listOf(PaykitReceiverPaths.SERVER)) + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)) + .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(COUNTERPARTY) } + verifyBlocking(paykitSdkService, times(2)) { canReceivePaymentRequests(COUNTERPARTY) } } @Test fun `recipient discovery bounds a stalled capability lookup`() = test { val discoveryStarted = CompletableDeferred() val stalledDiscovery = 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(COUNTERPARTY)).doSuspendableAnswer { + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)).doSuspendableAnswer { discoveryStarted.complete(Unit) stalledDiscovery.await() - listOf(PaykitReceiverPaths.SERVER) + true } val targetRefresh = async { sut.refreshEligibleTargets(listOf(COUNTERPARTY)) } @@ -1065,39 +1133,43 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @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(COUNTERPARTY)).thenReturn( - listOf(PaykitReceiverPaths.SERVER), + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)).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(COUNTERPARTY)).thenReturn( - listOf(PaykitReceiverPaths.SERVER), + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)).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( @@ -1106,13 +1178,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) @@ -1121,14 +1193,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) @@ -1147,13 +1219,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, @@ -1170,6 +1240,8 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { conversion = null, paymentDeadline = paymentDeadline, metadata = metadata, + paymentEndpoints = null, + requiredAppId = "bitkit", ), acceptedEventId = null, acceptedOutboundStatus = null, @@ -1184,15 +1256,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..ecf380e884 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/PaykitReceivedPaymentContactsTest.kt @@ -0,0 +1,224 @@ +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 { + @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 `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() + } + } + + companion object { + const val BUYER = "pubky7don8zi885feihpjsyx7t53srod6z1n4xjiyaaxucpqarm6sh85o" + const val OTHER_BUYER = "pubkya3mduedw686dysw8ndr5c1dyry5h8k6i8hzbbmx9gf9k43zq4s9o" + const val ADDRESS = "bcrt1qfn50lqawrce0evh66qrnlt8j447lwmeyqp5gmd" + const val OTHER_ADDRESS = "bcrt1qpsps9chsjnnd3veems9phzlvw42em682rsj8hh" + const val MAINNET_ADDRESS = "bc1qexample" + const val TESTNET_ADDRESS = "tb1qexample" + val LEGACY_ADDRESSES = listOf("mlegacy", "nlegacy", "2legacy") + const val METHOD = "btc-regtest-p2wpkh" + const val BOLT11_METHOD = "btc-lightning-bolt11" + const val INVOICE = "lnbcrt1example" + const val MAINNET_INVOICE = "lnbc1example" + 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") + } + } +} diff --git a/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionNotificationSchedulerTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionNotificationSchedulerTest.kt index ad1e9dea63..518657703d 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), @@ -328,7 +325,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/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..478721d46d 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt @@ -17,7 +17,6 @@ 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.assertFalse @@ -60,22 +59,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( diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitContactResolverTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitContactResolverTest.kt index dcb74d2530..a9bacffdc8 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(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(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 @@ -87,6 +125,6 @@ class PrivatePaykitContactResolverTest : BaseUnitTest() { val result = sut.contactPublicKeyForPrivateOnchainAddresses(listOf(PRIVATE_ADDRESS)) - assertEquals(CONTACT_KEY, result) + assertEquals(PubkyPublicKeyFormat.normalized(CONTACT_KEY), result) } } diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 4005ab2fc6..e74e85a7c7 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -5,11 +5,9 @@ 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.LinkedPeerRecord import com.synonym.paykit.LinkedPeerState import com.synonym.paykit.PaykitException -import com.synonym.paykit.PaymentAmountContext import com.synonym.paykit.PrivatePaymentListDeliveryReport import com.synonym.paykit.PrivatePaymentListReservationUpdateInput import com.synonym.paykit.PrivatePaymentListSyncChange @@ -28,15 +26,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 @@ -56,7 +50,6 @@ 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.PaykitResolvedPaymentEndpoint import to.bitkit.services.PaykitSdkService import to.bitkit.services.PubkyService @@ -83,11 +76,8 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { 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() @@ -126,25 +116,21 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(lightningRepo.lightningState).thenReturn(lightningState) whenever(clock.now()).thenReturn(Instant.fromEpochSeconds(NOW_SECONDS)) whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) - whenever { pubkyService.discoverRelevantReceiverPaths(any()) } - .thenReturn(listOf(WALLET_RECEIVER_PATH)) whenever(paykitSdkService.hasPrivatePaymentAccess()).thenReturn(true) 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()) }.thenAnswer { - privateReceiverPathSelection(it.getArgument(1)) - } whenever { paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any()) } .thenReturn(privateListDeliveryReport(queuedCounterparties = listOf(CONTACT_KEY))) whenever { paykitSdkService.linkedPeers() }.thenReturn(emptyList()) whenever { paykitSdkService.pendingOutboundPrivateCounterparties() }.thenReturn(emptyList()) - whenever { paykitSdkService.clearPrivatePaymentList(any(), any()) }.thenReturn(privateListDeliveryReport()) + whenever { paykitSdkService.clearPrivatePaymentList(any()) }.thenReturn(privateListDeliveryReport()) 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 } @@ -179,16 +165,15 @@ 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, ) } @@ -200,168 +185,16 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @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)) - } - - 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, 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) } - } - - @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()) } - .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()) } - - assertTrue(sut.removePublishedEndpointsForCleanup("test").isSuccess) - verifyBlocking(paykitSdkService, never()) { clearPrivatePaymentList(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()) } - .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)) - } - - 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 }) - } - - @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()) } - .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()) - } - - @Test - fun `initial link burst discovers a server receiver published after the contact was saved`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) - whenever { paykitSdkService.contactRecord(CONTACT_KEY) } - .thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH))) - whenever { pubkyService.discoverRelevantReceiverPaths(CONTACT_KEY) } - .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") - runCurrent() - 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) } - .thenReturn(listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH)) - - sut.startInitialLinkBurst(listOf(CONTACT_KEY), "test") - runCurrent() - - verify(paykitSdkService, times(2)).contactRecord(CONTACT_KEY) - verifyBlocking(pubkyService, never()) { - saveContact( - CONTACT_KEY, - null, - listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH), - ) - } - sut.closeAndClear() + verify(addressReservationRepo, never()).currentOrRotatedAddress(any()) } @Test @@ -370,16 +203,16 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.startInitialLinkBurst(listOf(CONTACT_KEY), "test") runCurrent() - clearInvocations(pubkyService) + clearInvocations(paykitSdkService) sut.startInitialLinkBurst(listOf(OTHER_CONTACT_KEY), "test") runCurrent() - clearInvocations(pubkyService) + clearInvocations(paykitSdkService) advanceTimeBy(2_000) runCurrent() - verifyBlocking(pubkyService) { discoverRelevantReceiverPaths(OTHER_CONTACT_KEY) } - verifyBlocking(pubkyService, never()) { discoverRelevantReceiverPaths(CONTACT_KEY) } + verifyBlocking(paykitSdkService) { ensureLinkWithPeer(OTHER_CONTACT_KEY) } + verifyBlocking(paykitSdkService, never()) { ensureLinkWithPeer(CONTACT_KEY) } sut.closeAndClear() } @@ -394,7 +227,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { advanceTimeBy(30_000) runCurrent() - verifyBlocking(pubkyService, never()) { discoverRelevantReceiverPaths(any()) } + verify(paykitSdkService, never()).ensureLinkWithPeer(CONTACT_KEY) verifyBlocking(paykitSdkService, never()) { syncPrivatePaymentListsWithReservations(any(), any()) } sut.closeAndClear() } @@ -405,142 +238,16 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.startInitialLinkBurst(listOf(CONTACT_KEY), "test") runCurrent() - clearInvocations(pubkyService) + clearInvocations(paykitSdkService) sut.startInitialLinkBurst(emptyList(), "test") advanceTimeBy(30_000) runCurrent() - verifyBlocking(pubkyService, never()) { discoverRelevantReceiverPaths(any()) } + verifyBlocking(paykitSdkService, never()) { ensureLinkWithPeer(any(), any()) } sut.closeAndClear() } - @Test - fun `prepareSavedContacts clears receiver paths that are no longer eligible`() = 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()) } - .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)) - } - - 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, - ) - } - - @Test - fun `prepareSavedContacts preserves receiver paths when marker lookup 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.privateReceiverPathSelection(eq(CONTACT_KEY), 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)) - } - - 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, - ) - } - - @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()) } - .thenReturn( - privateReceiverPathSelection( - publishableReceiverPaths = emptyList(), - cleanupProtectedReceiverPaths = listOf(WALLET_RECEIVER_PATH), - error = PrivatePaykitTestAppError("marker unavailable"), - ), - ) - - val result = sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) - - assertTrue(result.isFailure) - verifyBlocking(paykitSdkService, never()) { syncPrivatePaymentListsWithReservations(any(), any()) } - } - - @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()) } - .thenReturn( - privateReceiverPathSelection( - publishableReceiverPaths = emptyList(), - cleanupProtectedReceiverPaths = listOf(WALLET_RECEIVER_PATH), - error = PrivatePaykitTestAppError("marker unavailable"), - ), - ) - whenever { paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any()) }.thenAnswer { - privateListDeliveryReportForUpdates(it.getArgument(0)) - } - - val result = sut.prepareSavedContacts( - listOf(CONTACT_KEY, OTHER_CONTACT_KEY), - requireImmediatePublication = true, - ) - - 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 }) - } - @Test fun `prepareSavedContacts succeeds when link preparation fails but SDK queues reservations`() = test { settingsData.value = SettingsData( @@ -548,7 +255,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { publicPaykitLightningEnabled = false, publicPaykitOnchainEnabled = true, ) - whenever { paykitSdkService.ensureLinkWithPeer(CONTACT_KEY, WALLET_RECEIVER_PATH) }.thenAnswer { + whenever { paykitSdkService.ensureLinkWithPeer(CONTACT_KEY) }.thenAnswer { throw PrivatePaykitTestAppError("still linking") } @@ -557,31 +264,11 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(result.isSuccess, result.exceptionOrNull().toString()) verifyBlocking(paykitSdkService) { syncPrivatePaymentListsWithReservations(any(), eq(false)) } assertEquals( - setOf(WALLET_RECEIVER_PATH), - cacheData.value.contacts.getValue(CONTACT_KEY).publishedPrivatePaymentReceiverPaths, + true, + cacheData.value.contacts.getValue(CONTACT_KEY).hasPublishedPrivatePaymentList, ) } - @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()) }.thenReturn( - privateReceiverPathSelection( - publishableReceiverPaths = emptyList(), - linkableReceiverPaths = emptyList(), - ), - ) - - val result = sut.prepareSavedContacts(listOf(CONTACT_KEY, OTHER_CONTACT_KEY)) - - assertTrue(result.isSuccess, result.exceptionOrNull().toString()) - verifyBlocking(paykitSdkService, times(1)) { linkedPeers() } - } - @Test fun `private message drain keeps retrying while link is still pending`() = test { settingsData.value = SettingsData( @@ -598,7 +285,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { advanceTimeBy(257_000) runCurrent() - verifyBlocking(paykitSdkService, atLeast(8)) { ensureLinkWithPeer(CONTACT_KEY, WALLET_RECEIVER_PATH) } + verifyBlocking(paykitSdkService, atLeast(8)) { ensureLinkWithPeer(CONTACT_KEY) } sut.closeAndClear() } @@ -633,49 +320,75 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `received wallet invoice rotation preserves server receiver invoice`() = test { + fun `publication skips contacts without Paykit and cleanup has nothing to withdraw`() = 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), + publicPaykitLightningEnabled = false, + publicPaykitOnchainEnabled = true, ) - 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)))) + whenever { paykitSdkService.ensureLinkWithPeer(CONTACT_KEY) } + .thenAnswer { throw PaykitException.NotFound("not_found", "No App Registry") } - 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))) + val publication = sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) + val cleanup = sut.disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) - val result = sut.handleReceivedPayment("010101") + 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()) { clearPrivatePaymentList(any()) } + } - 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() + @Test + fun `disabled cleanup retains its capability through failures and direct retries`() = test { + val publicRepo = PublicPaykitRepo( + ioDispatcher = testDispatcher, + pubkyRepo = mock(), + walletRepo = walletRepo, + lightningRepo = lightningRepo, + coreService = coreService, + paykitSdkService = paykitSdkService, + settingsStore = settingsStore, + privatePaykitCacheStore = cacheStore, + clock = clock, + ) + for (failureStage in listOf("enable capability", "withdraw", "disable capability")) { + cacheData.value = PrivatePaykitCacheData( + contacts = mapOf(CONTACT_KEY to PrivatePaykitContactCacheData(hasPublishedPrivatePaymentList = true)), + ) + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) + sut = createSut(publicRepo) + var capability = false + var failed = false + doSuspendableAnswer { + val enabled = it.getArgument(0) + if (!failed && failureStage == if (enabled) "enable capability" else "disable capability") { + failed = true + throw AppError("Registration unavailable") + } + capability = enabled + }.whenever(paykitSdkService).syncPaykitApp(any()) + doSuspendableAnswer { + assertTrue(capability, "Withdrawal requires the private capability") + if (!failed && failureStage == "withdraw") { + failed = true + throw AppError("Delivery unavailable") + } + privateListDeliveryReport() + }.whenever(paykitSdkService).clearPrivatePaymentList(CONTACT_KEY) + + sut.disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) + assertTrue(failed, failureStage) + assertTrue(cacheData.value.cleanupPending, failureStage) + publicRepo.syncPaykitApp(privateSharingEnabled = false).getOrThrow() + assertTrue(capability, failureStage) + + sut.retryPendingEndpointRemoval(listOf(CONTACT_KEY)).getOrThrow() + + assertFalse(capability, failureStage) + assertFalse(cacheData.value.cleanupPending, failureStage) + assertFalse(cacheData.value.contacts[CONTACT_KEY]?.hasPublishedPrivatePaymentList == true, failureStage) + } } @Test @@ -690,7 +403,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { val result = sut.disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) assertTrue(result.isSuccess) - verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } + verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY) } assertTrue(cacheData.value.contacts.isEmpty()) } @@ -702,14 +415,13 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { publicPaykitOnchainEnabled = true, ) sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true).getOrThrow() - whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) }.thenReturn( + whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY) }.thenReturn( privateListDeliveryReport( failedToQueue = listOf( PrivatePaymentListSyncChange( counterparty = CONTACT_KEY, - counterpartyReceiverPath = WALLET_RECEIVER_PATH, outboundMessageId = null, - error = "failed", + error = mock(), ), ), ), @@ -749,7 +461,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.retryPendingEndpointRemoval(listOf(CONTACT_KEY)).getOrThrow() - verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } + verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY) } assertFalse(cacheData.value.cleanupPending) } @@ -771,7 +483,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 }, ) } @@ -783,14 +495,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.clearPrivatePaymentList(CONTACT_KEY) }.thenReturn( privateListDeliveryReport( failedToQueue = listOf( PrivatePaymentListSyncChange( counterparty = CONTACT_KEY, - counterpartyReceiverPath = WALLET_RECEIVER_PATH, outboundMessageId = null, - error = "failed", + error = mock(), ), ), ), @@ -810,123 +521,27 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { publicPaykitOnchainEnabled = true, ) sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) - whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } + whenever { paykitSdkService.clearPrivatePaymentList(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() @@ -936,50 +551,31 @@ 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()) } - } - - @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) - - 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) } + verifyBlocking(paykitSdkService, never()) { clearPrivatePaymentList(any()) } } @Test fun `cleanup drains all 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(), ), ) sut = createSut() whenever { paykitSdkService.linkedPeers() }.thenReturn( listOf( linkedPeer(CONTACT_KEY, LinkedPeerState.LINKED), - linkedPeer(OTHER_CONTACT_KEY, LinkedPeerState.LINKED, SERVER_RECEIVER_PATH), + linkedPeer(OTHER_CONTACT_KEY, LinkedPeerState.LINKED), ), ) 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) { clearPrivatePaymentList(CONTACT_KEY) } + verifyBlocking(paykitSdkService) { clearPrivatePaymentList(OTHER_CONTACT_KEY) } + verifyBlocking(paykitSdkService, atLeast(1)) { linkedPeers() } verifyBlocking(paykitSdkService, times(1)) { pendingOutboundPrivateCounterparties() } verifyBlocking(paykitSdkService, times(2)) { processPendingPrivateMessages() } verifyBlocking(paykitSdkService, times(2)) { receivePrivateMessagesFromLinkedPeers() } @@ -990,8 +586,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), ) @@ -1000,9 +596,9 @@ 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) { clearPrivatePaymentList(CONTACT_KEY) } + verifyBlocking(paykitSdkService) { clearPrivatePaymentList(OTHER_CONTACT_KEY) } + verifyBlocking(paykitSdkService, atLeast(1)) { linkedPeers() } assertTrue(cacheData.value.contacts.isEmpty()) assertTrue(cacheData.value.deletedContactCleanupPendingPublicKeys.isEmpty()) } @@ -1011,13 +607,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") @@ -1031,23 +626,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.clearPrivatePaymentList(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) @@ -1068,19 +662,18 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { 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), + CONTACT_KEY to cachedPublishedContact(), + OTHER_CONTACT_KEY to cachedPublishedContact(), ), ) sut = createSut() - whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) }.thenReturn( + whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY) }.thenReturn( privateListDeliveryReport( failedToQueue = listOf( PrivatePaymentListSyncChange( counterparty = CONTACT_KEY, - counterpartyReceiverPath = WALLET_RECEIVER_PATH, outboundMessageId = null, - error = "failed", + error = mock(), ), ), ), @@ -1101,9 +694,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)), @@ -1113,13 +706,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, @@ -1127,7 +720,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)) @@ -1138,7 +731,7 @@ 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 @@ -1173,7 +766,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, @@ -1194,7 +787,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)))) @@ -1204,7 +797,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, ) @@ -1227,7 +820,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)))) @@ -1257,7 +850,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)))) @@ -1276,7 +869,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)))) @@ -1286,7 +879,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, ) @@ -1297,7 +890,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, @@ -1317,11 +910,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( @@ -1345,7 +937,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, ) @@ -1356,11 +948,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( @@ -1381,11 +972,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") @@ -1401,7 +991,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, @@ -1419,12 +1009,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()) } } @@ -1433,7 +1023,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() @@ -1446,7 +1036,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))) @@ -1459,14 +1049,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() @@ -1475,16 +1065,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() @@ -1494,7 +1083,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals( 8uL, cacheData.value.contacts.getValue(CONTACT_KEY) - .consumedPrivatePaymentListVersionsByReceiverPath[WALLET_RECEIVER_PATH], + .consumedPrivatePaymentListVersion, ) } @@ -1503,10 +1092,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, @@ -1519,7 +1108,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) } } @@ -1527,7 +1116,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 { @@ -1540,11 +1129,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( @@ -1563,16 +1151,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(), ) } } @@ -1581,11 +1168,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( @@ -1602,21 +1188,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()) } } @@ -1625,11 +1207,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( @@ -1645,13 +1226,67 @@ 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(publicPaykitRepo, never()) { beginPayment(any()) } } + @Test + 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 `beginPaymentRequest rechecks expiration after private resolution`() = test { val request = paymentRequest() @@ -1660,11 +1295,10 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { Instant.fromEpochSeconds(NOW_SECONDS + 61), ) whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment( + paykitSdkService.prepareAndResolvePrivatePaymentRequest( eq(CONTACT_KEY), - eq(SERVER_RECEIVER_PATH), + eq("request-id"), eq(null), - any(), ) }.thenReturn( resolution( @@ -1687,7 +1321,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() @@ -1697,12 +1331,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, @@ -1747,6 +1381,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { return PaykitResolvedPaymentEndpoint( identifier = methodId.rawValue, payload = PublicPaykitRepo.serializePayload(value), + appId = "bitkit", ) } @@ -1755,7 +1390,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), @@ -1770,7 +1404,6 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { queued = queuedCounterparties.map { PrivatePaymentListSyncChange( counterparty = it, - counterpartyReceiverPath = WALLET_RECEIVER_PATH, outboundMessageId = null, error = null, ) @@ -1778,7 +1411,6 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { cleared = clearedCounterparties.map { PrivatePaymentListSyncChange( counterparty = it, - counterpartyReceiverPath = WALLET_RECEIVER_PATH, outboundMessageId = null, error = null, ) @@ -1787,20 +1419,8 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { failedToDeliver = emptyList(), ) - private fun cachedPublishedContact(receiverPath: String) = PrivatePaykitContactCacheData( - publishedPrivatePaymentReceiverPaths = setOf(receiverPath), - ) - - 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( @@ -1808,34 +1428,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, @@ -1844,10 +1460,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 c85a168be5..3c01d1cb84 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -4,11 +4,11 @@ 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.PaykitException import com.synonym.paykit.PaykitProfile +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,6 +54,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 @@ -148,7 +149,6 @@ class PubkyRepoTest : BaseUnitTest() { .thenReturn(mock()) } whenever(pubkyService.resolveContactProfile(any(), any())).thenAnswer { throw TestAppError("Offline") } - whenever(pubkyService.discoverRelevantReceiverPaths(any())).thenAnswer { throw TestAppError("Offline") } val result = sut.importContacts(profiles + profiles) @@ -158,7 +158,6 @@ class PubkyRepoTest : BaseUnitTest() { verify(pubkyService).saveContact(profile.publicKey, profile.name, restorePrivateConnection = true) } verify(pubkyService, never()).resolveContactProfile(any(), any()) - verify(pubkyService, never()).discoverRelevantReceiverPaths(any()) } @Test @@ -390,27 +389,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, + ), + ) + } } } @@ -2253,71 +2260,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) } } @@ -2690,14 +2639,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, @@ -2707,12 +2654,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..16207eaafe 100644 --- a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt @@ -14,13 +14,15 @@ import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.mock import org.mockito.kotlin.never +import org.mockito.kotlin.times import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.whenever +import to.bitkit.data.PrivatePaykitCacheData +import to.bitkit.data.PrivatePaykitCacheStore 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.PaykitSdkService import to.bitkit.test.BaseUnitTest @@ -45,11 +47,13 @@ class PublicPaykitRepoTest : BaseUnitTest() { private val coreService = mock() private val paykitSdkService = mock() private val settingsStore = mock() + private val privatePaykitCacheStore = mock() private val clock = mock() private val publicKey = MutableStateFlow("pubkyself") private val walletState = MutableStateFlow(WalletState()) private val settingsFlow = MutableStateFlow(SettingsData()) + private val privateCacheFlow = MutableStateFlow(PrivatePaykitCacheData()) private lateinit var sut: PublicPaykitRepo @@ -63,6 +67,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { whenever(pubkyRepo.publicKey).thenReturn(publicKey) whenever(walletRepo.walletState).thenReturn(walletState) whenever(settingsStore.data).thenReturn(settingsFlow) + whenever(privatePaykitCacheStore.data).thenReturn(privateCacheFlow) whenever(clock.now()).thenReturn(Instant.fromEpochMilliseconds(NOW_MILLIS)) whenever(paykitSdkService.syncPublicEndpoints(any())).thenReturn(syncReport()) whenever { walletRepo.refreshReusableReceiveAddress() }.thenReturn(Result.success(Unit)) @@ -78,6 +83,21 @@ class PublicPaykitRepoTest : BaseUnitTest() { PublicPaykitRepo.lightningRouteHintsValidator = null } + @Test + fun `private capability remains enabled until all pending cleanup is complete`() = test { + for (pending in listOf( + PrivatePaykitCacheData(cleanupPending = true), + PrivatePaykitCacheData(deletedContactCleanupPendingPublicKeys = setOf("pubkycontact")), + PrivatePaykitCacheData(), + )) { + privateCacheFlow.value = pending + sut.syncPaykitApp(privateSharingEnabled = false).getOrThrow() + } + val capabilities = argumentCaptor() + verifyBlocking(paykitSdkService, times(3)) { syncPaykitApp(capabilities.capture()) } + assertEquals(listOf(true, true, false), capabilities.allValues) + } + @Test fun `syncCurrentPublishedEndpoints configures SDK public endpoints`() = test { walletState.value = WalletState(onchainAddress = "bc1ptest") @@ -120,19 +140,19 @@ 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) } + .thenThrow(appError) val error = sut.syncPublishedEndpoints(publish = true).exceptionOrNull() - assertEquals(markerError, error) + assertEquals(appError, error) verifyBlocking(paykitSdkService, never()) { syncPublicEndpoints(any()) } } @@ -154,15 +174,15 @@ class PublicPaykitRepoTest : BaseUnitTest() { } @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) } + .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 +190,14 @@ 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) }.thenThrow(appError) val error = sut.syncPublishedEndpoints(publish = false).exceptionOrNull() assertEquals(endpointError, error) - assertEquals(listOf(markerError), error?.suppressedExceptions) + assertEquals(listOf(appError), error?.suppressedExceptions) } @Test @@ -214,7 +234,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 +251,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( @@ -270,6 +311,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { coreService = coreService, paykitSdkService = paykitSdkService, settingsStore = settingsStore, + privatePaykitCacheStore = privatePaykitCacheStore, clock = clock, ) @@ -282,6 +324,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..3a3076c3be --- /dev/null +++ b/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt @@ -0,0 +1,165 @@ +package to.bitkit.services + +import com.synonym.bitkitcore.Activity +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 org.junit.Test +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.async.ServiceQueue +import to.bitkit.ext.create +import to.bitkit.repositories.PaykitReceivedPaymentContacts +import to.bitkit.repositories.PrivatePaykitContactResolver +import to.bitkit.test.BaseUnitTest +import javax.inject.Provider +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ActivityServicePaykitContactsTest : BaseUnitTest() { + private val contacts = mock() + private val resolver = mock() + private val sut by lazy { ActivityService(mock(), mock(), mock(), mock(), Provider { resolver }) } + private var rows = listOf() + private var details: TransactionDetails? = null + private val updates = mutableListOf() + + @Test + fun `backfill matches cached outputs and preserves all other onchain metadata`() = coreTest { + val original = onchain(address = "unknown") + rows = listOf(Activity.Onchain(original)) + val outputs = listOf(output("request-address"), output("change-address")) + details = mock { on { this.outputs }.thenReturn(outputs) } + whenever(contacts.contactsForAddresses(listOf("unknown", "request-address", "change-address"))) + .thenReturn(setOf("buyer")) + + assertTrue(sut.backfillPaykitContacts()) + + assertEquals(listOf(Activity.Onchain(original.copy(contact = "buyer"))), updates) + verify(contacts).contactsForAddresses(listOf("unknown", "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 refuses ambiguity across stored receiving address and other outputs`() = coreTest { + rows = listOf(Activity.Onchain(onchain(address = "request-address"))) + val outputs = listOf(output("other-request-address")) + details = mock { on { this.outputs }.thenReturn(outputs) } + whenever(contacts.contactsForAddresses(listOf("request-address", "other-request-address"))) + .thenReturn(setOf("buyer", "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"))) + whenever(contacts.contactsForAddresses(listOf("request-address"))).thenReturn(setOf("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) } + whenever(contacts.contactsForAddresses(listOf("request-address", "other-request-address"))) + .thenReturn(setOf("buyer", "other-buyer")) + 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(resolver.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()) + } + + private fun coreTest(block: suspend () -> Unit) = test { + whenever(resolver.receivedPaymentContacts).thenReturn(contacts) + 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 { rows } + native.`when` { getTransactionDetails(any(), any()) }.thenAnswer { details } + native.`when` { updateActivity(any(), any()) }.thenAnswer { + updates.add(it.arguments[1] as Activity) + null + } + block() + } + } + } + + 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/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index efc08ce571..7e564a5259 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -1,12 +1,11 @@ package to.bitkit.services import com.synonym.paykit.ContactRecord -import com.synonym.paykit.EncryptedLinkRecoveryMarkerPolicy -import com.synonym.paykit.EndpointManagementScope import com.synonym.paykit.IdentityStatus import com.synonym.paykit.LinkedPeerRecord import com.synonym.paykit.LinkedPeerState import com.synonym.paykit.PaykitException +import com.synonym.paykit.PaykitIdentitySecretKey import com.synonym.paykit.PaykitSdk import com.synonym.paykit.PaymentRequestLifecycleState import com.synonym.paykit.PaymentRequestLocalRole @@ -14,17 +13,19 @@ import com.synonym.paykit.PaymentRequestRecord import com.synonym.paykit.PaymentRequestRecurrence import com.synonym.paykit.PaymentRequestTerms import com.synonym.paykit.PrivatePaymentListDeliveryReport +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.PublicContactSharingPolicy -import com.synonym.paykit.ReceiverNoiseSecretKey import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Deferred import kotlinx.coroutines.async +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.runTest @@ -37,20 +38,22 @@ import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock import org.mockito.kotlin.never 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.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.utils.AppError import kotlin.coroutines.cancellation.CancellationException -import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertNull @@ -129,6 +132,121 @@ 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()) { 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()) { 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()) { 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")) { @@ -138,17 +256,16 @@ class PaykitSdkServiceTest { 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() + 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(access.exportPaykitIdentitySecretKey()).thenReturn(noise) val error = if (failure == "cancel") { CancellationException("cancelled") } else { @@ -168,7 +285,7 @@ class PaykitSdkServiceTest { handlesCreated++ sdk } - val result = PubkySessionBootstrapResult(access, "pubky_test") + val result = PubkySessionBootstrapResult(access, "pubky_test", PubkyIdentityCapability.PRIVATE_LINK_CAPABLE) if (failure == null) { service.activateRegisteredIdentity(result) @@ -183,7 +300,6 @@ class PaykitSdkServiceTest { assertEquals(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) @@ -192,16 +308,16 @@ class PaykitSdkServiceTest { } @Test - fun `deletion blocks all known receivers before removing the contact`() = runTest { + fun `deletion blocks the identity before removing the contact`() = runTest { for (failBlock in listOf(false, 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) + val peer = contactPeer(LinkedPeerState.LINKED) whenever(sdk.linkedPeers()).thenReturn(listOf(peer)) 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 } @@ -211,8 +327,7 @@ class PaykitSdkServiceTest { } 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) } } @@ -239,7 +354,7 @@ class PaykitSdkServiceTest { whenever(sdk.paymentRequests()).thenReturn(listOf(request)) val service = PaykitSdkService(mock(), mock(), mock()) { sdk } assertFailsWith { service.removeContact(RING_PUBKY) } - verify(sdk, never()).blockPeer(any(), any()) + verify(sdk, never()).blockPeer(any()) verify(sdk, never()).removeContact(any()) if (endsNaturally) { whenever(recurrence.endsAt).thenReturn("2026-02-01T00:00:00Z") @@ -252,23 +367,31 @@ class PaykitSdkServiceTest { } @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) { 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() } } @@ -278,10 +401,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")) @@ -293,8 +416,8 @@ class PaykitSdkServiceTest { val service = PaykitSdkService(mock(), mock(), 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) } } @@ -306,9 +429,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", @@ -326,13 +450,18 @@ class PaykitSdkServiceTest { val bootstrap = mock() whenever(bootstrap.republishIdentity(any())).thenReturn(true) val access = mock() - val noise = mock() + val noise = mock() whenever(noise.exportBytes()).thenReturn(ByteArray(32) { 1 }) whenever(access.exportSessionSecret()).thenReturn("new-session") - whenever(access.exportReceiverNoiseSecretKey()).thenReturn(noise) + whenever(access.exportPaykitIdentitySecretKey()).thenReturn(noise) val service = PaykitSdkService(mock(), keychain, store, { bootstrap }) { sdk } - val result = PubkySessionBootstrapResult(access, "pubky$originalKey") + val result = + PubkySessionBootstrapResult( + access, + "pubky$originalKey", + PubkyIdentityCapability.PRIVATE_LINK_CAPABLE + ) if (resetFails) { assertEquals(resetError, assertFailsWith { service.activateRegisteredIdentity(result) }) verify(sdk, never()).initialize() @@ -341,7 +470,7 @@ class PaykitSdkServiceTest { } service.activateRegisteredIdentity(result) - if (previousKey == differentKey || cachedOwner == differentKey) { + if (cachedOwner == differentKey) { assertEquals(PubkyStoreData(), cache) inOrder(store, sdk) { verify(store).reset() @@ -355,25 +484,23 @@ class PaykitSdkServiceTest { } @Test - fun `only explicit readd unblocks every saved private receiver`() = runTest { + fun `only explicit readd unblocks the contact`() = 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), + contactPeer(LinkedPeerState.BLOCKED), ), ) val service = PaykitSdkService(mock(), mock(), mock()) { sdk } if (restoreConnection) { service.saveContact(RING_PUBKY, "Contact", restorePrivateConnection = true) - verify(sdk).unblockPeer(RING_PUBKY, PaykitReceiverPaths.WALLET) - verify(sdk).unblockPeer(RING_PUBKY, PaykitReceiverPaths.SERVER) + verify(sdk).unblockPeer(RING_PUBKY) } else { assertFailsWith { service.saveContact(RING_PUBKY, "Contact") } verify(sdk, never()).saveContact(any()) - verify(sdk, never()).unblockPeer(any(), any()) + verify(sdk, never()).unblockPeer(any()) } } } @@ -383,8 +510,7 @@ class PaykitSdkServiceTest { for (failurePoint in listOf("lookup", "unblock", "save", "cancel")) { val sdk = mock() val peers = listOf( - contactPeer(PaykitReceiverPaths.WALLET, LinkedPeerState.BLOCKED), - contactPeer(PaykitReceiverPaths.SERVER, LinkedPeerState.BLOCKED), + contactPeer(LinkedPeerState.BLOCKED), ) val failure = if (failurePoint == "cancel") { CancellationException("cancelled") @@ -396,7 +522,7 @@ class PaykitSdkServiceTest { when (failurePoint) { "lookup" -> whenever(sdk.linkedPeers()).thenThrow(failure).thenReturn(peers) "unblock", "cancel" -> { - whenever(sdk.unblockPeer(RING_PUBKY, PaykitReceiverPaths.SERVER)) + whenever(sdk.unblockPeer(RING_PUBKY)) .thenThrow(failure).thenReturn(peers.last().copy(state = LinkedPeerState.NOT_LINKED)) } "save" -> whenever(sdk.saveContact(any())).thenThrow(failure).thenReturn(mock()) @@ -407,10 +533,9 @@ class PaykitSdkServiceTest { } assertSame(failure, thrown) if (failurePoint == "lookup") { - verify(sdk, never()).blockPeer(any(), any()) + verify(sdk, never()).blockPeer(any()) } else { - verify(sdk).blockPeer(RING_PUBKY, PaykitReceiverPaths.WALLET) - verify(sdk).blockPeer(RING_PUBKY, PaykitReceiverPaths.SERVER) + verify(sdk).blockPeer(RING_PUBKY) } service.saveContact(RING_PUBKY, "Contact", restorePrivateConnection = true) verify(sdk, atLeastOnce()).saveContact(any()) @@ -421,14 +546,13 @@ class PaykitSdkServiceTest { 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), + contactPeer(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) + whenever(sdk.blockPeer(RING_PUBKY)).thenThrow(rollbackFailure) val service = PaykitSdkService(mock(), mock(), mock()) { sdk } val thrown = assertFailsWith { @@ -437,22 +561,20 @@ class PaykitSdkServiceTest { assertSame(restorationFailure, thrown) assertEquals(listOf(rollbackFailure), thrown.suppressed.toList()) - verify(sdk).blockPeer(RING_PUBKY, PaykitReceiverPaths.WALLET) - verify(sdk).blockPeer(RING_PUBKY, PaykitReceiverPaths.SERVER) + verify(sdk).blockPeer(RING_PUBKY) } @Test fun `blocked peer cleanup does not attempt network delivery`() = runTest { val sdk = mock() - whenever(sdk.linkedPeers()).thenReturn(listOf(contactPeer(PaykitReceiverPaths.SERVER, LinkedPeerState.BLOCKED))) + whenever(sdk.linkedPeers()).thenReturn(listOf(contactPeer(LinkedPeerState.BLOCKED))) val service = PaykitSdkService(mock(), mock(), mock()) { sdk } - assertNull(service.clearPrivatePaymentList(RING_PUBKY, PaykitReceiverPaths.SERVER)) - verify(sdk, never()).clearPrivatePaymentListAndProcessOutbound(any(), any()) + assertNull(service.clearPrivatePaymentList(RING_PUBKY)) + verify(sdk, never()).clearPrivatePaymentListAndProcessOutbound(any()) } - 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, @@ -471,11 +593,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 @@ -512,86 +632,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() @@ -657,21 +697,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), @@ -680,6 +705,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 ea6ff80aa3..40d00af7d9 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceWipeTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceWipeTest.kt @@ -2,17 +2,10 @@ package to.bitkit.services 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 kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineStart @@ -21,7 +14,6 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher 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 @@ -48,62 +40,6 @@ 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) - } - 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) - - assertEquals("state_load_failed", assertFailsWith { store.loadStateBlob() }.code) - assertNull(store.loadStateBlob()) - - 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 - } - native.`when` { decodeSdkStateBlobSnapshot(encoded) }.thenAnswer { snapshot } - whenever(blocking.upsert(key, encoded)).thenAnswer { throw KeychainError.FailedToSave(key) }.thenAnswer { - whenever(blocking.load(key)).thenReturn(encoded) - } - - 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, - ) - } - } - @Test fun `session storage callbacks translate keychain failures and recover on retry`() { val keychain = mock() @@ -139,21 +75,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() @@ -171,41 +103,29 @@ class PaykitSdkServiceWipeTest { val service = PaykitSdkService(mock(), keychain, store, { bootstrap }, dispatcher) { 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) - - 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()) + native.`when` { requiredSessionCapabilities() }.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) - private fun stubReceiverNoiseSecret(keychain: Keychain) { - val blocking = mock() - whenever(keychain.accessBlocking(any())).doAnswer { - it.getArgument Any?>(0).invoke(blocking) + 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()) } - whenever(blocking.load(Keychain.Key.PAYKIT_RECEIVER_NOISE_SECRET_KEY.name)) - .thenReturn(ByteArray(32) { 1 }) } } 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 585e559f59..0c03d9fff6 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 @@ -52,7 +52,7 @@ class ContactDetailViewModelTest : BaseUnitTest() { override fun now() = now } 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/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 9625990a2a..cfbc3e09f3 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 @@ -192,7 +192,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/usecases/RefreshContactPaykitReceiversUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/RefreshContactPaykitLinkUseCaseTest.kt similarity index 68% rename from app/src/test/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCaseTest.kt rename to app/src/test/java/to/bitkit/usecases/RefreshContactPaykitLinkUseCaseTest.kt index 052a91a001..7470d823cb 100644 --- a/app/src/test/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/RefreshContactPaykitLinkUseCaseTest.kt @@ -15,7 +15,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 +32,7 @@ class RefreshContactPaykitReceiversUseCaseTest : BaseUnitTest() { }, ) - private val sut = RefreshContactPaykitReceiversUseCase( + private val sut = RefreshContactPaykitLinkUseCase( ioDispatcher = testDispatcher, pubkyRepo = pubkyRepo, privatePaykitRepo = privatePaykitRepo, @@ -44,8 +44,7 @@ class RefreshContactPaykitReceiversUseCaseTest : BaseUnitTest() { } @Test - fun `refreshes receiver paths before publishing the contact`() = test { - whenever { pubkyRepo.refreshContactReceiverPaths(contactKeys.last()) }.thenReturn(Result.success(Unit)) + fun `refreshes contact endpoints before starting the link burst`() = test { whenever { privatePaykitRepo.refreshSavedContactEndpoints(contactKeys.last(), contactKeys) }.thenReturn(Result.success(Unit)) @@ -53,22 +52,22 @@ class RefreshContactPaykitReceiversUseCaseTest : BaseUnitTest() { val result = sut(contactKeys.last()) assertTrue(result.isSuccess) - inOrder(pubkyRepo, privatePaykitRepo).apply { - verify(pubkyRepo).refreshContactReceiverPaths(contactKeys.last()) + inOrder(privatePaykitRepo).apply { verify(privatePaykitRepo).refreshSavedContactEndpoints(contactKeys.last(), contactKeys) - verify(privatePaykitRepo).startInitialLinkBurst(contactKeys, "contact receiver refresh") + verify(privatePaykitRepo).startInitialLinkBurst(contactKeys, "contact link refresh") } } @Test - fun `stops when receiver discovery fails`() = test { - val error = IllegalStateException("Discovery failed") - whenever { pubkyRepo.refreshContactReceiverPaths(contactKeys.last()) }.thenReturn(Result.failure(error)) + fun `stops when endpoint refresh fails`() = 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") + verify(privatePaykitRepo, never()).startInitialLinkBurst(contactKeys, "contact link refresh") } } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index e1971da6d7..89a4a487b0 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -181,7 +181,7 @@ 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.timedsheets.TimedSheetManager import java.math.BigDecimal @@ -241,7 +241,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) @@ -385,8 +385,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever { pubkyRepo.republishIdentityIfNeeded() }.thenReturn(Result.success(Unit)) whenever { pubkyRepo.hasIdentity() }.thenAnswer { pubkyPublicKey.value != null } 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) @@ -410,6 +410,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } whenever(paykitPaymentRequestRepo.isPending(any())).thenReturn(true) whenever { paykitPaymentRequestRepo.ensurePaymentAllowed(any()) }.thenReturn(Result.success(Unit)) + whenever { paykitPaymentRequestRepo.claimForPayment(any()) }.thenReturn(Result.success(Unit)) whenever { lightningRepo.payInvoice(any(), anyOrNull(), any()) }.doSuspendableAnswer { if (it.getArgument Boolean>(2)()) { lightningRepo.payInvoice(it.getArgument(0), it.getArgument(1)) @@ -420,9 +421,9 @@ 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.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()) @@ -520,7 +521,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { paykitPaymentRequestRepo = paykitPaymentRequestRepo, paykitPaymentProofRepo = paykitPaymentProofRepo, paykitPaymentRequestDiagnostics = paykitPaymentRequestDiagnostics, - refreshContactPaykitReceivers = refreshContactPaykitReceivers, + refreshContactPaykitLink = refreshContactPaykitLink, samRockRepo = samRockRepo, appUpdateSheet = mock(), backupSheet = mock(), @@ -887,7 +888,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.setIsAuthenticated(true) val request = paymentRequest() val bolt11 = "lnbcrt1updatedpaymentrequest" - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 8uL) + val privateContext = privatePaymentContext(8uL) whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.beginPaymentRequest(request)).thenReturn( Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList), @@ -996,7 +997,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { Result.success( PublicPaykitPaymentResult.Opened( paymentRequest = bolt11, - privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 8uL), + privatePaymentContext = privatePaymentContext(8uL), ), ), ) @@ -1023,7 +1024,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( @@ -1105,7 +1106,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { surfacedPaykitPaymentRequestIds += request.id setActiveContactPaymentContext( publicKey = testPublicKey, - privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 7uL), + privatePaymentContext = privatePaymentContext(7uL), incomingPaymentRequest = request, ) setRequestedPaymentRequestId(request.id) @@ -1169,7 +1170,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { Result.success( PublicPaykitPaymentResult.Opened( paymentRequest = bolt11, - privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 8uL), + privatePaymentContext = privatePaymentContext(8uL), ), ), ) @@ -1184,7 +1185,6 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val subscriptionId = PaykitSubscriptionId( request.paymentRequestId, request.counterparty, - request.counterpartyReceiverPath, ) sut.showSheet(Sheet.Subscription(SubscriptionRoute.Review(subscriptionId))) sut.openIncomingPaymentRequest(request.id) @@ -1950,7 +1950,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { Result.success( PublicPaykitPaymentResult.Opened( paymentRequest = automaticInvoice, - privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 7uL), + privatePaymentContext = privatePaymentContext(7uL), ), ) } @@ -2096,7 +2096,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { setActiveContactPaymentContext(manualContext.publicKey) PublicPaykitPaymentResult.Opened( paymentRequest = "lnbcrt1incoming", - privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 7uL), + privatePaymentContext = privatePaymentContext(7uL), ) } pendingPaykitPaymentRequests.value = listOf(request) @@ -2129,7 +2129,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { Result.success( PublicPaykitPaymentResult.Opened( paymentRequest = requestInvoice, - privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 7uL), + privatePaymentContext = privatePaymentContext(7uL), ), ) } @@ -2349,7 +2349,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { Result.success( PublicPaykitPaymentResult.Opened( paymentRequest = requestInvoice, - privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 7uL), + privatePaymentContext = privatePaymentContext(7uL), ), ) } @@ -2488,7 +2488,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)) @@ -3039,7 +3039,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() } - verify(refreshContactPaykitReceivers).invoke(testPublicKey) + verify(refreshContactPaykitLink).invoke(testPublicKey) } @Test @@ -3109,7 +3109,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { expectNoEvents() } verify(pubkyRepo, never()).loadContacts() - verify(refreshContactPaykitReceivers).invoke(testPublicKey) + verify(refreshContactPaykitLink).invoke(testPublicKey) verify(coreService, never()).decode(any()) } @@ -3133,7 +3133,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() } verify(pubkyRepo).loadContacts() - verify(refreshContactPaykitReceivers).invoke(testPublicKey) + verify(refreshContactPaykitLink).invoke(testPublicKey) verify(coreService, never()).decode(any()) } @@ -3160,7 +3160,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() } verify(pubkyRepo).loadContacts() - verify(refreshContactPaykitReceivers).invoke(testPublicKey) + verify(refreshContactPaykitLink).invoke(testPublicKey) verify(coreService, never()).decode(any()) } @@ -6134,7 +6134,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( @@ -6179,7 +6179,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", @@ -6206,7 +6206,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", @@ -6235,7 +6235,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", @@ -6304,7 +6304,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)) @@ -6342,7 +6342,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { beforeSendAttempt = any(), onBroadcast = any(), ) - verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue) + verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue, "bitkit") } @Test @@ -6354,7 +6354,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.openContactPayment( paymentRequest = bolt11, publicKey = testPublicKey, - privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 7uL), + privatePaymentContext = privatePaymentContext(7uL), incomingPaymentRequest = request, ) advanceUntilIdle() @@ -6390,7 +6390,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)) @@ -6414,7 +6414,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)) @@ -6434,21 +6434,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)) @@ -6467,9 +6471,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(), @@ -6486,12 +6490,12 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @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) 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)) @@ -6511,29 +6515,34 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ), ) - sut.sendEffect.test { - confirmCurrentPayment() - - assertTrue(awaitItem() is SendEffect.NavigateToPending) - } + confirmCurrentPayment() + 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)) @@ -6552,10 +6561,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 @@ -6564,14 +6577,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 { @@ -6581,7 +6597,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)) @@ -6598,10 +6621,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) } } @@ -6611,9 +6637,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) @@ -6637,13 +6670,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) } @@ -6658,9 +6694,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) @@ -6685,13 +6728,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) @@ -6703,7 +6749,7 @@ 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) + val privateContext = privatePaymentContext(7uL) val lnurl = LnurlPayData( uri = "lnurl1failedrequest", callback = "https://example.com/callback", @@ -6771,9 +6817,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) @@ -6809,9 +6862,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( @@ -6826,7 +6886,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()) @@ -6836,8 +6898,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( @@ -6854,7 +6918,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)) @@ -6881,7 +6945,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)) @@ -6913,7 +6977,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)) @@ -6933,7 +6997,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( @@ -6954,7 +7020,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `hardware retry denial keeps the started proof until 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)) @@ -7002,7 +7068,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @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)) @@ -7029,7 +7095,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `dismissing 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)) @@ -7056,10 +7122,12 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @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)) @@ -7075,23 +7143,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() @@ -7116,13 +7184,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) @@ -7142,7 +7210,7 @@ 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) @@ -7164,7 +7232,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { confirmCurrentPayment() - verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue) + verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue, "bitkit") verify(paykitPaymentRequestRepo).refresh() } @@ -7378,7 +7446,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)) @@ -7414,7 +7482,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)) @@ -7453,7 +7521,7 @@ 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 @@ -7466,7 +7534,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { )) { 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)) @@ -7506,7 +7574,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) @@ -7592,6 +7660,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @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") @@ -7602,7 +7671,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", @@ -7647,7 +7718,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)) @@ -7671,7 +7742,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()) } @@ -7680,7 +7753,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"))) @@ -7717,7 +7790,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)) @@ -7757,7 +7830,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( @@ -7782,7 +7855,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( @@ -7817,7 +7890,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( @@ -7840,7 +7913,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { 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( @@ -7894,7 +7967,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)) @@ -7929,7 +8002,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))) @@ -7955,7 +8028,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"))) @@ -8298,7 +8371,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pubkyPublicKey.value = testPublicKey advanceUntilIdle() - verify(publicPaykitRepo).syncLocalReceiverMarker() + verify(publicPaykitRepo).syncPaykitApp() verify(privatePaykitRepo, never()).prepareSavedContacts(any>(), any()) verify(privatePaykitRepo, never()).pruneUnsavedContactState(any>()) @@ -8380,7 +8453,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()) } @@ -8394,7 +8467,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.refreshPrivatePaykitEndpoints() advanceUntilIdle() - verify(publicPaykitRepo).syncLocalReceiverMarker() + verify(publicPaykitRepo).syncPaykitApp() } private suspend fun TestScope.confirmCurrentPayment() { @@ -8527,7 +8600,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( @@ -8738,10 +8811,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, @@ -8751,7 +8832,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..e9b71e7433 100644 --- a/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt @@ -80,7 +80,7 @@ class SettingsViewModelTest : BaseUnitTest() { 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.syncPaykitApp(anyOrNull()) }.thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.disableSharingAndPruneUnsavedContactState(any>()) } .thenReturn(Result.success(Unit)) @@ -145,7 +145,7 @@ class SettingsViewModelTest : BaseUnitTest() { assertFalse(settingsData.value.publicPaykitCleanupPending) verify(publicPaykitRepo, never()).syncPublishedEndpoints(publish = false) - verify(publicPaykitRepo).syncLocalReceiverMarker(publicSharingEnabled = false, privateSharingEnabled = false) + verify(publicPaykitRepo).syncPaykitApp(privateSharingEnabled = false) verify(privatePaykitRepo).disableSharingAndPruneUnsavedContactState(contacts.value.map { it.publicKey }) } @@ -153,8 +153,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, ) } @@ -169,7 +168,7 @@ class SettingsViewModelTest : BaseUnitTest() { assertTrue(settingsData.value.publicPaykitCleanupPending) verify(publicPaykitRepo, never()).syncPublishedEndpoints(publish = false) - verify(publicPaykitRepo).syncLocalReceiverMarker(publicSharingEnabled = false, privateSharingEnabled = false) + verify(publicPaykitRepo).syncPaykitApp(privateSharingEnabled = false) verify(privatePaykitRepo).disableSharingAndPruneUnsavedContactState(contacts.value.map { it.publicKey }) } 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/paykit-shared-runtime.changed.md b/changelog.d/next/paykit-shared-runtime.changed.md new file mode 100644 index 0000000000..3299757cb7 --- /dev/null +++ b/changelog.d/next/paykit-shared-runtime.changed.md @@ -0,0 +1 @@ +Share Paykit contacts and payment state with authorized apps while keeping wallet keys private, and label received payments with their Paykit payer contact. diff --git a/docs/paykit-issuer-interoperability.md b/docs/paykit-issuer-interoperability.md index 1f30e14df5..6b38bb5e25 100644 --- a/docs/paykit-issuer-interoperability.md +++ b/docs/paykit-issuer-interoperability.md @@ -74,7 +74,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..6a724d6685 --- /dev/null +++ b/docs/pubky-auth-companion-claims.md @@ -0,0 +1,60 @@ +# 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. + +## 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 12b81d2ebe..75be389dca 100644 --- a/docs/pubky.md +++ b/docs/pubky.md @@ -27,11 +27,22 @@ 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:0.1.0-rc57` from GitHub Packages. 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). All calls are dispatched on `ServiceQueue.CORE` (single-thread executor) to ensure serial access to the underlying Rust state. +### 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..f1edd44eb0 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-rc58" } 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 8c24ba27d6..f53c2d0005 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) | diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index f6c0b4074f..1bde13b8ce 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,7 +18,7 @@ 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. `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. @@ -59,10 +59,10 @@ That run established the issuer shapes captured by the fixture: lowercase `btc`, ## Payment deadline history -`payment-deadline-history.xml` covers rc56 requests with actual-payment deadlines. +`payment-deadline-history.xml` covers requests with actual-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 +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 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/delete-and-readd-contact.xml b/journeys/payment-requests/delete-and-readd-contact.xml index e740907019..9c03487386 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" diff --git a/journeys/payment-requests/issuer-interoperability.xml b/journeys/payment-requests/issuer-interoperability.xml index bfe6b3ad8a..bd1e42760f 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 diff --git a/journeys/payment-requests/payment-deadline-history.xml b/journeys/payment-requests/payment-deadline-history.xml index 1ba8b15b3d..ad08b9421b 100644 --- a/journeys/payment-requests/payment-deadline-history.xml +++ b/journeys/payment-requests/payment-deadline-history.xml @@ -1,7 +1,7 @@ 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 + Use a disposable Paykit test identity linked to a controlled shared-runtime peer. Prepare one-time BTC requests with 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 diff --git a/journeys/payment-requests/request-summary.xml b/journeys/payment-requests/request-summary.xml index c4da618fdf..1e341de5af 100644 --- a/journeys/payment-requests/request-summary.xml +++ b/journeys/payment-requests/request-summary.xml @@ -1,6 +1,6 @@ - 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. On the requester, send the payer contact a Payment Request for 21,000 sats with the note "Lunch last week" diff --git a/journeys/pubky-marketplace/README.md b/journeys/pubky-marketplace/README.md index 60e837a9ac..bbcdbffd41 100644 --- a/journeys/pubky-marketplace/README.md +++ b/journeys/pubky-marketplace/README.md @@ -5,6 +5,23 @@ watch-only account claim, a linked buyer receives the resulting Payment Request, 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: @@ -12,7 +29,7 @@ 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`. + auth URL carries `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,8 +50,9 @@ 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 fixture must use the same shared-runtime SDK and the +[companion claim contract](../../docs/pubky-auth-companion-claims.md). The request and endpoint must satisfy the issuer contract from Android issue [#1208](https://github.com/synonymdev/bitkit-android/issues/1208): lowercase `btc`, a @@ -43,14 +61,12 @@ string `value`. The fixture must keep watch-only account material and spending a Evidence must show the claimed account xpub and account index while omitting wallet seed material and tokens. -Use the pinned +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. +runtime is a reference for the marketplace driver and Locks harness, not a shared-runtime +acceptance fixture. Use a fixture revision updated for the companion claim contract above and +record its exact revisions. 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 @@ -90,8 +106,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 | @@ -101,68 +117,3 @@ Keep these artifacts at each boundary: `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. diff --git a/journeys/pubky-marketplace/paykit-only-approval.xml b/journeys/pubky-marketplace/paykit-only-approval.xml new file mode 100644 index 0000000000..340ee26eb4 --- /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 no Earn introduction, watch-only consent (id "PubkyAuthWatchOnlyConsent"), or account picker is 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..82325a8e74 --- /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 or an account picker + 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..b11efee9c2 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,6 +46,7 @@ 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 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/create-and-propose.xml b/journeys/subscriptions/create-and-propose.xml index 382dda8e6f..a548c004b6 100644 --- a/journeys/subscriptions/create-and-propose.xml +++ b/journeys/subscriptions/create-and-propose.xml @@ -5,7 +5,7 @@ the two linked Bitkit instances described in README.md. - 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") 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 { From bfe5c7f7e85643f43d75c031f2c697e420813f57 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 30 Sep 2026 22:55:41 -0500 Subject: [PATCH 02/77] chore: rename changelog fragment --- .../next/{paykit-shared-runtime.changed.md => 1401.changed.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{paykit-shared-runtime.changed.md => 1401.changed.md} (100%) diff --git a/changelog.d/next/paykit-shared-runtime.changed.md b/changelog.d/next/1401.changed.md similarity index 100% rename from changelog.d/next/paykit-shared-runtime.changed.md rename to changelog.d/next/1401.changed.md From 7d4fcde8a9bd005eee2305335e286436cc793533 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 30 Sep 2026 23:12:03 -0500 Subject: [PATCH 03/77] docs: clarify paykit integration contracts --- docs/pubky.md | 2 +- journeys/payment-requests/request-summary.xml | 2 +- journeys/pubky-marketplace/README.md | 47 +++++-------------- .../paykit-only-approval.xml | 2 +- .../pubky-marketplace/paykit-reconnect.xml | 2 +- 5 files changed, 15 insertions(+), 40 deletions(-) diff --git a/docs/pubky.md b/docs/pubky.md index 75be389dca..180a8f01e3 100644 --- a/docs/pubky.md +++ b/docs/pubky.md @@ -30,7 +30,7 @@ Delegates Pubky operations to `PaykitSdkService`, which uses: 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:0.1.0-rc57` from GitHub Packages. 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). +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). All calls are dispatched on `ServiceQueue.CORE` (single-thread executor) to ensure serial access to the underlying Rust state. diff --git a/journeys/payment-requests/request-summary.xml b/journeys/payment-requests/request-summary.xml index 1e341de5af..118ee10abb 100644 --- a/journeys/payment-requests/request-summary.xml +++ b/journeys/payment-requests/request-summary.xml @@ -8,7 +8,7 @@ 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 diff --git a/journeys/pubky-marketplace/README.md b/journeys/pubky-marketplace/README.md index bbcdbffd41..e5da87d9b9 100644 --- a/journeys/pubky-marketplace/README.md +++ b/journeys/pubky-marketplace/README.md @@ -1,7 +1,7 @@ # 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. @@ -27,9 +27,9 @@ bytes and no Paykit secret. Unknown, duplicate, mismatched, or wrong-sized claim 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=paykit-access-v1.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. @@ -51,41 +51,17 @@ 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, or use a Ring identity whose root secret is available to Bitkit. -The fixture must use the same shared-runtime SDK and the -[companion claim contract](../../docs/pubky-auth-companion-claims.md). -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. -The pinned -[`BitcoinErrorLog/pubky-marketplace/payments-env`](https://github.com/BitcoinErrorLog/pubky-marketplace/tree/ed03a32ecfe02deab40ad10ae1bac7fa18465c10/payments-env) -runtime is a reference for the marketplace driver and Locks harness, not a shared-runtime -acceptance fixture. Use a fixture revision updated for the companion claim contract above and -record its exact revisions. 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 @@ -115,5 +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). +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 index 340ee26eb4..06f77fa5fa 100644 --- a/journeys/pubky-marketplace/paykit-only-approval.xml +++ b/journeys/pubky-marketplace/paykit-only-approval.xml @@ -11,7 +11,7 @@ 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 no Earn introduction, watch-only consent (id "PubkyAuthWatchOnlyConsent"), or account picker is shown. + 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 index 82325a8e74..259599f0d5 100644 --- a/journeys/pubky-marketplace/paykit-reconnect.xml +++ b/journeys/pubky-marketplace/paykit-reconnect.xml @@ -9,7 +9,7 @@ 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 or an account picker + 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 From dd190bf793593be7f961c034fbd420f5259fc5a8 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 30 Sep 2026 23:52:02 -0500 Subject: [PATCH 04/77] fix: tighten paykit attribution and cleanup reporting --- .../PrivatePaykitContactResolver.kt | 24 +- .../bitkit/repositories/PrivatePaykitRepo.kt | 19 +- .../java/to/bitkit/services/CoreService.kt | 59 +++-- .../to/bitkit/services/LightningService.kt | 24 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 8 - .../PrivatePaykitContactResolverTest.kt | 27 +- .../repositories/PrivatePaykitRepoTest.kt | 11 +- .../ActivityServicePaykitContactsTest.kt | 242 ++++++++++++++++-- .../bitkit/services/LightningServiceTest.kt | 30 +++ .../viewmodels/AppViewModelSendFlowTest.kt | 6 +- 10 files changed, 367 insertions(+), 83 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt index b302089f40..7d83815b5a 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt @@ -35,14 +35,28 @@ class PrivatePaykitContactResolver @Inject constructor( .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) { + if (receivingAddress.isNullOrBlank() || receivingAddress !in addresses) return@withContext null val shared = receivedPaymentContacts - val contacts = addresses.mapNotNull { - addressReservationRepo.get().contactPublicKeyForReservedAddress(it) + 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 - (contacts + shared.contactsForAddresses(addresses)) - .mapNotNull(PubkyPublicKeyFormat::normalized).distinct().singleOrNull() + contact.takeIf { + (reservedContacts.values.filterNotNull() + shared.contactsForAddresses(addresses)) + .mapNotNull(PubkyPublicKeyFormat::normalized).distinct().singleOrNull() == contact + } } } diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 57cba64f4e..e8796cbe1b 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -303,17 +303,7 @@ class PrivatePaykitRepo @Inject constructor( withContext(serializedDispatcher) { runSuspendCatching { updateContactSharingCleanupPending(true) - val removalError = removePublishedEndpoints().exceptionOrNull() - if (removalError != null) { - updateContactSharingCleanupPending(true) - Logger.warn( - "Deferred private Paykit endpoint cleanup after disable failed", - removalError, - context = TAG, - ) - return@runSuspendCatching - } - + removePublishedEndpoints().getOrThrow() clearUnsavedContactState(savedPublicKeys).getOrThrow() updateContactSharingCleanupPending(false) publicPaykitRepo.syncPaykitApp().onFailure { @@ -519,13 +509,6 @@ class PrivatePaykitRepo @Inject constructor( } } - suspend fun contactPublicKeyForPrivateOnchainAddresses(addresses: Collection): String? = - withContext(serializedDispatcher) { - addresses.firstNotNullOfOrNull { - addressReservationRepo.contactPublicKeyForReservedAddress(it) - } - } - suspend fun backupSnapshot(): Result = withContext(serializedDispatcher) { runSuspendCatching { diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index bc798bdc73..1ff13f5362 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -588,7 +588,7 @@ class ActivityService( if (resolver.receivedPaymentContacts !== contacts) break val contact = when (activity) { is Activity.Lightning -> receivedPaykitContact(activity.v1, contacts) - is Activity.Onchain -> receivedPaykitContact(activity.v1, contacts) + is Activity.Onchain -> receivedPaykitContact(activity.v1) } if (contact != null && resolver.receivedPaymentContacts === contacts) { val updated = when (activity) { @@ -607,12 +607,13 @@ class ActivityService( return contacts.contactsForPaymentHash(row.id).singleOrNull() } - private fun receivedPaykitContact(row: OnchainActivity, contacts: PaykitReceivedPaymentContacts): String? { + private suspend fun receivedPaykitContact(row: OnchainActivity): String? { if (row.contact != null || row.txType != PaymentType.RECEIVED) return null val details = getBitkitCoreTransactionDetails(walletId = row.walletId, txId = row.txId) ?: return null - if (details.outputs.isEmpty()) return null - val addresses = listOfNotNull(row.address) + details.outputs.mapNotNull { it.scriptpubkeyAddress } - return contacts.contactsForAddresses(addresses).singleOrNull() + return privatePaykitContactResolver.get().contactPublicKeyForPrivateOnchainAddresses( + receivingAddress = row.address, + addresses = details.outputs.mapNotNull { it.scriptpubkeyAddress }, + ) } suspend fun delete(id: String, walletId: String = defaultWalletId): Boolean = ServiceQueue.CORE.background { @@ -921,11 +922,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}'", @@ -950,7 +950,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 } @@ -977,23 +977,45 @@ 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 addresses = fetchAddressSearchBatch(addressType, isChange, index, addressTypeKey, accountIndex) + ?: return null if ( currentWalletAddress.isNotBlank() && @@ -1021,14 +1043,16 @@ class ActivityService( isChange: Boolean, index: Int, addressTypeKey: String, + 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, + accountIndex = accountIndex, ).map { it.address } }.onFailure { Logger.warn( @@ -1250,15 +1274,17 @@ 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 && payment.status != PaymentStatus.FAILED && - !transactionDetails?.outputs.isNullOrEmpty() + !details?.outputs.isNullOrEmpty() ) { privatePaykitContactResolver.get().contactPublicKeyForPrivateOnchainAddresses( - listOfNotNull(resolvedAddress) + - transactionDetails.outputs.mapNotNull { it.scriptpubkeyAddress }, + receivingAddress = resolvedAddress, + addresses = details.outputs.mapNotNull { it.scriptpubkeyAddress }, ) } else { null @@ -1310,9 +1336,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") diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 71b75c30ef..b00abd93fd 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 @@ -114,6 +115,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" @@ -679,14 +687,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 +705,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() @@ -1330,13 +1345,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/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index ee66d5c496..6c1f832744 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1698,15 +1698,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) diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitContactResolverTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitContactResolverTest.kt index a9bacffdc8..500c8252a3 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitContactResolverTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitContactResolverTest.kt @@ -61,7 +61,7 @@ class PrivatePaykitContactResolverTest : BaseUnitTest() { whenever(paymentRequestRepo.receivedPaymentContacts).thenReturn(shared) whenever(addressReservationRepo.contactPublicKeyForReservedAddress(PRIVATE_ADDRESS)).thenReturn(CONTACT_KEY) - assertNull(sut.contactPublicKeyForPrivateOnchainAddresses(listOf(PRIVATE_ADDRESS))) + assertNull(sut.contactPublicKeyForPrivateOnchainAddresses(PRIVATE_ADDRESS, listOf(PRIVATE_ADDRESS))) } @Test @@ -74,7 +74,7 @@ class PrivatePaykitContactResolverTest : BaseUnitTest() { null } - assertNull(sut.contactPublicKeyForPrivateOnchainAddresses(listOf(PRIVATE_ADDRESS))) + assertNull(sut.contactPublicKeyForPrivateOnchainAddresses(PRIVATE_ADDRESS, listOf(PRIVATE_ADDRESS))) } @Test @@ -123,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) + + 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 e74e85a7c7..4e75d489c9 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -377,7 +377,9 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { privateListDeliveryReport() }.whenever(paykitSdkService).clearPrivatePaymentList(CONTACT_KEY) - sut.disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) + val result = sut.disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) + + assertTrue(result.isFailure, failureStage) assertTrue(failed, failureStage) assertTrue(cacheData.value.cleanupPending, failureStage) publicRepo.syncPaykitApp(privateSharingEnabled = false).getOrThrow() @@ -408,13 +410,14 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @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() + settingsData.value = settingsData.value.copy(sharesPrivatePaykitEndpoints = false) whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY) }.thenReturn( privateListDeliveryReport( failedToQueue = listOf( @@ -429,8 +432,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 diff --git a/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt b/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt index 3a3076c3be..88f206c57d 100644 --- a/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt +++ b/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt @@ -1,6 +1,7 @@ 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 @@ -10,44 +11,84 @@ 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.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.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 javax.inject.Provider import kotlin.test.assertEquals 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 resolver = mock() - private val sut by lazy { ActivityService(mock(), mock(), mock(), mock(), Provider { resolver }) } + 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() @Test - fun `backfill matches cached outputs and preserves all other onchain metadata`() = coreTest { - val original = onchain(address = "unknown") + 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) } - whenever(contacts.contactsForAddresses(listOf("unknown", "request-address", "change-address"))) - .thenReturn(setOf("buyer")) + sharedAddresses("request-address" to BUYER) assertTrue(sut.backfillPaykitContacts()) - assertEquals(listOf(Activity.Onchain(original.copy(contact = "buyer"))), updates) - verify(contacts).contactsForAddresses(listOf("unknown", "request-address", "change-address")) + assertEquals(listOf(Activity.Onchain(original.copy(contact = BUYER))), updates) + verify(contacts).contactsForAddresses(listOf("request-address", "change-address")) } @Test @@ -80,10 +121,9 @@ class ActivityServicePaykitContactsTest : BaseUnitTest() { @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("other-request-address")) + val outputs = listOf(output("request-address"), output("other-request-address")) details = mock { on { this.outputs }.thenReturn(outputs) } - whenever(contacts.contactsForAddresses(listOf("request-address", "other-request-address"))) - .thenReturn(setOf("buyer", "other-buyer")) + sharedAddresses("request-address" to BUYER, "other-request-address" to OTHER_BUYER) assertFalse(sut.backfillPaykitContacts()) assertTrue(updates.isEmpty()) @@ -92,7 +132,7 @@ class ActivityServicePaykitContactsTest : BaseUnitTest() { @Test fun `backfill waits for complete cached outputs even when stored address matches`() = coreTest { rows = listOf(Activity.Onchain(onchain(address = "request-address"))) - whenever(contacts.contactsForAddresses(listOf("request-address"))).thenReturn(setOf("buyer")) + sharedAddresses("request-address" to BUYER) assertFalse(sut.backfillPaykitContacts()) details = mock { on { outputs }.thenReturn(emptyList()) } @@ -100,8 +140,6 @@ class ActivityServicePaykitContactsTest : BaseUnitTest() { val outputs = listOf(output("other-request-address")) details = mock { on { this.outputs }.thenReturn(outputs) } - whenever(contacts.contactsForAddresses(listOf("request-address", "other-request-address"))) - .thenReturn(setOf("buyer", "other-buyer")) assertFalse(sut.backfillPaykitContacts()) assertTrue(updates.isEmpty()) } @@ -110,7 +148,7 @@ class ActivityServicePaykitContactsTest : BaseUnitTest() { fun `backfill refuses stale identity snapshot before write`() = coreTest { rows = listOf(Activity.Lightning(lightning())) whenever(contacts.contactsForPaymentHash(any())).thenAnswer { - whenever(resolver.receivedPaymentContacts).thenReturn(PaykitReceivedPaymentContacts.Empty) + whenever(requestRepo.receivedPaymentContacts).thenReturn(PaykitReceivedPaymentContacts.Empty) setOf("buyer") } @@ -130,8 +168,169 @@ class ActivityServicePaykitContactsTest : BaseUnitTest() { 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 `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 200), + cacheData.value.addressSearchLastUsedReceiveIndexes, + ) + } + + @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, never()).addressInfosForType(AddressType.P2WPKH, true, 1200, 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(resolver.receivedPaymentContacts).thenReturn(contacts) + whenever(requestRepo.receivedPaymentContacts).thenReturn(contacts) + 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`> { @@ -145,11 +344,22 @@ class ActivityServicePaykitContactsTest : BaseUnitTest() { 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( 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/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 89a4a487b0..57a863a403 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -465,8 +465,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())) @@ -4815,11 +4813,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) From 517114b72719bce38f9e35c355c9a98fd28c6210 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 1 Oct 2026 00:06:49 -0500 Subject: [PATCH 05/77] test: cover paykit key generation and rotation --- .../PaykitReceivedPaymentContacts.kt | 12 +- .../to/bitkit/services/PaykitSdkService.kt | 2 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 2 +- .../PaykitPaymentProofRepoTest.kt | 3 +- .../PaykitPaymentRequestRepoTest.kt | 8 +- .../PaykitReceivedPaymentContactsTest.kt | 111 ++++++++------ .../repositories/PrivatePaykitRepoTest.kt | 12 +- .../services/PaykitKeyGenerationTest.kt | 141 ++++++++++++++++++ .../RefreshContactPaykitLinkUseCaseTest.kt | 10 +- 9 files changed, 232 insertions(+), 69 deletions(-) create mode 100644 app/src/test/java/to/bitkit/services/PaykitKeyGenerationTest.kt diff --git a/app/src/main/java/to/bitkit/repositories/PaykitReceivedPaymentContacts.kt b/app/src/main/java/to/bitkit/repositories/PaykitReceivedPaymentContacts.kt index 1487aeb5e6..11584865a2 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitReceivedPaymentContacts.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitReceivedPaymentContacts.kt @@ -14,11 +14,6 @@ internal class PaykitReceivedPaymentContacts private constructor( private val addresses: Map>, private val paymentHashes: Map>, ) { - fun contactsForAddresses(values: Collection): Set = - values.flatMapTo(mutableSetOf()) { addresses[it].orEmpty() } - - fun contactsForPaymentHash(value: String): Set = paymentHashes[value.lowercase()].orEmpty() - companion object { val Empty = PaykitReceivedPaymentContacts(emptyMap(), emptyMap()) @@ -32,7 +27,7 @@ internal class PaykitReceivedPaymentContacts private constructor( for (record in records) { val contact = validCounterparty(record) ?: continue val terms = checkNotNull(record.terms) - // Only immutable request destinations identify a payer; proofs and current lists do not. + // 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) { @@ -87,4 +82,9 @@ internal class PaykitReceivedPaymentContacts private constructor( 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/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 5d91bce963..3316316e99 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -571,7 +571,7 @@ 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, diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 6c1f832744..1affda6575 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -4359,7 +4359,7 @@ class AppViewModel @Inject constructor( private suspend fun preparePaymentProof(request: PaykitPaymentRequest?): Result { if (request == null) return Result.success(null) - val preparation = runCatching { paymentProofPreparation() }.getOrElse { return Result.failure(it) } + val preparation = runSuspendCatching { paymentProofPreparation() }.getOrElse { return Result.failure(it) } return paykitPaymentProofRepo.prepare( request = request, paymentEndpointIdentifier = preparation.endpointIdentifier, diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index c91bd32910..083dd620e8 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -10,6 +10,7 @@ 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.test.StandardTestDispatcher import org.junit.Before import org.junit.Test @@ -70,7 +71,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(store.hasPendingProofs()).thenReturn(true) whenever( paykitSdkService.identityStatus() - ).thenReturn(IdentityStatus(LOCAL_IDENTITY, com.synonym.paykit.PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + ).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 { diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index c959758f35..76fe9c3971 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -136,12 +136,12 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { val record = paymentRequestRecord(role = PaymentRequestLocalRole.PAYEE).let { it.copy( proposalAppId = "marketplace", - terms = it.terms!!.copy( + terms = requireNotNull(it.terms).copy( acceptedPaymentEndpointIdentifiers = listOf(MethodId.P2wpkh.rawValue), paymentEndpoints = mapOf( - MethodId.P2wpkh.rawValue to PaykitReceivedPaymentContactsTest.payload(address) + MethodId.P2wpkh.rawValue to PaykitReceivedPaymentContactsTest.payload(address), ), - ) + ), ) } whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) @@ -152,7 +152,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { } assertEquals( setOf(PubkyPublicKeyFormat.normalized(COUNTERPARTY)), - sut.receivedPaymentContacts.contactsForAddresses(listOf(address)) + sut.receivedPaymentContacts.contactsForAddresses(listOf(address)), ) assertTrue(sut.pendingRequests.value.isEmpty()) assertTrue(sut.paymentRequestHistory.value.isEmpty()) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitReceivedPaymentContactsTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitReceivedPaymentContactsTest.kt index ecf380e884..2658ab80e3 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitReceivedPaymentContactsTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitReceivedPaymentContactsTest.kt @@ -22,6 +22,70 @@ 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()) @@ -69,7 +133,7 @@ class PaykitReceivedPaymentContactsTest { ) assertTrue( index(*records.toTypedArray()) - .contactsForAddresses(listOf(ADDRESS, MAINNET_ADDRESS, TESTNET_ADDRESS)).isEmpty() + .contactsForAddresses(listOf(ADDRESS, MAINNET_ADDRESS, TESTNET_ADDRESS)).isEmpty(), ) } @@ -78,7 +142,7 @@ class PaykitReceivedPaymentContactsTest { val identifier = "btc-signet-p2wpkh" val record = receivedRequest( accepted = listOf(identifier), - endpoints = mapOf(identifier to payload(TESTNET_ADDRESS)) + endpoints = mapOf(identifier to payload(TESTNET_ADDRESS)), ) val contacts = PaykitReceivedPaymentContacts.from(listOf(record), Network.SIGNET) assertEquals(setOf(BUYER), contacts.contactsForAddresses(listOf(TESTNET_ADDRESS))) @@ -91,7 +155,7 @@ class PaykitReceivedPaymentContactsTest { 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)) + endpoints = mapOf(identifier to payload(address)), ) assertEquals(setOf(BUYER), index(record).contactsForAddresses(listOf(address))) } @@ -180,45 +244,4 @@ class PaykitReceivedPaymentContactsTest { block() } } - - companion object { - const val BUYER = "pubky7don8zi885feihpjsyx7t53srod6z1n4xjiyaaxucpqarm6sh85o" - const val OTHER_BUYER = "pubkya3mduedw686dysw8ndr5c1dyry5h8k6i8hzbbmx9gf9k43zq4s9o" - const val ADDRESS = "bcrt1qfn50lqawrce0evh66qrnlt8j447lwmeyqp5gmd" - const val OTHER_ADDRESS = "bcrt1qpsps9chsjnnd3veems9phzlvw42em682rsj8hh" - const val MAINNET_ADDRESS = "bc1qexample" - const val TESTNET_ADDRESS = "tb1qexample" - val LEGACY_ADDRESSES = listOf("mlegacy", "nlegacy", "2legacy") - const val METHOD = "btc-regtest-p2wpkh" - const val BOLT11_METHOD = "btc-lightning-bolt11" - const val INVOICE = "lnbcrt1example" - const val MAINNET_INVOICE = "lnbc1example" - 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") - } - } } diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 4e75d489c9..ea185f8e51 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -1242,16 +1242,16 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { 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( + 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 { + whenever( + paykitSdkService.prepareAndResolvePrivatePaymentRequest(eq(CONTACT_KEY), any(), eq(7uL)), + ).thenAnswer { resolution(resolvedEndpoint(MethodId.P2wpkh, addresses.getValue(it.getArgument(1))), version = null) } 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..6b6c51f9bf --- /dev/null +++ b/app/src/test/java/to/bitkit/services/PaykitKeyGenerationTest.kt @@ -0,0 +1,141 @@ +package to.bitkit.services + +import com.synonym.paykit.IdentityStatus +import com.synonym.paykit.PaykitAppRegistry +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()) { 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 `stored root refresh rotates the session key and rejects 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) + whenever(sdk.identityStatus()) + .thenReturn(IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + val keychain = mock() + val storageKey = "${Keychain.Key.PAYKIT_KEY_GENERATION.name}:$RING_PUBKY" + whenever(keychain.loadString(storageKey)).thenReturn("2", "2", "3", "3") + val root = mock() + val initialKey = mock() + val rotatedKey = mock() + 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()) { sdk } + val provider = providers.constructed().single() + repeat(2) { assertEquals(RING_PUBKY, 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(2)).identityStatus() + verify(provider, times(2)).setPaykitIdentitySecretKey(any()) + } + } + } + + @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) + } + } +} diff --git a/app/src/test/java/to/bitkit/usecases/RefreshContactPaykitLinkUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/RefreshContactPaykitLinkUseCaseTest.kt index 7470d823cb..86a1abf911 100644 --- a/app/src/test/java/to/bitkit/usecases/RefreshContactPaykitLinkUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/RefreshContactPaykitLinkUseCaseTest.kt @@ -45,9 +45,8 @@ class RefreshContactPaykitLinkUseCaseTest : BaseUnitTest() { @Test fun `refreshes contact endpoints before starting the link burst`() = test { - whenever { - privatePaykitRepo.refreshSavedContactEndpoints(contactKeys.last(), contactKeys) - }.thenReturn(Result.success(Unit)) + whenever(privatePaykitRepo.refreshSavedContactEndpoints(contactKeys.last(), contactKeys)) + .thenReturn(Result.success(Unit)) val result = sut(contactKeys.last()) @@ -61,9 +60,8 @@ class RefreshContactPaykitLinkUseCaseTest : BaseUnitTest() { @Test fun `stops when endpoint refresh fails`() = test { val error = IllegalStateException("Endpoint refresh failed") - whenever { - privatePaykitRepo.refreshSavedContactEndpoints(contactKeys.last(), contactKeys) - }.thenReturn(Result.failure(error)) + whenever(privatePaykitRepo.refreshSavedContactEndpoints(contactKeys.last(), contactKeys)) + .thenReturn(Result.failure(error)) val result = sut(contactKeys.last()) From b5a231d6e6389b5f4eda0d5389660a2f473f6a69 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 1 Oct 2026 07:02:19 -0500 Subject: [PATCH 06/77] fix: guard paykit execution and cache contact backfills --- .../java/to/bitkit/data/keychain/Keychain.kt | 1 + .../PaykitPaymentRequestPresentationStore.kt | 23 ++ .../repositories/PaykitPaymentRequestRepo.kt | 61 ++++- .../PaykitReceivedPaymentContacts.kt | 3 +- .../PrivatePaykitAddressReservationRepo.kt | 10 +- .../PrivatePaykitContactResolver.kt | 6 + .../java/to/bitkit/services/CoreService.kt | 88 +++++-- ...ykitPaymentRequestPresentationStoreTest.kt | 44 ++++ ...aykitPaymentRequestRepoSubscriptionTest.kt | 30 +++ .../PaykitPaymentRequestRepoTest.kt | 221 +++++++++++++++++- .../PaykitReceivedPaymentContactsTest.kt | 11 + ...PrivatePaykitAddressReservationRepoTest.kt | 26 ++- .../ActivityServicePaykitContactsTest.kt | 119 +++++++++- .../viewmodels/AppViewModelSendFlowTest.kt | 5 + changelog.d/next/1401.changed.md | 2 +- journeys/payment-requests/README.md | 7 + .../accepted-device-ownership.xml | 21 ++ 17 files changed, 650 insertions(+), 28 deletions(-) create mode 100644 journeys/payment-requests/accepted-device-ownership.xml 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 481fa0af58..1a8df74c5d 100644 --- a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt +++ b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt @@ -236,6 +236,7 @@ class Keychain @Inject constructor( 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/repositories/PaykitPaymentRequestPresentationStore.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStore.kt index 8ffba9fe87..abc97b191d 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,28 @@ class PaykitPaymentRequestPresentationStore @Inject constructor( } } + /** Local execution ownership is never exported or imported by wallet backups. */ + 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)) + ids + } + + 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 30d33dbd28..2769265437 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -298,6 +298,9 @@ class PaykitPaymentRequestRepo @Inject constructor( 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, @@ -307,6 +310,12 @@ class PaykitPaymentRequestRepo @Inject constructor( @Volatile private var presentedRequestIds = emptySet() + @Volatile + private var acceptedOneTimeRequestIds = emptySet() + + @Volatile + private var activatedGeneration = -1L + @Volatile private var subscriptionAcceptedAt = emptyMap() @@ -321,10 +330,17 @@ class PaykitPaymentRequestRepo @Inject constructor( if (!PubkyPublicKeyFormat.matches(activeIdentity, normalizedIdentity)) { stateGeneration.incrementAndGet() } + val generation = stateGeneration.get() operationMutex.withLock { if (PubkyPublicKeyFormat.matches(activeIdentity, normalizedIdentity)) 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()) @@ -335,6 +351,7 @@ class PaykitPaymentRequestRepo @Inject constructor( presentedSubscriptionProposalIds = subscriptionState.presentedProposalIds dismissedSubscriptionPaymentIds = subscriptionState.dismissedPaymentIds activeIdentity = normalizedIdentity + activatedGeneration = generation } } @@ -773,8 +790,20 @@ 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 (!isLocallyPayable(request)) throw PaykitPaymentRequestError.RequestUnavailable + if (forExecution && !hasCurrentExecutionContext(request)) { + throw PaykitPaymentRequestError.RequestUnavailable + } if ( paykitSdkService.linkedPeers().any { it.state == LinkedPeerState.BLOCKED && @@ -783,11 +812,13 @@ class PaykitPaymentRequestRepo @Inject constructor( ) { throw PaykitPaymentRequestError.RequestUnavailable } + if (!isCurrentState(generation, identity)) throw PaykitPaymentRequestError.RequestUnavailable } } suspend fun claimForPayment(request: PaykitPaymentRequest): Result = withContext(ioDispatcher) { runSuspendCatching { + ensurePaymentAllowed(request, forExecution = false).getOrThrow() paykitSdkService.claimPaymentRequestForExecution(request.counterparty, request.paymentRequestId) Unit } @@ -797,18 +828,23 @@ 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() + val identity = activeIdentity ?: throw PaykitPaymentRequestError.RequestUnavailable + val generation = stateGeneration.get() + ensurePaymentAllowed(it, forExecution = false).getOrThrow() paykitSdkService.acceptPaymentRequest( counterparty = it.counterparty, paymentRequestId = it.paymentRequestId, ) + val acceptedIds = presentationStore.addAcceptedOneTimeId(identity, it.id) + if (!isCurrentState(generation, identity)) throw PaykitPaymentRequestError.RequestUnavailable + acceptedOneTimeRequestIds = acceptedIds }.getOrThrow() } }.onFailure { @@ -907,6 +943,7 @@ class PaykitPaymentRequestRepo @Inject constructor( clearStateLocked() activeIdentity = null presentedRequestIds = emptySet() + acceptedOneTimeRequestIds = emptySet() presentedSubscriptionProposalIds = emptySet() dismissedSubscriptionPaymentIds = emptySet() } @@ -1015,7 +1052,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 @@ -1143,6 +1182,20 @@ 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) + return activatedGeneration == stateGeneration.get() && _subscriptions.value.any { + it.isPayer && it.lifecycleState == PaymentRequestLifecycleState.ACTIVE_RECURRING && request.belongsTo(it) + } + } + private suspend fun updateRequest( request: PaykitPaymentRequest, resultingState: PaymentRequestLifecycleState, diff --git a/app/src/main/java/to/bitkit/repositories/PaykitReceivedPaymentContacts.kt b/app/src/main/java/to/bitkit/repositories/PaykitReceivedPaymentContacts.kt index 11584865a2..4e5a7f5f0e 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitReceivedPaymentContacts.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitReceivedPaymentContacts.kt @@ -10,7 +10,8 @@ import org.lightningdevkit.ldknode.Network import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.toLdkNetwork -internal class PaykitReceivedPaymentContacts private constructor( +@ConsistentCopyVisibility +internal data class PaykitReceivedPaymentContacts private constructor( private val addresses: Map>, private val paymentHashes: Map>, ) { diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt index e57115f1fc..4fde83adc5 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt @@ -47,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() @@ -153,7 +161,7 @@ class PrivatePaykitAddressReservationRepo @Inject constructor( assignments.firstOrNull { (_, assignment) -> assignment.addressType == addressType && - (assignment.address == address || resolvedAddress(assignment).getOrNull() == address) + (assignment.address == address || resolvedAddress(assignment).getOrThrow() == address) }?.first } diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt index 7d83815b5a..47c01cdf88 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt @@ -20,6 +20,12 @@ class PrivatePaykitContactResolver @Inject constructor( 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 diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 1ff13f5362..79afc00afb 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -211,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 { @@ -372,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, @@ -397,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) } @@ -428,6 +447,7 @@ class ActivityService( transactionDetails: List, transferChannelIdsByFundingTxId: Map, ): HwSnapshotResult = ServiceQueue.CORE.background { + invalidatePaykitContactBackfill() val existingActivities = getActivities( walletId = walletId, filter = ActivityFilter.ONCHAIN, @@ -565,13 +585,24 @@ 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) return@background false + 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, @@ -584,13 +615,19 @@ class ActivityService( sortDirection = null, ) var changed = false + var complete = true for (activity in activities) { - if (resolver.receivedPaymentContacts !== contacts) break + if (!isCurrentPaykitContactBackfill(resolver, snapshot)) return@background changed val contact = when (activity) { is Activity.Lightning -> receivedPaykitContact(activity.v1, contacts) - is Activity.Onchain -> receivedPaykitContact(activity.v1) + is Activity.Onchain -> { + val (contact, hydrated) = receivedPaykitContact(activity.v1) + complete = complete && hydrated + contact + } } - if (contact != null && resolver.receivedPaymentContacts === contacts) { + if (!isCurrentPaykitContactBackfill(resolver, snapshot)) return@background changed + if (contact != null) { val updated = when (activity) { is Activity.Lightning -> Activity.Lightning(activity.v1.copy(contact = contact)) is Activity.Onchain -> Activity.Onchain(activity.v1.copy(contact = contact)) @@ -599,24 +636,38 @@ class ActivityService( 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 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): String? { - if (row.contact != null || row.txType != PaymentType.RECEIVED) return null - val details = getBitkitCoreTransactionDetails(walletId = row.walletId, txId = row.txId) ?: return null + 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 = details.outputs.mapNotNull { it.scriptpubkeyAddress }, - ) + addresses = addresses, + ) to true } suspend fun delete(id: String, walletId: String = defaultWalletId): Boolean = ServiceQueue.CORE.background { + invalidatePaykitContactBackfill() deleteActivityById(walletId = walletId, activityId = id) } @@ -860,6 +911,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 { @@ -884,7 +936,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) } @@ -1317,6 +1369,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)) @@ -1413,7 +1466,7 @@ class ActivityService( } // Insert activity - insertActivity(activity) + insert(activity) // Add random tags val numTags = (0..3).random() @@ -1445,7 +1498,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 @@ -1468,7 +1521,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) @@ -1480,6 +1533,7 @@ class ActivityService( ServiceQueue.CORE.background { runCatching { val coreDetails = mapToCoreTransactionDetails(txid, details) + invalidatePaykitContactBackfill() upsertTransactionDetails(listOf(coreDetails)) val payments = lightningService.listPayments() ?: run { @@ -1511,6 +1565,7 @@ class ActivityService( ServiceQueue.CORE.background { runCatching { val coreDetails = mapToCoreTransactionDetails(txid, details) + invalidatePaykitContactBackfill() upsertTransactionDetails(listOf(coreDetails)) val payments = lightningService.listPayments() ?: run { @@ -1573,6 +1628,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 { @@ -1633,7 +1689,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) @@ -1677,7 +1733,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) } @@ -1697,7 +1753,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/test/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStoreTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStoreTest.kt index d16a5ea6c6..4d454033df 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStoreTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStoreTest.kt @@ -92,6 +92,50 @@ class PaykitPaymentRequestPresentationStoreTest : BaseUnitTest() { assertEquals(2L, sut.backupStateVersion.value) } + @Test + fun `accepted one time ownership survives reopening but never enters wallet backup`() = 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(0L, sut.backupStateVersion.value) + reopened.restoreBackup(emptyMap()) + assertEquals(setOf(requestId, secondId), reopened.loadAcceptedOneTimeIds(IDENTITY)) + + values.clear() + reopened.restoreBackup(emptyMap()) + assertEquals(emptySet(), reopened.loadAcceptedOneTimeIds(IDENTITY)) + } + + @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)) + } + verify(keychain, never()).upsertString(any(), any()) + } + @Test fun `backup restore preserves precise acceptance billing boundaries`() = test { val keychain = mock() diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt index 02bd56fdaa..b63d36a37e 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt @@ -157,6 +157,36 @@ 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 `refresh keeps creator subscription without generating a payer payment`() = test { val metadataText = """ diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 76fe9c3971..f9b838b06e 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -103,6 +103,10 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { 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.loadSubscriptionState(any()) ).thenReturn(PaykitSubscriptionPresentationState()) @@ -182,6 +186,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `blocking an already presented accepted request prevents payment`() = test { + restoreAcceptedRequest() val record = paymentRequestRecord(state = PaymentRequestLifecycleState.ACCEPTED) whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) sut.refresh().getOrThrow() @@ -194,6 +199,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `accepted request checks blocking after waiting for synchronization`() = test { + restoreAcceptedRequest() val record = paymentRequestRecord(state = PaymentRequestLifecycleState.ACCEPTED) whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) sut.refresh().getOrThrow() @@ -420,7 +426,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { 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", @@ -538,6 +544,211 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(sut.pendingRequests.value.isEmpty()) assertEquals(PaymentRequestLifecycleState.ACCEPTED, sut.paymentRequestHistory.value.single().lifecycleState) verifyBlocking(paykitSdkService) { processPendingPrivateMessages() } + verify(presentationStore).addAcceptedOneTimeId( + LOCAL_IDENTITY, + PaykitPaymentRequestId(PAYMENT_REQUEST_ID, COUNTERPARTY), + ) + } + + @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 `final authorization rechecks identity after asynchronous peer lookup`() = test { + restoreAcceptedRequest() + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(paymentRequestRecord())) + 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 `queued identity switch invalidates ownership before acquiring the repository mutex`() = test { + restoreAcceptedRequest() + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(paymentRequestRecord())) + 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 `unknown acceptance or failed persistence never grants local ownership`() = test { + val proposed = paymentRequestRecord() + val accepted = proposed.copy(state = PaymentRequestLifecycleState.ACCEPTED) + whenever(paykitSdkService.acceptPaymentRequest(any(), any())) + .thenThrow(IllegalStateException("unknown completion")).thenReturn(accepted) + whenever(presentationStore.addAcceptedOneTimeId(any(), any())).thenThrow(IllegalStateException("disk")) + for (sdkSucceeded in listOf(false, true)) { + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(proposed)) + sut.refresh().getOrThrow() + assertTrue(sut.accept(sut.pendingRequests.value.single()).isFailure) + if (!sdkSucceeded) verify(presentationStore, never()).addAcceptedOneTimeId(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 `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.loadSubscriptionState(any())).thenReturn(PaykitSubscriptionPresentationState()) + val other = PaykitPaymentRequestRepo( + testDispatcher, + paykitSdkService, + settingsStore, + otherStore, + diagnostics, + paymentProofStore, + paymentProofRepo, + subscriptionNotificationScheduler, + 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, never()).addAcceptedOneTimeId(any(), any()) + 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 @@ -1208,6 +1419,14 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(!sut.isPending(request)) } + 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, diff --git a/app/src/test/java/to/bitkit/repositories/PaykitReceivedPaymentContactsTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitReceivedPaymentContactsTest.kt index 2658ab80e3..61ce4bdab2 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitReceivedPaymentContactsTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitReceivedPaymentContactsTest.kt @@ -93,6 +93,17 @@ class PaykitReceivedPaymentContactsTest { assertTrue(contacts.contactsForAddresses(listOf(OTHER_ADDRESS)).isEmpty()) } + @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 { diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt index 478721d46d..a82342646d 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt @@ -19,8 +19,10 @@ import to.bitkit.services.AddressDerivationInfo import to.bitkit.services.CoreService 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 { @@ -168,7 +170,7 @@ class PrivatePaykitAddressReservationRepoTest : BaseUnitTest() { } @Test - fun `clearContactAssignment removes private address attribution history`() = test { + fun `clearContactAssignment invalidates attribution even if persistence fails`() = test { reservationData.value = PrivatePaykitReservationData( contactAssignments = mapOf( CONTACT_KEY to PrivatePaykitStoredAssignmentData( @@ -188,11 +190,31 @@ class PrivatePaykitAddressReservationRepoTest : BaseUnitTest() { ), ) - sut.clearContactAssignment(CONTACT_KEY) + assertEquals(CONTACT_KEY, sut.contactPublicKeyForReservedAddress(PRIVATE_ADDRESS)) + val version = sut.attributionVersion + whenever(reservationStore.update(any())).thenThrow(IllegalStateException("disk")) + assertFailsWith { sut.clearContactAssignment(CONTACT_KEY) } + assertTrue(sut.attributionVersion > version) assertNull(sut.contactPublicKeyForReservedAddress(PRIVATE_ADDRESS)) } + @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 fun `contactPublicKeyForReservedAddress skips assignments for other address types`() = test { reservationData.value = PrivatePaykitReservationData( diff --git a/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt b/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt index 88f206c57d..79c2df9309 100644 --- a/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt +++ b/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt @@ -43,8 +43,10 @@ import to.bitkit.repositories.PaykitReceivedPaymentContactsTest.Companion.OTHER_ 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 @@ -76,6 +78,9 @@ class ActivityServicePaykitContactsTest : BaseUnitTest() { 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 { @@ -193,6 +198,109 @@ class ActivityServicePaykitContactsTest : BaseUnitTest() { 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) @@ -322,6 +430,7 @@ class ActivityServicePaykitContactsTest : BaseUnitTest() { 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 { @@ -338,8 +447,14 @@ class ActivityServicePaykitContactsTest : BaseUnitTest() { anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull() ) - }.thenAnswer { rows } - native.`when` { getTransactionDetails(any(), any()) }.thenAnswer { details } + }.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 diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 57a863a403..13fe5f01e9 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -6745,6 +6745,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test + @Suppress("LongMethod") fun `failed LNURL request callback releases preparation and retry reopens request`() = test { val request = paymentRequest() val privateContext = privatePaymentContext(7uL) @@ -6790,6 +6791,10 @@ 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() diff --git a/changelog.d/next/1401.changed.md b/changelog.d/next/1401.changed.md index 3299757cb7..c6c333754f 100644 --- a/changelog.d/next/1401.changed.md +++ b/changelog.d/next/1401.changed.md @@ -1 +1 @@ -Share Paykit contacts and payment state with authorized apps while keeping wallet keys private, and label received payments with their Paykit payer contact. +Share Paykit contacts and payment state with authorized apps while keeping wallet keys private, keep accepted one-time requests payable only on the accepting install, and label received payments with their Paykit payer contact. diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index 1bde13b8ce..48b10e4369 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -24,6 +24,13 @@ Rejected fixture shapes stay in unit tests because Bitkit intentionally does not `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. +## Accepting install + +`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 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 + + From b68738e405f9be9000b01f6383bd5efc26f9b714 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 1 Oct 2026 08:03:29 -0500 Subject: [PATCH 07/77] fix: prune completed paykit acceptance records --- .../PaykitPaymentRequestPresentationStore.kt | 19 +++++++ .../repositories/PaykitPaymentRequestRepo.kt | 22 ++++++++ ...ykitPaymentRequestPresentationStoreTest.kt | 30 +++++++++++ .../PaykitPaymentRequestRepoTest.kt | 53 +++++++++++++++++++ 4 files changed, 124 insertions(+) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStore.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStore.kt index abc97b191d..1b947eac66 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStore.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStore.kt @@ -90,6 +90,25 @@ class PaykitPaymentRequestPresentationStore @Inject constructor( 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)) + remaining + } + private fun loadAcceptedOneTimeIdsByIdentity(): Map> { val value = keychain.loadString(ACCEPTED_KEY) ?: return emptyMap() return runCatching { Json.decodeFromString>>(value) } diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 2769265437..7f3a82ee7e 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -1066,6 +1066,8 @@ 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 receivedContacts = ReceivedContactsSnapshot(generation, expectedIdentity, contacts) val subscriptionStateChanged = subscriptionAcceptedAt != updatedSubscriptionAcceptedAt || @@ -1355,6 +1357,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) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStoreTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStoreTest.kt index 4d454033df..7922c4c27e 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 @@ -122,6 +123,32 @@ class PaykitPaymentRequestPresentationStoreTest : BaseUnitTest() { assertEquals(emptySet(), reopened.loadAcceptedOneTimeIds(IDENTITY)) } + @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() @@ -133,6 +160,9 @@ class PaykitPaymentRequestPresentationStoreTest : BaseUnitTest() { assertFailsWith { sut.addAcceptedOneTimeId(IDENTITY, PaykitPaymentRequestId("request", COUNTERPARTY)) } + assertFailsWith { + sut.removeAcceptedOneTimeIds(IDENTITY, setOf(PaykitPaymentRequestId("request", COUNTERPARTY))) + } verify(keychain, never()).upsertString(any(), any()) } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index f9b838b06e..3da62bae62 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -575,6 +575,59 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { 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 `final authorization rechecks identity after asynchronous peer lookup`() = test { restoreAcceptedRequest() From b62f6b2bd864bda0b43a7516c1710ef98ae8c561 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 1 Oct 2026 09:50:01 -0500 Subject: [PATCH 08/77] fix: preserve paykit payment recovery and contact choices --- .../main/java/to/bitkit/data/CacheStore.kt | 12 ++++ .../bitkit/models/PaykitPaymentStateBackup.kt | 1 + .../to/bitkit/repositories/ActivityRepo.kt | 2 + .../java/to/bitkit/repositories/BackupRepo.kt | 2 + .../repositories/PaykitPaymentProofRepo.kt | 14 ++++- .../PaykitPaymentRequestPresentationStore.kt | 10 +++- .../repositories/PaykitPaymentRequestRepo.kt | 29 ++++++++-- .../bitkit/repositories/PrivatePaykitRepo.kt | 5 +- .../java/to/bitkit/services/CoreService.kt | 8 ++- .../to/bitkit/services/PaykitSdkService.kt | 4 +- .../models/PaykitPaymentStateBackupTest.kt | 3 + .../PaykitPaymentProofRepoTest.kt | 15 +++++ ...ykitPaymentRequestPresentationStoreTest.kt | 13 +++-- .../PaykitPaymentRequestRepoTest.kt | 55 +++++++++++++------ .../repositories/PrivatePaykitRepoTest.kt | 13 ++--- .../ActivityServicePaykitContactsTest.kt | 14 +++++ journeys/payment-requests/README.md | 5 ++ journeys/pubky-marketplace/wallet-leg.xml | 3 + 18 files changed, 165 insertions(+), 43 deletions(-) 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/models/PaykitPaymentStateBackup.kt b/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt index b9429d238a..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( diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 20ee50dbff..af0023109b 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -508,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() @@ -539,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..a0ac179657 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -641,6 +641,7 @@ class BackupRepo @Inject constructor( paykitPaymentState = PaykitPaymentStateBackup( subscriptions = paykitPresentationStore.backupSnapshot(), pendingProofs = paykitPaymentProofRepo.get().backupSnapshot(), + acceptedOneTimeRequests = paykitPresentationStore.acceptedOneTimeBackupSnapshot(), ), ) @@ -781,6 +782,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/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index a46552f1e4..593f6f5b05 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -273,8 +273,18 @@ class PaykitPaymentProofRepo @Inject constructor( return@withContext } - val identity = currentIdentity() ?: return@withContext - val fallbackProof = runSuspendCatching { + 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 { diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStore.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStore.kt index 1b947eac66..a50fa1d099 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStore.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStore.kt @@ -74,7 +74,6 @@ class PaykitPaymentRequestPresentationStore @Inject constructor( } } - /** Local execution ownership is never exported or imported by wallet backups. */ fun loadAcceptedOneTimeIds(identity: String): Set { val normalizedIdentity = PubkyPublicKeyFormat.normalized(identity) ?: return emptySet() return loadAcceptedOneTimeIdsByIdentity()[normalizedIdentity].orEmpty() @@ -87,6 +86,7 @@ class PaykitPaymentRequestPresentationStore @Inject constructor( val ids = current[normalizedIdentity].orEmpty() + id val state = current + (normalizedIdentity to ids) keychain.upsertString(ACCEPTED_KEY, Json.encodeToString(state)) + _backupStateVersion.update { it + 1 } ids } @@ -106,9 +106,17 @@ class PaykitPaymentRequestPresentationStore @Inject constructor( 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) } diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 7f3a82ee7e..ebe23e6958 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -5,6 +5,7 @@ package to.bitkit.repositories import com.synonym.paykit.LinkedPeerState import com.synonym.paykit.OutboundPrivateCounterpartySendReport import com.synonym.paykit.OutboundPrivateMessageStatus +import com.synonym.paykit.PaykitException import com.synonym.paykit.PaymentRequestLifecycleState import com.synonym.paykit.PaymentRequestLocalRole import com.synonym.paykit.PaymentRequestRecord @@ -838,13 +839,27 @@ class PaykitPaymentRequestRepo @Inject constructor( val identity = activeIdentity ?: throw PaykitPaymentRequestError.RequestUnavailable val generation = stateGeneration.get() ensurePaymentAllowed(it, forExecution = false).getOrThrow() - paykitSdkService.acceptPaymentRequest( - counterparty = it.counterparty, - paymentRequestId = it.paymentRequestId, - ) + 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 -> 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 { @@ -1192,7 +1207,11 @@ class PaykitPaymentRequestRepo @Inject constructor( activatedGeneration == stateGeneration.get() && request.id in acceptedOneTimeRequestIds private fun hasCurrentExecutionContext(request: PaykitPaymentRequest): Boolean { - if (request.billingPeriod == null) return hasLocalAcceptance(request) + 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) } diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index e8796cbe1b..c90be47998 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -825,7 +825,10 @@ class PrivatePaykitRepo @Inject constructor( for (publicKey in publicKeys.distinct()) { val link = runSuspendCatching { paykitSdkService.ensureLinkWithPeer(publicKey) } .onFailure { Logger.warn("Failed to prepare private Paykit link during '$reason'", it, context = TAG) } - if (link.exceptionOrNull() is PaykitException.NotFound) continue + if (link.isFailure) { + if (link.exceptionOrNull() !is PaykitException.NotFound) linkRetryKeys += publicKey + continue + } linkRetryKeys += publicKey runSuspendCatching { privatePaymentListUpdate(publicKey, forceRefreshLightning) } .onSuccess { updates += it } diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 79afc00afb..254fdd942f 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -618,6 +618,7 @@ class ActivityService( 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 contact = when (activity) { is Activity.Lightning -> receivedPaykitContact(activity.v1, contacts) is Activity.Onchain -> { @@ -627,7 +628,7 @@ class ActivityService( } } if (!isCurrentPaykitContactBackfill(resolver, snapshot)) return@background changed - if (contact != null) { + if (contact != null && !cacheStore.data.first().isContactDetached(activity.rawId(), activity.walletId())) { val updated = when (activity) { is Activity.Lightning -> Activity.Lightning(activity.v1.copy(contact = contact)) is Activity.Onchain -> Activity.Onchain(activity.v1.copy(contact = contact)) @@ -884,7 +885,9 @@ class ActivityService( val contact = existingActivity ?.takeIf { it is Activity.Lightning } ?.let { (it as Activity.Lightning).v1.contact } - ?: payment.takeUnless { it.status == PaymentStatus.FAILED }?.let { + ?: payment.takeUnless { + it.status == PaymentStatus.FAILED || cacheStore.data.first().isContactDetached(it.id, defaultWalletId) + }?.let { privatePaykitContactPublicKeyForReceivedInvoicePaymentHash(it.id, it.direction) } @@ -1332,6 +1335,7 @@ class ActivityService( val existingContact = existingOnchainActivity?.v1?.contact 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( diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 3316316e99..1a04128ef4 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -514,7 +514,7 @@ class PaykitSdkService @Inject constructor( suspend fun fetchPubkyFollows(publicKey: String): List { isSetup.await() return operationLock.withLock { - handle().fetchPubkyFollows(publicKey, maxEntries = 1000u) + handle().fetchPubkyFollows(publicKey, maxEntries = 10_000u) } } @@ -1303,7 +1303,7 @@ internal class PaykitSdkSessionProvider( } fun setPaykitIdentitySecretKey(key: PaykitIdentitySecretKey) = synchronized(lock) { - if ((paykitIdentitySecretKey?.keyGeneration() ?: 1uL) != key.keyGeneration()) { + if (paykitIdentitySecretKey?.keyGeneration() != key.keyGeneration()) { liveSessionAccess = null } paykitIdentitySecretKey = key diff --git a/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt b/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt index edc194cd23..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 @@ -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/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index 083dd620e8..4c0e5081f3 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -434,6 +434,21 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(storedProofs.isEmpty()) } + @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) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStoreTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStoreTest.kt index 7922c4c27e..015d11d2da 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStoreTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStoreTest.kt @@ -94,11 +94,11 @@ class PaykitPaymentRequestPresentationStoreTest : BaseUnitTest() { } @Test - fun `accepted one time ownership survives reopening but never enters wallet backup`() = 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 { + whenever(keychain.upsertString(any(), any())).thenAnswer { values[it.getArgument(0)] = it.getArgument(1) Unit } @@ -114,13 +114,16 @@ class PaykitPaymentRequestPresentationStoreTest : BaseUnitTest() { assertEquals(setOf(requestId, secondId), reopened.loadAcceptedOneTimeIds(IDENTITY)) assertEquals(setOf(secondId), reopened.loadAcceptedOneTimeIds(COUNTERPARTY)) assertEquals(emptyMap(), reopened.backupSnapshot()) - assertEquals(0L, sut.backupStateVersion.value) + assertEquals(3L, sut.backupStateVersion.value) + val backup = reopened.acceptedOneTimeBackupSnapshot() reopened.restoreBackup(emptyMap()) assertEquals(setOf(requestId, secondId), reopened.loadAcceptedOneTimeIds(IDENTITY)) values.clear() reopened.restoreBackup(emptyMap()) - assertEquals(emptySet(), reopened.loadAcceptedOneTimeIds(IDENTITY)) + reopened.restoreAcceptedOneTimeRequests(backup) + assertEquals(setOf(requestId, secondId), reopened.loadAcceptedOneTimeIds(IDENTITY)) + assertEquals(setOf(secondId), reopened.loadAcceptedOneTimeIds(COUNTERPARTY)) } @Test @@ -129,7 +132,7 @@ class PaykitPaymentRequestPresentationStoreTest : BaseUnitTest() { 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 { + whenever(keychain.upsertString(eq(key), any())).thenAnswer { stored = it.getArgument(1) Unit } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 3da62bae62..07e4631e28 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -9,6 +9,7 @@ import com.synonym.bitkitcore.validateBitcoinAddress import com.synonym.paykit.IdentityStatus import com.synonym.paykit.LinkedPeerRecord import com.synonym.paykit.LinkedPeerState +import com.synonym.paykit.PaykitException import com.synonym.paykit.PaymentDeadline import com.synonym.paykit.PaymentProofRecord import com.synonym.paykit.PaymentReference @@ -107,6 +108,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(presentationStore.addAcceptedOneTimeId(any(), any())).thenAnswer { setOf(it.getArgument(1)) } + whenever(presentationStore.removeAcceptedOneTimeIds(any(), any())).thenReturn(emptySet()) whenever( presentationStore.loadSubscriptionState(any()) ).thenReturn(PaykitSubscriptionPresentationState()) @@ -631,7 +633,8 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `final authorization rechecks identity after asynchronous peer lookup`() = test { restoreAcceptedRequest() - whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(paymentRequestRecord())) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())) + .thenReturn(listOf(paymentRequestRecord(state = PaymentRequestLifecycleState.ACCEPTED))) sut.refresh().getOrThrow() val request = sut.pendingRequests.value.single() val checking = CompletableDeferred() @@ -652,7 +655,8 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `queued identity switch invalidates ownership before acquiring the repository mutex`() = test { restoreAcceptedRequest() - whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(paymentRequestRecord())) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())) + .thenReturn(listOf(paymentRequestRecord(state = PaymentRequestLifecycleState.ACCEPTED))) sut.refresh().getOrThrow() val request = sut.pendingRequests.value.single() val refreshing = CompletableDeferred() @@ -674,24 +678,40 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `unknown acceptance or failed persistence never grants local ownership`() = test { + fun `failed intent persistence prevents remote acceptance`() = test { val proposed = paymentRequestRecord() val accepted = proposed.copy(state = PaymentRequestLifecycleState.ACCEPTED) - whenever(paykitSdkService.acceptPaymentRequest(any(), any())) - .thenThrow(IllegalStateException("unknown completion")).thenReturn(accepted) whenever(presentationStore.addAcceptedOneTimeId(any(), any())).thenThrow(IllegalStateException("disk")) - for (sdkSucceeded in listOf(false, true)) { - whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(proposed)) - sut.refresh().getOrThrow() - assertTrue(sut.accept(sut.pendingRequests.value.single()).isFailure) - if (!sdkSucceeded) verify(presentationStore, never()).addAcceptedOneTimeId(any(), any()) + 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) - } + 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 { + val proposed = paymentRequestRecord() + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(proposed)) + whenever(paykitSdkService.acceptPaymentRequest(any(), any())) + .thenAnswer { throw PaykitException.Transport("transport_error", "response lost") } + 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))) + sut.refresh().getOrThrow() + val retry = sut.pendingRequests.value.single() + sut.accept(retry).getOrThrow() + sut.ensurePaymentAllowed(retry).getOrThrow() } @Test @@ -725,6 +745,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @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, @@ -760,7 +781,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(other.claimForPayment(remoteAccepted).isFailure) assertTrue(other.ensurePaymentAllowed(remoteAccepted).isFailure) verify(paykitSdkService, never()).claimPaymentRequestForExecution(any(), any()) - verify(otherStore, never()).addAcceptedOneTimeId(any(), any()) + verify(otherStore).removeAcceptedOneTimeIds(eq(LOCAL_IDENTITY), eq(setOf(stale.id))) sut.refresh().getOrThrow() sut.accept(sut.pendingRequests.value.single()).getOrThrow() other.clear() diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index ea185f8e51..f0bb6bf6af 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -249,24 +249,21 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `prepareSavedContacts succeeds when link preparation fails but SDK queues reservations`() = test { + fun `prepareSavedContacts defers reservations while link preparation is unavailable`() = test { settingsData.value = SettingsData( sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false, publicPaykitOnchainEnabled = true, ) - whenever { paykitSdkService.ensureLinkWithPeer(CONTACT_KEY) }.thenAnswer { - throw PrivatePaykitTestAppError("still linking") + whenever(paykitSdkService.ensureLinkWithPeer(CONTACT_KEY)).thenAnswer { + throw PaykitException.Transport("offline", "Unavailable homeserver") } val result = sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) assertTrue(result.isSuccess, result.exceptionOrNull().toString()) - verifyBlocking(paykitSdkService) { syncPrivatePaymentListsWithReservations(any(), eq(false)) } - assertEquals( - true, - cacheData.value.contacts.getValue(CONTACT_KEY).hasPublishedPrivatePaymentList, - ) + verify(paykitSdkService, never()).syncPrivatePaymentListsWithReservations(any(), any()) + verify(addressReservationRepo, never()).currentOrRotatedAddress(any()) } @Test diff --git a/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt b/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt index 79c2df9309..d9d3dcc369 100644 --- a/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt +++ b/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt @@ -36,6 +36,7 @@ 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 @@ -123,6 +124,19 @@ class ActivityServicePaykitContactsTest : BaseUnitTest() { 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 refuses ambiguity across stored receiving address and other outputs`() = coreTest { rows = listOf(Activity.Onchain(onchain(address = "request-address"))) diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index 48b10e4369..0c4c4455f5 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -26,6 +26,11 @@ Rejected fixture shapes stay in unit tests because Bitkit intentionally does not ## 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 diff --git a/journeys/pubky-marketplace/wallet-leg.xml b/journeys/pubky-marketplace/wallet-leg.xml index b11efee9c2..87d7dabb61 100644 --- a/journeys/pubky-marketplace/wallet-leg.xml +++ b/journeys/pubky-marketplace/wallet-leg.xml @@ -50,5 +50,8 @@ 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 From 453bc70e331edd4389104700a53b59089892a645 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 1 Oct 2026 10:03:16 -0500 Subject: [PATCH 09/77] refactor: simplify paykit contact backfill --- .../repositories/PaykitPaymentRequestRepo.kt | 4 ++- .../java/to/bitkit/services/CoreService.kt | 30 +++++++++++-------- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index ebe23e6958..03ce8bffe5 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -851,7 +851,9 @@ class PaykitPaymentRequestRepo @Inject constructor( }.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 -> true + is PaykitException.Transport, + is PaykitException.Storage, + is PaykitException.Identity -> true else -> false } if (!alreadySaved && !uncertain) { diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 254fdd942f..9d96292365 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -619,20 +619,10 @@ class ActivityService( for (activity in activities) { if (!isCurrentPaykitContactBackfill(resolver, snapshot)) return@background changed if (cacheStore.data.first().isContactDetached(activity.rawId(), activity.walletId())) continue - val contact = when (activity) { - is Activity.Lightning -> receivedPaykitContact(activity.v1, contacts) - is Activity.Onchain -> { - val (contact, hydrated) = receivedPaykitContact(activity.v1) - complete = complete && hydrated - contact - } - } + val (updated, hydrated) = attributedPaykitActivity(activity, contacts) + complete = complete && hydrated if (!isCurrentPaykitContactBackfill(resolver, snapshot)) return@background changed - if (contact != null && !cacheStore.data.first().isContactDetached(activity.rawId(), activity.walletId())) { - val updated = when (activity) { - is Activity.Lightning -> Activity.Lightning(activity.v1.copy(contact = contact)) - is Activity.Onchain -> Activity.Onchain(activity.v1.copy(contact = contact)) - } + if (updated != null && !cacheStore.data.first().isContactDetached(activity.rawId(), activity.walletId())) { updateActivity(activityId = activity.rawId(), activity = updated) changed = true } @@ -651,6 +641,20 @@ class ActivityService( 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() From 8362c93fe8877480a056b6455489952bd164ae5c Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 1 Oct 2026 10:41:28 -0500 Subject: [PATCH 10/77] chore: update Paykit to rc59 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f1edd44eb0..35ddfa5263 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-rc58" } +paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc59" } 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" } From 967e4c0997d6efc80e52a5d3e84d038b77646ea5 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 1 Oct 2026 12:13:18 -0500 Subject: [PATCH 11/77] fix: preserve private sharing settings during contact cleanup --- .../bitkit/repositories/PrivatePaykitRepo.kt | 4 +- .../bitkit/repositories/PublicPaykitRepo.kt | 8 +- .../to/bitkit/services/PaykitSdkService.kt | 15 +++- .../repositories/PrivatePaykitRepoTest.kt | 51 ++++++++++-- .../repositories/PublicPaykitRepoTest.kt | 22 ++---- .../services/PaykitKeyGenerationTest.kt | 4 +- .../bitkit/services/PaykitSdkServiceTest.kt | 77 +++++++++++++++---- .../services/PaykitSdkServiceWipeTest.kt | 9 ++- .../services/PubkyIdentityRepublishTest.kt | 8 ++ .../bitkit/usecases/WipeWalletUseCaseTest.kt | 2 +- 10 files changed, 150 insertions(+), 50 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index c90be47998..8f7ba449c1 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -1251,7 +1251,6 @@ class PrivatePaykitRepo @Inject constructor( if (normalizedKeys.isEmpty()) return@runSuspendCatching ensureState() - publicPaykitRepo.syncPaykitApp(privateSharingEnabled = true).getOrThrow() val cleanupStateByPublicKey = normalizedKeys.associateWith(::publishedEndpointCleanupState) val preparation = clearPrivatePaymentLists(normalizedKeys) val failedPublicKeys = preparation.failedPublicKeys.toMutableSet() @@ -1283,6 +1282,9 @@ class PrivatePaykitRepo @Inject constructor( clearPublishedEndpointCache(normalizedKeys.filterNot { it in failedPublicKeys }) firstError?.let { throw it } publicPaykitRepo.syncPaykitApp().getOrThrow() + }.onFailure { + runSuspendCatching { settingsStore.update { it.copy(publicPaykitCleanupPending = true) } } + .onFailure(it::addSuppressed) } private suspend fun clearPrivatePaymentLists( diff --git a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt index 1cc5007b10..ce09246cba 100644 --- a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -10,7 +10,6 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import org.lightningdevkit.ldknode.Bolt11Invoice import org.lightningdevkit.ldknode.Network -import to.bitkit.data.PrivatePaykitCacheStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.di.IoDispatcher @@ -100,7 +99,6 @@ class PublicPaykitRepo @Inject constructor( private val coreService: CoreService, private val paykitSdkService: PaykitSdkService, private val settingsStore: SettingsStore, - private val privatePaykitCacheStore: PrivatePaykitCacheStore, private val clock: Clock, ) { companion object { @@ -276,11 +274,7 @@ class PublicPaykitRepo @Inject constructor( runSuspendCatching { val settings = settingsStore.data.first() val privateSharing = privateSharingEnabled ?: settings.sharesPrivatePaykitEndpoints - val privateCache = privatePaykitCacheStore.data.first() - paykitSdkService.syncPaykitApp( - privatePaymentsEnabled = privateSharing || privateCache.cleanupPending || - privateCache.deletedContactCleanupPendingPublicKeys.isNotEmpty(), - ) + paykitSdkService.syncPaykitApp(privatePaymentsEnabled = privateSharing) } } diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 1a04128ef4..9e60d814ab 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -82,6 +82,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 @@ -157,6 +158,7 @@ class PaykitSdkService @Inject constructor( private val pubkyStore: PubkyStore, sharedPubky: SharedPubkyClient, @IoDispatcher ioDispatcher: CoroutineDispatcher, + private val settingsStore: SettingsStore, ) : BaseCoroutineScope(ioDispatcher, TAG) { private val sessionProvider = PaykitSdkSessionProvider(keychain, sharedPubky) private val paymentAdapter = PaykitSdkPaymentAdapter() @@ -199,8 +201,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) { @@ -666,6 +669,11 @@ class PaykitSdkService @Inject constructor( ) { return@withStateRevisionTracking null } + val publicKey = handle.identityStatus()?.publicKey + if (publicKey != null) { + val app = handle.paykitAppRegistry(publicKey)?.apps?.find { it.appId == "bitkit" } + if (app?.capabilities?.privatePayments == false) return@withStateRevisionTracking null + } handle.clearPrivatePaymentListAndProcessOutbound(counterparty) } } @@ -1053,7 +1061,10 @@ class PaykitSdkService @Inject constructor( runSuspendCatching { val capabilities = appCapabilities(handle) if (capabilities.privatePayments) { - handle.publishPaykitApp("Bitkit", capabilities) + handle.publishPaykitApp( + "Bitkit", + capabilities.copy(privatePayments = settingsStore.data.first().sharesPrivatePaykitEndpoints), + ) } }.onFailure { Logger.warn("Failed to publish Paykit app", it, context = TAG) diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index f0bb6bf6af..d2f5cdce93 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -113,6 +113,10 @@ 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) @@ -337,7 +341,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `disabled cleanup retains its capability through failures and direct retries`() = test { + fun `disabled cleanup uses existing capability and retries withdrawal and registry failures`() = test { val publicRepo = PublicPaykitRepo( ioDispatcher = testDispatcher, pubkyRepo = mock(), @@ -346,20 +350,20 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { coreService = coreService, paykitSdkService = paykitSdkService, settingsStore = settingsStore, - privatePaykitCacheStore = cacheStore, clock = clock, ) - for (failureStage in listOf("enable capability", "withdraw", "disable capability")) { + 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 = false + var capability = true var failed = false doSuspendableAnswer { val enabled = it.getArgument(0) - if (!failed && failureStage == if (enabled) "enable capability" else "disable capability") { + assertFalse(enabled, "Cleanup must not enable private payments") + if (!failed && failureStage == "disable capability") { failed = true throw AppError("Registration unavailable") } @@ -379,8 +383,8 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(result.isFailure, failureStage) assertTrue(failed, failureStage) assertTrue(cacheData.value.cleanupPending, failureStage) - publicRepo.syncPaykitApp(privateSharingEnabled = false).getOrThrow() assertTrue(capability, failureStage) + assertTrue(settingsData.value.publicPaykitCleanupPending, failureStage) sut.retryPendingEndpointRemoval(listOf(CONTACT_KEY)).getOrThrow() @@ -390,6 +394,41 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { } } + @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 `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), + ) + + assertTrue(sut.removeSavedContact(CONTACT_KEY).isFailure) + assertTrue(settingsData.value.publicPaykitCleanupPending) + assertTrue(CONTACT_KEY in cacheData.value.deletedContactCleanupPendingPublicKeys) + + sut.retryPendingEndpointRemoval(emptyList()).getOrThrow() + + assertFalse(CONTACT_KEY in cacheData.value.deletedContactCleanupPendingPublicKeys) + verifyBlocking(publicPaykitRepo, never()) { syncPaykitApp(true) } + verifyBlocking(publicPaykitRepo, times(2)) { syncPaykitApp() } + } + @Test fun `disable sharing removes onchain-only private publications from cache`() = test { settingsData.value = SettingsData( diff --git a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt index 16207eaafe..6ab7c4ceb2 100644 --- a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt @@ -17,8 +17,6 @@ import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.whenever -import to.bitkit.data.PrivatePaykitCacheData -import to.bitkit.data.PrivatePaykitCacheStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.services.CoreService @@ -47,13 +45,11 @@ class PublicPaykitRepoTest : BaseUnitTest() { private val coreService = mock() private val paykitSdkService = mock() private val settingsStore = mock() - private val privatePaykitCacheStore = mock() private val clock = mock() private val publicKey = MutableStateFlow("pubkyself") private val walletState = MutableStateFlow(WalletState()) private val settingsFlow = MutableStateFlow(SettingsData()) - private val privateCacheFlow = MutableStateFlow(PrivatePaykitCacheData()) private lateinit var sut: PublicPaykitRepo @@ -67,7 +63,6 @@ class PublicPaykitRepoTest : BaseUnitTest() { whenever(pubkyRepo.publicKey).thenReturn(publicKey) whenever(walletRepo.walletState).thenReturn(walletState) whenever(settingsStore.data).thenReturn(settingsFlow) - whenever(privatePaykitCacheStore.data).thenReturn(privateCacheFlow) whenever(clock.now()).thenReturn(Instant.fromEpochMilliseconds(NOW_MILLIS)) whenever(paykitSdkService.syncPublicEndpoints(any())).thenReturn(syncReport()) whenever { walletRepo.refreshReusableReceiveAddress() }.thenReturn(Result.success(Unit)) @@ -84,18 +79,14 @@ class PublicPaykitRepoTest : BaseUnitTest() { } @Test - fun `private capability remains enabled until all pending cleanup is complete`() = test { - for (pending in listOf( - PrivatePaykitCacheData(cleanupPending = true), - PrivatePaykitCacheData(deletedContactCleanupPendingPublicKeys = setOf("pubkycontact")), - PrivatePaykitCacheData(), - )) { - privateCacheFlow.value = pending - sut.syncPaykitApp(privateSharingEnabled = false).getOrThrow() + 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(3)) { syncPaykitApp(capabilities.capture()) } - assertEquals(listOf(true, true, false), capabilities.allValues) + verifyBlocking(paykitSdkService, times(2)) { syncPaykitApp(capabilities.capture()) } + assertEquals(listOf(false, true), capabilities.allValues) } @Test @@ -311,7 +302,6 @@ class PublicPaykitRepoTest : BaseUnitTest() { coreService = coreService, paykitSdkService = paykitSdkService, settingsStore = settingsStore, - privatePaykitCacheStore = privatePaykitCacheStore, clock = clock, ) diff --git a/app/src/test/java/to/bitkit/services/PaykitKeyGenerationTest.kt b/app/src/test/java/to/bitkit/services/PaykitKeyGenerationTest.kt index 6b6c51f9bf..364e4d9cc2 100644 --- a/app/src/test/java/to/bitkit/services/PaykitKeyGenerationTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitKeyGenerationTest.kt @@ -45,7 +45,7 @@ class PaykitKeyGenerationTest { whenever(keychain.loadString(storageKey)).thenReturn(null, generation.toString()) val key = mock() val bytes = ByteArray(32) { 1 } - val service = PaykitSdkService(mock(), keychain, mock()) { sdk } + 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) @@ -88,7 +88,7 @@ class PaykitKeyGenerationTest { mockConstruction(PaykitSdkSessionProvider::class.java) { provider, _ -> whenever(provider.loadLocalSecretKey()).thenReturn(root) }.use { providers -> - val service = PaykitSdkService(mock(), keychain, mock()) { sdk } + val service = PaykitSdkService(mock(), keychain, mock(), settingsStore = mock()) { sdk } val provider = providers.constructed().single() repeat(2) { assertEquals(RING_PUBKY, service.currentPublicKey()) } repeat(2) { diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index 7e564a5259..3fd488337c 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -4,6 +4,9 @@ import com.synonym.paykit.ContactRecord import com.synonym.paykit.IdentityStatus import com.synonym.paykit.LinkedPeerRecord import com.synonym.paykit.LinkedPeerState +import com.synonym.paykit.PaykitApp +import com.synonym.paykit.PaykitAppCapabilities +import com.synonym.paykit.PaykitAppRegistry import com.synonym.paykit.PaykitException import com.synonym.paykit.PaykitIdentitySecretKey import com.synonym.paykit.PaykitSdk @@ -42,6 +45,8 @@ 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 @@ -78,6 +83,7 @@ class PaykitSdkServiceTest { mock(), mock(), ioDispatcher = StandardTestDispatcher(testScheduler), + settingsStore = mock(), platformInitializer = { wipe = async(start = CoroutineStart.UNDISPATCHED) { service.withWalletWipe { @@ -101,7 +107,7 @@ class PaykitSdkServiceTest { val sdk = mock() whenever(sdk.contactRecords()).thenReturn(emptyList()) var handlesCreated = 0 - val service = PaykitSdkService(mock(), mock(), mock()) { + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { handlesCreated++ sdk } @@ -182,7 +188,7 @@ class PaykitSdkServiceTest { sdk.identityStatus() ).thenReturn(IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) whenever(sdk.listPaymentRequests(any())).thenReturn(listOf(server, bitkit)) - val service = PaykitSdkService(mock(), mock(), mock()) { sdk } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } assertEquals(listOf(server, bitkit), service.allPaymentRequests(RING_PUBKY)) assertEquals(listOf(bitkit), service.paymentRequests()) @@ -194,7 +200,7 @@ class PaykitSdkServiceTest { whenever( sdk.identityStatus() ).thenReturn(IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) - val service = PaykitSdkService(mock(), mock(), mock()) { sdk } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } assertFailsWith { service.allPaymentRequests("a3mduedw686dysw8ndr5c1dyry5h8k6i8hzbbmx9gf9k43zq4s9o") @@ -231,7 +237,7 @@ class PaykitSdkServiceTest { for ((authUrl, companion) in invalidRequests) { val keychain = mock() val sdk = mock() - val service = PaykitSdkService(mock(), keychain, mock()) { sdk } + val service = PaykitSdkService(mock(), keychain, mock(), settingsStore = mock()) { sdk } assertTrue( runSuspendCatching { service.approveAuthWithCompanionClaim( @@ -281,7 +287,7 @@ class PaykitSdkServiceTest { var handlesCreated = 0 val store = mock() whenever(store.data).thenReturn(flowOf(PubkyStoreData())) - val service = PaykitSdkService(mock(), keychain, store) { + val service = PaykitSdkService(mock(), keychain, store, settingsStore = mock()) { handlesCreated++ sdk } @@ -320,7 +326,7 @@ class PaykitSdkServiceTest { 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()) @@ -352,7 +358,7 @@ 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()) verify(sdk, never()).removeContact(any()) @@ -378,7 +384,7 @@ class PaykitSdkServiceTest { whenever(store.data).thenReturn(flow { throw error }) val access = mock() whenever(access.exportSessionSecret()).thenReturn("new-session") - val service = PaykitSdkService(mock(), keychain, store) { sdk } + val service = PaykitSdkService(mock(), keychain, store, settingsStore = mock()) { sdk } val thrown = assertFailsWith { service.activateRegisteredIdentity( @@ -413,7 +419,7 @@ 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) @@ -454,7 +460,7 @@ class PaykitSdkServiceTest { whenever(noise.exportBytes()).thenReturn(ByteArray(32) { 1 }) whenever(access.exportSessionSecret()).thenReturn("new-session") whenever(access.exportPaykitIdentitySecretKey()).thenReturn(noise) - val service = PaykitSdkService(mock(), keychain, store, { bootstrap }) { sdk } + val service = PaykitSdkService(mock(), keychain, store, { bootstrap }, settingsStore = mock()) { sdk } val result = PubkySessionBootstrapResult( @@ -493,7 +499,7 @@ class PaykitSdkServiceTest { contactPeer(LinkedPeerState.BLOCKED), ), ) - val service = PaykitSdkService(mock(), mock(), mock()) { sdk } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } if (restoreConnection) { service.saveContact(RING_PUBKY, "Contact", restorePrivateConnection = true) verify(sdk).unblockPeer(RING_PUBKY) @@ -527,7 +533,7 @@ class PaykitSdkServiceTest { } "save" -> whenever(sdk.saveContact(any())).thenThrow(failure).thenReturn(mock()) } - val service = PaykitSdkService(mock(), mock(), mock()) { sdk } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } val thrown = assertFailsWith { service.saveContact(RING_PUBKY, "Contact", restorePrivateConnection = true) } @@ -553,7 +559,7 @@ class PaykitSdkServiceTest { whenever(sdk.linkedPeers()).thenReturn(peers) whenever(sdk.saveContact(any())).thenThrow(restorationFailure) whenever(sdk.blockPeer(RING_PUBKY)).thenThrow(rollbackFailure) - val service = PaykitSdkService(mock(), mock(), mock()) { sdk } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } val thrown = assertFailsWith { service.saveContact(RING_PUBKY, "Contact", restorePrivateConnection = true) @@ -568,11 +574,54 @@ class PaykitSdkServiceTest { fun `blocked peer cleanup does not attempt network delivery`() = runTest { val sdk = mock() whenever(sdk.linkedPeers()).thenReturn(listOf(contactPeer(LinkedPeerState.BLOCKED))) - val service = PaykitSdkService(mock(), mock(), mock()) { sdk } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } assertNull(service.clearPrivatePaymentList(RING_PUBKY)) verify(sdk, never()).clearPrivatePaymentListAndProcessOutbound(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.clearPrivatePaymentList(RING_PUBKY)) + + verify(sdk, never()).clearPrivatePaymentListAndProcessOutbound(any()) + verify(sdk, never()).publishPaykitApp(any(), 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.identityStatus()).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)) + } + } + private fun contactPeer(state: LinkedPeerState) = LinkedPeerRecord( counterparty = RING_PUBKY, state = state, diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceWipeTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceWipeTest.kt index 40d00af7d9..cb00fea911 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceWipeTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceWipeTest.kt @@ -100,7 +100,14 @@ 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` { requiredSessionCapabilities() }.thenReturn("capabilities") diff --git a/app/src/test/java/to/bitkit/services/PubkyIdentityRepublishTest.kt b/app/src/test/java/to/bitkit/services/PubkyIdentityRepublishTest.kt index 4037beda38..d5d2ccdffe 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() @@ -204,6 +210,7 @@ class PubkyIdentityRepublishTest { mock(), { bootstrap }, StandardTestDispatcher(testScheduler), + settingsStore = mock(), ) { mock() } service.republishIdentityIfNeeded(publicKey, now = 0) @@ -237,6 +244,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/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 From 40aef850b7b16b88907fdcc8f90160a4e5f09047 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 1 Oct 2026 12:34:35 -0500 Subject: [PATCH 12/77] test: align request presentation with shared paykit --- .../test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt | 2 +- journeys/payment-requests/automatic-presentation.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 13fe5f01e9..0665807f26 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -934,7 +934,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { Result.success( PublicPaykitPaymentResult.Opened( paymentRequest = bolt11, - privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 8uL), + privatePaymentContext = privatePaymentContext(version = 8uL), ), ), ) diff --git a/journeys/payment-requests/automatic-presentation.xml b/journeys/payment-requests/automatic-presentation.xml index 47b5009cdd..8f19ff2dc7 100644 --- a/journeys/payment-requests/automatic-presentation.xml +++ b/journeys/payment-requests/automatic-presentation.xml @@ -1,6 +1,6 @@ - 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 From d68ecbb8b15b88192a5340af269e0ec4689bb1ec Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 1 Oct 2026 12:56:19 -0500 Subject: [PATCH 13/77] test: align private paykit settings stub --- .../test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index d2f5cdce93..bfa098398a 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -113,7 +113,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { cacheData.value = PrivatePaykitCacheData() } whenever(settingsStore.data).thenReturn(settingsData) - whenever { settingsStore.update(any()) }.thenAnswer { + whenever(settingsStore.update(any())).thenAnswer { val transform = it.getArgument<(SettingsData) -> SettingsData>(0) settingsData.value = transform(settingsData.value) } From a371aa696d6f5d666766ba6c0e1aaf70965ca98c Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 1 Oct 2026 16:53:39 -0500 Subject: [PATCH 14/77] fix: reduce paykit sync overhead and retry session setup --- .../ContactPaymentSettingsRepo.kt | 12 +- .../to/bitkit/services/PaykitSdkService.kt | 172 ++++++++++++------ .../java/to/bitkit/viewmodels/AppViewModel.kt | 1 + .../ContactPaymentSettingsRepoTest.kt | 25 +++ .../services/PaykitBackupStateTrackingTest.kt | 78 ++++++++ .../services/PaykitKeyGenerationTest.kt | 61 ++++++- .../bitkit/services/PaykitSdkServiceTest.kt | 43 ++++- .../viewmodels/AppViewModelSendFlowTest.kt | 29 +++ journeys/contacts/README.md | 10 + journeys/contacts/contact-payment-sharing.xml | 20 ++ 10 files changed, 371 insertions(+), 80 deletions(-) create mode 100644 journeys/contacts/contact-payment-sharing.xml diff --git a/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt b/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt index e21e6faa0d..6feff41ab7 100644 --- a/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt @@ -84,7 +84,6 @@ class ContactPaymentSettingsRepo @Inject constructor( } private suspend fun disable(contacts: List): Result { - val previous = settingsStore.data.first() runSuspendCatching { settingsStore.update { it.copy( @@ -108,16 +107,7 @@ class ContactPaymentSettingsRepo @Inject constructor( .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/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 9e60d814ab..4957f3007e 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -1,6 +1,7 @@ package to.bitkit.services import android.content.Context +import androidx.annotation.VisibleForTesting import com.synonym.paykit.ContactRecord import com.synonym.paykit.ContactUpdate import com.synonym.paykit.EndpointSyncReport @@ -181,6 +182,8 @@ class PaykitSdkService @Inject constructor( private var setupFailed = false private var platformInitializer: () -> Unit = { PaykitAndroid.initializeOrThrow(context) } private var sdk: PaykitSdk? = null + private var cachedPaykitKey: PaykitKeyGeneration? = null + private var cachedBackupState: PaykitBackupStateSnapshot? = null private val _backupStateVersion = MutableStateFlow(0L) val backupStateVersion: StateFlow = _backupStateVersion.asStateFlow() private var sdkFactory: () -> PaykitSdk = { @@ -226,11 +229,12 @@ class PaykitSdkService @Inject constructor( platformInitializer() launch { republishIdentityIfNeeded() } operationLock.withLock { - refreshPaykitKey() + refreshPaykitKey(force = true) var handle = handle() try { handle.initialize() } catch (e: PaykitException.Identity) { + invalidatePaykitKeyIfNeeded(e) if (!sessionProvider.canDeferStaleSession(e.context)) throw e Logger.warn( @@ -304,18 +308,16 @@ class PaykitSdkService @Inject constructor( suspend fun currentPublicKey(): String? { isSetup.await() return operationLock.withLock { - refreshPaykitKey() - val handle = handle() - handle.identityStatus()?.publicKey?.let { return@withLock it } - handle.initialize().publicKey + withPaykitKey { handle -> + handle.identityStatus()?.publicKey ?: handle.initialize().publicKey + } } } suspend fun hasPrivatePaymentAccess(): Boolean { isSetup.await() return operationLock.withLock { - refreshPaykitKey() - handle().identityStatus()?.capability == PubkyIdentityCapability.PRIVATE_LINK_CAPABLE + withPaykitKey { it.identityStatus()?.capability == PubkyIdentityCapability.PRIVATE_LINK_CAPABLE } } } @@ -470,12 +472,12 @@ class PaykitSdkService @Inject constructor( suspend fun publishPaykitProfile(profile: PaykitProfile): PaykitProfileRecord { isSetup.await() return operationLock.withLock { - refreshPaykitKey() - val handle = handle() - val publicKey = requireNotNull(handle.identityStatus()?.publicKey) - val current = handle.fetchPaykitProfile(publicKey) - handle.publishPaykitProfile(profile, current?.revision).also { - notifyBackupStateChanged() + withPaykitKey { handle -> + val publicKey = requireNotNull(handle.identityStatus()?.publicKey) + val current = handle.fetchPaykitProfile(publicKey) + handle.publishPaykitProfile(profile, current?.revision).also { + notifyBackupStateChanged() + } } } } @@ -499,11 +501,11 @@ class PaykitSdkService @Inject constructor( suspend fun deletePaykitProfile() { isSetup.await() operationLock.withLock { - refreshPaykitKey() - val handle = handle() - val publicKey = requireNotNull(handle.identityStatus()?.publicKey) - handle.fetchPaykitProfile(publicKey)?.let { handle.deletePaykitProfile(it.revision) } - notifyBackupStateChanged() + withPaykitKey { handle -> + val publicKey = requireNotNull(handle.identityStatus()?.publicKey) + handle.fetchPaykitProfile(publicKey)?.let { handle.deletePaykitProfile(it.revision) } + notifyBackupStateChanged() + } } } @@ -524,16 +526,14 @@ class PaykitSdkService @Inject constructor( suspend fun contactRecords(): List { isSetup.await() return operationLock.withLock { - refreshPaykitKey() - handle().contactRecords() + withPaykitKey { it.contactRecords() } } } suspend fun contactRecord(publicKey: String): ContactRecord? { isSetup.await() return operationLock.withLock { - refreshPaykitKey() - handle().contactRecord(publicKey) + withPaykitKey { it.contactRecord(publicKey) } } } @@ -583,6 +583,7 @@ class PaykitSdkService @Inject constructor( 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) } } @@ -702,30 +703,29 @@ class PaykitSdkService @Inject constructor( suspend fun allPaymentRequests(expectedIdentity: String? = null): List { isSetup.await() return operationLock.withLock { - refreshPaykitKey() - val handle = handle() - if (expectedIdentity != null) { - check(PubkyPublicKeyFormat.matches(handle.identityStatus()?.publicKey, expectedIdentity)) { - "Payment Request identity changed" + withPaykitKey { handle -> + if (expectedIdentity != null) { + check(PubkyPublicKeyFormat.matches(handle.identityStatus()?.publicKey, expectedIdentity)) { + "Payment Request identity changed" + } } - } - handle.listPaymentRequests( - PaymentRequestFilter( - counterparty = null, - localRole = null, - states = emptyList(), - recurring = null, - receivedOnly = false, + handle.listPaymentRequests( + PaymentRequestFilter( + counterparty = null, + localRole = null, + states = emptyList(), + recurring = null, + receivedOnly = false, + ), ) - ) + } } } suspend fun identityStatus(): IdentityStatus? { isSetup.await() return operationLock.withLock { - refreshPaykitKey() - handle().identityStatus() + withPaykitKey { it.identityStatus() } } } @@ -853,16 +853,14 @@ class PaykitSdkService @Inject constructor( suspend fun linkedPeers(): List { isSetup.await() return operationLock.withLock { - refreshPaykitKey() - handle().linkedPeers() + withPaykitKey { it.linkedPeers() } } } suspend fun pendingOutboundPrivateCounterparties(): List { isSetup.await() return operationLock.withLock { - refreshPaykitKey() - handle().pendingOutboundPrivateCounterparties() + withPaykitKey { it.pendingOutboundPrivateCounterparties() } } } @@ -948,8 +946,7 @@ class PaykitSdkService @Inject constructor( suspend fun exportBackupState(): String { isSetup.await() return operationLock.withLock { - refreshPaykitKey() - handle().exportBackupString() + withPaykitKey { it.exportBackupString() } } } @@ -1003,12 +1000,24 @@ class PaykitSdkService @Inject constructor( } } - private suspend fun refreshPaykitKey() { - val root = sessionProvider.loadLocalSecretKey() ?: return - sessionProvider.setPaykitIdentitySecretKey(paykitKey(root)) + private suspend fun refreshPaykitKey(force: Boolean = false) { + if (force) cachedPaykitKey = null + val root = sessionProvider.loadLocalSecretKey() ?: run { + 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 + val key = paykitKey(root) + sessionProvider.setPaykitIdentitySecretKey(key) + cachedPaykitKey = PaykitKeyGeneration(publicKey, key.keyGeneration()) } - suspend fun paykitKeyForAuthorization(secretKeyHex: String): PaykitIdentitySecretKey { + @VisibleForTesting + internal suspend fun paykitKeyForAuthorization(secretKeyHex: String): PaykitIdentitySecretKey { isSetup.await() return operationLock.withLock { paykitKey(localSecretKey(secretKeyHex)) } } @@ -1067,6 +1076,7 @@ class PaykitSdkService @Inject constructor( ) } }.onFailure { + invalidatePaykitKeyIfNeeded(it) Logger.warn("Failed to publish Paykit app", it, context = TAG) } } @@ -1106,18 +1116,39 @@ class PaykitSdkService @Inject constructor( blockedPeers.forEach { peer -> runSuspendCatching { handle.blockPeer(peer.counterparty) - }.onFailure(restorationError::addSuppressed) + }.onFailure { + invalidatePaykitKeyIfNeeded(it) + restorationError.addSuppressed(it) + } } } } } } - private suspend fun withStateRevisionTracking(block: suspend (PaykitSdk) -> T): T { + private suspend fun withPaykitKey(block: suspend (PaykitSdk) -> T): T { refreshPaykitKey() - val handle = handle() - return withPaykitBackupStateTracking( - readRevision = { handle.backupStateRevision() }, + return runSuspendCatching { block(handle()) } + .onFailure(::invalidatePaykitKeyIfNeeded) + .getOrThrow() + } + + 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 { handle.backupStateRevision() } + .onFailure(::invalidatePaykitKeyIfNeeded) + .getOrThrow() + }, + readStateRevision = { handle.stateRevision() }, + cachedSnapshot = cachedBackupState, + onSnapshot = { cachedBackupState = it }, onChange = ::notifyBackupStateChanged, ) { block(handle) @@ -1141,6 +1172,8 @@ class PaykitSdkService @Inject constructor( private fun resetRuntime() { sdk = null + cachedPaykitKey = null + cachedBackupState = null } companion object { @@ -1185,17 +1218,46 @@ internal fun isBitkitPaymentRequest(record: PaymentRequestRecord): Boolean = whe 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 }, + cachedSnapshot: PaykitBackupStateSnapshot? = null, + onSnapshot: (PaykitBackupStateSnapshot?) -> Unit = {}, onChange: () -> Unit, operation: suspend () -> T, ): T { - val previousRevision = runSuspendCatching { readRevision() }.getOrNull() + val observedStateRevision = runSuspendCatching { readStateRevision() }.getOrNull() + val previousRevision = if (cachedSnapshot != null && observedStateRevision == cachedSnapshot.stateRevision) { + cachedSnapshot.backupRevision + } else { + runSuspendCatching { readRevision() }.getOrNull() + } + val previousStateRevision = runSuspendCatching { readStateRevision() }.getOrNull() + var succeeded = false return try { - operation() + operation().also { succeeded = true } } finally { withContext(NonCancellable) { + val nextStateRevision = runSuspendCatching { readStateRevision() }.getOrNull() + val unchangedRevision = previousStateRevision?.takeIf { it == nextStateRevision } + if (succeeded && unchangedRevision != null && previousRevision != null) { + onSnapshot(PaykitBackupStateSnapshot(unchangedRevision, previousRevision)) + return@withContext + } val nextRevision = runSuspendCatching { readRevision() }.getOrNull() + val stateRevision = runSuspendCatching { readStateRevision() }.getOrNull() + onSnapshot( + if (succeeded && stateRevision != null && nextRevision != null) { + PaykitBackupStateSnapshot(stateRevision, nextRevision) + } else { + null + }, + ) if (previousRevision == null || nextRevision == null || previousRevision != nextRevision) { onChange() } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 1affda6575..3d953dc9d8 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -902,6 +902,7 @@ class AppViewModel @Inject constructor( if (isOnline.value != ConnectivityState.CONNECTED) continue val refreshMaintenance = maintenanceDelay <= Duration.ZERO if (refreshMaintenance) { + if (isPaykitEnabled.value && walletRepo.walletExists()) pubkyRepo.restoreSessionIfNeeded() pubkyRepo.republishIdentityIfNeeded() privatePaykitRepo.refreshKnownSavedContactEndpoints("payment request polling") maintenanceIntervalIndex = diff --git a/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt index b9efee5774..0bf9ffe89b 100644 --- a/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt @@ -18,6 +18,7 @@ import to.bitkit.models.PubkyProfile import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError import kotlin.test.assertFalse +import kotlin.test.assertSame import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) @@ -160,6 +161,30 @@ 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)).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>()) + } + } + private fun createSut() = ContactPaymentSettingsRepo( settingsStore = settingsStore, publicPaykitRepo = publicPaykitRepo, diff --git a/app/src/test/java/to/bitkit/services/PaykitBackupStateTrackingTest.kt b/app/src/test/java/to/bitkit/services/PaykitBackupStateTrackingTest.kt index 408ab4e501..24a8e57509 100644 --- a/app/src/test/java/to/bitkit/services/PaykitBackupStateTrackingTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitBackupStateTrackingTest.kt @@ -10,6 +10,7 @@ import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertNull import kotlin.test.assertSame import kotlin.test.assertTrue @@ -80,4 +81,81 @@ class PaykitBackupStateTrackingTest : BaseUnitTest() { assertEquals("after", revision) assertEquals(1, changes) } + + @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" }, + 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", 2), + )) { + 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 `failed writes recheck backup content even when the local revision is unchanged`() = test { + var snapshot: PaykitBackupStateSnapshot? = PaykitBackupStateSnapshot("state", "before") + var reads = 0 + var changes = 0 + val failure = AppError("Write outcome unknown") + val thrown = assertFailsWith { + withPaykitBackupStateTracking( + readRevision = { + reads++ + "after" + }, + readStateRevision = { "state" }, + cachedSnapshot = snapshot, + onSnapshot = { snapshot = it }, + onChange = { changes++ }, + ) { throw failure } + } + assertSame(failure, thrown) + assertEquals(1, reads) + 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 index 364e4d9cc2..09bae705a5 100644 --- a/app/src/test/java/to/bitkit/services/PaykitKeyGenerationTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitKeyGenerationTest.kt @@ -2,6 +2,7 @@ package to.bitkit.services import com.synonym.paykit.IdentityStatus 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 @@ -66,20 +67,24 @@ class PaykitKeyGenerationTest { } @Test - fun `stored root refresh rotates the session key and rejects registry rollback`() = runTest { + 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) - whenever(sdk.identityStatus()) - .thenReturn(IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + 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" - whenever(keychain.loadString(storageKey)).thenReturn("2", "2", "3", "3") + var savedGeneration = "2" + whenever(keychain.loadString(storageKey)).thenAnswer { savedGeneration } + whenever(keychain.upsertString(storageKey, "3")).thenAnswer { savedGeneration = "3" } val root = mock() - val initialKey = mock() - val rotatedKey = 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) @@ -91,6 +96,10 @@ class PaykitKeyGenerationTest { 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) @@ -105,7 +114,7 @@ class PaykitKeyGenerationTest { } verify(root, times(2)).derivePaykitIdentitySecretKey(any()) verify(keychain).upsertString(any(), any()) - verify(sdk, times(2)).identityStatus() + verify(sdk, times(5)).identityStatus() verify(provider, times(2)).setPaykitIdentitySecretKey(any()) } } @@ -138,4 +147,42 @@ class PaykitKeyGenerationTest { 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/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index 3fd488337c..0f4256194c 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -40,6 +40,7 @@ import org.mockito.kotlin.doReturn import org.mockito.kotlin.inOrder 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 @@ -70,6 +71,28 @@ class PaykitSdkServiceTest { private const val RING_PUBKY = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" } + @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), + settingsStore = mock(), + platformInitializer = {}, + sdkFactory = { sdk }, + ) + + assertSame(failure, assertFailsWith { service.initialize() }) + assertSame(failure, assertFailsWith { service.contactRecords() }) + service.initialize() + assertEquals(emptyList(), service.contactRecords()) + } + @Test fun `wallet wipe drains initialization before cleanup and allows fresh work`() = runTest { val sdk = mock() @@ -103,21 +126,27 @@ 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.backupStateRevision()).thenReturn("backup") var handlesCreated = 0 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 diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 0665807f26..a27e20d94a 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -736,6 +736,34 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } } + @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()).thenReturn(Result.success(Unit)) + clearInvocations(pubkyRepo, paykitPaymentRequestRepo) + + sut.startPaykitPaymentRequestPolling() + try { + advanceTimeBy(30.seconds.inWholeMilliseconds) + runCurrent() + verify(pubkyRepo).restoreSessionIfNeeded() + verify(paykitPaymentRequestRepo, never()).refresh() + + advanceTimeBy(60.seconds.inWholeMilliseconds) + runCurrent() + verify(pubkyRepo, times(2)).restoreSessionIfNeeded() + verify(paykitPaymentRequestRepo).refresh() + } finally { + sut.stopPaykitPaymentRequestPolling() + } + } + @Test fun `offline periodic polling skips inbox and maintenance`() = test { enablePaykitUi() @@ -756,6 +784,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { runCurrent() verify(pubkyRepo, never()).republishIdentityIfNeeded() verify(paykitPaymentRequestRepo, never()).refresh() + verify(pubkyRepo, never()).restoreSessionIfNeeded() verify(paykitPaymentRequestRepo, never()).refreshEligibleTargets(any(), any()) verify(paykitPaymentProofRepo, never()).reconcile() verify(privatePaykitRepo, never()).refreshKnownSavedContactEndpoints("payment request polling") diff --git a/journeys/contacts/README.md b/journeys/contacts/README.md index 3e7abb48c3..b19cd2bbff 100644 --- a/journeys/contacts/README.md +++ b/journeys/contacts/README.md @@ -1,5 +1,15 @@ # Contacts +`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. + Import journeys require a disposable identity with a known following list. They save local Bitkit contacts; payment sharing remains a separate step. 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. 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 + + From 0665c494cc2694d1bd25de1fd1c6a75fb2054063 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 2 Oct 2026 06:26:12 -0500 Subject: [PATCH 15/77] fix: keep private message sync off frequent request polls --- .../repositories/PaykitPaymentRequestRepo.kt | 11 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 2 +- .../PaykitPaymentRequestRepoTest.kt | 17 +++ .../viewmodels/AppViewModelSendFlowTest.kt | 113 +++++++++--------- journeys/payment-requests/README.md | 6 + .../automatic-presentation.xml | 2 +- 6 files changed, 91 insertions(+), 60 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 03ce8bffe5..66023da0ce 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -440,7 +440,7 @@ class PaykitPaymentRequestRepo @Inject constructor( } } - suspend fun refresh(): Result { + suspend fun refresh(syncPrivateMessages: Boolean = true): Result { val generation = stateGeneration.get() val expectedIdentity = activeIdentity return withContext(ioDispatcher) { @@ -450,7 +450,7 @@ class PaykitPaymentRequestRepo @Inject constructor( clearStateLocked() return@withLock } - runSuspendCatching { synchronizeLocked(generation, expectedIdentity) } + runSuspendCatching { synchronizeLocked(generation, expectedIdentity, syncPrivateMessages) } .onFailure { discardExpiredRequestsLocked() } .getOrThrow() } @@ -971,9 +971,12 @@ class PaykitPaymentRequestRepo @Inject constructor( private suspend fun synchronizeLocked( generation: Long, expectedIdentity: String?, + syncPrivateMessages: Boolean = true, ) { - processPendingMessages() - paykitSdkService.receivePrivateMessagesFromLinkedPeers().also(::logIntakeFailures) + if (syncPrivateMessages) { + processPendingMessages() + paykitSdkService.receivePrivateMessagesFromLinkedPeers().also(::logIntakeFailures) + } val now = clock.now() val subscriptionNow = subscriptionClock.now() receivedContacts = null diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 3d953dc9d8..9a6d0912df 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -875,7 +875,7 @@ 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 { + paykitPaymentRequestRepo.refresh(syncPrivateMessages = refreshMaintenance).onSuccess { activityRepo.backfillPaykitContacts() presentNextIncomingPaykitPaymentRequest() } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 07e4631e28..83eaa9221e 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -136,6 +136,23 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.clear() } + @Test + fun `shared state refresh skips private messages until a full refresh`() = test { + val record = paymentRequestRecord() + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) + + sut.refresh(syncPrivateMessages = false).getOrThrow() + + assertEquals(record.paymentRequestId, sut.pendingRequests.value.single().paymentRequestId) + verify(paykitSdkService, never()).processPendingPrivateMessages() + verify(paykitSdkService, never()).receivePrivateMessagesFromLinkedPeers() + + sut.refresh().getOrThrow() + + verify(paykitSdkService).processPendingPrivateMessages() + verify(paykitSdkService).receivePrivateMessagesFromLinkedPeers() + } + @Test fun `shared app destinations stay out of request UI and clear on identity switch`() = test { val address = PaykitReceivedPaymentContactsTest.ADDRESS diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index a27e20d94a..3ddce0ebf6 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -682,7 +682,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) @@ -704,32 +704,37 @@ 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 `private message sync 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(syncPrivateMessages = true) + for (interval in listOf(60.seconds, 120.seconds, 120.seconds)) { + clearInvocations(pubkyRepo, paykitPaymentRequestRepo) advanceTimeBy(interval.inWholeMilliseconds - 1) runCurrent() verify(pubkyRepo, never()).republishIdentityIfNeeded() + verify(paykitPaymentRequestRepo, never()).refresh(syncPrivateMessages = true) + verify(paykitPaymentRequestRepo, atLeast(1)).refresh(syncPrivateMessages = false) advanceTimeBy(1) runCurrent() verify(pubkyRepo).republishIdentityIfNeeded() + verify(paykitPaymentRequestRepo).refresh(syncPrivateMessages = true) } } finally { sut.stopPaykitPaymentRequestPolling() @@ -745,7 +750,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { if (++attempts == 2) pubkyPublicKey.value = testPublicKey Unit } - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) clearInvocations(pubkyRepo, paykitPaymentRequestRepo) sut.startPaykitPaymentRequestPolling() @@ -753,12 +758,12 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceTimeBy(30.seconds.inWholeMilliseconds) runCurrent() verify(pubkyRepo).restoreSessionIfNeeded() - verify(paykitPaymentRequestRepo, never()).refresh() + verify(paykitPaymentRequestRepo, never()).refresh(any()) advanceTimeBy(60.seconds.inWholeMilliseconds) runCurrent() verify(pubkyRepo, times(2)).restoreSessionIfNeeded() - verify(paykitPaymentRequestRepo).refresh() + verify(paykitPaymentRequestRepo).refresh(any()) } finally { sut.stopPaykitPaymentRequestPolling() } @@ -783,7 +788,7 @@ 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() @@ -815,7 +820,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) @@ -823,28 +828,28 @@ class AppViewModelSendFlowTest : BaseUnitTest() { try { runCurrent() - verify(paykitPaymentRequestRepo).refresh() + verify(paykitPaymentRequestRepo).refresh(any()) clearInvocations(paykitPaymentRequestRepo) advanceTimeBy(30.seconds.inWholeMilliseconds) runCurrent() - verify(paykitPaymentRequestRepo, atLeast(2)).refresh() + verify(paykitPaymentRequestRepo, atLeast(2)).refresh(any()) clearInvocations(paykitPaymentRequestRepo) clearInvocations(privatePaykitRepo, paykitPaymentProofRepo) advanceTimeBy(9.seconds.inWholeMilliseconds) runCurrent() - verify(paykitPaymentRequestRepo, never()).refresh() + verify(paykitPaymentRequestRepo, never()).refresh(any()) val request = paymentRequest() - whenever(paykitPaymentRequestRepo.refresh()).doSuspendableAnswer { + 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)) @@ -853,10 +858,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() @@ -874,19 +879,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")), ) @@ -894,17 +899,17 @@ 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() } @@ -916,7 +921,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val request = paymentRequest() val bolt11 = "lnbcrt1updatedpaymentrequest" val privateContext = privatePaymentContext(8uL) - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.beginPaymentRequest(request)).thenReturn( Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList), Result.success( @@ -977,7 +982,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { 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()) } @Test @@ -1235,7 +1240,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) ) @@ -1267,7 +1272,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) ) @@ -1879,7 +1884,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) ) @@ -1930,7 +1935,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) ) @@ -2006,7 +2011,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `unresolvable automatic request falls back to low frequency retries`() = test { 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) @@ -2043,7 +2048,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() @@ -2070,7 +2075,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 { @@ -2105,7 +2110,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() @@ -2129,7 +2134,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) @@ -2170,7 +2175,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() @@ -2217,7 +2222,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() @@ -2390,7 +2395,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() @@ -2431,7 +2436,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() @@ -2465,7 +2470,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() @@ -2497,7 +2502,7 @@ 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) @@ -2534,7 +2539,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) @@ -2560,7 +2565,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) @@ -3780,7 +3785,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() @@ -3799,7 +3804,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() @@ -6134,7 +6139,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { 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() @@ -6304,7 +6309,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() @@ -6829,7 +6834,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pendingPaykitPaymentRequests.value = emptyList() stubOpenedPaymentRequest(request, lnurl.uri) whenever(coreService.decode(lnurl.uri)).thenReturn(Scanner.LnurlPay(lnurl)) - whenever(paykitPaymentRequestRepo.refresh()).doSuspendableAnswer { + whenever(paykitPaymentRequestRepo.refresh(any())).doSuspendableAnswer { pendingPaykitPaymentRequests.value = listOf(request) Result.success(Unit) } @@ -6837,7 +6842,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.retryIncomingPaymentRequest(request.id) advanceUntilIdle() - verify(paykitPaymentRequestRepo).refresh() + verify(paykitPaymentRequestRepo).refresh(syncPrivateMessages = true) verify(privatePaykitRepo, atLeast(1)).beginPaymentRequest(request) assertEquals(request.id, sut.sendUiState.value.incomingPaymentRequestId) assertTrue(sut.currentSheet.value is Sheet.Send) @@ -7247,7 +7252,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { 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)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) stubSuccessfulOnchainSend(address, request.amountSats) @@ -7265,7 +7270,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { confirmCurrentPayment() verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue, "bitkit") - verify(paykitPaymentRequestRepo).refresh() + verify(paykitPaymentRequestRepo).refresh(syncPrivateMessages = true) } @Test @@ -7399,7 +7404,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `initial subscription retry keeps the send sheet presented`() = test { val request = paymentRequest() pendingPaykitPaymentRequests.value = listOf(request) - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.beginPaymentRequestWaitingForUpdatedList(request)).thenReturn( Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList) ) diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index 0c4c4455f5..e558cf8919 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -24,6 +24,12 @@ Rejected fixture shapes stay in unit tests because Bitkit intentionally does not `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 refreshes shared request state every 10 seconds. Private messages are synchronized on +startup and explicit refreshes, and on maintenance rounds that back off from 30 to 60 to 120 seconds. +A new peer message can therefore take up to two minutes plus synchronization time to appear during steady polling. + ## Accepting install Acceptance intent is saved before the remote operation and included in wallet backups. diff --git a/journeys/payment-requests/automatic-presentation.xml b/journeys/payment-requests/automatic-presentation.xml index 8f19ff2dc7..e192331d0e 100644 --- a/journeys/payment-requests/automatic-presentation.xml +++ b/journeys/payment-requests/automatic-presentation.xml @@ -5,7 +5,7 @@ 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" + On the payer, wait for the next private-message sync and verify Payment Request confirmation (testTag "PaymentRequestConfirm") appears automatically with 21,000 sats and For shows "First request" 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" From 5da2ef23561b8358fb14158ff3bd59054f818c3b Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 2 Oct 2026 08:50:33 -0500 Subject: [PATCH 16/77] fix: preserve paykit activation and recurring payments --- .../repositories/PaykitPaymentRequestRepo.kt | 15 +++++--- .../bitkit/repositories/PrivatePaykitRepo.kt | 15 ++++++-- ...aykitPaymentRequestRepoSubscriptionTest.kt | 37 ++++++++++++++---- .../PaykitPaymentRequestRepoTest.kt | 38 +++++++++++++++++++ ...itSubscriptionNotificationSchedulerTest.kt | 3 -- .../repositories/PrivatePaykitRepoTest.kt | 37 ++++++++++++++++++ .../fixed-onchain-destination.xml | 17 +++++++++ 7 files changed, 143 insertions(+), 19 deletions(-) create mode 100644 journeys/subscriptions/fixed-onchain-destination.xml diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 66023da0ce..254abed701 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -328,12 +328,15 @@ 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.get() + val generation = stateGeneration.incrementAndGet() operationMutex.withLock { - if (PubkyPublicKeyFormat.matches(activeIdentity, normalizedIdentity)) return@withLock + if (stateGeneration.get() != generation) return@withLock clearStateLocked() activeIdentity = null acceptedOneTimeRequestIds = emptySet() @@ -351,6 +354,7 @@ class PaykitPaymentRequestRepo @Inject constructor( subscriptionAcceptedAt = subscriptionState.acceptedAt presentedSubscriptionProposalIds = subscriptionState.presentedProposalIds dismissedSubscriptionPaymentIds = subscriptionState.dismissedPaymentIds + if (stateGeneration.get() != generation) return@withLock activeIdentity = normalizedIdentity activatedGeneration = generation } @@ -1218,7 +1222,8 @@ class PaykitPaymentRequestRepo @Inject constructor( } } return activatedGeneration == stateGeneration.get() && _subscriptions.value.any { - it.isPayer && it.lifecycleState == PaymentRequestLifecycleState.ACTIVE_RECURRING && request.belongsTo(it) + it.isPayer && it.lifecycleState == PaymentRequestLifecycleState.ACTIVE_RECURRING && + request.belongsTo(it) && request.billingPeriod !in it.paidPeriods } } diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 8f7ba449c1..191e22e0f4 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -668,7 +668,12 @@ class PrivatePaykitRepo @Inject constructor( 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 || paymentRequest != null)) { Logger.info( @@ -1418,7 +1423,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() @@ -1444,7 +1453,7 @@ class PrivatePaykitRepo @Inject constructor( true } } - endpoint.methodId.isOnchain -> { + endpoint.methodId.isOnchain && !allowUsedOnchainAddress -> { val isUsed = runSuspendCatching { coreService.isAddressUsed(endpoint.value) } .onFailure { Logger.warn( diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt index b63d36a37e..70d6721555 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt @@ -187,6 +187,31 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat assertTrue(sut.ensurePaymentAllowed(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 + } + whenever(paykitSdkService.allPaymentRequests(anyOrNull())) + .thenReturn(listOf(record.copy(paymentProofs = listOf(proof)))) + 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.ensurePaymentAllowed(nextPeriod).getOrThrow() + } + @Test fun `refresh keeps creator subscription without generating a payer payment`() = test { val metadataText = """ @@ -314,7 +339,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) @@ -495,11 +520,10 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat fun `subscription clock offset makes the next billing 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) @@ -523,11 +547,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) @@ -556,11 +579,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) @@ -570,7 +592,6 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat PaykitPaymentRequestId( paymentRequestId = PAYMENT_REQUEST_ID, counterparty = COUNTERPARTY, - counterpartyReceiverPath = PaykitReceiverPaths.SERVER, billingPeriodStartsAt = it, ) to PaykitPaymentProofKind.Onchain } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 83eaa9221e..f7020d3739 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -694,6 +694,43 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { 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() @@ -774,6 +811,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentProofRepo, subscriptionNotificationScheduler, clock, + clock, ) other.activate(LOCAL_IDENTITY) var record = paymentRequestRecord() diff --git a/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionNotificationSchedulerTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionNotificationSchedulerTest.kt index 518657703d..572f7d7d08 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionNotificationSchedulerTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionNotificationSchedulerTest.kt @@ -171,7 +171,6 @@ class PaykitSubscriptionNotificationSchedulerTest { PaykitPaymentRequestId( paymentRequestId = PAYMENT_REQUEST_ID, counterparty = COUNTERPARTY, - counterpartyReceiverPath = RECEIVER_PATH, billingPeriodStartsAt = NEXT_PERIOD_START.toString(), ) ), @@ -206,7 +205,6 @@ class PaykitSubscriptionNotificationSchedulerTest { PaykitPaymentRequestId( paymentRequestId = PAYMENT_REQUEST_ID, counterparty = COUNTERPARTY, - counterpartyReceiverPath = RECEIVER_PATH, billingPeriodStartsAt = it.startsAt.toString(), ) }, @@ -250,7 +248,6 @@ class PaykitSubscriptionNotificationSchedulerTest { PaykitPaymentRequestId( paymentRequestId = PAYMENT_REQUEST_ID, counterparty = COUNTERPARTY, - counterpartyReceiverPath = RECEIVER_PATH, billingPeriodStartsAt = NEXT_PERIOD_START.toString(), ) ), diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index bfa098398a..5c5b355b10 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -8,6 +8,7 @@ import com.synonym.paykit.ContactRecord import com.synonym.paykit.LinkedPeerRecord import com.synonym.paykit.LinkedPeerState import com.synonym.paykit.PaykitException +import com.synonym.paykit.PaymentRequestLifecycleState import com.synonym.paykit.PrivatePaymentListDeliveryReport import com.synonym.paykit.PrivatePaymentListReservationUpdateInput import com.synonym.paykit.PrivatePaymentListSyncChange @@ -1328,6 +1329,42 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { 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 expiration after private resolution`() = test { val request = paymentRequest() 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 + + From 820335046a49337930e6f51c414d45320b0dc99b Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 2 Oct 2026 09:44:23 -0500 Subject: [PATCH 17/77] fix: preserve paykit recovery and payment details --- .../repositories/PaykitPaymentRequestRepo.kt | 3 +- .../PaykitSubscriptionProposal.kt | 2 + .../bitkit/repositories/PrivatePaykitRepo.kt | 24 ++++++----- .../java/to/bitkit/services/CoreService.kt | 22 ++++++---- .../PaykitPaymentRequestRepoTest.kt | 40 ++++++++++++------- .../PaykitSubscriptionProposalTest.kt | 28 +++++++++---- .../repositories/PrivatePaykitRepoTest.kt | 21 ++++++++++ .../ActivityServicePaykitContactsTest.kt | 25 +++++++++++- journeys/subscriptions/create-and-propose.xml | 8 +++- 9 files changed, 127 insertions(+), 46 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 254abed701..6fc6dd345a 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -857,7 +857,8 @@ class PaykitPaymentRequestRepo @Inject constructor( val uncertain = when (error) { is PaykitException.Transport, is PaykitException.Storage, - is PaykitException.Identity -> true + is PaykitException.Identity, + is PaykitException.ConcurrentUpdate -> true else -> false } if (!alreadySaved && !uncertain) { 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/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 191e22e0f4..c133f33d56 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -1230,10 +1230,7 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun removePublishedEndpoints(): Result = withContext(serializedDispatcher) { runSuspendCatching { publicationMutex.withLock { - val keys = ( - knownSavedContactKeys + ensureState().contacts.keys + pendingDeletedContactCleanupPublicKeys() - ).distinct() - removePublishedEndpointsLocked(keys).getOrThrow() + removePublishedEndpointsLocked().getOrThrow() } } } @@ -1248,16 +1245,24 @@ 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 linkedPublicKeys = paykitSdkService.linkedPeers() + .filter { it.state != LinkedPeerState.NOT_LINKED } + .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 preparation = clearPrivatePaymentLists(normalizedKeys) + val preparation = clearPrivatePaymentLists(normalizedKeys, linkedPublicKeys) val failedPublicKeys = preparation.failedPublicKeys.toMutableSet() var firstError = preparation.firstError @@ -1294,11 +1299,8 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun clearPrivatePaymentLists( publicKeys: Collection, + linkedPublicKeys: Set, ): PrivateEndpointCleanupPreparation { - val linkedPublicKeys = paykitSdkService.linkedPeers() - .filter { it.state != LinkedPeerState.NOT_LINKED } - .mapNotNull { normalizedPublicKey(it.counterparty) } - .toSet() val failedPublicKeys = mutableSetOf() val clearedRetryKeys = mutableListOf() var firstError: Throwable? = null diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 9d96292365..210d7cf1b9 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -1073,8 +1073,11 @@ class ActivityService( var index = 0 var currentAddressBatch: Int? = null while (index < endIndex) { - val addresses = fetchAddressSearchBatch(addressType, isChange, index, addressTypeKey, accountIndex) - ?: 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() && @@ -1085,34 +1088,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, accountIndex: UInt, - ): List? { + ): List? { val scope = if (isChange) "change" else "receive" return runSuspendCatching { lightningService.addressInfosForType( addressType = addressType, isChange = isChange, startIndex = index, - count = ADDRESS_SEARCH_BATCH_SIZE, + count = count, accountIndex = accountIndex, - ).map { it.address } + ) }.onFailure { Logger.warn( "Skipping '$addressTypeKey' '$scope' address search batch '$index'", @@ -1143,7 +1149,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 } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index f7020d3739..0f6d6cb5b9 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -750,22 +750,32 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `lost acceptance response is reconciled from shared state`() = test { - val proposed = paymentRequestRecord() - whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(proposed)) - whenever(paykitSdkService.acceptPaymentRequest(any(), any())) - .thenAnswer { throw PaykitException.Transport("transport_error", "response lost") } - 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()) + for (error in listOf( + PaykitException.Transport("transport_error", "response lost"), + PaykitException.ConcurrentUpdate("concurrent_update", "response read locked"), + )) { + 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))) - sut.refresh().getOrThrow() - val retry = sut.pendingRequests.value.single() - sut.accept(retry).getOrThrow() - sut.ensurePaymentAllowed(retry).getOrThrow() + 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 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/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 5c5b355b10..2e4174e478 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -341,6 +341,27 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { verifyBlocking(paykitSdkService, never()) { clearPrivatePaymentList(any()) } } + @Test + fun `full cleanup discovers remote peers and retries failed discovery without local state`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) + var failLookup = true + whenever(paykitSdkService.linkedPeers()).thenAnswer { + if (failLookup) throw AppError("Peer lookup unavailable") + listOf(linkedPeer(CONTACT_KEY, LinkedPeerState.LINKED)) + } + + assertTrue(sut.disableSharingAndPruneUnsavedContactState(emptyList()).isFailure) + assertTrue(cacheData.value.contacts.isEmpty()) + assertTrue(cacheData.value.cleanupPending) + verify(paykitSdkService, never()).clearPrivatePaymentList(any()) + + failLookup = false + sut.retryPendingEndpointRemoval(emptyList()).getOrThrow() + + verify(paykitSdkService).clearPrivatePaymentList(CONTACT_KEY) + assertFalse(cacheData.value.cleanupPending) + } + @Test fun `disabled cleanup uses existing capability and retries withdrawal and registry failures`() = test { val publicRepo = PublicPaykitRepo( diff --git a/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt b/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt index d9d3dcc369..6965d8e781 100644 --- a/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt +++ b/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt @@ -384,11 +384,31 @@ class ActivityServicePaykitContactsTest : BaseUnitTest() { assertEquals("server-address", row.address) assertEquals(BUYER, row.contact) assertEquals( - mapOf("nativeSegwit" to 600, "nativeSegwit:account:5" to 200), + 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( @@ -404,7 +424,8 @@ class ActivityServicePaykitContactsTest : BaseUnitTest() { 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, never()).addressInfosForType(AddressType.P2WPKH, true, 1200, 200, 5u) + verify(lightningService).addressInfosForType(AddressType.P2WPKH, true, 1200, 1, 5u) + verify(lightningService, never()).addressInfosForType(AddressType.P2WPKH, true, 1400, 200, 5u) } @Test diff --git a/journeys/subscriptions/create-and-propose.xml b/journeys/subscriptions/create-and-propose.xml index a548c004b6..373f7b97a8 100644 --- a/journeys/subscriptions/create-and-propose.xml +++ b/journeys/subscriptions/create-and-propose.xml @@ -2,7 +2,8 @@ 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 as identities @@ -13,12 +14,15 @@ 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" From 2458eaec128402e7fad238344432467a7bb86bbd Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 2 Oct 2026 10:17:34 -0500 Subject: [PATCH 18/77] fix: stop redundant paykit polling --- .../bitkit/repositories/PrivatePaykitRepo.kt | 21 ++++++++---- .../java/to/bitkit/viewmodels/AppViewModel.kt | 3 +- .../repositories/PrivatePaykitRepoTest.kt | 33 +++++++++++++++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 16 ++++++--- journeys/payment-requests/README.md | 2 ++ 5 files changed, 61 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index c133f33d56..11312da0af 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -231,13 +231,14 @@ class PrivatePaykitRepo @Inject constructor( _initialLinkBurstStarted.tryEmit(Unit) initialLinkBurstJob = retryScope.launch { - (listOf(kotlin.time.Duration.ZERO) + initialLinkBurstRetryDelays).forEach { retryDelay -> + for (retryDelay in listOf(kotlin.time.Duration.ZERO) + initialLinkBurstRetryDelays) { delay(retryDelay) val keys = synchronized(initialLinkBurstLock) { if (generation != initialLinkBurstGeneration) return@launch initialLinkBurstPublicKeys.toList() } refreshSavedContactEndpointsDuringInitialLinkBurst(keys, reason) + if (pendingPrivateMessageDrainKeys(keys, retryMissingPeers = true).isEmpty()) break } synchronized(initialLinkBurstLock) { if (generation != initialLinkBurstGeneration) return@launch @@ -834,7 +835,7 @@ class PrivatePaykitRepo @Inject constructor( if (link.exceptionOrNull() !is PaykitException.NotFound) linkRetryKeys += publicKey continue } - linkRetryKeys += publicKey + if (link.getOrThrow().state != LinkedPeerState.LINKED) linkRetryKeys += publicKey runSuspendCatching { privatePaymentListUpdate(publicKey, forceRefreshLightning) } .onSuccess { updates += it } .onFailure { @@ -854,10 +855,11 @@ class PrivatePaykitRepo @Inject constructor( reason: String, retryKeys: Collection, ) { - if (retryKeys.isEmpty()) return + val pendingKeys = pendingPrivateMessageDrainKeys(retryKeys, retryMissingPeers = true) + if (pendingKeys.isEmpty()) return - drainPendingPrivateMessages(reason, advancingLinksFor = retryKeys.toList()) - val pendingRetryKeys = pendingPrivateMessageDrainKeys(retryKeys) + drainPendingPrivateMessages(reason, advancingLinksFor = pendingKeys.toList()) + val pendingRetryKeys = pendingPrivateMessageDrainKeys(pendingKeys) if (pendingRetryKeys.isNotEmpty()) { schedulePendingPrivateMessageDrainRetries(reason, retryKeys = pendingRetryKeys) } @@ -1008,7 +1010,10 @@ class PrivatePaykitRepo @Inject constructor( pendingMessageDrainRetryKeys.toList() } if (retryKeys.isEmpty()) return@withContext - drainPendingPrivateMessages(reason, advancingLinksFor = retryKeys) + val pendingKeys = pendingPrivateMessageDrainKeys(retryKeys) + if (pendingKeys.isNotEmpty()) { + drainPendingPrivateMessages(reason, advancingLinksFor = pendingKeys.toList()) + } updatePendingMessageDrainRetryKeys(retryKeys) } @@ -1035,6 +1040,7 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun pendingPrivateMessageDrainKeys( retryKeys: Collection, + retryMissingPeers: Boolean = false, ): Set { val retryKeys = retryKeys.toSet() if (retryKeys.isEmpty()) return emptySet() @@ -1060,7 +1066,8 @@ class PrivatePaykitRepo @Inject constructor( 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 } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 9a6d0912df..31cc463b83 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -955,8 +955,7 @@ class AppViewModel @Inject constructor( refreshPaymentRequestTargets() INITIAL_PAYKIT_SYNC_RETRY_DELAYS.forEach { delay(it) - refreshIncomingPaykitPaymentRequests() - refreshPaymentRequestTargets() + refreshIncomingPaykitPaymentRequests(refreshMaintenance = false) } } } diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 2e4174e478..ffadb68324 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -5,6 +5,7 @@ import com.synonym.bitkitcore.LightningInvoice import com.synonym.bitkitcore.NetworkType import com.synonym.bitkitcore.Scanner import com.synonym.paykit.ContactRecord +import com.synonym.paykit.LinkedPeerHandshakeReport import com.synonym.paykit.LinkedPeerRecord import com.synonym.paykit.LinkedPeerState import com.synonym.paykit.PaykitException @@ -130,6 +131,9 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever { paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any()) } .thenReturn(privateListDeliveryReport(queuedCounterparties = listOf(CONTACT_KEY))) whenever { paykitSdkService.linkedPeers() }.thenReturn(emptyList()) + whenever { paykitSdkService.ensureLinkWithPeer(any(), any()) }.thenAnswer { + LinkedPeerHandshakeReport(it.getArgument(0), LinkedPeerState.LINKED, 1uL, null) + } whenever { paykitSdkService.pendingOutboundPrivateCounterparties() }.thenReturn(emptyList()) whenever { paykitSdkService.clearPrivatePaymentList(any()) }.thenReturn(privateListDeliveryReport()) whenever { publicPaykitRepo.beginPayment(any()) } @@ -156,6 +160,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) @@ -180,6 +185,9 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { true, cacheData.value.contacts.getValue(CONTACT_KEY).hasPublishedPrivatePaymentList, ) + verify(paykitSdkService).ensureLinkWithPeer(CONTACT_KEY) + verify(paykitSdkService, never()).processPendingPrivateMessages() + verify(paykitSdkService, never()).receivePrivateMessagesFromLinkedPeers() } @Test @@ -221,6 +229,31 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.closeAndClear() } + @Test + fun `initial link burst stops after linking and delivering pending messages`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) + whenever(paykitSdkService.linkedPeers()).thenReturn(listOf(linkedPeer(CONTACT_KEY, LinkedPeerState.LINKING))) + sut.startInitialLinkBurst(listOf(CONTACT_KEY), "test") + runCurrent() + + 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)).processPendingPrivateMessages() + + whenever(paykitSdkService.pendingOutboundPrivateCounterparties()).thenReturn(emptyList()) + advanceTimeBy(2_000) + runCurrent() + clearInvocations(paykitSdkService) + advanceTimeBy(120_000) + runCurrent() + verify(paykitSdkService, never()).ensureLinkWithPeer(any(), any()) + verify(paykitSdkService, never()).receivePrivateMessagesFromLinkedPeers() + sut.closeAndClear() + } + @Test fun `endpoint cleanup cancels scheduled link publication before local state is cleared`() = test { settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 3ddce0ebf6..affd7cc7b5 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -828,15 +828,21 @@ class AppViewModelSendFlowTest : BaseUnitTest() { try { runCurrent() - verify(paykitPaymentRequestRepo).refresh(any()) - clearInvocations(paykitPaymentRequestRepo) + verify(paykitPaymentRequestRepo).refresh(syncPrivateMessages = true) + clearInvocations(paykitPaymentRequestRepo, paykitPaymentProofRepo) - advanceTimeBy(30.seconds.inWholeMilliseconds) + advanceTimeBy(29.seconds.inWholeMilliseconds) + runCurrent() + verify(paykitPaymentRequestRepo, atLeast(1)).refresh(syncPrivateMessages = false) + verify(paykitPaymentRequestRepo, never()).refresh(syncPrivateMessages = true) + verify(paykitPaymentRequestRepo, never()).refreshEligibleTargets(any(), any()) + verify(paykitPaymentProofRepo, never()).reconcile() + + advanceTimeBy(1.seconds.inWholeMilliseconds) runCurrent() verify(paykitPaymentRequestRepo, atLeast(2)).refresh(any()) - clearInvocations(paykitPaymentRequestRepo) - clearInvocations(privatePaykitRepo, paykitPaymentProofRepo) + clearInvocations(paykitPaymentRequestRepo, privatePaykitRepo, paykitPaymentProofRepo) advanceTimeBy(9.seconds.inWholeMilliseconds) runCurrent() diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index e558cf8919..eda485232e 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -28,6 +28,8 @@ Rejected fixture shapes stay in unit tests because Bitkit intentionally does not Bitkit refreshes shared request state every 10 seconds. Private messages are synchronized on startup and explicit refreshes, and on maintenance rounds that back off from 30 to 60 to 120 seconds. +Startup link retries stop when no link or outbound work remains; subsequent startup request polls +read shared state without repeating private-message synchronization or target discovery. A new peer message can therefore take up to two minutes plus synchronization time to appear during steady polling. ## Accepting install From 2ca29dc90d6f62620c610ed7a684e003c5cef0b4 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 2 Oct 2026 10:50:44 -0500 Subject: [PATCH 19/77] fix: guard paykit payment authorization and cleanup --- .../repositories/PaykitPaymentRequestRepo.kt | 6 +++- .../bitkit/repositories/PrivatePaykitRepo.kt | 8 +++-- .../java/to/bitkit/services/CoreService.kt | 1 + ...aykitPaymentRequestRepoSubscriptionTest.kt | 31 +++++++++++++++++++ .../PaykitPaymentRequestRepoTest.kt | 1 + .../repositories/PrivatePaykitRepoTest.kt | 9 +++++- .../ActivityServicePaykitContactsTest.kt | 22 +++++++++++++ .../cancellation-during-confirmation.xml | 16 ++++++++++ 8 files changed, 90 insertions(+), 4 deletions(-) create mode 100644 journeys/subscriptions/cancellation-during-confirmation.xml diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 6fc6dd345a..048f3fc64c 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -818,6 +818,9 @@ class PaykitPaymentRequestRepo @Inject constructor( throw PaykitPaymentRequestError.RequestUnavailable } if (!isCurrentState(generation, identity)) throw PaykitPaymentRequestError.RequestUnavailable + if (forExecution && !hasCurrentExecutionContext(request)) { + throw PaykitPaymentRequestError.RequestUnavailable + } } } @@ -858,7 +861,8 @@ class PaykitPaymentRequestRepo @Inject constructor( is PaykitException.Transport, is PaykitException.Storage, is PaykitException.Identity, - is PaykitException.ConcurrentUpdate -> true + is PaykitException.ConcurrentUpdate, + is PaykitException.SharedStateBusy -> true else -> false } if (!alreadySaved && !uncertain) { diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 11312da0af..9d656a1347 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -1254,13 +1254,17 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun removePublishedEndpointsLocked(publicKeys: Collection? = null): Result = runSuspendCatching { - val linkedPublicKeys = paykitSdkService.linkedPeers() + val peers = paykitSdkService.linkedPeers() + val linkedPublicKeys = peers .filter { it.state != LinkedPeerState.NOT_LINKED } .mapNotNull { normalizedPublicKey(it.counterparty) } .toSet() + val discoveredPublicKeys = peers + .filter { it.state == LinkedPeerState.LINKED } + .mapNotNull { normalizedPublicKey(it.counterparty) } val keys = publicKeys ?: ( knownSavedContactKeys + ensureState().contacts.keys + pendingDeletedContactCleanupPublicKeys() + - linkedPublicKeys + discoveredPublicKeys ) val normalizedBatch = normalizedPublicKeyBatch(keys) discardInvalidCleanupKeys(normalizedBatch.invalidKeys) diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 210d7cf1b9..2c96b03aee 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -623,6 +623,7 @@ class ActivityService( 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 } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt index 70d6721555..aefcc22de7 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt @@ -187,6 +187,37 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat 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(syncPrivateMessages = false).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( diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 0f6d6cb5b9..cdf3afb0bd 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -753,6 +753,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { 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()) diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index ffadb68324..d967a28eae 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -377,10 +377,15 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @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 whenever(paykitSdkService.linkedPeers()).thenAnswer { if (failLookup) throw AppError("Peer lookup unavailable") - listOf(linkedPeer(CONTACT_KEY, LinkedPeerState.LINKED)) + listOf( + linkedPeer(CONTACT_KEY, LinkedPeerState.LINKED), + linkedPeer(OTHER_CONTACT_KEY, LinkedPeerState.LINKING), + linkedPeer(recoveringPublicKey, LinkedPeerState.RECOVERY_REQUIRED), + ) } assertTrue(sut.disableSharingAndPruneUnsavedContactState(emptyList()).isFailure) @@ -392,6 +397,8 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.retryPendingEndpointRemoval(emptyList()).getOrThrow() verify(paykitSdkService).clearPrivatePaymentList(CONTACT_KEY) + verify(paykitSdkService, never()).clearPrivatePaymentList(OTHER_CONTACT_KEY) + verify(paykitSdkService, never()).clearPrivatePaymentList(recoveringPublicKey) assertFalse(cacheData.value.cleanupPending) } diff --git a/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt b/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt index 6965d8e781..3050848191 100644 --- a/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt +++ b/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt @@ -13,6 +13,7 @@ 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 @@ -137,6 +138,27 @@ class ActivityServicePaykitContactsTest : BaseUnitTest() { 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"))) 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 + + From 88231813cc9e5df616410b2294628bd544ec50c4 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 2 Oct 2026 10:58:07 -0500 Subject: [PATCH 20/77] style: format paykit backfill test --- .../ActivityServicePaykitContactsTest.kt | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt b/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt index 3050848191..95f5ee04ec 100644 --- a/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt +++ b/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt @@ -145,13 +145,15 @@ class ActivityServicePaykitContactsTest : BaseUnitTest() { 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) - }) + 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) From 2f42424d81f81d6dadb19f76b178673edd37c64c Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 2 Oct 2026 11:30:24 -0500 Subject: [PATCH 21/77] fix: yield between paykit handshake advances --- .../to/bitkit/services/PaykitSdkService.kt | 6 ++-- .../bitkit/services/PaykitSdkServiceTest.kt | 34 +++++++++++++++++++ .../delete-and-readd-contact.xml | 3 ++ 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 4957f3007e..c9b2e3cde6 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -646,7 +646,7 @@ class PaykitSdkService @Inject constructor( suspend fun ensureLinkWithPeer( counterparty: String, - maxAdvanceSteps: UInt = 8u, + maxAdvanceSteps: UInt = 1u, ) = run { isSetup.await() operationLock.withLock { @@ -876,7 +876,7 @@ class PaykitSdkService @Inject constructor( counterparty = counterparty, amount = amount, afterPrivatePaymentListVersion = afterPrivatePaymentListVersion, - maxAdvanceSteps = 8u, + maxAdvanceSteps = 1u, ) } } @@ -898,7 +898,7 @@ class PaykitSdkService @Inject constructor( counterparty, paymentRequestId, afterPrivatePaymentListVersion, - 8u, + 1u, ) } } diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index 0f4256194c..88ca016f83 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -2,6 +2,7 @@ package to.bitkit.services import com.synonym.paykit.ContactRecord import com.synonym.paykit.IdentityStatus +import com.synonym.paykit.LinkedPeerHandshakeReport import com.synonym.paykit.LinkedPeerRecord import com.synonym.paykit.LinkedPeerState import com.synonym.paykit.PaykitApp @@ -71,6 +72,39 @@ class PaykitSdkServiceTest { private const val RING_PUBKY = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" } + @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 fun `initialization can be retried after shared state contention`() = runTest { val failure = PaykitException.ConcurrentUpdate("concurrent_update", "Resource locked") diff --git a/journeys/payment-requests/delete-and-readd-contact.xml b/journeys/payment-requests/delete-and-readd-contact.xml index 9c03487386..2308be1335 100644 --- a/journeys/payment-requests/delete-and-readd-contact.xml +++ b/journeys/payment-requests/delete-and-readd-contact.xml @@ -14,7 +14,10 @@ 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 + 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 From 7d3c1967ba3b6c40798ced4b355e8ed8974c39f8 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 2 Oct 2026 12:22:47 -0500 Subject: [PATCH 22/77] fix: consolidate paykit background retries --- .../bitkit/repositories/PrivatePaykitRepo.kt | 79 +++---------------- .../RefreshContactPaykitLinkUseCase.kt | 1 - .../java/to/bitkit/viewmodels/AppViewModel.kt | 29 +------ .../repositories/PrivatePaykitRepoTest.kt | 48 ++++------- .../RefreshContactPaykitLinkUseCaseTest.kt | 12 +-- .../viewmodels/AppViewModelSendFlowTest.kt | 1 - .../delete-and-readd-contact.xml | 1 + 7 files changed, 32 insertions(+), 139 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 9d656a1347..e74675f99f 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -14,11 +14,8 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableSharedFlow 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 @@ -90,7 +87,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 = @@ -107,12 +104,6 @@ class PrivatePaykitRepo @Inject constructor( 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 PrivatePublicationPreparation( val updates: List, @@ -216,39 +207,6 @@ class PrivatePaykitRepo @Inject constructor( } } - 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 { - for (retryDelay in listOf(kotlin.time.Duration.ZERO) + initialLinkBurstRetryDelays) { - delay(retryDelay) - val keys = synchronized(initialLinkBurstLock) { - if (generation != initialLinkBurstGeneration) return@launch - initialLinkBurstPublicKeys.toList() - } - refreshSavedContactEndpointsDuringInitialLinkBurst(keys, reason) - if (pendingPrivateMessageDrainKeys(keys, retryMissingPeers = true).isEmpty()) break - } - synchronized(initialLinkBurstLock) { - if (generation != initialLinkBurstGeneration) return@launch - initialLinkBurstJob = null - initialLinkBurstPublicKeys.clear() - } - } - } - } - suspend fun retryPendingEndpointRemoval( savedPublicKeys: Collection, ): Result = withContext(serializedDispatcher) { @@ -321,7 +279,6 @@ class PrivatePaykitRepo @Inject constructor( } suspend fun removePublishedEndpointsForCleanup(context: String): Result = withContext(serializedDispatcher) { - clearInitialLinkBurst() runSuspendCatching { updateContactSharingCleanupPending(true) removePublishedEndpoints().getOrThrow() @@ -336,7 +293,6 @@ class PrivatePaykitRepo @Inject constructor( suspend fun closeAndClear(): Result = withContext(serializedDispatcher) { runSuspendCatching { publicationMutex.withLock { - clearInitialLinkBurst() clearPendingMessageDrainRetries() knownSavedContactKeys.clear() state = PrivatePaykitState() @@ -370,7 +326,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() @@ -855,7 +811,10 @@ class PrivatePaykitRepo @Inject constructor( reason: String, retryKeys: Collection, ) { - val pendingKeys = pendingPrivateMessageDrainKeys(retryKeys, retryMissingPeers = true) + val newKeys = synchronized(pendingMessageDrainRetryLock) { + retryKeys.toSet() - pendingMessageDrainRetryKeys + } + val pendingKeys = pendingPrivateMessageDrainKeys(newKeys, retryMissingPeers = true) if (pendingKeys.isEmpty()) return drainPendingPrivateMessages(reason, advancingLinksFor = pendingKeys.toList()) @@ -969,8 +928,6 @@ class PrivatePaykitRepo @Inject constructor( } paykitSdkService.processPendingPrivateMessages() paykitSdkService.receivePrivateMessagesFromLinkedPeers() - paykitSdkService.processPendingPrivateMessages() - paykitSdkService.receivePrivateMessagesFromLinkedPeers() }.onFailure { Logger.warn("Failed to process pending private Paykit messages during '$reason'", it, context = TAG) } @@ -984,7 +941,10 @@ class PrivatePaykitRepo @Inject constructor( 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() @@ -1380,27 +1340,6 @@ class PrivatePaykitRepo @Inject constructor( ) } - 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 normalizedPublicKeyBatch(publicKeys: Collection): NormalizedPublicKeyBatch { val invalidKeys = mutableSetOf() val normalizedKeys = publicKeys.mapNotNull { publicKey -> diff --git a/app/src/main/java/to/bitkit/usecases/RefreshContactPaykitLinkUseCase.kt b/app/src/main/java/to/bitkit/usecases/RefreshContactPaykitLinkUseCase.kt index 830993fc54..e85ff5460e 100644 --- a/app/src/main/java/to/bitkit/usecases/RefreshContactPaykitLinkUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/RefreshContactPaykitLinkUseCase.kt @@ -23,7 +23,6 @@ class RefreshContactPaykitLinkUseCase @Inject constructor( runSuspendCatching { val savedPublicKeys = (pubkyRepo.contacts.value.map { it.publicKey } + publicKey).distinct() privatePaykitRepo.refreshSavedContactEndpoints(publicKey, savedPublicKeys).getOrThrow() - privatePaykitRepo.startInitialLinkBurst(savedPublicKeys, "contact link refresh") }.onFailure { Logger.warn( "Failed to refresh the Paykit link for '${PubkyPublicKeyFormat.redacted(publicKey)}'", diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 31cc463b83..4f4d454f26 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -389,7 +389,6 @@ class AppViewModel @Inject constructor( private var isPaymentRequestIdentityActivating = false private var isSubmittingPaymentRequest = false private var paykitPaymentRequestPollingJob: Job? = null - private var initialPaykitPaymentRequestPollingJob: Job? = null private val paymentRequestPresentationRetryAttempts = mutableMapOf() private val paymentRequestPresentationRetryJobs = mutableMapOf() private val timedSheetManager = timedSheetManagerProvider(viewModelScope).apply { @@ -566,7 +565,6 @@ class AppViewModel @Inject constructor( observePublicPaykitInvoiceExpiry() observePrivatePaykitContacts() observePaykitPaymentRequestConnectivity() - observeInitialPaykitLinkBursts() observeIncomingPaykitPaymentRequests() observePaykitOnchainPaymentResolution() observeSendEvents() @@ -760,7 +758,6 @@ class AppViewModel @Inject constructor( .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() refreshPaymentRequestTargets(force = true) @@ -788,7 +785,6 @@ class AppViewModel @Inject constructor( Logger.warn("Failed to reconcile private Paykit receive indexes for '$reason'", it, context = TAG) } privatePaykitRepo.refreshKnownSavedContactEndpoints(reason, forceRefreshLightning = forceRefreshLightning) - privatePaykitRepo.startInitialLinkBurst(contactKeys, reason) refreshIncomingPaykitPaymentRequests() refreshPaymentRequestTargets(force = true) } @@ -810,12 +806,6 @@ class AppViewModel @Inject constructor( } } - private fun observeInitialPaykitLinkBursts() { - viewModelScope.launch { - privatePaykitRepo.initialLinkBurstStarted.collect { startInitialPaykitPaymentRequestPolling() } - } - } - private fun observePaykitOnchainPaymentResolution() { viewModelScope.launch { paykitPaymentProofRepo.onchainPaymentResolutions.collect { resolutions -> @@ -894,6 +884,8 @@ class AppViewModel @Inject constructor( paykitPaymentRequestPollingJob = viewModelScope.launch { if (isOnline.value == ConnectivityState.CONNECTED) pubkyRepo.republishIdentityIfNeeded() + refreshIncomingPaykitPaymentRequests() + refreshPaymentRequestTargets() var maintenanceIntervalIndex = 0 var maintenanceDelay = PAYKIT_MAINTENANCE_INTERVALS.first() while (true) { @@ -913,7 +905,6 @@ class AppViewModel @Inject constructor( if (refreshMaintenance) refreshPaymentRequestTargets(force = true) } } - startInitialPaykitPaymentRequestPolling() } fun synchronizeSubscriptionNotifications(enabled: Boolean) { @@ -942,24 +933,9 @@ class AppViewModel @Inject constructor( fun stopPaykitPaymentRequestPolling() { 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(refreshMaintenance = false) - } - } - } - private fun observeIncomingPaykitPaymentRequests() { viewModelScope.launch { currentSheet.collect { sheet -> @@ -5963,7 +5939,6 @@ class AppViewModel @Inject constructor( 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_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 diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index d967a28eae..8ffd106ff8 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -211,29 +211,29 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `restarting initial link burst replaces saved contact keys`() = test { + fun `repeated refreshes preserve pending link retry backoff`() = 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() + advanceTimeBy(1_000) runCurrent() clearInvocations(paykitSdkService) - sut.startInitialLinkBurst(listOf(OTHER_CONTACT_KEY), "test") - runCurrent() - clearInvocations(paykitSdkService) + sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() advanceTimeBy(2_000) runCurrent() - - verifyBlocking(paykitSdkService) { ensureLinkWithPeer(OTHER_CONTACT_KEY) } - verifyBlocking(paykitSdkService, never()) { ensureLinkWithPeer(CONTACT_KEY) } + verify(paykitSdkService, never()).ensureLinkWithPeer(CONTACT_KEY) + advanceTimeBy(1_000) + runCurrent() + verify(paykitSdkService).ensureLinkWithPeer(CONTACT_KEY) sut.closeAndClear() } @Test - fun `initial link burst stops after linking and delivering pending messages`() = test { + fun `private retries stop after linking and delivering pending messages`() = test { settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) whenever(paykitSdkService.linkedPeers()).thenReturn(listOf(linkedPeer(CONTACT_KEY, LinkedPeerState.LINKING))) - sut.startInitialLinkBurst(listOf(CONTACT_KEY), "test") + sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() runCurrent() whenever(paykitSdkService.linkedPeers()).thenReturn(listOf(linkedPeer(CONTACT_KEY, LinkedPeerState.LINKED))) @@ -255,11 +255,13 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @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) @@ -270,22 +272,6 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { 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(paykitSdkService) - - sut.startInitialLinkBurst(emptyList(), "test") - advanceTimeBy(30_000) - runCurrent() - - verifyBlocking(paykitSdkService, never()) { ensureLinkWithPeer(any(), any()) } - sut.closeAndClear() - } - @Test fun `prepareSavedContacts defers reservations while link preparation is unavailable`() = test { settingsData.value = SettingsData( @@ -680,8 +666,8 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { verifyBlocking(paykitSdkService) { clearPrivatePaymentList(OTHER_CONTACT_KEY) } verifyBlocking(paykitSdkService, atLeast(1)) { linkedPeers() } verifyBlocking(paykitSdkService, times(1)) { pendingOutboundPrivateCounterparties() } - verifyBlocking(paykitSdkService, times(2)) { processPendingPrivateMessages() } - verifyBlocking(paykitSdkService, times(2)) { receivePrivateMessagesFromLinkedPeers() } + verifyBlocking(paykitSdkService) { processPendingPrivateMessages() } + verifyBlocking(paykitSdkService) { receivePrivateMessagesFromLinkedPeers() } assertTrue(cacheData.value.contacts.isEmpty()) } diff --git a/app/src/test/java/to/bitkit/usecases/RefreshContactPaykitLinkUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/RefreshContactPaykitLinkUseCaseTest.kt index 86a1abf911..d0e0f94233 100644 --- a/app/src/test/java/to/bitkit/usecases/RefreshContactPaykitLinkUseCaseTest.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 @@ -44,21 +42,18 @@ class RefreshContactPaykitLinkUseCaseTest : BaseUnitTest() { } @Test - fun `refreshes contact endpoints before starting the link burst`() = test { + 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(privatePaykitRepo).apply { - verify(privatePaykitRepo).refreshSavedContactEndpoints(contactKeys.last(), contactKeys) - verify(privatePaykitRepo).startInitialLinkBurst(contactKeys, "contact link refresh") - } + verify(privatePaykitRepo).refreshSavedContactEndpoints(contactKeys.last(), contactKeys) } @Test - fun `stops when endpoint refresh fails`() = test { + fun `returns endpoint refresh failure`() = test { val error = IllegalStateException("Endpoint refresh failed") whenever(privatePaykitRepo.refreshSavedContactEndpoints(contactKeys.last(), contactKeys)) .thenReturn(Result.failure(error)) @@ -66,6 +61,5 @@ class RefreshContactPaykitLinkUseCaseTest : BaseUnitTest() { val result = sut(contactKeys.last()) assertEquals(error, result.exceptionOrNull()) - verify(privatePaykitRepo, never()).startInitialLinkBurst(contactKeys, "contact link refresh") } } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index affd7cc7b5..4a14dd2131 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -429,7 +429,6 @@ class AppViewModelSendFlowTest : BaseUnitTest() { 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()) } .thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.pruneUnsavedContactState(any>()) } diff --git a/journeys/payment-requests/delete-and-readd-contact.xml b/journeys/payment-requests/delete-and-readd-contact.xml index 2308be1335..debc8ab0cc 100644 --- a/journeys/payment-requests/delete-and-readd-contact.xml +++ b/journeys/payment-requests/delete-and-readd-contact.xml @@ -17,6 +17,7 @@ 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" From 09c6426b966845fcc7a2f137845451e822e6b9de Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 2 Oct 2026 14:04:01 -0500 Subject: [PATCH 23/77] fix: receive Paykit messages on each inbox poll --- .../repositories/PaykitPaymentRequestRepo.kt | 10 +++++----- .../java/to/bitkit/viewmodels/AppViewModel.kt | 2 +- ...PaykitPaymentRequestRepoSubscriptionTest.kt | 2 +- .../PaykitPaymentRequestRepoTest.kt | 8 ++++---- .../viewmodels/AppViewModelSendFlowTest.kt | 18 +++++++++--------- journeys/payment-requests/README.md | 9 ++++----- 6 files changed, 24 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 048f3fc64c..dea2c397a1 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -444,7 +444,7 @@ class PaykitPaymentRequestRepo @Inject constructor( } } - suspend fun refresh(syncPrivateMessages: Boolean = true): Result { + suspend fun refresh(processOutgoingMessages: Boolean = true): Result { val generation = stateGeneration.get() val expectedIdentity = activeIdentity return withContext(ioDispatcher) { @@ -454,7 +454,7 @@ class PaykitPaymentRequestRepo @Inject constructor( clearStateLocked() return@withLock } - runSuspendCatching { synchronizeLocked(generation, expectedIdentity, syncPrivateMessages) } + runSuspendCatching { synchronizeLocked(generation, expectedIdentity, processOutgoingMessages) } .onFailure { discardExpiredRequestsLocked() } .getOrThrow() } @@ -980,12 +980,12 @@ class PaykitPaymentRequestRepo @Inject constructor( private suspend fun synchronizeLocked( generation: Long, expectedIdentity: String?, - syncPrivateMessages: Boolean = true, + processOutgoingMessages: Boolean = true, ) { - if (syncPrivateMessages) { + if (processOutgoingMessages) { processPendingMessages() - paykitSdkService.receivePrivateMessagesFromLinkedPeers().also(::logIntakeFailures) } + paykitSdkService.receivePrivateMessagesFromLinkedPeers().also(::logIntakeFailures) val now = clock.now() val subscriptionNow = subscriptionClock.now() receivedContacts = null diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 4f4d454f26..582752b14b 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -865,7 +865,7 @@ 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(syncPrivateMessages = refreshMaintenance).onSuccess { + paykitPaymentRequestRepo.refresh(processOutgoingMessages = refreshMaintenance).onSuccess { activityRepo.backfillPaykitContacts() presentNextIncomingPaykitPaymentRequest() } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt index aefcc22de7..c8efc77563 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt @@ -210,7 +210,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat checking.await() whenever(paykitSdkService.allPaymentRequests(anyOrNull())) .thenReturn(listOf(record.copy(state = PaymentRequestLifecycleState.CANCELED))) - sut.refresh(syncPrivateMessages = false).getOrThrow() + sut.refresh(processOutgoingMessages = false).getOrThrow() checked.complete(Unit) assertTrue(authorization.await().isFailure) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index cdf3afb0bd..744b16a62d 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -137,20 +137,20 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `shared state refresh skips private messages until a full refresh`() = test { + fun `inbox refresh receives messages without draining outbound work`() = test { val record = paymentRequestRecord() whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) - sut.refresh(syncPrivateMessages = false).getOrThrow() + sut.refresh(processOutgoingMessages = false).getOrThrow() assertEquals(record.paymentRequestId, sut.pendingRequests.value.single().paymentRequestId) verify(paykitSdkService, never()).processPendingPrivateMessages() - verify(paykitSdkService, never()).receivePrivateMessagesFromLinkedPeers() + verify(paykitSdkService).receivePrivateMessagesFromLinkedPeers() sut.refresh().getOrThrow() verify(paykitSdkService).processPendingPrivateMessages() - verify(paykitSdkService).receivePrivateMessagesFromLinkedPeers() + verify(paykitSdkService, times(2)).receivePrivateMessagesFromLinkedPeers() } @Test diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 4a14dd2131..292c437886 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -721,19 +721,19 @@ class AppViewModelSendFlowTest : BaseUnitTest() { try { advanceTimeBy(30.seconds.inWholeMilliseconds) runCurrent() - verify(paykitPaymentRequestRepo, atLeast(2)).refresh(syncPrivateMessages = true) + verify(paykitPaymentRequestRepo, atLeast(2)).refresh(processOutgoingMessages = true) for (interval in listOf(60.seconds, 120.seconds, 120.seconds)) { clearInvocations(pubkyRepo, paykitPaymentRequestRepo) advanceTimeBy(interval.inWholeMilliseconds - 1) runCurrent() verify(pubkyRepo, never()).republishIdentityIfNeeded() - verify(paykitPaymentRequestRepo, never()).refresh(syncPrivateMessages = true) - verify(paykitPaymentRequestRepo, atLeast(1)).refresh(syncPrivateMessages = false) + verify(paykitPaymentRequestRepo, never()).refresh(processOutgoingMessages = true) + verify(paykitPaymentRequestRepo, atLeast(1)).refresh(processOutgoingMessages = false) advanceTimeBy(1) runCurrent() verify(pubkyRepo).republishIdentityIfNeeded() - verify(paykitPaymentRequestRepo).refresh(syncPrivateMessages = true) + verify(paykitPaymentRequestRepo).refresh(processOutgoingMessages = true) } } finally { sut.stopPaykitPaymentRequestPolling() @@ -827,13 +827,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { try { runCurrent() - verify(paykitPaymentRequestRepo).refresh(syncPrivateMessages = true) + verify(paykitPaymentRequestRepo).refresh(processOutgoingMessages = true) clearInvocations(paykitPaymentRequestRepo, paykitPaymentProofRepo) advanceTimeBy(29.seconds.inWholeMilliseconds) runCurrent() - verify(paykitPaymentRequestRepo, atLeast(1)).refresh(syncPrivateMessages = false) - verify(paykitPaymentRequestRepo, never()).refresh(syncPrivateMessages = true) + verify(paykitPaymentRequestRepo, atLeast(1)).refresh(processOutgoingMessages = false) + verify(paykitPaymentRequestRepo, never()).refresh(processOutgoingMessages = true) verify(paykitPaymentRequestRepo, never()).refreshEligibleTargets(any(), any()) verify(paykitPaymentProofRepo, never()).reconcile() @@ -6847,7 +6847,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.retryIncomingPaymentRequest(request.id) advanceUntilIdle() - verify(paykitPaymentRequestRepo).refresh(syncPrivateMessages = true) + verify(paykitPaymentRequestRepo).refresh(processOutgoingMessages = true) verify(privatePaykitRepo, atLeast(1)).beginPaymentRequest(request) assertEquals(request.id, sut.sendUiState.value.incomingPaymentRequestId) assertTrue(sut.currentSheet.value is Sheet.Send) @@ -7275,7 +7275,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { confirmCurrentPayment() verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue, "bitkit") - verify(paykitPaymentRequestRepo).refresh(syncPrivateMessages = true) + verify(paykitPaymentRequestRepo).refresh(processOutgoingMessages = true) } @Test diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index eda485232e..2e0556bfd1 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -26,11 +26,10 @@ Rejected fixture shapes stay in unit tests because Bitkit intentionally does not ## Foreground synchronization -Bitkit refreshes shared request state every 10 seconds. Private messages are synchronized on -startup and explicit refreshes, and on maintenance rounds that back off from 30 to 60 to 120 seconds. -Startup link retries stop when no link or outbound work remains; subsequent startup request polls -read shared state without repeating private-message synchronization or target discovery. -A new peer message can therefore take up to two minutes plus synchronization time to appear during steady polling. +Bitkit receives private messages and refreshes shared request state every 10 seconds. +Outbound retries, endpoint publication, and target discovery run on startup, explicit refreshes, +and maintenance rounds that back off from 30 to 60 to 120 seconds. +While foregrounded and online, new peer messages are checked on the next inbox poll, plus synchronization time. ## Accepting install From 10a3a647acf546c2bf01581f5c060d83bb0e05c4 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 2 Oct 2026 14:20:13 -0500 Subject: [PATCH 24/77] fix: report private endpoint withdrawal failures --- .../bitkit/repositories/PrivatePaykitRepo.kt | 41 ++++++++++++------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index e74675f99f..e72811d76d 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -859,20 +859,7 @@ class PrivatePaykitRepo @Inject constructor( report: PrivatePaymentListDeliveryReport, reason: String, ): Throwable? { - 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, - ) - } + logPrivatePaymentListDeliveryFailures(report, reason) var didUpdateCache = false for (change in report.queued) { @@ -896,6 +883,23 @@ 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 { @@ -1244,6 +1248,10 @@ class PrivatePaykitRepo @Inject constructor( ) val 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 } @@ -1284,6 +1292,7 @@ class PrivatePaykitRepo @Inject constructor( runSuspendCatching { val report = paykitSdkService.clearPrivatePaymentList(publicKey) ?: return@runSuspendCatching false + logPrivatePaymentListDeliveryFailures(report, "cleanup") if (report.failedToQueue.isNotEmpty() || report.failedToDeliver.isNotEmpty()) { throw PrivatePaykitError.PrivateUnavailable } @@ -1291,6 +1300,10 @@ class PrivatePaykitRepo @Inject constructor( }.onSuccess { if (it) clearedRetryKeys += publicKey }.onFailure { + Logger.warn( + "Failed to clear private Paykit endpoints for '${redacted(publicKey)}': ${it::class.simpleName}", + context = TAG, + ) failedPublicKeys += publicKey firstError = firstError ?: it } From eaf6a092b66ba74d69fd2466381a850da42af48d Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 2 Oct 2026 15:32:23 -0500 Subject: [PATCH 25/77] fix: coalesce paykit preparation and reduce inbox polling --- .../repositories/PaykitPaymentRequestRepo.kt | 10 +- .../bitkit/repositories/PrivatePaykitRepo.kt | 198 ++++++++++++++---- .../java/to/bitkit/viewmodels/AppViewModel.kt | 20 +- ...aykitPaymentRequestRepoSubscriptionTest.kt | 2 +- .../PaykitPaymentRequestRepoTest.kt | 8 +- .../repositories/PrivatePaykitRepoTest.kt | 89 +++++++- .../viewmodels/AppViewModelSendFlowTest.kt | 60 ++++-- journeys/contacts/README.md | 5 + journeys/contacts/import-all-contacts.xml | 1 + journeys/payment-requests/README.md | 9 +- 10 files changed, 322 insertions(+), 80 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index dea2c397a1..048f3fc64c 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -444,7 +444,7 @@ class PaykitPaymentRequestRepo @Inject constructor( } } - suspend fun refresh(processOutgoingMessages: Boolean = true): Result { + suspend fun refresh(syncPrivateMessages: Boolean = true): Result { val generation = stateGeneration.get() val expectedIdentity = activeIdentity return withContext(ioDispatcher) { @@ -454,7 +454,7 @@ class PaykitPaymentRequestRepo @Inject constructor( clearStateLocked() return@withLock } - runSuspendCatching { synchronizeLocked(generation, expectedIdentity, processOutgoingMessages) } + runSuspendCatching { synchronizeLocked(generation, expectedIdentity, syncPrivateMessages) } .onFailure { discardExpiredRequestsLocked() } .getOrThrow() } @@ -980,12 +980,12 @@ class PaykitPaymentRequestRepo @Inject constructor( private suspend fun synchronizeLocked( generation: Long, expectedIdentity: String?, - processOutgoingMessages: Boolean = true, + syncPrivateMessages: Boolean = true, ) { - if (processOutgoingMessages) { + if (syncPrivateMessages) { processPendingMessages() + paykitSdkService.receivePrivateMessagesFromLinkedPeers().also(::logIntakeFailures) } - paykitSdkService.receivePrivateMessagesFromLinkedPeers().also(::logIntakeFailures) val now = clock.now() val subscriptionNow = subscriptionClock.now() receivedContacts = null diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index e72811d76d..870779fb63 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -54,6 +54,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 @@ -76,6 +77,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. @@ -99,15 +101,25 @@ 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 var preparationJob: Job? = null + private var preparationGeneration = 0 + private var pendingForceRefreshLightning = false + private val unavailableLinkRetryAt = mutableMapOf() private var state: PrivatePaykitState? = null private val pendingMessageDrainRetryLock = Any() private val pendingMessageDrainRetryKeys = mutableSetOf() private var pendingMessageDrainRetryJob: Job? = null private var pendingMessageDrainRetryGeneration = 0 + private data class PrivateLinkPreparation( + val publicKeys: List, + val linkRetryKeys: List, + ) + private data class PrivatePublicationPreparation( val updates: List, - val linkRetryKeys: List, val firstError: Throwable?, ) @@ -163,7 +175,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) } @@ -171,6 +183,56 @@ class PrivatePaykitRepo @Inject constructor( } } + suspend fun scheduleSavedContactPreparation(publicKeys: Collection): Result = + withContext(serializedDispatcher) { + runSuspendCatching { + val keys = rememberSavedContacts(publicKeys, replacing = true) + scheduleContactPreparation(keys) + } + } + + private fun scheduleContactPreparation(publicKeys: Collection, forceRefreshLightning: Boolean = false) { + 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 + clearPendingMessageDrainRetries() + } + suspend fun refreshSavedContactEndpoints( publicKey: String, savedPublicKeys: Collection, @@ -193,15 +255,7 @@ class PrivatePaykitRepo @Inject constructor( forceRefreshLightning: Boolean = false, ): Result = withContext(serializedDispatcher) { runSuspendCatching { - if (!canPublishPrivateEndpoints()) { - prepareRelevantPrivateLinksIfAvailable(knownSavedContactKeys, 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) } @@ -244,6 +298,8 @@ class PrivatePaykitRepo @Inject constructor( runSuspendCatching { val normalizedKey = normalizedPublicKey(publicKey) ?: return@runSuspendCatching knownSavedContactKeys.remove(normalizedKey) + pendingPreparationKeys.remove(normalizedKey) + unavailableLinkRetryAt.remove(normalizedKey) removePublishedEndpoints(normalizedKey).onFailure { updateDeletedContactCleanupPending(normalizedKey, true) Logger.warn( @@ -292,9 +348,11 @@ class PrivatePaykitRepo @Inject constructor( suspend fun closeAndClear(): Result = withContext(serializedDispatcher) { runSuspendCatching { + invalidateContactPreparation() publicationMutex.withLock { clearPendingMessageDrainRetries() knownSavedContactKeys.clear() + unavailableLinkRetryAt.clear() state = PrivatePaykitState() cacheStore.reset() addressReservationRepo.clearContactAssignments(excludingPublicKeys = emptySet()) @@ -487,9 +545,10 @@ class PrivatePaykitRepo @Inject constructor( runSuspendCatching { val decoded = backup?.let { json.decodeFromString(it) } decoded?.let { paykitSdkService.retainRecoveryBackup(it.sdkState) } - clearPendingMessageDrainRetries() + invalidateContactPreparation() state = PrivatePaykitState() knownSavedContactKeys.clear() + unavailableLinkRetryAt.clear() if (backup == null) { paykitSdkService.clearState() } else { @@ -735,34 +794,43 @@ class PrivatePaykitRepo @Inject constructor( 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 (!isCurrentPublication(generation, identity, requireImmediatePublication)) return@withLock if (!canPublishPrivateEndpoints()) { if (requireImmediatePublication) throw PrivatePaykitError.PrivateUnavailable return@withLock } - pubkyService.currentPublicKey() ?: throw PublicPaykitError.SessionNotActive - val preparation = preparePrivatePaymentListReservations( - publicKeys = keys, - reason = reason, - forceRefreshLightning = forceRefreshLightning, + val publication = preparePrivatePaymentListReservations( + preparation.publicKeys, + reason, + forceRefreshLightning, + generation, ) - - if (preparation.updates.isEmpty()) { - drainAndSchedulePrivateLinkRetries(reason, preparation.linkRetryKeys) - if (requireImmediatePublication) preparation.firstError?.let { throw it } + if (!isCurrentPublication(generation, identity, requireImmediatePublication)) return@withLock + if (publication.updates.isEmpty()) { + schedulePendingPrivateMessageDrainRetries(reason, preparation.linkRetryKeys) + if (requireImmediatePublication) publication.firstError?.let { throw it } return@withLock } val report = paykitSdkService.syncPrivatePaymentListsWithReservations( - updates = preparation.updates, + updates = publication.updates, clearUnlistedLinkedPeers = false, ) val deliveryError = applyPrivatePaymentListDeliveryReport(report, reason) - val firstError = preparation.firstError ?: deliveryError + val firstError = publication.firstError ?: deliveryError val retryKeys = (preparation.linkRetryKeys + privatePaymentListDeliveryRetryKeys(report)).distinct() - drainAndSchedulePrivateLinkRetries(reason, retryKeys) + schedulePendingPrivateMessageDrainRetries(reason, retryKeys) if (firstError != null) { if (requireImmediatePublication) throw firstError @@ -776,34 +844,82 @@ class PrivatePaykitRepo @Inject constructor( } } + private suspend fun isCurrentPublication( + generation: Int, + identity: String, + requireImmediatePublication: Boolean, + ): Boolean { + val isCurrent = generation == preparationGeneration && pubkyService.currentPublicKey() == identity + if (!isCurrent && requireImmediatePublication) throw PrivatePaykitError.PrivateUnavailable + return isCurrent + } + private suspend fun preparePrivatePaymentListReservations( publicKeys: Collection, reason: String, forceRefreshLightning: Boolean, + generation: Int, ): PrivatePublicationPreparation { var firstError: Throwable? = null val updates = mutableListOf() + for (publicKey in publicKeys) { + 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) + } + + 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()) { - val link = runSuspendCatching { paykitSdkService.ensureLinkWithPeer(publicKey) } - .onFailure { Logger.warn("Failed to prepare private Paykit link during '$reason'", it, context = TAG) } - if (link.isFailure) { - if (link.exceptionOrNull() !is PaykitException.NotFound) linkRetryKeys += publicKey - continue - } - if (link.getOrThrow().state != LinkedPeerState.LINKED) linkRetryKeys += publicKey - runSuspendCatching { privatePaymentListUpdate(publicKey, forceRefreshLightning) } - .onSuccess { updates += it } - .onFailure { - firstError = firstError ?: it - logPrivatePublicationPreparationFailure(publicKey, reason, it) + if (generation != preparationGeneration) break + if (canPreparePrivateLink(publicKey, peerStates[publicKey], retryUnavailableLinks)) { + runSuspendCatching { + if (peerStates[publicKey] == LinkedPeerState.LINKED) { + LinkedPeerState.LINKED + } else { + paykitSdkService.ensureLinkWithPeer(publicKey).state + } + }.onSuccess { + unavailableLinkRetryAt.remove(publicKey) + if (it != LinkedPeerState.LINKED) linkRetryKeys += publicKey + preparedKeys += publicKey + }.onFailure { + Logger.warn("Failed to prepare private Paykit link during '$reason'", it, context = TAG) + if (it is PaykitException.NotFound || it is PaykitException.Transport) { + unavailableLinkRetryAt[publicKey] = clock.now() + unavailableLinkRetryDelay + } + if (it !is PaykitException.NotFound) linkRetryKeys += publicKey } + } } - return PrivatePublicationPreparation(updates, linkRetryKeys, firstError) + return PrivateLinkPreparation(preparedKeys, linkRetryKeys) } + private suspend fun canPreparePrivateLink( + publicKey: String, + state: LinkedPeerState?, + retryUnavailableLinks: Boolean, + ): Boolean = publicKey in knownSavedContactKeys && + !isContactSharingCleanupPending() && state != LinkedPeerState.BLOCKED && + (retryUnavailableLinks || unavailableLinkRetryAt[publicKey]?.let { it > clock.now() } != true) + private suspend fun prepareRelevantPrivateLinksIfAvailable(publicKeys: Collection, reason: String) { - if (!hasPrivatePaymentAccessForCurrentProfile()) return + if (isContactSharingCleanupPending() || !hasPrivatePaymentAccessForCurrentProfile()) return drainAndSchedulePrivateLinkRetries(reason, publicKeys.distinct()) } @@ -917,7 +1033,10 @@ class PrivatePaykitRepo @Inject constructor( advancingLinksFor: List = emptyList(), ) { runSuspendCatching { + val generation = preparationGeneration advancingLinksFor.distinct().forEach { retryKey -> + if (generation != preparationGeneration || retryKey !in knownSavedContactKeys) return@forEach + if (unavailableLinkRetryAt[retryKey]?.let { it > clock.now() } == true) return@forEach runSuspendCatching { paykitSdkService.ensureLinkWithPeer( counterparty = retryKey, @@ -1460,6 +1579,7 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun canPublishPrivateEndpoints(): Boolean { val settings = settingsStore.data.first() return settings.sharesPrivatePaykitEndpoints && + !isContactSharingCleanupPending() && hasPrivatePaymentAccessForCurrentProfile() && App.currentActivity?.value != null && walletRepo.walletExists() && @@ -1478,6 +1598,7 @@ class PrivatePaykitRepo @Inject constructor( ensureState().contacts.values.any { it.hasPublishedPrivatePaymentList } private suspend fun updateContactSharingCleanupPending(isPending: Boolean) { + if (isPending) invalidateContactPreparation() cacheStore.update { it.copy(cleanupPending = isPending) } } @@ -1589,6 +1710,7 @@ class PrivatePaykitRepo @Inject constructor( 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/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 582752b14b..368f294a41 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -216,6 +216,7 @@ import java.math.BigDecimal import java.util.concurrent.atomic.AtomicLong import javax.inject.Inject import kotlin.coroutines.cancellation.CancellationException +import kotlin.time.Clock import kotlin.time.Duration import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.milliseconds @@ -268,6 +269,7 @@ class AppViewModel @Inject constructor( private val highBalanceSheet: HighBalanceTimedSheet, private val formatMoneyValue: FormatMoneyValue, private val widgetsRepo: WidgetsRepo, + private val clock: Clock, ) : ViewModel() { val healthState = healthRepo.healthState @@ -754,7 +756,7 @@ class AppViewModel @Inject constructor( } } - 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) } @@ -865,7 +867,7 @@ 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(processOutgoingMessages = refreshMaintenance).onSuccess { + paykitPaymentRequestRepo.refresh(syncPrivateMessages = refreshMaintenance).onSuccess { activityRepo.backfillPaykitContacts() presentNextIncomingPaykitPaymentRequest() } @@ -887,19 +889,19 @@ class AppViewModel @Inject constructor( refreshIncomingPaykitPaymentRequests() refreshPaymentRequestTargets() var maintenanceIntervalIndex = 0 - var maintenanceDelay = PAYKIT_MAINTENANCE_INTERVALS.first() + var nextMaintenance = clock.now() + 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 now = clock.now() + val refreshMaintenance = now >= nextMaintenance if (refreshMaintenance) { + maintenanceIntervalIndex = + (maintenanceIntervalIndex + 1).coerceAtMost(PAYKIT_MAINTENANCE_INTERVALS.lastIndex) + nextMaintenance = now + PAYKIT_MAINTENANCE_INTERVALS[maintenanceIntervalIndex] if (isPaykitEnabled.value && walletRepo.walletExists()) pubkyRepo.restoreSessionIfNeeded() pubkyRepo.republishIdentityIfNeeded() privatePaykitRepo.refreshKnownSavedContactEndpoints("payment request polling") - maintenanceIntervalIndex = - (maintenanceIntervalIndex + 1).coerceAtMost(PAYKIT_MAINTENANCE_INTERVALS.lastIndex) - maintenanceDelay = PAYKIT_MAINTENANCE_INTERVALS[maintenanceIntervalIndex] } refreshIncomingPaykitPaymentRequests(refreshMaintenance) if (refreshMaintenance) refreshPaymentRequestTargets(force = true) @@ -5938,7 +5940,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 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 diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt index c8efc77563..aefcc22de7 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt @@ -210,7 +210,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat checking.await() whenever(paykitSdkService.allPaymentRequests(anyOrNull())) .thenReturn(listOf(record.copy(state = PaymentRequestLifecycleState.CANCELED))) - sut.refresh(processOutgoingMessages = false).getOrThrow() + sut.refresh(syncPrivateMessages = false).getOrThrow() checked.complete(Unit) assertTrue(authorization.await().isFailure) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 744b16a62d..5909a67384 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -137,20 +137,20 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `inbox refresh receives messages without draining outbound work`() = test { + fun `inbox refresh reads shared state without private message sync`() = test { val record = paymentRequestRecord() whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) - sut.refresh(processOutgoingMessages = false).getOrThrow() + sut.refresh(syncPrivateMessages = false).getOrThrow() assertEquals(record.paymentRequestId, sut.pendingRequests.value.single().paymentRequestId) verify(paykitSdkService, never()).processPendingPrivateMessages() - verify(paykitSdkService).receivePrivateMessagesFromLinkedPeers() + verify(paykitSdkService, never()).receivePrivateMessagesFromLinkedPeers() sut.refresh().getOrThrow() verify(paykitSdkService).processPendingPrivateMessages() - verify(paykitSdkService, times(2)).receivePrivateMessagesFromLinkedPeers() + verify(paykitSdkService).receivePrivateMessagesFromLinkedPeers() } @Test diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 8ffd106ff8..3854579075 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -185,7 +185,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { true, cacheData.value.contacts.getValue(CONTACT_KEY).hasPublishedPrivatePaymentList, ) - verify(paykitSdkService).ensureLinkWithPeer(CONTACT_KEY) + verify(paykitSdkService, never()).ensureLinkWithPeer(CONTACT_KEY) verify(paykitSdkService, never()).processPendingPrivateMessages() verify(paykitSdkService, never()).receivePrivateMessagesFromLinkedPeers() } @@ -824,7 +824,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @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, @@ -837,9 +837,92 @@ 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() } + resumeLink.complete(Unit) + runCurrent() + + verify(paykitSdkService).ensureLinkWithPeer(CONTACT_KEY) + verify(paykitSdkService).syncPrivatePaymentListsWithReservations(any(), eq(false)) + 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 diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 292c437886..0856f00018 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -429,7 +429,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { paykitPaymentProofRepo.markOnchainPaymentStarted(any(), any(), any()) }.thenReturn(Result.success(Unit)) whenever { activityRepo.setContact(any(), any(), any(), any()) }.thenReturn(Result.success(Unit)) - whenever { privatePaykitRepo.prepareSavedContacts(any>(), any()) } + whenever { privatePaykitRepo.scheduleSavedContactPreparation(any>()) } .thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.pruneUnsavedContactState(any>()) } .thenReturn(Result.success(Unit)) @@ -528,6 +528,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { formatMoneyValue = formatMoneyValue, widgetsRepo = widgetsRepo, pubkyRepo = pubkyRepo, + clock = object : Clock { + override fun now() = kotlin.time.Instant.fromEpochMilliseconds(testDispatcher.scheduler.currentTime) + }, ) private suspend fun emitNodeEvent( @@ -721,25 +724,50 @@ class AppViewModelSendFlowTest : BaseUnitTest() { try { advanceTimeBy(30.seconds.inWholeMilliseconds) runCurrent() - verify(paykitPaymentRequestRepo, atLeast(2)).refresh(processOutgoingMessages = true) - for (interval in listOf(60.seconds, 120.seconds, 120.seconds)) { + verify(paykitPaymentRequestRepo, atLeast(2)).refresh(syncPrivateMessages = true) + 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(processOutgoingMessages = true) - verify(paykitPaymentRequestRepo, atLeast(1)).refresh(processOutgoingMessages = false) + verify(paykitPaymentRequestRepo, never()).refresh(syncPrivateMessages = true) + verify(paykitPaymentRequestRepo, atLeast(1)).refresh(syncPrivateMessages = false) advanceTimeBy(1) runCurrent() verify(pubkyRepo).republishIdentityIfNeeded() - verify(paykitPaymentRequestRepo).refresh(processOutgoingMessages = true) + verify(paykitPaymentRequestRepo).refresh(syncPrivateMessages = true) } } finally { sut.stopPaykitPaymentRequestPolling() } } + @Test + fun `slow inbox refresh counts toward maintenance deadline`() = test { + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + whenever(paykitPaymentRequestRepo.refresh(true)).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(false)).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 `foreground maintenance retries a missing session until restored`() = test { enablePaykitUi() @@ -827,13 +855,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { try { runCurrent() - verify(paykitPaymentRequestRepo).refresh(processOutgoingMessages = true) + verify(paykitPaymentRequestRepo).refresh(syncPrivateMessages = true) clearInvocations(paykitPaymentRequestRepo, paykitPaymentProofRepo) advanceTimeBy(29.seconds.inWholeMilliseconds) runCurrent() - verify(paykitPaymentRequestRepo, atLeast(1)).refresh(processOutgoingMessages = false) - verify(paykitPaymentRequestRepo, never()).refresh(processOutgoingMessages = true) + verify(paykitPaymentRequestRepo, atLeast(1)).refresh(syncPrivateMessages = false) + verify(paykitPaymentRequestRepo, never()).refresh(syncPrivateMessages = true) verify(paykitPaymentRequestRepo, never()).refreshEligibleTargets(any(), any()) verify(paykitPaymentProofRepo, never()).reconcile() @@ -874,8 +902,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() } @@ -6847,7 +6875,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.retryIncomingPaymentRequest(request.id) advanceUntilIdle() - verify(paykitPaymentRequestRepo).refresh(processOutgoingMessages = true) + verify(paykitPaymentRequestRepo).refresh(syncPrivateMessages = true) verify(privatePaykitRepo, atLeast(1)).beginPaymentRequest(request) assertEquals(request.id, sut.sendUiState.value.incomingPaymentRequestId) assertTrue(sut.currentSheet.value is Sheet.Send) @@ -7275,7 +7303,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { confirmCurrentPayment() verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue, "bitkit") - verify(paykitPaymentRequestRepo).refresh(processOutgoingMessages = true) + verify(paykitPaymentRequestRepo).refresh(syncPrivateMessages = true) } @Test @@ -8414,13 +8442,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() verify(publicPaykitRepo).syncPaykitApp() - verify(privatePaykitRepo, never()).prepareSavedContacts(any>(), any()) + verify(privatePaykitRepo, never()).scheduleSavedContactPreparation(any>()) verify(privatePaykitRepo, never()).pruneUnsavedContactState(any>()) pubkyContactsLoadVersion.value = 1L advanceUntilIdle() - verify(privatePaykitRepo).prepareSavedContacts(any>(), any()) + verify(privatePaykitRepo).scheduleSavedContactPreparation(any>()) verify(privatePaykitRepo).pruneUnsavedContactState(any>()) } @@ -8448,7 +8476,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() verify(privatePaykitRepo).removeSavedContact(contact.publicKey) - verify(privatePaykitRepo).prepareSavedContacts(emptySet(), false) + verify(privatePaykitRepo).scheduleSavedContactPreparation(emptySet()) verify(privatePaykitRepo).pruneUnsavedContactState(emptySet()) } diff --git a/journeys/contacts/README.md b/journeys/contacts/README.md index b19cd2bbff..5a86533b0d 100644 --- a/journeys/contacts/README.md +++ b/journeys/contacts/README.md @@ -12,6 +12,11 @@ public endpoint or app-registry update failing. 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. diff --git a/journeys/contacts/import-all-contacts.xml b/journeys/contacts/import-all-contacts.xml index aedbcadb9d..2119a7b8d5 100644 --- a/journeys/contacts/import-all-contacts.xml +++ b/journeys/contacts/import-all-contacts.xml @@ -11,6 +11,7 @@ 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/payment-requests/README.md b/journeys/payment-requests/README.md index 2e0556bfd1..50dd31091e 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -26,10 +26,11 @@ Rejected fixture shapes stay in unit tests because Bitkit intentionally does not ## Foreground synchronization -Bitkit receives private messages and refreshes shared request state every 10 seconds. -Outbound retries, endpoint publication, and target discovery run on startup, explicit refreshes, -and maintenance rounds that back off from 30 to 60 to 120 seconds. -While foregrounded and online, new peer messages are checked on the next inbox poll, plus synchronization time. +Bitkit checks shared request state every 10 seconds while foregrounded and online. +Private-message intake, outbound retries, endpoint publication, and target discovery 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. New peer messages may wait for maintenance plus synchronization time. ## Accepting install From 0fdff066a7b83245900463a4bf150118338106e1 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 2 Oct 2026 16:59:11 -0500 Subject: [PATCH 26/77] fix: coordinate private contact preparation and request discovery --- app/src/main/java/to/bitkit/di/EnvModule.kt | 4 + .../bitkit/repositories/PrivatePaykitRepo.kt | 20 +++- .../java/to/bitkit/viewmodels/AppViewModel.kt | 15 +-- .../repositories/PrivatePaykitRepoTest.kt | 98 ++++++++++++++++++- .../viewmodels/AppViewModelSendFlowTest.kt | 42 +++++++- 5 files changed, 166 insertions(+), 13 deletions(-) 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/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 870779fb63..242730de80 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -191,6 +191,10 @@ class PrivatePaykitRepo @Inject constructor( } } + suspend fun awaitContactPreparation(): Unit = withContext(serializedDispatcher) { + preparationJob?.join() + } + private fun scheduleContactPreparation(publicKeys: Collection, forceRefreshLightning: Boolean = false) { pendingPreparationKeys.addAll(publicKeys.filter { forceRefreshLightning || it !in activePreparationKeys }) pendingForceRefreshLightning = pendingForceRefreshLightning || forceRefreshLightning @@ -900,8 +904,14 @@ class PrivatePaykitRepo @Inject constructor( preparedKeys += publicKey }.onFailure { Logger.warn("Failed to prepare private Paykit link during '$reason'", it, context = TAG) - if (it is PaykitException.NotFound || it is PaykitException.Transport) { + 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 } @@ -1049,8 +1059,12 @@ class PrivatePaykitRepo @Inject constructor( ) } } - paykitSdkService.processPendingPrivateMessages() - paykitSdkService.receivePrivateMessagesFromLinkedPeers() + if (paykitSdkService.pendingOutboundPrivateCounterparties().isNotEmpty()) { + paykitSdkService.processPendingPrivateMessages() + } + if (paykitSdkService.linkedPeers().any { it.state == LinkedPeerState.LINKED }) { + paykitSdkService.receivePrivateMessagesFromLinkedPeers() + } }.onFailure { Logger.warn("Failed to process pending private Paykit messages during '$reason'", it, context = TAG) } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 368f294a41..ca18aa9808 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -216,13 +216,13 @@ import java.math.BigDecimal import java.util.concurrent.atomic.AtomicLong import javax.inject.Inject import kotlin.coroutines.cancellation.CancellationException -import kotlin.time.Clock import kotlin.time.Duration import kotlin.time.Duration.Companion.hours 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.TimeSource @OptIn(ExperimentalTime::class) @Suppress("TooManyFunctions", "LargeClass", "LongParameterList") @@ -269,7 +269,7 @@ class AppViewModel @Inject constructor( private val highBalanceSheet: HighBalanceTimedSheet, private val formatMoneyValue: FormatMoneyValue, private val widgetsRepo: WidgetsRepo, - private val clock: Clock, + private val timeSource: TimeSource, ) : ViewModel() { val healthState = healthRepo.healthState @@ -760,6 +760,7 @@ class AppViewModel @Inject constructor( .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.awaitContactPreparation() if (!PubkyPublicKeyFormat.matches(pubkyRepo.publicKey.value, state.publicKey)) return refreshIncomingPaykitPaymentRequests() refreshPaymentRequestTargets(force = true) @@ -787,6 +788,7 @@ class AppViewModel @Inject constructor( Logger.warn("Failed to reconcile private Paykit receive indexes for '$reason'", it, context = TAG) } privatePaykitRepo.refreshKnownSavedContactEndpoints(reason, forceRefreshLightning = forceRefreshLightning) + privatePaykitRepo.awaitContactPreparation() refreshIncomingPaykitPaymentRequests() refreshPaymentRequestTargets(force = true) } @@ -886,22 +888,23 @@ class AppViewModel @Inject constructor( paykitPaymentRequestPollingJob = viewModelScope.launch { if (isOnline.value == ConnectivityState.CONNECTED) pubkyRepo.republishIdentityIfNeeded() + privatePaykitRepo.awaitContactPreparation() refreshIncomingPaykitPaymentRequests() refreshPaymentRequestTargets() var maintenanceIntervalIndex = 0 - var nextMaintenance = clock.now() + PAYKIT_MAINTENANCE_INTERVALS.first() + var nextMaintenance = timeSource.markNow() + PAYKIT_MAINTENANCE_INTERVALS.first() while (true) { delay(PAYKIT_PAYMENT_REQUEST_REFRESH_INTERVAL) if (isOnline.value != ConnectivityState.CONNECTED) continue - val now = clock.now() - val refreshMaintenance = now >= nextMaintenance + val refreshMaintenance = nextMaintenance.hasPassedNow() if (refreshMaintenance) { maintenanceIntervalIndex = (maintenanceIntervalIndex + 1).coerceAtMost(PAYKIT_MAINTENANCE_INTERVALS.lastIndex) - nextMaintenance = now + PAYKIT_MAINTENANCE_INTERVALS[maintenanceIntervalIndex] + nextMaintenance = timeSource.markNow() + PAYKIT_MAINTENANCE_INTERVALS[maintenanceIntervalIndex] if (isPaykitEnabled.value && walletRepo.walletExists()) pubkyRepo.restoreSessionIfNeeded() pubkyRepo.republishIdentityIfNeeded() privatePaykitRepo.refreshKnownSavedContactEndpoints("payment request polling") + privatePaykitRepo.awaitContactPreparation() } refreshIncomingPaykitPaymentRequests(refreshMaintenance) if (refreshMaintenance) refreshPaymentRequestTargets(force = true) diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 3854579075..a34da1242e 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -305,9 +305,46 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(result.isSuccess, result.exceptionOrNull().toString()) advanceTimeBy(257_000) runCurrent() + sut.closeAndClear() verifyBlocking(paykitSdkService, atLeast(8)) { ensureLinkWithPeer(CONTACT_KEY) } + verify(paykitSdkService, never()).processPendingPrivateMessages() + verify(paykitSdkService, never()).receivePrivateMessagesFromLinkedPeers() + } + + @Test + fun `private message drain receives immediately when advancement links the peer`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) + var state = LinkedPeerState.LINKING + whenever(paykitSdkService.linkedPeers()).thenAnswer { listOf(linkedPeer(CONTACT_KEY, state)) } + whenever(paykitSdkService.ensureLinkWithPeer(CONTACT_KEY)).thenAnswer { + state = LinkedPeerState.LINKED + LinkedPeerHandshakeReport(CONTACT_KEY, state, 1uL, null) + } + + sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() + sut.closeAndClear() + + verify(paykitSdkService).receivePrivateMessagesFromLinkedPeers() + verify(paykitSdkService, never()).processPendingPrivateMessages() + } + + @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) + } + + sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() sut.closeAndClear() + + verify(paykitSdkService).processPendingPrivateMessages() + verify(paykitSdkService, never()).receivePrivateMessagesFromLinkedPeers() } @Test @@ -658,6 +695,8 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { linkedPeer(OTHER_CONTACT_KEY, LinkedPeerState.LINKED), ), ) + whenever { paykitSdkService.pendingOutboundPrivateCounterparties() } + .thenReturn(listOf(CONTACT_KEY, OTHER_CONTACT_KEY), emptyList()) val result = sut.removePublishedEndpointsForCleanup("test") @@ -665,7 +704,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY) } verifyBlocking(paykitSdkService) { clearPrivatePaymentList(OTHER_CONTACT_KEY) } verifyBlocking(paykitSdkService, atLeast(1)) { linkedPeers() } - verifyBlocking(paykitSdkService, times(1)) { pendingOutboundPrivateCounterparties() } + verifyBlocking(paykitSdkService, times(2)) { pendingOutboundPrivateCounterparties() } verifyBlocking(paykitSdkService) { processPendingPrivateMessages() } verifyBlocking(paykitSdkService) { receivePrivateMessagesFromLinkedPeers() } assertTrue(cacheData.value.contacts.isEmpty()) @@ -858,8 +897,12 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { 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)) @@ -925,6 +968,59 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { 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 fun `closeAndClear clears SDK state`() = test { val result = sut.closeAndClear() diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 0856f00018..126ff41c30 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -528,9 +528,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { formatMoneyValue = formatMoneyValue, widgetsRepo = widgetsRepo, pubkyRepo = pubkyRepo, - clock = object : Clock { - override fun now() = kotlin.time.Instant.fromEpochMilliseconds(testDispatcher.scheduler.currentTime) - }, + timeSource = testDispatcher.scheduler.timeSource, ) private suspend fun emitNodeEvent( @@ -768,6 +766,35 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } } + @Test + fun `maintenance waits for contact preparation before inbox and target refresh`() = test { + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + 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) + + advanceTimeBy(1) + runCurrent() + verify(paykitPaymentRequestRepo, never()).refresh(any()) + verify(paykitPaymentRequestRepo, never()).refreshEligibleTargets(any(), any()) + + prepared.complete(Unit) + runCurrent() + verify(paykitPaymentRequestRepo).refresh(syncPrivateMessages = true) + verify(paykitPaymentRequestRepo).refreshEligibleTargets(any(), eq(true)) + } finally { + prepared.complete(Unit) + sut.stopPaykitPaymentRequestPolling() + } + } + @Test fun `foreground maintenance retries a missing session until restored`() = test { enablePaykitUi() @@ -8445,11 +8472,20 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(privatePaykitRepo, never()).scheduleSavedContactPreparation(any>()) verify(privatePaykitRepo, never()).pruneUnsavedContactState(any>()) + val prepared = CompletableDeferred() + whenever(privatePaykitRepo.awaitContactPreparation()).doSuspendableAnswer { prepared.await() } + clearInvocations(paykitPaymentRequestRepo) pubkyContactsLoadVersion.value = 1L + runCurrent() + verify(paykitPaymentRequestRepo, never()).refresh(any()) + verify(paykitPaymentRequestRepo, never()).refreshEligibleTargets(any(), any()) + + prepared.complete(Unit) advanceUntilIdle() verify(privatePaykitRepo).scheduleSavedContactPreparation(any>()) verify(privatePaykitRepo).pruneUnsavedContactState(any>()) + verify(paykitPaymentRequestRepo).refreshEligibleTargets(any(), eq(true)) } @Test From 85f1963d36f32383f165574a0c04b088a569dd98 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 2 Oct 2026 18:07:42 -0500 Subject: [PATCH 27/77] Avoid transport maintenance when opening subscription reminders --- app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt | 2 +- .../PaykitPaymentRequestRepoSubscriptionTest.kt | 6 ++++-- .../java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt | 6 +++++- journeys/paykit-clock-changes.md | 1 + 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index ca18aa9808..7d4af28de8 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -931,7 +931,7 @@ class AppViewModel @Inject constructor( requestedPaymentRequestTags = persistentListOf() } viewModelScope.launch { - refreshIncomingPaykitPaymentRequests() + refreshIncomingPaykitPaymentRequests(refreshMaintenance = false) } } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt index aefcc22de7..83a0fff48c 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt @@ -127,7 +127,7 @@ 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() @@ -144,8 +144,10 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat ), ) - sut.refresh().getOrThrow() + sut.refresh(syncPrivateMessages = false).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) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 126ff41c30..cd7e152fe6 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -1310,13 +1310,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(syncPrivateMessages = false) + verify(paykitPaymentRequestRepo, never()).refresh(syncPrivateMessages = true) + verify(paykitPaymentProofRepo, never()).reconcile() + verify(privatePaykitRepo, never()).awaitContactPreparation() verify(privatePaykitRepo).beginPaymentRequest(targetRequest) verify(privatePaykitRepo, never()).beginPaymentRequest(otherRequest) } diff --git a/journeys/paykit-clock-changes.md b/journeys/paykit-clock-changes.md index 2fd22c8c6a..6090a2d2d0 100644 --- a/journeys/paykit-clock-changes.md +++ b/journeys/paykit-clock-changes.md @@ -22,6 +22,7 @@ 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. 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. From bb4637dd5bcdaf47f42dda306486b8d0e5846a07 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 2 Oct 2026 18:34:31 -0500 Subject: [PATCH 28/77] fix: exclude sdk queue waits from request discovery timeout --- .../repositories/PaykitPaymentRequestRepo.kt | 31 ++++------ .../to/bitkit/services/PaykitSdkService.kt | 14 +++-- .../PaykitPaymentRequestRepoTest.kt | 24 +++----- .../bitkit/services/PaykitSdkServiceTest.kt | 58 +++++++++++++++++++ 4 files changed, 88 insertions(+), 39 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 048f3fc64c..cabf87c262 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -25,7 +25,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray @@ -60,7 +59,6 @@ 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.ExperimentalTime import kotlin.time.Instant @@ -264,7 +262,6 @@ class PaykitPaymentRequestRepo @Inject constructor( ) { companion object { private const val TAG = "PaykitPaymentRequestRepo" - private val TARGET_DISCOVERY_TIMEOUT = 5.seconds } private val operationMutex = Mutex() @@ -1165,31 +1162,27 @@ class PaykitPaymentRequestRepo @Inject constructor( val failedPublicKeys = mutableSetOf() val targets = context.savedPublicKeys.mapNotNull { publicKey -> if (publicKey !in context.linkedPublicKeys) return@mapNotNull null - val lookup = withTimeoutOrNull(TARGET_DISCOVERY_TIMEOUT) { - runSuspendCatching { paykitSdkService.canReceivePaymentRequests(publicKey) } - } - 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] - } + val lookup = runSuspendCatching { paykitSdkService.canReceivePaymentRequests(publicKey) } 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] + .getOrNull() + if (capable == null) { + isComplete = false + failedPublicKeys += publicKey + if (lookup.isSuccess) { + Logger.warn( + "Timed out inspecting request support for '${PubkyPublicKeyFormat.redacted(publicKey)}'", + context = TAG, + ) } + return@mapNotNull previousTargets[publicKey] + } if (!capable) { isComplete = false return@mapNotNull null diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index c9b2e3cde6..9ad7184c48 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -729,12 +729,15 @@ class PaykitSdkService @Inject constructor( } } - suspend fun canReceivePaymentRequests(publicKey: String): Boolean { + /** Returns null when the registry lookup times out, excluding queued SDK work. */ + suspend fun canReceivePaymentRequests(publicKey: String): Boolean? { isSetup.await() return operationLock.withLock { - handle().paykitAppRegistry(publicKey)?.apps?.any { - it.capabilities.paymentRequests && it.capabilities.outgoingPayments - } == true + withTimeoutOrNull(PAYMENT_REQUEST_DISCOVERY_TIMEOUT) { + handle().paykitAppRegistry(publicKey)?.apps?.any { + it.capabilities.paymentRequests && it.capabilities.outgoingPayments + } == true + } } } @@ -1191,6 +1194,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 payment-request capability lookup after acquiring the SDK lock. */ + private val PAYMENT_REQUEST_DISCOVERY_TIMEOUT = 5.seconds + fun localSecretKey(secretKeyHex: String): PubkyLocalSecretKey = PubkyLocalSecretKey(secretKeyHex.fromHex()) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 5909a67384..2dd788dabe 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -1456,29 +1456,21 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `recipient discovery bounds a stalled capability lookup`() = test { - val discoveryStarted = CompletableDeferred() - val stalledDiscovery = CompletableDeferred() + 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)), ) - whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)).doSuspendableAnswer { - discoveryStarted.complete(Unit) - stalledDiscovery.await() - true - } - - val targetRefresh = async { sut.refreshEligibleTargets(listOf(COUNTERPARTY)) } - discoveryStarted.await() - advanceTimeBy(5.seconds.inWholeMilliseconds) - runCurrent() + whenever(paykitSdkService.canReceivePaymentRequests(COUNTERPARTY)) + .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 diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index 88ca016f83..cfab60e406 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -28,16 +28,21 @@ import com.synonym.paykit.PublicContactSharingPolicy import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Deferred +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.atLeastOnce import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -63,15 +68,68 @@ import to.bitkit.utils.AppError import kotlin.coroutines.cancellation.CancellationException 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.Duration.Companion.seconds class PaykitSdkServiceTest { companion object { private const val RING_PUBKY = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" } + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun `request discovery timeout excludes time queued for the SDK`() = 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) } + advanceTimeBy(6.seconds.inWholeMilliseconds) + runCurrent() + + assertFalse(discovery.isCompleted) + verify(sdk, never()).paykitAppRegistry(any()) + releaseContacts.complete(Unit) + contacts.await() + assertEquals(true, discovery.await()) + } + + @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() From 75e5429a7955e0ca2ca92cb898f51e5d44b56842 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 2 Oct 2026 20:21:34 -0500 Subject: [PATCH 29/77] fix: reduce redundant paykit synchronization --- .../ContactPaymentSettingsRepo.kt | 19 ++- .../repositories/PaykitPaymentRequestRepo.kt | 22 ++- .../to/bitkit/services/PaykitSdkService.kt | 9 + .../java/to/bitkit/viewmodels/AppViewModel.kt | 38 +++-- .../ContactPaymentSettingsRepoTest.kt | 159 ++++++++++++++++++ ...aykitPaymentRequestRepoSubscriptionTest.kt | 40 +++-- .../PaykitPaymentRequestRepoTest.kt | 31 +++- .../viewmodels/AppViewModelSendFlowTest.kt | 82 +++++++++ journeys/contacts/README.md | 4 + 9 files changed, 366 insertions(+), 38 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt b/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt index 6feff41ab7..fa2fd97fd1 100644 --- a/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt @@ -4,6 +4,8 @@ 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 @@ -21,11 +23,24 @@ 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() + } } private suspend fun enable(contacts: List): Result { diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index cabf87c262..b8694aceef 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -594,7 +594,7 @@ class PaykitPaymentRequestRepo @Inject constructor( proposal = proposal, expectedIdentity = expectedIdentity, ) - val reports = processPendingMessages() + val reports = processPendingMessages(target.publicKey) val request = record.toCreatedPaykitPaymentRequest( draft = draft.copy(note = note), @@ -667,7 +667,7 @@ class PaykitPaymentRequestRepo @Inject constructor( proposal = proposal, expectedIdentity = expectedIdentity, ) - val deliveryStatus = if (proposalWasSent(record, processPendingMessages())) { + val deliveryStatus = if (proposalWasSent(record, processPendingMessages(target.publicKey))) { PaykitPaymentRequestDeliveryStatus.Sent } else { PaykitPaymentRequestDeliveryStatus.Queued @@ -1337,8 +1337,22 @@ class PaykitPaymentRequestRepo @Inject constructor( scheduleExpirationLocked() } - private suspend fun processPendingMessages(): List = - runSuspendCatching { paykitSdkService.processPendingPrivateMessages() } + private suspend fun processPendingMessages( + counterparty: String? = null, + ): List = + runSuspendCatching { + if (counterparty == null) { + paykitSdkService.processPendingPrivateMessages() + } else { + listOf( + OutboundPrivateCounterpartySendReport( + counterparty = counterparty, + report = paykitSdkService.processOutboundPrivateMessages(counterparty), + error = null, + ), + ) + } + } .onSuccess(::logOutboundFailures) .onFailure { Logger.warn("Failed to deliver pending Paykit private messages", it, context = TAG) } .getOrDefault(emptyList()) diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 9ad7184c48..8e6b52e92c 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -689,6 +689,15 @@ class PaykitSdkService @Inject constructor( } } + suspend fun processOutboundPrivateMessages(counterparty: String) = run { + isSetup.await() + operationLock.withLock { + withStateRevisionTracking { handle -> + handle.processOutboundPrivateMessages(counterparty) + } + } + } + suspend fun processPendingPrivateMessages(): List { isSetup.await() return operationLock.withLock { diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 7d4af28de8..6502c4bbfd 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -144,6 +144,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 @@ -257,6 +258,7 @@ 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, @@ -1342,25 +1344,27 @@ class AppViewModel @Inject constructor( } private suspend fun retryPendingPaykitEndpointRemoval(contactKeys: Collection, reason: String) { - 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) } - } + 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) + } + + 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) + } + } } } diff --git a/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt index 0bf9ffe89b..98dff240aa 100644 --- a/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt @@ -1,12 +1,20 @@ 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 @@ -17,6 +25,7 @@ import to.bitkit.data.SettingsStore import to.bitkit.models.PubkyProfile 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 @@ -185,6 +194,156 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { } } + @Test + fun `enabling waits for both withdrawal phases even when cleanup fails`() = test { + val cleanupResults = listOf(Result.success(Unit), Result.failure(ContactPaymentSettingsTestError("withdrawal"))) + for (cleanupResult in cleanupResults) { + 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)).doSuspendableAnswer { + publicCleanup.await() + cleanupResult + } + val sut = createSut() + + val disable = async { 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) + 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) + 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/PaykitPaymentRequestRepoSubscriptionTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt index 83a0fff48c..0dcf2b7a93 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt @@ -6,6 +6,7 @@ import com.synonym.paykit.BillingPeriod import com.synonym.paykit.IdentityStatus import com.synonym.paykit.LinkedPeerRecord import com.synonym.paykit.LinkedPeerState +import com.synonym.paykit.OutboundPrivateSendReport import com.synonym.paykit.PaymentDeadline import com.synonym.paykit.PaymentProofRecord import com.synonym.paykit.PaymentReference @@ -93,6 +94,9 @@ 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.allPaymentRequests(anyOrNull())).thenReturn(emptyList()) whenever(paykitSdkService.linkedPeers()).thenReturn(emptyList()) @@ -316,18 +320,32 @@ 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) { diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 2dd788dabe..8c78c41e4c 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -9,6 +9,7 @@ import com.synonym.bitkitcore.validateBitcoinAddress import com.synonym.paykit.IdentityStatus import com.synonym.paykit.LinkedPeerRecord import com.synonym.paykit.LinkedPeerState +import com.synonym.paykit.OutboundPrivateSendReport import com.synonym.paykit.PaykitException import com.synonym.paykit.PaymentDeadline import com.synonym.paykit.PaymentProofRecord @@ -98,6 +99,9 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { 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.allPaymentRequests(anyOrNull())).thenReturn(emptyList()) whenever(paykitSdkService.linkedPeers()).thenReturn(emptyList()) @@ -1032,9 +1036,21 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `proposal revalidates only the selected saved contact`() = test { + fun `proposal revalidates and drains only the selected saved contact`() = test { val target = PaykitPaymentRequestTarget(COUNTERPARTY) val stalledDiscovery = CompletableDeferred() + 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)) @@ -1055,7 +1071,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentRequestRecord( role = PaymentRequestLocalRole.PAYEE, counterparty = COUNTERPARTY, - ), + ).copy(proposalOutboundMessageId = 7uL), ) val proposal = async { @@ -1067,9 +1083,16 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { } runCurrent() - assertTrue(proposal.isCompleted) - proposal.await().getOrThrow() + val completedBeforeUnrelatedDelivery = proposal.isCompleted + unrelatedDelivery.complete(Unit) + stalledDiscovery.complete(Unit) + val creation = proposal.await().getOrThrow() + assertTrue(completedBeforeUnrelatedDelivery) + assertEquals(PaykitPaymentRequestDeliveryStatus.Sent, creation.request.deliveryStatus) verifyBlocking(paykitSdkService, never()) { canReceivePaymentRequests(SECOND_IDENTITY) } + verify(paykitSdkService).processOutboundPrivateMessages(COUNTERPARTY) + verify(paykitSdkService, never()).processOutboundPrivateMessages(SECOND_IDENTITY) + verify(paykitSdkService, never()).processPendingPrivateMessages() } @Test diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index cd7e152fe6..e4333e8280 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -28,6 +28,7 @@ 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 @@ -118,6 +119,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 @@ -253,6 +255,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()) @@ -515,6 +520,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { nodeServiceFgState = nodeServiceFgState, publicPaykitRepo = publicPaykitRepo, privatePaykitRepo = privatePaykitRepo, + contactPaymentSettingsRepo = contactPaymentSettingsRepo, paykitPaymentRequestRepo = paykitPaymentRequestRepo, paykitPaymentProofRepo = paykitPaymentProofRepo, paykitPaymentRequestDiagnostics = paykitPaymentRequestDiagnostics, @@ -8520,6 +8526,82 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(privatePaykitRepo).pruneUnsavedContactState(emptySet()) } + @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)).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) + 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 fun `private Paykit refresh retries public cleanup while UI is disabled`() = test { settingsData.value = SettingsData(publicPaykitCleanupPending = true) diff --git a/journeys/contacts/README.md b/journeys/contacts/README.md index 5a86533b0d..166de2ca99 100644 --- a/journeys/contacts/README.md +++ b/journeys/contacts/README.md @@ -10,6 +10,10 @@ and retain both cleanup jobs without republishing cleared private lists. Restore 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. From 4951920381d7c8e9e1115a34e129e2dd9ce93b2e Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 2 Oct 2026 21:44:15 -0500 Subject: [PATCH 30/77] fix: keep contact payments responsive during background sync --- .../repositories/PaykitPaymentRequestRepo.kt | 24 ++- .../bitkit/repositories/PrivatePaykitRepo.kt | 60 +++++-- .../bitkit/services/PaykitSdkOperationLock.kt | 16 ++ .../to/bitkit/services/PaykitSdkService.kt | 24 ++- .../java/to/bitkit/viewmodels/AppViewModel.kt | 4 - .../PaykitPaymentRequestRepoTest.kt | 86 ++++++++++ .../repositories/PrivatePaykitRepoTest.kt | 153 +++++++++++++++--- .../services/PaykitSdkOperationLockTest.kt | 21 +++ .../bitkit/services/PaykitSdkServiceTest.kt | 68 +++++++- .../viewmodels/AppViewModelSendFlowTest.kt | 15 +- journeys/contacts/README.md | 14 ++ 11 files changed, 421 insertions(+), 64 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index b8694aceef..65979cc1b8 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -15,6 +15,9 @@ 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 @@ -23,7 +26,9 @@ 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.serialization.Serializable import kotlinx.serialization.json.Json @@ -262,6 +267,9 @@ class PaykitPaymentRequestRepo @Inject constructor( ) { companion object { private const val TAG = "PaykitPaymentRequestRepo" + + /** Maximum number of concurrent public capability lookups per discovery. */ + private const val PAYMENT_REQUEST_DISCOVERY_CONCURRENCY = 8 } private val operationMutex = Mutex() @@ -1160,9 +1168,8 @@ class PaykitPaymentRequestRepo @Inject constructor( ): PaykitPaymentRequestTargetDiscovery { var isComplete = true val failedPublicKeys = mutableSetOf() - val targets = context.savedPublicKeys.mapNotNull { publicKey -> - if (publicKey !in context.linkedPublicKeys) return@mapNotNull null - val lookup = runSuspendCatching { paykitSdkService.canReceivePaymentRequests(publicKey) } + val linkedKeys = context.savedPublicKeys.filter { it in context.linkedPublicKeys } + val targets = paymentRequestCapabilities(linkedKeys).mapNotNull { (publicKey, lookup) -> val capable = lookup .onFailure { Logger.warn( @@ -1196,6 +1203,17 @@ class PaykitPaymentRequestRepo @Inject constructor( ) } + private suspend fun paymentRequestCapabilities(publicKeys: List) = coroutineScope { + val permits = Semaphore(PAYMENT_REQUEST_DISCOVERY_CONCURRENCY) + publicKeys.map { publicKey -> + async { + permits.withPermit { + publicKey to runSuspendCatching { paykitSdkService.canReceivePaymentRequests(publicKey) } + } + } + }.awaitAll() + } + private fun acceptedPaymentEndpointIdentifiers(settings: SettingsData): List = buildList { if (PublicPaykitRepo.isLightningPaymentOptionEnabled(settings)) add(MethodId.Bolt11.rawValue) if (PublicPaykitRepo.isOnchainPaymentOptionEnabled(settings)) { diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 242730de80..d49f1270a0 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -13,7 +13,9 @@ 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.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -613,10 +615,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) @@ -943,7 +945,7 @@ class PrivatePaykitRepo @Inject constructor( val pendingKeys = pendingPrivateMessageDrainKeys(newKeys, retryMissingPeers = true) if (pendingKeys.isEmpty()) return - drainPendingPrivateMessages(reason, advancingLinksFor = pendingKeys.toList()) + drainPendingPrivateMessages(reason, retryKeys = pendingKeys) val pendingRetryKeys = pendingPrivateMessageDrainKeys(pendingKeys) if (pendingRetryKeys.isNotEmpty()) { schedulePendingPrivateMessageDrainRetries(reason, retryKeys = pendingRetryKeys) @@ -1040,12 +1042,17 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun drainPendingPrivateMessages( reason: String, - advancingLinksFor: List = emptyList(), + retryKeys: Collection, ) { + val retryKeys = retryKeys.mapNotNull(::normalizedPublicKey).toSet() + currentCoroutineContext().ensureActive() + if (retryKeys.isEmpty()) return runSuspendCatching { val generation = preparationGeneration - advancingLinksFor.distinct().forEach { retryKey -> - if (generation != preparationGeneration || retryKey !in knownSavedContactKeys) return@forEach + retryKeys.forEach { retryKey -> + currentCoroutineContext().ensureActive() + if (generation != preparationGeneration) return@runSuspendCatching + if (retryKey !in knownSavedContactKeys) return@forEach if (unavailableLinkRetryAt[retryKey]?.let { it > clock.now() } == true) return@forEach runSuspendCatching { paykitSdkService.ensureLinkWithPeer( @@ -1059,11 +1066,31 @@ class PrivatePaykitRepo @Inject constructor( ) } } - if (paykitSdkService.pendingOutboundPrivateCounterparties().isNotEmpty()) { - paykitSdkService.processPendingPrivateMessages() + currentCoroutineContext().ensureActive() + if (generation != preparationGeneration) return@runSuspendCatching + val pendingKeys = paykitSdkService.pendingOutboundPrivateCounterparties() + .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) + } } - if (paykitSdkService.linkedPeers().any { it.state == LinkedPeerState.LINKED }) { - paykitSdkService.receivePrivateMessagesFromLinkedPeers() + currentCoroutineContext().ensureActive() + if (generation != preparationGeneration) return@runSuspendCatching + val linkedKeys = paykitSdkService.linkedPeers().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) @@ -1109,7 +1136,7 @@ class PrivatePaykitRepo @Inject constructor( if (retryKeys.isEmpty()) return@withContext val pendingKeys = pendingPrivateMessageDrainKeys(retryKeys) if (pendingKeys.isNotEmpty()) { - drainPendingPrivateMessages(reason, advancingLinksFor = pendingKeys.toList()) + drainPendingPrivateMessages(reason, retryKeys = pendingKeys) } updatePendingMessageDrainRetryKeys(retryKeys) } @@ -1375,11 +1402,14 @@ class PrivatePaykitRepo @Inject constructor( 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()) { + drainPendingPrivateMessages( + reason = "private endpoint cleanup", + retryKeys = pendingRetryKeys, + ) + pendingRetryKeys = pendingPrivateMessageDrainKeys(preparation.clearedRetryKeys) + } if (pendingRetryKeys.isNotEmpty()) { Logger.warn( "Private Paykit endpoint withdrawal remains pending for ${pendingRetryKeys.map(::redacted)}", diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkOperationLock.kt b/app/src/main/java/to/bitkit/services/PaykitSdkOperationLock.kt index 03115dd4b9..0d8f46b163 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkOperationLock.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkOperationLock.kt @@ -15,6 +15,22 @@ internal class PaykitSdkOperationLock { private var generation = 0L private var isWiping = false + /** Admits identity-independent public reads without holding the mutation lock. */ + suspend fun withPublicRead(operation: suspend () -> T): T { + val admittedGeneration = synchronized(stateLock) { + checkAvailable() + generation + } + currentCoroutineContext().ensureActive() + val result = operation() + currentCoroutineContext().ensureActive() + synchronized(stateLock) { + checkAvailable() + if (admittedGeneration != generation) throw wipeError() + } + return result + } + suspend fun withLock(operation: suspend () -> T): T { val wipeContext = currentCoroutineContext()[WipeContext] if (synchronized(stateLock) { diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 8e6b52e92c..63ad7624c6 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -181,7 +181,10 @@ class PaykitSdkService @Inject constructor( private var isSetup = CompletableDeferred() private var setupFailed = false private var platformInitializer: () -> Unit = { PaykitAndroid.initializeOrThrow(context) } + + @Volatile private var sdk: PaykitSdk? = null + private var cachedPaykitKey: PaykitKeyGeneration? = null private var cachedBackupState: PaykitBackupStateSnapshot? = null private val _backupStateVersion = MutableStateFlow(0L) @@ -689,6 +692,15 @@ class PaykitSdkService @Inject constructor( } } + suspend fun receivePrivateMessages(counterparty: String) = run { + isSetup.await() + operationLock.withLock { + withStateRevisionTracking { handle -> + handle.receivePrivateMessages(counterparty) + } + } + } + suspend fun processOutboundPrivateMessages(counterparty: String) = run { isSetup.await() operationLock.withLock { @@ -738,15 +750,17 @@ class PaykitSdkService @Inject constructor( } } - /** Returns null when the registry lookup times out, excluding queued SDK work. */ + /** Returns null on timeout or runtime replacement; public reads do not hold the mutation lock. */ suspend fun canReceivePaymentRequests(publicKey: String): Boolean? { isSetup.await() - return operationLock.withLock { - withTimeoutOrNull(PAYMENT_REQUEST_DISCOVERY_TIMEOUT) { - handle().paykitAppRegistry(publicKey)?.apps?.any { + return operationLock.withPublicRead { + val handle = sdk ?: operationLock.withLock { handle() } + val result = withTimeoutOrNull(PAYMENT_REQUEST_DISCOVERY_TIMEOUT) { + handle.paykitAppRegistry(publicKey)?.apps?.any { it.capabilities.paymentRequests && it.capabilities.outgoingPayments } == true } + result.takeIf { sdk === handle } } } @@ -1203,7 +1217,7 @@ class PaykitSdkService @Inject constructor( /** Maximum time identity maintenance may delay its caller. */ private val IDENTITY_REPUBLISH_WAIT_TIMEOUT = 5.seconds - /** Maximum duration of a payment-request capability lookup after acquiring the SDK lock. */ + /** Maximum duration of a public payment-request capability lookup. */ private val PAYMENT_REQUEST_DISCOVERY_TIMEOUT = 5.seconds fun localSecretKey(secretKeyHex: String): PubkyLocalSecretKey = diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 6502c4bbfd..b74eb3c322 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -762,7 +762,6 @@ class AppViewModel @Inject constructor( .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.awaitContactPreparation() if (!PubkyPublicKeyFormat.matches(pubkyRepo.publicKey.value, state.publicKey)) return refreshIncomingPaykitPaymentRequests() refreshPaymentRequestTargets(force = true) @@ -790,7 +789,6 @@ class AppViewModel @Inject constructor( Logger.warn("Failed to reconcile private Paykit receive indexes for '$reason'", it, context = TAG) } privatePaykitRepo.refreshKnownSavedContactEndpoints(reason, forceRefreshLightning = forceRefreshLightning) - privatePaykitRepo.awaitContactPreparation() refreshIncomingPaykitPaymentRequests() refreshPaymentRequestTargets(force = true) } @@ -890,7 +888,6 @@ class AppViewModel @Inject constructor( paykitPaymentRequestPollingJob = viewModelScope.launch { if (isOnline.value == ConnectivityState.CONNECTED) pubkyRepo.republishIdentityIfNeeded() - privatePaykitRepo.awaitContactPreparation() refreshIncomingPaykitPaymentRequests() refreshPaymentRequestTargets() var maintenanceIntervalIndex = 0 @@ -906,7 +903,6 @@ class AppViewModel @Inject constructor( if (isPaykitEnabled.value && walletRepo.walletExists()) pubkyRepo.restoreSessionIfNeeded() pubkyRepo.republishIdentityIfNeeded() privatePaykitRepo.refreshKnownSavedContactEndpoints("payment request polling") - privatePaykitRepo.awaitContactPreparation() } refreshIncomingPaykitPaymentRequests(refreshMaintenance) if (refreshMaintenance) refreshPaymentRequestTargets(force = true) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 8c78c41e4c..f321adfb78 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -27,6 +27,8 @@ import com.synonym.paykit.PubkyIdentityCapability import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceTimeBy @@ -1174,6 +1176,90 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { verifyBlocking(paykitSdkService, times(1)) { canReceivePaymentRequests(COUNTERPARTY) } } + @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())).thenReturn(true) + sut.refreshEligibleTargets(keys).getOrThrow() + val releaseSlowPeer = CompletableDeferred() + val completedPeers = mutableSetOf() + var active = 0 + var maxActive = 0 + whenever(paykitSdkService.canReceivePaymentRequests(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())).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()) + assertTrue(sut.eligibleTargets.value.isEmpty()) + } + @Test fun `single recipient refresh adds a newly eligible contact`() = test { whenever( diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index a34da1242e..891b01e660 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -20,6 +20,7 @@ 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 @@ -241,7 +242,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { clearInvocations(paykitSdkService) advanceTimeBy(2_000) runCurrent() - verify(paykitSdkService, atLeast(1)).processPendingPrivateMessages() + verify(paykitSdkService, atLeast(1)).processOutboundPrivateMessages(CONTACT_KEY) whenever(paykitSdkService.pendingOutboundPrivateCounterparties()).thenReturn(emptyList()) advanceTimeBy(2_000) @@ -250,6 +251,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { advanceTimeBy(120_000) runCurrent() verify(paykitSdkService, never()).ensureLinkWithPeer(any(), any()) + verify(paykitSdkService, never()).receivePrivateMessages(any()) verify(paykitSdkService, never()).receivePrivateMessagesFromLinkedPeers() sut.closeAndClear() } @@ -308,25 +310,97 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.closeAndClear() verifyBlocking(paykitSdkService, atLeast(8)) { ensureLinkWithPeer(CONTACT_KEY) } + verify(paykitSdkService, never()).processOutboundPrivateMessages(any()) + verify(paykitSdkService, never()).receivePrivateMessages(any()) verify(paykitSdkService, never()).processPendingPrivateMessages() verify(paykitSdkService, never()).receivePrivateMessagesFromLinkedPeers() } @Test - fun `private message drain receives immediately when advancement links the peer`() = test { + 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(CONTACT_KEY, state)) } + 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) } - sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() - sut.closeAndClear() + 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() + } + } + + @Test + 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 } + + sut.prepareSavedContacts(keys).getOrThrow() - verify(paykitSdkService).receivePrivateMessagesFromLinkedPeers() + 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 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) + } } @Test @@ -343,7 +417,8 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() sut.closeAndClear() - verify(paykitSdkService).processPendingPrivateMessages() + verify(paykitSdkService).processOutboundPrivateMessages(CONTACT_KEY) + verify(paykitSdkService, never()).receivePrivateMessages(any()) verify(paykitSdkService, never()).receivePrivateMessagesFromLinkedPeers() } @@ -681,7 +756,27 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `cleanup drains all contacts in one batch`() = test { + 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()) + verify(paykitSdkService).clearPrivatePaymentList(CONTACT_KEY) + verify(paykitSdkService, never()).ensureLinkWithPeer(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 only pending peers using normalized SDK keys`() = test { cacheData.value = PrivatePaykitCacheData( contacts = mapOf( CONTACT_KEY to cachedPublishedContact(), @@ -689,24 +784,33 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { ), ) sut = createSut() - whenever { paykitSdkService.linkedPeers() }.thenReturn( + whenever(paykitSdkService.linkedPeers()).thenReturn( listOf( - linkedPeer(CONTACT_KEY, LinkedPeerState.LINKED), - linkedPeer(OTHER_CONTACT_KEY, LinkedPeerState.LINKED), + linkedPeer(CONTACT_KEY.removePrefix("pubky"), LinkedPeerState.LINKED), + linkedPeer(OTHER_CONTACT_KEY.removePrefix("pubky"), LinkedPeerState.LINKED), ), ) - whenever { paykitSdkService.pendingOutboundPrivateCounterparties() } - .thenReturn(listOf(CONTACT_KEY, OTHER_CONTACT_KEY), emptyList()) + 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) } verifyBlocking(paykitSdkService) { clearPrivatePaymentList(OTHER_CONTACT_KEY) } + verify(paykitSdkService).ensureLinkWithPeer(CONTACT_KEY) + verify(paykitSdkService, never()).ensureLinkWithPeer(eq(OTHER_CONTACT_KEY), any()) verifyBlocking(paykitSdkService, atLeast(1)) { linkedPeers() } - verifyBlocking(paykitSdkService, times(2)) { pendingOutboundPrivateCounterparties() } - verifyBlocking(paykitSdkService) { processPendingPrivateMessages() } - verifyBlocking(paykitSdkService) { receivePrivateMessagesFromLinkedPeers() } + verifyBlocking(paykitSdkService, times(3)) { pendingOutboundPrivateCounterparties() } + 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()) } @@ -1073,13 +1177,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 { @@ -1087,13 +1193,18 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { stalledPublish.await() privateListDeliveryReport(queuedCounterparties = listOf(CONTACT_KEY)) } - whenever { - paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, 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() diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkOperationLockTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkOperationLockTest.kt index ecb8df5030..b14762d2ed 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkOperationLockTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkOperationLockTest.kt @@ -16,6 +16,27 @@ import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class PaykitSdkOperationLockTest { + @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.withPublicRead { releaseRead.await() } + } + } + runCurrent() + lock.withLock { } + lock.withWalletWipe { + assertFailsWith { + lock.withPublicRead { error("read admitted during wipe") } + } + } + releaseRead.complete(Unit) + assertEquals("wallet_wipe_in_progress", read.await().code) + assertEquals("fresh", lock.withPublicRead { "fresh" }) + } + @Test fun `wipe drains active work rejects queued work and permits cleanup and fresh work`() = runTest { val lock = PaykitSdkOperationLock() diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index cfab60e406..62700c0a0a 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -17,6 +17,7 @@ import com.synonym.paykit.PaymentRequestRecord import com.synonym.paykit.PaymentRequestRecurrence import com.synonym.paykit.PaymentRequestTerms import com.synonym.paykit.PrivatePaymentListDeliveryReport +import com.synonym.paykit.PrivateStreamIntakeReport import com.synonym.paykit.PubkyAuthCompanionClaim import com.synonym.paykit.PubkyClientConfig import com.synonym.paykit.PubkyIdentityCapability @@ -81,7 +82,7 @@ class PaykitSdkServiceTest { @Test @OptIn(ExperimentalCoroutinesApi::class) - fun `request discovery timeout excludes time queued for the SDK`() = runTest { + fun `request discovery completes while unrelated SDK work is blocked`() = runTest { val sdk = mock() val releaseContacts = CompletableDeferred() whenever { sdk.contactRecords() }.doSuspendableAnswer { @@ -101,14 +102,35 @@ class PaykitSdkServiceTest { val contacts = async { service.contactRecords() } runCurrent() val discovery = async { service.canReceivePaymentRequests(RING_PUBKY) } - advanceTimeBy(6.seconds.inWholeMilliseconds) runCurrent() - assertFalse(discovery.isCompleted) - verify(sdk, never()).paykitAppRegistry(any()) - releaseContacts.complete(Unit) + try { + assertTrue(discovery.isCompleted) + assertFalse(contacts.isCompleted) + assertEquals(true, discovery.await()) + } finally { + releaseContacts.complete(Unit) + } contacts.await() - assertEquals(true, discovery.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 @@ -163,6 +185,40 @@ class PaykitSdkServiceTest { verify(sdk).prepareAndResolvePrivatePaymentRequest(RING_PUBKY, "request", null, 1u) } + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun `scoped private receive holds mutation lock and tracks backup changes`() = runTest { + 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() + try { + assertFalse(contacts.isCompleted) + verify(sdk, never()).contactRecords() + } finally { + releaseReceive.complete(Unit) + } + + assertSame(report, receive.await()) + assertEquals(emptyList(), contacts.await()) + assertEquals(1L, service.backupStateVersion.value) + verify(sdk).receivePrivateMessages(RING_PUBKY) + verify(sdk, never()).receivePrivateMessagesFromLinkedPeers() + } + @Test fun `initialization can be retried after shared state contention`() = runTest { val failure = PaykitException.ConcurrentUpdate("concurrent_update", "Resource locked") diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index e4333e8280..de277764c2 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -773,7 +773,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `maintenance waits for contact preparation before inbox and target refresh`() = test { + fun `maintenance discovers targets and refreshes inbox without joining all contact preparation`() = test { enablePaykitUi() pubkyPublicKey.value = testPublicKey whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) @@ -788,11 +788,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceTimeBy(1) runCurrent() - verify(paykitPaymentRequestRepo, never()).refresh(any()) - verify(paykitPaymentRequestRepo, never()).refreshEligibleTargets(any(), any()) - - prepared.complete(Unit) - runCurrent() + assertFalse(prepared.isCompleted) + verify(privatePaykitRepo, never()).awaitContactPreparation() verify(paykitPaymentRequestRepo).refresh(syncPrivateMessages = true) verify(paykitPaymentRequestRepo).refreshEligibleTargets(any(), eq(true)) } finally { @@ -8487,10 +8484,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { clearInvocations(paykitPaymentRequestRepo) pubkyContactsLoadVersion.value = 1L runCurrent() - verify(paykitPaymentRequestRepo, never()).refresh(any()) - verify(paykitPaymentRequestRepo, never()).refreshEligibleTargets(any(), any()) - - prepared.complete(Unit) + assertFalse(prepared.isCompleted) + verify(privatePaykitRepo, never()).awaitContactPreparation() advanceUntilIdle() verify(privatePaykitRepo).scheduleSavedContactPreparation(any>()) diff --git a/journeys/contacts/README.md b/journeys/contacts/README.md index 166de2ca99..a0527dad11 100644 --- a/journeys/contacts/README.md +++ b/journeys/contacts/README.md @@ -24,3 +24,17 @@ after deletion. Unavailable private-link lookups are retried after five minutes 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. + +## 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. From 09e224cc53b62e5f70eef8f817da40f280e8af52 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 2 Oct 2026 22:17:07 -0500 Subject: [PATCH 31/77] fix: avoid duplicate private cleanup registry sync --- .../bitkit/repositories/PrivatePaykitRepo.kt | 18 +++--- .../repositories/PrivatePaykitRepoTest.kt | 63 +++++++++++++++++++ 2 files changed, 73 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index d49f1270a0..4b653149d7 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -278,10 +278,8 @@ class PrivatePaykitRepo @Inject constructor( if (!cleanupPending) updateContactSharingCleanupPending(true) removePublishedEndpoints().getOrThrow() clearUnsavedContactState(savedPublicKeys).getOrThrow() + syncPaykitAppAfterCleanup().getOrThrow() updateContactSharingCleanupPending(false) - publicPaykitRepo.syncPaykitApp().onFailure { - updateContactSharingCleanupPending(true) - }.getOrThrow() } retryPendingDeletedContactEndpointRemoval(savedPublicKeys).getOrThrow() }.onFailure { @@ -326,10 +324,8 @@ class PrivatePaykitRepo @Inject constructor( updateContactSharingCleanupPending(true) removePublishedEndpoints().getOrThrow() clearUnsavedContactState(savedPublicKeys).getOrThrow() + syncPaykitAppAfterCleanup().getOrThrow() updateContactSharingCleanupPending(false) - publicPaykitRepo.syncPaykitApp().onFailure { - updateContactSharingCleanupPending(true) - }.getOrThrow() } } @@ -344,8 +340,8 @@ class PrivatePaykitRepo @Inject constructor( runSuspendCatching { updateContactSharingCleanupPending(true) removePublishedEndpoints().getOrThrow() + syncPaykitAppAfterCleanup().getOrThrow() updateContactSharingCleanupPending(false) - publicPaykitRepo.syncPaykitApp().getOrThrow() }.onFailure { updateContactSharingCleanupPending(true) Logger.warn("Failed to remove private Paykit endpoints during '$context'", it, context = TAG) @@ -1433,12 +1429,18 @@ class PrivatePaykitRepo @Inject constructor( clearPublishedEndpointCache(normalizedKeys.filterNot { it in failedPublicKeys }) firstError?.let { throw it } - publicPaykitRepo.syncPaykitApp().getOrThrow() + 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, linkedPublicKeys: Set, diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 891b01e660..dc866e3d99 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -498,6 +498,63 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { verify(paykitSdkService, never()).clearPrivatePaymentList(OTHER_CONTACT_KEY) verify(paykitSdkService, never()).clearPrivatePaymentList(recoveringPublicKey) 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() + } + } + } + + @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 @@ -520,7 +577,9 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { 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") { @@ -545,12 +604,14 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { 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) } } @@ -602,6 +663,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(result.isSuccess) verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY) } + verify(publicPaykitRepo).syncPaykitApp() assertTrue(cacheData.value.contacts.isEmpty()) } @@ -766,6 +828,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(result.isSuccess, result.exceptionOrNull().toString()) verify(paykitSdkService).clearPrivatePaymentList(CONTACT_KEY) + verify(publicPaykitRepo).syncPaykitApp() verify(paykitSdkService, never()).ensureLinkWithPeer(any(), any()) verify(paykitSdkService, never()).processOutboundPrivateMessages(any()) verify(paykitSdkService, never()).receivePrivateMessages(any()) From 288c7d8e404277f4629325c7a1d5ff961cde693a Mon Sep 17 00:00:00 2001 From: benk10 Date: Sat, 3 Oct 2026 10:13:58 -0500 Subject: [PATCH 32/77] perf: use rc61 and separate inbox polling from maintenance --- .../java/to/bitkit/ext/PaykitExceptionExt.kt | 6 ++++ .../repositories/PaykitPaymentRequestRepo.kt | 16 +++++++--- .../java/to/bitkit/repositories/PubkyRepo.kt | 5 +++ .../java/to/bitkit/viewmodels/AppViewModel.kt | 19 ++++++++--- ...aykitPaymentRequestRepoSubscriptionTest.kt | 4 +-- .../PaykitPaymentRequestRepoTest.kt | 11 +++++-- .../to/bitkit/repositories/PubkyRepoTest.kt | 28 ++++++++++++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 32 ++++++++++--------- gradle/libs.versions.toml | 2 +- 9 files changed, 92 insertions(+), 31 deletions(-) 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/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 40fa8b15e2..62a92fb7ab 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -252,6 +252,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( @@ -450,7 +456,7 @@ class PaykitPaymentRequestRepo @Inject constructor( } } - suspend fun refresh(syncPrivateMessages: Boolean = true): Result { + suspend fun refresh(mode: PaykitPaymentRequestRefreshMode = PaykitPaymentRequestRefreshMode.FULL): Result { val generation = stateGeneration.get() val expectedIdentity = activeIdentity return withContext(ioDispatcher) { @@ -460,7 +466,7 @@ class PaykitPaymentRequestRepo @Inject constructor( clearStateLocked() return@withLock } - runSuspendCatching { synchronizeLocked(generation, expectedIdentity, syncPrivateMessages) } + runSuspendCatching { synchronizeLocked(generation, expectedIdentity, mode) } .onFailure { discardExpiredRequestsLocked() } .getOrThrow() } @@ -986,10 +992,10 @@ class PaykitPaymentRequestRepo @Inject constructor( private suspend fun synchronizeLocked( generation: Long, expectedIdentity: String?, - syncPrivateMessages: Boolean = true, + mode: PaykitPaymentRequestRefreshMode = PaykitPaymentRequestRefreshMode.FULL, ) { - if (syncPrivateMessages) { - processPendingMessages() + if (mode == PaykitPaymentRequestRefreshMode.FULL) processPendingMessages() + if (mode != PaykitPaymentRequestRefreshMode.STORED) { paykitSdkService.receivePrivateMessagesFromLinkedPeers().also(::logIntakeFailures) } val now = clock.now() diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 99a6ccdbfa..f65a42a1f9 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -51,6 +51,7 @@ 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 @@ -434,6 +435,10 @@ class PubkyRepo @Inject constructor( val publicKey = pubkyService.importSession(savedSessionSecret).ensurePubkyPrefix() InitResult.Restored(publicKey) }.getOrElse { + if (it.isPaykitTemporarilyUnavailable()) { + Logger.warn("Deferred session restoration, keeping saved session", it, context = TAG) + return@getOrElse InitResult.RestorationFailed + } Logger.warn("Failed to restore paykit session, attempting re-sign-in", it, context = TAG) resolveSignedInSession(savedSessionSecret, storedSecretKeyHex ?: adoptedSecretKeyHex()) } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index c2044849af..2f8b715a7f 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -162,6 +162,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 @@ -878,10 +879,12 @@ class AppViewModel @Inject constructor( } } - private suspend fun refreshIncomingPaykitPaymentRequests(refreshMaintenance: Boolean = true) { + private suspend fun refreshIncomingPaykitPaymentRequests( + mode: PaykitPaymentRequestRefreshMode = PaykitPaymentRequestRefreshMode.FULL, + ) { if (!isPaykitEnabled.value || pubkyRepo.publicKey.value == null || !walletRepo.walletExists()) return - if (refreshMaintenance) paykitPaymentProofRepo.reconcile() - paykitPaymentRequestRepo.refresh(syncPrivateMessages = refreshMaintenance).onSuccess { + if (mode == PaykitPaymentRequestRefreshMode.FULL) paykitPaymentProofRepo.reconcile() + paykitPaymentRequestRepo.refresh(mode).onSuccess { activityRepo.backfillPaykitContacts() presentNextIncomingPaykitPaymentRequest() } @@ -916,7 +919,13 @@ class AppViewModel @Inject constructor( pubkyRepo.republishIdentityIfNeeded() privatePaykitRepo.refreshKnownSavedContactEndpoints("payment request polling") } - refreshIncomingPaykitPaymentRequests(refreshMaintenance) + refreshIncomingPaykitPaymentRequests( + if (refreshMaintenance) { + PaykitPaymentRequestRefreshMode.FULL + } else { + PaykitPaymentRequestRefreshMode.INBOX + }, + ) if (refreshMaintenance) refreshPaymentRequestTargets(force = true) } } @@ -941,7 +950,7 @@ class AppViewModel @Inject constructor( requestedPaymentRequestTags = persistentListOf() } viewModelScope.launch { - refreshIncomingPaykitPaymentRequests(refreshMaintenance = false) + refreshIncomingPaykitPaymentRequests(PaykitPaymentRequestRefreshMode.STORED) } } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt index 3eb037254c..d4ea382883 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt @@ -148,7 +148,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat ), ) - sut.refresh(syncPrivateMessages = false).getOrThrow() + sut.refresh(PaykitPaymentRequestRefreshMode.STORED).getOrThrow() verify(paykitSdkService, never()).processPendingPrivateMessages() verify(paykitSdkService, never()).receivePrivateMessagesFromLinkedPeers() @@ -216,7 +216,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat checking.await() whenever(paykitSdkService.allPaymentRequests(anyOrNull())) .thenReturn(listOf(record.copy(state = PaymentRequestLifecycleState.CANCELED))) - sut.refresh(syncPrivateMessages = false).getOrThrow() + sut.refresh(PaykitPaymentRequestRefreshMode.STORED).getOrThrow() checked.complete(Unit) assertTrue(authorization.await().isFailure) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index e195d6eeb2..a2a74e2527 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -144,20 +144,25 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `inbox refresh reads shared state without private message sync`() = test { + fun `refresh modes control message intake and outbound maintenance`() = test { val record = paymentRequestRecord() whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) - sut.refresh(syncPrivateMessages = false).getOrThrow() + 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).receivePrivateMessagesFromLinkedPeers() + verify(paykitSdkService, times(2)).receivePrivateMessagesFromLinkedPeers() } @Test diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 4d79911d4c..4fece61057 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -2311,6 +2311,34 @@ 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.initialize() + assertFalse(sut.isAuthenticated.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 `initialize should complete contacts load after contact fetch failure`() = test { val session = "saved_session" diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 05d49df7a6..ef03f804df 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -138,6 +138,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 @@ -745,7 +746,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `private message sync and identity republish follow maintenance intervals`() = test { + fun `outbound maintenance and identity republish follow maintenance intervals`() = test { enablePaykitUi() pubkyPublicKey.value = testPublicKey whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) @@ -753,19 +754,19 @@ class AppViewModelSendFlowTest : BaseUnitTest() { try { advanceTimeBy(30.seconds.inWholeMilliseconds) runCurrent() - verify(paykitPaymentRequestRepo, atLeast(2)).refresh(syncPrivateMessages = true) + 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(syncPrivateMessages = true) - verify(paykitPaymentRequestRepo, atLeast(1)).refresh(syncPrivateMessages = false) + verify(paykitPaymentRequestRepo, never()).refresh(PaykitPaymentRequestRefreshMode.FULL) + verify(paykitPaymentRequestRepo, atLeast(1)).refresh(PaykitPaymentRequestRefreshMode.INBOX) advanceTimeBy(1) runCurrent() verify(pubkyRepo).republishIdentityIfNeeded() - verify(paykitPaymentRequestRepo).refresh(syncPrivateMessages = true) + verify(paykitPaymentRequestRepo).refresh(PaykitPaymentRequestRefreshMode.FULL) } } finally { sut.stopPaykitPaymentRequestPolling() @@ -776,8 +777,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `slow inbox refresh counts toward maintenance deadline`() = test { enablePaykitUi() pubkyPublicKey.value = testPublicKey - whenever(paykitPaymentRequestRepo.refresh(true)).thenReturn(Result.success(Unit)) - whenever(paykitPaymentRequestRepo.refresh(false)).doSuspendableAnswer { + whenever(paykitPaymentRequestRepo.refresh(PaykitPaymentRequestRefreshMode.FULL)) + .thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(PaykitPaymentRequestRefreshMode.INBOX)).doSuspendableAnswer { kotlinx.coroutines.delay(25.seconds) Result.success(Unit) } @@ -815,7 +817,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { runCurrent() assertFalse(prepared.isCompleted) verify(privatePaykitRepo, never()).awaitContactPreparation() - verify(paykitPaymentRequestRepo).refresh(syncPrivateMessages = true) + verify(paykitPaymentRequestRepo).refresh(PaykitPaymentRequestRefreshMode.FULL) verify(paykitPaymentRequestRepo).refreshEligibleTargets(any(), eq(true)) } finally { prepared.complete(Unit) @@ -910,13 +912,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { try { runCurrent() - verify(paykitPaymentRequestRepo).refresh(syncPrivateMessages = true) + verify(paykitPaymentRequestRepo).refresh(PaykitPaymentRequestRefreshMode.FULL) clearInvocations(paykitPaymentRequestRepo, paykitPaymentProofRepo) advanceTimeBy(29.seconds.inWholeMilliseconds) runCurrent() - verify(paykitPaymentRequestRepo, atLeast(1)).refresh(syncPrivateMessages = false) - verify(paykitPaymentRequestRepo, never()).refresh(syncPrivateMessages = true) + verify(paykitPaymentRequestRepo, atLeast(1)).refresh(PaykitPaymentRequestRefreshMode.INBOX) + verify(paykitPaymentRequestRepo, never()).refresh(PaykitPaymentRequestRefreshMode.FULL) verify(paykitPaymentRequestRepo, never()).refreshEligibleTargets(any(), any()) verify(paykitPaymentProofRepo, never()).reconcile() @@ -1345,8 +1347,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.onPaykitSubscriptionNotificationTapped(testPublicKey, targetRequest.id) runCurrent() - verify(paykitPaymentRequestRepo).refresh(syncPrivateMessages = false) - verify(paykitPaymentRequestRepo, never()).refresh(syncPrivateMessages = true) + verify(paykitPaymentRequestRepo).refresh(PaykitPaymentRequestRefreshMode.STORED) + verify(paykitPaymentRequestRepo, never()).refresh(PaykitPaymentRequestRefreshMode.FULL) verify(paykitPaymentProofRepo, never()).reconcile() verify(privatePaykitRepo, never()).awaitContactPreparation() verify(privatePaykitRepo).beginPaymentRequest(targetRequest) @@ -7029,7 +7031,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.retryIncomingPaymentRequest(request.id) advanceUntilIdle() - verify(paykitPaymentRequestRepo).refresh(syncPrivateMessages = true) + verify(paykitPaymentRequestRepo).refresh(PaykitPaymentRequestRefreshMode.FULL) verify(privatePaykitRepo, atLeast(1)).beginPaymentRequest(request) assertEquals(request.id, sut.sendUiState.value.incomingPaymentRequestId) assertTrue(sut.currentSheet.value is Sheet.Send) @@ -7457,7 +7459,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { confirmCurrentPayment() verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue, "bitkit") - verify(paykitPaymentRequestRepo).refresh(syncPrivateMessages = true) + verify(paykitPaymentRequestRepo).refresh(PaykitPaymentRequestRefreshMode.FULL) } @Test diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bdd9530794..289f991884 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-rc60" } +paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc61" } 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" } From 172cb302abb6e7d2a2fa8f6bbefe123d558636bd Mon Sep 17 00:00:00 2001 From: benk10 Date: Sat, 3 Oct 2026 11:41:34 -0500 Subject: [PATCH 33/77] chore: update Paykit to rc62 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 289f991884..d221865021 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-rc61" } +paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc62" } 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" } From a29e32da074ffa9315edc0953855d45ccecae1df Mon Sep 17 00:00:00 2001 From: benk10 Date: Sun, 4 Oct 2026 18:29:05 -0500 Subject: [PATCH 34/77] perf: coalesce overlapping paykit refreshes --- .../repositories/PaykitPaymentRequestRepo.kt | 26 ++++- .../bitkit/repositories/PrivatePaykitRepo.kt | 3 +- .../bitkit/utils/SubscriptionClockOffset.kt | 2 +- ...aykitPaymentRequestRepoSubscriptionTest.kt | 19 +++- .../PaykitPaymentRequestRepoTest.kt | 104 ++++++++++++++++++ .../repositories/PrivatePaykitRepoTest.kt | 19 ++++ 6 files changed, 167 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 62a92fb7ab..0ae04dd798 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -285,6 +285,10 @@ class PaykitPaymentRequestRepo @Inject constructor( 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 completedRefreshMode = PaykitPaymentRequestRefreshMode.STORED private val repoScope = appScope(ioDispatcher, TAG) private var expirationJob: Job? = null private var cachedTargetContext: PaykitPaymentRequestTargetContext? = null @@ -456,9 +460,16 @@ class PaykitPaymentRequestRepo @Inject constructor( } } - suspend fun refresh(mode: PaykitPaymentRequestRefreshMode = PaykitPaymentRequestRefreshMode.FULL): Result { + suspend fun refresh(mode: PaykitPaymentRequestRefreshMode = PaykitPaymentRequestRefreshMode.FULL): Result = + refresh(mode, forceFresh = false) + + suspend fun refreshAfterStateChange(): Result = + refresh(PaykitPaymentRequestRefreshMode.FULL, forceFresh = true) + + private suspend fun refresh(mode: PaykitPaymentRequestRefreshMode, forceFresh: Boolean): Result { val generation = stateGeneration.get() val expectedIdentity = activeIdentity + val refreshVersion = completedRefreshVersion.get() return withContext(ioDispatcher) { runSuspendCatching { operationMutex.withLock { @@ -466,9 +477,22 @@ class PaykitPaymentRequestRepo @Inject constructor( clearStateLocked() return@withLock } + if (!isCurrentState(generation, expectedIdentity)) return@withLock + val proofVersion = paymentProofStore.backupStateVersion.value + val hasFreshSnapshot = completedRefreshVersion.get() != refreshVersion && + completedRefreshGeneration == generation && completedRefreshProofVersion == proofVersion + if (!forceFresh && hasFreshSnapshot && mode <= completedRefreshMode) { + return@withLock + } runSuspendCatching { synchronizeLocked(generation, expectedIdentity, mode) } .onFailure { discardExpiredRequestsLocked() } .getOrThrow() + if (isCurrentState(generation, expectedIdentity)) { + completedRefreshMode = mode + completedRefreshGeneration = generation + completedRefreshProofVersion = proofVersion + completedRefreshVersion.incrementAndGet() + } } }.onFailure { Logger.warn("Failed to refresh incoming Paykit payment requests", it, context = TAG) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 4b653149d7..230b5c8c9d 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -501,7 +501,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 { @@ -510,6 +509,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() } @@ -1633,7 +1633,6 @@ class PrivatePaykitRepo @Inject constructor( } private suspend fun hasPrivatePaymentAccessForCurrentProfile(): Boolean = runSuspendCatching { - pubkyService.currentPublicKey() ?: return@runSuspendCatching false paykitSdkService.hasPrivatePaymentAccess() }.getOrDefault(false) 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/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt index d4ea382883..99cf6dce91 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt @@ -21,6 +21,7 @@ import com.synonym.paykit.PubkyIdentityCapability import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceTimeBy @@ -108,6 +109,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( @@ -568,7 +570,7 @@ 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.allPaymentRequests(anyOrNull())).thenReturn(listOf(proposal), listOf(active)) @@ -585,8 +587,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")), diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index a2a74e2527..dba2bb3eaa 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -29,6 +29,8 @@ 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 @@ -88,6 +90,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { private val presentationStore = mock() private val diagnostics = mock() private val paymentProofStore = mock() + private val proofStateVersion = MutableStateFlow(0L) private val paymentProofRepo = mock() private val subscriptionNotificationScheduler = mock() private var schedulerOriginMillis = 0L @@ -121,6 +124,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { ).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( @@ -165,6 +169,106 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { verify(paykitSdkService, times(2)).receivePrivateMessagesFromLinkedPeers() } + @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() } + 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() + + sut.refresh().getOrThrow() + verify(paykitSdkService, times(2)).allPaymentRequests(anyOrNull()) + } + + @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.refresh() } + runCurrent() + resume.complete(Unit) + first.await().getOrThrow() + afterFailure.await().getOrThrow() + + assertEquals(requestId, sut.pendingRequests.value.single().id) + verify(paykitSdkService, times(2)).allPaymentRequests(anyOrNull()) + } + @Test fun `shared app destinations stay out of request UI and clear on identity switch`() = test { val address = PaykitReceivedPaymentContactsTest.ADDRESS diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index dc866e3d99..7fc6f53774 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -154,6 +154,25 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { App.currentActivity = null } + @Test + fun `handleOnchainActivity skips SDK access when no contact address was used`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) + whenever(addressReservationRepo.contactsWithUsedReservedAddresses()).thenReturn(emptyList()) + + sut.handleOnchainActivity().getOrThrow() + + verify(paykitSdkService, never()).hasPrivatePaymentAccess() + 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( From e8a7686cce9de508db0115033d81a73c3a410908 Mon Sep 17 00:00:00 2001 From: benk10 Date: Sun, 4 Oct 2026 18:29:10 -0500 Subject: [PATCH 35/77] fix: block deleted contacts without peer records --- .../main/java/to/bitkit/services/PaykitSdkService.kt | 2 +- .../java/to/bitkit/services/PaykitSdkServiceTest.kt | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index f0d24f2771..ca5b408466 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -664,7 +664,7 @@ class PaykitSdkService @Inject constructor( Logger.warn("Failed to withdraw private endpoints before contact deletion", it, context = TAG) } } - peers.forEach { handle.blockPeer(publicKey) } + handle.blockPeer(publicKey) handle.removeContact(publicKey) } } diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index 4917bd6c22..a0185b3490 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -506,13 +506,17 @@ class PaykitSdkServiceTest { @Test fun `deletion blocks the identity before removing the contact`() = runTest { - for (failBlock in listOf(false, true)) { + 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() whenever(sdk.contactRecord(RING_PUBKY)).thenReturn(contact) - val peer = contactPeer(LinkedPeerState.LINKED) - whenever(sdk.linkedPeers()).thenReturn(listOf(peer)) + whenever(sdk.linkedPeers()).thenReturn(listOfNotNull(state?.let { contactPeer(it) })) if (failBlock) { whenever(sdk.blockPeer(RING_PUBKY)) .thenThrow(IllegalStateException("storage failure")) From fbe136ee4dde00302114e64e51125c67ea3090cd Mon Sep 17 00:00:00 2001 From: benk10 Date: Sun, 4 Oct 2026 20:03:18 -0500 Subject: [PATCH 36/77] Batch private Paykit list withdrawal --- .../bitkit/repositories/PrivatePaykitRepo.kt | 51 +++++------ .../to/bitkit/services/PaykitSdkService.kt | 23 ++--- .../repositories/PrivatePaykitRepoTest.kt | 88 ++++++++++--------- .../bitkit/services/PaykitSdkServiceTest.kt | 42 ++++++++- 4 files changed, 120 insertions(+), 84 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 230b5c8c9d..a46f3bb516 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -1445,35 +1445,30 @@ class PrivatePaykitRepo @Inject constructor( publicKeys: Collection, linkedPublicKeys: Set, ): PrivateEndpointCleanupPreparation { - val failedPublicKeys = mutableSetOf() - val clearedRetryKeys = mutableListOf() - var firstError: Throwable? = null - publicKeys.forEach { publicKey -> - if (publicKey !in linkedPublicKeys && - state?.contacts?.get(publicKey)?.hasPublishedPrivatePaymentList != true - ) { - return@forEach - } - runSuspendCatching { - val report = paykitSdkService.clearPrivatePaymentList(publicKey) - ?: return@runSuspendCatching false - logPrivatePaymentListDeliveryFailures(report, "cleanup") - if (report.failedToQueue.isNotEmpty() || report.failedToDeliver.isNotEmpty()) { - throw PrivatePaykitError.PrivateUnavailable - } - true - }.onSuccess { - if (it) clearedRetryKeys += publicKey - }.onFailure { - Logger.warn( - "Failed to clear private Paykit endpoints for '${redacted(publicKey)}': ${it::class.simpleName}", - context = TAG, - ) - failedPublicKeys += publicKey - firstError = firstError ?: it - } + val cleanupKeys = publicKeys.filter { + it in linkedPublicKeys || state?.contacts?.get(it)?.hasPublishedPrivatePaymentList == true + } + if (cleanupKeys.isEmpty()) return PrivateEndpointCleanupPreparation(emptyList(), emptySet(), null) + + 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) { diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index ca5b408466..604de747a6 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -732,26 +732,29 @@ class PaykitSdkService @Inject constructor( } } - suspend fun clearPrivatePaymentList( - counterparty: 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) + val blockedPeers = handle.linkedPeers().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 = handle.identityStatus()?.publicKey if (publicKey != null) { val app = handle.paykitAppRegistry(publicKey)?.apps?.find { it.appId == "bitkit" } if (app?.capabilities?.privatePayments == false) return@withStateRevisionTracking null } - handle.clearPrivatePaymentListAndProcessOutbound(counterparty) + handle.syncPrivatePaymentListsWithReservationsAndProcessOutbound( + updates = updates, + clearUnlistedLinkedPeers = false, + ) } } } diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 7fc6f53774..4833cd342b 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -10,6 +10,7 @@ import com.synonym.paykit.LinkedPeerRecord import com.synonym.paykit.LinkedPeerState import com.synonym.paykit.PaykitException 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 @@ -136,7 +137,9 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { LinkedPeerHandshakeReport(it.getArgument(0), LinkedPeerState.LINKED, 1uL, null) } whenever { paykitSdkService.pendingOutboundPrivateCounterparties() }.thenReturn(emptyList()) - whenever { paykitSdkService.clearPrivatePaymentList(any()) }.thenReturn(privateListDeliveryReport()) + 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) } @@ -488,7 +491,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(cleanup.isSuccess, cleanup.exceptionOrNull().toString()) verifyBlocking(addressReservationRepo, never()) { currentOrRotatedAddress(any()) } verifyBlocking(paykitSdkService, never()) { syncPrivatePaymentListsWithReservations(any(), any()) } - verifyBlocking(paykitSdkService, never()) { clearPrivatePaymentList(any()) } + verifyBlocking(paykitSdkService, never()) { clearPrivatePaymentLists(any()) } } @Test @@ -508,14 +511,14 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(sut.disableSharingAndPruneUnsavedContactState(emptyList()).isFailure) assertTrue(cacheData.value.contacts.isEmpty()) assertTrue(cacheData.value.cleanupPending) - verify(paykitSdkService, never()).clearPrivatePaymentList(any()) + verify(paykitSdkService, never()).clearPrivatePaymentLists(any()) failLookup = false sut.retryPendingEndpointRemoval(emptyList()).getOrThrow() - verify(paykitSdkService).clearPrivatePaymentList(CONTACT_KEY) - verify(paykitSdkService, never()).clearPrivatePaymentList(OTHER_CONTACT_KEY) - verify(paykitSdkService, never()).clearPrivatePaymentList(recoveringPublicKey) + verify(paykitSdkService).clearPrivatePaymentLists(listOf(CONTACT_KEY)) + verify(paykitSdkService, never()).clearPrivatePaymentLists(listOf(OTHER_CONTACT_KEY)) + verify(paykitSdkService, never()).clearPrivatePaymentLists(listOf(recoveringPublicKey)) assertFalse(cacheData.value.cleanupPending) verify(publicPaykitRepo).syncPaykitApp() } @@ -614,7 +617,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { throw AppError("Delivery unavailable") } privateListDeliveryReport() - }.whenever(paykitSdkService).clearPrivatePaymentList(CONTACT_KEY) + }.whenever(paykitSdkService).clearPrivatePaymentLists(listOf(CONTACT_KEY)) val result = sut.disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) @@ -681,7 +684,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { val result = sut.disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) assertTrue(result.isSuccess) - verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY) } + verifyBlocking(paykitSdkService) { clearPrivatePaymentLists(listOf(CONTACT_KEY)) } verify(publicPaykitRepo).syncPaykitApp() assertTrue(cacheData.value.contacts.isEmpty()) } @@ -695,7 +698,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true).getOrThrow() settingsData.value = settingsData.value.copy(sharesPrivatePaykitEndpoints = false) - whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY) }.thenReturn( + whenever { paykitSdkService.clearPrivatePaymentLists(listOf(CONTACT_KEY)) }.thenReturn( privateListDeliveryReport( failedToQueue = listOf( PrivatePaymentListSyncChange( @@ -743,7 +746,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.retryPendingEndpointRemoval(listOf(CONTACT_KEY)).getOrThrow() - verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY) } + verifyBlocking(paykitSdkService) { clearPrivatePaymentLists(listOf(CONTACT_KEY)) } assertFalse(cacheData.value.cleanupPending) } @@ -777,7 +780,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { publicPaykitOnchainEnabled = true, ) sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) - whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY) }.thenReturn( + whenever { paykitSdkService.clearPrivatePaymentLists(listOf(CONTACT_KEY)) }.thenReturn( privateListDeliveryReport( failedToQueue = listOf( PrivatePaymentListSyncChange( @@ -803,7 +806,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { publicPaykitOnchainEnabled = true, ) sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) - whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY) } + whenever { paykitSdkService.clearPrivatePaymentLists(listOf(CONTACT_KEY)) } .thenReturn(privateListDeliveryReport(clearedCounterparties = listOf(CONTACT_KEY))) whenever { paykitSdkService.pendingOutboundPrivateCounterparties() } .thenReturn(listOf(CONTACT_KEY)) @@ -833,7 +836,7 @@ 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()) } + verifyBlocking(paykitSdkService, never()) { clearPrivatePaymentLists(any()) } } @Test @@ -846,7 +849,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { val result = sut.removePublishedEndpointsForCleanup("test") assertTrue(result.isSuccess, result.exceptionOrNull().toString()) - verify(paykitSdkService).clearPrivatePaymentList(CONTACT_KEY) + verify(paykitSdkService).clearPrivatePaymentLists(listOf(CONTACT_KEY)) verify(publicPaykitRepo).syncPaykitApp() verify(paykitSdkService, never()).ensureLinkWithPeer(any(), any()) verify(paykitSdkService, never()).processOutboundPrivateMessages(any()) @@ -881,8 +884,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { val result = sut.removePublishedEndpointsForCleanup("test") assertTrue(result.isSuccess, result.exceptionOrNull().toString()) - verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY) } - verifyBlocking(paykitSdkService) { clearPrivatePaymentList(OTHER_CONTACT_KEY) } + verifyBlocking(paykitSdkService) { clearPrivatePaymentLists(listOf(CONTACT_KEY, OTHER_CONTACT_KEY)) } verify(paykitSdkService).ensureLinkWithPeer(CONTACT_KEY) verify(paykitSdkService, never()).ensureLinkWithPeer(eq(OTHER_CONTACT_KEY), any()) verifyBlocking(paykitSdkService, atLeast(1)) { linkedPeers() } @@ -910,8 +912,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { val result = sut.retryPendingEndpointRemoval(emptyList()) assertTrue(result.isSuccess, result.exceptionOrNull().toString()) - verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY) } - verifyBlocking(paykitSdkService) { clearPrivatePaymentList(OTHER_CONTACT_KEY) } + verifyBlocking(paykitSdkService) { clearPrivatePaymentLists(listOf(CONTACT_KEY, OTHER_CONTACT_KEY)) } verifyBlocking(paykitSdkService, atLeast(1)) { linkedPeers() } assertTrue(cacheData.value.contacts.isEmpty()) assertTrue(cacheData.value.deletedContactCleanupPendingPublicKeys.isEmpty()) @@ -945,7 +946,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut = createSut() val cleanupStarted = CompletableDeferred() val resumeCleanup = CompletableDeferred() - whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY) } + whenever { paykitSdkService.clearPrivatePaymentLists(listOf(CONTACT_KEY)) } .doSuspendableAnswer { cleanupStarted.complete(Unit) resumeCleanup.await() @@ -974,31 +975,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(), - OTHER_CONTACT_KEY to cachedPublishedContact(), - ), - ) - sut = createSut() - whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY) }.thenReturn( - privateListDeliveryReport( - failedToQueue = listOf( - PrivatePaymentListSyncChange( - counterparty = CONTACT_KEY, - outboundMessageId = null, - error = mock(), - ), + 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 @@ -1897,6 +1900,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { queuedCounterparties: List = emptyList(), clearedCounterparties: List = emptyList(), failedToQueue: List = emptyList(), + failedToDeliver: List = emptyList(), ) = PrivatePaymentListDeliveryReport( queued = queuedCounterparties.map { PrivatePaymentListSyncChange( @@ -1913,7 +1917,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) }, failedToQueue = failedToQueue, - failedToDeliver = emptyList(), + failedToDeliver = failedToDeliver, ) private fun cachedPublishedContact() = PrivatePaykitContactCacheData( diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index a0185b3490..f6777b390d 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -17,6 +17,7 @@ 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.PrivateStreamIntakeReport import com.synonym.paykit.ProfileResolution import com.synonym.paykit.PubkyAuthCompanionClaim @@ -1174,8 +1175,8 @@ class PaykitSdkServiceTest { val sdk = mock() whenever(sdk.linkedPeers()).thenReturn(listOf(contactPeer(LinkedPeerState.BLOCKED))) val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } - assertNull(service.clearPrivatePaymentList(RING_PUBKY)) - verify(sdk, never()).clearPrivatePaymentListAndProcessOutbound(any()) + assertNull(service.clearPrivatePaymentLists(listOf(RING_PUBKY))) + verify(sdk, never()).syncPrivatePaymentListsWithReservationsAndProcessOutbound(any(), any()) } @Test @@ -1191,12 +1192,45 @@ class PaykitSdkServiceTest { ) val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } - assertNull(service.clearPrivatePaymentList(RING_PUBKY)) + assertNull(service.clearPrivatePaymentLists(listOf(RING_PUBKY))) - verify(sdk, never()).clearPrivatePaymentListAndProcessOutbound(any()) + 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 `initialization publishes the saved private sharing preference`() = runTest { for (enabled in listOf(false, true)) { From b4b21418eb94479ddf0d2a6f0c3c9b356b2d932d Mon Sep 17 00:00:00 2001 From: benk10 Date: Sun, 4 Oct 2026 20:40:30 -0500 Subject: [PATCH 37/77] fix: preserve Android security providers during crypto setup --- app/src/main/java/to/bitkit/utils/Crypto.kt | 33 +++++--------- .../test/java/to/bitkit/utils/CryptoTest.kt | 44 +++++++++++++++++++ 2 files changed, 55 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/to/bitkit/utils/Crypto.kt b/app/src/main/java/to/bitkit/utils/Crypto.kt index 30903e024c..92f59e016a 100644 --- a/app/src/main/java/to/bitkit/utils/Crypto.kt +++ b/app/src/main/java/to/bitkit/utils/Crypto.kt @@ -22,7 +22,6 @@ import java.security.KeyFactory import java.security.KeyPairGenerator import java.security.MessageDigest import java.security.SecureRandom -import java.security.Security import javax.crypto.Cipher import javax.crypto.KeyAgreement import javax.crypto.spec.GCMParameterSpec @@ -49,26 +48,16 @@ class Crypto @Inject constructor() { private val params = ECNamedCurveTable.getParameterSpec("secp256k1") private val transformation = "AES/GCM/NoPadding" - init { - // TODO move init to VM (to enable error handling on UI)? - try { - val provider = Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) - when { - provider == null -> Security.addProvider(BouncyCastleProvider()) - provider::class.java != BouncyCastleProvider::class.java -> { - // We substitute the outdated BC provider registered in Android - Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME) - Security.insertProviderAt(BouncyCastleProvider(), 1) - } - } - } catch (e: Exception) { - throw CryptoError.SecurityProviderSetupFailed() - } + // 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) } @@ -91,14 +80,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) @@ -120,7 +109,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( @@ -136,7 +125,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) { @@ -175,7 +164,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/test/java/to/bitkit/utils/CryptoTest.kt b/app/src/test/java/to/bitkit/utils/CryptoTest.kt index 482588db7c..48eff077b5 100644 --- a/app/src/test/java/to/bitkit/utils/CryptoTest.kt +++ b/app/src/test/java/to/bitkit/utils/CryptoTest.kt @@ -8,8 +8,12 @@ import to.bitkit.ext.fromHex import to.bitkit.ext.toBase64 import to.bitkit.ext.toHex import to.bitkit.fcm.EncryptedNotification +import java.security.Provider +import java.security.Security import kotlin.test.assertContentEquals import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertSame class CryptoTest { private lateinit var sut: Crypto @@ -19,6 +23,36 @@ class CryptoTest { sut = Crypto() } + @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() @@ -52,6 +86,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(), @@ -106,5 +142,13 @@ 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) + } } } From e4e5f750cea648d9f8161a3eb309a81a65f81546 Mon Sep 17 00:00:00 2001 From: benk10 Date: Sun, 4 Oct 2026 21:33:10 -0500 Subject: [PATCH 38/77] Avoid redundant Paykit startup and failed-write reads --- .../bitkit/repositories/PublicPaykitRepo.kt | 1 + .../to/bitkit/services/PaykitSdkService.kt | 27 +++++---- .../repositories/PublicPaykitRepoTest.kt | 34 +++++++++++ .../services/PaykitBackupStateTrackingTest.kt | 57 ++++++++++++------- .../bitkit/services/PaykitSdkServiceTest.kt | 54 ++++++++++++++---- 5 files changed, 131 insertions(+), 42 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt index ce09246cba..acf1a9513d 100644 --- a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -234,6 +234,7 @@ class PublicPaykitRepo @Inject constructor( requireEndpoint: Boolean = false, ): Result = withContext(ioDispatcher) { runSuspendCatching { + pubkyRepo.isRestoringSession.first { !it } val desired = buildWalletEndpoints( refresh = false, forceRefreshLightning = forceRefreshLightning, diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 604de747a6..b520a0e550 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -306,7 +306,7 @@ class PaykitSdkService @Inject constructor( operationLock.withLock { refreshPaykitKey(force = true) var handle = handle() - try { + val identityStatus = try { handle.initialize() } catch (e: PaykitException.Identity) { invalidatePaykitKeyIfNeeded(e) @@ -326,7 +326,7 @@ class PaykitSdkService @Inject constructor( sessionProvider.resumeStoredSessionAccess() } } - publishAppIfLiveSessionAvailable(handle) + publishAppIfLiveSessionAvailable(handle, identityStatus) } isSetup.complete(Unit) } catch (t: Throwable) { @@ -683,7 +683,7 @@ class PaykitSdkService @Inject constructor( isSetup.await() operationLock.withLock { withStateRevisionTracking { handle -> - val capabilities = appCapabilities(handle) + val capabilities = appCapabilities(handle.identityStatus()) handle.publishPaykitApp( displayName = "Bitkit", capabilities = capabilities.copy( @@ -1155,11 +1155,11 @@ class PaykitSdkService @Inject constructor( resetRuntime() refreshPaykitKey() val handle = handle() - handle.initialize() + val identityStatus = handle.initialize() if (result.sessionAccess.exportLocalSecretKey() != null) { handle.publishPaykitNoiseKeyAuthorization() } - publishAppIfLiveSessionAvailable(handle) + publishAppIfLiveSessionAvailable(handle, identityStatus) launchIdentityRepublish(publicKey = result.publicKey) } @@ -1170,9 +1170,9 @@ class PaykitSdkService @Inject constructor( notifyBackupStateChanged() } - private suspend fun publishAppIfLiveSessionAvailable(handle: PaykitSdk) { + private suspend fun publishAppIfLiveSessionAvailable(handle: PaykitSdk, identityStatus: IdentityStatus?) { runSuspendCatching { - val capabilities = appCapabilities(handle) + val capabilities = appCapabilities(identityStatus) if (capabilities.privatePayments) { handle.publishPaykitApp( "Bitkit", @@ -1185,9 +1185,9 @@ class PaykitSdkService @Inject constructor( } } - private suspend fun appCapabilities(handle: PaykitSdk): PaykitAppCapabilities { + private fun appCapabilities(identityStatus: IdentityStatus?): PaykitAppCapabilities { val hasPrivatePaymentAccess = - handle.identityStatus()?.capability == PubkyIdentityCapability.PRIVATE_LINK_CAPABLE + identityStatus?.capability == PubkyIdentityCapability.PRIVATE_LINK_CAPABLE return PaykitAppCapabilities( privatePayments = hasPrivatePaymentAccess, paymentRequests = hasPrivatePaymentAccess, @@ -1391,16 +1391,21 @@ internal suspend fun withPaykitBackupStateTracking( operation().also { succeeded = true } } finally { withContext(NonCancellable) { + if (!succeeded) { + onSnapshot(null) + onChange() + return@withContext + } val nextStateRevision = runSuspendCatching { readStateRevision() }.getOrNull() val unchangedRevision = previousStateRevision?.takeIf { it == nextStateRevision } - if (succeeded && unchangedRevision != null && previousRevision != null) { + if (unchangedRevision != null && previousRevision != null) { onSnapshot(PaykitBackupStateSnapshot(unchangedRevision, previousRevision)) return@withContext } val nextRevision = runSuspendCatching { readRevision() }.getOrNull() val stateRevision = runSuspendCatching { readStateRevision() }.getOrNull() onSnapshot( - if (succeeded && stateRevision != null && nextRevision != null) { + if (stateRevision != null && nextRevision != null) { PaykitBackupStateSnapshot(stateRevision, nextRevision) } else { null diff --git a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt index 6ab7c4ceb2..a8eb46f831 100644 --- a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt @@ -6,6 +6,8 @@ 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 @@ -16,6 +18,7 @@ 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 @@ -25,6 +28,7 @@ import to.bitkit.services.PaykitResolvedPaymentEndpoint 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 +52,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 +63,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)) @@ -108,6 +115,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) } + 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( diff --git a/app/src/test/java/to/bitkit/services/PaykitBackupStateTrackingTest.kt b/app/src/test/java/to/bitkit/services/PaykitBackupStateTrackingTest.kt index 24a8e57509..b2e4469886 100644 --- a/app/src/test/java/to/bitkit/services/PaykitBackupStateTrackingTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitBackupStateTrackingTest.kt @@ -8,6 +8,7 @@ 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 @@ -40,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" @@ -54,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" @@ -80,6 +92,8 @@ class PaykitBackupStateTrackingTest : BaseUnitTest() { assertTrue(job.isCancelled) assertEquals("after", revision) assertEquals(1, changes) + assertEquals(0, reads) + assertNull(snapshot) } @Test @@ -136,26 +150,27 @@ class PaykitBackupStateTrackingTest : BaseUnitTest() { } @Test - fun `failed writes recheck backup content even when the local revision is unchanged`() = test { - var snapshot: PaykitBackupStateSnapshot? = PaykitBackupStateSnapshot("state", "before") - var reads = 0 - var changes = 0 - val failure = AppError("Write outcome unknown") - val thrown = assertFailsWith { - withPaykitBackupStateTracking( - readRevision = { - reads++ - "after" - }, - readStateRevision = { "state" }, - cachedSnapshot = snapshot, - onSnapshot = { snapshot = it }, - onChange = { changes++ }, - ) { throw failure } + 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 changes = 0 + val thrown = assertFailsWith(failure::class) { + withPaykitBackupStateTracking( + readRevision = { + reads++ + "before" + }, + readStateRevision = { "state" }, + cachedSnapshot = snapshot, + onSnapshot = { snapshot = it }, + onChange = { changes++ }, + ) { throw failure } + } + assertSame(failure, thrown) + assertEquals(0, reads) + assertEquals(1, changes) + assertNull(snapshot) } - assertSame(failure, thrown) - assertEquals(1, reads) - assertEquals(1, changes) - assertNull(snapshot) } } diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index f6777b390d..3705609000 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -454,14 +454,10 @@ class PaykitSdkServiceTest { val bytes = ByteArray(32) { 1 } 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.exportPaykitIdentitySecretKey()).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 { @@ -478,7 +474,9 @@ class PaykitSdkServiceTest { var handlesCreated = 0 val store = mock() whenever(store.data).thenReturn(flowOf(PubkyStoreData())) - val service = PaykitSdkService(mock(), keychain, store, settingsStore = mock()) { + val settingsStore = mock() + whenever(settingsStore.data).thenReturn(flowOf(SettingsData(sharesPrivatePaykitEndpoints = false))) + val service = PaykitSdkService(mock(), keychain, store, settingsStore = settingsStore) { handlesCreated++ sdk } @@ -491,7 +489,9 @@ class PaykitSdkServiceTest { 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) } @@ -1237,7 +1237,7 @@ class PaykitSdkServiceTest { val sdk = mock() val settingsStore = mock() whenever(settingsStore.data).thenReturn(flowOf(SettingsData(sharesPrivatePaykitEndpoints = enabled))) - whenever(sdk.identityStatus()).thenReturn( + whenever(sdk.initialize()).thenReturn( IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE), ) val service = PaykitSdkService( @@ -1252,9 +1252,43 @@ class PaykitSdkServiceTest { 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( gates: Map>, started: MutableList, From e7aad3724570b54b377c81873a049f6761e5f02a Mon Sep 17 00:00:00 2001 From: benk10 Date: Sun, 4 Oct 2026 23:29:32 -0500 Subject: [PATCH 39/77] Skip linked-peer handshake advancement during drains --- .../bitkit/repositories/PrivatePaykitRepo.kt | 3 + .../repositories/PrivatePaykitRepoTest.kt | 66 ++++++++++++++++++- 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index a46f3bb516..552995cb7f 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -1045,9 +1045,12 @@ class PrivatePaykitRepo @Inject constructor( if (retryKeys.isEmpty()) return runSuspendCatching { val generation = preparationGeneration + val alreadyLinkedKeys = paykitSdkService.linkedPeers().filter { it.state == LinkedPeerState.LINKED } + .mapNotNull { normalizedPublicKey(it.counterparty) }.toSet() retryKeys.forEach { retryKey -> currentCoroutineContext().ensureActive() if (generation != preparationGeneration) return@runSuspendCatching + if (retryKey in alreadyLinkedKeys) return@forEach if (retryKey !in knownSavedContactKeys) return@forEach if (unavailableLinkRetryAt[retryKey]?.let { it > clock.now() } == true) return@forEach runSuspendCatching { diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 4833cd342b..5d04e544b4 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -379,6 +379,69 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { } } + @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())).doSuspendableAnswer { awaitCancellation() } + + val preparation = async { sut.prepareSavedContacts(keys) } + try { + runCurrent() + assertTrue(preparation.isCompleted) + preparation.await().getOrThrow() + advanceTimeBy(1_000) + runCurrent() + + verify(paykitSdkService, never()).ensureLinkWithPeer(any(), any()) + keys.forEach { + verify(paykitSdkService, times(2)).processOutboundPrivateMessages(it) + verify(paykitSdkService, times(2)).receivePrivateMessages(it) + } + } finally { + preparation.cancel() + sut.closeAndClear() + } + } + + @Test + 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() + } + + try { + sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() + verify(paykitSdkService, never()).ensureLinkWithPeer(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 `private message drain isolates send and receive failures per peer`() = test { val keys = listOf(CONTACT_KEY, OTHER_CONTACT_KEY) @@ -885,8 +948,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(result.isSuccess, result.exceptionOrNull().toString()) verifyBlocking(paykitSdkService) { clearPrivatePaymentLists(listOf(CONTACT_KEY, OTHER_CONTACT_KEY)) } - verify(paykitSdkService).ensureLinkWithPeer(CONTACT_KEY) - verify(paykitSdkService, never()).ensureLinkWithPeer(eq(OTHER_CONTACT_KEY), any()) + verify(paykitSdkService, never()).ensureLinkWithPeer(any(), any()) verifyBlocking(paykitSdkService, atLeast(1)) { linkedPeers() } verifyBlocking(paykitSdkService, times(3)) { pendingOutboundPrivateCounterparties() } verify(paykitSdkService).processOutboundPrivateMessages(CONTACT_KEY) From 0eaae43e96fd04495708fe30a55664a0c0d1bc50 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 5 Oct 2026 02:56:32 -0500 Subject: [PATCH 40/77] Document foreground Paykit inbox polling --- journeys/payment-requests/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index 50dd31091e..f4e092935b 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -26,11 +26,11 @@ Rejected fixture shapes stay in unit tests because Bitkit intentionally does not ## Foreground synchronization -Bitkit checks shared request state every 10 seconds while foregrounded and online. -Private-message intake, outbound retries, endpoint publication, and target discovery run on startup, +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. New peer messages may wait for maintenance plus synchronization time. +start catch-up rounds. Incoming messages wait for the next poll plus synchronization time. ## Accepting install From a38f5150de26c666169c473bb2c14e33ace48486 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 5 Oct 2026 05:06:37 -0500 Subject: [PATCH 41/77] fix: refresh Paykit requests after state changes --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 16 ++++-- .../PaykitPaymentRequestRepoTest.kt | 57 +++++++++++++++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 45 +++++++++++++-- .../definite-pre-broadcast-retry.xml | 2 +- .../delete-and-readd-contact.xml | 2 +- 5 files changed, 110 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 2f8b715a7f..588bebf45a 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -776,7 +776,7 @@ class AppViewModel @Inject constructor( privatePaykitRepo.pruneUnsavedContactState(state.contactKeys) .onFailure { Logger.warn("Failed to prune private Paykit contact state", it, context = TAG) } if (!PubkyPublicKeyFormat.matches(pubkyRepo.publicKey.value, state.publicKey)) return - refreshIncomingPaykitPaymentRequests() + refreshIncomingPaykitPaymentRequests(forceFresh = true) refreshPaymentRequestTargets(force = true) lastPrivatePaykitContactKeys = state.contactKeys } finally { @@ -802,7 +802,7 @@ class AppViewModel @Inject constructor( Logger.warn("Failed to reconcile private Paykit receive indexes for '$reason'", it, context = TAG) } privatePaykitRepo.refreshKnownSavedContactEndpoints(reason, forceRefreshLightning = forceRefreshLightning) - refreshIncomingPaykitPaymentRequests() + refreshIncomingPaykitPaymentRequests(forceFresh = true) refreshPaymentRequestTargets(force = true) } @@ -881,10 +881,16 @@ class AppViewModel @Inject constructor( private suspend fun refreshIncomingPaykitPaymentRequests( mode: PaykitPaymentRequestRefreshMode = PaykitPaymentRequestRefreshMode.FULL, + forceFresh: Boolean = false, ) { if (!isPaykitEnabled.value || pubkyRepo.publicKey.value == null || !walletRepo.walletExists()) return if (mode == PaykitPaymentRequestRefreshMode.FULL) paykitPaymentProofRepo.reconcile() - paykitPaymentRequestRepo.refresh(mode).onSuccess { + val result = if (forceFresh) { + paykitPaymentRequestRepo.refreshAfterStateChange() + } else { + paykitPaymentRequestRepo.refresh(mode) + } + result.onSuccess { activityRepo.backfillPaykitContacts() presentNextIncomingPaykitPaymentRequest() } @@ -5606,7 +5612,7 @@ class AppViewModel @Inject constructor( } clearActiveContactPaymentContext() viewModelScope.launch { - refreshIncomingPaykitPaymentRequests() + refreshIncomingPaykitPaymentRequests(forceFresh = true) openIncomingPaymentRequestWithTags(id, _sendUiState.value.selectedTags) } } @@ -5622,7 +5628,7 @@ class AppViewModel @Inject constructor( clearActiveContactPaymentContext() viewModelScope.launch { try { - refreshIncomingPaykitPaymentRequests() + refreshIncomingPaykitPaymentRequests(forceFresh = true) val request = paykitPaymentRequestRepo.pendingRequest(id) ?: run { toast(PaykitPaymentRequestError.RequestUnavailable) return@launch diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index dba2bb3eaa..5b26f1e027 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -269,6 +269,63 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { verify(paykitSdkService, times(2)).allPaymentRequests(anyOrNull()) } + @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() + } + + @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 stay out of request UI and clear on identity switch`() = test { val address = PaykitReceivedPaymentContactsTest.ADDRESS diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index ef03f804df..6f1295a8c9 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -428,6 +428,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(paykitPaymentRequestRepo.eligibleTargets).thenReturn(MutableStateFlow(emptyList())) whenever(paykitPaymentRequestRepo.isCreatingRequest).thenReturn(MutableStateFlow(false)) whenever { paykitPaymentRequestRepo.refreshEligibleTargets(any(), any()) }.thenReturn(Result.success(Unit)) + whenever { paykitPaymentRequestRepo.refreshAfterStateChange() }.thenReturn(Result.success(Unit)) whenever(paykitPaymentRequestRepo.automaticPendingRequests()).thenAnswer { pendingPaykitPaymentRequests.value.filterNot { it.id in surfacedPaykitPaymentRequestIds } } @@ -7023,7 +7024,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pendingPaykitPaymentRequests.value = emptyList() stubOpenedPaymentRequest(request, lnurl.uri) whenever(coreService.decode(lnurl.uri)).thenReturn(Scanner.LnurlPay(lnurl)) - whenever(paykitPaymentRequestRepo.refresh(any())).doSuspendableAnswer { + whenever(paykitPaymentRequestRepo.refreshAfterStateChange()).doSuspendableAnswer { pendingPaykitPaymentRequests.value = listOf(request) Result.success(Unit) } @@ -7031,7 +7032,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.retryIncomingPaymentRequest(request.id) advanceUntilIdle() - verify(paykitPaymentRequestRepo).refresh(PaykitPaymentRequestRefreshMode.FULL) + verify(paykitPaymentRequestRepo).refreshAfterStateChange() verify(privatePaykitRepo, atLeast(1)).beginPaymentRequest(request) assertEquals(request.id, sut.sendUiState.value.incomingPaymentRequestId) assertTrue(sut.currentSheet.value is Sheet.Send) @@ -7592,8 +7593,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(any())).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) ) @@ -7617,7 +7625,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) } @@ -8651,7 +8664,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pubkyContacts.value = listOf(contact) pubkyContactsLoadVersion.value = 1L advanceUntilIdle() - clearInvocations(privatePaykitRepo) + clearInvocations(privatePaykitRepo, paykitPaymentRequestRepo) pubkyContacts.value = emptyList() pubkyContactsLoadVersion.value = 2L @@ -8660,6 +8673,28 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(privatePaykitRepo).removeSavedContact(contact.publicKey) verify(privatePaykitRepo).scheduleSavedContactPreparation(emptySet()) verify(privatePaykitRepo).pruneUnsavedContactState(emptySet()) + inOrder(privatePaykitRepo, paykitPaymentRequestRepo).apply { + verify(privatePaykitRepo).removeSavedContact(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 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 debc8ab0cc..ce519ff239 100644 --- a/journeys/payment-requests/delete-and-readd-contact.xml +++ b/journeys/payment-requests/delete-and-readd-contact.xml @@ -8,7 +8,7 @@ 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 From b752e7d1f72093b85b0e031b70aac2716b918e1f Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 5 Oct 2026 06:24:48 -0500 Subject: [PATCH 42/77] fix: preserve Paykit cleanup and request presentation state --- .../ContactPaymentSettingsRepo.kt | 27 ++++++++++ .../PaykitReceivedPaymentContacts.kt | 11 +++- .../bitkit/repositories/PrivatePaykitRepo.kt | 10 ++-- .../to/bitkit/services/PaykitSdkService.kt | 16 +++++- app/src/main/java/to/bitkit/ui/ContentView.kt | 23 +++++++++ .../java/to/bitkit/viewmodels/AppViewModel.kt | 49 +++++++++--------- .../to/bitkit/viewmodels/SettingsViewModel.kt | 39 +------------- .../ContactPaymentSettingsRepoTest.kt | 5 +- .../PaykitReceivedPaymentContactsTest.kt | 20 +++++++- .../repositories/PrivatePaykitRepoTest.kt | 19 +++++-- .../bitkit/services/PaykitSdkServiceTest.kt | 51 +++++++++++++++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 12 ++++- .../viewmodels/SettingsViewModelTest.kt | 12 ++++- .../issuer-interoperability.xml | 2 + .../requested-resolution-failure.xml | 2 + 15 files changed, 218 insertions(+), 80 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt b/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt index fa2fd97fd1..8f4aaacdbe 100644 --- a/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt @@ -10,6 +10,8 @@ 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 javax.inject.Inject @@ -43,6 +45,31 @@ class ContactPaymentSettingsRepo @Inject constructor( } } + 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) + 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 { val previous = settingsStore.data.first() val canUsePrivateContactPayments = privatePaykitRepo.hasPrivatePaymentAccess() diff --git a/app/src/main/java/to/bitkit/repositories/PaykitReceivedPaymentContacts.kt b/app/src/main/java/to/bitkit/repositories/PaykitReceivedPaymentContacts.kt index 4e5a7f5f0e..680103b9f4 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitReceivedPaymentContacts.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitReceivedPaymentContacts.kt @@ -33,7 +33,7 @@ internal data class PaykitReceivedPaymentContacts private constructor( .filterKeys(terms.acceptedPaymentEndpointIdentifiers::contains) for ((identifier, payload) in acceptedEndpoints) { val endpoint = PublicPaykitRepo.parseEndpoint(identifier, payload, network) ?: continue - val value = endpoint.value + val value = normalizeAddressCase(endpoint.value) runCatching { when { endpoint.methodId.isOnchain && isValidAddress(value, network) -> { @@ -68,6 +68,15 @@ internal data class PaykitReceivedPaymentContacts private constructor( 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 diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 552995cb7f..eae0605220 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -1379,15 +1379,15 @@ class PrivatePaykitRepo @Inject constructor( runSuspendCatching { val peers = paykitSdkService.linkedPeers() val linkedPublicKeys = peers - .filter { it.state != LinkedPeerState.NOT_LINKED } + .filter { + it.state == LinkedPeerState.LINKED || it.state == LinkedPeerState.LINKING || + it.state == LinkedPeerState.RECOVERY_REQUIRED + } .mapNotNull { normalizedPublicKey(it.counterparty) } .toSet() - val discoveredPublicKeys = peers - .filter { it.state == LinkedPeerState.LINKED } - .mapNotNull { normalizedPublicKey(it.counterparty) } val keys = publicKeys ?: ( knownSavedContactKeys + ensureState().contacts.keys + pendingDeletedContactCleanupPublicKeys() + - discoveredPublicKeys + linkedPublicKeys ) val normalizedBatch = normalizedPublicKeyBatch(keys) discardInvalidCleanupKeys(normalizedBatch.invalidKeys) diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index b520a0e550..ab7e4bd0a0 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -739,7 +739,8 @@ class PaykitSdkService @Inject constructor( isSetup.await() return operationLock.withLock { withStateRevisionTracking { handle -> - val blockedPeers = handle.linkedPeers().filter { it.state == LinkedPeerState.BLOCKED } + val peers = handle.linkedPeers() + val blockedPeers = peers.filter { it.state == LinkedPeerState.BLOCKED } val updates = counterparties.filterNot { counterparty -> blockedPeers.any { PubkyPublicKeyFormat.matches(it.counterparty, counterparty) @@ -751,6 +752,19 @@ class PaykitSdkService @Inject constructor( 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 { + handle.ensureLinkWithPeer(update.counterparty, 1u) + }.onFailure { + Logger.warn("Failed to recover private Paykit link before withdrawal", it, context = TAG) + } + } + } handle.syncPrivatePaymentListsWithReservationsAndProcessOutbound( updates = updates, clearUnlistedLinkedPeers = false, diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index cf3e60adac..7d025bdb0f 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -7,8 +7,12 @@ import android.content.Intent import android.os.Build import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.DrawerState import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api @@ -28,7 +32,11 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.Lifecycle @@ -73,6 +81,7 @@ import to.bitkit.ui.components.BottomSheetOverlayHost import to.bitkit.ui.components.BottomSheetOverlayState import to.bitkit.ui.components.DefaultSheetContainerColor import to.bitkit.ui.components.DrawerMenu +import to.bitkit.ui.components.GradientCircularProgressIndicator import to.bitkit.ui.components.Sheet import to.bitkit.ui.components.SheetHandlePlacement import to.bitkit.ui.components.SheetHost @@ -483,6 +492,7 @@ fun ContentView( val isPaykitEnabled by settingsViewModel.isPaykitEnabled.collectAsStateWithLifecycle() val showWidgets by settingsViewModel.showWidgets.collectAsStateWithLifecycle() val currentSheet by appViewModel.currentSheet.collectAsStateWithLifecycle() + val requestedPaymentRequestId by appViewModel.requestedPaymentRequestId.collectAsStateWithLifecycle() val isCreatingPaymentRequest by appViewModel.isCreatingPaymentRequest.collectAsStateWithLifecycle() val hwSendViewModel = hiltViewModel() val hwSendUiState by hwSendViewModel.uiState.collectAsStateWithLifecycle() @@ -774,6 +784,19 @@ fun ContentView( ) BottomSheetOverlayHost(state = bottomSheetOverlayState) + + if (requestedPaymentRequestId != null && currentSheet == null) { + val description = stringResource(R.string.wallet__payment_request) + GradientCircularProgressIndicator( + modifier = Modifier + .align(Alignment.Center) + .background(Colors.Gray6, RoundedCornerShape(8.dp)) + .padding(24.dp) + .size(32.dp) + .semantics { contentDescription = description } + .testTag("PaymentRequestPreparing") + ) + } } } } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 588bebf45a..8d0b97221a 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -380,7 +380,8 @@ class AppViewModel @Inject constructor( 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 var requestedPaymentRequest: PaykitPaymentRequest? = null private var shouldRestorePaymentRequestSheet = false private var preparedContactPaymentContext: ContactPaymentContext? = null @@ -949,7 +950,7 @@ class AppViewModel @Inject constructor( val currentIdentity = pubkyRepo.publicKey.value if (currentIdentity != null && !PubkyPublicKeyFormat.matches(currentIdentity, payerIdentity)) return invalidatePaymentRequestPresentation() - requestedPaymentRequestId = requestId + _requestedPaymentRequestId.update { requestId } requestedPaymentRequest = null shouldRestorePaymentRequestSheet = false requestedPaymentRequestIdentity = payerIdentity @@ -1020,7 +1021,7 @@ class AppViewModel @Inject constructor( private suspend fun presentNextIncomingPaykitPaymentRequest() { if (isPresentingPaymentRequest || isPaymentRequestPresentationBlocked()) return - if (requestedPaymentRequestId == null) { + if (requestedPaymentRequestId.value == null) { paykitPaymentRequestRepo.automaticSubscriptionProposals().firstOrNull()?.let { showSheet(Sheet.Subscription(SubscriptionRoute.Review(it.id))) return @@ -1053,7 +1054,7 @@ class AppViewModel @Inject constructor( } private fun paymentRequestsForPresentation(): List? { - val requestedId = requestedPaymentRequestId + val requestedId = requestedPaymentRequestId.value return if (requestedId == null) { paykitPaymentRequestRepo.automaticPendingRequests().filter { request -> !paykitPaymentRequestRepo.isProcessing(request) && @@ -1112,7 +1113,7 @@ 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) { invalidatePaymentRequestPresentation() clearRequestedPaymentRequest() } @@ -1136,7 +1137,7 @@ class AppViewModel @Inject constructor( publicKey = request.counterparty, privatePaymentContext = result.privatePaymentContext, incomingPaymentRequest = request, - selectedTags = requestedPaymentRequestTags.takeIf { requestedPaymentRequestId == request.id } + selectedTags = requestedPaymentRequestTags.takeIf { requestedPaymentRequestId.value == request.id } ?: persistentListOf(), ) return true @@ -1147,7 +1148,7 @@ 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++ @@ -1167,7 +1168,7 @@ class AppViewModel @Inject constructor( 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, @@ -1176,7 +1177,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", @@ -1201,7 +1202,7 @@ class AppViewModel @Inject constructor( } 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), @@ -1222,9 +1223,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() @@ -1246,9 +1247,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,7 +1277,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() } @@ -1305,7 +1306,7 @@ class AppViewModel @Inject constructor( } private fun clearRequestedPaymentRequest() { - requestedPaymentRequestId = null + _requestedPaymentRequestId.update { null } requestedPaymentRequest = null shouldRestorePaymentRequestSheet = false requestedPaymentRequestIdentity = null @@ -3224,7 +3225,7 @@ class AppViewModel @Inject constructor( if (!retryIncomingRequest) { paymentRequestPresentationGeneration++ - if (requestedPaymentRequestId == interruptedRequest.id) { + if (requestedPaymentRequestId.value == interruptedRequest.id) { clearRequestedPaymentRequest() } clearPaymentRequestPresentationRetry(interruptedRequest.id) @@ -3233,7 +3234,7 @@ class AppViewModel @Inject constructor( } if ( - requestedPaymentRequestId == interruptedRequest.id || + requestedPaymentRequestId.value == interruptedRequest.id || paykitPaymentRequestRepo.automaticPendingRequests().any { it.id == interruptedRequest.id } ) { deferPaymentRequestPresentation(interruptedRequest, failureReason) @@ -3270,7 +3271,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) @@ -5576,13 +5577,13 @@ class AppViewModel @Inject constructor( fun openIncomingPaymentRequestWithTags(id: PaykitPaymentRequestId, tags: List) { val request = paykitPaymentRequestRepo.pendingRequest(id) ?: return - if (paykitPaymentRequestRepo.isProcessing(request) || requestedPaymentRequestId != null) { + if (paykitPaymentRequestRepo.isProcessing(request) || requestedPaymentRequestId.value != null) { toast(PaykitPaymentRequestError.OperationInProgress) return } invalidatePaymentRequestPresentation() clearPaymentRequestPresentationRetry(id) - requestedPaymentRequestId = id + _requestedPaymentRequestId.update { id } requestedPaymentRequest = request shouldRestorePaymentRequestSheet = _currentSheet.value is Sheet.PaymentRequests requestedPaymentRequestTags = tags.filter(String::isNotBlank).distinct().toImmutableList() @@ -5657,7 +5658,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 } diff --git a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt index 8ca40d853d..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,39 +270,10 @@ 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 } - - 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) } - - 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.syncPaykitApp(privateSharingEnabled = false) - .onFailure { - settingsStore.update { settings -> settings.copy(publicPaykitCleanupPending = true) } - Logger.warn("Failed to update Paykit app capabilities after disabling Paykit UI", it, context = TAG) - } - } } val isPinEnabled = settingsStore.data.map { it.isPinEnabled } diff --git a/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt index 98dff240aa..01081d618f 100644 --- a/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt @@ -197,7 +197,8 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { @Test fun `enabling waits for both withdrawal phases even when cleanup fails`() = test { val cleanupResults = listOf(Result.success(Unit), Result.failure(ContactPaymentSettingsTestError("withdrawal"))) - for (cleanupResult in cleanupResults) { + 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() @@ -213,7 +214,7 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { } val sut = createSut() - val disable = async { sut.setEnabled(false) } + val disable = async { if (disablePaykit) sut.disablePaykit() else sut.setEnabled(false) } runCurrent() val enable = async { sut.setEnabled(true) } runCurrent() diff --git a/app/src/test/java/to/bitkit/repositories/PaykitReceivedPaymentContactsTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitReceivedPaymentContactsTest.kt index 61ce4bdab2..58f2bd953b 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitReceivedPaymentContactsTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitReceivedPaymentContactsTest.kt @@ -42,7 +42,7 @@ class PaykitReceivedPaymentContactsTest { const val TESTNET_ADDRESS = "tb1qexample" /** Network-ambiguous prefixes reported as testnet by the mocked decoder. */ - val LEGACY_ADDRESSES = listOf("mlegacy", "nlegacy", "2legacy") + val LEGACY_ADDRESSES = listOf("mLegacy", "nlegacy", "2legacy") /** Regtest on-chain endpoint identifier for address fixtures. */ const val METHOD = "btc-regtest-p2wpkh" @@ -93,6 +93,24 @@ class PaykitReceivedPaymentContactsTest { 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)) diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 5d04e544b4..cdf87c416c 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -562,12 +562,16 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) val recoveringPublicKey = "pubky6rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" var failLookup = true + var delivered = false whenever(paykitSdkService.linkedPeers()).thenAnswer { if (failLookup) throw AppError("Peer lookup unavailable") listOf( linkedPeer(CONTACT_KEY, LinkedPeerState.LINKED), - linkedPeer(OTHER_CONTACT_KEY, LinkedPeerState.LINKING), - linkedPeer(recoveringPublicKey, LinkedPeerState.RECOVERY_REQUIRED), + linkedPeer(OTHER_CONTACT_KEY, if (delivered) LinkedPeerState.LINKED else LinkedPeerState.LINKING), + linkedPeer( + recoveringPublicKey, + if (delivered) LinkedPeerState.LINKED else LinkedPeerState.RECOVERY_REQUIRED, + ), ) } @@ -577,11 +581,16 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { verify(paykitSdkService, never()).clearPrivatePaymentLists(any()) failLookup = false + assertTrue(sut.retryPendingEndpointRemoval(emptyList()).isFailure) + assertTrue(cacheData.value.cleanupPending) + verify(publicPaykitRepo, never()).syncPaykitApp() + + delivered = true sut.retryPendingEndpointRemoval(emptyList()).getOrThrow() - verify(paykitSdkService).clearPrivatePaymentLists(listOf(CONTACT_KEY)) - verify(paykitSdkService, never()).clearPrivatePaymentLists(listOf(OTHER_CONTACT_KEY)) - verify(paykitSdkService, never()).clearPrivatePaymentLists(listOf(recoveringPublicKey)) + verify(paykitSdkService, times(2)).clearPrivatePaymentLists( + listOf(CONTACT_KEY, OTHER_CONTACT_KEY, recoveringPublicKey), + ) assertFalse(cacheData.value.cleanupPending) verify(publicPaykitRepo).syncPaykitApp() } diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index 3705609000..22193cf2ea 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -18,6 +18,7 @@ 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.PrivateStreamIntakeReport import com.synonym.paykit.ProfileResolution import com.synonym.paykit.PubkyAuthCompanionClaim @@ -1231,6 +1232,56 @@ class PaykitSdkServiceTest { 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)) { diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 6f1295a8c9..1f5c50caf5 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -1167,6 +1167,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.showPaymentRequests() sut.openIncomingPaymentRequestWithTags(request.id, listOf("Lunch")) + assertEquals(request.id, sut.requestedPaymentRequestId.value) advanceTimeBy(TRANSITION_SCREEN_MS) runCurrent() @@ -1180,6 +1181,10 @@ 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 @@ -1429,6 +1434,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, @@ -9107,9 +9113,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } private fun setRequestedPaymentRequestId(id: PaykitPaymentRequestId?) { - val field = AppViewModel::class.java.getDeclaredField("requestedPaymentRequestId") + val field = AppViewModel::class.java.getDeclaredField("_requestedPaymentRequestId") field.isAccessible = true - field.set(sut, id) + @Suppress("UNCHECKED_CAST") + val state = field.get(sut) as MutableStateFlow + state.value = id } private fun activeContactPaymentContext(): ContactPaymentContext? { diff --git a/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt index e9b71e7433..fe44ba3eb0 100644 --- a/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt @@ -83,6 +83,16 @@ class SettingsViewModelTest : BaseUnitTest() { 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() } @@ -253,8 +263,6 @@ class SettingsViewModelTest : BaseUnitTest() { settingsStore = settingsStore, pubkyRepo = pubkyRepo, contactPaymentSettingsRepo = contactPaymentSettingsRepo, - publicPaykitRepo = publicPaykitRepo, - privatePaykitRepo = privatePaykitRepo, widgetsStore = widgetsStore, widgetsRepo = widgetsRepo, ) diff --git a/journeys/payment-requests/issuer-interoperability.xml b/journeys/payment-requests/issuer-interoperability.xml index bd1e42760f..f07ea4b0b1 100644 --- a/journeys/payment-requests/issuer-interoperability.xml +++ b/journeys/payment-requests/issuer-interoperability.xml @@ -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") + If confirmation is not already open, verify progress (id "PaymentRequestPreparing") is visible while the request is being prepared Verify the Payment Request confirmation screen (testTag "PaymentRequestConfirm") shows 100,000 sats + Verify progress (id "PaymentRequestPreparing") is no longer visible Tap Show details (testTag "SendConfirmToggleDetails") Verify the recipient (testTag "ReviewContactRecipient") is the saved fixture issuer contact diff --git a/journeys/payment-requests/requested-resolution-failure.xml b/journeys/payment-requests/requested-resolution-failure.xml index 4f42853c2e..f4418ada9c 100644 --- a/journeys/payment-requests/requested-resolution-failure.xml +++ b/journeys/payment-requests/requested-resolution-failure.xml @@ -13,7 +13,9 @@ Verify the incoming request row (testTag "PaymentRequestRow-<payment-request-id>") is visible Tap Pay (testTag "PaymentRequestPay-<payment-request-id>") + Verify progress (id "PaymentRequestPreparing") is visible while the request is being prepared 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 From cd4648b874e94da1dddd47781116306026bc31db Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 5 Oct 2026 09:53:09 -0500 Subject: [PATCH 43/77] fix: preserve Paykit presentation and queue ordering --- .../repositories/PaykitPaymentProofRepo.kt | 1 + .../repositories/PaykitPaymentRequestRepo.kt | 34 +++-- .../bitkit/repositories/PrivatePaykitRepo.kt | 7 +- .../bitkit/services/PaykitSdkOperationLock.kt | 70 +++++++++- .../to/bitkit/services/PaykitSdkService.kt | 22 +++- app/src/main/java/to/bitkit/ui/ContentView.kt | 3 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 64 +++++++-- ...aykitPaymentRequestRepoSubscriptionTest.kt | 28 ++-- .../PaykitPaymentRequestRepoTest.kt | 28 +++- .../repositories/PrivatePaykitRepoTest.kt | 30 ++++- .../services/PaykitSdkOperationLockTest.kt | 89 ++++++++++++- .../viewmodels/AppViewModelSendFlowTest.kt | 122 +++++++++++++----- changelog.d/next/1401.changed.md | 2 +- .../automatic-presentation.xml | 4 + .../requested-resolution-failure.xml | 5 +- .../subscriptions/review-and-subscribe.xml | 1 + 16 files changed, 415 insertions(+), 95 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index 593f6f5b05..47426410ce 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -123,6 +123,7 @@ class PaykitPaymentProofRepo @Inject constructor( } private val operationMutex = Mutex() + val proofStateVersion = store.backupStateVersion suspend fun backupSnapshot(): List = withContext(ioDispatcher) { operationMutex.withLock { store.load().map { PaykitPaymentStateBackup.Proof(it) } } diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 0ae04dd798..4c58625c4c 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -399,14 +399,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 + } } } @@ -463,8 +467,9 @@ class PaykitPaymentRequestRepo @Inject constructor( suspend fun refresh(mode: PaykitPaymentRequestRefreshMode = PaykitPaymentRequestRefreshMode.FULL): Result = refresh(mode, forceFresh = false) - suspend fun refreshAfterStateChange(): Result = - refresh(PaykitPaymentRequestRefreshMode.FULL, forceFresh = true) + suspend fun refreshAfterStateChange( + mode: PaykitPaymentRequestRefreshMode = PaykitPaymentRequestRefreshMode.FULL, + ): Result = refresh(mode, forceFresh = true) private suspend fun refresh(mode: PaykitPaymentRequestRefreshMode, forceFresh: Boolean): Result { val generation = stateGeneration.get() @@ -863,7 +868,14 @@ class PaykitPaymentRequestRepo @Inject constructor( suspend fun claimForPayment(request: PaykitPaymentRequest): Result = withContext(ioDispatcher) { runSuspendCatching { ensurePaymentAllowed(request, forExecution = false).getOrThrow() - paykitSdkService.claimPaymentRequestForExecution(request.counterparty, request.paymentRequestId) + 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 } } diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index eae0605220..1f8c6a20a1 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -1039,6 +1039,7 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun drainPendingPrivateMessages( reason: String, retryKeys: Collection, + includeUnsavedPeers: Boolean = false, ) { val retryKeys = retryKeys.mapNotNull(::normalizedPublicKey).toSet() currentCoroutineContext().ensureActive() @@ -1047,11 +1048,10 @@ class PrivatePaykitRepo @Inject constructor( val generation = preparationGeneration val alreadyLinkedKeys = paykitSdkService.linkedPeers().filter { it.state == LinkedPeerState.LINKED } .mapNotNull { normalizedPublicKey(it.counterparty) }.toSet() - retryKeys.forEach { retryKey -> + (retryKeys - alreadyLinkedKeys).forEach { retryKey -> currentCoroutineContext().ensureActive() if (generation != preparationGeneration) return@runSuspendCatching - if (retryKey in alreadyLinkedKeys) return@forEach - if (retryKey !in knownSavedContactKeys) return@forEach + if (!includeUnsavedPeers && retryKey !in knownSavedContactKeys) return@forEach if (unavailableLinkRetryAt[retryKey]?.let { it > clock.now() } == true) return@forEach runSuspendCatching { paykitSdkService.ensureLinkWithPeer( @@ -1406,6 +1406,7 @@ class PrivatePaykitRepo @Inject constructor( drainPendingPrivateMessages( reason = "private endpoint cleanup", retryKeys = pendingRetryKeys, + includeUnsavedPeers = true, ) pendingRetryKeys = pendingPrivateMessageDrainKeys(preparation.clearedRetryKeys) } diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkOperationLock.kt b/app/src/main/java/to/bitkit/services/PaykitSdkOperationLock.kt index b140e2366f..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() } } @@ -47,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) { @@ -81,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 ab7e4bd0a0..3d9d0dd73f 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -696,7 +696,12 @@ class PaykitSdkService @Inject constructor( 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() }) } @@ -710,7 +715,12 @@ class PaykitSdkService @Inject constructor( 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, @@ -859,7 +869,7 @@ class PaykitSdkService @Inject constructor( expectedIdentity: String, ): PaymentRequestRecord { isSetup.await() - return operationLock.withLock { + return operationLock.withLock(PaykitSdkOperationLock.Priority.Interactive) { withStateRevisionTracking { handle -> val identityStatus = handle.identityStatus() check( @@ -985,7 +995,7 @@ class PaykitSdkService @Inject constructor( amount: PaymentAmountContext? = null, ): PaykitPreparedPrivateContactPayment { isSetup.await() - val prepared = operationLock.withLock { + val prepared = operationLock.withLock(PaykitSdkOperationLock.Priority.Interactive) { withStateRevisionTracking { handle -> handle.prepareAndResolvePrivateContactPayment( counterparty = counterparty, @@ -1007,7 +1017,7 @@ class PaykitSdkService @Inject constructor( afterPrivatePaymentListVersion: ULong?, ): PaykitPreparedPrivateContactPayment { isSetup.await() - val prepared = operationLock.withLock { + val prepared = operationLock.withLock(PaykitSdkOperationLock.Priority.Interactive) { withStateRevisionTracking { it.prepareAndResolvePrivatePaymentRequest( counterparty, @@ -1027,7 +1037,7 @@ class PaykitSdkService @Inject constructor( counterparty: String, ): PaykitPublicContactPaymentResolution { isSetup.await() - val resolution = operationLock.withLock { + val resolution = operationLock.withLock(PaykitSdkOperationLock.Priority.Interactive) { handle().resolvePublicContactPayment(counterparty, amount = null) } return resolution.toPaykitPublicContactPaymentResolution() diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 7d025bdb0f..5cc6531ae0 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -785,7 +785,8 @@ fun ContentView( BottomSheetOverlayHost(state = bottomSheetOverlayState) - if (requestedPaymentRequestId != null && currentSheet == null) { + val canPreparePaymentRequest = isPaykitEnabled && isProfileAuthenticated + if (canPreparePaymentRequest && requestedPaymentRequestId != null && currentSheet == null) { val description = stringResource(R.string.wallet__payment_request) GradientCircularProgressIndicator( modifier = Modifier diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 8d0b97221a..d42849fa27 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -397,6 +397,7 @@ class AppViewModel @Inject constructor( private var paykitPaymentRequestPollingJob: Job? = null private val paymentRequestPresentationRetryAttempts = mutableMapOf() private val paymentRequestPresentationRetryJobs = mutableMapOf() + private val paymentRequestPresentationCompletionJobs = mutableMapOf() private val timedSheetManager = timedSheetManagerProvider(viewModelScope).apply { registerSheet(appUpdateSheet) registerSheet(backupSheet) @@ -887,7 +888,7 @@ class AppViewModel @Inject constructor( if (!isPaykitEnabled.value || pubkyRepo.publicKey.value == null || !walletRepo.walletExists()) return if (mode == PaykitPaymentRequestRefreshMode.FULL) paykitPaymentProofRepo.reconcile() val result = if (forceFresh) { - paykitPaymentRequestRepo.refreshAfterStateChange() + paykitPaymentRequestRepo.refreshAfterStateChange(mode) } else { paykitPaymentRequestRepo.refresh(mode) } @@ -957,6 +958,17 @@ class AppViewModel @Inject constructor( requestedPaymentRequestTags = persistentListOf() } viewModelScope.launch { + 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) } } @@ -968,6 +980,11 @@ class AppViewModel @Inject constructor( } private fun observeIncomingPaykitPaymentRequests() { + viewModelScope.launch { + paykitPaymentProofRepo.proofStateVersion.drop(1).collect { + refreshIncomingPaykitPaymentRequests(PaykitPaymentRequestRefreshMode.STORED, forceFresh = true) + } + } viewModelScope.launch { currentSheet.collect { sheet -> if (sheet == null) { @@ -1009,14 +1026,11 @@ class AppViewModel @Inject constructor( } if (sheet !is Sheet.Send || currentSheet.value !is Sheet.Send) 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() + clearPaymentRequestPresentationRetry(request.id) } private suspend fun presentNextIncomingPaykitPaymentRequest() { @@ -1057,7 +1071,11 @@ class AppViewModel @Inject constructor( val requestedId = requestedPaymentRequestId.value return if (requestedId == null) { paykitPaymentRequestRepo.automaticPendingRequests().filter { request -> + val isCompleting = synchronized(contactPaymentContextLock) { + request.id in paymentRequestPresentationCompletionJobs + } !paykitPaymentRequestRepo.isProcessing(request) && + !isCompleting && paymentRequestPresentationRetryJobs[request.id]?.isActive != true }.takeIf { it.isNotEmpty() } } else { @@ -1193,9 +1211,7 @@ 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 @@ -1294,12 +1310,34 @@ 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, ) { invalidatePaymentRequestPresentation(dismissActiveRequest) clearPaymentRequestPresentationRetries() + val completions = synchronized(contactPaymentContextLock) { + paymentRequestPresentationCompletionJobs.values.toList().also { + paymentRequestPresentationCompletionJobs.clear() + } + } + completions.forEach { it.cancel() } if (!preserveRequestedPaymentRequest) clearRequestedPaymentRequest() paymentRequestSheetTransitionJob?.cancel() paymentRequestSheetTransitionJob = null @@ -3217,6 +3255,7 @@ class AppViewModel @Inject constructor( uncertainOnchainPaymentRequestId = null val interruptedRequest = synchronized(contactPaymentContextLock) { val request = activeContactPaymentContext?.incomingPaymentRequest + if (request != null && !retryIncomingRequest) completePaymentRequestPresentation(request) activeContactPaymentContext = null preparedContactPaymentContext = null request @@ -3229,7 +3268,6 @@ class AppViewModel @Inject constructor( clearRequestedPaymentRequest() } clearPaymentRequestPresentationRetry(interruptedRequest.id) - viewModelScope.launch { paykitPaymentRequestRepo.markPresented(interruptedRequest) } return } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt index 99cf6dce91..798779e378 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt @@ -6,6 +6,7 @@ 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 @@ -239,15 +240,22 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat 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(record.copy(paymentProofs = listOf(proof)))) + .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() } @@ -787,20 +795,20 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat counterparty = COUNTERPARTY, billingPeriodStartsAt = "2027-01-01T08:00:00Z", ) - whenever(paymentProofStore.completedRequestProofKindsAwaitingSubmission(LOCAL_IDENTITY)) - .thenReturn(mapOf(requestId to PaykitPaymentProofKind.Onchain)) whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( - listOf( - paymentRequestRecord( - state = PaymentRequestLifecycleState.ACTIVE_RECURRING, - paymentDeadline = PaymentDeadline.PeriodStart(3600uL), - ), - ), + 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)) - 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, diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 5b26f1e027..58ef588488 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -259,7 +259,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paymentProofStore.inFlightRequestIds(LOCAL_IDENTITY)).thenReturn(emptySet()) proofStateVersion.value += 1 - val afterFailure = async { sut.refresh() } + val afterFailure = async { sut.refreshAfterStateChange(PaykitPaymentRequestRefreshMode.STORED) } runCurrent() resume.complete(Unit) first.await().getOrThrow() @@ -267,6 +267,8 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals(requestId, sut.pendingRequests.value.single().id) verify(paykitSdkService, times(2)).allPaymentRequests(anyOrNull()) + verify(paykitSdkService).processPendingPrivateMessages() + verify(paykitSdkService).receivePrivateMessagesFromLinkedPeers() } @Test @@ -1103,7 +1105,20 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { 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() + assertFalse(marking.isCompleted) + finishRefresh.complete(Unit) + refresh.await().getOrThrow() + assertTrue(marking.await()) + sut.refresh(PaykitPaymentRequestRefreshMode.STORED).getOrThrow() assertEquals(listOf(request), sut.pendingRequests.value) assertTrue(sut.automaticPendingRequests().isEmpty()) @@ -1127,6 +1142,9 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `identity switch invalidates an in-flight refresh before waiting for the operation lock`() = 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 { @@ -1134,18 +1152,22 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { resumeRefresh.await() emptyList() } - whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(paymentRequestRecord())) whenever(presentationStore.load(SECOND_IDENTITY)).thenReturn(emptySet()) val refresh = async { sut.refresh() } runCurrent() refreshStarted.await() + val marking = async { sut.markPresented(request) } + runCurrent() + assertFalse(marking.isCompleted) val activation = async { sut.activate(SECOND_IDENTITY) } runCurrent() resumeRefresh.complete(Unit) refresh.await().getOrThrow() activation.await() + assertFalse(marking.await()) + verify(presentationStore, never()).save(any(), any()) assertTrue(sut.pendingRequests.value.isEmpty()) assertTrue(sut.paymentRequestHistory.value.isEmpty()) diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index cdf87c416c..536acc9fa8 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -8,6 +8,7 @@ import com.synonym.paykit.ContactRecord 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.PaymentRequestLifecycleState import com.synonym.paykit.PrivatePaymentListDeliveryFailure @@ -562,18 +563,36 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) val recoveringPublicKey = "pubky6rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" var failLookup = true - var delivered = false + 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 (delivered) LinkedPeerState.LINKED else LinkedPeerState.LINKING), + linkedPeer(OTHER_CONTACT_KEY, if (linked) LinkedPeerState.LINKED else LinkedPeerState.LINKING), linkedPeer( recoveringPublicKey, - if (delivered) LinkedPeerState.LINKED else LinkedPeerState.RECOVERY_REQUIRED, + if (linked) LinkedPeerState.LINKED else LinkedPeerState.RECOVERY_REQUIRED, ), ) } + whenever(paykitSdkService.ensureLinkWithPeer(any(), any())).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()) @@ -585,12 +604,15 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(cacheData.value.cleanupPending) verify(publicPaykitRepo, never()).syncPaykitApp() - delivered = true + 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() } diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkOperationLockTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkOperationLockTest.kt index 2d4408db20..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,77 @@ 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() @@ -49,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 { @@ -66,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() @@ -135,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/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 1f5c50caf5..ab40bf8710 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -273,6 +273,7 @@ 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 surfacedPaykitPaymentRequestIds = mutableSetOf() private val testPublicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xy" private val nonCanonicalTestPublicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" @@ -428,7 +429,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(paykitPaymentRequestRepo.eligibleTargets).thenReturn(MutableStateFlow(emptyList())) whenever(paykitPaymentRequestRepo.isCreatingRequest).thenReturn(MutableStateFlow(false)) whenever { paykitPaymentRequestRepo.refreshEligibleTargets(any(), any()) }.thenReturn(Result.success(Unit)) - whenever { paykitPaymentRequestRepo.refreshAfterStateChange() }.thenReturn(Result.success(Unit)) + whenever { paykitPaymentRequestRepo.refreshAfterStateChange(any()) }.thenReturn(Result.success(Unit)) whenever(paykitPaymentRequestRepo.automaticPendingRequests()).thenAnswer { pendingPaykitPaymentRequests.value.filterNot { it.id in surfacedPaykitPaymentRequestIds } } @@ -453,6 +454,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(paykitPaymentRequestRepo.isExpired(any())).thenReturn(false) whenever(paykitPaymentRequestRepo.isProcessing(any())).thenReturn(false) whenever(paykitPaymentProofRepo.onchainPaymentResolutions).thenReturn(onchainPaymentResolutions) + whenever(paykitPaymentProofRepo.proofStateVersion).thenReturn(proofStateVersion) whenever { paykitPaymentProofRepo.prepare(any(), any(), any(), any()) }.thenReturn(Result.success(Unit)) whenever { paykitPaymentProofRepo.associateLightningPayment(any(), any(), any(), eq("bitkit")) @@ -1227,35 +1229,64 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @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 = privatePaymentContext(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 @@ -1364,6 +1395,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", @@ -1382,6 +1414,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 @@ -1392,18 +1425,26 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `subscription notification for another identity is ignored`() = 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"), ), ) - pubkyPublicKey.value = testPublicKey + 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 @@ -6329,6 +6370,12 @@ 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) @@ -6355,6 +6402,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) } @@ -7449,6 +7505,14 @@ class AppViewModelSendFlowTest : BaseUnitTest() { balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) whenever(paykitPaymentRequestRepo.accept(request)).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) @@ -7465,6 +7529,12 @@ class AppViewModelSendFlowTest : BaseUnitTest() { confirmCurrentPayment() + 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) } @@ -9112,14 +9182,6 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) } - private fun setRequestedPaymentRequestId(id: PaykitPaymentRequestId?) { - val field = AppViewModel::class.java.getDeclaredField("_requestedPaymentRequestId") - field.isAccessible = true - @Suppress("UNCHECKED_CAST") - val state = field.get(sut) as MutableStateFlow - state.value = id - } - private fun activeContactPaymentContext(): ContactPaymentContext? { val field = AppViewModel::class.java.getDeclaredField("activeContactPaymentContext") field.isAccessible = true diff --git a/changelog.d/next/1401.changed.md b/changelog.d/next/1401.changed.md index c6c333754f..d0e76b9f8d 100644 --- a/changelog.d/next/1401.changed.md +++ b/changelog.d/next/1401.changed.md @@ -1 +1 @@ -Share Paykit contacts and payment state with authorized apps while keeping wallet keys private, keep accepted one-time requests payable only on the accepting install, and label received payments with their Paykit payer contact. +Share Paykit contacts and payment state with authorized apps while keeping wallet keys private, keep accepted one-time requests payable only on the accepting install, prevent paid subscription periods from being offered again, and label received payments with their Paykit payer contact. diff --git a/journeys/payment-requests/automatic-presentation.xml b/journeys/payment-requests/automatic-presentation.xml index e192331d0e..9fe2b05e1f 100644 --- a/journeys/payment-requests/automatic-presentation.xml +++ b/journeys/payment-requests/automatic-presentation.xml @@ -16,5 +16,9 @@ Close the confirmation without paying Open Receive and close it again Verify Home remains visible and neither reviewed request reopens automatically + On the requester, send a Payment Request above the payer's available balance with the note "Unaffordable request" + On the payer, verify an insufficient-balance toast appears once and no payment confirmation opens + 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/requested-resolution-failure.xml b/journeys/payment-requests/requested-resolution-failure.xml index f4418ada9c..5640c73418 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 @@ -19,5 +19,8 @@ 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 + Start another Pay attempt and verify progress (id "PaymentRequestPreparing") appears + While preparation is pending, 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/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. From 32669196855a73299c13db4eeed77b8593ed8614 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 5 Oct 2026 12:43:30 -0500 Subject: [PATCH 44/77] fix: complete active Paykit calls before cancellation --- .../to/bitkit/services/PaykitSdkService.kt | 257 ++++++++++-------- .../bitkit/services/PaykitSdkServiceTest.kt | 224 +++++++++++++-- .../services/PaykitSdkServiceWipeTest.kt | 78 ++++++ 3 files changed, 426 insertions(+), 133 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 3d9d0dd73f..82a1bc4d9f 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -307,7 +307,7 @@ class PaykitSdkService @Inject constructor( refreshPaykitKey(force = true) var handle = handle() val identityStatus = try { - handle.initialize() + completeSdkCall { handle.initialize() } } catch (e: PaykitException.Identity) { invalidatePaykitKeyIfNeeded(e) if (!sessionProvider.canDeferStaleSession(e.context)) throw e @@ -321,7 +321,7 @@ class PaykitSdkService @Inject constructor( resetRuntime() try { handle = handle() - handle.initialize() + completeSdkCall { handle.initialize() } } finally { sessionProvider.resumeStoredSessionAccess() } @@ -392,7 +392,8 @@ class PaykitSdkService @Inject constructor( isSetup.await() return operationLock.withLock { withPaykitKey { handle -> - handle.identityStatus()?.publicKey ?: handle.initialize().publicKey + completeSdkCall { handle.identityStatus() }?.publicKey + ?: completeSdkCall { handle.initialize() }.publicKey } } } @@ -400,7 +401,9 @@ class PaykitSdkService @Inject constructor( suspend fun hasPrivatePaymentAccess(): Boolean { isSetup.await() return operationLock.withLock { - withPaykitKey { it.identityStatus()?.capability == PubkyIdentityCapability.PRIVATE_LINK_CAPABLE } + withPaykitKey { + completeSdkCall { it.identityStatus() }?.capability == PubkyIdentityCapability.PRIVATE_LINK_CAPABLE + } } } @@ -552,10 +555,10 @@ class PaykitSdkService @Inject constructor( isSetup.await() return operationLock.withLock { withPaykitKey { handle -> - val publicKey = requireNotNull(handle.identityStatus()?.publicKey) + val publicKey = requireNotNull(completeSdkCall { handle.identityStatus() }?.publicKey) val current = handle.fetchPaykitProfile(publicKey) - handle.publishPaykitProfile(profile, current?.revision).also { - notifyBackupStateChanged() + completeSdkCall { + handle.publishPaykitProfile(profile, current?.revision).also { notifyBackupStateChanged() } } } } @@ -565,14 +568,14 @@ class PaykitSdkService @Inject constructor( isSetup.await() return operationLock.withLock { if (expectedIdentity != null) { - val identityStatus = handle().identityStatus() + val identityStatus = completeSdkCall { handle().identityStatus() } check( 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() } } } } @@ -581,9 +584,12 @@ class PaykitSdkService @Inject constructor( isSetup.await() operationLock.withLock { withPaykitKey { handle -> - val publicKey = requireNotNull(handle.identityStatus()?.publicKey) - handle.fetchPaykitProfile(publicKey)?.let { handle.deletePaykitProfile(it.revision) } - notifyBackupStateChanged() + val publicKey = requireNotNull(completeSdkCall { handle.identityStatus() }?.publicKey) + val current = handle.fetchPaykitProfile(publicKey) + completeSdkCall { + current?.let { handle.deletePaykitProfile(it.revision) } + notifyBackupStateChanged() + } } } } @@ -597,14 +603,14 @@ class PaykitSdkService @Inject constructor( suspend fun contactRecords(): List { isSetup.await() return operationLock.withLock { - withPaykitKey { it.contactRecords() } + withPaykitKey { completeSdkCall { it.contactRecords() } } } } suspend fun contactRecord(publicKey: String): ContactRecord? { isSetup.await() return operationLock.withLock { - withPaykitKey { it.contactRecord(publicKey) } + withPaykitKey { completeSdkCall { it.contactRecord(publicKey) } } } } @@ -618,15 +624,18 @@ 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" } } - val existing = handle.contactRecord(publicKey) + val existing = completeSdkCall { handle.contactRecord(publicKey) } check(restorePrivateConnection || existing != null) { "Contact no longer exists" } val update = ContactUpdate(publicKey, label) - if (!restorePrivateConnection) return@withStateRevisionTracking handle.saveContact(update) - val blockedPeers = handle.linkedPeers().filter { + if (!restorePrivateConnection) { + return@withStateRevisionTracking completeSdkCall { handle.saveContact(update) } + } + val blockedPeers = completeSdkCall { handle.linkedPeers() }.filter { it.state == LinkedPeerState.BLOCKED && PubkyPublicKeyFormat.matches(it.counterparty, publicKey) } restorePrivateContact(handle, blockedPeers, update) @@ -638,11 +647,11 @@ class PaykitSdkService @Inject constructor( isSetup.await() return operationLock.withLock { withStateRevisionTracking { handle -> - val peers = handle.linkedPeers().filter { + val peers = completeSdkCall { handle.linkedPeers() }.filter { PubkyPublicKeyFormat.matches(it.counterparty, publicKey) } 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() } @@ -653,9 +662,7 @@ class PaykitSdkService @Inject constructor( if (hasActiveSubscription) throw PubkyContactError.ActiveSubscription peers.filter { it.state == LinkedPeerState.LINKED }.forEach { runSuspendCatching { - val report = handle.clearPrivatePaymentListAndProcessOutbound( - publicKey, - ) + 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) } @@ -664,8 +671,8 @@ class PaykitSdkService @Inject constructor( Logger.warn("Failed to withdraw private endpoints before contact deletion", it, context = TAG) } } - handle.blockPeer(publicKey) - handle.removeContact(publicKey) + completeSdkCall { handle.blockPeer(publicKey) } + completeSdkCall { handle.removeContact(publicKey) } } } } @@ -683,13 +690,15 @@ class PaykitSdkService @Inject constructor( isSetup.await() operationLock.withLock { withStateRevisionTracking { handle -> - val capabilities = appCapabilities(handle.identityStatus()) - handle.publishPaykitApp( - displayName = "Bitkit", - capabilities = capabilities.copy( - privatePayments = capabilities.privatePayments && privatePaymentsEnabled - ), - ) + val capabilities = appCapabilities(completeSdkCall { handle.identityStatus() }) + completeSdkCall { + handle.publishPaykitApp( + displayName = "Bitkit", + capabilities = capabilities.copy( + privatePayments = capabilities.privatePayments && privatePaymentsEnabled + ), + ) + } } } } @@ -703,7 +712,9 @@ class PaykitSdkService @Inject constructor( } return operationLock.withLock(priority) { withStateRevisionTracking { handle -> - handle.syncPublicEndpointsWithReceivingDetails(endpoints.map { it.toPublicReceivingDetail() }) + completeSdkCall { + handle.syncPublicEndpointsWithReceivingDetails(endpoints.map { it.toPublicReceivingDetail() }) + } } } } @@ -722,10 +733,12 @@ class PaykitSdkService @Inject constructor( } return operationLock.withLock(priority) { withStateRevisionTracking { handle -> - handle.syncPrivatePaymentListsWithReservationsAndProcessOutbound( - updates = updates, - clearUnlistedLinkedPeers = clearUnlistedLinkedPeers, - ) + completeSdkCall { + handle.syncPrivatePaymentListsWithReservationsAndProcessOutbound( + updates = updates, + clearUnlistedLinkedPeers = clearUnlistedLinkedPeers, + ) + } } } } @@ -737,7 +750,7 @@ class PaykitSdkService @Inject constructor( isSetup.await() operationLock.withLock { withStateRevisionTracking { handle -> - handle.ensureLinkWithPeer(counterparty, maxAdvanceSteps) + completeSdkCall { handle.ensureLinkWithPeer(counterparty, maxAdvanceSteps) } } } } @@ -749,7 +762,7 @@ class PaykitSdkService @Inject constructor( isSetup.await() return operationLock.withLock { withStateRevisionTracking { handle -> - val peers = handle.linkedPeers() + val peers = completeSdkCall { handle.linkedPeers() } val blockedPeers = peers.filter { it.state == LinkedPeerState.BLOCKED } val updates = counterparties.filterNot { counterparty -> blockedPeers.any { @@ -757,7 +770,7 @@ class PaykitSdkService @Inject constructor( } }.map { PrivatePaymentListReservationUpdateInput(it, emptyList()) } if (updates.isEmpty()) return@withStateRevisionTracking null - val publicKey = handle.identityStatus()?.publicKey + 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 @@ -769,16 +782,18 @@ class PaykitSdkService @Inject constructor( } ) { runSuspendCatching { - handle.ensureLinkWithPeer(update.counterparty, 1u) + completeSdkCall { handle.ensureLinkWithPeer(update.counterparty, 1u) } }.onFailure { Logger.warn("Failed to recover private Paykit link before withdrawal", it, context = TAG) } } } - handle.syncPrivatePaymentListsWithReservationsAndProcessOutbound( - updates = updates, - clearUnlistedLinkedPeers = false, - ) + completeSdkCall { + handle.syncPrivatePaymentListsWithReservationsAndProcessOutbound( + updates = updates, + clearUnlistedLinkedPeers = false, + ) + } } } } @@ -787,7 +802,7 @@ class PaykitSdkService @Inject constructor( isSetup.await() return operationLock.withLock { withStateRevisionTracking { handle -> - handle.receivePrivateMessagesFromLinkedPeers() + completeSdkCall { handle.receivePrivateMessagesFromLinkedPeers() } } } } @@ -796,7 +811,7 @@ class PaykitSdkService @Inject constructor( isSetup.await() operationLock.withLock { withStateRevisionTracking { handle -> - handle.receivePrivateMessages(counterparty) + completeSdkCall { handle.receivePrivateMessages(counterparty) } } } } @@ -805,7 +820,7 @@ class PaykitSdkService @Inject constructor( isSetup.await() operationLock.withLock { withStateRevisionTracking { handle -> - handle.processOutboundPrivateMessages(counterparty) + completeSdkCall { handle.processOutboundPrivateMessages(counterparty) } } } } @@ -814,7 +829,7 @@ class PaykitSdkService @Inject constructor( isSetup.await() return operationLock.withLock { withStateRevisionTracking { handle -> - handle.processPendingPrivateMessages() + completeSdkCall { handle.processPendingPrivateMessages() } } } } @@ -826,19 +841,22 @@ class PaykitSdkService @Inject constructor( return operationLock.withLock { withPaykitKey { handle -> if (expectedIdentity != null) { - check(PubkyPublicKeyFormat.matches(handle.identityStatus()?.publicKey, expectedIdentity)) { + val identity = completeSdkCall { handle.identityStatus() }?.publicKey + check(PubkyPublicKeyFormat.matches(identity, expectedIdentity)) { "Payment Request identity changed" } } - handle.listPaymentRequests( - PaymentRequestFilter( - counterparty = null, - localRole = null, - states = emptyList(), - recurring = null, - receivedOnly = false, - ), - ) + completeSdkCall { + handle.listPaymentRequests( + PaymentRequestFilter( + counterparty = null, + localRole = null, + states = emptyList(), + recurring = null, + receivedOnly = false, + ), + ) + } } } } @@ -846,7 +864,7 @@ class PaykitSdkService @Inject constructor( suspend fun identityStatus(): IdentityStatus? { isSetup.await() return operationLock.withLock { - withPaykitKey { it.identityStatus() } + withPaykitKey { completeSdkCall { it.identityStatus() } } } } @@ -871,7 +889,7 @@ class PaykitSdkService @Inject constructor( isSetup.await() return operationLock.withLock(PaykitSdkOperationLock.Priority.Interactive) { withStateRevisionTracking { handle -> - val identityStatus = handle.identityStatus() + val identityStatus = completeSdkCall { handle.identityStatus() } check( identityStatus?.capability == PubkyIdentityCapability.PRIVATE_LINK_CAPABLE && PubkyPublicKeyFormat.matches(identityStatus.publicKey, expectedIdentity) @@ -896,7 +914,7 @@ class PaykitSdkService @Inject constructor( paymentDeadline = null, metadata = PrivateJsonObject(proposal.metadataJson), ) - handle.proposePaymentRequest(counterparty, terms) + completeSdkCall { handle.proposePaymentRequest(counterparty, terms) } } } } @@ -908,8 +926,8 @@ class PaykitSdkService @Inject constructor( isSetup.await() return operationLock.withLock { withStateRevisionTracking { handle -> - handle.claimPaymentRequestForExecution(counterparty, paymentRequestId) - handle.acceptPaymentRequest(counterparty, paymentRequestId) + completeSdkCall { handle.claimPaymentRequestForExecution(counterparty, paymentRequestId) } + completeSdkCall { handle.acceptPaymentRequest(counterparty, paymentRequestId) } } } } @@ -917,7 +935,9 @@ class PaykitSdkService @Inject constructor( suspend fun claimPaymentRequestForExecution(counterparty: String, paymentRequestId: String): PaymentRequestRecord { isSetup.await() return operationLock.withLock { - withStateRevisionTracking { it.claimPaymentRequestForExecution(counterparty, paymentRequestId) } + withStateRevisionTracking { + completeSdkCall { it.claimPaymentRequestForExecution(counterparty, paymentRequestId) } + } } } @@ -933,18 +953,20 @@ class PaykitSdkService @Inject constructor( isSetup.await() return operationLock.withLock { withStateRevisionTracking { handle -> - handle.submitPaymentProof( - counterparty, - paymentRequestId, - PaymentProofSubmission( - billingPeriod = billingPeriod?.sdkValue, - paymentAppId = paymentAppId, - 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), + ), + ) + } } } } @@ -957,7 +979,7 @@ class PaykitSdkService @Inject constructor( isSetup.await() return operationLock.withLock { withStateRevisionTracking { handle -> - handle.rejectPaymentRequest(counterparty, paymentRequestId, reason) + completeSdkCall { handle.rejectPaymentRequest(counterparty, paymentRequestId, reason) } } } } @@ -970,7 +992,7 @@ class PaykitSdkService @Inject constructor( isSetup.await() return operationLock.withLock { withStateRevisionTracking { handle -> - handle.cancelPaymentRequest(counterparty, paymentRequestId, reason) + completeSdkCall { handle.cancelPaymentRequest(counterparty, paymentRequestId, reason) } } } } @@ -978,14 +1000,14 @@ class PaykitSdkService @Inject constructor( suspend fun linkedPeers(): List { isSetup.await() return operationLock.withLock { - withPaykitKey { it.linkedPeers() } + withPaykitKey { completeSdkCall { it.linkedPeers() } } } } suspend fun pendingOutboundPrivateCounterparties(): List { isSetup.await() return operationLock.withLock { - withPaykitKey { it.pendingOutboundPrivateCounterparties() } + withPaykitKey { completeSdkCall { it.pendingOutboundPrivateCounterparties() } } } } @@ -997,12 +1019,14 @@ class PaykitSdkService @Inject constructor( isSetup.await() val prepared = operationLock.withLock(PaykitSdkOperationLock.Priority.Interactive) { withStateRevisionTracking { handle -> - handle.prepareAndResolvePrivateContactPayment( - counterparty = counterparty, - amount = amount, - afterPrivatePaymentListVersion = afterPrivatePaymentListVersion, - maxAdvanceSteps = 1u, - ) + completeSdkCall { + handle.prepareAndResolvePrivateContactPayment( + counterparty = counterparty, + amount = amount, + afterPrivatePaymentListVersion = afterPrivatePaymentListVersion, + maxAdvanceSteps = 1u, + ) + } } } return PaykitPreparedPrivateContactPayment( @@ -1019,12 +1043,14 @@ class PaykitSdkService @Inject constructor( isSetup.await() val prepared = operationLock.withLock(PaykitSdkOperationLock.Priority.Interactive) { withStateRevisionTracking { - it.prepareAndResolvePrivatePaymentRequest( - counterparty, - paymentRequestId, - afterPrivatePaymentListVersion, - 1u, - ) + completeSdkCall { + it.prepareAndResolvePrivatePaymentRequest( + counterparty, + paymentRequestId, + afterPrivatePaymentListVersion, + 1u, + ) + } } } return PaykitPreparedPrivateContactPayment( @@ -1071,7 +1097,7 @@ class PaykitSdkService @Inject constructor( suspend fun exportBackupState(): String { isSetup.await() return operationLock.withLock { - withPaykitKey { it.exportBackupString() } + withPaykitKey { completeSdkCall { it.exportBackupString() } } } } @@ -1082,10 +1108,13 @@ class PaykitSdkService @Inject constructor( suspend fun signOut() { isSetup.await() operationLock.withLock { - withStateRevisionTracking { handle -> - handle.signOut() + try { + withStateRevisionTracking { handle -> + completeSdkCall { handle.signOut() } + } + } finally { + resetRuntime() } - resetRuntime() } } @@ -1095,8 +1124,10 @@ class PaykitSdkService @Inject constructor( isSetup.await() operationLock.withLock { try { - handle().forgetSessionAccess() - notifyBackupStateChanged() + completeSdkCall { + handle().forgetSessionAccess() + notifyBackupStateChanged() + } } finally { resetRuntime() } @@ -1179,9 +1210,9 @@ class PaykitSdkService @Inject constructor( resetRuntime() refreshPaykitKey() val handle = handle() - val identityStatus = handle.initialize() + val identityStatus = completeSdkCall { handle.initialize() } if (result.sessionAccess.exportLocalSecretKey() != null) { - handle.publishPaykitNoiseKeyAuthorization() + completeSdkCall { handle.publishPaykitNoiseKeyAuthorization() } } publishAppIfLiveSessionAvailable(handle, identityStatus) launchIdentityRepublish(publicKey = result.publicKey) @@ -1198,10 +1229,10 @@ class PaykitSdkService @Inject constructor( runSuspendCatching { val capabilities = appCapabilities(identityStatus) if (capabilities.privatePayments) { - handle.publishPaykitApp( - "Bitkit", - capabilities.copy(privatePayments = settingsStore.data.first().sharesPrivatePaykitEndpoints), - ) + val privatePayments = settingsStore.data.first().sharesPrivatePaykitEndpoints + completeSdkCall { + handle.publishPaykitApp("Bitkit", capabilities.copy(privatePayments = privatePayments)) + } } }.onFailure { invalidatePaykitKeyIfNeeded(it) @@ -1232,8 +1263,8 @@ class PaykitSdkService @Inject constructor( var failure: Throwable? = null return try { runSuspendCatching { - blockedPeers.forEach { handle.unblockPeer(it.counterparty) } - handle.saveContact(update) + blockedPeers.forEach { completeSdkCall { handle.unblockPeer(it.counterparty) } } + completeSdkCall { handle.saveContact(update) } }.onFailure { failure = it }.getOrThrow() } catch (error: CancellationException) { failure = error @@ -1270,7 +1301,7 @@ class PaykitSdkService @Inject constructor( private suspend fun withStateRevisionTracking(block: suspend (PaykitSdk) -> T): T = withPaykitKey { handle -> withPaykitBackupStateTracking( readRevision = { - runSuspendCatching { handle.backupStateRevision() } + runSuspendCatching { completeSdkCall { handle.backupStateRevision() } } .onFailure(::invalidatePaykitKeyIfNeeded) .getOrThrow() }, @@ -1288,6 +1319,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 diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index 22193cf2ea..eab509b7b1 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -202,35 +202,169 @@ class PaykitSdkServiceTest { @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 preparation finishes the active SDK call before releasing the queue`() = runTest { + for (isRequest in listOf(false, true)) { + 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.contactRecords() }.thenAnswer { + assertTrue(finished) + emptyList() + } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + val preparation = async { + if (isRequest) { + service.prepareAndResolvePrivatePaymentRequest(RING_PUBKY, "request", null) + } else { + service.prepareAndResolvePrivateContactPayment(RING_PUBKY, null) + } + } + 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 `cancelling an active execution claim does not continue to accept`() = runTest { 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 + val releaseClaim = CompletableDeferred() + var claimFinished = false + whenever { sdk.claimPaymentRequestForExecution(RING_PUBKY, "request") }.doSuspendableAnswer { + releaseClaim.await() + claimFinished = 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) + releaseClaim.complete(Unit) + + assertFailsWith { acceptance.await() } + assertTrue(claimFinished) + 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" } - 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() } + receive.cancel() runCurrent() - try { - assertFalse(contacts.isCompleted) - verify(sdk, never()).contactRecords() - } finally { - releaseReceive.complete(Unit) + 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.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() } - assertSame(report, receive.await()) - assertEquals(emptyList(), contacts.await()) - assertEquals(1L, service.backupStateVersion.value) - verify(sdk).receivePrivateMessages(RING_PUBKY) - verify(sdk, never()).receivePrivateMessagesFromLinkedPeers() + service.processPendingPrivateMessages() + + verify(sdk, times(2)).backupStateRevision() } @Test @@ -496,7 +630,7 @@ class PaykitSdkServiceTest { 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) val handlesBeforeReload = handlesCreated @@ -1073,10 +1207,11 @@ class PaykitSdkServiceTest { "save" -> whenever(sdk.saveContact(any())).thenThrow(failure).thenReturn(mock()) } val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } - val thrown = assertFailsWith { + val thrown = assertFailsWith(failure::class) { service.saveContact(RING_PUBKY, "Contact", restorePrivateConnection = true) } - assertSame(failure, thrown) + if (failure !is CancellationException) assertSame(failure, thrown) + assertEquals(failure.message, thrown.message) if (failurePoint == "lookup") { verify(sdk, never()).blockPeer(any()) } else { @@ -1109,6 +1244,47 @@ class PaykitSdkServiceTest { verify(sdk).blockPeer(RING_PUBKY) } + @Test + fun `cancelled contact restore completes unblocking and rollback before releasing the queue`() = runTest { + val sdk = mock() + whenever { sdk.linkedPeers() }.thenReturn(listOf(contactPeer(LinkedPeerState.BLOCKED))) + val releaseUnblock = CompletableDeferred() + val releaseRollback = CompletableDeferred() + val events = mutableListOf() + whenever { sdk.unblockPeer(RING_PUBKY) }.doSuspendableAnswer { + releaseUnblock.await() + events += "unblocked" + contactPeer(LinkedPeerState.NOT_LINKED) + } + whenever { sdk.blockPeer(RING_PUBKY) }.doSuspendableAnswer { + events += "rollback" + releaseRollback.await() + events += "blocked" + contactPeer(LinkedPeerState.BLOCKED) + } + whenever { sdk.contactRecords() }.thenAnswer { + events += "next" + emptyList() + } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + val restore = async { service.saveContact(RING_PUBKY, "Contact", restorePrivateConnection = true) } + runCurrent() + restore.cancel() + val next = async { service.contactRecords() } + runCurrent() + assertEquals(emptyList(), events) + releaseUnblock.complete(Unit) + runCurrent() + assertEquals(listOf("unblocked", "rollback"), events) + assertFalse(next.isCompleted) + releaseRollback.complete(Unit) + + assertFailsWith { restore.await() } + next.await() + assertEquals(listOf("unblocked", "rollback", "blocked", "next"), events) + verify(sdk, never()).saveContact(any()) + } + @Test fun `a contact save queued behind an identity change is not saved to the new identity`() = runTest { val originalIdentity = "pubky$RING_PUBKY" diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceWipeTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceWipeTest.kt index e7fa94fbaa..b47e5c3958 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceWipeTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceWipeTest.kt @@ -1,5 +1,6 @@ package to.bitkit.services +import com.synonym.paykit.IdentityStatus import com.synonym.paykit.PaykitException import com.synonym.paykit.PaykitSdk import com.synonym.paykit.PubkyIdentityCapability @@ -9,9 +10,11 @@ import com.synonym.paykit.PubkySessionBootstrapResult 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.mockStatic @@ -30,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 @@ -41,6 +45,80 @@ class PaykitSdkServiceWipeTest { private const val RING_PUBKY = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" } + @Test + @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) + } + 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) + + 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()) + } + + @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 + } + 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) + + assertFailsWith { teardown.await() } + assertEquals(emptyList(), next.await()) + assertEquals(2, handlesCreated) + assertEquals(1L, service.backupStateVersion.value) + verify(sdk, never()).contactRecords() + verify(freshSdk).contactRecords() + } + } + @Test fun `session storage callbacks translate keychain failures and recover on retry`() { val keychain = mock() From 3f61604211403655841e91c1756379733eb7de68 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 5 Oct 2026 12:43:51 -0500 Subject: [PATCH 45/77] perf: reuse identity status during private publication --- .../bitkit/repositories/PrivatePaykitRepo.kt | 22 +++- .../repositories/PrivatePaykitRepoTest.kt | 122 +++++++++++++++++- 2 files changed, 133 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 1f8c6a20a1..3a50e25967 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -1,6 +1,7 @@ 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 @@ -9,6 +10,7 @@ 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 @@ -806,8 +808,9 @@ class PrivatePaykitRepo @Inject constructor( ) publicationMutex.withLock { - if (!isCurrentPublication(generation, identity, requireImmediatePublication)) return@withLock - if (!canPublishPrivateEndpoints()) { + val status = paykitSdkService.identityStatus() + if (!isCurrentPublication(generation, identity, requireImmediatePublication, status)) return@withLock + if (!canPublishPrivateEndpoints(status)) { if (requireImmediatePublication) throw PrivatePaykitError.PrivateUnavailable return@withLock } @@ -850,8 +853,10 @@ class PrivatePaykitRepo @Inject constructor( generation: Int, identity: String, requireImmediatePublication: Boolean, + status: IdentityStatus? = null, ): Boolean { - val isCurrent = generation == preparationGeneration && pubkyService.currentPublicKey() == identity + val isCurrent = generation == preparationGeneration && + (status?.publicKey ?: pubkyService.currentPublicKey()) == identity if (!isCurrent && requireImmediatePublication) throw PrivatePaykitError.PrivateUnavailable return isCurrent } @@ -1621,14 +1626,19 @@ 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 && + val locallyEligible = settings.sharesPrivatePaykitEndpoints && !isContactSharingCleanupPending() && - hasPrivatePaymentAccessForCurrentProfile() && 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 { diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 536acc9fa8..6b8c54efe9 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -5,6 +5,7 @@ import com.synonym.bitkitcore.LightningInvoice import com.synonym.bitkitcore.NetworkType import com.synonym.bitkitcore.Scanner import com.synonym.paykit.ContactRecord +import com.synonym.paykit.IdentityStatus import com.synonym.paykit.LinkedPeerHandshakeReport import com.synonym.paykit.LinkedPeerRecord import com.synonym.paykit.LinkedPeerState @@ -17,6 +18,7 @@ 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 @@ -126,6 +128,8 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(clock.now()).thenReturn(Instant.fromEpochSeconds(NOW_SECONDS)) whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) 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)) @@ -159,13 +163,33 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `handleOnchainActivity skips SDK access when no contact address was used`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) - whenever(addressReservationRepo.contactsWithUsedReservedAddresses()).thenReturn(emptyList()) + 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() + sut.handleOnchainActivity().getOrThrow() + } verify(paykitSdkService, never()).hasPrivatePaymentAccess() + verify(paykitSdkService, never()).identityStatus() verify(pubkyService, never()).currentPublicKey() } @@ -212,13 +236,101 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { 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() + + clearInvocations(paykitSdkService, pubkyService) + whenever(paykitSdkService.identityStatus()).thenReturn(null) + + 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 `hasPrivatePaymentAccess returns false when the SDK check fails`() = test { - whenever(paykitSdkService.hasPrivatePaymentAccess()).thenThrow(IllegalStateException("Paykit unavailable")) + var accessFails = true + whenever(paykitSdkService.hasPrivatePaymentAccess()).thenAnswer { + check(!accessFails) { "Paykit unavailable" } + true + } assertFalse(sut.hasPrivatePaymentAccess()) + + 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 `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 + }, + ), + ) + + assertIs(publication.await().exceptionOrNull()) + } + verify(addressReservationRepo, never()).currentOrRotatedAddress(any()) + verify(paykitSdkService, never()).syncPrivatePaymentListsWithReservations(any(), any()) + } + + @Test + 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) + + assertIs(publication.await().exceptionOrNull()) + verify(pubkyService, times(2)).currentPublicKey() + verify(paykitSdkService, never()).syncPrivatePaymentListsWithReservations(any(), any()) } @Test From d44d5bbcb6abe4be7c326b998c8affedd7a69c7d Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 5 Oct 2026 14:58:33 -0500 Subject: [PATCH 46/77] Refresh payment requests only for relevant proof changes --- .../repositories/PaykitPaymentProofRepo.kt | 30 ++++- .../java/to/bitkit/viewmodels/AppViewModel.kt | 2 +- .../PaykitPaymentProofRepoTest.kt | 108 +++++++++++++++++- .../viewmodels/AppViewModelSendFlowTest.kt | 3 +- 4 files changed, 139 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index 47426410ce..16eb2fb57a 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -6,8 +6,14 @@ 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 @@ -123,7 +129,29 @@ class PaykitPaymentProofRepo @Inject constructor( } private val operationMutex = Mutex() - val proofStateVersion = store.backupStateVersion + + 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) } } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 3972c054fc..059ddbe7e7 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -987,7 +987,7 @@ class AppViewModel @Inject constructor( private fun observeIncomingPaykitPaymentRequests() { viewModelScope.launch { - paykitPaymentProofRepo.proofStateVersion.drop(1).collect { + paykitPaymentProofRepo.paymentRequestStateChanges(pubkyRepo.publicKey).collect { refreshIncomingPaykitPaymentRequests(PaykitPaymentRequestRefreshMode.STORED, forceFresh = true) } } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index 4c0e5081f3..36b9139fc4 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -11,7 +11,14 @@ import com.synonym.paykit.PaymentRequestRecord import com.synonym.paykit.PaymentRequestTerms import com.synonym.paykit.PrivateJsonObject import com.synonym.paykit.PubkyIdentityCapability +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 @@ -30,6 +37,7 @@ 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.PaykitSdkService @@ -43,6 +51,7 @@ 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 +70,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 { @@ -93,6 +108,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 @@ -791,7 +890,14 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { 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, diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index ab40bf8710..33814647f4 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -35,6 +35,7 @@ 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 @@ -454,7 +455,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(paykitPaymentRequestRepo.isExpired(any())).thenReturn(false) whenever(paykitPaymentRequestRepo.isProcessing(any())).thenReturn(false) whenever(paykitPaymentProofRepo.onchainPaymentResolutions).thenReturn(onchainPaymentResolutions) - whenever(paykitPaymentProofRepo.proofStateVersion).thenReturn(proofStateVersion) + 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(), eq("bitkit")) From 0d21ff9b9a740fe914ec65c9d4340a7b1b61d048 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 5 Oct 2026 16:18:29 -0500 Subject: [PATCH 47/77] fix: keep Paykit request controls responsive during refresh --- .../repositories/PaykitPaymentRequestRepo.kt | 131 ++++++++--- .../java/to/bitkit/repositories/PubkyRepo.kt | 7 +- .../to/bitkit/services/PaykitSdkService.kt | 7 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 5 + ...aykitPaymentRequestRepoSubscriptionTest.kt | 27 +++ .../PaykitPaymentRequestRepoTest.kt | 220 ++++++++++++++++-- .../to/bitkit/repositories/PubkyRepoTest.kt | 51 ++++ .../bitkit/services/PaykitSdkServiceTest.kt | 56 +++++ .../viewmodels/AppViewModelSendFlowTest.kt | 21 ++ changelog.d/next/1401.changed.md | 2 +- journeys/subscriptions/create-and-propose.xml | 2 +- 11 files changed, 473 insertions(+), 56 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index f8b6ea961b..252d555b02 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -2,6 +2,7 @@ package to.bitkit.repositories +import com.synonym.paykit.LinkedPeerRecord import com.synonym.paykit.LinkedPeerState import com.synonym.paykit.OutboundPrivateCounterpartySendReport import com.synonym.paykit.OutboundPrivateMessageStatus @@ -280,6 +281,7 @@ class PaykitPaymentRequestRepo @Inject constructor( } private val operationMutex = Mutex() + private val refreshMutex = Mutex() private val targetDiscoveryMutex = Mutex() private val creationMutex = Mutex() private val processingLock = Any() @@ -288,6 +290,8 @@ class PaykitPaymentRequestRepo @Inject constructor( 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 @@ -324,6 +328,13 @@ class PaykitPaymentRequestRepo @Inject constructor( 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() @@ -477,26 +488,39 @@ class PaykitPaymentRequestRepo @Inject constructor( val refreshVersion = completedRefreshVersion.get() return withContext(ioDispatcher) { runSuspendCatching { - operationMutex.withLock { - if (!isAvailable()) { - clearStateLocked() - return@withLock - } - if (!isCurrentState(generation, expectedIdentity)) return@withLock - val proofVersion = paymentProofStore.backupStateVersion.value - val hasFreshSnapshot = completedRefreshVersion.get() != refreshVersion && - completedRefreshGeneration == generation && completedRefreshProofVersion == proofVersion - if (!forceFresh && hasFreshSnapshot && mode <= completedRefreshMode) { - return@withLock - } - runSuspendCatching { synchronizeLocked(generation, expectedIdentity, mode) } - .onFailure { discardExpiredRequestsLocked() } - .getOrThrow() - if (isCurrentState(generation, expectedIdentity)) { - completedRefreshMode = mode - completedRefreshGeneration = generation - completedRefreshProofVersion = proofVersion - completedRefreshVersion.incrementAndGet() + 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 + receivedContacts = null + RefreshCheckpoint(actionVersion, proofVersion) + } ?: return@refresh + val snapshot = runSuspendCatching { fetchRequestSnapshot(expectedIdentity, mode) } + operationMutex.withLock { + if (!isCurrentState(generation, expectedIdentity)) return@withLock + runSuspendCatching { + val records = snapshot.getOrThrow() + if (checkpoint.actionVersion != actionVersion) { + synchronizeLocked(generation, expectedIdentity, PaykitPaymentRequestRefreshMode.STORED) + } else { + applyRequestSnapshotLocked(records, generation, expectedIdentity) + } + }.onFailure { discardExpiredRequestsLocked() }.getOrThrow() + if (isCurrentState(generation, expectedIdentity)) { + completedRefreshMode = mode + completedRefreshGeneration = generation + completedRefreshProofVersion = checkpoint.proofVersion + completedRefreshActionVersion = actionVersion + completedRefreshVersion.incrementAndGet() + } } } }.onFailure { @@ -633,6 +657,7 @@ class PaykitPaymentRequestRepo @Inject constructor( acceptedPaymentEndpointIdentifiers = endpointIdentifiers, metadataJson = JsonObject(mapOf("note" to JsonPrimitive(note))).toString(), ) + actionVersion++ val record = paykitSdkService.proposePaymentRequest( counterparty = target.publicKey, proposal = proposal, @@ -645,7 +670,7 @@ class PaykitPaymentRequestRepo @Inject constructor( target = target, endpointIdentifiers = endpointIdentifiers, createdAt = proposalDate, - reports = reports, + wasSent = proposalWasSent(record, reports, generation, expectedIdentity), ) publishCreatedRequest(request, generation, expectedIdentity) } @@ -706,12 +731,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, proposal = proposal, expectedIdentity = expectedIdentity, ) - val deliveryStatus = if (proposalWasSent(record, processPendingMessages(target.publicKey))) { + val reports = processPendingMessages(target.publicKey) + val deliveryStatus = if (proposalWasSent(record, reports, generation, expectedIdentity)) { PaykitPaymentRequestDeliveryStatus.Sent } else { PaykitPaymentRequestDeliveryStatus.Queued @@ -794,16 +821,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) && 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).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( @@ -966,6 +1012,7 @@ 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.paymentRequestId, @@ -1024,23 +1071,41 @@ class PaykitPaymentRequestRepo @Inject constructor( } } - @Suppress("LongMethod", "CyclomaticComplexMethod") private suspend fun synchronizeLocked( generation: Long, expectedIdentity: String?, mode: PaykitPaymentRequestRefreshMode = PaykitPaymentRequestRefreshMode.FULL, ) { + receivedContacts = null + val snapshot = fetchRequestSnapshot(expectedIdentity, mode) + applyRequestSnapshotLocked(snapshot, generation, expectedIdentity) + } + + private suspend fun fetchRequestSnapshot( + expectedIdentity: String?, + mode: PaykitPaymentRequestRefreshMode, + ): RequestSnapshot { if (mode == PaykitPaymentRequestRefreshMode.FULL) processPendingMessages() if (mode != PaykitPaymentRequestRefreshMode.STORED) { paykitSdkService.receivePrivateMessagesFromLinkedPeers().also(::logIntakeFailures) } + val allRecords = paykitSdkService.allPaymentRequests(expectedIdentity) + val blockedPeers = paykitSdkService.linkedPeers().filter { it.state == LinkedPeerState.BLOCKED } + return RequestSnapshot(allRecords, blockedPeers) + } + + @Suppress("LongMethod", "CyclomaticComplexMethod") + private suspend fun applyRequestSnapshotLocked( + snapshot: RequestSnapshot, + generation: Long, + expectedIdentity: String?, + ) { val now = clock.now() val subscriptionNow = subscriptionClock.now() - receivedContacts = null - val allRecords = paykitSdkService.allPaymentRequests(expectedIdentity) + val allRecords = snapshot.records val contacts = PaykitReceivedPaymentContacts.from(allRecords, Env.network) val records = allRecords.filter(::isBitkitPaymentRequest) - val blockedPeers = paykitSdkService.linkedPeers().filter { it.state == LinkedPeerState.BLOCKED } + val blockedPeers = snapshot.blockedPeers val availableRecords = records.filterNot { record -> blockedPeers.any { PubkyPublicKeyFormat.matches(it.counterparty, record.counterparty) @@ -1304,6 +1369,7 @@ class PaykitPaymentRequestRepo @Inject constructor( val current = _pendingRequests.value.firstOrNull { it.id == request.id } ?: throw PaykitPaymentRequestError.RequestUnavailable + actionVersion++ operation(current) processPendingMessages() val updatedRequest = current.copy(lifecycleState = resultingState) @@ -1328,6 +1394,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 @@ -1710,14 +1777,8 @@ 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) && - messageId in report.report?.sent.orEmpty() - } - } == true return PaykitPaymentRequest( paymentRequestId = paymentRequestId, counterparty = target.publicKey, diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index ca1652eb65..1e7ace6b57 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -936,6 +936,7 @@ class PubkyRepo @Inject constructor( requireNotNull(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)) { "No session available" } + clearSessionContactProfiles() deleteAllContacts() runSuspendCatching { pubkyService.deletePaykitProfile() @@ -1078,9 +1079,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 diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index b76d7a5c6a..7cda5c770d 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -1070,9 +1070,10 @@ class PaykitSdkService @Inject constructor( suspend fun resolvePublicContactPayment( counterparty: String, ): PaykitPublicContactPaymentResolution { - isSetup.await() - val resolution = operationLock.withLock(PaykitSdkOperationLock.Priority.Interactive) { - handle().resolvePublicContactPayment(counterparty, 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() } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 059ddbe7e7..04ef25be26 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -5770,6 +5770,11 @@ class AppViewModel @Inject constructor( toast(error) hideSheet() } + if (contactPaymentContext?.incomingPaymentRequest != null) { + viewModelScope.launch { + refreshIncomingPaykitPaymentRequests(PaykitPaymentRequestRefreshMode.STORED, forceFresh = true) + } + } } fun handleDeeplinkIntent(intent: Intent) { diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt index 9895df91e4..b3beffb609 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt @@ -381,6 +381,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() diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 58ef588488..432c545f88 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -9,6 +9,8 @@ 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 @@ -24,6 +26,7 @@ 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 @@ -43,6 +46,7 @@ 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 @@ -169,6 +173,76 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { verify(paykitSdkService, times(2)).receivePrivateMessagesFromLinkedPeers() } + @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() + } + } + + @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() @@ -392,7 +466,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @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.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) @@ -416,9 +490,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()) @@ -1114,10 +1188,11 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { runCurrent() val marking = async { sut.markPresented(request) } runCurrent() - assertFalse(marking.isCompleted) + assertTrue(marking.isCompleted) + assertTrue(marking.await()) + assertTrue(sut.automaticPendingRequests().isEmpty()) finishRefresh.complete(Unit) refresh.await().getOrThrow() - assertTrue(marking.await()) sut.refresh(PaykitPaymentRequestRefreshMode.STORED).getOrThrow() assertEquals(listOf(request), sut.pendingRequests.value) @@ -1141,7 +1216,7 @@ 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() @@ -1157,16 +1232,13 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { val refresh = async { sut.refresh() } runCurrent() refreshStarted.await() - val marking = async { sut.markPresented(request) } - runCurrent() - assertFalse(marking.isCompleted) - 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() - assertFalse(marking.await()) verify(presentationStore, never()).save(any(), any()) assertTrue(sut.pendingRequests.value.isEmpty()) @@ -1226,6 +1298,116 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(creation.wasPublishedToActiveState) } + @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) + 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 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) @@ -1280,6 +1462,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { 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) @@ -1303,7 +1486,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { 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()) @@ -1325,6 +1508,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals(LOCAL_IDENTITY, creation.creatorIdentity) assertFalse(creation.wasPublishedToActiveState) assertTrue(sut.paymentRequestHistory.value.isEmpty()) + verify(paykitSdkService, never()).allPaymentRequests(anyOrNull()) } @Test @@ -1868,6 +2052,16 @@ 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)), diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 9c1e42c41d..31e65ac571 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -1189,6 +1189,57 @@ 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) + 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).removeContact(VALID_CONTACT_KEY_A) + verify(pubkyService).removeContact(VALID_CONTACT_KEY_B) + } + @Test fun `deleteProfile should fail when signOut fails`() = test { authenticateForTesting() diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index b9d5ed28a5..d5c1cf797a 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -28,7 +28,9 @@ 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.PublicPaymentResolutionStatus import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Deferred @@ -839,14 +841,68 @@ class PaykitSdkServiceTest { val lockedGate = CompletableDeferred>() whenever(sdk.contactRecords()).doSuspendableAnswer { lockedGate.await() } 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() diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 33814647f4..bc8d294309 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -6778,6 +6778,18 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `execution claim failure blocks versionless request without consuming private details`() = test { val request = paymentRequest() 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(paykitPaymentRequestRepo.claimForPayment(request)) .thenReturn(Result.failure(IllegalStateException("request claimed by another app"))) @@ -6801,6 +6813,15 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) confirmCurrentPayment() + + 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()) diff --git a/changelog.d/next/1401.changed.md b/changelog.d/next/1401.changed.md index d0e76b9f8d..374ff5fc7d 100644 --- a/changelog.d/next/1401.changed.md +++ b/changelog.d/next/1401.changed.md @@ -1 +1 @@ -Share Paykit contacts and payment state with authorized apps while keeping wallet keys private, keep accepted one-time requests payable only on the accepting install, prevent paid subscription periods from being offered again, and label received payments with their Paykit payer contact. +Share Paykit contacts and payment state with authorized apps while keeping wallet keys private, keep accepted one-time requests payable only on the accepting install, prevent paid subscription periods from being offered again, keep request controls responsive during background refresh, show confirmed proposal delivery accurately, and label received payments with their Paykit payer contact. diff --git a/journeys/subscriptions/create-and-propose.xml b/journeys/subscriptions/create-and-propose.xml index 373f7b97a8..5c590f8dc9 100644 --- a/journeys/subscriptions/create-and-propose.xml +++ b/journeys/subscriptions/create-and-propose.xml @@ -25,6 +25,6 @@ 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" From 98a15701fee441fca306c087f13c07aa0cfa42f8 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 5 Oct 2026 17:20:51 -0500 Subject: [PATCH 48/77] fix: contain identity failures during proof cleanup --- .../repositories/PaykitPaymentProofRepo.kt | 10 ++++----- .../PaykitPaymentProofRepoTest.kt | 21 +++++++++++++++++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index 16eb2fb57a..540f18dced 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -608,9 +608,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) } @@ -623,8 +623,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) { diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index 36b9139fc4..bcf6c7d5ea 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -2,6 +2,7 @@ package to.bitkit.repositories import com.synonym.paykit.BillingPeriod import com.synonym.paykit.IdentityStatus +import com.synonym.paykit.PaykitException import com.synonym.paykit.PaymentProofRecord import com.synonym.paykit.PaymentReference import com.synonym.paykit.PaymentRequestAmount @@ -11,6 +12,7 @@ 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 @@ -46,6 +48,7 @@ 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 @@ -533,6 +536,24 @@ 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) From bb6cef73a939a01e9dddeba5afa6f3436ed26fe9 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 5 Oct 2026 18:40:50 -0500 Subject: [PATCH 49/77] fix: retry pending endpoint cleanup during maintenance --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 4 +++ .../repositories/PrivatePaykitRepoTest.kt | 5 +++ .../viewmodels/AppViewModelSendFlowTest.kt | 36 +++++++++++++++++++ 3 files changed, 45 insertions(+) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 04ef25be26..45f9f4619a 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -931,6 +931,10 @@ class AppViewModel @Inject constructor( 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", + ) privatePaykitRepo.refreshKnownSavedContactEndpoints("payment request polling") } refreshIncomingPaykitPaymentRequests( diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 6b8c54efe9..5f4fb7787c 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -45,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 @@ -762,6 +763,10 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertFalse(cacheData.value.cleanupPending) assertTrue(cacheData.value.contacts.isEmpty()) verify(publicPaykitRepo).syncPaykitApp() + + clearInvocations(paykitSdkService, publicPaykitRepo) + sut.retryPendingEndpointRemoval(emptyList()).getOrThrow() + verifyNoInteractions(paykitSdkService, publicPaykitRepo) } } } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index bc8d294309..0770b42113 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -857,6 +857,42 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } } + @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() From b101e89e00015481e4b0ce91596b6cedd9a50302 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 5 Oct 2026 23:35:34 -0500 Subject: [PATCH 50/77] fix: prepare incoming payments in the send sheet --- .../ui/components/DrawerMenuWidgetsTest.kt | 6 + .../PaymentRequestsScreenTest.kt | 27 +++ .../wallets/send/SendConfirmScreenTest.kt | 124 +++++++++- .../repositories/PaykitPaymentRequestRepo.kt | 28 ++- .../bitkit/repositories/PrivatePaykitRepo.kt | 19 +- .../to/bitkit/services/PaykitSdkService.kt | 37 ++- app/src/main/java/to/bitkit/ui/ContentView.kt | 28 +-- .../java/to/bitkit/ui/components/SheetHost.kt | 2 + .../IncomingPaymentRequestDetailsScreen.kt | 10 +- .../screens/wallets/send/SendConfirmScreen.kt | 72 ++++-- .../java/to/bitkit/ui/sheets/SendSheet.kt | 23 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 176 ++++++++++++-- ...aykitPaymentRequestRepoSubscriptionTest.kt | 8 + .../PaykitPaymentRequestRepoTest.kt | 26 +- .../repositories/PrivatePaykitRepoTest.kt | 10 + .../bitkit/services/PaykitSdkServiceTest.kt | 117 ++++++++- .../viewmodels/AppViewModelSendFlowTest.kt | 223 +++++++++++++++++- changelog.d/next/1401.changed.md | 2 +- journeys/payment-requests/README.md | 6 + .../automatic-presentation.xml | 15 +- .../issuer-interoperability.xml | 4 +- .../requested-resolution-failure.xml | 8 +- 22 files changed, 846 insertions(+), 125 deletions(-) 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/screens/paymentrequests/PaymentRequestsScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt index c8ede596a7..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") 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/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 252d555b02..19f72a6950 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -54,6 +54,7 @@ import to.bitkit.models.satsToMsat import to.bitkit.services.PaykitPaymentRequestProposalTerms import to.bitkit.services.PaykitPaymentRequestRecurrenceTerms import to.bitkit.services.PaykitReadLane +import to.bitkit.services.PaykitSdkOperationLock.Priority import to.bitkit.services.PaykitSdkService import to.bitkit.services.isBitkitPaymentRequest import to.bitkit.utils.AppError @@ -503,7 +504,10 @@ class PaykitPaymentRequestRepo @Inject constructor( receivedContacts = null RefreshCheckpoint(actionVersion, proofVersion) } ?: return@refresh - val snapshot = runSuspendCatching { fetchRequestSnapshot(expectedIdentity, mode) } + val snapshot = runSuspendCatching { + val priority = if (forceFresh) Priority.Ordered else Priority.Background + fetchRequestSnapshot(expectedIdentity, mode, priority) + } operationMutex.withLock { if (!isCurrentState(generation, expectedIdentity)) return@withLock runSuspendCatching { @@ -551,7 +555,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) } @@ -594,7 +598,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() @@ -641,7 +645,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() @@ -775,7 +779,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() @@ -840,7 +844,7 @@ class PaykitPaymentRequestRepo @Inject constructor( } if (failedByDrain || !isCurrentState(generation, expectedIdentity)) return false return runSuspendCatching { - paykitSdkService.allPaymentRequests(expectedIdentity).any { + paykitSdkService.allPaymentRequests(expectedIdentity, Priority.Interactive).any { PubkyPublicKeyFormat.matches(it.counterparty, record.counterparty) && it.paymentRequestId == record.paymentRequestId && it.localRole == PaymentRequestLocalRole.PAYEE && @@ -1084,13 +1088,14 @@ class PaykitPaymentRequestRepo @Inject constructor( private suspend fun fetchRequestSnapshot( expectedIdentity: String?, mode: PaykitPaymentRequestRefreshMode, + priority: Priority = Priority.Ordered, ): RequestSnapshot { if (mode == PaykitPaymentRequestRefreshMode.FULL) processPendingMessages() if (mode != PaykitPaymentRequestRefreshMode.STORED) { paykitSdkService.receivePrivateMessagesFromLinkedPeers().also(::logIntakeFailures) } - val allRecords = paykitSdkService.allPaymentRequests(expectedIdentity) - val blockedPeers = paykitSdkService.linkedPeers().filter { it.state == LinkedPeerState.BLOCKED } + val allRecords = paykitSdkService.allPaymentRequests(expectedIdentity, priority) + val blockedPeers = paykitSdkService.linkedPeers(priority).filter { it.state == LinkedPeerState.BLOCKED } return RequestSnapshot(allRecords, blockedPeers) } @@ -1246,12 +1251,13 @@ 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?.capability != PubkyIdentityCapability.PRIVATE_LINK_CAPABLE || @@ -1259,7 +1265,7 @@ class PaykitPaymentRequestRepo @Inject constructor( ) { return null } - val linkedPublicKeys = paykitSdkService.linkedPeers() + val linkedPublicKeys = paykitSdkService.linkedPeers(priority) .filter { it.state == LinkedPeerState.LINKED } .mapNotNull { PubkyPublicKeyFormat.normalized(it.counterparty) } .toSet() @@ -1476,7 +1482,7 @@ class PaykitPaymentRequestRepo @Inject constructor( listOf( OutboundPrivateCounterpartySendReport( counterparty = counterparty, - report = paykitSdkService.processOutboundPrivateMessages(counterparty), + report = paykitSdkService.processOutboundPrivateMessages(counterparty, Priority.Interactive), error = null, ), ) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 3a50e25967..7978e37e85 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -45,6 +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.PaykitSdkOperationLock.Priority import to.bitkit.services.PaykitSdkService import to.bitkit.services.PubkyService import to.bitkit.utils.Logger @@ -1045,13 +1046,14 @@ class PrivatePaykitRepo @Inject constructor( reason: String, retryKeys: Collection, includeUnsavedPeers: Boolean = false, + priority: Priority = Priority.Ordered, ) { val retryKeys = retryKeys.mapNotNull(::normalizedPublicKey).toSet() currentCoroutineContext().ensureActive() if (retryKeys.isEmpty()) return runSuspendCatching { val generation = preparationGeneration - val alreadyLinkedKeys = paykitSdkService.linkedPeers().filter { it.state == LinkedPeerState.LINKED } + val alreadyLinkedKeys = paykitSdkService.linkedPeers(priority).filter { it.state == LinkedPeerState.LINKED } .mapNotNull { normalizedPublicKey(it.counterparty) }.toSet() (retryKeys - alreadyLinkedKeys).forEach { retryKey -> currentCoroutineContext().ensureActive() @@ -1072,7 +1074,7 @@ class PrivatePaykitRepo @Inject constructor( } currentCoroutineContext().ensureActive() if (generation != preparationGeneration) return@runSuspendCatching - val pendingKeys = paykitSdkService.pendingOutboundPrivateCounterparties() + val pendingKeys = paykitSdkService.pendingOutboundPrivateCounterparties(priority) .mapNotNull(::normalizedPublicKey).toSet().intersect(retryKeys) pendingKeys.forEach { publicKey -> currentCoroutineContext().ensureActive() @@ -1085,7 +1087,7 @@ class PrivatePaykitRepo @Inject constructor( } currentCoroutineContext().ensureActive() if (generation != preparationGeneration) return@runSuspendCatching - val linkedKeys = paykitSdkService.linkedPeers().filter { it.state == LinkedPeerState.LINKED } + val linkedKeys = paykitSdkService.linkedPeers(priority).filter { it.state == LinkedPeerState.LINKED } .mapNotNull { normalizedPublicKey(it.counterparty) }.toSet().intersect(retryKeys) linkedKeys.forEach { publicKey -> currentCoroutineContext().ensureActive() @@ -1138,9 +1140,9 @@ class PrivatePaykitRepo @Inject constructor( pendingMessageDrainRetryKeys.toList() } if (retryKeys.isEmpty()) return@withContext - val pendingKeys = pendingPrivateMessageDrainKeys(retryKeys) + val pendingKeys = pendingPrivateMessageDrainKeys(retryKeys, priority = Priority.Background) if (pendingKeys.isNotEmpty()) { - drainPendingPrivateMessages(reason, retryKeys = pendingKeys) + drainPendingPrivateMessages(reason, retryKeys = pendingKeys, priority = Priority.Background) } updatePendingMessageDrainRetryKeys(retryKeys) } @@ -1159,7 +1161,7 @@ class PrivatePaykitRepo @Inject constructor( } private suspend fun updatePendingMessageDrainRetryKeys(retryKeys: Collection) { - val remainingKeys = pendingPrivateMessageDrainKeys(retryKeys) + val remainingKeys = pendingPrivateMessageDrainKeys(retryKeys, priority = Priority.Background) synchronized(pendingMessageDrainRetryLock) { pendingMessageDrainRetryKeys.removeAll(retryKeys.toSet()) pendingMessageDrainRetryKeys.addAll(remainingKeys) @@ -1169,11 +1171,12 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun pendingPrivateMessageDrainKeys( 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 @@ -1184,7 +1187,7 @@ class PrivatePaykitRepo @Inject constructor( } } .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 diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 7cda5c770d..0c9b181a99 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -107,6 +107,7 @@ 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 javax.inject.Inject @@ -824,9 +825,12 @@ class PaykitSdkService @Inject constructor( } } - suspend fun processOutboundPrivateMessages(counterparty: String) = run { + suspend fun processOutboundPrivateMessages(counterparty: String) = + processOutboundPrivateMessages(counterparty, Priority.Ordered) + + internal suspend fun processOutboundPrivateMessages(counterparty: String, priority: Priority) = run { isSetup.await() - operationLock.withLock { + operationLock.withLock(priority) { withStateRevisionTracking { handle -> completeSdkCall { handle.processOutboundPrivateMessages(counterparty) } } @@ -844,9 +848,15 @@ class PaykitSdkService @Inject constructor( suspend fun paymentRequests(): List = allPaymentRequests().filter(::isBitkitPaymentRequest) - suspend fun allPaymentRequests(expectedIdentity: String? = null): List { + 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 { + return operationLock.withLock(priority) { withPaykitKey { handle -> if (expectedIdentity != null) { val identity = completeSdkCall { handle.identityStatus() }?.publicKey @@ -869,9 +879,11 @@ class PaykitSdkService @Inject constructor( } } - suspend fun identityStatus(): IdentityStatus? { + suspend fun identityStatus(): IdentityStatus? = identityStatus(Priority.Ordered) + + internal suspend fun identityStatus(priority: Priority): IdentityStatus? { isSetup.await() - return operationLock.withLock { + return operationLock.withLock(priority) { withPaykitKey { completeSdkCall { it.identityStatus() } } } } @@ -1005,16 +1017,21 @@ class PaykitSdkService @Inject constructor( } } - suspend fun linkedPeers(): List { + suspend fun linkedPeers(): List = linkedPeers(Priority.Ordered) + + internal suspend fun linkedPeers(priority: Priority): List { isSetup.await() - return operationLock.withLock { + 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 { + return operationLock.withLock(priority) { withPaykitKey { completeSdkCall { it.pendingOutboundPrivateCounterparties() } } } } diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index e920204e26..28a99984b7 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -7,12 +7,8 @@ import android.content.Intent import android.os.Build import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.DrawerState import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api @@ -32,11 +28,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.Lifecycle @@ -81,7 +73,6 @@ import to.bitkit.ui.components.BottomSheetOverlayHost import to.bitkit.ui.components.BottomSheetOverlayState import to.bitkit.ui.components.DefaultSheetContainerColor import to.bitkit.ui.components.DrawerMenu -import to.bitkit.ui.components.GradientCircularProgressIndicator import to.bitkit.ui.components.Sheet import to.bitkit.ui.components.SheetHandlePlacement import to.bitkit.ui.components.SheetHost @@ -280,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 @@ -492,7 +484,6 @@ fun ContentView( val isPaykitEnabled by settingsViewModel.isPaykitEnabled.collectAsStateWithLifecycle() val showWidgets by settingsViewModel.showWidgets.collectAsStateWithLifecycle() val currentSheet by appViewModel.currentSheet.collectAsStateWithLifecycle() - val requestedPaymentRequestId by appViewModel.requestedPaymentRequestId.collectAsStateWithLifecycle() val isCreatingPaymentRequest by appViewModel.isCreatingPaymentRequest.collectAsStateWithLifecycle() val hwSendViewModel = hiltViewModel() val hwSendUiState by hwSendViewModel.uiState.collectAsStateWithLifecycle() @@ -549,6 +540,7 @@ fun ContentView( walletViewModel = walletViewModel, startDestination = sheet.route, hardwareWalletId = sheet.hardwareWalletId, + preparingRequest = sheet.preparingRequest, hwSendViewModel = hwSendViewModel, ) } @@ -704,7 +696,6 @@ fun ContentView( onHomeCalculatorInputActiveChanged = { isHomeCalculatorInputActive = it }, ) - val navBackStackEntry by navController.currentBackStackEntryAsState() val currentRoute = navBackStackEntry?.destination?.route LaunchedEffect( isPaykitEnabled, @@ -784,18 +775,9 @@ fun ContentView( BottomSheetOverlayHost(state = bottomSheetOverlayState) - val canPreparePaymentRequest = isPaykitEnabled && isProfileAuthenticated - if (canPreparePaymentRequest && requestedPaymentRequestId != null && currentSheet == null) { - val description = stringResource(R.string.wallet__payment_request) - GradientCircularProgressIndicator( - modifier = Modifier - .align(Alignment.Center) - .background(Colors.Gray6, RoundedCornerShape(8.dp)) - .padding(24.dp) - .size(32.dp) - .semantics { contentDescription = description } - .testTag("PaymentRequestPreparing") - ) + val hasOverlaySheet = bottomSheetOverlayState.entries.isNotEmpty() || drawerState.isOpen + LaunchedEffect(hasOverlaySheet) { + appViewModel.setPaymentRequestOverlayVisible(hasOverlaySheet) } } } 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..0127ea5ef7 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, 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/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..0b4d07a3bb 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() @@ -302,6 +309,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() @@ -593,7 +614,7 @@ fun SendSheet( } AnimatedVisibility( - visible = isOffline, + visible = isOffline && preparingRequest == null, enter = fadeIn(), exit = fadeOut(), ) { diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 45f9f4619a..9f3e2c6aaf 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 @@ -326,6 +329,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 @@ -388,6 +392,8 @@ class AppViewModel @Inject constructor( private val pendingContactPaymentContexts = mutableMapOf() 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 @@ -395,6 +401,8 @@ class AppViewModel @Inject constructor( 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 @@ -425,7 +433,10 @@ class AppViewModel @Inject constructor( fun setIsAuthenticated(value: Boolean) { _isAuthenticated.value = value - if (!value) return + if (!value) { + clearPaymentRequestPreparation() + return + } markDeferredScanUnlocked() @@ -913,6 +924,7 @@ class AppViewModel @Inject constructor( } fun startPaykitPaymentRequestPolling() { + isPaymentRequestPollingStopped = false if (paykitPaymentRequestPollingJob?.isActive == true) return paykitPaymentRequestPollingJob = viewModelScope.launch { @@ -984,20 +996,34 @@ class AppViewModel @Inject constructor( } fun stopPaykitPaymentRequestPolling() { + isPaymentRequestPollingStopped = true + clearPaymentRequestPreparation() paykitPaymentRequestPollingJob?.cancel() paykitPaymentRequestPollingJob = null clearPaymentRequestPresentationRetries() } 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 { paykitPaymentProofRepo.paymentRequestStateChanges(pubkyRepo.publicKey).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() } } @@ -1034,7 +1060,7 @@ 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 if (!paykitPaymentRequestRepo.isPending(request)) return completePaymentRequestPresentation(request) @@ -1085,6 +1111,7 @@ class AppViewModel @Inject constructor( request.id in paymentRequestPresentationCompletionJobs } !paykitPaymentRequestRepo.isProcessing(request) && + request.id !in dismissedPreparingRequestIds && !isCompleting && paymentRequestPresentationRetryJobs[request.id]?.isActive != true }.takeIf { it.isNotEmpty() } @@ -1129,9 +1156,37 @@ class AppViewModel @Inject constructor( request: PaykitPaymentRequest, generation: Long, ): Boolean { - val presentationResult = privatePaykitRepo.beginPaymentRequest(request) - val result = presentationResult.getOrNull() - if (!isCurrentPaymentRequestPresentation(request, generation) || isPaymentRequestPresentationBlocked()) { + val preparation = 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) { + 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) { + 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() @@ -1147,6 +1202,7 @@ class AppViewModel @Inject constructor( } return false } + val result = presentationResult.getOrNull() if (result !is PublicPaykitPaymentResult.Opened) { if (result == PublicPaykitPaymentResult.PrivateLinkPending) { finishPrivateLinkPendingPaymentRequestPresentation(request) @@ -1159,7 +1215,6 @@ class AppViewModel @Inject constructor( } return false } - openContactPayment( paymentRequest = result.paymentRequest, publicKey = request.counterparty, @@ -1184,6 +1239,7 @@ class AppViewModel @Inject constructor( } clearPaymentRequestPresentationRetry(request.id) if (!isRequested) return + finishPaymentRequestPreparation(paymentRequestPreparation.value) toast( type = Toast.ToastType.INFO, title = context.getString(R.string.wallet__payment_request), @@ -1192,11 +1248,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.value?.let { it == request.id } ?: true) + } private fun deferPaymentRequestPresentation( request: PaykitPaymentRequest, @@ -1292,6 +1354,7 @@ 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() @@ -1340,6 +1403,7 @@ class AppViewModel @Inject constructor( dismissActiveRequest: Boolean, preserveRequestedPaymentRequest: Boolean, ) { + dismissedPreparingRequestIds.clear() invalidatePaymentRequestPresentation(dismissActiveRequest) clearPaymentRequestPresentationRetries() val completions = synchronized(contactPaymentContextLock) { @@ -1361,10 +1425,51 @@ class AppViewModel @Inject constructor( requestedPaymentRequestTags = persistentListOf() } + 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 @@ -2409,6 +2514,7 @@ class AppViewModel @Inject constructor( ) } + @Suppress("LongMethod") private fun launchScan( source: ScanSource, data: String, @@ -2419,6 +2525,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, @@ -2451,6 +2560,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 @@ -2596,13 +2706,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 @@ -3138,6 +3249,7 @@ class AppViewModel @Inject constructor( .onSuccess { logDecodedScan(it, isPaymentRequest) } .getOrNull() + currentCoroutineContext().ensureActive() handleDecodedScan(scan, input, fromMainScanner, suppressQuickPay) } @@ -3260,7 +3372,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 @@ -3279,6 +3392,9 @@ class AppViewModel @Inject constructor( request } if (interruptedRequest == null) return + paymentRequestPreparation.value?.takeIf { it.request.id == interruptedRequest.id }?.let { + finishPaymentRequestPreparation(it) + } if (!retryIncomingRequest) { paymentRequestPresentationGeneration++ @@ -3837,6 +3953,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 @@ -5078,6 +5204,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 @@ -5114,6 +5241,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() @@ -5637,6 +5770,7 @@ class AppViewModel @Inject constructor( toast(PaykitPaymentRequestError.OperationInProgress) return } + dismissedPreparingRequestIds.remove(id) invalidatePaymentRequestPresentation() clearPaymentRequestPresentationRetry(id) _requestedPaymentRequestId.update { id } @@ -6176,6 +6310,14 @@ private data class PaymentProofPreparation( 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, diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt index b3beffb609..6f4160533e 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt @@ -102,6 +102,14 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat whenever(paykitSdkService.receivePrivateMessagesFromLinkedPeers()).thenReturn(emptyList()) 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()) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 432c545f88..c48e3b65c1 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -61,6 +61,7 @@ import to.bitkit.data.SettingsStore import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.services.PaykitPaymentRequestProposalTerms import to.bitkit.services.PaykitReadLane +import to.bitkit.services.PaykitSdkOperationLock.Priority import to.bitkit.services.PaykitSdkService import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals @@ -115,6 +116,14 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.receivePrivateMessagesFromLinkedPeers()).thenReturn(emptyList()) 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()) @@ -171,6 +180,8 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { verify(paykitSdkService).processPendingPrivateMessages() verify(paykitSdkService, times(2)).receivePrivateMessagesFromLinkedPeers() + verify(paykitSdkService, times(3)).allPaymentRequests(LOCAL_IDENTITY, Priority.Background) + verify(paykitSdkService, times(3)).linkedPeers(Priority.Background) } @Test @@ -218,6 +229,8 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { 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) } } @@ -373,6 +386,10 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { 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 @@ -1296,6 +1313,9 @@ 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 @@ -1325,7 +1345,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(creation.wasPublishedToActiveState) assertEquals(LOCAL_IDENTITY, creation.creatorIdentity) } - verify(paykitSdkService, times(cases.size)).allPaymentRequests(LOCAL_IDENTITY) + verify(paykitSdkService, times(cases.size)).allPaymentRequests(LOCAL_IDENTITY, Priority.Interactive) verify(paykitSdkService, times(cases.size)).proposePaymentRequest(any(), any(), eq(LOCAL_IDENTITY)) } @@ -1669,6 +1689,10 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() 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 diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 5f4fb7787c..908323280f 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -59,6 +59,7 @@ import to.bitkit.services.CoreService import to.bitkit.services.PaykitPreparedPrivateContactPayment import to.bitkit.services.PaykitPrivateContactPaymentResolution 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 @@ -143,6 +144,10 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { LinkedPeerHandshakeReport(it.getArgument(0), LinkedPeerState.LINKED, 1uL, null) } whenever { paykitSdkService.pendingOutboundPrivateCounterparties() }.thenReturn(emptyList()) + whenever(paykitSdkService.linkedPeers(any())).doSuspendableAnswer { paykitSdkService.linkedPeers() } + whenever(paykitSdkService.pendingOutboundPrivateCounterparties(any())).doSuspendableAnswer { + paykitSdkService.pendingOutboundPrivateCounterparties() + } whenever { paykitSdkService.clearPrivatePaymentLists(any()) }.thenAnswer { privateListDeliveryReport(clearedCounterparties = it.getArgument(0)) } @@ -446,6 +451,8 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.closeAndClear() verifyBlocking(paykitSdkService, atLeast(8)) { ensureLinkWithPeer(CONTACT_KEY) } + verify(paykitSdkService, atLeast(1)).linkedPeers(Priority.Background) + verify(paykitSdkService, atLeast(1)).pendingOutboundPrivateCounterparties(Priority.Background) verify(paykitSdkService, never()).processOutboundPrivateMessages(any()) verify(paykitSdkService, never()).receivePrivateMessages(any()) verify(paykitSdkService, never()).processPendingPrivateMessages() @@ -1099,6 +1106,9 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { verify(paykitSdkService, never()).ensureLinkWithPeer(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) diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index d5c1cf797a..b0f667d230 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -5,6 +5,7 @@ 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.PaykitApp import com.synonym.paykit.PaykitAppCapabilities import com.synonym.paykit.PaykitAppRegistry @@ -73,6 +74,7 @@ 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 @@ -251,8 +253,13 @@ class PaykitSdkServiceTest { } @Test - fun `cancelled private preparation finishes the active SDK call before releasing the queue`() = runTest { - for (isRequest in listOf(false, true)) { + 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) }, + ) + for (operation in operations) { val sdk = mock() val release = CompletableDeferred() var finished = false @@ -268,18 +275,17 @@ class PaykitSdkServiceTest { finished = true mock() } + whenever { sdk.processOutboundPrivateMessages(RING_PUBKY) }.doSuspendableAnswer { + release.await() + finished = true + mock() + } whenever { sdk.contactRecords() }.thenAnswer { assertTrue(finished) emptyList() } val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } - val preparation = async { - if (isRequest) { - service.prepareAndResolvePrivatePaymentRequest(RING_PUBKY, "request", null) - } else { - service.prepareAndResolvePrivateContactPayment(RING_PUBKY, null) - } - } + val preparation = async { service.operation() } runCurrent() val next = async { service.contactRecords() } preparation.cancel() @@ -296,6 +302,99 @@ class PaykitSdkServiceTest { } } + @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 `cancelling an active execution claim does not continue to accept`() = runTest { val sdk = mock() diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 0770b42113..b27f0cdc67 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -1094,25 +1094,223 @@ class AppViewModelSendFlowTest : BaseUnitTest() { runCurrent() val request = paymentRequest() val bolt11 = "lnbcrt1newpendingrequest" - whenever(privatePaykitRepo.beginPaymentRequest(request)).thenReturn( - Result.success( - PublicPaykitPaymentResult.Opened( - paymentRequest = bolt11, - privatePaymentContext = privatePaymentContext(version = 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(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 `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 @@ -1893,9 +2091,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() @@ -2175,8 +2376,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() @@ -2409,6 +2614,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) @@ -2752,6 +2958,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.stopPaykitPaymentRequestPolling() assertFalse(isPresentingPaymentRequest()) + assertNull((sut.currentSheet.value as? Sheet.Send)?.preparingRequest) } @Test diff --git a/changelog.d/next/1401.changed.md b/changelog.d/next/1401.changed.md index 374ff5fc7d..ae4cb6864f 100644 --- a/changelog.d/next/1401.changed.md +++ b/changelog.d/next/1401.changed.md @@ -1 +1 @@ -Share Paykit contacts and payment state with authorized apps while keeping wallet keys private, keep accepted one-time requests payable only on the accepting install, prevent paid subscription periods from being offered again, keep request controls responsive during background refresh, show confirmed proposal delivery accurately, and label received payments with their Paykit payer contact. +Share Paykit contacts and payment state with authorized apps while keeping wallet keys private, keep accepted one-time requests payable only on the accepting install, prevent paid subscription periods from being offered again, keep request controls responsive and prioritize active payment requests over routine background reads, show incoming request details in the payment confirmation sheet while payment options load, show confirmed proposal delivery accurately, and label received payments with their Paykit payer contact. diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index f4e092935b..76769cd305 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -92,3 +92,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/automatic-presentation.xml b/journeys/payment-requests/automatic-presentation.xml index 9fe2b05e1f..cc7ea79edc 100644 --- a/journeys/payment-requests/automatic-presentation.xml +++ b/journeys/payment-requests/automatic-presentation.xml @@ -5,19 +5,28 @@ 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, wait for the next private-message sync and 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" - On the payer, verify an insufficient-balance toast appears once and no payment confirmation opens + 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/issuer-interoperability.xml b/journeys/payment-requests/issuer-interoperability.xml index f07ea4b0b1..a2fa009c47 100644 --- a/journeys/payment-requests/issuer-interoperability.xml +++ b/journeys/payment-requests/issuer-interoperability.xml @@ -17,9 +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") - If confirmation is not already open, verify progress (id "PaymentRequestPreparing") is visible while the request is being prepared + 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 - Verify progress (id "PaymentRequestPreparing") is no longer visible + 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/requested-resolution-failure.xml b/journeys/payment-requests/requested-resolution-failure.xml index 5640c73418..7ced97ab57 100644 --- a/journeys/payment-requests/requested-resolution-failure.xml +++ b/journeys/payment-requests/requested-resolution-failure.xml @@ -13,14 +13,16 @@ Verify the incoming request row (testTag "PaymentRequestRow-<payment-request-id>") is visible Tap Pay (testTag "PaymentRequestPay-<payment-request-id>") - Verify progress (id "PaymentRequestPreparing") is visible while the request is being prepared + Verify the existing confirmation sheet (id "PaymentRequestConfirm") shows the saved sender, amount and note, with its payment control loading and disabled (id "PaymentRequestPreparing") 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 - Start another Pay attempt and verify progress (id "PaymentRequestPreparing") appears - While preparation is pending, navigate to Dev settings and turn off Enable Paykit UI (id "PaykitUiToggle") + 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 sheet and verify the request details remain available with Pay enabled for a manual retry + 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 From 9ffb08ac7720fac42af6b514f27a68da82a82f5b Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 6 Oct 2026 01:11:35 -0500 Subject: [PATCH 51/77] fix: prioritize incoming request work during polling --- .../repositories/PaykitPaymentRequestRepo.kt | 19 +++-- .../to/bitkit/services/PaykitSdkService.kt | 18 ++++- .../java/to/bitkit/viewmodels/AppViewModel.kt | 26 ++++++- ...aykitPaymentRequestRepoSubscriptionTest.kt | 6 ++ .../PaykitPaymentRequestRepoTest.kt | 65 +++++++++++++++- .../bitkit/services/PaykitSdkServiceTest.kt | 61 +++++++++++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 77 ++++++++++++++++++- changelog.d/next/1401.changed.md | 2 +- 8 files changed, 258 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 19f72a6950..280d395e6a 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -479,11 +479,18 @@ class PaykitPaymentRequestRepo @Inject constructor( 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): Result { + private suspend fun refresh( + mode: PaykitPaymentRequestRefreshMode, + forceFresh: Boolean, + messagePriority: Priority = Priority.Ordered, + ): Result { val generation = stateGeneration.get() val expectedIdentity = activeIdentity val refreshVersion = completedRefreshVersion.get() @@ -506,7 +513,7 @@ class PaykitPaymentRequestRepo @Inject constructor( } ?: return@refresh val snapshot = runSuspendCatching { val priority = if (forceFresh) Priority.Ordered else Priority.Background - fetchRequestSnapshot(expectedIdentity, mode, priority) + fetchRequestSnapshot(expectedIdentity, mode, priority, messagePriority) } operationMutex.withLock { if (!isCurrentState(generation, expectedIdentity)) return@withLock @@ -1089,10 +1096,11 @@ class PaykitPaymentRequestRepo @Inject constructor( expectedIdentity: String?, mode: PaykitPaymentRequestRefreshMode, priority: Priority = Priority.Ordered, + messagePriority: Priority = Priority.Ordered, ): RequestSnapshot { - if (mode == PaykitPaymentRequestRefreshMode.FULL) processPendingMessages() + if (mode == PaykitPaymentRequestRefreshMode.FULL) processPendingMessages(priority = messagePriority) if (mode != PaykitPaymentRequestRefreshMode.STORED) { - paykitSdkService.receivePrivateMessagesFromLinkedPeers().also(::logIntakeFailures) + paykitSdkService.receivePrivateMessagesFromLinkedPeers(messagePriority).also(::logIntakeFailures) } val allRecords = paykitSdkService.allPaymentRequests(expectedIdentity, priority) val blockedPeers = paykitSdkService.linkedPeers(priority).filter { it.state == LinkedPeerState.BLOCKED } @@ -1474,10 +1482,11 @@ class PaykitPaymentRequestRepo @Inject constructor( private suspend fun processPendingMessages( counterparty: String? = null, + priority: Priority = Priority.Ordered, ): List = runSuspendCatching { if (counterparty == null) { - paykitSdkService.processPendingPrivateMessages() + paykitSdkService.processPendingPrivateMessages(priority) } else { listOf( OutboundPrivateCounterpartySendReport( diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 0c9b181a99..177d5360bd 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -807,9 +807,14 @@ class PaykitSdkService @Inject constructor( } } - 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 -> completeSdkCall { handle.receivePrivateMessagesFromLinkedPeers() } } @@ -837,9 +842,14 @@ class PaykitSdkService @Inject constructor( } } - suspend fun processPendingPrivateMessages(): List { + suspend fun processPendingPrivateMessages(): List = + processPendingPrivateMessages(Priority.Ordered) + + internal suspend fun processPendingPrivateMessages( + priority: Priority, + ): List { isSetup.await() - return operationLock.withLock { + return operationLock.withLock(priority) { withStateRevisionTracking { handle -> completeSdkCall { handle.processPendingPrivateMessages() } } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 9f3e2c6aaf..895ccccaea 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -193,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 @@ -901,13 +902,14 @@ class AppViewModel @Inject constructor( private suspend fun refreshIncomingPaykitPaymentRequests( mode: PaykitPaymentRequestRefreshMode = PaykitPaymentRequestRefreshMode.FULL, forceFresh: Boolean = false, + messagePriority: Priority = Priority.Ordered, ) { if (!isPaykitEnabled.value || pubkyRepo.publicKey.value == null || !walletRepo.walletExists()) return if (mode == PaykitPaymentRequestRefreshMode.FULL) paykitPaymentProofRepo.reconcile() val result = if (forceFresh) { paykitPaymentRequestRepo.refreshAfterStateChange(mode) } else { - paykitPaymentRequestRepo.refresh(mode) + paykitPaymentRequestRepo.refresh(mode, messagePriority) } result.onSuccess { activityRepo.backfillPaykitContacts() @@ -929,7 +931,7 @@ class AppViewModel @Inject constructor( paykitPaymentRequestPollingJob = viewModelScope.launch { if (isOnline.value == ConnectivityState.CONNECTED) pubkyRepo.republishIdentityIfNeeded() - refreshIncomingPaykitPaymentRequests() + refreshIncomingPaykitPaymentRequests(messagePriority = Priority.Background) refreshPaymentRequestTargets() var maintenanceIntervalIndex = 0 var nextMaintenance = timeSource.markNow() + PAYKIT_MAINTENANCE_INTERVALS.first() @@ -947,20 +949,36 @@ class AppViewModel @Inject constructor( contactKeys = pubkyRepo.contacts.value.map { it.publicKey }, reason = "payment request polling", ) - privatePaykitRepo.refreshKnownSavedContactEndpoints("payment request polling") } + val refreshIdentity = pubkyRepo.publicKey.value refreshIncomingPaykitPaymentRequests( if (refreshMaintenance) { PaykitPaymentRequestRefreshMode.FULL } else { PaykitPaymentRequestRefreshMode.INBOX }, + messagePriority = Priority.Background, ) - if (refreshMaintenance) refreshPaymentRequestTargets(force = true) + if (refreshMaintenance) { + refreshIdlePaykitContactEndpoints(refreshIdentity) + refreshPaymentRequestTargets(force = true) + } } } } + 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) { paykitPaymentRequestRepo.synchronizeSubscriptionNotifications(enabled) } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt index 6f4160533e..f18e70cd4c 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt @@ -100,6 +100,12 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat OutboundPrivateSendReport(emptyList(), emptyList(), emptyList(), emptyList(), emptyList()), ) whenever(paykitSdkService.receivePrivateMessagesFromLinkedPeers()).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() } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index c48e3b65c1..626c829502 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -114,6 +114,12 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { OutboundPrivateSendReport(emptyList(), emptyList(), emptyList(), emptyList(), emptyList()), ) whenever(paykitSdkService.receivePrivateMessagesFromLinkedPeers()).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() } @@ -182,6 +188,40 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { 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 @@ -265,7 +305,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { resume.await() emptyList() } - val first = async { sut.refresh() } + val first = async { sut.refresh(PaykitPaymentRequestRefreshMode.FULL, Priority.Background) } reading.await() val queued = PaykitPaymentRequestRefreshMode.entries.map { mode -> async { sut.refresh(mode) } } runCurrent() @@ -275,9 +315,12 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { 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 @@ -913,6 +956,26 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { 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() diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index b0f667d230..8819d882ad 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -5,6 +5,7 @@ import com.synonym.paykit.IdentityStatus import com.synonym.paykit.LinkedPeerHandshakeReport import com.synonym.paykit.LinkedPeerRecord import com.synonym.paykit.LinkedPeerState +import com.synonym.paykit.OutboundPrivateCounterpartySendReport import com.synonym.paykit.OutboundPrivateSendReport import com.synonym.paykit.PaykitApp import com.synonym.paykit.PaykitAppCapabilities @@ -20,6 +21,7 @@ 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 @@ -258,6 +260,8 @@ class PaykitSdkServiceTest { { 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() @@ -280,6 +284,16 @@ class PaykitSdkServiceTest { 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() @@ -302,6 +316,53 @@ class PaykitSdkServiceTest { } } + @Test + fun `queued passive messages yield to foreground work while ordered drains remain barriers`() = runTest { + val operations = listOf Any?>( + { processPendingPrivateMessages(it) }, + { receivePrivateMessagesFromLinkedPeers(it) }, + ) + for (operation in operations) { + for (priority in listOf(Priority.Background, Priority.Ordered)) { + 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.identityStatus() }.thenAnswer { + events += "interactive" + IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE) + } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + val active = async { service.contactRecords() } + runCurrent() + val messages = async { service.operation(priority) } + val foreground = async { service.identityStatus(Priority.Interactive) } + runCurrent() + assertTrue(events.isEmpty()) + release.complete(Unit) + awaitAll(active, messages, foreground) + val queued = if (priority == Priority.Background) { + listOf("interactive", "messages") + } else { + listOf("messages", "interactive") + } + assertEquals(listOf("active completed") + queued, events) + } + } + } + @Test fun `foreground request work overtakes queued routine reads but not active work`() = runTest { val operations = listOf Any?>( diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index b27f0cdc67..a70f48b82e 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -174,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 @@ -431,6 +432,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(paykitPaymentRequestRepo.isCreatingRequest).thenReturn(MutableStateFlow(false)) whenever { paykitPaymentRequestRepo.refreshEligibleTargets(any(), any()) }.thenReturn(Result.success(Unit)) whenever { paykitPaymentRequestRepo.refreshAfterStateChange(any()) }.thenReturn(Result.success(Unit)) + whenever { paykitPaymentRequestRepo.refresh(any(), any()) }.doSuspendableAnswer { + paykitPaymentRequestRepo.refresh(it.getArgument(0)) + } whenever(paykitPaymentRequestRepo.automaticPendingRequests()).thenAnswer { pendingPaykitPaymentRequests.value.filterNot { it.id in surfacedPaykitPaymentRequestIds } } @@ -807,6 +811,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { 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() @@ -815,7 +820,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(privatePaykitRepo.awaitContactPreparation()).doSuspendableAnswer { prepared.await() } advanceTimeBy(30.seconds.inWholeMilliseconds - 1) runCurrent() - clearInvocations(paykitPaymentRequestRepo) + clearInvocations(paykitPaymentRequestRepo, privatePaykitRepo) advanceTimeBy(1) runCurrent() @@ -823,12 +828,82 @@ class AppViewModelSendFlowTest : BaseUnitTest() { 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 `foreground maintenance retries a missing session until restored`() = test { enablePaykitUi() diff --git a/changelog.d/next/1401.changed.md b/changelog.d/next/1401.changed.md index ae4cb6864f..7895cf439f 100644 --- a/changelog.d/next/1401.changed.md +++ b/changelog.d/next/1401.changed.md @@ -1 +1 @@ -Share Paykit contacts and payment state with authorized apps while keeping wallet keys private, keep accepted one-time requests payable only on the accepting install, prevent paid subscription periods from being offered again, keep request controls responsive and prioritize active payment requests over routine background reads, show incoming request details in the payment confirmation sheet while payment options load, show confirmed proposal delivery accurately, and label received payments with their Paykit payer contact. +Share Paykit contacts and payment state with authorized apps while keeping wallet keys private, keep accepted one-time requests payable only on the accepting install, prevent paid subscription periods from being offered again, keep request controls responsive and prioritize active payment requests over routine background work, show incoming request details in the payment confirmation sheet while payment options load, show confirmed proposal delivery accurately, and label received payments with their Paykit payer contact. From 57c9cab98ab658baf1d1b3c9fefea723be7d0092 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 6 Oct 2026 03:23:39 -0500 Subject: [PATCH 52/77] fix: retain subscription reminders through refresh failures --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 15 +++--- .../viewmodels/AppViewModelSendFlowTest.kt | 49 +++++++++++++++++++ changelog.d/next/1401.changed.md | 2 +- journeys/paykit-clock-changes.md | 2 + 4 files changed, 61 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 895ccccaea..91f37b2f41 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -772,7 +772,7 @@ 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 @@ -796,15 +796,16 @@ class AppViewModel @Inject constructor( privatePaykitRepo.pruneUnsavedContactState(state.contactKeys) .onFailure { Logger.warn("Failed to prune private Paykit contact state", it, context = TAG) } if (!PubkyPublicKeyFormat.matches(pubkyRepo.publicKey.value, state.publicKey)) return - refreshIncomingPaykitPaymentRequests(forceFresh = true) + 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( @@ -903,15 +904,17 @@ class AppViewModel @Inject constructor( mode: PaykitPaymentRequestRefreshMode = PaykitPaymentRequestRefreshMode.FULL, forceFresh: Boolean = false, messagePriority: Priority = Priority.Ordered, - ) { - if (!isPaykitEnabled.value || pubkyRepo.publicKey.value == null || !walletRepo.walletExists()) return + ): 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) } - result.onSuccess { + return result.onSuccess { activityRepo.backfillPaykitContacts() presentNextIncomingPaykitPaymentRequest() } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index a70f48b82e..a044ba7d46 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -1734,6 +1734,55 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(privatePaykitRepo, never()).beginPaymentRequest(otherRequest) } + @Test + fun `subscription notification target survives failed cold start refresh`() = 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, targetRequest) + Result.success(Unit) + } + sut.startPaykitPaymentRequestPolling() + try { + runCurrent() + verify(privatePaykitRepo).beginPaymentRequest(targetRequest) + verify(privatePaykitRepo, never()).beginPaymentRequest(otherRequest) + } finally { + sut.stopPaykitPaymentRequestPolling() + } + } + @Test fun `subscription notification is ignored when unavailable or for another identity`() = test { val targetRequest = paymentRequest().copy( diff --git a/changelog.d/next/1401.changed.md b/changelog.d/next/1401.changed.md index 7895cf439f..a8611abdb3 100644 --- a/changelog.d/next/1401.changed.md +++ b/changelog.d/next/1401.changed.md @@ -1 +1 @@ -Share Paykit contacts and payment state with authorized apps while keeping wallet keys private, keep accepted one-time requests payable only on the accepting install, prevent paid subscription periods from being offered again, keep request controls responsive and prioritize active payment requests over routine background work, show incoming request details in the payment confirmation sheet while payment options load, show confirmed proposal delivery accurately, and label received payments with their Paykit payer contact. +Share Paykit contacts and payment state with authorized apps while keeping wallet keys private, prepare incoming payments in the payment sheet, preserve subscription reminders through retries, and label received payments with their Paykit contact. diff --git a/journeys/paykit-clock-changes.md b/journeys/paykit-clock-changes.md index 6090a2d2d0..de8c1c9b8c 100644 --- a/journeys/paykit-clock-changes.md +++ b/journeys/paykit-clock-changes.md @@ -24,6 +24,8 @@ Create a fresh wallet and a matching Pubky identity, save a contact, and link a 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. Restore connectivity and allow a later refresh to open the 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. + 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. ## Connection loss and saved identity recovery From 5310b75d3f6de1d4d6483d6c0ca7c7daae19bfd2 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 6 Oct 2026 03:54:32 -0500 Subject: [PATCH 53/77] perf: prioritize sharing withdrawal completion --- .../ContactPaymentSettingsRepo.kt | 13 ++++- .../bitkit/repositories/PublicPaykitRepo.kt | 18 +++++- .../to/bitkit/services/PaykitSdkService.kt | 6 +- .../ContactPaymentSettingsRepoTest.kt | 45 +++++++++----- .../repositories/PrivatePaykitRepoTest.kt | 2 +- .../repositories/PublicPaykitRepoTest.kt | 58 +++++++++++++------ .../bitkit/services/PaykitSdkServiceTest.kt | 33 +++++++---- .../viewmodels/AppViewModelSendFlowTest.kt | 14 +++-- .../viewmodels/SettingsViewModelTest.kt | 20 +++++-- 9 files changed, 145 insertions(+), 64 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt b/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt index 8f4aaacdbe..9603d6ca08 100644 --- a/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt @@ -14,6 +14,7 @@ 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 @@ -54,7 +55,10 @@ class ContactPaymentSettingsRepo @Inject constructor( val contacts = pubkyRepo.contacts.value.map { it.publicKey } val privateCleanup = privatePaykitRepo.disableSharingAndPruneUnsavedContactState(contacts) val publicCleanup = when { - hadPublicState -> publicPaykitRepo.syncPublishedEndpoints(publish = false) + hadPublicState -> publicPaykitRepo.syncPublishedEndpoints( + publish = false, + appSyncPriority = Priority.Interactive, + ) previous.sharesPrivatePaykitEndpoints -> publicPaykitRepo.syncPaykitApp( privateSharingEnabled = false, ) @@ -113,7 +117,10 @@ class ContactPaymentSettingsRepo @Inject constructor( privatePaykitRepo.disableSharingAndPruneUnsavedContactState(contacts) .onFailure(error::addSuppressed) } - publicPaykitRepo.syncPublishedEndpoints(publish = previous.sharesPublicPaykitEndpoints) + publicPaykitRepo.syncPublishedEndpoints( + publish = previous.sharesPublicPaykitEndpoints, + appSyncPriority = if (previous.sharesPublicPaykitEndpoints) Priority.Ordered else Priority.Interactive, + ) .onFailure { error.addSuppressed(it) markPublicPaykitRetry(error) @@ -145,7 +152,7 @@ class ContactPaymentSettingsRepo @Inject constructor( privatePaykitRepo.disableSharingAndPruneUnsavedContactState(contacts) .onFailure { privateCleanupError = it } - publicPaykitRepo.syncPublishedEndpoints(publish = false) + publicPaykitRepo.syncPublishedEndpoints(publish = false, appSyncPriority = Priority.Interactive) .onFailure { publicCleanupError = it } publicCleanupError?.let { error -> diff --git a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt index acf1a9513d..1dce23798a 100644 --- a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -19,6 +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.PaykitSdkOperationLock.Priority import to.bitkit.services.PaykitSdkService import to.bitkit.utils.AppError import to.bitkit.utils.Logger @@ -208,11 +209,17 @@ 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 appError = syncPaykitApp().exceptionOrNull() + val appError = syncPaykitApp(priority = appSyncPriority).exceptionOrNull() if (endpointError != null) { appError?.let(endpointError::addSuppressed) throw endpointError @@ -271,11 +278,16 @@ class PublicPaykitRepo @Inject constructor( 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 privateSharing = privateSharingEnabled ?: settings.sharesPrivatePaykitEndpoints - paykitSdkService.syncPaykitApp(privatePaymentsEnabled = privateSharing) + paykitSdkService.syncPaykitApp(privatePaymentsEnabled = privateSharing, priority = priority) } } diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 177d5360bd..87ea0efcac 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -695,9 +695,11 @@ class PaykitSdkService @Inject constructor( it.resolveProfile(publicKey, allowPubkyProfileFallback) } - suspend fun syncPaykitApp(privatePaymentsEnabled: Boolean) { + suspend fun syncPaykitApp(privatePaymentsEnabled: Boolean) = syncPaykitApp(privatePaymentsEnabled, Priority.Ordered) + + internal suspend fun syncPaykitApp(privatePaymentsEnabled: Boolean, priority: Priority) { isSetup.await() - operationLock.withLock { + operationLock.withLock(priority) { withStateRevisionTracking { handle -> val capabilities = appCapabilities(completeSdkCall { handle.identityStatus() }) completeSdkCall { diff --git a/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt index 01081d618f..cbbf15cafe 100644 --- a/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt @@ -23,6 +23,7 @@ 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 @@ -54,6 +55,7 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { Unit } whenever { publicPaykitRepo.syncPublishedEndpoints(any()) }.thenReturn(Result.success(Unit)) + whenever { publicPaykitRepo.syncPublishedEndpoints(any(), any()) }.thenReturn(Result.success(Unit)) whenever { publicPaykitRepo.syncPaykitApp(anyOrNull()) } .thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.enableSharingAndPrepareSavedContacts(any>()) } @@ -118,21 +120,29 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) inOrder(privatePaykitRepo, publicPaykitRepo) { verify(privatePaykitRepo).disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) - verify(publicPaykitRepo).syncPublishedEndpoints(publish = false) + 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 @@ -150,7 +160,7 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) inOrder(privatePaykitRepo, publicPaykitRepo) { verify(privatePaykitRepo).disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) - verify(publicPaykitRepo).syncPublishedEndpoints(publish = false) + verify(publicPaykitRepo).syncPublishedEndpoints(publish = false, appSyncPriority = Priority.Interactive) } } @@ -179,7 +189,8 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { sharesPrivatePaykitEndpoints = true, ) val failure = ContactPaymentSettingsTestError("app update failed") - whenever(publicPaykitRepo.syncPublishedEndpoints(publish = false)).thenReturn(Result.failure(failure)) + whenever(publicPaykitRepo.syncPublishedEndpoints(publish = false, appSyncPriority = Priority.Interactive)) + .thenReturn(Result.failure(failure)) whenever(privatePaykitRepo.disableSharingAndPruneUnsavedContactState(any>())) .thenReturn(privateCleanup) @@ -208,10 +219,11 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { privateCleanup.await() cleanupResult } - whenever(publicPaykitRepo.syncPublishedEndpoints(publish = false)).doSuspendableAnswer { - publicCleanup.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) } @@ -238,7 +250,7 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { assertTrue(settingsFlow.value.sharesPrivatePaykitEndpoints) inOrder(privatePaykitRepo, publicPaykitRepo) { verify(privatePaykitRepo).disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) - verify(publicPaykitRepo).syncPublishedEndpoints(publish = false) + verify(publicPaykitRepo).syncPublishedEndpoints(publish = false, appSyncPriority = Priority.Interactive) verify(publicPaykitRepo).syncPublishedEndpoints(publish = true) verify(privatePaykitRepo).enableSharingAndPrepareSavedContacts(listOf(CONTACT_KEY)) } @@ -264,7 +276,10 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { assertFalse(disable.isCompleted) verify(privatePaykitRepo).disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) - verify(publicPaykitRepo, never()).syncPublishedEndpoints(publish = false) + verify(publicPaykitRepo, never()).syncPublishedEndpoints( + publish = false, + appSyncPriority = Priority.Interactive, + ) rollback.complete(Unit) assertTrue(enable.await().isFailure) diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 908323280f..e8b2130c76 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -827,7 +827,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { throw AppError("Registration unavailable") } capability = enabled - }.whenever(paykitSdkService).syncPaykitApp(any()) + }.whenever(paykitSdkService).syncPaykitApp(any(), eq(Priority.Ordered)) doSuspendableAnswer { assertTrue(capability, "Withdrawal requires the private capability") if (!failed && failureStage == "withdraw") { diff --git a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt index a8eb46f831..0c55c000ca 100644 --- a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt @@ -14,6 +14,9 @@ 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 @@ -25,6 +28,7 @@ import to.bitkit.data.SettingsStore import to.bitkit.services.CoreService import to.bitkit.services.PaykitPublicContactPaymentResolution 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 @@ -92,7 +96,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { sut.syncPaykitApp().getOrThrow() } val capabilities = argumentCaptor() - verifyBlocking(paykitSdkService, times(2)) { syncPaykitApp(capabilities.capture()) } + verifyBlocking(paykitSdkService, times(2)) { syncPaykitApp(capabilities.capture(), eq(Priority.Ordered)) } assertEquals(listOf(false, true), capabilities.allValues) } @@ -135,7 +139,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { isRestoringSession.value = false publication.await().getOrThrow() - verifyBlocking(paykitSdkService) { syncPaykitApp(privatePaymentsEnabled = false) } + verifyBlocking(paykitSdkService) { syncPaykitApp(privatePaymentsEnabled = false, priority = Priority.Ordered) } verifyBlocking(paykitSdkService) { syncPublicEndpoints(any()) } verifyBlocking(pubkyRepo, never()) { currentPublicKey() } verifyBlocking(pubkyRepo, never()) { awaitInitialization() } @@ -172,7 +176,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { ) walletState.value = WalletState(onchainAddress = "bc1ptest") val appError = RuntimeException("app registration failed") - whenever { paykitSdkService.syncPaykitApp(privatePaymentsEnabled = false) } + whenever { paykitSdkService.syncPaykitApp(privatePaymentsEnabled = false, priority = Priority.Ordered) } .thenThrow(appError) val error = sut.syncPublishedEndpoints(publish = true).exceptionOrNull() @@ -183,26 +187,36 @@ class PublicPaykitRepoTest : BaseUnitTest() { @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 app capability update fails`() = test { settingsFlow.value = SettingsData(publicPaykitCleanupPending = true) val appError = RuntimeException("app registration failed") - whenever { paykitSdkService.syncPaykitApp(privatePaymentsEnabled = false) } + whenever { paykitSdkService.syncPaykitApp(privatePaymentsEnabled = false, priority = Priority.Ordered) } .thenThrow(appError) val error = sut.syncPublishedEndpoints(publish = false).exceptionOrNull() @@ -217,12 +231,20 @@ class PublicPaykitRepoTest : BaseUnitTest() { val endpointError = RuntimeException("endpoint failed") val appError = RuntimeException("app registration failed") whenever { paykitSdkService.syncPublicEndpoints(emptyList()) }.thenThrow(endpointError) - whenever { paykitSdkService.syncPaykitApp(privatePaymentsEnabled = false) }.thenThrow(appError) + 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(appError), error?.suppressedExceptions) + inOrder(paykitSdkService) { + verify(paykitSdkService).syncPublicEndpoints(emptyList()) + verify(paykitSdkService).syncPaykitApp(privatePaymentsEnabled = false, priority = Priority.Interactive) + } } @Test diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index 8819d882ad..6a90d6a2e6 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -317,13 +317,15 @@ class PaykitSdkServiceTest { } @Test - fun `queued passive messages yield to foreground work while ordered drains remain barriers`() = runTest { + fun `queued passive messages yield to foreground app sync while ordered drains remain barriers`() = runTest { val operations = listOf Any?>( { processPendingPrivateMessages(it) }, { receivePrivateMessagesFromLinkedPeers(it) }, ) for (operation in operations) { - for (priority in listOf(Priority.Background, Priority.Ordered)) { + 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() @@ -340,25 +342,34 @@ class PaykitSdkServiceTest { events += "messages" emptyList() } - whenever { sdk.identityStatus() }.thenAnswer { - events += "interactive" - IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE) + 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(priority) } - val foreground = async { service.identityStatus(Priority.Interactive) } + val messages = async { service.operation(messagePriority) } + val publication = async { + if (appPriority == Priority.Ordered) { + service.syncPaykitApp(privatePaymentsEnabled = false) + } else { + service.syncPaykitApp(privatePaymentsEnabled = false, priority = appPriority) + } + } runCurrent() assertTrue(events.isEmpty()) release.complete(Unit) - awaitAll(active, messages, foreground) - val queued = if (priority == Priority.Background) { - listOf("interactive", "messages") + awaitAll(active, messages, publication) + val queued = if (messagePriority == Priority.Background && appPriority == Priority.Interactive) { + listOf("publication", "messages") } else { - listOf("messages", "interactive") + listOf("messages", "publication") } assertEquals(listOf("active completed") + queued, events) + verify(sdk).publishPaykitApp("Bitkit", PaykitAppCapabilities(false, true, false, true)) } } } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index a044ba7d46..b16302b350 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -9176,10 +9176,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { privateCleanup.await() Result.success(Unit) } - whenever(publicPaykitRepo.syncPublishedEndpoints(publish = false)).doSuspendableAnswer { - publicCleanup.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() @@ -9198,7 +9199,10 @@ class AppViewModelSendFlowTest : BaseUnitTest() { runCurrent() verify(privatePaykitRepo, never()).retryPendingEndpointRemoval(any>()) - verify(publicPaykitRepo, times(1)).syncPublishedEndpoints(publish = false) + verify(publicPaykitRepo, times(1)).syncPublishedEndpoints( + publish = false, + appSyncPriority = Priority.Interactive, + ) assertTrue(settingsData.value.publicPaykitCleanupPending) publicCleanup.complete(Unit) assertTrue(disable.await().isSuccess) diff --git a/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt index fe44ba3eb0..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,7 +80,8 @@ 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.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)) @@ -121,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( @@ -154,7 +156,10 @@ class SettingsViewModelTest : BaseUnitTest() { advanceUntilIdle() assertFalse(settingsData.value.publicPaykitCleanupPending) - verify(publicPaykitRepo, never()).syncPublishedEndpoints(publish = false) + verify(publicPaykitRepo, never()).syncPublishedEndpoints( + publish = false, + appSyncPriority = Priority.Interactive, + ) verify(publicPaykitRepo).syncPaykitApp(privateSharingEnabled = false) verify(privatePaykitRepo).disableSharingAndPruneUnsavedContactState(contacts.value.map { it.publicKey }) } @@ -177,7 +182,10 @@ class SettingsViewModelTest : BaseUnitTest() { advanceUntilIdle() assertTrue(settingsData.value.publicPaykitCleanupPending) - verify(publicPaykitRepo, never()).syncPublishedEndpoints(publish = false) + verify(publicPaykitRepo, never()).syncPublishedEndpoints( + publish = false, + appSyncPriority = Priority.Interactive, + ) verify(publicPaykitRepo).syncPaykitApp(privateSharingEnabled = false) verify(privatePaykitRepo).disableSharingAndPruneUnsavedContactState(contacts.value.map { it.publicKey }) } @@ -203,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()) } From 1a1cab33534bfd24aef927a40a2ad354eebc6708 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 6 Oct 2026 06:02:27 -0500 Subject: [PATCH 54/77] fix: preserve payment request state through refresh retries --- .../repositories/PaykitPaymentRequestRepo.kt | 39 +++- .../java/to/bitkit/viewmodels/AppViewModel.kt | 51 ++++- ...aykitPaymentRequestRepoSubscriptionTest.kt | 70 ++++++- .../PaykitPaymentRequestRepoTest.kt | 64 +++++- .../viewmodels/AppViewModelSendFlowTest.kt | 185 +++++++++++++----- journeys/README.md | 2 +- journeys/paykit-clock-changes.md | 1 + .../requested-resolution-failure.xml | 3 +- 8 files changed, 351 insertions(+), 64 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 280d395e6a..5ba93d1c63 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -400,6 +400,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( @@ -508,7 +527,7 @@ class PaykitPaymentRequestRepo @Inject constructor( completedRefreshGeneration == generation && completedRefreshProofVersion == proofVersion && completedRefreshActionVersion == actionVersion if (!forceFresh && hasFreshSnapshot && mode <= completedRefreshMode) return@operation null - receivedContacts = null + completedRefreshGeneration = -1L RefreshCheckpoint(actionVersion, proofVersion) } ?: return@refresh val snapshot = runSuspendCatching { @@ -517,18 +536,19 @@ class PaykitPaymentRequestRepo @Inject constructor( } operationMutex.withLock { if (!isCurrentState(generation, expectedIdentity)) return@withLock + val proofVersion = paymentProofStore.backupStateVersion.value runSuspendCatching { val records = snapshot.getOrThrow() - if (checkpoint.actionVersion != actionVersion) { + if (checkpoint != RefreshCheckpoint(actionVersion, proofVersion)) { synchronizeLocked(generation, expectedIdentity, PaykitPaymentRequestRefreshMode.STORED) } else { - applyRequestSnapshotLocked(records, generation, expectedIdentity) + applyRequestSnapshotLocked(records, generation, expectedIdentity, proofVersion) } }.onFailure { discardExpiredRequestsLocked() }.getOrThrow() if (isCurrentState(generation, expectedIdentity)) { completedRefreshMode = mode completedRefreshGeneration = generation - completedRefreshProofVersion = checkpoint.proofVersion + completedRefreshProofVersion = proofVersion completedRefreshActionVersion = actionVersion completedRefreshVersion.incrementAndGet() } @@ -1087,9 +1107,9 @@ class PaykitPaymentRequestRepo @Inject constructor( expectedIdentity: String?, mode: PaykitPaymentRequestRefreshMode = PaykitPaymentRequestRefreshMode.FULL, ) { - receivedContacts = null + val proofVersion = paymentProofStore.backupStateVersion.value val snapshot = fetchRequestSnapshot(expectedIdentity, mode) - applyRequestSnapshotLocked(snapshot, generation, expectedIdentity) + applyRequestSnapshotLocked(snapshot, generation, expectedIdentity, proofVersion) } private suspend fun fetchRequestSnapshot( @@ -1112,6 +1132,7 @@ class PaykitPaymentRequestRepo @Inject constructor( snapshot: RequestSnapshot, generation: Long, expectedIdentity: String?, + proofVersion: Long, ) { val now = clock.now() val subscriptionNow = subscriptionClock.now() @@ -1223,13 +1244,16 @@ class PaykitPaymentRequestRepo @Inject constructor( if (!isCurrentState(generation, expectedIdentity) || expectedIdentity == null) return pruneAcceptedOneTimeRequestIds(records, expectedIdentity, generation) if (!isCurrentState(generation, expectedIdentity)) return - receivedContacts = ReceivedContactsSnapshot(generation, expectedIdentity, contacts) 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 } @@ -1595,6 +1619,7 @@ class PaykitPaymentRequestRepo @Inject constructor( } private fun clearStateLocked() { + completedRefreshGeneration = -1L receivedContacts = null expirationJob?.cancel() expirationJob = null diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 91f37b2f41..b64c54db0e 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -916,10 +916,26 @@ class AppViewModel @Inject constructor( } 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( @@ -1091,14 +1107,15 @@ class AppViewModel @Inject constructor( } private suspend fun presentNextIncomingPaykitPaymentRequest() { - if (isPresentingPaymentRequest || isPaymentRequestPresentationBlocked()) return - if (requestedPaymentRequestId.value == 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 @@ -1124,6 +1141,11 @@ 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.value return if (requestedId == null) { @@ -1150,6 +1172,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) @@ -1177,7 +1200,9 @@ class AppViewModel @Inject constructor( request: PaykitPaymentRequest, generation: Long, ): Boolean { - val preparation = paymentRequestIdentity?.takeIf { + 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 { @@ -1186,14 +1211,18 @@ class AppViewModel @Inject constructor( paymentRequestPreparation.update { preparation } try { if (preparation != null) { - showSheet(preparation.sheet) - sheetTransitionJob?.join() + 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) { + if (scheduledScan?.contactPaymentContext?.incomingPaymentRequest?.id != request.id && + paymentRequestPresentationRetryJobs[request.id]?.isActive != true + ) { finishPaymentRequestPreparation(preparation) } } @@ -1217,7 +1246,7 @@ class AppViewModel @Inject constructor( } if (error != null) paykitPaymentRequestDiagnostics.logPresentationFailure(request.counterparty, error) if (!paykitPaymentRequestRepo.isPending(request)) { - if (requestedPaymentRequestId.value == request.id) { + if (requestedPaymentRequestId.value == request.id && requestedPaymentRequestIdentity == null) { invalidatePaymentRequestPresentation() clearRequestedPaymentRequest() } @@ -1234,7 +1263,7 @@ class AppViewModel @Inject constructor( ?: IncomingPaykitPaymentRequestFailureReason.ResolutionFailed, ) } - return false + return preparation != null && paymentRequestPresentationRetryJobs[request.id]?.isActive == true } openContactPayment( paymentRequest = result.paymentRequest, @@ -1259,6 +1288,9 @@ class AppViewModel @Inject constructor( clearRequestedPaymentRequest() } clearPaymentRequestPresentationRetry(request.id) + if (paymentRequestPreparation.value?.request?.id == request.id) { + dismissedPreparingRequestIds.add(request.id) + } if (!isRequested) return finishPaymentRequestPreparation(paymentRequestPreparation.value) toast( @@ -1380,6 +1412,7 @@ class AppViewModel @Inject constructor( 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)) { diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt index f18e70cd4c..2c348ba81e 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt @@ -22,7 +22,9 @@ 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 @@ -776,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) }) } @@ -856,6 +859,10 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat 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 @@ -1042,7 +1049,7 @@ 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, @@ -1057,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 diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 626c829502..171b5b1e0f 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -68,6 +68,7 @@ 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 @@ -401,6 +402,52 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { 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() @@ -463,7 +510,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `shared app destinations stay out of request UI and clear on identity switch`() = 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( @@ -490,6 +537,21 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { 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() diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index b16302b350..2bde4e53ef 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -432,6 +432,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(paykitPaymentRequestRepo.isCreatingRequest).thenReturn(MutableStateFlow(false)) 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)) } @@ -1047,7 +1048,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { runCurrent() verify(paykitPaymentRequestRepo, never()).refresh(any()) - val request = paymentRequest() + val request = paymentRequest().also { surfacedPaykitPaymentRequestIds += it.id } whenever(paykitPaymentRequestRepo.refresh(any())).doSuspendableAnswer { pendingPaykitPaymentRequests.value = listOf(request) Result.success(Unit) @@ -1121,7 +1122,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @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" @@ -1146,19 +1147,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 @@ -1389,7 +1399,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `request arriving during another presentation is not left waiting for refresh`() = test { + fun `request arriving during preparation opens after the owned sheet closes`() = test { enablePaykitUi() pubkyPublicKey.value = testPublicKey sut.setIsAuthenticated(true) @@ -1418,6 +1428,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) @@ -1483,7 +1498,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { 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) @@ -1500,7 +1515,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @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") @@ -1510,32 +1525,42 @@ 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() - sut.openIncomingPaymentRequest(request.id) - advanceTimeBy(TRANSITION_SCREEN_MS) - runCurrent() - sut.dismissIncomingPaymentRequest(request).getOrThrow() + assertEquals(Sheet.PaymentRequests, sut.currentSheet.value) + verify(privatePaykitRepo, times(2)).beginPaymentRequest(request) + verify(paykitPaymentRequestRepo, never()).markPresented(request) - 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) }) + sut.openIncomingPaymentRequest(request.id) + advanceTimeBy(TRANSITION_SCREEN_MS) + runCurrent() + sut.dismissIncomingPaymentRequest(request).getOrThrow() + + 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 @@ -1735,7 +1760,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `subscription notification target survives failed cold start refresh`() = test { + fun `subscription notification target survives failed and temporarily excluding refreshes`() = test { sut.setIsAuthenticated(true) whenever(pubkyRepo.hasIdentity()).thenReturn(true) val targetRequest = paymentRequest().copy( @@ -1770,11 +1795,24 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(toastManager, never()).enqueue(any()) whenever(paykitPaymentRequestRepo.refresh(PaykitPaymentRequestRefreshMode.FULL)).doSuspendableAnswer { - pendingPaykitPaymentRequests.value = listOf(otherRequest, targetRequest) + 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) @@ -1783,6 +1821,38 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } } + @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( @@ -2517,7 +2587,8 @@ 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(any())).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.beginPaymentRequest(request)) @@ -2528,14 +2599,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) @@ -2545,6 +2633,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { runCurrent() verify(privatePaykitRepo, times(18)).beginPaymentRequest(request) + assertEquals(request, (sut.currentSheet.value as? Sheet.Send)?.preparingRequest) sut.stopPaykitPaymentRequestPolling() } @@ -2999,7 +3088,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" @@ -3014,14 +3104,21 @@ class AppViewModelSendFlowTest : BaseUnitTest() { 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 diff --git a/journeys/README.md b/journeys/README.md index 7519e35fd8..84e5576963 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -152,7 +152,7 @@ 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`. | | `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/paykit-clock-changes.md b/journeys/paykit-clock-changes.md index de8c1c9b8c..9a7b3d476d 100644 --- a/journeys/paykit-clock-changes.md +++ b/journeys/paykit-clock-changes.md @@ -25,6 +25,7 @@ Create a fresh wallet and a matching Pubky identity, save a contact, and link a 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. Restore connectivity and allow a later refresh to open the 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. +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. diff --git a/journeys/payment-requests/requested-resolution-failure.xml b/journeys/payment-requests/requested-resolution-failure.xml index 7ced97ab57..696ef00de2 100644 --- a/journeys/payment-requests/requested-resolution-failure.xml +++ b/journeys/payment-requests/requested-resolution-failure.xml @@ -14,6 +14,7 @@ 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." @@ -21,7 +22,7 @@ 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 sheet and verify the request details remain available with Pay enabled for a manual retry + 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 From de9dd1c0b9600224a6f8781112c5a748000eef7f Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 6 Oct 2026 06:53:47 -0500 Subject: [PATCH 55/77] fix: refresh sent payment request receipts --- .../CreatePaymentRequestScreenTest.kt | 69 +++++++++++++++++-- .../CreatePaymentRequestScreen.kt | 27 +++++--- journeys/README.md | 1 + journeys/payment-requests/README.md | 5 ++ journeys/payment-requests/request-summary.xml | 7 +- 5 files changed, 93 insertions(+), 16 deletions(-) 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 ef1fca7fc3..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( @@ -172,7 +230,8 @@ class CreatePaymentRequestScreenTest { 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/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt index 2b927c88fc..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 @@ -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( @@ -603,6 +608,7 @@ private val previewCreatedRequest = PaykitPaymentRequest( createdAt = Instant.parse("2027-01-15T08:00:00Z"), expiresAt = previewDraft.expiresAt, acceptedPaymentEndpointIdentifiers = listOf("btc-lightning-bolt11"), + direction = PaykitPaymentRequestDirection.Outgoing, ) @Preview(showSystemUi = true) @@ -647,6 +653,7 @@ private fun PaymentRequestSentPreview() { BottomSheetPreview { PaymentRequestSentContent( request = previewCreatedRequest, + history = persistentListOf(), contact = PubkyProfile.placeholder(previewTarget.publicKey), onDone = {}, modifier = Modifier.sheetHeight(), diff --git a/journeys/README.md b/journeys/README.md index 84e5576963..cd35017baa 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -153,6 +153,7 @@ Known differences in the corpus, as of the iOS port (synonymdev/bitkit-ios#691): | `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` | 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/payment-requests/README.md b/journeys/payment-requests/README.md index 76769cd305..2ebde5372a 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -19,6 +19,11 @@ 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, 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. diff --git a/journeys/payment-requests/request-summary.xml b/journeys/payment-requests/request-summary.xml index 118ee10abb..0ccf8d31d1 100644 --- a/journeys/payment-requests/request-summary.xml +++ b/journeys/payment-requests/request-summary.xml @@ -1,9 +1,10 @@ - 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. + 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" @@ -12,7 +13,11 @@ 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 From bde1523c8a7521fad4081c1341e40d945c02ed30 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 6 Oct 2026 08:39:47 -0500 Subject: [PATCH 56/77] perf: adopt paykit rc63 runtime updates --- .../java/to/bitkit/repositories/PubkyRepo.kt | 90 +++--- .../to/bitkit/services/PaykitSdkService.kt | 169 ++++++++---- .../java/to/bitkit/services/PubkyService.kt | 13 + .../to/bitkit/repositories/PubkyRepoTest.kt | 111 ++++---- .../services/PaykitBackupStateTrackingTest.kt | 102 ++++++- .../services/PaykitKeyGenerationTest.kt | 49 ++++ .../bitkit/services/PaykitSdkServiceTest.kt | 260 ++++++++++++++++++ gradle/libs.versions.toml | 2 +- journeys/contacts/README.md | 8 +- 9 files changed, 648 insertions(+), 156 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 1e7ace6b57..4c24f3c8e8 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -4,6 +4,7 @@ import android.graphics.Bitmap import android.graphics.BitmapFactory import coil3.ImageLoader 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 @@ -377,23 +378,25 @@ 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) 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) @@ -428,23 +431,34 @@ 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) @@ -1210,21 +1224,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 } @@ -1236,32 +1252,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 { - 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 }) @@ -1270,7 +1279,6 @@ class PubkyRepo @Inject constructor( } markContactsLoaded() Logger.info("Imported '${imported.size}' contacts", context = TAG) - firstError?.let { throw it } clearPendingImport() } } diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 87ea0efcac..59babd218b 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -292,8 +292,21 @@ class PaykitSdkService @Inject constructor( } } - @Suppress("TooGenericExceptionCaught") suspend fun initialize() { + initialize { initializeRuntime() } + } + + suspend fun initializeAndImportSession(secret: String): Result { + var imported: Result? = null + initialize { + imported = runSuspendCatching { importSessionLocked(secret) } + if (imported.isFailure) initializeRuntime() + } + return imported ?: runSuspendCatching { importSession(secret) } + } + + @Suppress("TooGenericExceptionCaught") + private suspend fun initialize(activate: suspend () -> Unit) { setupMutex.withLock { if (isSetup.isCompleted && !setupFailed) return if (setupFailed) { @@ -304,31 +317,7 @@ class PaykitSdkService @Inject constructor( try { platformInitializer() launch { republishIdentityIfNeeded() } - operationLock.withLock { - 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) - } + operationLock.withLock { activate() } isSetup.complete(Unit) } catch (t: Throwable) { setupFailed = true @@ -338,6 +327,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) { @@ -410,20 +425,19 @@ class PaykitSdkService @Inject constructor( suspend fun importSession(secret: String): PubkySessionBootstrapResult { isSetup.await() - return operationLock.withLock { - val result = bootstrap().importSession( - sessionSecret = secret, - localSecretKey = sessionProvider.loadLocalSecretKey(), - requiredCapabilities = paykitAuthorizerSessionCapabilities(), - ) + return operationLock.withLock { importSessionLocked(secret) } + } - activateBootstrapResult( - result = result, - ) + 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( @@ -647,7 +661,41 @@ class PaykitSdkService @Inject constructor( val blockedPeers = completeSdkCall { handle.linkedPeers() }.filter { it.state == LinkedPeerState.BLOCKED && PubkyPublicKeyFormat.matches(it.counterparty, publicKey) } - restorePrivateContact(handle, blockedPeers, update) + restorePrivateContacts(handle, blockedPeers) { completeSdkCall { handle.saveContact(update) } } + } + } + } + + /** + * Explicitly re-adds [updates] in one SDK save, restoring only their blocked private connections. The identity + * and sign-in checks run under the operation lock; restoration failures use the single-contact rollback path. + * 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" + } + } + val blockedPeers = completeSdkCall { handle.linkedPeers() }.filter { peer -> + peer.state == LinkedPeerState.BLOCKED && updates.any { + PubkyPublicKeyFormat.matches(peer.counterparty, it.publicKey) + } + } + if (isStillCurrent?.invoke() == false) throw PubkyContactError.SignInChanged + restorePrivateContacts(handle, blockedPeers) { + if (isStillCurrent?.invoke() == false) throw PubkyContactError.SignInChanged + completeSdkCall { handle.saveContacts(updates) } + } } } } @@ -1195,8 +1243,12 @@ class PaykitSdkService @Inject constructor( } private suspend fun refreshPaykitKey(force: Boolean = false) { - if (force) cachedPaykitKey = null + if (force) { + cachedPaykitKey = null + cachedBackupState = null + } val root = sessionProvider.loadLocalSecretKey() ?: run { + if (cachedPaykitKey != null) cachedBackupState = null cachedPaykitKey = null return } @@ -1205,6 +1257,7 @@ class PaykitSdkService @Inject constructor( 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()) @@ -1293,16 +1346,16 @@ class PaykitSdkService @Inject constructor( _backupStateVersion.update { it + 1 } } - private suspend fun restorePrivateContact( + private suspend fun restorePrivateContacts( handle: PaykitSdk, blockedPeers: List, - update: ContactUpdate, - ): ContactRecord { + save: suspend () -> T, + ): T { var failure: Throwable? = null return try { runSuspendCatching { blockedPeers.forEach { completeSdkCall { handle.unblockPeer(it.counterparty) } } - completeSdkCall { handle.saveContact(update) } + save() }.onFailure { failure = it }.getOrThrow() } catch (error: CancellationException) { failure = error @@ -1344,6 +1397,11 @@ class PaykitSdkService @Inject constructor( .getOrThrow() }, readStateRevision = { handle.stateRevision() }, + readObservedSnapshot = { + handle.observedBackupStateRevision()?.let { + PaykitBackupStateSnapshot(it.stateRevision, it.backupRevision) + } + }, cachedSnapshot = cachedBackupState, onSnapshot = { cachedBackupState = it }, onChange = ::notifyBackupStateChanged, @@ -1475,18 +1533,14 @@ internal data class PaykitBackupStateSnapshot(val stateRevision: String, val bac 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 observedStateRevision = runSuspendCatching { readStateRevision() }.getOrNull() - val previousRevision = if (cachedSnapshot != null && observedStateRevision == cachedSnapshot.stateRevision) { - cachedSnapshot.backupRevision - } else { - runSuspendCatching { readRevision() }.getOrNull() - } - val previousStateRevision = runSuspendCatching { readStateRevision() }.getOrNull() + val previousRevision = cachedSnapshot?.backupRevision + ?: runSuspendCatching { readRevision() }.getOrNull() var succeeded = false return try { operation().also { succeeded = true } @@ -1498,13 +1552,12 @@ internal suspend fun withPaykitBackupStateTracking( return@withContext } val nextStateRevision = runSuspendCatching { readStateRevision() }.getOrNull() - val unchangedRevision = previousStateRevision?.takeIf { it == nextStateRevision } - if (unchangedRevision != null && previousRevision != null) { - onSnapshot(PaykitBackupStateSnapshot(unchangedRevision, previousRevision)) - return@withContext - } - val nextRevision = runSuspendCatching { readRevision() }.getOrNull() - val stateRevision = 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) diff --git a/app/src/main/java/to/bitkit/services/PubkyService.kt b/app/src/main/java/to/bitkit/services/PubkyService.kt index 71d45c761b..cd0e619af3 100644 --- a/app/src/main/java/to/bitkit/services/PubkyService.kt +++ b/app/src/main/java/to/bitkit/services/PubkyService.kt @@ -2,6 +2,7 @@ package to.bitkit.services import com.synonym.bitkitcore.approvePubkyAuth 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 @@ -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) @@ -217,6 +222,14 @@ class PubkyService @Inject constructor( ) } + 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) } diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 31e65ac571..f14a1842aa 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -5,6 +5,7 @@ import coil3.ImageLoader import coil3.disk.DiskCache import coil3.memory.MemoryCache 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 @@ -151,6 +152,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,16 +197,8 @@ 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") } @@ -210,33 +206,32 @@ class PubkyRepoTest : BaseUnitTest() { 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()) } @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, true, VALID_SELF_KEY)) - .thenReturn(mock()) - whenever(pubkyService.saveContact(bob.publicKey, bob.name, 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, true, VALID_SELF_KEY) - verify(pubkyService, times(2)).saveContact(bob.publicKey, bob.name, 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 @@ -252,16 +247,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, true, VALID_CONTACT_KEY_A)) - .thenReturn(mock()) - whenever(pubkyService.saveContact(VALID_CONTACT_KEY_A, ownProfile.name, 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) @@ -2251,6 +2245,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() } @@ -2262,6 +2257,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") } @@ -2301,9 +2298,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() @@ -2319,9 +2316,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() @@ -2378,7 +2375,8 @@ class PubkyRepoTest : BaseUnitTest() { } for (failure in failures) { currentFailure = failure - sut.initialize() + sut = createSut() + sut.awaitInitialization() assertFalse(sut.isAuthenticated.value) verify(pubkyService, never()).signIn(any()) verify(keychain, never()).delete(Keychain.Key.PAYKIT_SESSION.name) @@ -2452,7 +2450,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) @@ -2533,11 +2532,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) @@ -2548,6 +2546,7 @@ class PubkyRepoTest : BaseUnitTest() { clearInvocations(pubkyService) repo.restoreSessionIfNeeded() + verify(pubkyService, never()).initializeAndImportSession(any()) verify(pubkyService, never()).importSession(any()) verify(pubkyService, never()).signIn(any()) } @@ -3084,11 +3083,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(), 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)) } @@ -3103,40 +3102,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(), 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(), 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(), 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) @@ -3159,7 +3162,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(), any(), any(), anyOrNull())) + whenever(pubkyService.saveContacts(any(), eq(VALID_SELF_KEY), any())) .doSuspendableAnswer { saveStarted.complete(Unit) failSave.await() diff --git a/app/src/test/java/to/bitkit/services/PaykitBackupStateTrackingTest.kt b/app/src/test/java/to/bitkit/services/PaykitBackupStateTrackingTest.kt index b2e4469886..1206a918a6 100644 --- a/app/src/test/java/to/bitkit/services/PaykitBackupStateTrackingTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitBackupStateTrackingTest.kt @@ -108,6 +108,7 @@ class PaykitBackupStateTrackingTest : BaseUnitTest() { "content" }, readStateRevision = { "state" }, + readObservedSnapshot = { PaykitBackupStateSnapshot("state", "content") }, cachedSnapshot = snapshot, onSnapshot = { snapshot = it }, onChange = { changes++ }, @@ -123,7 +124,7 @@ class PaykitBackupStateTrackingTest : BaseUnitTest() { for ((cachedState, finalContent, expectedReads) in listOf( Triple("before", "content", 1), Triple("before", "changed", 1), - Triple("stale", "changed", 2), + Triple("stale", "changed", 1), )) { var state = "before" var content = "content" @@ -149,11 +150,105 @@ class PaykitBackupStateTrackingTest : BaseUnitTest() { } } + @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( @@ -162,6 +257,10 @@ class PaykitBackupStateTrackingTest : BaseUnitTest() { "before" }, readStateRevision = { "state" }, + readObservedSnapshot = { + observedReads++ + PaykitBackupStateSnapshot("state", "before") + }, cachedSnapshot = snapshot, onSnapshot = { snapshot = it }, onChange = { changes++ }, @@ -169,6 +268,7 @@ class PaykitBackupStateTrackingTest : BaseUnitTest() { } 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 index 09bae705a5..88d71dcff6 100644 --- a/app/src/test/java/to/bitkit/services/PaykitKeyGenerationTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitKeyGenerationTest.kt @@ -1,6 +1,7 @@ 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 @@ -120,6 +121,54 @@ class PaykitKeyGenerationTest { } } + @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() diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index 6a90d6a2e6..cbf2f850c9 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -1,10 +1,12 @@ package to.bitkit.services import com.synonym.paykit.ContactRecord +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 @@ -34,6 +36,7 @@ import com.synonym.paykit.PubkySessionBootstrapResult import com.synonym.paykit.PublicContactPaymentResolution import com.synonym.paykit.PublicContactSharingPolicy import com.synonym.paykit.PublicPaymentResolutionStatus +import com.synonym.paykit.paykitAuthorizerSessionCapabilities import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Deferred @@ -49,6 +52,7 @@ 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 @@ -519,6 +523,7 @@ class PaykitSdkServiceTest { 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 } @@ -562,6 +567,105 @@ class PaykitSdkServiceTest { 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() + } else { + assertSame(failure, service.initializeAndImportSession("saved-session").exceptionOrNull()) + } + } + assertEquals(emptyList(), service.contactRecords()) + verify(sdk).initialize() + verify(keychain, never()).delete(any()) + verify(keychain, never()).upsertString(any(), any()) + } + } + @Test fun `wallet wipe drains initialization before cleanup and allows fresh work`() = runTest { val sdk = mock() @@ -599,6 +703,7 @@ class PaykitSdkServiceTest { val sdk = mock() 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(), settingsStore = mock()) { @@ -1411,6 +1516,161 @@ class PaykitSdkServiceTest { } } + @Test + fun `bulk contact save uses one inventory and save and tracks its backup change`() = runTest { + val sdk = mock() + val otherKey = "5${RING_PUBKY.drop(1)}" + val selected = contactPeer(LinkedPeerState.BLOCKED) + val unselected = selected.copy(counterparty = otherKey) + 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.linkedPeers()).thenReturn(listOf(selected, unselected)) + whenever(sdk.saveContacts(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).linkedPeers() + verify(sdk).unblockPeer(RING_PUBKY) + verify(sdk, never()).unblockPeer(otherKey) + verify(sdk).saveContacts(updates) + verify(sdk, never()).saveContact(any()) + verify(sdk, never()).contactRecord(any()) + verify(sdk, never()).blockPeer(any()) + assertEquals(1L, service.backupStateVersion.value) + } + + @Test + fun `failed bulk restoration rolls back selected peers and permits retry`() = runTest { + for (failurePoint in listOf("lookup", "unblock", "save")) { + val sdk = mock() + val secondKey = "5${RING_PUBKY.drop(1)}" + val peers = listOf( + contactPeer(LinkedPeerState.BLOCKED), + contactPeer(LinkedPeerState.BLOCKED).copy(counterparty = secondKey), + ) + val updates = peers.map { ContactUpdate(it.counterparty, "Contact") } + val failure = IllegalStateException("storage failure") + whenever(sdk.linkedPeers()).thenReturn(peers) + whenever(sdk.saveContacts(updates)).thenReturn(emptyList()) + when (failurePoint) { + "lookup" -> whenever(sdk.linkedPeers()).thenThrow(failure).thenReturn(peers) + "unblock" -> whenever(sdk.unblockPeer(secondKey)).thenThrow(failure).thenReturn(peers.last()) + "save" -> whenever(sdk.saveContacts(updates)).thenThrow(failure).thenReturn(emptyList()) + } + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + + assertSame(failure, assertFailsWith { service.saveContacts(updates) }) + + if (failurePoint == "lookup") { + verify(sdk, never()).blockPeer(any()) + } else { + peers.forEach { verify(sdk).blockPeer(it.counterparty) } + } + assertEquals(emptyList(), service.saveContacts(updates)) + verify(sdk, times(2)).linkedPeers() + verify(sdk, times(if (failurePoint == "save") 2 else 1)).saveContacts(updates) + verify(sdk, never()).saveContact(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) + } + whenever(sdk.contactRecords()).doSuspendableAnswer { + releaseRead.await() + emptyList() + } + whenever(sdk.linkedPeers()).thenReturn(emptyList()) + whenever(sdk.saveContacts(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() + } + releaseRead.complete(Unit) + read.await() + + when (change) { + "identity" -> assertIs(save.await().exceptionOrNull()) + "sign-in" -> assertSame(PubkyContactError.SignInChanged, save.await().exceptionOrNull()) + "cancel" -> assertFailsWith { save.await() } + } + verify(sdk, never()).saveContacts(any()) + verify(sdk, never()).unblockPeer(any()) + service.saveContacts(updates, currentIdentity) { true } + verify(sdk).saveContacts(updates) + } + } + + @Test + fun `cancelled bulk save completes SDK mutation and rollback before releasing the queue`() = runTest { + val sdk = mock() + whenever(sdk.linkedPeers()).thenReturn(listOf(contactPeer(LinkedPeerState.BLOCKED))) + val releaseSave = CompletableDeferred() + val releaseRollback = CompletableDeferred() + val events = mutableListOf() + whenever(sdk.saveContacts(any())).doSuspendableAnswer { + releaseSave.await() + events += "saved" + emptyList() + } + whenever(sdk.blockPeer(RING_PUBKY)).doSuspendableAnswer { + events += "rollback" + releaseRollback.await() + events += "blocked" + contactPeer(LinkedPeerState.BLOCKED) + } + 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 { service.saveContacts(updates) } + runCurrent() + save.cancel() + val next = async { service.contactRecords() } + runCurrent() + assertEquals(emptyList(), events) + releaseSave.complete(Unit) + runCurrent() + assertEquals(listOf("saved", "rollback"), events) + assertFalse(next.isCompleted) + releaseRollback.complete(Unit) + + assertFailsWith { save.await() } + next.await() + assertEquals(listOf("saved", "rollback", "blocked", "next"), events) + verify(sdk).saveContacts(updates) + assertEquals(1L, service.backupStateVersion.value) + } + @Test fun `failed private connection restoration leaves contact creation retryable`() = runTest { for (failurePoint in listOf("lookup", "unblock", "save", "cancel")) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d221865021..a8b17995f9 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-rc62" } +paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc63" } 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/contacts/README.md b/journeys/contacts/README.md index a0527dad11..c7d1b27ace 100644 --- a/journeys/contacts/README.md +++ b/journeys/contacts/README.md @@ -23,7 +23,13 @@ after deletion. Unavailable private-link lookups are retried after five minutes 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 From 117072b5ee4a9bfce57f8333a1c6071c1b0c55e2 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 6 Oct 2026 09:27:01 -0500 Subject: [PATCH 57/77] Preserve subscription reminders during manual request payments --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 51 ++++++++++- .../viewmodels/AppViewModelSendFlowTest.kt | 91 +++++++++++++++++++ journeys/paykit-clock-changes.md | 2 +- 3 files changed, 139 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index b64c54db0e..f8ce4ef1f1 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -386,6 +386,11 @@ 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() @@ -399,6 +404,7 @@ class AppViewModel @Inject constructor( private var shouldRestorePaymentRequestSheet = false private var preparedContactPaymentContext: ContactPaymentContext? = 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 @@ -1010,6 +1016,7 @@ class AppViewModel @Inject constructor( val currentIdentity = pubkyRepo.publicKey.value if (currentIdentity != null && !PubkyPublicKeyFormat.matches(currentIdentity, payerIdentity)) return invalidatePaymentRequestPresentation() + deferredSubscriptionNotification = null _requestedPaymentRequestId.update { requestId } requestedPaymentRequest = null shouldRestorePaymentRequestSheet = false @@ -1102,7 +1109,7 @@ class AppViewModel @Inject constructor( if (!paykitPaymentRequestRepo.isPending(request)) return completePaymentRequestPresentation(request) paymentRequestPresentationGeneration++ - clearRequestedPaymentRequest() + clearRequestedPaymentRequest(restoreDeferredReminder = false) clearPaymentRequestPresentationRetry(request.id) } @@ -1196,6 +1203,22 @@ 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, @@ -1466,17 +1489,31 @@ class AppViewModel @Inject constructor( } } completions.forEach { it.cancel() } - if (!preserveRequestedPaymentRequest) clearRequestedPaymentRequest() + if (!preserveRequestedPaymentRequest) { + deferredSubscriptionNotification = null + clearRequestedPaymentRequest() + } paymentRequestSheetTransitionJob?.cancel() paymentRequestSheetTransitionJob = null } - private fun clearRequestedPaymentRequest() { + 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) { @@ -5324,6 +5361,7 @@ class AppViewModel @Inject constructor( else -> _currentSheet.update { null } } + restoreDeferredSubscriptionNotification() showQueuedPairingCodeSheet() if (shouldFlushDeferredScan) flushDeferredScan() } @@ -5820,14 +5858,19 @@ class AppViewModel @Inject constructor( fun openIncomingPaymentRequestWithTags(id: PaykitPaymentRequestId, tags: List) { val request = paykitPaymentRequestRepo.pendingRequest(id) ?: return - if (paykitPaymentRequestRepo.isProcessing(request) || requestedPaymentRequestId.value != 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.update { id } + requestedPaymentRequestIdentity = null requestedPaymentRequest = request shouldRestorePaymentRequestSheet = _currentSheet.value is Sheet.PaymentRequests requestedPaymentRequestTags = tags.filter(String::isNotBlank).distinct().toImmutableList() diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 2bde4e53ef..8eb8fc6a0f 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -1821,6 +1821,97 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } } + @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) diff --git a/journeys/paykit-clock-changes.md b/journeys/paykit-clock-changes.md index 9a7b3d476d..c44f79ec7a 100644 --- a/journeys/paykit-clock-changes.md +++ b/journeys/paykit-clock-changes.md @@ -24,7 +24,7 @@ Create a fresh wallet and a matching Pubky identity, save a contact, and link a 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. Restore connectivity and allow a later refresh to open the 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. +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. From d56c3f7b1a4823fbde7a5d01353f27f75cf6fada Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 6 Oct 2026 11:04:09 -0500 Subject: [PATCH 58/77] fix: avoid duplicate paykit activation after import failure --- .../to/bitkit/services/PaykitSdkService.kt | 1 - .../bitkit/services/PaykitSdkServiceTest.kt | 54 ++++++++++++++++++- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 59babd218b..54fa3a1535 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -300,7 +300,6 @@ class PaykitSdkService @Inject constructor( var imported: Result? = null initialize { imported = runSuspendCatching { importSessionLocked(secret) } - if (imported.isFailure) initializeRuntime() } return imported ?: runSuspendCatching { importSession(secret) } } diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index cbf2f850c9..4557cbf7c3 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -655,17 +655,69 @@ class PaykitSdkServiceTest { 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(sdk).initialize() 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() From 51e26b0b710fb00edacfb7aada8bb0f5e57743fb Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 6 Oct 2026 11:30:53 -0500 Subject: [PATCH 59/77] fix: coalesce pubky restore and profile loads --- .../java/to/bitkit/repositories/PubkyRepo.kt | 119 ++++++++------ .../to/bitkit/repositories/PubkyRepoTest.kt | 148 ++++++++++++++++++ 2 files changed, 219 insertions(+), 48 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 4c24f3c8e8..27604f7178 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -153,7 +153,12 @@ class PubkyRepo @Inject constructor( private val scope = appScope(ioDispatcher, TAG) private val serviceInitializeMutex = Mutex() private val initializeMutex = Mutex() + 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 @@ -323,39 +328,29 @@ 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 } /** - * 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 { @@ -365,6 +360,28 @@ class PubkyRepo @Inject constructor( } } + private suspend fun loadIdentityData() { + coroutineScope { + launch { loadProfile() } + launch { loadContacts() } + } + } + + private suspend fun restoreSession(): Boolean { + val version = completedRestoreVersion.get() + return initializeMutex.withLock { + 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 { @@ -699,39 +716,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() } } diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index f14a1842aa..016e978e39 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -849,6 +849,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() @@ -2615,6 +2683,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() From fe364a5cd234648081c72c5dbb38973fb464cc62 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 6 Oct 2026 13:00:29 -0500 Subject: [PATCH 60/77] fix: batch profile deletion and use paykit rc64 --- .../PrivatePaykitAddressReservationRepo.kt | 14 +++++ .../bitkit/repositories/PrivatePaykitRepo.kt | 63 ++++++++++--------- .../java/to/bitkit/repositories/PubkyRepo.kt | 11 ++-- .../to/bitkit/services/PaykitSdkService.kt | 19 ++++++ .../java/to/bitkit/services/PubkyService.kt | 4 ++ .../screens/profile/EditProfileViewModel.kt | 2 + .../java/to/bitkit/viewmodels/AppViewModel.kt | 6 +- .../repositories/PrivatePaykitRepoTest.kt | 19 ++++++ .../to/bitkit/repositories/PubkyRepoTest.kt | 5 +- .../bitkit/services/PaykitSdkServiceTest.kt | 5 ++ .../viewmodels/AppViewModelSendFlowTest.kt | 5 +- gradle/libs.versions.toml | 2 +- journeys/profile/delete-profile.xml | 2 + 13 files changed, 113 insertions(+), 44 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt index 4fde83adc5..24bdf79c7a 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt @@ -241,6 +241,20 @@ class PrivatePaykitAddressReservationRepo @Inject constructor( } } + 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 !in removedKeys }, + contactAssignmentHistory = current.contactAssignmentHistory.filterKeys { it !in removedKeys }, + ) + if (next == current) return@locked + ledger = next + persist(next) + notifyBackupStateChanged() + } + } + suspend fun clear() = withContext(ioDispatcher) { locked { ledger = PrivatePaykitReservationData() diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 7978e37e85..5767a237b4 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -110,6 +110,7 @@ class PrivatePaykitRepo @Inject constructor( private var activePreparationKeys = emptySet() 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 @@ -201,6 +202,7 @@ class PrivatePaykitRepo @Inject constructor( } 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 @@ -242,6 +244,15 @@ class PrivatePaykitRepo @Inject constructor( clearPendingMessageDrainRetries() } + suspend fun beginProfileDeletion() = withContext(serializedDispatcher) { + isDeletingProfile = true + invalidateContactPreparation() + } + + suspend fun endProfileDeletion() = withContext(serializedDispatcher) { + isDeletingProfile = false + } + suspend fun refreshSavedContactEndpoints( publicKey: String, savedPublicKeys: Collection, @@ -274,6 +285,7 @@ class PrivatePaykitRepo @Inject constructor( savedPublicKeys: Collection, ): Result = withContext(serializedDispatcher) { runSuspendCatching { + if (isDeletingProfile) return@runSuspendCatching val settings = settingsStore.data.first() val hasDisabledPublications = !settings.sharesPrivatePaykitEndpoints && hasPublishedPrivateEndpoints() val cleanupPending = isContactSharingCleanupPending() @@ -296,28 +308,29 @@ 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) - pendingPreparationKeys.remove(normalizedKey) - unavailableLinkRetryAt.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) } } @@ -1373,9 +1386,6 @@ class PrivatePaykitRepo @Inject constructor( } } - private suspend fun removePublishedEndpoints(publicKey: String): Result = - removePublishedEndpoints(listOf(publicKey)) - private suspend fun removePublishedEndpoints(publicKeys: Collection): Result = withContext(serializedDispatcher) { publicationMutex.withLock { @@ -1540,18 +1550,11 @@ class PrivatePaykitRepo @Inject constructor( 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 @@ -1694,9 +1697,7 @@ class PrivatePaykitRepo @Inject constructor( .mapNotNull(::normalizedPublicKey) .filterNot { it in remainingPendingKeys } clearContactStates(successfulKeys) - successfulKeys.forEach { publicKey -> - addressReservationRepo.clearContactAssignment(publicKey) - } + addressReservationRepo.removeContactAssignments(successfulKeys) removalResult.getOrThrow() } } diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 27604f7178..fa14449ac4 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -996,12 +996,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() diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 54fa3a1535..86853768d1 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -733,6 +733,25 @@ class PaykitSdkService @Inject constructor( } } + 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) } + } + } + } + suspend fun resolveContactProfile( publicKey: String, allowPubkyProfileFallback: Boolean, diff --git a/app/src/main/java/to/bitkit/services/PubkyService.kt b/app/src/main/java/to/bitkit/services/PubkyService.kt index cd0e619af3..9a13d4dc7b 100644 --- a/app/src/main/java/to/bitkit/services/PubkyService.kt +++ b/app/src/main/java/to/bitkit/services/PubkyService.kt @@ -234,6 +234,10 @@ class PubkyService @Inject constructor( paykitSdkService.removeContact(publicKey) } + suspend fun removeContacts(publicKeys: List): List = ServiceQueue.CORE.background { + paykitSdkService.removeContacts(publicKeys) + } + suspend fun resolveContactProfile( publicKey: String, allowPubkyProfileFallback: Boolean, 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/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index f8ce4ef1f1..6a19d80c2c 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -786,11 +786,11 @@ class AppViewModel @Inject constructor( 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, ) diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index e8b2130c76..34775c24a4 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -1318,6 +1318,25 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { 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()) + + 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) diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 016e978e39..54921d7ab6 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -1260,6 +1260,7 @@ class PubkyRepoTest : BaseUnitTest() { 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)) @@ -1298,8 +1299,8 @@ class PubkyRepoTest : BaseUnitTest() { deletion.await().getOrThrow() screenLookup.await() assertTrue(sut.contacts.value.isEmpty()) - verify(pubkyService).removeContact(VALID_CONTACT_KEY_A) - verify(pubkyService).removeContact(VALID_CONTACT_KEY_B) + verify(pubkyService).removeContacts(records.map { it.publicKey }) + verify(pubkyService, never()).removeContact(any()) } @Test diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index 4557cbf7c3..a0124ea297 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -1021,8 +1021,10 @@ class PaykitSdkServiceTest { whenever(sdk.paymentRequests()).thenReturn(listOf(request)) val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } assertFailsWith { service.removeContact(RING_PUBKY) } + 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 { @@ -1030,6 +1032,9 @@ 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))) } } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 8eb8fc6a0f..d649a7e9a6 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -482,6 +482,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)) @@ -9323,11 +9324,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pubkyContactsLoadVersion.value = 2L advanceUntilIdle() - verify(privatePaykitRepo).removeSavedContact(contact.publicKey) + verify(privatePaykitRepo).removeSavedContacts(setOf(contact.publicKey)) verify(privatePaykitRepo).scheduleSavedContactPreparation(emptySet()) verify(privatePaykitRepo).pruneUnsavedContactState(emptySet()) inOrder(privatePaykitRepo, paykitPaymentRequestRepo).apply { - verify(privatePaykitRepo).removeSavedContact(contact.publicKey) + verify(privatePaykitRepo).removeSavedContacts(setOf(contact.publicKey)) verify(paykitPaymentRequestRepo).refreshAfterStateChange() } verify(paykitPaymentRequestRepo, never()).refresh(any()) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a8b17995f9..1ee9c67df1 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-rc63" } +paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc64" } 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/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. From 908322787acde2d765757e00d92aa140d3e5a612 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 6 Oct 2026 15:40:07 -0500 Subject: [PATCH 61/77] fix: restore contacts atomically with Paykit rc65 --- .../to/bitkit/services/PaykitSdkService.kt | 53 +--- .../bitkit/services/PaykitSdkServiceTest.kt | 288 ++++++------------ changelog.d/next/1401.changed.md | 2 +- gradle/libs.versions.toml | 2 +- journeys/contacts/import-all-contacts.xml | 3 + 5 files changed, 103 insertions(+), 245 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 86853768d1..00a10b23b2 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -651,23 +651,20 @@ class PaykitSdkService @Inject constructor( } } if (isStillCurrent?.invoke() == false) throw PubkyContactError.SignInChanged - val existing = completeSdkCall { handle.contactRecord(publicKey) } - check(restorePrivateConnection || existing != null) { "Contact no longer exists" } 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) } } - val blockedPeers = completeSdkCall { handle.linkedPeers() }.filter { - it.state == LinkedPeerState.BLOCKED && PubkyPublicKeyFormat.matches(it.counterparty, publicKey) - } - restorePrivateContacts(handle, blockedPeers) { completeSdkCall { handle.saveContact(update) } } + completeSdkCall { handle.saveContactsAndUnblockPeers(listOf(update)) }.single() } } } /** - * Explicitly re-adds [updates] in one SDK save, restoring only their blocked private connections. The identity - * and sign-in checks run under the operation lock; restoration failures use the single-contact rollback path. + * 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( @@ -685,16 +682,8 @@ class PaykitSdkService @Inject constructor( "Paykit identity changed before saving contacts" } } - val blockedPeers = completeSdkCall { handle.linkedPeers() }.filter { peer -> - peer.state == LinkedPeerState.BLOCKED && updates.any { - PubkyPublicKeyFormat.matches(peer.counterparty, it.publicKey) - } - } if (isStillCurrent?.invoke() == false) throw PubkyContactError.SignInChanged - restorePrivateContacts(handle, blockedPeers) { - if (isStillCurrent?.invoke() == false) throw PubkyContactError.SignInChanged - completeSdkCall { handle.saveContacts(updates) } - } + completeSdkCall { handle.saveContactsAndUnblockPeers(updates) } } } } @@ -1364,36 +1353,6 @@ class PaykitSdkService @Inject constructor( _backupStateVersion.update { it + 1 } } - private suspend fun restorePrivateContacts( - handle: PaykitSdk, - blockedPeers: List, - save: suspend () -> T, - ): T { - var failure: Throwable? = null - return try { - runSuspendCatching { - blockedPeers.forEach { completeSdkCall { handle.unblockPeer(it.counterparty) } } - save() - }.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) - }.onFailure { - invalidatePaykitKeyIfNeeded(it) - restorationError.addSuppressed(it) - } - } - } - } - } - } - private suspend fun withPaykitKey(block: suspend (PaykitSdk) -> T): T { refreshPaykitKey() return runSuspendCatching { block(handle()) } diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index a0124ea297..0a88ec4cb9 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -54,7 +54,6 @@ 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 @@ -1552,40 +1551,50 @@ class PaykitSdkServiceTest { } @Test - fun `only explicit readd unblocks the contact`() = 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(LinkedPeerState.BLOCKED), - ), - ) + 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) - } 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()) + } else { + verify(sdk).saveContact(ContactUpdate(RING_PUBKY, "Contact")) + verify(sdk, never()).saveContactsAndUnblockPeers(any()) } + verify(sdk, never()).linkedPeers() + verify(sdk, never()).unblockPeer(any()) } } @Test - fun `bulk contact save uses one inventory and save and tracks its backup change`() = runTest { + 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 otherKey = "5${RING_PUBKY.drop(1)}" - val selected = contactPeer(LinkedPeerState.BLOCKED) - val unselected = selected.copy(counterparty = otherKey) 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.linkedPeers()).thenReturn(listOf(selected, unselected)) - whenever(sdk.saveContacts(updates)).thenAnswer { + whenever(sdk.saveContactsAndUnblockPeers(updates)).thenAnswer { revision = "saved" records } @@ -1595,10 +1604,10 @@ class PaykitSdkServiceTest { verifyNoInteractions(sdk) assertSame(records, service.saveContacts(updates)) - verify(sdk).linkedPeers() - verify(sdk).unblockPeer(RING_PUBKY) - verify(sdk, never()).unblockPeer(otherKey) - verify(sdk).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()) @@ -1606,36 +1615,26 @@ class PaykitSdkServiceTest { } @Test - fun `failed bulk restoration rolls back selected peers and permits retry`() = runTest { - for (failurePoint in listOf("lookup", "unblock", "save")) { + fun `failed contact restoration permits retry without compensating writes`() = runTest { + for (bulk in listOf(false, true)) { val sdk = mock() - val secondKey = "5${RING_PUBKY.drop(1)}" - val peers = listOf( - contactPeer(LinkedPeerState.BLOCKED), - contactPeer(LinkedPeerState.BLOCKED).copy(counterparty = secondKey), - ) - val updates = peers.map { ContactUpdate(it.counterparty, "Contact") } + val updates = listOf(ContactUpdate(RING_PUBKY, "Contact")) + val record = mock() val failure = IllegalStateException("storage failure") - whenever(sdk.linkedPeers()).thenReturn(peers) - whenever(sdk.saveContacts(updates)).thenReturn(emptyList()) - when (failurePoint) { - "lookup" -> whenever(sdk.linkedPeers()).thenThrow(failure).thenReturn(peers) - "unblock" -> whenever(sdk.unblockPeer(secondKey)).thenThrow(failure).thenReturn(peers.last()) - "save" -> whenever(sdk.saveContacts(updates)).thenThrow(failure).thenReturn(emptyList()) - } + whenever(sdk.saveContactsAndUnblockPeers(updates)).thenThrow(failure).thenReturn(listOf(record)) val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } - - assertSame(failure, assertFailsWith { service.saveContacts(updates) }) - - if (failurePoint == "lookup") { - verify(sdk, never()).blockPeer(any()) + suspend fun save() = if (bulk) { + service.saveContacts(updates) } else { - peers.forEach { verify(sdk).blockPeer(it.counterparty) } + listOf(service.saveContact(RING_PUBKY, "Contact", restorePrivateConnection = true)) } - assertEquals(emptyList(), service.saveContacts(updates)) - verify(sdk, times(2)).linkedPeers() - verify(sdk, times(if (failurePoint == "save") 2 else 1)).saveContacts(updates) - verify(sdk, never()).saveContact(any()) + + assertSame(failure, assertFailsWith { save() }) + assertEquals(listOf(record), save()) + + verify(sdk, times(2)).saveContactsAndUnblockPeers(updates) + verify(sdk, never()).blockPeer(any()) + verify(sdk, never()).unblockPeer(any()) } } @@ -1654,8 +1653,7 @@ class PaykitSdkServiceTest { releaseRead.await() emptyList() } - whenever(sdk.linkedPeers()).thenReturn(emptyList()) - whenever(sdk.saveContacts(any())).thenReturn(emptyList()) + whenever(sdk.saveContactsAndUnblockPeers(any())).thenReturn(emptyList()) val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } val read = async { service.contactRecords() } runCurrent() @@ -1668,7 +1666,11 @@ class PaykitSdkServiceTest { when (change) { "identity" -> currentIdentity = "pubky8${RING_PUBKY.drop(1)}" "sign-in" -> isCurrent = false - "cancel" -> save.cancel() + "cancel" -> { + save.cancel() + runCurrent() + assertTrue(save.isCompleted) + } } releaseRead.complete(Unit) read.await() @@ -1678,155 +1680,51 @@ class PaykitSdkServiceTest { "sign-in" -> assertSame(PubkyContactError.SignInChanged, save.await().exceptionOrNull()) "cancel" -> assertFailsWith { save.await() } } - verify(sdk, never()).saveContacts(any()) - verify(sdk, never()).unblockPeer(any()) + verify(sdk, never()).saveContactsAndUnblockPeers(any()) service.saveContacts(updates, currentIdentity) { true } - verify(sdk).saveContacts(updates) + verify(sdk).saveContactsAndUnblockPeers(updates) } } @Test - fun `cancelled bulk save completes SDK mutation and rollback before releasing the queue`() = runTest { - val sdk = mock() - whenever(sdk.linkedPeers()).thenReturn(listOf(contactPeer(LinkedPeerState.BLOCKED))) - val releaseSave = CompletableDeferred() - val releaseRollback = CompletableDeferred() - val events = mutableListOf() - whenever(sdk.saveContacts(any())).doSuspendableAnswer { - releaseSave.await() - events += "saved" - emptyList() - } - whenever(sdk.blockPeer(RING_PUBKY)).doSuspendableAnswer { - events += "rollback" - releaseRollback.await() - events += "blocked" - contactPeer(LinkedPeerState.BLOCKED) - } - 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 { service.saveContacts(updates) } - runCurrent() - save.cancel() - val next = async { service.contactRecords() } - runCurrent() - assertEquals(emptyList(), events) - releaseSave.complete(Unit) - runCurrent() - assertEquals(listOf("saved", "rollback"), events) - assertFalse(next.isCompleted) - releaseRollback.complete(Unit) - - assertFailsWith { save.await() } - next.await() - assertEquals(listOf("saved", "rollback", "blocked", "next"), events) - verify(sdk).saveContacts(updates) - 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 `cancelled contact restore completes its atomic mutation before releasing the queue`() = runTest { + for (bulk in listOf(false, true)) { val sdk = mock() - val peers = listOf( - contactPeer(LinkedPeerState.BLOCKED), - ) - val failure = if (failurePoint == "cancel") { - CancellationException("cancelled") - } else { - IllegalStateException("storage failure") + val releaseSave = CompletableDeferred() + val events = mutableListOf() + whenever(sdk.saveContactsAndUnblockPeers(any())).doSuspendableAnswer { + releaseSave.await() + events += "saved" + listOf(mock()) } - 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)) - .thenThrow(failure).thenReturn(peers.last().copy(state = LinkedPeerState.NOT_LINKED)) - } - "save" -> whenever(sdk.saveContact(any())).thenThrow(failure).thenReturn(mock()) + whenever(sdk.contactRecords()).thenAnswer { + events += "next" + emptyList() } val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } - val thrown = assertFailsWith(failure::class) { - service.saveContact(RING_PUBKY, "Contact", restorePrivateConnection = true) - } - if (failure !is CancellationException) assertSame(failure, thrown) - assertEquals(failure.message, thrown.message) - if (failurePoint == "lookup") { - verify(sdk, never()).blockPeer(any()) - } else { - verify(sdk).blockPeer(RING_PUBKY) + val updates = listOf(ContactUpdate(RING_PUBKY, "Contact")) + val save = async { + if (bulk) { + service.saveContacts(updates) + } else { + service.saveContact(RING_PUBKY, "Contact", restorePrivateConnection = true) + } } - service.saveContact(RING_PUBKY, "Contact", restorePrivateConnection = true) - verify(sdk, atLeastOnce()).saveContact(any()) - } - } - - @Test - fun `private connection restoration preserves failures from rollback`() = runTest { - val sdk = mock() - val peers = listOf( - contactPeer(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)).thenThrow(rollbackFailure) - val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } - - val thrown = assertFailsWith { - service.saveContact(RING_PUBKY, "Contact", restorePrivateConnection = true) - } - - assertSame(restorationFailure, thrown) - assertEquals(listOf(rollbackFailure), thrown.suppressed.toList()) - verify(sdk).blockPeer(RING_PUBKY) - } + runCurrent() + save.cancel() + val next = async { service.contactRecords() } + runCurrent() + assertEquals(emptyList(), events) + assertFalse(next.isCompleted) + releaseSave.complete(Unit) - @Test - fun `cancelled contact restore completes unblocking and rollback before releasing the queue`() = runTest { - val sdk = mock() - whenever { sdk.linkedPeers() }.thenReturn(listOf(contactPeer(LinkedPeerState.BLOCKED))) - val releaseUnblock = CompletableDeferred() - val releaseRollback = CompletableDeferred() - val events = mutableListOf() - whenever { sdk.unblockPeer(RING_PUBKY) }.doSuspendableAnswer { - releaseUnblock.await() - events += "unblocked" - contactPeer(LinkedPeerState.NOT_LINKED) - } - whenever { sdk.blockPeer(RING_PUBKY) }.doSuspendableAnswer { - events += "rollback" - releaseRollback.await() - events += "blocked" - contactPeer(LinkedPeerState.BLOCKED) - } - whenever { sdk.contactRecords() }.thenAnswer { - events += "next" - emptyList() + assertFailsWith { save.await() } + next.await() + assertEquals(listOf("saved", "next"), events) + verify(sdk).saveContactsAndUnblockPeers(updates) + verify(sdk, never()).blockPeer(any()) + assertEquals(1L, service.backupStateVersion.value) } - val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } - val restore = async { service.saveContact(RING_PUBKY, "Contact", restorePrivateConnection = true) } - runCurrent() - restore.cancel() - val next = async { service.contactRecords() } - runCurrent() - assertEquals(emptyList(), events) - releaseUnblock.complete(Unit) - runCurrent() - assertEquals(listOf("unblocked", "rollback"), events) - assertFalse(next.isCompleted) - releaseRollback.complete(Unit) - - assertFailsWith { restore.await() } - next.await() - assertEquals(listOf("unblocked", "rollback", "blocked", "next"), events) - verify(sdk, never()).saveContact(any()) } @Test @@ -1850,8 +1748,7 @@ class PaykitSdkServiceTest { whenever( newSdk.identityStatus() ).thenReturn(IdentityStatus(newIdentity, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) - whenever(newSdk.linkedPeers()).thenReturn(emptyList()) - whenever(newSdk.saveContact(any())).thenReturn(mock()) + whenever(newSdk.saveContactsAndUnblockPeers(any())).thenReturn(listOf(mock())) val handles = ArrayDeque(listOf(originalSdk, newSdk)) val service = PaykitSdkService( context = mock(), @@ -1886,9 +1783,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()) } /** @@ -1915,8 +1812,7 @@ class PaykitSdkServiceTest { whenever( newSdk.identityStatus() ).thenReturn(IdentityStatus(identity, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) - whenever(newSdk.linkedPeers()).thenReturn(emptyList()) - whenever(newSdk.saveContact(any())).thenReturn(mock()) + whenever(newSdk.saveContactsAndUnblockPeers(any())).thenReturn(listOf(mock())) val handles = ArrayDeque(listOf(originalSdk, newSdk)) val service = PaykitSdkService( context = mock(), @@ -1951,9 +1847,9 @@ 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 diff --git a/changelog.d/next/1401.changed.md b/changelog.d/next/1401.changed.md index a8611abdb3..5bec108fca 100644 --- a/changelog.d/next/1401.changed.md +++ b/changelog.d/next/1401.changed.md @@ -1 +1 @@ -Share Paykit contacts and payment state with authorized apps while keeping wallet keys private, prepare incoming payments in the payment sheet, preserve subscription reminders through retries, and label received payments with their Paykit contact. +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, preserve subscription reminders through retries, and label received payments with their Paykit contact. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1ee9c67df1..31b72634ca 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-rc64" } +paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc65" } 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/contacts/import-all-contacts.xml b/journeys/contacts/import-all-contacts.xml index 2119a7b8d5..1633f47e66 100644 --- a/journeys/contacts/import-all-contacts.xml +++ b/journeys/contacts/import-all-contacts.xml @@ -5,6 +5,9 @@ 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 From b5c43df4f0e2500aef26c22e8406cdab95610a1e Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 6 Oct 2026 17:25:14 -0500 Subject: [PATCH 62/77] Cover bulk contact assignment cleanup --- .../PrivatePaykitAddressReservationRepo.kt | 24 -------- ...PrivatePaykitAddressReservationRepoTest.kt | 59 ++++++++++++++++++- 2 files changed, 57 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt index 24bdf79c7a..db7d9d830e 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt @@ -199,30 +199,6 @@ class PrivatePaykitAddressReservationRepo @Inject constructor( return lightningRepo.getAddressBalance(address).getOrDefault(0u) > 0u } - suspend fun clearContactAssignment(publicKey: String) = withContext(ioDispatcher) { - val normalizedKey = normalizedPublicKey(publicKey) - locked { current -> - val hadAssignment = current.contactAssignments.keys.any { - it == normalizedKey - } - val hadHistory = current.contactAssignmentHistory.keys.any { - it == normalizedKey - } - if (!hadAssignment && !hadHistory) return@locked - val next = current.copy( - contactAssignments = current.contactAssignments.filterKeys { - it != normalizedKey - }, - contactAssignmentHistory = current.contactAssignmentHistory.filterKeys { - it != normalizedKey - }, - ) - ledger = next - persist(next) - notifyBackupStateChanged() - } - } - suspend fun clearContactAssignments(excludingPublicKeys: Collection) = withContext(ioDispatcher) { val savedKeys = excludingPublicKeys.mapNotNull { normalizedPublicKeyOrNull(it) }.toSet() locked { current -> diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt index a82342646d..fd4c70180d 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt @@ -170,7 +170,52 @@ class PrivatePaykitAddressReservationRepoTest : BaseUnitTest() { } @Test - fun `clearContactAssignment invalidates attribution even if persistence fails`() = 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( @@ -187,16 +232,26 @@ class PrivatePaykitAddressReservationRepoTest : BaseUnitTest() { address = PRIVATE_ADDRESS, ), ), + historyOnlyKey to listOf( + PrivatePaykitStoredAssignmentData("nativeSegwit", 2, historicalAddress), + ), ), ) 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.clearContactAssignment(CONTACT_KEY) } + 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 From 074e84d0903d3abf36aa4ca0b496da271fc744bf Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 6 Oct 2026 17:25:27 -0500 Subject: [PATCH 63/77] Enforce absolute Paykit deadlines before payment submission --- .../to/bitkit/repositories/HwWalletRepo.kt | 7 +- .../to/bitkit/repositories/LightningRepo.kt | 13 +- .../repositories/PaykitPaymentProofRepo.kt | 5 +- .../repositories/PaykitPaymentRequestRepo.kt | 31 +++- .../java/to/bitkit/repositories/TrezorRepo.kt | 3 + .../to/bitkit/services/LightningService.kt | 16 ++- .../java/to/bitkit/services/TrezorService.kt | 13 +- .../screens/wallets/send/HwSendSignScreen.kt | 12 +- .../screens/wallets/send/HwSendViewModel.kt | 61 +++++++- .../java/to/bitkit/ui/sheets/SendSheet.kt | 2 + app/src/main/java/to/bitkit/utils/Errors.kt | 1 + .../java/to/bitkit/viewmodels/AppViewModel.kt | 75 +++++++--- .../bitkit/repositories/HwWalletRepoTest.kt | 8 +- .../bitkit/repositories/LightningRepoTest.kt | 20 ++- .../PaykitPaymentProofRepoTest.kt | 39 +++++ .../PaykitPaymentRequestRepoTest.kt | 115 ++++++++++++++- .../repositories/PrivatePaykitRepoTest.kt | 25 ++-- .../services/PaymentDeadlineSubmissionTest.kt | 118 +++++++++++++++ .../ui/screens/trezor/TrezorViewModelTest.kt | 6 +- .../wallets/send/HwSendViewModelTest.kt | 134 +++++++++++++++++- .../viewmodels/AppViewModelSendFlowTest.kt | 89 +++++++++++- .../viewmodels/TransferViewModelTest.kt | 112 ++++++--------- changelog.d/next/1401.changed.md | 2 +- journeys/payment-requests/README.md | 9 +- .../absolute-payment-deadline.xml | 26 ++++ .../payment-deadline-history.xml | 6 +- 26 files changed, 810 insertions(+), 138 deletions(-) create mode 100644 app/src/test/java/to/bitkit/services/PaymentDeadlineSubmissionTest.kt create mode 100644 journeys/payment-requests/absolute-payment-deadline.xml 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 540f18dced..5457ff00bf 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -368,12 +368,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, diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 5ba93d1c63..797f2e8ad1 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -7,6 +7,7 @@ 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 @@ -67,6 +68,7 @@ import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Clock import kotlin.time.Duration +import kotlin.time.Duration.Companion.nanoseconds import kotlin.time.ExperimentalTime import kotlin.time.Instant @@ -91,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, @@ -122,9 +125,13 @@ data class PaykitPaymentRequest( 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) @@ -194,6 +201,7 @@ private data class ParsedPaykitPaymentRequestTerms( val amountSats: ULong, val endpoints: List, val expiresAt: Instant?, + val paymentDeadlineAt: Instant?, ) enum class PaykitPaymentRequestDeliveryStatus { Queued, Sent } @@ -923,6 +931,7 @@ class PaykitPaymentRequestRepo @Inject constructor( 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 @@ -939,6 +948,7 @@ class PaykitPaymentRequestRepo @Inject constructor( if (forExecution && !hasCurrentExecutionContext(request)) { throw PaykitPaymentRequestError.RequestUnavailable } + if (request.isPaymentDeadlineExpired(clock.now())) throw PaykitPaymentRequestError.RequestExpired } } @@ -1642,6 +1652,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 { @@ -1651,7 +1664,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) @@ -1664,7 +1678,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 @@ -1732,9 +1746,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) } @@ -1760,7 +1780,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)) } @@ -1776,6 +1796,7 @@ private fun PaymentRequestRecord.toPaykitPaymentRequest( 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) { 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/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index b00abd93fd..a4d19d2ad3 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -82,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 @@ -142,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 { @@ -187,6 +190,7 @@ class LightningService internal constructor( watchOnlyAccountStore: WatchOnlyAccountStore, loggerLdk: LoggerLdk, watchOnlyAccountLifecycleCoordinator: WatchOnlyAccountLifecycleCoordinator, + clock: Clock = Clock.System, ) : this( bgDispatcher = bgDispatcher, ioDispatcher = ioDispatcher, @@ -197,6 +201,7 @@ class LightningService internal constructor( loggerLdk = loggerLdk, watchOnlyAccountLifecycleCoordinator = watchOnlyAccountLifecycleCoordinator, ldkQueue = ServiceQueue.LDK.queueContext, + clock = clock, ) @Volatile @@ -955,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() @@ -964,6 +970,7 @@ class LightningService internal constructor( ) return ServiceQueue.LDK.background(ldkQueue) { + ensurePaymentDeadline(paymentDeadlineAt) if (isMaxAmount) { node.onchainPayment().sendAllToAddress( address = address, @@ -981,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) @@ -990,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) @@ -1001,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() 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/screens/wallets/send/HwSendSignScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendSignScreen.kt index ab5855afdf..232bb0b62a 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,8 @@ fun HwSendSignScreen( viewModel: HwSendViewModel, prepareContactPayment: suspend () -> Boolean, authorizeContactPayment: suspend (hasAttemptedBroadcast: Boolean) -> Boolean, + onPaymentDeadlineExpired: suspend (hasAttemptedBroadcast: Boolean) -> Unit, + paymentDeadlineAt: Instant?, onBack: () -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -62,6 +65,7 @@ fun HwSendSignScreen( amountSats = sendUiState.amount, satsPerVByte = satsPerVByte, tags = sendUiState.selectedTags, + paymentDeadlineAt = paymentDeadlineAt, ) val onBackRequest: () -> Unit = { if (uiState.canLeave) onBack() } @@ -83,7 +87,12 @@ fun HwSendSignScreen( vendor = vendor, onBack = onBackRequest, onOpenConnect = { - viewModel.signAndBroadcast(request, prepareContactPayment, authorizeContactPayment) + viewModel.signAndBroadcast( + request, + prepareContactPayment, + authorizeContactPayment, + onPaymentDeadlineExpired + ) }, ) @@ -96,6 +105,7 @@ fun HwSendSignScreen( passphrase, prepareContactPayment, authorizeContactPayment, + onPaymentDeadlineExpired, ) }, 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..678ebc4400 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 @@ -39,8 +39,11 @@ 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 +52,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 +84,7 @@ class HwSendViewModel @Inject constructor( request: HwSendRequest, prepareContactPayment: suspend () -> Boolean = { true }, authorizeContactPayment: suspend (hasAttemptedBroadcast: Boolean) -> Boolean = { true }, + onPaymentDeadlineExpired: suspend (hasAttemptedBroadcast: Boolean) -> Unit = {}, ) { if (_uiState.value.isSigning || signingJob?.isActive == true) return if (pendingBroadcast?.matches(request) == false) return @@ -111,13 +116,10 @@ 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 } + val result = broadcast(payment, onPaymentDeadlineExpired) ?: return@runCatching 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 +138,50 @@ 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) { + _uiState.update { it.copy(isBroadcastUnresolved = payment.hasAttemptedBroadcast) } + onPaymentDeadlineExpired(payment.hasAttemptedBroadcast) + return false + } + val authorized = authorizeContactPayment(payment.hasAttemptedBroadcast) + if (!authorized && payment.hasAttemptedBroadcast && + payment.request.paymentDeadlineAt?.let { clock.now() > it } == true + ) { + _uiState.update { it.copy(isBroadcastUnresolved = true) } + } + return authorized + } + + private suspend fun broadcast( + payment: PendingHwSendBroadcast, + onPaymentDeadlineExpired: suspend (Boolean) -> Unit, + ): HwFundingBroadcastResult? { + _uiState.update { it.copy(isBroadcastUnresolved = true) } + pendingBroadcast = payment.copy(hasAttemptedBroadcast = 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 + _uiState.update { it.copy(isBroadcastUnresolved = payment.hasAttemptedBroadcast) } + onPaymentDeadlineExpired(payment.hasAttemptedBroadcast) + return null + } + throw error + } + } + fun submitPassphrase( request: HwSendRequest, passphrase: String, prepareContactPayment: suspend () -> Boolean = { true }, authorizeContactPayment: suspend (hasAttemptedBroadcast: Boolean) -> Boolean = { true }, + onPaymentDeadlineExpired: suspend (hasAttemptedBroadcast: Boolean) -> Unit = {}, ) { if (passphrase.isEmpty()) return val state = _uiState.value @@ -155,7 +196,12 @@ class HwSendViewModel @Inject constructor( .onSuccess { if (!_uiState.value.isPassphraseRequired) return@onSuccess _uiState.update { it.copy(isPassphraseRequired = false) } - signAndBroadcast(request, prepareContactPayment, authorizeContactPayment) + signAndBroadcast( + request, + prepareContactPayment, + authorizeContactPayment, + onPaymentDeadlineExpired, + ) } .onFailure { error -> if (error is HwPassphraseMismatchError) { @@ -376,6 +422,7 @@ data class HwSendRequest( val amountSats: ULong, val satsPerVByte: ULong, val tags: List, + val paymentDeadlineAt: Instant? = null, ) private data class PendingHwSendBroadcast( 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 0b4d07a3bb..ef37593029 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -363,6 +363,8 @@ fun SendSheet( viewModel = hwSendViewModel, prepareContactPayment = appViewModel::prepareHardwareContactPayment, authorizeContactPayment = appViewModel::authorizeHardwareContactPayment, + onPaymentDeadlineExpired = appViewModel::onHardwarePaymentDeadlineExpired, + paymentDeadlineAt = appViewModel.hardwarePaymentDeadlineAt, onBack = { navController.previousBackStackEntry ?.savedStateHandle 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/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 6a19d80c2c..4128473a11 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -229,6 +229,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 import kotlin.time.TimeSource @OptIn(ExperimentalTime::class) @@ -4410,6 +4411,7 @@ class AppViewModel @Inject constructor( address = address, amount = amount, tags = tags, + paymentDeadlineAt = incomingPaymentRequest?.paymentDeadlineAt, beforeSendAttempt = { if (preparedPaymentProofRequest != null) { markOnchainPaymentStarted(incomingPaymentRequest, address).getOrThrow() @@ -4531,7 +4533,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() } @@ -4886,6 +4888,7 @@ class AppViewModel @Inject constructor( address: String, amount: ULong, tags: List = emptyList(), + paymentDeadlineAt: Instant? = null, beforeSendAttempt: suspend () -> Unit = {}, onBroadcast: suspend (Txid) -> Unit = {}, ): Result { @@ -4899,6 +4902,7 @@ class AppViewModel @Inject constructor( amount == walletRepo.balanceState.value.maxSendOnchainSats, tags = tags, beforeSendAttempt = beforeSendAttempt, + paymentDeadlineAt = paymentDeadlineAt, onBroadcast = { broadcastTxId = it onBroadcast(it) @@ -4909,9 +4913,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) { @@ -5588,22 +5598,45 @@ class AppViewModel @Inject constructor( return true } + val hardwarePaymentDeadlineAt: Instant? + get() = synchronized(contactPaymentContextLock) { + activeContactPaymentContext?.incomingPaymentRequest?.paymentDeadlineAt + } + 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 + 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, + ) { if (hasAttemptedBroadcast) { 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) { @@ -6497,20 +6530,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/test/java/to/bitkit/repositories/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt index ad7830fbd2..899ad193ac 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(), org.mockito.kotlin.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(), org.mockito.kotlin.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(), org.mockito.kotlin.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(), org.mockito.kotlin.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/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index bcf6c7d5ea..9eb3ed1722 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -3,6 +3,7 @@ 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 @@ -254,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" @@ -437,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"), diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 171b5b1e0f..b79be29bad 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -72,6 +72,7 @@ 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 @@ -632,6 +633,36 @@ 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() @@ -665,7 +696,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 @@ -809,7 +840,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentRequestRecord( id = "deadline-$state", state = state, - paymentDeadline = PaymentDeadline.At(clock.now().toString()), + paymentDeadline = PaymentDeadline.At((clock.now() - 1.seconds).toString()), ) }, ) @@ -1042,7 +1073,14 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `final authorization rechecks identity after asynchronous peer lookup`() = test { restoreAcceptedRequest() whenever(paykitSdkService.allPaymentRequests(anyOrNull())) - .thenReturn(listOf(paymentRequestRecord(state = PaymentRequestLifecycleState.ACCEPTED))) + .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() @@ -1060,6 +1098,77 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { 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() diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 34775c24a4..19f9c80ddd 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -2027,12 +2027,7 @@ 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 `beginPaymentRequest rechecks proposal and payment deadlines after private resolution`() = test { whenever { paykitSdkService.prepareAndResolvePrivatePaymentRequest( eq(CONTACT_KEY), @@ -2048,8 +2043,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()) } } 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..29e98b4758 --- /dev/null +++ b/app/src/test/java/to/bitkit/services/PaymentDeadlineSubmissionTest.kt @@ -0,0 +1,118 @@ +package to.bitkit.services + +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.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 { com.synonym.bitkitcore.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 { com.synonym.bitkitcore.onchainBroadcastRawTx("signed-tx", "electrum") } } + native.verifyNoMoreInteractions() + } + } + } + + private fun lightningService( + node: Node, + clock: Clock, + queue: kotlin.coroutines.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/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..289c7b8382 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 @@ -35,11 +35,15 @@ 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 +56,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,6 +68,9 @@ class HwSendViewModelTest : BaseUnitTest() { preActivityMetadataRepo = preActivityMetadataRepo, coreService = coreService, activityRepo = activityRepo, + clock = object : Clock { + override fun now() = now + }, ) } @@ -277,7 +285,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(), org.mockito.kotlin.anyOrNull()) } @Test @@ -445,7 +453,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(), org.mockito.kotlin.anyOrNull()) assertEquals(HwSendUiState(), sut.uiState.value) sut.signAndBroadcast(request()) @@ -454,6 +462,128 @@ 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() + + sut.signAndBroadcast( + request().copy(paymentDeadlineAt = deadline), + authorizeContactPayment = { + attempts += it + true + }, + onPaymentDeadlineExpired = { attempts += it }, + ) + advanceUntilIdle() + + assertEquals(listOf(false, false), attempts) + assertFalse(sut.uiState.value.isBroadcastUnresolved) + assertFalse(sut.uiState.value.isSigning) + sut.cancel() + assertFalse(sut.uiState.value.hasPendingBroadcast) + } + + @Test + fun `expiry during rebroadcast keeps the earlier uncertain attempt`() = 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 authorize: suspend (Boolean) -> Boolean = { + attempts += it + attempts.size < 3 + } + val request = request().copy(paymentDeadlineAt = deadline) + + sut.signAndBroadcast( + request, + authorizeContactPayment = authorize, + onPaymentDeadlineExpired = { attempts += it } + ) + advanceUntilIdle() + sut.signAndBroadcast( + request, + authorizeContactPayment = authorize, + onPaymentDeadlineExpired = { attempts += it } + ) + advanceUntilIdle() + + assertEquals(listOf(false, true, true), attempts) + assertTrue(sut.uiState.value.isBroadcastUnresolved) + assertFalse(sut.uiState.value.isSigning) + sut.cancel() + assertTrue(sut.uiState.value.hasPendingBroadcast) + assertTrue(sut.uiState.value.isBroadcastUnresolved) + verify(hwWalletRepo).signFunding(WALLET_ID, fixture.funding) + } + + @Test + fun `expiry before rebroadcast preserves uncertainty without repeating authorization`() = 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) + 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() + sut.cancel() + + assertEquals(1, authorizations) + assertEquals(true, expiredPriorAttempt) + assertTrue(sut.uiState.value.isBroadcastUnresolved) + assertTrue(sut.uiState.value.hasPendingBroadcast) + assertFalse(sut.uiState.value.isSigning) + verify(hwWalletRepo).broadcastFunding(fixture.signedTx, deadline) + } + + @Test + fun `expiry during retry authorization preserves the earlier uncertain attempt`() = 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() + sut.cancel() + + assertTrue(sut.uiState.value.isBroadcastUnresolved) + assertTrue(sut.uiState.value.hasPendingBroadcast) + assertFalse(sut.uiState.value.isSigning) + 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/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index d649a7e9a6..16b731a623 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -189,6 +189,7 @@ import to.bitkit.ui.utils.ScreenDeepLinks import to.bitkit.usecases.FormatMoneyValue 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 @@ -450,8 +451,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(paykitPaymentRequestRepo.isPending(any())).thenReturn(true) whenever { paykitPaymentRequestRepo.ensurePaymentAllowed(any()) }.thenReturn(Result.success(Unit)) whenever { paykitPaymentRequestRepo.claimForPayment(any()) }.thenReturn(Result.success(Unit)) - whenever { lightningRepo.payInvoice(any(), anyOrNull(), any()) }.doSuspendableAnswer { - if (it.getArgument Boolean>(2)()) { + 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()) @@ -1837,7 +1838,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val preparation = CompletableDeferred>() whenever(privatePaykitRepo.beginPaymentRequest(request)).doSuspendableAnswer { preparation.await() } whenever(privatePaykitRepo.beginPaymentRequest(reminder)).thenReturn( - Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList) + Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList), ) val bolt11 = "lnbcrt1manualrequest" stubLightningScan(bolt11 = bolt11, amountSats = 0u) @@ -7182,6 +7183,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + paymentDeadlineAt = anyOrNull(), ) verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue, "bitkit") } @@ -7326,6 +7328,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + paymentDeadlineAt = anyOrNull(), ) verify(paykitPaymentProofRepo, never()).markOnchainPaymentStarted(any(), any(), any()) } @@ -7884,6 +7887,34 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(privatePaykitRepo).releasePrivatePaymentList(testPublicKey, privateContext) } + @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 until cancellation`() = test { val request = paymentRequest() @@ -8421,6 +8452,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { 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"), @@ -8676,6 +8708,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + paymentDeadlineAt = anyOrNull(), ) } @@ -8716,6 +8749,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + paymentDeadlineAt = anyOrNull(), ) } @@ -8776,6 +8810,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + paymentDeadlineAt = anyOrNull(), ) } @@ -8802,6 +8837,52 @@ 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" @@ -8835,6 +8916,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + paymentDeadlineAt = anyOrNull(), ) } @@ -9522,6 +9604,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + paymentDeadlineAt = anyOrNull(), ) }.doSuspendableAnswer { invocation -> kotlin.check(invocation.getArgument(0) == address) 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/changelog.d/next/1401.changed.md b/changelog.d/next/1401.changed.md index 5bec108fca..5b372e0508 100644 --- a/changelog.d/next/1401.changed.md +++ b/changelog.d/next/1401.changed.md @@ -1 +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, preserve subscription reminders through retries, and label received payments with their Paykit contact. +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, preserve subscription reminders through retries, and label received payments with their Paykit contact. diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index 2ebde5372a..ffac29178f 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -84,9 +84,14 @@ That run established the issuer shapes captured by the fixture: lowercase `btc`, ## Payment deadline history -`payment-deadline-history.xml` covers 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 +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 diff --git a/journeys/payment-requests/absolute-payment-deadline.xml b/journeys/payment-requests/absolute-payment-deadline.xml new file mode 100644 index 0000000000..62ffc9fe19 --- /dev/null +++ b/journeys/payment-requests/absolute-payment-deadline.xml @@ -0,0 +1,26 @@ + + + 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. + + + 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 + For a hardware payment whose earlier broadcast outcome is uncertain, let its deadline pass before retrying and verify the pending proof and signed transaction remain available for reconciliation + 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/payment-deadline-history.xml b/journeys/payment-requests/payment-deadline-history.xml index ad08b9421b..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. + 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 deadlines in proposed, accepted, rejected, canceled and proof-submitted states, + 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. From 53d459a61c30af29f572f1de7c22af9fbec479aa Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 6 Oct 2026 19:36:20 -0500 Subject: [PATCH 64/77] fix: retain pending links after publication failure --- .../bitkit/repositories/PrivatePaykitRepo.kt | 91 ++++++++++++------- .../repositories/PrivatePaykitRepoTest.kt | 24 +++++ 2 files changed, 80 insertions(+), 35 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 5767a237b4..40e3762531 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -821,45 +821,66 @@ class PrivatePaykitRepo @Inject constructor( retryUnavailableLinks = requireImmediatePublication, ) - 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 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 - } + 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 = publication.updates, - clearUnlistedLinkedPeers = false, - ) - val deliveryError = applyPrivatePaymentListDeliveryReport(report, reason) - val firstError = publication.firstError ?: deliveryError - val retryKeys = (preparation.linkRetryKeys + privatePaymentListDeliveryRetryKeys(report)).distinct() - schedulePendingPrivateMessageDrainRetries(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 }) } } diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 19f9c80ddd..948ccf8033 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -352,6 +352,30 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { verify(addressReservationRepo, never()).currentOrRotatedAddress(any()) } + @Test + 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) + } + + try { + assertTrue(sut.prepareSavedContacts(listOf(CONTACT_KEY)).isFailure) + advanceTimeBy(1_000) + runCurrent() + verify(paykitSdkService, times(2)).ensureLinkWithPeer(CONTACT_KEY) + } finally { + sut.closeAndClear() + } + } + @Test fun `repeated refreshes preserve pending link retry backoff`() = test { settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) From fd04581d25b38c19500f47f3f0f8f360f20f670b Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 6 Oct 2026 22:10:28 -0500 Subject: [PATCH 65/77] fix: streamline paykit payment acceptance with rc66 --- .../java/to/bitkit/repositories/BackupRepo.kt | 1 + .../repositories/PaykitPaymentRequestRepo.kt | 25 ++- .../java/to/bitkit/repositories/PubkyRepo.kt | 21 ++- .../to/bitkit/services/PaykitSdkService.kt | 47 +++++- .../screens/wallets/send/HwSendSignScreen.kt | 9 +- .../java/to/bitkit/ui/sheets/SendSheet.kt | 1 + .../java/to/bitkit/viewmodels/AppViewModel.kt | 29 +++- .../to/bitkit/repositories/BackupRepoTest.kt | 44 +++++ .../PaykitPaymentRequestRepoTest.kt | 40 ++++- .../to/bitkit/repositories/PubkyRepoTest.kt | 17 ++ .../bitkit/services/PaykitSdkServiceTest.kt | 155 +++++++++++++++++- .../viewmodels/AppViewModelSendFlowTest.kt | 44 +++++ changelog.d/next/1401.changed.md | 2 +- gradle/libs.versions.toml | 2 +- journeys/profile/README.md | 2 + 15 files changed, 408 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index a0ac179657..c5af9e14ae 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -431,6 +431,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()) { diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 797f2e8ad1..1eac6d4569 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -318,6 +318,11 @@ 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 @@ -1058,13 +1063,13 @@ class PaykitPaymentRequestRepo @Inject constructor( current.counterparty, 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 } @@ -1419,13 +1424,16 @@ class PaykitPaymentRequestRepo @Inject constructor( 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 { @@ -1434,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, @@ -1629,6 +1649,7 @@ class PaykitPaymentRequestRepo @Inject constructor( } private fun clearStateLocked() { + setPaymentSubmissionActive(false) completedRefreshGeneration = -1L receivedContacts = null expirationJob?.cancel() diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index fa14449ac4..61fa115f00 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -252,6 +252,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( @@ -417,7 +418,13 @@ class PubkyRepo @Inject constructor( ) }.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 -> { @@ -429,12 +436,12 @@ 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 } } } initializationReady.complete(Unit) @@ -479,7 +486,7 @@ class PubkyRepo @Inject constructor( }.getOrElse { if (it.isPaykitTemporarilyUnavailable()) { Logger.warn("Deferred session restoration, keeping saved session", it, context = TAG) - return@getOrElse InitResult.RestorationFailed + return@getOrElse InitResult.RestorationDeferred } Logger.warn("Failed to restore paykit session, attempting re-sign-in", it, context = TAG) resolveSignedInSession(savedSessionSecret, storedSecretKeyHex ?: adoptedSecretKeyHex()) @@ -509,7 +516,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 + } } } } diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 00a10b23b2..66ecdc8190 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -258,10 +258,16 @@ 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.withPaymentAdapterAndPubkySharedStateAndClientConfig( sessionProvider = sessionProvider, @@ -296,6 +302,10 @@ class PaykitSdkService @Inject constructor( initialize { initializeRuntime() } } + fun setPaymentSubmissionActive(active: Boolean) { + _isPaymentSubmissionActive.update { active } + } + suspend fun initializeAndImportSession(secret: String): Result { var imported: Result? = null initialize { @@ -890,10 +900,23 @@ class PaykitSdkService @Inject constructor( suspend fun processOutboundPrivateMessages(counterparty: String) = processOutboundPrivateMessages(counterparty, Priority.Ordered) - internal suspend fun processOutboundPrivateMessages(counterparty: String, priority: Priority) = run { + 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() operationLock.withLock(priority) { withStateRevisionTracking { handle -> + if (expectedIdentity != null) { + val identity = completeSdkCall { handle.identityStatus() }?.publicKey + check(PubkyPublicKeyFormat.matches(identity, expectedIdentity)) { + "Payment Request identity changed" + } + } completeSdkCall { handle.processOutboundPrivateMessages(counterparty) } } } @@ -1011,17 +1034,16 @@ class PaykitSdkService @Inject constructor( paymentRequestId: String, ): PaymentRequestRecord { isSetup.await() - return operationLock.withLock { + return operationLock.withLock(Priority.Interactive) { withStateRevisionTracking { handle -> - completeSdkCall { handle.claimPaymentRequestForExecution(counterparty, paymentRequestId) } - completeSdkCall { handle.acceptPaymentRequest(counterparty, paymentRequestId) } + completeSdkCall { handle.claimAndAcceptPaymentRequest(counterparty, paymentRequestId) } } } } suspend fun claimPaymentRequestForExecution(counterparty: String, paymentRequestId: String): PaymentRequestRecord { isSetup.await() - return operationLock.withLock { + return operationLock.withLock(Priority.Interactive) { withStateRevisionTracking { completeSdkCall { it.claimPaymentRequestForExecution(counterparty, paymentRequestId) } } @@ -1189,8 +1211,18 @@ class PaykitSdkService @Inject constructor( suspend fun exportBackupState(): String { isSetup.await() - return operationLock.withLock { - withPaykitKey { completeSdkCall { it.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 } } @@ -1446,6 +1478,7 @@ class PaykitSdkService @Inject constructor( } private fun resetRuntime() { + runtimeGeneration++ sdk = null cachedPaykitKey = null cachedBackupState = null 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 232bb0b62a..642cc84b7c 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 @@ -51,6 +51,7 @@ fun HwSendSignScreen( prepareContactPayment: suspend () -> Boolean, authorizeContactPayment: suspend (hasAttemptedBroadcast: Boolean) -> Boolean, onPaymentDeadlineExpired: suspend (hasAttemptedBroadcast: Boolean) -> Unit, + onPaymentSubmissionChanged: (Boolean) -> Unit, paymentDeadlineAt: Instant?, onBack: () -> Unit, ) { @@ -73,8 +74,14 @@ fun HwSendSignScreen( LaunchedEffect(walletId) { viewModel.warmUp(walletId) } + LaunchedEffect(uiState.isSigning) { + onPaymentSubmissionChanged(uiState.isSigning) + } DisposableEffect(viewModel) { - onDispose(viewModel::cancel) + onDispose { + viewModel.cancel() + onPaymentSubmissionChanged(false) + } } // Without this the sheet's NavHost pops the route itself, ignoring the guard and skipping onBack BackHandler(onBack = onBackRequest) 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 ef37593029..9d58cb0cc5 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -364,6 +364,7 @@ fun SendSheet( prepareContactPayment = appViewModel::prepareHardwareContactPayment, authorizeContactPayment = appViewModel::authorizeHardwareContactPayment, onPaymentDeadlineExpired = appViewModel::onHardwarePaymentDeadlineExpired, + onPaymentSubmissionChanged = appViewModel::onHardwarePaymentSubmissionChanged, paymentDeadlineAt = appViewModel.hardwarePaymentDeadlineAt, onBack = { navController.previousBackStackEntry diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 4128473a11..74f72e776f 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -416,6 +416,10 @@ class AppViewModel @Inject constructor( 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 val paymentRequestPresentationRetryAttempts = mutableMapOf() private val paymentRequestPresentationRetryJobs = mutableMapOf() @@ -1058,9 +1062,17 @@ class AppViewModel @Inject constructor( }.filterNotNull().collect(::clearPaymentRequestPreparation) } viewModelScope.launch { - paykitPaymentProofRepo.paymentRequestStateChanges(pubkyRepo.publicKey).collect { - refreshIncomingPaykitPaymentRequests(PaykitPaymentRequestRefreshMode.STORED, forceFresh = true) - } + 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 @@ -3475,6 +3487,7 @@ class AppViewModel @Inject constructor( failureReason: IncomingPaykitPaymentRequestFailureReason, retryIncomingRequest: Boolean = true, ) { + isSubmittingPaymentRequest = false uncertainOnchainPaymentRequestId = null val interruptedRequest = synchronized(contactPaymentContextLock) { val request = activeContactPaymentContext?.incomingPaymentRequest @@ -3503,7 +3516,6 @@ class AppViewModel @Inject constructor( ) { deferPaymentRequestPresentation(interruptedRequest, failureReason) } - isSubmittingPaymentRequest = false } private suspend fun rejectPubkyAuthScan( @@ -5568,6 +5580,11 @@ 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 @@ -5603,6 +5620,10 @@ class AppViewModel @Inject constructor( activeContactPaymentContext?.incomingPaymentRequest?.paymentDeadlineAt } + fun onHardwarePaymentSubmissionChanged(active: Boolean) { + paykitPaymentRequestRepo.setPaymentSubmissionActive(active && activeIncomingPaymentRequest() != null) + } + suspend fun authorizeHardwareContactPayment(hasAttemptedBroadcast: Boolean): Boolean { val contactPaymentContext = synchronized(contactPaymentContextLock) { activeContactPaymentContext } val request = contactPaymentContext?.incomingPaymentRequest ?: return true diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index df3195696d..c768cc070f 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -104,12 +104,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 +147,48 @@ 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 `start observing is skipped while wiping`() = test { sut.setWiping(true) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index b79be29bad..8b123bd50e 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -98,6 +98,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { 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 @@ -111,6 +112,11 @@ 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()), @@ -948,7 +954,7 @@ 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.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) whenever( @@ -959,16 +965,46 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { ).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 diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 54921d7ab6..d76065a47c 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -2447,6 +2447,7 @@ class PubkyRepoTest : BaseUnitTest() { 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) } @@ -2457,6 +2458,22 @@ class PubkyRepoTest : BaseUnitTest() { 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 `initialize should complete contacts load after contact fetch failure`() = test { val session = "saved_session" diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index 0a88ec4cb9..d660e55473 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -471,13 +471,150 @@ class PaykitSdkServiceTest { } @Test - fun `cancelling an active execution claim does not continue to accept`() = runTest { + 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 `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 releaseClaim = CompletableDeferred() - var claimFinished = false - whenever { sdk.claimPaymentRequestForExecution(RING_PUBKY, "request") }.doSuspendableAnswer { - releaseClaim.await() - claimFinished = true + 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 } @@ -486,10 +623,12 @@ class PaykitSdkServiceTest { acceptance.cancel() runCurrent() assertFalse(acceptance.isCompleted) - releaseClaim.complete(Unit) + releaseAcceptance.complete(Unit) assertFailsWith { acceptance.await() } - assertTrue(claimFinished) + 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) } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 16b731a623..653a484873 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -277,6 +277,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { 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" @@ -431,6 +432,11 @@ 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) @@ -8099,6 +8105,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" diff --git a/changelog.d/next/1401.changed.md b/changelog.d/next/1401.changed.md index 5b372e0508..19698e9c2b 100644 --- a/changelog.d/next/1401.changed.md +++ b/changelog.d/next/1401.changed.md @@ -1 +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, preserve subscription reminders through retries, and label received payments with their Paykit contact. +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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 31b72634ca..0c46534f78 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-rc65" } +paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc66" } 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/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. From fb8909817c2755d657320846b92063024828b445 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 6 Oct 2026 22:56:01 -0500 Subject: [PATCH 66/77] fix: resume wallet backups after cancellation --- .../java/to/bitkit/repositories/BackupRepo.kt | 49 +++++++++++-------- .../to/bitkit/repositories/BackupRepoTest.kt | 48 ++++++++++++++++++ 2 files changed, 77 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index c5af9e14ae..0229db4cb4 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 @@ -501,30 +502,38 @@ 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 { + runningBackups -= category + failedBackupRequired -= category + cacheStore.updateBackupStatus(category) { + it.copy( + running = false, + synced = currentTimeMillis(), + ) + } + 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) { diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index c768cc070f..a50588fafe 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 @@ -189,6 +192,51 @@ class BackupRepoTest : BaseUnitTest() { } } + @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 `start observing is skipped while wiping`() = test { sut.setWiping(true) From 6a2b4d207cfc8301344233423e84ee48778bec96 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 6 Oct 2026 22:49:31 -0500 Subject: [PATCH 67/77] fix: coalesce background paykit link preparation --- .../bitkit/repositories/PrivatePaykitRepo.kt | 22 ++++- .../to/bitkit/services/PaykitSdkService.kt | 5 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 2 +- .../repositories/PrivatePaykitRepoTest.kt | 95 +++++++++++++++++-- .../bitkit/services/PaykitSdkServiceTest.kt | 13 +-- .../viewmodels/AppViewModelSendFlowTest.kt | 3 +- 6 files changed, 117 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 40e3762531..a539365f76 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -108,6 +108,7 @@ class PrivatePaykitRepo @Inject constructor( 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 @@ -241,6 +242,7 @@ class PrivatePaykitRepo @Inject constructor( pendingPreparationKeys.clear() activePreparationKeys = emptySet() pendingForceRefreshLightning = false + activeLinkPreparationKeys.clear() clearPendingMessageDrainRetries() } @@ -934,7 +936,7 @@ class PrivatePaykitRepo @Inject constructor( if (peerStates[publicKey] == LinkedPeerState.LINKED) { LinkedPeerState.LINKED } else { - paykitSdkService.ensureLinkWithPeer(publicKey).state + advanceLinkIfIdle(publicKey) } }.onSuccess { unavailableLinkRetryAt.remove(publicKey) @@ -958,6 +960,20 @@ class PrivatePaykitRepo @Inject constructor( return PrivateLinkPreparation(preparedKeys, linkRetryKeys) } + 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) + } + } + private suspend fun canPreparePrivateLink( publicKey: String, state: LinkedPeerState?, @@ -1095,9 +1111,7 @@ class PrivatePaykitRepo @Inject constructor( if (!includeUnsavedPeers && retryKey !in knownSavedContactKeys) return@forEach if (unavailableLinkRetryAt[retryKey]?.let { it > clock.now() } == true) return@forEach runSuspendCatching { - paykitSdkService.ensureLinkWithPeer( - counterparty = retryKey, - ) + advanceLinkIfIdle(retryKey, priority) }.onFailure { Logger.warn( "Failed to advance private Paykit link for '${redacted(retryKey)}' during '$reason'", diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 66ecdc8190..5092042030 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -819,12 +819,13 @@ class PaykitSdkService @Inject constructor( } } - suspend fun ensureLinkWithPeer( + internal suspend fun ensureLinkWithPeer( counterparty: String, maxAdvanceSteps: UInt = 1u, + priority: Priority = Priority.Ordered, ) = run { isSetup.await() - operationLock.withLock { + operationLock.withLock(priority) { withStateRevisionTracking { handle -> completeSdkCall { handle.ensureLinkWithPeer(counterparty, maxAdvanceSteps) } } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 74f72e776f..90f19dd357 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -787,7 +787,7 @@ class AppViewModel @Inject constructor( paykitPaymentRequestRepo.activate(state.publicKey) if (!PubkyPublicKeyFormat.matches(pubkyRepo.publicKey.value, state.publicKey)) return paymentRequestIdentity = state.publicKey - refreshPrivateOnlyPaykitApp("contact sync") + if (identityChanged) refreshPrivateOnlyPaykitApp("contact sync") if (!state.contactsLoaded) return val removedKeys = lastPrivatePaykitContactKeys - state.contactKeys diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 948ccf8033..163098c5a6 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -140,9 +140,11 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever { paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any()) } .thenReturn(privateListDeliveryReport(queuedCounterparties = listOf(CONTACT_KEY))) whenever { paykitSdkService.linkedPeers() }.thenReturn(emptyList()) - whenever { paykitSdkService.ensureLinkWithPeer(any(), any()) }.thenAnswer { + 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.linkedPeers(any())).doSuspendableAnswer { paykitSdkService.linkedPeers() } whenever(paykitSdkService.pendingOutboundPrivateCounterparties(any())).doSuspendableAnswer { @@ -415,7 +417,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { clearInvocations(paykitSdkService) advanceTimeBy(120_000) runCurrent() - verify(paykitSdkService, never()).ensureLinkWithPeer(any(), any()) + verify(paykitSdkService, never()).ensureLinkWithPeer(any(), any(), any()) verify(paykitSdkService, never()).receivePrivateMessages(any()) verify(paykitSdkService, never()).receivePrivateMessagesFromLinkedPeers() sut.closeAndClear() @@ -476,6 +478,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { 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()) @@ -524,6 +527,78 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { } } + @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 `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() + } + } + @Test fun `private message drain and retries do not wait for linked peer advancement`() = test { val keys = listOf(CONTACT_KEY, OTHER_CONTACT_KEY) @@ -531,7 +606,9 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { keys.map { linkedPeer(it.removePrefix("pubky"), LinkedPeerState.LINKED) }, ) whenever(paykitSdkService.pendingOutboundPrivateCounterparties()).thenReturn(keys) - whenever(paykitSdkService.ensureLinkWithPeer(any(), any())).doSuspendableAnswer { awaitCancellation() } + whenever(paykitSdkService.ensureLinkWithPeer(any(), any(), eq(Priority.Ordered))).doSuspendableAnswer { + awaitCancellation() + } val preparation = async { sut.prepareSavedContacts(keys) } try { @@ -541,7 +618,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { advanceTimeBy(1_000) runCurrent() - verify(paykitSdkService, never()).ensureLinkWithPeer(any(), any()) + verify(paykitSdkService, never()).ensureLinkWithPeer(any(), any(), any()) keys.forEach { verify(paykitSdkService, times(2)).processOutboundPrivateMessages(it) verify(paykitSdkService, times(2)).receivePrivateMessages(it) @@ -574,7 +651,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { try { sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() - verify(paykitSdkService, never()).ensureLinkWithPeer(any(), any()) + verify(paykitSdkService, never()).ensureLinkWithPeer(any(), any(), any()) verify(paykitSdkService, never()).receivePrivateMessages(any()) advanceTimeBy(1_000) runCurrent() @@ -721,7 +798,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { ), ) } - whenever(paykitSdkService.ensureLinkWithPeer(any(), any())).thenAnswer { + whenever(paykitSdkService.ensureLinkWithPeer(any(), any(), eq(Priority.Ordered))).thenAnswer { linked = canAdvance LinkedPeerHandshakeReport( it.getArgument(0), @@ -1093,7 +1170,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(result.isSuccess, result.exceptionOrNull().toString()) verify(paykitSdkService).clearPrivatePaymentLists(listOf(CONTACT_KEY)) verify(publicPaykitRepo).syncPaykitApp() - verify(paykitSdkService, never()).ensureLinkWithPeer(any(), any()) + verify(paykitSdkService, never()).ensureLinkWithPeer(any(), any(), any()) verify(paykitSdkService, never()).processOutboundPrivateMessages(any()) verify(paykitSdkService, never()).receivePrivateMessages(any()) verify(paykitSdkService, never()).processPendingPrivateMessages() @@ -1127,7 +1204,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(result.isSuccess, result.exceptionOrNull().toString()) verifyBlocking(paykitSdkService) { clearPrivatePaymentLists(listOf(CONTACT_KEY, OTHER_CONTACT_KEY)) } - verify(paykitSdkService, never()).ensureLinkWithPeer(any(), any()) + verify(paykitSdkService, never()).ensureLinkWithPeer(any(), any(), any()) verifyBlocking(paykitSdkService, atLeast(1)) { linkedPeers() } verifyBlocking(paykitSdkService, times(3)) { pendingOutboundPrivateCounterparties() } verify(paykitSdkService, times(3)).pendingOutboundPrivateCounterparties(Priority.Ordered) @@ -1348,7 +1425,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.beginProfileDeletion() sut.scheduleSavedContactPreparation(listOf(CONTACT_KEY, OTHER_CONTACT_KEY)).getOrThrow() runCurrent() - verify(paykitSdkService, never()).ensureLinkWithPeer(any(), any()) + verify(paykitSdkService, never()).ensureLinkWithPeer(any(), any(), any()) sut.removeSavedContacts(listOf(CONTACT_KEY, OTHER_CONTACT_KEY)).getOrThrow() verify(paykitSdkService, never()).clearPrivatePaymentLists(any()) diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index d660e55473..1f53ee240f 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -320,10 +320,11 @@ class PaykitSdkServiceTest { } @Test - fun `queued passive messages yield to foreground app sync while ordered drains remain barriers`() = runTest { + 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) @@ -345,6 +346,10 @@ class PaykitSdkServiceTest { 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 { @@ -356,11 +361,7 @@ class PaykitSdkServiceTest { runCurrent() val messages = async { service.operation(messagePriority) } val publication = async { - if (appPriority == Priority.Ordered) { - service.syncPaykitApp(privatePaymentsEnabled = false) - } else { - service.syncPaykitApp(privatePaymentsEnabled = false, priority = appPriority) - } + service.syncPaykitApp(privatePaymentsEnabled = false, priority = appPriority) } runCurrent() assertTrue(events.isEmpty()) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 653a484873..906c0ca33a 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -9396,7 +9396,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val prepared = CompletableDeferred() whenever(privatePaykitRepo.awaitContactPreparation()).doSuspendableAnswer { prepared.await() } - clearInvocations(paykitPaymentRequestRepo) + clearInvocations(paykitPaymentRequestRepo, publicPaykitRepo) pubkyContactsLoadVersion.value = 1L runCurrent() assertFalse(prepared.isCompleted) @@ -9406,6 +9406,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(privatePaykitRepo).scheduleSavedContactPreparation(any>()) verify(privatePaykitRepo).pruneUnsavedContactState(any>()) verify(paykitPaymentRequestRepo).refreshEligibleTargets(any(), eq(true)) + verify(publicPaykitRepo, never()).syncPaykitApp() } @Test From 713d4d6a8c2d769e947b9bc1133f121c8204bc43 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 7 Oct 2026 00:27:53 -0500 Subject: [PATCH 68/77] chore: update paykit to rc67 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0c46534f78..785185fb23 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-rc66" } +paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc67" } 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" } From 90177b5361a7505cd11ba338b7e8b0c19dba8d4c Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 7 Oct 2026 00:40:45 -0500 Subject: [PATCH 69/77] fix: defer queued acceptance delivery during payment --- .../to/bitkit/services/PaykitSdkService.kt | 29 +++-- .../bitkit/services/PaykitSdkServiceTest.kt | 114 ++++++++++++++++++ 2 files changed, 135 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 5092042030..38590bc1d6 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -9,6 +9,7 @@ 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 @@ -910,17 +911,29 @@ class PaykitSdkService @Inject constructor( expectedIdentity: String?, ) = run { isSetup.await() - operationLock.withLock(priority) { - withStateRevisionTracking { handle -> - if (expectedIdentity != null) { - val identity = completeSdkCall { handle.identityStatus() }?.publicKey - check(PubkyPublicKeyFormat.matches(identity, expectedIdentity)) { - "Payment Request identity changed" + 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) } } - completeSdkCall { handle.processOutboundPrivateMessages(counterparty) } } - } + } while (report == null) + report } suspend fun processPendingPrivateMessages(): List = diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index 1f53ee240f..4dc4fc28f5 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -585,6 +585,120 @@ class PaykitSdkServiceTest { } } + @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() From 3280a4e208203ab7bdc4ca65b01cf5dc18936c84 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 7 Oct 2026 00:51:53 -0500 Subject: [PATCH 70/77] style: align payment callback naming and test formatting --- .../to/bitkit/ui/screens/wallets/send/HwSendSignScreen.kt | 6 +++--- app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt | 2 +- app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) 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 642cc84b7c..1c312ab363 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 @@ -51,7 +51,7 @@ fun HwSendSignScreen( prepareContactPayment: suspend () -> Boolean, authorizeContactPayment: suspend (hasAttemptedBroadcast: Boolean) -> Boolean, onPaymentDeadlineExpired: suspend (hasAttemptedBroadcast: Boolean) -> Unit, - onPaymentSubmissionChanged: (Boolean) -> Unit, + onPaymentSubmissionChange: (Boolean) -> Unit, paymentDeadlineAt: Instant?, onBack: () -> Unit, ) { @@ -75,12 +75,12 @@ fun HwSendSignScreen( viewModel.warmUp(walletId) } LaunchedEffect(uiState.isSigning) { - onPaymentSubmissionChanged(uiState.isSigning) + onPaymentSubmissionChange(uiState.isSigning) } DisposableEffect(viewModel) { onDispose { viewModel.cancel() - onPaymentSubmissionChanged(false) + onPaymentSubmissionChange(false) } } // Without this the sheet's NavHost pops the route itself, ignoring the guard and skipping onBack 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 9d58cb0cc5..817156d8c2 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -364,7 +364,7 @@ fun SendSheet( prepareContactPayment = appViewModel::prepareHardwareContactPayment, authorizeContactPayment = appViewModel::authorizeHardwareContactPayment, onPaymentDeadlineExpired = appViewModel::onHardwarePaymentDeadlineExpired, - onPaymentSubmissionChanged = appViewModel::onHardwarePaymentSubmissionChanged, + onPaymentSubmissionChange = appViewModel::onHardwarePaymentSubmissionChanged, paymentDeadlineAt = appViewModel.hardwarePaymentDeadlineAt, onBack = { navController.previousBackStackEntry diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index a50588fafe..e249f74184 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -183,7 +183,7 @@ class BackupRepoTest : BaseUnitTest() { verify(vssBackupClient).putObject(eq(BackupCategory.WALLET.name), payload.capture()) assertEquals( "pending-write", - json.decodeFromString(String(payload.firstValue)).paykitSdkBackupState + json.decodeFromString(String(payload.firstValue)).paykitSdkBackupState, ) verify(paykitPaymentProofRepo).backupSnapshot() verify(privatePaykitRepo).backupSnapshot() From 804e7547d3f0bf585d7dff36be8142ee5fa4afc0 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 7 Oct 2026 01:26:14 -0500 Subject: [PATCH 71/77] chore: update paykit to rc68 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 785185fb23..02fbf70c1a 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-rc67" } +paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc68" } 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" } From 6ef90211ffd9fbf5bb9c7927fb9466d9936d613f Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 7 Oct 2026 04:27:59 -0500 Subject: [PATCH 72/77] fix: complete reconciled hardware payments --- .../screens/wallets/send/HwSendSignScreen.kt | 1 + .../screens/wallets/send/HwSendViewModel.kt | 35 +++++++++ .../java/to/bitkit/ui/sheets/SendSheet.kt | 6 ++ .../java/to/bitkit/viewmodels/AppViewModel.kt | 12 +++ .../wallets/send/HwSendViewModelTest.kt | 73 ++++++++++++++++++- .../viewmodels/AppViewModelSendFlowTest.kt | 49 +++++++++++++ docs/paykit-issuer-interoperability.md | 10 ++- 7 files changed, 180 insertions(+), 6 deletions(-) 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 1c312ab363..4b1b451f1b 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 @@ -67,6 +67,7 @@ fun HwSendSignScreen( satsPerVByte = satsPerVByte, tags = sendUiState.selectedTags, paymentDeadlineAt = paymentDeadlineAt, + paymentRequestId = sendUiState.incomingPaymentRequestId, ) val onBackRequest: () -> Unit = { if (uiState.canLeave) onBack() } 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 678ebc4400..7111459588 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 @@ -34,6 +36,7 @@ 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 @@ -119,7 +122,9 @@ class HwSendViewModel @Inject constructor( if (!authorizeBroadcast(payment, authorizeContactPayment, onPaymentDeadlineExpired)) { return@runCatching } + currentCoroutineContext().ensureActive() val result = broadcast(payment, onPaymentDeadlineExpired) ?: 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) } @@ -255,6 +260,35 @@ class HwSendViewModel @Inject constructor( } } + fun resolveBroadcast(walletId: String, requestId: PaykitPaymentRequestId, transactionId: String) { + val pending = pendingBroadcast ?: return + if (!pending.hasAttemptedBroadcast || pendingResult.value != null) return + if (pending.request.walletId != walletId || pending.request.paymentRequestId != requestId) return + + // 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.value = HwSendResult(walletId, transactionId, pending.request.amountSats) + } finally { + if (signingAttempt == attempt) { + _uiState.update { it.copy(isSigning = false, isConnectingDevice = false) } + signingJob = null + } + } + } + } + private suspend fun prepareSignedTransaction( walletId: String, address: String, @@ -423,6 +457,7 @@ data class HwSendRequest( 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/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index 817156d8c2..98ddb83822 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -154,6 +154,12 @@ 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 + hwSendViewModel.resolveBroadcast(walletId, requestId, transactionId) + } LaunchedEffect(hwSendViewModel, navController) { hwSendViewModel.results.collect { result -> appViewModel.completeHardwareContactPayment(result.txId) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 90f19dd357..796c622bbd 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -866,6 +866,13 @@ 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 + if (_currentSheet.value is Sheet.Send && sendState.hardwareWalletId != null && + sendState.incomingPaymentRequestId == resolution.requestId + ) { + _sendUiState.update { it.copy(resolvedHardwarePaymentTxId = resolution.transactionId) } + return + } val resolvesCurrentPayment = uncertainOnchainPaymentRequestId == resolution.requestId if (!resolvesCurrentPayment) { synchronizeResolvedPaykitOnchainPayment(resolution, updateSendDetails = false) @@ -1493,6 +1500,7 @@ class AppViewModel @Inject constructor( dismissActiveRequest: Boolean, preserveRequestedPaymentRequest: Boolean, ) { + _sendUiState.update { it.copy(resolvedHardwarePaymentTxId = null) } dismissedPreparingRequestIds.clear() invalidatePaymentRequestPresentation(dismissActiveRequest) clearPaymentRequestPresentationRetries() @@ -5661,6 +5669,9 @@ class AppViewModel @Inject constructor( } fun completeHardwareContactPayment(txId: String) { + _sendUiState.update { + if (it.resolvedHardwarePaymentTxId == txId) it.copy(resolvedHardwarePaymentTxId = null) else it + } val context = synchronized(contactPaymentContextLock) { preparedContactPaymentContext } @@ -6412,6 +6423,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, 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 289c7b8382..797d314597 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,6 +10,7 @@ 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 @@ -30,6 +31,7 @@ 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 @@ -526,13 +528,14 @@ class HwSendViewModelTest : BaseUnitTest() { } @Test - fun `expiry before rebroadcast preserves uncertainty without repeating authorization`() = 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 request = request().copy(paymentDeadlineAt = deadline) + 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 = { @@ -556,9 +559,75 @@ class HwSendViewModelTest : BaseUnitTest() { assertTrue(sut.uiState.value.isBroadcastUnresolved) assertTrue(sut.uiState.value.hasPendingBroadcast) assertFalse(sut.uiState.value.isSigning) + assertFalse(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() + + 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 preserves the earlier uncertain attempt`() = test { whenever(context.getString(any())).thenReturn("message") diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 906c0ca33a..412100873c 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -8601,6 +8601,55 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(privatePaykitRepo, never()).releasePrivatePaymentList(any(), any()) } + @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.copy(paymentRequestId = "other-request"), + transactionId, + ), + ) + runCurrent() + assertNull(sut.sendUiState.value.resolvedHardwarePaymentTxId) + + onchainPaymentResolutions.value = listOf( + PaykitOnchainPaymentProofResolution(testPublicKey, request.id, transactionId), + ) + runCurrent() + repeat(2) { + sut.sendUiState.test { + assertEquals(transactionId, awaitItem().resolvedHardwarePaymentTxId) + } + } + 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() diff --git a/docs/paykit-issuer-interoperability.md b/docs/paykit-issuer-interoperability.md index 6b38bb5e25..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 From 660c3975455d3553b5f9d74b69542a45515df0ad Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 7 Oct 2026 05:00:46 -0500 Subject: [PATCH 73/77] Document hardware recovery checks and use atomic result updates --- .../screens/wallets/send/HwSendViewModel.kt | 2 +- journeys/payment-requests/README.md | 23 +++++++++++++++++++ .../absolute-payment-deadline.xml | 3 ++- 3 files changed, 26 insertions(+), 2 deletions(-) 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 7111459588..baceddba2a 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 @@ -279,7 +279,7 @@ class HwSendViewModel @Inject constructor( ) runSuspendCatching { persistResult(pending.request, result) } .onFailure { Logger.error("Failed to persist hardware send result", it, context = TAG) } - pendingResult.value = HwSendResult(walletId, transactionId, pending.request.amountSats) + pendingResult.update { HwSendResult(walletId, transactionId, pending.request.amountSats) } } finally { if (signingAttempt == attempt) { _uiState.update { it.copy(isSigning = false, isConnectingDevice = false) } diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index ffac29178f..fddb7729fa 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -82,6 +82,29 @@ 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. + +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. + ## Payment deadline history `absolute-payment-deadline.xml` covers one-time requests with absolute payment deadlines, diff --git a/journeys/payment-requests/absolute-payment-deadline.xml b/journeys/payment-requests/absolute-payment-deadline.xml index 62ffc9fe19..d340c96749 100644 --- a/journeys/payment-requests/absolute-payment-deadline.xml +++ b/journeys/payment-requests/absolute-payment-deadline.xml @@ -5,6 +5,8 @@ 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 @@ -18,7 +20,6 @@ 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 - For a hardware payment whose earlier broadcast outcome is uncertain, let its deadline pass before retrying and verify the pending proof and signed transaction remain available for reconciliation 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 From 9c87876d90a38cdea803b6b1c42c39f55bce22c4 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 7 Oct 2026 06:43:18 -0500 Subject: [PATCH 74/77] fix: preserve payment and backup recovery on interruption --- .../java/to/bitkit/repositories/BackupRepo.kt | 32 ++++--- .../repositories/PaykitPaymentProofRepo.kt | 2 + .../screens/wallets/send/HwSendSignScreen.kt | 27 +++--- .../screens/wallets/send/HwSendViewModel.kt | 46 +++++----- .../java/to/bitkit/ui/sheets/SendSheet.kt | 5 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 54 +++++++++--- .../to/bitkit/repositories/BackupRepoTest.kt | 43 ++++++++++ .../bitkit/repositories/HwWalletRepoTest.kt | 8 +- .../PaykitPaymentProofRepoTest.kt | 6 +- .../services/PaymentDeadlineSubmissionTest.kt | 14 +-- .../wallets/send/HwSendViewModelTest.kt | 46 ++++++---- .../viewmodels/AppViewModelSendFlowTest.kt | 86 +++++++++++++------ journeys/payment-requests/README.md | 4 + 13 files changed, 255 insertions(+), 118 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 0229db4cb4..f759ce09fc 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -515,13 +515,15 @@ class BackupRepo @Inject constructor( vssBackupClient.putObject(key = category.name, data = data) .onSuccess { - runningBackups -= category - failedBackupRequired -= category - cacheStore.updateBackupStatus(category) { - it.copy( - running = false, - synced = currentTimeMillis(), - ) + withContext(NonCancellable) { + cacheStore.updateBackupStatus(category) { + it.copy( + running = false, + synced = currentTimeMillis(), + ) + } + runningBackups -= category + failedBackupRequired -= category } Logger.info("Backup succeeded for: '$category'", context = TAG) } @@ -537,14 +539,16 @@ class BackupRepo @Inject constructor( } 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) } diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index 5457ff00bf..34cfc8a739 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -82,6 +82,7 @@ data class PaykitOnchainPaymentProofResolution( val identity: String, val requestId: PaykitPaymentRequestId, val transactionId: String, + val walletId: String = WalletScope.default, ) @Singleton @@ -534,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 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 4b1b451f1b..e57ebc60f4 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 @@ -52,6 +52,7 @@ fun HwSendSignScreen( authorizeContactPayment: suspend (hasAttemptedBroadcast: Boolean) -> Boolean, onPaymentDeadlineExpired: suspend (hasAttemptedBroadcast: Boolean) -> Unit, onPaymentSubmissionChange: (Boolean) -> Unit, + onBroadcastAttemptChanged: (Boolean) -> Unit, paymentDeadlineAt: Instant?, onBack: () -> Unit, ) { @@ -71,6 +72,15 @@ fun HwSendSignScreen( ) val onBackRequest: () -> Unit = { if (uiState.canLeave) onBack() } + val signAndBroadcast: () -> Unit = { + viewModel.signAndBroadcast( + request, + prepareContactPayment, + authorizeContactPayment, + onPaymentDeadlineExpired, + onBroadcastAttemptChanged, + ) + } LaunchedEffect(walletId) { viewModel.warmUp(walletId) @@ -94,27 +104,14 @@ fun HwSendSignScreen( hasPendingBroadcast = uiState.hasPendingBroadcast, vendor = vendor, onBack = onBackRequest, - onOpenConnect = { - viewModel.signAndBroadcast( - request, - prepareContactPayment, - authorizeContactPayment, - onPaymentDeadlineExpired - ) - }, + onOpenConnect = signAndBroadcast, ) if (uiState.isPassphraseRequired) { HwPassphrasePromptSheet( isVerifying = uiState.isVerifyingPassphrase, onSubmit = { passphrase -> - viewModel.submitPassphrase( - request, - passphrase, - prepareContactPayment, - authorizeContactPayment, - onPaymentDeadlineExpired, - ) + 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 baceddba2a..103b581f31 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 @@ -88,6 +88,7 @@ class HwSendViewModel @Inject constructor( 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 @@ -123,7 +124,8 @@ class HwSendViewModel @Inject constructor( return@runCatching } currentCoroutineContext().ensureActive() - val result = broadcast(payment, onPaymentDeadlineExpired) ?: return@runCatching + 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) } @@ -149,31 +151,28 @@ class HwSendViewModel @Inject constructor( onPaymentDeadlineExpired: suspend (Boolean) -> Unit, ): Boolean { if (payment.request.paymentDeadlineAt?.let { clock.now() > it } == true) { - _uiState.update { it.copy(isBroadcastUnresolved = payment.hasAttemptedBroadcast) } onPaymentDeadlineExpired(payment.hasAttemptedBroadcast) return false } - val authorized = authorizeContactPayment(payment.hasAttemptedBroadcast) - if (!authorized && payment.hasAttemptedBroadcast && - payment.request.paymentDeadlineAt?.let { clock.now() > it } == true - ) { - _uiState.update { it.copy(isBroadcastUnresolved = true) } - } - return authorized + 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 - _uiState.update { it.copy(isBroadcastUnresolved = payment.hasAttemptedBroadcast) } + onBroadcastAttemptChanged(payment.hasAttemptedBroadcast) + _uiState.update { it.copy(isBroadcastUnresolved = wasBroadcastUnresolved) } onPaymentDeadlineExpired(payment.hasAttemptedBroadcast) return null } @@ -182,11 +181,9 @@ class HwSendViewModel @Inject constructor( } fun submitPassphrase( - request: HwSendRequest, + walletId: String, passphrase: String, - prepareContactPayment: suspend () -> Boolean = { true }, - authorizeContactPayment: suspend (hasAttemptedBroadcast: Boolean) -> Boolean = { true }, - onPaymentDeadlineExpired: suspend (hasAttemptedBroadcast: Boolean) -> Unit = {}, + onVerified: () -> Unit, ) { if (passphrase.isEmpty()) return val state = _uiState.value @@ -197,16 +194,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, - onPaymentDeadlineExpired, - ) + onVerified() } .onFailure { error -> if (error is HwPassphraseMismatchError) { @@ -216,7 +208,7 @@ class HwSendViewModel @Inject constructor( description = context.getString(R.string.hardware__passphrase_mismatch), ) } else { - handleFailure(error, request.walletId) + handleFailure(error, walletId) } } } finally { @@ -260,10 +252,11 @@ class HwSendViewModel @Inject constructor( } } - fun resolveBroadcast(walletId: String, requestId: PaykitPaymentRequestId, transactionId: String) { - val pending = pendingBroadcast ?: return - if (!pending.hasAttemptedBroadcast || pendingResult.value != null) return - if (pending.request.walletId != walletId || pending.request.paymentRequestId != requestId) return + 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() @@ -287,6 +280,7 @@ class HwSendViewModel @Inject constructor( } } } + return true } private suspend fun prepareSignedTransaction( 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 98ddb83822..98e5026342 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -158,7 +158,9 @@ fun SendSheet( val transactionId = sendUiState.resolvedHardwarePaymentTxId ?: return@LaunchedEffect val walletId = sendUiState.hardwareWalletId ?: return@LaunchedEffect val requestId = sendUiState.incomingPaymentRequestId ?: return@LaunchedEffect - hwSendViewModel.resolveBroadcast(walletId, requestId, transactionId) + if (!hwSendViewModel.resolveBroadcast(walletId, requestId, transactionId)) { + appViewModel.acknowledgeHardwarePaymentResolution(transactionId) + } } LaunchedEffect(hwSendViewModel, navController) { hwSendViewModel.results.collect { result -> @@ -371,6 +373,7 @@ fun SendSheet( authorizeContactPayment = appViewModel::authorizeHardwareContactPayment, onPaymentDeadlineExpired = appViewModel::onHardwarePaymentDeadlineExpired, onPaymentSubmissionChange = appViewModel::onHardwarePaymentSubmissionChanged, + onBroadcastAttemptChanged = appViewModel::onHardwareBroadcastAttemptChanged, paymentDeadlineAt = appViewModel.hardwarePaymentDeadlineAt, onBack = { navController.previousBackStackEntry diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 796c622bbd..9dcb75251f 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -404,6 +404,8 @@ class AppViewModel @Inject constructor( 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() @@ -787,7 +789,7 @@ class AppViewModel @Inject constructor( paykitPaymentRequestRepo.activate(state.publicKey) if (!PubkyPublicKeyFormat.matches(pubkyRepo.publicKey.value, state.publicKey)) return paymentRequestIdentity = state.publicKey - if (identityChanged) refreshPrivateOnlyPaykitApp("contact sync") + refreshPrivateOnlyPaykitApp("contact sync", onlyIfNeeded = !identityChanged) if (!state.contactsLoaded) return val removedKeys = lastPrivatePaykitContactKeys - state.contactKeys @@ -867,9 +869,12 @@ class AppViewModel @Inject constructor( if (!PubkyPublicKeyFormat.matches(pubkyRepo.publicKey.value, resolution.identity)) return paykitPaymentProofRepo.consumeOnchainPaymentResolution(resolution) val sendState = _sendUiState.value - if (_currentSheet.value is Sheet.Send && sendState.hardwareWalletId != null && + 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 } @@ -903,12 +908,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) } @@ -1621,12 +1629,19 @@ class AppViewModel @Inject constructor( return shouldHideRequestSendSheet } - private suspend fun refreshPrivateOnlyPaykitApp(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 + 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 app registration for '$reason'", it, context = TAG) } @@ -3502,6 +3517,7 @@ class AppViewModel @Inject constructor( if (request != null && !retryIncomingRequest) completePaymentRequestPresentation(request) activeContactPaymentContext = null preparedContactPaymentContext = null + attemptedHardwarePaymentContext = null request } if (interruptedRequest == null) return @@ -5632,6 +5648,12 @@ class AppViewModel @Inject constructor( 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 @@ -5645,7 +5667,7 @@ class AppViewModel @Inject constructor( handleHardwarePaymentFailure( PaykitPaymentRequestError.RequestExpired, contactPaymentContext, - hasAttemptedBroadcast + hasAttemptedBroadcast, ) } @@ -5654,7 +5676,10 @@ class AppViewModel @Inject constructor( contactPaymentContext: ContactPaymentContext?, hasAttemptedBroadcast: Boolean, ) { - if (hasAttemptedBroadcast) { + val preservesUncertainPayment = synchronized(contactPaymentContextLock) { + contactPaymentContext != null && contactPaymentContext == attemptedHardwarePaymentContext + } + if (hasAttemptedBroadcast || preservesUncertainPayment) { toast(error) return } @@ -5669,9 +5694,7 @@ class AppViewModel @Inject constructor( } fun completeHardwareContactPayment(txId: String) { - _sendUiState.update { - if (it.resolvedHardwarePaymentTxId == txId) it.copy(resolvedHardwarePaymentTxId = null) else it - } + acknowledgeHardwarePaymentResolution(txId) val context = synchronized(contactPaymentContextLock) { preparedContactPaymentContext } @@ -5680,12 +5703,21 @@ class AppViewModel @Inject constructor( completeOnchainPaymentProofInBackground(request, txId, preparation) } + fun acknowledgeHardwarePaymentResolution(txId: String) { + _sendUiState.update { + if (it.resolvedHardwarePaymentTxId == txId) it.copy(resolvedHardwarePaymentTxId = null) else it + } + } + fun onHardwareSignCancelled() { cancelHardwarePaymentRequestIfNeeded() } private fun cancelHardwarePaymentRequestIfNeeded() { val request = synchronized(contactPaymentContextLock) { + if (preparedContactPaymentContext == attemptedHardwarePaymentContext) { + preparedContactPaymentContext = null + } activeContactPaymentContext ?.takeIf { it == preparedContactPaymentContext && _sendUiState.value.hardwareWalletId != null } ?.incomingPaymentRequest diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index e249f74184..d4ddc47502 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -237,6 +237,49 @@ class BackupRepoTest : BaseUnitTest() { } } + @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/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt index 899ad193ac..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(), org.mockito.kotlin.anyOrNull()) + 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(), org.mockito.kotlin.anyOrNull()) + 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(), org.mockito.kotlin.anyOrNull()) + 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(), org.mockito.kotlin.anyOrNull()) + verify(trezorRepo, never()).broadcastRawTx(any(), anyOrNull()) } @Test diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index 9eb3ed1722..6d301477f1 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -856,7 +856,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.prepare(firstRequest, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(firstRequest, ONCHAIN_ADDRESS).getOrThrow() repo.prepare(secondRequest, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() - repo.markOnchainPaymentStarted(secondRequest, ONCHAIN_ADDRESS).getOrThrow() + repo.markOnchainPaymentStarted(secondRequest, ONCHAIN_ADDRESS, "hardware-wallet").getOrThrow() repo.reconcile() @@ -864,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 diff --git a/app/src/test/java/to/bitkit/services/PaymentDeadlineSubmissionTest.kt b/app/src/test/java/to/bitkit/services/PaymentDeadlineSubmissionTest.kt index 29e98b4758..5f70b0462d 100644 --- a/app/src/test/java/to/bitkit/services/PaymentDeadlineSubmissionTest.kt +++ b/app/src/test/java/to/bitkit/services/PaymentDeadlineSubmissionTest.kt @@ -1,5 +1,6 @@ package to.bitkit.services +import com.synonym.bitkitcore.onchainBroadcastRawTx import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.runBlocking @@ -16,6 +17,7 @@ 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 @@ -36,7 +38,7 @@ class PaymentDeadlineSubmissionTest : BaseUnitTest() { object : Clock { override fun now() = now }, - StandardTestDispatcher(testScheduler) + StandardTestDispatcher(testScheduler), ) for (isMax in listOf(false, true)) { @@ -62,7 +64,7 @@ class PaymentDeadlineSubmissionTest : BaseUnitTest() { object : Clock { override fun now() = deadline }, - testDispatcher + testDispatcher, ) val error = runCatching { @@ -80,12 +82,12 @@ class PaymentDeadlineSubmissionTest : BaseUnitTest() { mock(), object : Clock { override fun now() = now - } + }, ) withContext(ServiceQueue.CORE.queueContext) { mockStatic(Class.forName("com.synonym.bitkitcore.Bitkitcore_androidKt")).use { native -> native.`when` { - runBlocking { com.synonym.bitkitcore.onchainBroadcastRawTx("signed-tx", "electrum") } + runBlocking { onchainBroadcastRawTx("signed-tx", "electrum") } }.thenReturn("txid") assertEquals("txid", service.broadcastRawTx("signed-tx", "electrum", deadline)) now = deadline + 1.nanoseconds @@ -93,7 +95,7 @@ class PaymentDeadlineSubmissionTest : BaseUnitTest() { val error = runCatching { service.broadcastRawTx("signed-tx", "electrum", deadline) }.exceptionOrNull() assertIs(error?.cause) - native.verify { runBlocking { com.synonym.bitkitcore.onchainBroadcastRawTx("signed-tx", "electrum") } } + native.verify { runBlocking { onchainBroadcastRawTx("signed-tx", "electrum") } } native.verifyNoMoreInteractions() } } @@ -102,7 +104,7 @@ class PaymentDeadlineSubmissionTest : BaseUnitTest() { private fun lightningService( node: Node, clock: Clock, - queue: kotlin.coroutines.CoroutineContext, + queue: CoroutineContext, ) = LightningService( bgDispatcher = testDispatcher, ioDispatcher = testDispatcher, 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 797d314597..e1663520b7 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 @@ -15,6 +15,7 @@ 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 @@ -240,7 +241,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) @@ -287,7 +290,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(), org.mockito.kotlin.anyOrNull()) + verify(hwWalletRepo, never()).broadcastFunding(any(), anyOrNull()) } @Test @@ -455,7 +458,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(), org.mockito.kotlin.anyOrNull()) + verify(hwWalletRepo, never()).broadcastFunding(any(), anyOrNull()) assertEquals(HwSendUiState(), sut.uiState.value) sut.signAndBroadcast(request()) @@ -471,6 +474,7 @@ class HwSendViewModelTest : BaseUnitTest() { whenever(hwWalletRepo.broadcastFunding(fixture.signedTx, deadline)) .thenReturn(Result.failure(AppError(ServiceError.PaymentDeadlineExpired()))) val attempts = mutableListOf() + val broadcastAttempts = mutableListOf() sut.signAndBroadcast( request().copy(paymentDeadlineAt = deadline), @@ -479,10 +483,12 @@ class HwSendViewModelTest : BaseUnitTest() { 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() @@ -490,7 +496,7 @@ class HwSendViewModelTest : BaseUnitTest() { } @Test - fun `expiry during rebroadcast keeps the earlier uncertain attempt`() = 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") @@ -499,6 +505,7 @@ class HwSendViewModelTest : BaseUnitTest() { Result.failure(AppError(ServiceError.PaymentDeadlineExpired())), ) val attempts = mutableListOf() + val broadcastAttempts = mutableListOf() val authorize: suspend (Boolean) -> Boolean = { attempts += it attempts.size < 3 @@ -508,22 +515,27 @@ class HwSendViewModelTest : BaseUnitTest() { sut.signAndBroadcast( request, authorizeContactPayment = authorize, - onPaymentDeadlineExpired = { attempts += it } + onPaymentDeadlineExpired = { attempts += it }, + onBroadcastAttemptChanged = { broadcastAttempts += it }, ) advanceUntilIdle() sut.signAndBroadcast( request, authorizeContactPayment = authorize, - onPaymentDeadlineExpired = { attempts += it } + onPaymentDeadlineExpired = { attempts += it }, + onBroadcastAttemptChanged = { broadcastAttempts += it }, ) advanceUntilIdle() assertEquals(listOf(false, true, true), attempts) - assertTrue(sut.uiState.value.isBroadcastUnresolved) + 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() - assertTrue(sut.uiState.value.hasPendingBroadcast) - assertTrue(sut.uiState.value.isBroadcastUnresolved) + assertFalse(sut.uiState.value.hasPendingBroadcast) + assertTrue(sut.uiState.value.canLeave) verify(hwWalletRepo).signFunding(WALLET_ID, fixture.funding) } @@ -552,14 +564,13 @@ class HwSendViewModelTest : BaseUnitTest() { onPaymentDeadlineExpired = { expiredPriorAttempt = it }, ) advanceUntilIdle() - sut.cancel() assertEquals(1, authorizations) assertEquals(true, expiredPriorAttempt) - assertTrue(sut.uiState.value.isBroadcastUnresolved) + assertFalse(sut.uiState.value.isBroadcastUnresolved) assertTrue(sut.uiState.value.hasPendingBroadcast) assertFalse(sut.uiState.value.isSigning) - assertFalse(sut.uiState.value.canLeave) + assertTrue(sut.uiState.value.canLeave) sut.resolveBroadcast(WALLET_ID, requestId, fixture.broadcast.txId) advanceUntilIdle() @@ -619,7 +630,7 @@ class HwSendViewModelTest : BaseUnitTest() { sut.signAndBroadcast(request().copy(paymentRequestId = requestId), authorizeContactPayment = { false }) advanceUntilIdle() - sut.resolveBroadcast(WALLET_ID, requestId, "txid") + assertFalse(sut.resolveBroadcast(WALLET_ID, requestId, "txid")) advanceUntilIdle() verify(hwWalletRepo, never()).broadcastFunding(any(), any()) @@ -629,7 +640,7 @@ class HwSendViewModelTest : BaseUnitTest() { } @Test - fun `expiry during retry authorization preserves the earlier uncertain attempt`() = 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") @@ -645,11 +656,14 @@ class HwSendViewModelTest : BaseUnitTest() { false }) advanceUntilIdle() - sut.cancel() - assertTrue(sut.uiState.value.isBroadcastUnresolved) + 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) } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 412100873c..27ac1cfbf2 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -7922,7 +7922,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `hardware retry denial keeps the started proof until cancellation`() = test { + fun `hardware retry denial keeps the started proof after cancellation`() = test { val request = paymentRequest() val privateContext = privatePaymentContext(7uL) whenever(context.getString(R.string.common__error)).thenReturn("Error") @@ -7953,6 +7953,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() assertTrue(sut.prepareHardwareContactPayment()) + sut.onHardwareBroadcastAttemptChanged(true) sut.sendEffect.test { assertFalse(sut.authorizeHardwareContactPayment(hasAttemptedBroadcast = true)) expectNoEvents() @@ -7965,8 +7966,16 @@ 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 @@ -7997,7 +8006,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `dismissing hardware signing fails the started payment proof`() = test { + fun `dismissing hardware signing preserves only attempted payment proofs`() = test { val request = paymentRequest() val privateContext = privatePaymentContext(7uL) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) @@ -8014,14 +8023,19 @@ class AppViewModelSendFlowTest : BaseUnitTest() { hardwareWalletId = "hardware-wallet", ) ) - sut.showSheet(Sheet.Send(SendRoute.HardwareSign)) - advanceUntilIdle() - assertTrue(sut.prepareHardwareContactPayment()) + for (attempted in listOf(true, false)) { + setActiveContactPaymentContext(testPublicKey, privateContext, request) + sut.showSheet(Sheet.Send(SendRoute.HardwareSign)) + advanceUntilIdle() + assertTrue(sut.prepareHardwareContactPayment()) + clearInvocations(paykitPaymentProofRepo) + if (attempted) sut.onHardwareBroadcastAttemptChanged(true) - sut.hideSheet() - advanceUntilIdle() + sut.hideSheet() + advanceUntilIdle() - verify(paykitPaymentProofRepo).failOnchainPayment(request) + verify(paykitPaymentProofRepo, times(if (attempted) 0 else 1)).failOnchainPayment(request) + } verify(privatePaykitRepo, never()).releasePrivatePaymentList(any(), any()) } @@ -8634,7 +8648,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertNull(sut.sendUiState.value.resolvedHardwarePaymentTxId) onchainPaymentResolutions.value = listOf( - PaykitOnchainPaymentProofResolution(testPublicKey, request.id, transactionId), + PaykitOnchainPaymentProofResolution(testPublicKey, request.id, transactionId, "hardware-wallet"), ) runCurrent() repeat(2) { @@ -8642,6 +8656,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { 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) @@ -8657,21 +8672,20 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pubkyPublicKey.value = testPublicKey runCurrent() - onchainPaymentResolutions.value = listOf( - PaykitOnchainPaymentProofResolution( - testPublicKey, - request.id, - transactionId, - ), - ) - runCurrent() + for (walletId in listOf(WalletScope.default, "hardware-wallet")) { + onchainPaymentResolutions.value = listOf( + PaykitOnchainPaymentProofResolution(testPublicKey, request.id, transactionId, walletId), + ) + runCurrent() - verify(paykitPaymentProofRepo).consumeOnchainPaymentResolution(any()) - verify(activityRepo).setContact( - contactPublicKey = request.counterparty, - forPaymentId = transactionId, - syncLdkPayments = false, - ) + verify(activityRepo).setContact( + contactPublicKey = request.counterparty, + forPaymentId = transactionId, + syncLdkPayments = false, + walletId = walletId, + ) + } + verify(paykitPaymentProofRepo, times(2)).consumeOnchainPaymentResolution(any()) assertNull(sut.successSendUiState.value.paymentHashOrTxId) } @@ -9458,6 +9472,30 @@ class AppViewModelSendFlowTest : BaseUnitTest() { 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() + + pubkyContactsLoadVersion.value = 2L + advanceUntilIdle() + verify(publicPaykitRepo, times(2)).syncPaykitApp() + } + @Test fun `private Paykit sync ignores contact row updates that keep the same keys`() = test { enablePaykitUi() diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index fddb7729fa..79fecb0d34 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -104,6 +104,10 @@ this test as blocked, not passed, when they are unavailable. Do not change the d 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 From 50811b2a5ac9ae82bbe0b7a4ffedd62573da8ae2 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 7 Oct 2026 07:29:32 -0500 Subject: [PATCH 75/77] fix: preserve hardware payment failure outcomes --- .../to/bitkit/ext/BroadcastExceptionExt.kt | 4 + .../screens/wallets/send/HwSendSignScreen.kt | 4 +- .../screens/wallets/send/HwSendViewModel.kt | 7 +- .../java/to/bitkit/ui/sheets/SendSheet.kt | 2 +- .../to/bitkit/repositories/BackupRepoTest.kt | 4 +- .../wallets/send/HwSendViewModelTest.kt | 84 +++++++++++-------- .../viewmodels/AppViewModelSendFlowTest.kt | 7 +- journeys/contacts/README.md | 3 + .../contacts/delete-newly-saved-contact.xml | 13 +++ journeys/payment-requests/README.md | 5 ++ 10 files changed, 91 insertions(+), 42 deletions(-) create mode 100644 journeys/contacts/delete-newly-saved-contact.xml 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/ui/screens/wallets/send/HwSendSignScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendSignScreen.kt index e57ebc60f4..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 @@ -52,7 +52,7 @@ fun HwSendSignScreen( authorizeContactPayment: suspend (hasAttemptedBroadcast: Boolean) -> Boolean, onPaymentDeadlineExpired: suspend (hasAttemptedBroadcast: Boolean) -> Unit, onPaymentSubmissionChange: (Boolean) -> Unit, - onBroadcastAttemptChanged: (Boolean) -> Unit, + onBroadcastAttemptChange: (Boolean) -> Unit, paymentDeadlineAt: Instant?, onBack: () -> Unit, ) { @@ -78,7 +78,7 @@ fun HwSendSignScreen( prepareContactPayment, authorizeContactPayment, onPaymentDeadlineExpired, - onBroadcastAttemptChanged, + onBroadcastAttemptChange, ) } 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 103b581f31..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 @@ -21,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 @@ -176,6 +177,10 @@ class HwSendViewModel @Inject constructor( onPaymentDeadlineExpired(payment.hasAttemptedBroadcast) return null } + if (error.isDefiniteHardwarePreBroadcastFailure()) { + pendingBroadcast = payment + onBroadcastAttemptChanged(payment.hasAttemptedBroadcast) + } throw error } } @@ -403,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) } } 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 98e5026342..abc9b961fc 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -373,7 +373,7 @@ fun SendSheet( authorizeContactPayment = appViewModel::authorizeHardwareContactPayment, onPaymentDeadlineExpired = appViewModel::onHardwarePaymentDeadlineExpired, onPaymentSubmissionChange = appViewModel::onHardwarePaymentSubmissionChanged, - onBroadcastAttemptChanged = appViewModel::onHardwareBroadcastAttemptChanged, + onBroadcastAttemptChange = appViewModel::onHardwareBroadcastAttemptChanged, paymentDeadlineAt = appViewModel.hardwarePaymentDeadlineAt, onBack = { navController.previousBackStackEntry diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index d4ddc47502..adcb8760a8 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -245,7 +245,7 @@ class BackupRepoTest : BaseUnitTest() { val statusWriteStarted = CompletableDeferred() val finishStatusWrite = CompletableDeferred() whenever(cacheStore.backupStatuses).thenReturn(statuses) - whenever { cacheStore.updateBackupStatus(eq(category), any()) }.doSuspendableAnswer { + whenever(cacheStore.updateBackupStatus(eq(category), any())).doSuspendableAnswer { val transform = it.getArgument<(BackupItemStatus) -> BackupItemStatus>(1) val updated = transform(statuses.value.getValue(category)) if (!updated.running) { @@ -259,7 +259,7 @@ class BackupRepoTest : BaseUnitTest() { } else { Result.failure(BackupRepoTestError("upload failed")) } - whenever { vssBackupClient.putObject(eq(category.name), any()) }.thenReturn(uploadResult) + whenever(vssBackupClient.putObject(eq(category.name), any())).thenReturn(uploadResult) val backup = launch { sut.triggerBackup(category) } runCurrent() 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 e1663520b7..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 @@ -79,44 +79,12 @@ class HwSendViewModelTest : BaseUnitTest() { @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) @@ -174,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") diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 27ac1cfbf2..6fc29f788b 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -8029,7 +8029,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() assertTrue(sut.prepareHardwareContactPayment()) clearInvocations(paykitPaymentProofRepo) - if (attempted) sut.onHardwareBroadcastAttemptChanged(true) + sut.onHardwareBroadcastAttemptChanged(true) + if (!attempted) sut.onHardwareBroadcastAttemptChanged(false) sut.hideSheet() advanceUntilIdle() @@ -9491,9 +9492,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() verify(publicPaykitRepo, times(2)).syncPaykitApp() - pubkyContactsLoadVersion.value = 2L + val contact = PubkyProfile.placeholder("pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg") + pubkyContacts.value = listOf(contact) advanceUntilIdle() verify(publicPaykitRepo, times(2)).syncPaykitApp() + verify(privatePaykitRepo).scheduleSavedContactPreparation(setOf(contact.publicKey)) } @Test diff --git a/journeys/contacts/README.md b/journeys/contacts/README.md index c7d1b27ace..9776491a11 100644 --- a/journeys/contacts/README.md +++ b/journeys/contacts/README.md @@ -1,5 +1,8 @@ # 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. 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/payment-requests/README.md b/journeys/payment-requests/README.md index 79fecb0d34..a96332a4ce 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -89,6 +89,11 @@ payment deadline, and fault injection that can drop a successful broadcast respo 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 From 7bc3ccb1a488d6646d10155f8931bf386ffa31b4 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 7 Oct 2026 08:32:53 -0500 Subject: [PATCH 76/77] fix: present queued requests after closing a sheet --- .../to/bitkit/ui/components/SheetHostTest.kt | 39 +++++++++++++++++++ .../java/to/bitkit/ui/components/SheetHost.kt | 3 +- .../bitkit/ext/BroadcastExceptionExtTest.kt | 16 ++++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 19 +++++---- gradle/libs.versions.toml | 2 +- 5 files changed, 69 insertions(+), 10 deletions(-) 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/main/java/to/bitkit/ui/components/SheetHost.kt b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt index 0127ea5ef7..ced2e3015a 100644 --- a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt +++ b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt @@ -142,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/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/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 6fc29f788b..16dccc499a 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -201,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 @@ -7619,8 +7620,12 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test @Suppress("LongMethod") - fun `failed LNURL request callback releases preparation and retry reopens request`() = test { - val request = paymentRequest() + 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", @@ -8006,7 +8011,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `dismissing hardware signing preserves only attempted payment proofs`() = test { + fun `dismissing hardware signing preserves only unresolved attempted payment proofs`() = test { val request = paymentRequest() val privateContext = privatePaymentContext(7uL) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) @@ -8023,19 +8028,19 @@ class AppViewModelSendFlowTest : BaseUnitTest() { hardwareWalletId = "hardware-wallet", ) ) - for (attempted in listOf(true, false)) { + 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) - sut.onHardwareBroadcastAttemptChanged(true) - if (!attempted) sut.onHardwareBroadcastAttemptChanged(false) + attemptChanges.forEach(sut::onHardwareBroadcastAttemptChanged) sut.hideSheet() advanceUntilIdle() - verify(paykitPaymentProofRepo, times(if (attempted) 0 else 1)).failOnchainPayment(request) + verify(paykitPaymentProofRepo, times(if (attemptChanges.lastOrNull() == true) 0 else 1)) + .failOnchainPayment(request) } verify(privatePaykitRepo, never()).releasePrivatePaymentList(any(), any()) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 02fbf70c1a..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-rc68" } +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" } From fd71df54ae052b44594fb4930d8ff4fb797087ee Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 7 Oct 2026 09:55:53 -0500 Subject: [PATCH 77/77] fix: retry deferred sessions while foregrounded --- .../java/to/bitkit/repositories/PubkyRepo.kt | 34 ++++- .../java/to/bitkit/viewmodels/AppViewModel.kt | 15 ++ .../to/bitkit/repositories/PubkyRepoTest.kt | 134 ++++++++++++++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 48 +++++++ journeys/paykit-clock-changes.md | 3 + 5 files changed, 233 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 61fa115f00..3fcbf211ae 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -148,11 +148,19 @@ 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() @@ -340,6 +348,23 @@ class PubkyRepo @Inject constructor( 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 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 @@ -368,9 +393,14 @@ class PubkyRepo @Inject constructor( } } - private suspend fun restoreSession(): Boolean { + 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() ) { @@ -401,6 +431,7 @@ class PubkyRepo @Inject constructor( 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 } } @@ -444,6 +475,7 @@ class PubkyRepo @Inject constructor( if (notifyFailure) _sessionRestorationFailed.update { result == InitResult.RestorationFailed } } } + deferredRestoreGeneration = signInGeneration.get().takeIf { result == InitResult.RestorationDeferred } initializationReady.complete(Unit) return result is InitResult.Restored } finally { diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 9dcb75251f..ce1f7ef551 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -423,6 +423,7 @@ class AppViewModel @Inject constructor( paykitPaymentRequestRepo.setPaymentSubmissionActive(value) } private var paykitPaymentRequestPollingJob: Job? = null + private var paykitSessionRestoreRetryJob: Job? = null private val paymentRequestPresentationRetryAttempts = mutableMapOf() private val paymentRequestPresentationRetryJobs = mutableMapOf() private val paymentRequestPresentationCompletionJobs = mutableMapOf() @@ -974,6 +975,7 @@ class AppViewModel @Inject constructor( isPaymentRequestPollingStopped = false if (paykitPaymentRequestPollingJob?.isActive == true) return + startPaykitSessionRestoreRetries() paykitPaymentRequestPollingJob = viewModelScope.launch { if (isOnline.value == ConnectivityState.CONNECTED) pubkyRepo.republishIdentityIfNeeded() refreshIncomingPaykitPaymentRequests(messagePriority = Priority.Background) @@ -1012,6 +1014,17 @@ class AppViewModel @Inject constructor( } } + 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 @@ -1061,6 +1074,8 @@ class AppViewModel @Inject constructor( fun stopPaykitPaymentRequestPolling() { isPaymentRequestPollingStopped = true + paykitSessionRestoreRetryJob?.cancel() + paykitSessionRestoreRetryJob = null clearPaymentRequestPreparation() paykitPaymentRequestPollingJob?.cancel() paykitPaymentRequestPollingJob = null diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index d76065a47c..8bb9963573 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -94,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 @@ -2474,6 +2475,139 @@ class PubkyRepoTest : BaseUnitTest() { 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" diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 16dccc499a..f9200c346b 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -914,6 +914,54 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } } + @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() diff --git a/journeys/paykit-clock-changes.md b/journeys/paykit-clock-changes.md index c44f79ec7a..3a8cfcb46b 100644 --- a/journeys/paykit-clock-changes.md +++ b/journeys/paykit-clock-changes.md @@ -38,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.