From 2e84f158810bca1b58034f2c1596e22ed8bc7415 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sun, 26 Jul 2026 18:42:23 +0200 Subject: [PATCH 1/9] fix: include paykit native symbols --- app/build.gradle.kts | 1 + .../java/to/bitkit/build/NativeReleaseConfigTest.kt | 12 +++++++++++- scripts/create-native-debug-symbols.sh | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 602aeb1f3..171a4042c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -388,6 +388,7 @@ dependencies { implementation(libs.vss.client) nativeDebugSymbols(libs.bitkit.core.nativeDebugSymbolsArtifact()) nativeDebugSymbols(libs.ldk.node.android.nativeDebugSymbolsArtifact()) + nativeDebugSymbols(libs.paykit.nativeDebugSymbolsArtifact()) nativeDebugSymbols(libs.vss.client.nativeDebugSymbolsArtifact()) // Firebase implementation(platform(libs.firebase.bom)) diff --git a/app/src/test/java/to/bitkit/build/NativeReleaseConfigTest.kt b/app/src/test/java/to/bitkit/build/NativeReleaseConfigTest.kt index 2ebe63e63..d52a6dfda 100644 --- a/app/src/test/java/to/bitkit/build/NativeReleaseConfigTest.kt +++ b/app/src/test/java/to/bitkit/build/NativeReleaseConfigTest.kt @@ -24,6 +24,16 @@ class NativeReleaseConfigTest { ) } + @Test + fun `release build resolves Paykit symbol archive`() { + val buildFile = repoRoot.resolve("app/build.gradle.kts").readText() + + assertTrue( + buildFile.contains("nativeDebugSymbols(libs.paykit.nativeDebugSymbolsArtifact())"), + "Release builds must resolve the Paykit native debug symbols classifier.", + ) + } + @Test fun `release recipe verifies native debug symbols archive`() { val justfile = repoRoot.resolve("Justfile").readText() @@ -103,7 +113,7 @@ class NativeReleaseConfigTest { ) assertTrue( symbolsScript.contains( - """required_libs="libbitkitcore.so libldk_node.so libvss_rust_client_ffi.so"""", + """required_libs="libbitkitcore.so libldk_node.so libpaykit.so libvss_rust_client_ffi.so"""", ), "Native debug symbols script must validate release-critical native libraries.", ) diff --git a/scripts/create-native-debug-symbols.sh b/scripts/create-native-debug-symbols.sh index db33ce798..31cb08272 100755 --- a/scripts/create-native-debug-symbols.sh +++ b/scripts/create-native-debug-symbols.sh @@ -26,7 +26,7 @@ esac output="app/build/outputs/native-debug-symbols/$variant/native-debug-symbols-$build_number.zip" output_dir=$(dirname "$output") dependency_symbols_dir="app/build/intermediates/native-debug-symbol-artifacts" -required_libs="libbitkitcore.so libldk_node.so libvss_rust_client_ffi.so" +required_libs="libbitkitcore.so libldk_node.so libpaykit.so libvss_rust_client_ffi.so" archive_symbol_suffixes=".dbg .sym" tmp_dirs="" From 4c6e0f638ed71ba49883922052808a7b754853ac Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 27 Jul 2026 18:09:45 +0200 Subject: [PATCH 2/9] fix: update native dependency releases --- .../java/to/bitkit/data/keychain/Keychain.kt | 1 + .../bitkit/repositories/PrivatePaykitRepo.kt | 19 +- .../to/bitkit/services/LightningService.kt | 2 + .../to/bitkit/services/PaykitSdkService.kt | 252 +++++++++++++++--- .../repositories/PrivatePaykitRepoTest.kt | 46 ++-- .../repositories/PublicPaykitRepoTest.kt | 8 +- .../bitkit/services/PaykitSdkServiceTest.kt | 63 +++++ gradle/libs.versions.toml | 8 +- 8 files changed, 314 insertions(+), 85 deletions(-) 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 7eb9431a7..00d4f31f5 100644 --- a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt +++ b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt @@ -232,6 +232,7 @@ class Keychain @Inject constructor( PIN, PIN_ATTEMPTS_REMAINING, PAYKIT_SESSION, + PAYKIT_RECEIVER_NOISE_SECRET_KEY, PAYKIT_SDK_STATE, PUBKY_SECRET_KEY, } diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 905f0c269..412372a01 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -1,12 +1,11 @@ package to.bitkit.repositories import com.synonym.bitkitcore.Scanner -import com.synonym.paykit.ContactPaymentResolutionPrivateState import com.synonym.paykit.LinkedPeerState -import com.synonym.paykit.PaymentEndpointReservationInput -import com.synonym.paykit.PaymentEndpointSource +import com.synonym.paykit.PrivatePaymentEndpointReservationInput import com.synonym.paykit.PrivatePaymentListDeliveryReport import com.synonym.paykit.PrivatePaymentListReservationUpdateInput +import com.synonym.paykit.PrivatePaymentResolutionState import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -33,6 +32,7 @@ import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.toHex import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.services.CoreService +import to.bitkit.services.PaykitPaymentEndpointSource import to.bitkit.services.PaykitReceiverPaths import to.bitkit.services.PaykitSdkService import to.bitkit.services.PubkyService @@ -457,7 +457,7 @@ class PrivatePaykitRepo @Inject constructor( includePublicEndpoints = true, ) val privateEndpoints = resolution.payableEndpoints - .filter { it.source == PaymentEndpointSource.PRIVATE_PAYMENT_LIST } + .filter { it.source == PaykitPaymentEndpointSource.PRIVATE_PAYMENT_LIST } .mapNotNull { PublicPaykitRepo.parseEndpoint(it.identifier, it.payload) } cacheResolvedPrivateEndpoints(publicKey, privateEndpoints) @@ -469,7 +469,7 @@ class PrivatePaykitRepo @Inject constructor( ) } - if (resolution.privateState == ContactPaymentResolutionPrivateState.RECOVERY_PENDING) { + if (resolution.privateState == PrivatePaymentResolutionState.RECOVERY_PENDING) { schedulePendingPrivateMessageDrainRetries( reason = "payment recovery", retryKeys = listOf(PrivateMessageDrainRetryKey(publicKey, PaykitReceiverPaths.WALLET)), @@ -477,7 +477,7 @@ class PrivatePaykitRepo @Inject constructor( } val publicEndpoints = resolution.payableEndpoints - .filter { it.source == PaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT } + .filter { it.source == PaykitPaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT } .mapNotNull { PublicPaykitRepo.parseEndpoint(it.identifier, it.payload) } val publicPayable = publicPaykitRepo.payableEndpoints(publicEndpoints) if (publicPayable.isNotEmpty()) { @@ -994,7 +994,7 @@ class PrivatePaykitRepo @Inject constructor( publicKey: String, receiverPath: String, endpoint: Endpoint, - ): PaymentEndpointReservationInput { + ): 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 @@ -1015,7 +1015,7 @@ class PrivatePaykitRepo @Inject constructor( ?.takeIf { endpoint.methodId == MethodId.Bolt11 && it.bolt11 == endpoint.value } ?.let { Instant.ofEpochSecond(it.expiresAt).toString() } - return PaymentEndpointReservationInput( + return PrivatePaymentEndpointReservationInput( reservationId = privateReservationId(publicKey, receiverPath, endpoint), identifier = endpoint.methodId.rawValue, payload = endpoint.rawPayload, @@ -1240,8 +1240,7 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun hasLocalSecretKeyForCurrentProfile(): Boolean = runSuspendCatching { pubkyService.currentPublicKey() ?: return@runSuspendCatching false - val status = paykitSdkService.identityStatus() ?: return@runSuspendCatching false - status.privateLinkCapable + paykitSdkService.hasLocalSecretKey() }.getOrDefault(false) private suspend fun isContactSharingCleanupPending(): Boolean = diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 75419bda3..8aa3b22ba 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -275,6 +275,8 @@ class LightningService @Inject constructor( feeRateCacheUpdateIntervalSecs = Env.walletSyncIntervalSecs, ), connectionTimeoutSecs = Env.walletSyncTimeoutSecs, + additionalWalletFullScanBatchSize = 5u, + additionalWalletFullScanStopGap = 20u, ), ) } diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 7df973096..8464f8459 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -1,14 +1,12 @@ package to.bitkit.services import android.content.Context -import com.synonym.paykit.ContactPaymentResolution -import com.synonym.paykit.ContactPaymentResolutionPrivateState +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.PaykitAndroid import com.synonym.paykit.PaykitException @@ -18,24 +16,29 @@ import com.synonym.paykit.PaykitReceiverCapabilities import com.synonym.paykit.PaykitReceiverMarker import com.synonym.paykit.PaykitSdk import com.synonym.paykit.PaykitSdkDefaults -import com.synonym.paykit.PaymentEndpointCandidate -import com.synonym.paykit.PaymentEndpointReservationCancellation -import com.synonym.paykit.PaymentEndpointSelectionRequest -import com.synonym.paykit.PaymentEndpointSource import com.synonym.paykit.PaymentPayload import com.synonym.paykit.PaymentTarget +import com.synonym.paykit.PrivateContactPaymentResolution +import com.synonym.paykit.PrivatePaymentEndpointCandidate +import com.synonym.paykit.PrivatePaymentEndpointReservationCancellation +import com.synonym.paykit.PrivatePaymentEndpointSelectionRequest import com.synonym.paykit.PrivatePaymentListDeliveryReport import com.synonym.paykit.PrivatePaymentListReservationUpdateInput +import com.synonym.paykit.PrivatePaymentResolutionState +import com.synonym.paykit.PrivateReceivingDetail +import com.synonym.paykit.PrivateReceivingDetailReservationResponse +import com.synonym.paykit.PrivateReceivingDetailReservationResponseKind import com.synonym.paykit.PubkyAuthRequest import com.synonym.paykit.PubkyLocalSecretKey import com.synonym.paykit.PubkyProfile import com.synonym.paykit.PubkySessionAccess import com.synonym.paykit.PubkySessionBootstrap import com.synonym.paykit.PubkySessionBootstrapResult -import com.synonym.paykit.ReceivingDetail -import com.synonym.paykit.ReceivingDetailReservationResponse -import com.synonym.paykit.ReceivingDetailReservationResponseKind -import com.synonym.paykit.ReceivingDetailScope +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 @@ -66,18 +69,26 @@ import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.repositories.Endpoint import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.utils.AppError +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 data class PaykitContactPaymentResolution( - val privateState: ContactPaymentResolutionPrivateState, + val privateState: PrivatePaymentResolutionState, val payableEndpoints: List, ) +enum class PaykitPaymentEndpointSource { + PRIVATE_PAYMENT_LIST, + PUBLIC_PAYMENT_ENDPOINT, +} + data class PaykitResolvedPaymentEndpoint( val counterparty: String, - val source: PaymentEndpointSource, + val source: PaykitPaymentEndpointSource, val identifier: String, val payload: String, ) @@ -148,10 +159,10 @@ class PaykitSdkService @Inject constructor( } } - suspend fun identityStatus(): IdentityStatus? { + suspend fun hasLocalSecretKey(): Boolean { isSetup.await() return operationMutex.withLock { - handle().identityStatus() + sessionProvider.loadLocalSecretKey() != null } } @@ -164,6 +175,7 @@ class PaykitSdkService @Inject constructor( val result = PubkySessionBootstrap().importSession( sessionSecret = secret, localSecretKey = if (includeLocalSecret) sessionProvider.loadLocalSecretKey() else null, + receiverNoiseSecretKey = sessionProvider.loadOrDeriveReceiverNoiseSecretKey(), requiredCapabilities = requiredSessionCapabilities(paykitSdkConfig()), ) operationMutex.withLock { @@ -186,6 +198,7 @@ class PaykitSdkService @Inject constructor( val previousPublicKey = operationMutex.withLock { currentSdkStatePublicKeyLocked() } val result = PubkySessionBootstrap().signUp( localSecretKey = localSecretKey(secretKeyHex), + receiverNoiseSecretKey = sessionProvider.loadOrDeriveReceiverNoiseSecretKey(), homeserverPublicKey = homeserverPublicKey, signupCode = signupCode, requiredCapabilities = requiredCapabilities(), @@ -206,6 +219,7 @@ class PaykitSdkService @Inject constructor( val previousPublicKey = operationMutex.withLock { currentSdkStatePublicKeyLocked() } val result = PubkySessionBootstrap().signIn( localSecretKey = localSecretKey(secretKeyHex), + receiverNoiseSecretKey = sessionProvider.loadOrDeriveReceiverNoiseSecretKey(), requiredCapabilities = requiredCapabilities(), ) operationMutex.withLock { @@ -237,6 +251,7 @@ class PaykitSdkService @Inject constructor( try { request.complete( localSecretKey = null, + receiverNoiseSecretKey = sessionProvider.loadOrDeriveReceiverNoiseSecretKey(), requiredCapabilities = requiredCapabilities(), ).also { activateBootstrapResult( @@ -427,10 +442,9 @@ class PaykitSdkService @Inject constructor( return@withStateRevisionTracking } - val status = handle.identityStatus() handle.publishPaykitReceiverMarker( PaykitReceiverCapabilities( - privatePayments = status?.privateLinkCapable == true, + privatePayments = sessionProvider.loadLocalSecretKey() != null, paymentRequests = false, receipts = false, outgoingPayments = true, @@ -444,7 +458,7 @@ class PaykitSdkService @Inject constructor( isSetup.await() return operationMutex.withLock { withStateRevisionTracking { handle -> - handle.syncPublicEndpointsWithReceivingDetails(endpoints.map { it.toReceivingDetail() }) + handle.syncPublicEndpointsWithReceivingDetails(endpoints.map { it.toPublicReceivingDetail() }) } } } @@ -529,18 +543,24 @@ class PaykitSdkService @Inject constructor( includePublicEndpoints: Boolean, ): PaykitContactPaymentResolution { isSetup.await() - val prepared = operationMutex.withLock { + val (privateResolution, publicResolution) = operationMutex.withLock { withStateRevisionTracking { handle -> - handle.prepareAndResolveContactPayment( + val privateResolution = handle.prepareAndResolvePrivateContactPayment( counterparty = counterparty, counterpartyReceiverPath = receiverPath, amount = null, - includePublicEndpoints = includePublicEndpoints, + afterPrivatePaymentListVersion = null, maxAdvanceSteps = 8u, - ) + ).resolution + val publicResolution = if (includePublicEndpoints) { + handle.resolvePublicContactPayment(counterparty, receiverPath, amount = null) + } else { + null + } + privateResolution to publicResolution } } - return prepared.resolution.toPaykitContactPaymentResolution() + return privateResolution.toPaykitContactPaymentResolution(publicResolution) } suspend fun resolvePublicContactPayment( @@ -554,17 +574,34 @@ class PaykitSdkService @Inject constructor( return resolution.toPaykitContactPaymentResolution() } - private fun ContactPaymentResolution.toPaykitContactPaymentResolution(): PaykitContactPaymentResolution { + private fun PrivateContactPaymentResolution.toPaykitContactPaymentResolution( + publicResolution: PublicContactPaymentResolution?, + ): PaykitContactPaymentResolution { return PaykitContactPaymentResolution( - privateState = privateState, + privateState = state, payableEndpoints = payableEndpoints.map { PaykitResolvedPaymentEndpoint( counterparty = it.counterparty, - source = it.source, + source = PaykitPaymentEndpointSource.PRIVATE_PAYMENT_LIST, identifier = it.identifier, payload = it.target.payload.exportText(), ) - }, + } + publicResolution?.resolvedEndpoints().orEmpty(), + ) + } + + private fun PublicContactPaymentResolution.toPaykitContactPaymentResolution() = + PaykitContactPaymentResolution( + privateState = PrivatePaymentResolutionState.NO_PRIVATE_ENDPOINT, + payableEndpoints = resolvedEndpoints(), + ) + + private fun PublicContactPaymentResolution.resolvedEndpoints() = payableEndpoints.map { + PaykitResolvedPaymentEndpoint( + counterparty = it.counterparty, + source = PaykitPaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, + identifier = it.identifier, + payload = it.target.payload.exportText(), ) } @@ -644,6 +681,7 @@ class PaykitSdkService @Inject constructor( shouldStoreLocalSecret: Boolean, ) { keychain.upsertString(Keychain.Key.PAYKIT_SESSION.name, access.exportSessionSecret()) + sessionProvider.persistReceiverNoiseSecretKey(access.exportReceiverNoiseSecretKey()) val localSecret = access.exportLocalSecretKey() if (shouldStoreLocalSecret && localSecret != null) { keychain.upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, secretKeyHex(localSecret)) @@ -790,6 +828,7 @@ private class PaykitSdkSessionProvider( private val keychain: Keychain, ) : SdkPubkySessionProvider { private val lock = Any() + private val receiverNoiseKeyStore = PaykitReceiverNoiseKeyStore(keychain) private var liveSessionAccess: PubkySessionAccess? = null fun setLiveSessionAccess(access: PubkySessionAccess) = synchronized(lock) { @@ -814,6 +853,7 @@ private class PaykitSdkSessionProvider( return PubkySessionAccess( sessionSecret = sessionSecret, localSecretKey = loadLocalSecretKey(), + receiverNoiseSecretKey = loadOrDeriveReceiverNoiseSecretKey(), ) } @@ -833,23 +873,163 @@ private class PaykitSdkSessionProvider( ?: return null 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) + } + } + + private fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray = + Mac.getInstance("HmacSHA256").run { + init(SecretKeySpec(key, "HmacSHA256")) + doFinal(data) + } +} + +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, + ) + }, + ) + private var validatedBytes: ByteArray? = null + + @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 { + validatedBytes?.let { return it } + + val derivedBytes = deriveBytes() + checkKeyLength(derivedBytes, "Derived Paykit receiver Noise key is invalid") + loadBytes()?.let { storedBytes -> + checkKeyLength(storedBytes, "Stored Paykit receiver Noise key is invalid") + if (!storedBytes.contentEquals(derivedBytes)) { + throw AppError("Stored Paykit receiver Noise key does not match the wallet seed") + } + } ?: upsertBytes(derivedBytes.copyOf()) + + validatedBytes = 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 currentReceivingDetails(scope: ReceivingDetailScope): List = emptyList() + override fun currentPublicReceivingDetails(): List = emptyList() - override fun reserveReceivingDetails( + override fun currentPrivateReceivingDetails( counterparty: String, counterpartyReceiverPath: String, - ): ReceivingDetailReservationResponse = - ReceivingDetailReservationResponse( - kind = ReceivingDetailReservationResponseKind.USE_CURRENT_RECEIVING_DETAILS, + ): List = emptyList() + + override fun reservePrivateReceivingDetails( + counterparty: String, + counterpartyReceiverPath: String, + ): PrivateReceivingDetailReservationResponse = + PrivateReceivingDetailReservationResponse( + kind = PrivateReceivingDetailReservationResponseKind.USE_CURRENT_RECEIVING_DETAILS, reservations = emptyList(), ) - override fun cancelReceivingDetailReservation(cancellation: PaymentEndpointReservationCancellation) = Unit + override fun cancelPrivateReceivingDetailReservation( + cancellation: PrivatePaymentEndpointReservationCancellation, + ) = Unit + + override fun selectPublicPaymentEndpointIds(request: PublicPaymentEndpointSelectionRequest): List { + val parsed = request.candidates.mapNotNull { candidate -> + PublicPaykitRepo.parseEndpoint( + methodId = candidate.identifier, + endpointData = candidate.payload.exportText(), + )?.let { candidate.candidateId to it } + } + return PublicPaykitRepo.payablePreferenceOrder.flatMap { methodId -> + parsed.mapNotNull { (id, endpoint) -> id.takeIf { endpoint.methodId == methodId } } + } + } + + override fun buildPublicPaymentTarget(endpoint: PublicPaymentEndpointCandidate): PaymentTarget = + PaymentTarget(endpoint.payload) - override fun selectPaymentEndpointIds(request: PaymentEndpointSelectionRequest): List { + override fun selectPrivatePaymentEndpointIds(request: PrivatePaymentEndpointSelectionRequest): List { val parsed = request.candidates.mapNotNull { candidate -> PublicPaykitRepo.parseEndpoint( methodId = candidate.identifier, @@ -861,11 +1041,11 @@ class PaykitSdkPaymentAdapter : SdkPaymentAdapter { } } - override fun buildPaymentTarget(endpoint: PaymentEndpointCandidate): PaymentTarget = + override fun buildPrivatePaymentTarget(endpoint: PrivatePaymentEndpointCandidate): PaymentTarget = PaymentTarget(endpoint.payload) } -private fun Endpoint.toReceivingDetail() = ReceivingDetail( +private fun Endpoint.toPublicReceivingDetail() = PublicReceivingDetail( identifier = methodId.rawValue, payload = PaymentPayload(rawPayload), ) diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 20d62407f..24c921c99 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -4,17 +4,14 @@ import android.app.Activity import com.synonym.bitkitcore.LightningInvoice import com.synonym.bitkitcore.NetworkType import com.synonym.bitkitcore.Scanner -import com.synonym.paykit.ContactPaymentResolutionPrivateState import com.synonym.paykit.ContactRecord import com.synonym.paykit.CounterpartyReceiver -import com.synonym.paykit.IdentityStatus import com.synonym.paykit.LinkedPeerRecord import com.synonym.paykit.LinkedPeerState -import com.synonym.paykit.PaymentEndpointSource import com.synonym.paykit.PrivatePaymentListDeliveryReport import com.synonym.paykit.PrivatePaymentListReservationUpdateInput import com.synonym.paykit.PrivatePaymentListSyncChange -import com.synonym.paykit.PubkyIdentityCapability +import com.synonym.paykit.PrivatePaymentResolutionState import com.synonym.paykit.PublicationStatus import kotlinx.coroutines.CancellationException import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -49,6 +46,7 @@ import to.bitkit.data.SettingsStore import to.bitkit.models.NodeLifecycleState import to.bitkit.services.CoreService import to.bitkit.services.PaykitContactPaymentResolution +import to.bitkit.services.PaykitPaymentEndpointSource import to.bitkit.services.PaykitPrivateReceiverPathSelection import to.bitkit.services.PaykitResolvedPaymentEndpoint import to.bitkit.services.PaykitSdkService @@ -115,14 +113,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(lightningRepo.lightningState).thenReturn(lightningState) whenever(clock.now()).thenReturn(Instant.fromEpochSeconds(NOW_SECONDS)) whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) - whenever(paykitSdkService.identityStatus()).thenReturn( - IdentityStatus( - publicKey = OWN_KEY, - capability = PubkyIdentityCapability.PRIVATE_LINK_CAPABLE, - liveSessionAvailable = true, - privateLinkCapable = true, - ), - ) + whenever(paykitSdkService.hasLocalSecretKey()).thenReturn(true) whenever(walletRepo.walletExists()).thenReturn(true) whenever { walletRepo.refreshReusableReceiveAddressIfReserved() }.thenReturn(Result.success(Unit)) whenever { addressReservationRepo.reconcileReservedIndexesWithLdk() }.thenReturn(Result.success(Unit)) @@ -710,14 +701,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `beginSavedContactPayment uses public SDK endpoint when private capability is unavailable`() = test { settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) - whenever(paykitSdkService.identityStatus()).thenReturn( - IdentityStatus( - publicKey = OWN_KEY, - capability = PubkyIdentityCapability.PUBLIC_ONLY, - liveSessionAvailable = true, - privateLinkCapable = false, - ), - ) + whenever(paykitSdkService.hasLocalSecretKey()).thenReturn(false) sut.prepareSavedContacts(listOf(CONTACT_KEY)) whenever { paykitSdkService.prepareAndResolveContactPayment( @@ -730,7 +714,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { resolvedEndpoint( methodId = MethodId.P2wpkh, value = "bcrt1qpublic", - source = PaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, + source = PaykitPaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, ), ), ) @@ -790,7 +774,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { resolvedEndpoint( methodId = MethodId.P2wpkh, value = "bcrt1qpublic", - source = PaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, + source = PaykitPaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, ), ), ) @@ -908,7 +892,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { resolvedEndpoint( methodId = MethodId.P2wpkh, value = "bcrt1qpublic", - source = PaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, + source = PaykitPaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, ), ), ) @@ -934,9 +918,9 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { resolvedEndpoint( methodId = MethodId.P2wpkh, value = "bcrt1qpublic", - source = PaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, + source = PaykitPaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, ), - privateState = ContactPaymentResolutionPrivateState.RECOVERY_PENDING, + privateState = PrivatePaymentResolutionState.RECOVERY_PENDING, ), ) @@ -957,7 +941,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { includePublicEndpoints = true, ) }.thenReturn( - resolution(privateState = ContactPaymentResolutionPrivateState.RECOVERY_PENDING), + resolution(privateState = PrivatePaymentResolutionState.RECOVERY_PENDING), ) val result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() @@ -985,7 +969,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { resolvedEndpoint( methodId = MethodId.P2wpkh, value = "bcrt1qpublic", - source = PaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, + source = PaykitPaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, ), ), ) @@ -1020,7 +1004,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { resolvedEndpoint( methodId = MethodId.P2wpkh, value = "bcrt1qpublic", - source = PaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, + source = PaykitPaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, ), ), ) @@ -1053,7 +1037,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { resolvedEndpoint( methodId = MethodId.P2wpkh, value = "bcrt1qpublic", - source = PaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, + source = PaykitPaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, ), ), ) @@ -1095,7 +1079,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { private fun resolution( vararg endpoints: PaykitResolvedPaymentEndpoint, - privateState: ContactPaymentResolutionPrivateState = ContactPaymentResolutionPrivateState.AVAILABLE, + privateState: PrivatePaymentResolutionState = PrivatePaymentResolutionState.AVAILABLE, ) = PaykitContactPaymentResolution( privateState = privateState, payableEndpoints = endpoints.toList(), @@ -1104,7 +1088,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { private fun resolvedEndpoint( methodId: MethodId, value: String, - source: PaymentEndpointSource = PaymentEndpointSource.PRIVATE_PAYMENT_LIST, + source: PaykitPaymentEndpointSource = PaykitPaymentEndpointSource.PRIVATE_PAYMENT_LIST, ): PaykitResolvedPaymentEndpoint { return PaykitResolvedPaymentEndpoint( counterparty = CONTACT_KEY, diff --git a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt index db262bba9..777b6191f 100644 --- a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt @@ -3,10 +3,9 @@ package to.bitkit.repositories import com.synonym.bitkitcore.LightningInvoice import com.synonym.bitkitcore.NetworkType import com.synonym.bitkitcore.Scanner -import com.synonym.paykit.ContactPaymentResolutionPrivateState import com.synonym.paykit.EndpointSyncChange import com.synonym.paykit.EndpointSyncReport -import com.synonym.paykit.PaymentEndpointSource +import com.synonym.paykit.PrivatePaymentResolutionState import com.synonym.paykit.PublicationStatus import kotlinx.coroutines.flow.MutableStateFlow import org.junit.After @@ -22,6 +21,7 @@ import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.services.CoreService import to.bitkit.services.PaykitContactPaymentResolution +import to.bitkit.services.PaykitPaymentEndpointSource import to.bitkit.services.PaykitReceiverPaths import to.bitkit.services.PaykitResolvedPaymentEndpoint import to.bitkit.services.PaykitSdkService @@ -254,7 +254,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { ) private fun resolution(vararg endpoints: PaykitResolvedPaymentEndpoint) = PaykitContactPaymentResolution( - privateState = ContactPaymentResolutionPrivateState.NO_PRIVATE_ENDPOINT, + privateState = PrivatePaymentResolutionState.NO_PRIVATE_ENDPOINT, payableEndpoints = endpoints.toList(), ) @@ -264,7 +264,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { ): PaykitResolvedPaymentEndpoint { return PaykitResolvedPaymentEndpoint( counterparty = "pubkycontact", - source = PaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, + source = PaykitPaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, identifier = methodId.rawValue, payload = PublicPaykitRepo.serializePayload(value), ) diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index c809c7d7e..299133e9e 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -4,7 +4,13 @@ import com.synonym.paykit.EncryptedLinkRecoveryMarkerPolicy import com.synonym.paykit.EndpointManagementScope import com.synonym.paykit.PublicContactSharingPolicy import org.junit.Test +import to.bitkit.data.keychain.Keychain +import to.bitkit.ext.fromHex +import to.bitkit.ext.toHex +import to.bitkit.utils.AppError +import kotlin.test.assertContentEquals import kotlin.test.assertEquals +import kotlin.test.assertFailsWith class PaykitSdkServiceTest { @Test @@ -13,4 +19,61 @@ class PaykitSdkServiceTest { assertEquals(PublicContactSharingPolicy.LOCAL_ONLY, BitkitPaykitSdkConfig.publicContactSharing) assertEquals(EncryptedLinkRecoveryMarkerPolicy.ENABLED, BitkitPaykitSdkConfig.encryptedLinkRecoveryMarkers) } + + @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 cannot be replaced`() { + val store = keyStore( + loadBytes = { ByteArray(32) { 1 } }, + deriveBytes = { ByteArray(32) { 1 } }, + ) + + assertFailsWith { + store.persistBytes(ByteArray(32) { 2 }) + } + } + + private fun keyStore( + loadBytes: () -> ByteArray?, + upsertBytes: (ByteArray) -> Unit = {}, + deriveBytes: () -> ByteArray, + ) = PaykitReceiverNoiseKeyStore(loadBytes, upsertBytes, deriveBytes) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6bce84028..a3d0ceb58 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,8 +21,8 @@ activity-compose = { module = "androidx.activity:activity-compose", version = "1 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.2" } -paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc33" } +bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.4.4" } +paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc40" } 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" } @@ -64,7 +64,7 @@ ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } -ldk-node-android = { module = "com.synonym:ldk-node-android", version = "0.7.0-rc.52" } +ldk-node-android = { module = "com.synonym:ldk-node-android", version = "0.7.0-rc.58" } lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycle" } lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" } @@ -88,7 +88,7 @@ test-junit-ext = { module = "androidx.test.ext:junit", version = "1.3.0" } test-mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version = "6.2.2" } test-robolectric = { module = "org.robolectric:robolectric", version = "4.16.1" } test-turbine = { group = "app.cash.turbine", name = "turbine", version = "1.2.1" } -vss-client = { module = "com.synonym:vss-client-android", version = "0.5.20" } +vss-client = { module = "com.synonym:vss-client-android", version = "0.5.21" } work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version = "2.11.0" } zxing = { module = "com.google.zxing:core", version = "3.5.4" } lottie = { module = "com.airbnb.android:lottie-compose", version = "6.7.1" } From 64ca85f2cb74837d7833aee95ba53e64bc0d473a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 27 Jul 2026 18:34:26 +0200 Subject: [PATCH 3/9] fix: refresh paykit receiver key --- .../to/bitkit/services/PaykitSdkService.kt | 4 ---- .../to/bitkit/services/PaykitSdkServiceTest.kt | 18 ++++++++++++++++++ 2 files changed, 18 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 8464f8459..c9bfcd1ee 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -946,7 +946,6 @@ internal class PaykitReceiverNoiseKeyStore( ) }, ) - private var validatedBytes: ByteArray? = null @Synchronized fun loadOrDerive(): ReceiverNoiseSecretKey = @@ -969,8 +968,6 @@ internal class PaykitReceiverNoiseKeyStore( } private fun validatedKeyBytes(): ByteArray { - validatedBytes?.let { return it } - val derivedBytes = deriveBytes() checkKeyLength(derivedBytes, "Derived Paykit receiver Noise key is invalid") loadBytes()?.let { storedBytes -> @@ -980,7 +977,6 @@ internal class PaykitReceiverNoiseKeyStore( } } ?: upsertBytes(derivedBytes.copyOf()) - validatedBytes = derivedBytes.copyOf() return derivedBytes } diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index 299133e9e..8a9f097c4 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -71,6 +71,24 @@ class PaykitSdkServiceTest { } } + @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) + } + private fun keyStore( loadBytes: () -> ByteArray?, upsertBytes: (ByteArray) -> Unit = {}, From 41dd6cfe418668c457af7f6a8157d3cf859ba24a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 28 Jul 2026 16:09:26 +0200 Subject: [PATCH 4/9] fix: use core build-id release --- 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 a3d0ceb58..dcdc8b411 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,7 +21,7 @@ activity-compose = { module = "androidx.activity:activity-compose", version = "1 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.4.4" } +bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.5.3" } paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc40" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } From 47fcd48b6aaed3178a93276079bfd9cd0583bbbb Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 28 Jul 2026 16:42:20 +0200 Subject: [PATCH 5/9] fix: consume paykit list versions --- .../to/bitkit/data/PrivatePaykitStores.kt | 2 + .../repositories/PrivatePaykitModels.kt | 8 ++ .../bitkit/repositories/PrivatePaykitRepo.kt | 39 +++++++++- .../to/bitkit/services/PaykitSdkService.kt | 5 +- .../repositories/PrivatePaykitRepoTest.kt | 75 ++++++++++++++++++- 5 files changed, 121 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/to/bitkit/data/PrivatePaykitStores.kt b/app/src/main/java/to/bitkit/data/PrivatePaykitStores.kt index 3ec708739..ed06ddd4e 100644 --- a/app/src/main/java/to/bitkit/data/PrivatePaykitStores.kt +++ b/app/src/main/java/to/bitkit/data/PrivatePaykitStores.kt @@ -68,6 +68,8 @@ data class PrivatePaykitCacheData( @Serializable data class PrivatePaykitContactCacheData( val remoteEndpoints: List = emptyList(), + val remotePaymentListVersionsByReceiverPath: Map = emptyMap(), + val consumedPaymentListVersionsByReceiverPath: Map = emptyMap(), val localInvoicesByReceiverPath: Map = emptyMap(), val receivedInvoicePaymentHashes: List = emptyList(), val publishedPrivatePaymentReceiverPaths: Set = emptySet(), diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitModels.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitModels.kt index 7741558c9..6e87e8baf 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitModels.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitModels.kt @@ -32,12 +32,16 @@ internal data class PrivatePaykitState( internal data class ContactState( var remoteEndpoints: List = emptyList(), + var remotePaymentListVersionsByReceiverPath: Map = emptyMap(), + var consumedPaymentListVersionsByReceiverPath: Map = emptyMap(), var localInvoicesByReceiverPath: Map = emptyMap(), var receivedInvoicePaymentHashes: List = emptyList(), var publishedPrivatePaymentReceiverPaths: Set = emptySet(), ) { constructor(cache: PrivatePaykitContactCacheData) : this( remoteEndpoints = cache.remoteEndpoints.map { StoredPaymentEntry(it.methodId, it.endpointData) }, + remotePaymentListVersionsByReceiverPath = cache.remotePaymentListVersionsByReceiverPath, + consumedPaymentListVersionsByReceiverPath = cache.consumedPaymentListVersionsByReceiverPath, localInvoicesByReceiverPath = cache.localInvoicesByReceiverPath.mapValues { (_, invoice) -> StoredInvoice(invoice.bolt11, invoice.paymentHash, invoice.expiresAt) }, @@ -48,11 +52,15 @@ internal data class ContactState( val hasCacheState: Boolean get() = publishedPrivatePaymentReceiverPaths.isNotEmpty() || remoteEndpoints.isNotEmpty() || + remotePaymentListVersionsByReceiverPath.isNotEmpty() || + consumedPaymentListVersionsByReceiverPath.isNotEmpty() || localInvoicesByReceiverPath.isNotEmpty() || receivedInvoicePaymentHashes.isNotEmpty() fun cacheState() = PrivatePaykitContactCacheData( remoteEndpoints = remoteEndpoints.map { PrivatePaykitStoredPaymentEntryData(it.methodId, it.endpointData) }, + remotePaymentListVersionsByReceiverPath = remotePaymentListVersionsByReceiverPath, + consumedPaymentListVersionsByReceiverPath = consumedPaymentListVersionsByReceiverPath, localInvoicesByReceiverPath = localInvoicesByReceiverPath.mapValues { (_, invoice) -> PrivatePaykitStoredInvoiceData(invoice.bolt11, invoice.paymentHash, invoice.expiresAt) }, diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 412372a01..3b9e94a67 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -316,7 +316,7 @@ class PrivatePaykitRepo @Inject constructor( } if (filteredEntries.size == contactState.remoteEndpoints.size) return@runSuspendCatching - contactState.remoteEndpoints = filteredEntries + contactState.consumeRemotePaymentList(PaykitReceiverPaths.WALLET) persistState(markWalletBackup = true) } } @@ -334,7 +334,7 @@ class PrivatePaykitRepo @Inject constructor( } if (filteredEntries.size == contactState.remoteEndpoints.size) return@runSuspendCatching - contactState.remoteEndpoints = filteredEntries + contactState.consumeRemotePaymentList(PaykitReceiverPaths.WALLET) persistState(markWalletBackup = true) } } @@ -455,12 +455,18 @@ class PrivatePaykitRepo @Inject constructor( counterparty = publicKey, receiverPath = PaykitReceiverPaths.WALLET, includePublicEndpoints = true, + afterPrivatePaymentListVersion = consumedPaymentListVersion(publicKey), ) val privateEndpoints = resolution.payableEndpoints .filter { it.source == PaykitPaymentEndpointSource.PRIVATE_PAYMENT_LIST } .mapNotNull { PublicPaykitRepo.parseEndpoint(it.identifier, it.payload) } - cacheResolvedPrivateEndpoints(publicKey, privateEndpoints) + cacheResolvedPrivateEndpoints( + publicKey = publicKey, + receiverPath = PaykitReceiverPaths.WALLET, + privatePaymentListVersion = resolution.privatePaymentListVersion, + endpoints = privateEndpoints, + ) val privatePayable = privatePayableEndpoints(privateEndpoints, publicKey) if (privatePayable.isNotEmpty()) { @@ -1032,12 +1038,37 @@ class PrivatePaykitRepo @Inject constructor( return "$publicKey:$receiverPath:${endpoint.methodId.rawValue}:$payloadHashPrefix" } - private suspend fun cacheResolvedPrivateEndpoints(publicKey: String, endpoints: List) { + private suspend fun cacheResolvedPrivateEndpoints( + publicKey: String, + receiverPath: String, + privatePaymentListVersion: ULong?, + endpoints: List, + ) { val contactState = ensureState().contacts.getOrPut(publicKey) { ContactState() } contactState.remoteEndpoints = endpoints.map { StoredPaymentEntry(it.methodId.rawValue, it.rawPayload) } + contactState.remotePaymentListVersionsByReceiverPath = if (privatePaymentListVersion == null) { + contactState.remotePaymentListVersionsByReceiverPath - receiverPath + } else { + contactState.remotePaymentListVersionsByReceiverPath + (receiverPath to privatePaymentListVersion) + } persistState(markWalletBackup = true) } + private fun ContactState.consumeRemotePaymentList(receiverPath: String) { + val version = remotePaymentListVersionsByReceiverPath[receiverPath] + remoteEndpoints = emptyList() + remotePaymentListVersionsByReceiverPath = remotePaymentListVersionsByReceiverPath - receiverPath + if (version != null) { + consumedPaymentListVersionsByReceiverPath = + consumedPaymentListVersionsByReceiverPath + (receiverPath to version) + } + } + + private suspend fun consumedPaymentListVersion(publicKey: String) = ensureState() + .contacts[publicKey] + ?.consumedPaymentListVersionsByReceiverPath + ?.get(PaykitReceiverPaths.WALLET) + private suspend fun removePublishedEndpoints(): Result = withContext(serializedDispatcher) { runSuspendCatching { val keys = (knownSavedContactKeys + ensureState().contacts.keys + pendingDeletedContactCleanupPublicKeys()) diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index c9bfcd1ee..cdf970eac 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -79,6 +79,7 @@ import javax.inject.Singleton data class PaykitContactPaymentResolution( val privateState: PrivatePaymentResolutionState, val payableEndpoints: List, + val privatePaymentListVersion: ULong? = null, ) enum class PaykitPaymentEndpointSource { @@ -541,6 +542,7 @@ class PaykitSdkService @Inject constructor( counterparty: String, receiverPath: String, includePublicEndpoints: Boolean, + afterPrivatePaymentListVersion: ULong? = null, ): PaykitContactPaymentResolution { isSetup.await() val (privateResolution, publicResolution) = operationMutex.withLock { @@ -549,7 +551,7 @@ class PaykitSdkService @Inject constructor( counterparty = counterparty, counterpartyReceiverPath = receiverPath, amount = null, - afterPrivatePaymentListVersion = null, + afterPrivatePaymentListVersion = afterPrivatePaymentListVersion, maxAdvanceSteps = 8u, ).resolution val publicResolution = if (includePublicEndpoints) { @@ -587,6 +589,7 @@ class PaykitSdkService @Inject constructor( payload = it.target.payload.exportText(), ) } + publicResolution?.resolvedEndpoints().orEmpty(), + privatePaymentListVersion = privatePaymentListVersion, ) } diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 24c921c99..53fa61337 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -27,6 +27,7 @@ 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 @@ -758,6 +759,66 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } } + @Test + fun `private payment consumes the complete payment list version`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) + sut.prepareSavedContacts(listOf(CONTACT_KEY)) + whenever { + paykitSdkService.prepareAndResolveContactPayment( + CONTACT_KEY, + WALLET_RECEIVER_PATH, + includePublicEndpoints = true, + afterPrivatePaymentListVersion = null, + ) + }.thenReturn( + resolution( + resolvedEndpoint(MethodId.Bolt11, PRIVATE_BOLT11), + resolvedEndpoint(MethodId.P2wpkh, PRIVATE_ADDRESS), + privatePaymentListVersion = 7uL, + ), + ) + whenever(coreService.decode(PRIVATE_BOLT11)) + .thenReturn(Scanner.Lightning(lightningInvoice(PRIVATE_BOLT11, byteArrayOf(9, 9, 9)))) + whenever { coreService.isAddressUsed(PRIVATE_ADDRESS) }.thenReturn(false) + + assertTrue(sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() is PublicPaykitPaymentResult.Opened) + sut.discardRemoteLightningEndpoints(CONTACT_KEY, setOf("090909")).getOrThrow() + + val contactCache = cacheData.value.contacts.getValue(CONTACT_KEY) + assertTrue(contactCache.remoteEndpoints.isEmpty()) + assertEquals(7uL, contactCache.consumedPaymentListVersionsByReceiverPath[WALLET_RECEIVER_PATH]) + + whenever { + paykitSdkService.prepareAndResolveContactPayment( + CONTACT_KEY, + WALLET_RECEIVER_PATH, + includePublicEndpoints = true, + afterPrivatePaymentListVersion = 7uL, + ) + }.thenReturn( + resolution( + resolvedEndpoint( + methodId = MethodId.P2wpkh, + value = "bcrt1qpublic", + source = PaykitPaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, + ), + ), + ) + + assertEquals( + PublicPaykitPaymentResult.Opened("bcrt1qpublic"), + sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow(), + ) + verifyBlocking(paykitSdkService) { + prepareAndResolveContactPayment( + CONTACT_KEY, + WALLET_RECEIVER_PATH, + includePublicEndpoints = true, + afterPrivatePaymentListVersion = 7uL, + ) + } + } + @Test fun `beginSavedContactPayment refreshes private endpoints before unified resolution`() = test { settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) @@ -821,7 +882,9 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.beginSavedContactPayment(CONTACT_KEY) } - verifyBlocking(paykitSdkService, never()) { prepareAndResolveContactPayment(any(), any(), any()) } + verifyBlocking(paykitSdkService, never()) { + prepareAndResolveContactPayment(any(), any(), any(), anyOrNull()) + } verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } } @@ -837,7 +900,9 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.beginSavedContactPayment(CONTACT_KEY) } - verifyBlocking(paykitSdkService, never()) { prepareAndResolveContactPayment(any(), any(), any()) } + verifyBlocking(paykitSdkService, never()) { + prepareAndResolveContactPayment(any(), any(), any(), anyOrNull()) + } verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } } @@ -852,7 +917,9 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.beginSavedContactPayment(CONTACT_KEY) } - verifyBlocking(paykitSdkService, never()) { prepareAndResolveContactPayment(any(), any(), any()) } + verifyBlocking(paykitSdkService, never()) { + prepareAndResolveContactPayment(any(), any(), any(), anyOrNull()) + } verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } } @@ -1080,9 +1147,11 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { private fun resolution( vararg endpoints: PaykitResolvedPaymentEndpoint, privateState: PrivatePaymentResolutionState = PrivatePaymentResolutionState.AVAILABLE, + privatePaymentListVersion: ULong? = null, ) = PaykitContactPaymentResolution( privateState = privateState, payableEndpoints = endpoints.toList(), + privatePaymentListVersion = privatePaymentListVersion, ) private fun resolvedEndpoint( From 80ff398371a39bd5f5bb1186fc9e8f2a051a8f8c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 28 Jul 2026 17:01:25 +0200 Subject: [PATCH 6/9] fix: preserve paykit list consumption --- .../bitkit/repositories/PrivatePaykitRepo.kt | 37 +++++++++++++++++-- .../java/to/bitkit/viewmodels/AppViewModel.kt | 16 +++++--- .../repositories/PrivatePaykitRepoTest.kt | 7 +++- .../viewmodels/AppViewModelSendFlowTest.kt | 32 ++++++++++++++++ 4 files changed, 83 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 3b9e94a67..ee6a41191 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -20,6 +20,9 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString import org.lightningdevkit.ldknode.PaymentDirection import org.lightningdevkit.ldknode.PaymentKind import org.lightningdevkit.ldknode.PaymentStatus @@ -28,6 +31,7 @@ import to.bitkit.async.appScope import to.bitkit.data.PrivatePaykitCacheStore import to.bitkit.data.SettingsStore import to.bitkit.di.IoDispatcher +import to.bitkit.di.json import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.toHex import to.bitkit.models.PubkyPublicKeyFormat @@ -66,6 +70,9 @@ class PrivatePaykitRepo @Inject constructor( companion object { private const val TAG = "PrivatePaykitRepo" private const val MAX_RECEIVED_INVOICE_HASHES_PER_CONTACT = 100 + + /** Identifies Bitkit's wrapper around the Paykit SDK backup state. */ + private const val BACKUP_STATE_PREFIX = "bitkit-paykit-v1:" private val privateInvoiceExpiry = 24.hours private val invoiceRefreshBuffer = 30.minutes @@ -414,7 +421,15 @@ class PrivatePaykitRepo @Inject constructor( withContext(serializedDispatcher) { runSuspendCatching { pubkyService.currentPublicKey() ?: return@runSuspendCatching null - paykitSdkService.exportBackupState() + val backupState = PrivatePaykitBackupState( + sdkState = paykitSdkService.exportBackupState(), + consumedPaymentListVersionsByContact = ensureState().contacts.mapNotNull { (publicKey, contact) -> + contact.consumedPaymentListVersionsByReceiverPath + .takeIf { it.isNotEmpty() } + ?.let { publicKey to it } + }.toMap(), + ) + BACKUP_STATE_PREFIX + json.encodeToString(backupState) } } @@ -422,12 +437,22 @@ class PrivatePaykitRepo @Inject constructor( withContext(serializedDispatcher) { runSuspendCatching { clearPendingMessageDrainRetries() - state = PrivatePaykitState() knownSavedContactKeys.clear() if (backup == null) { + state = PrivatePaykitState() paykitSdkService.clearState() } else { - paykitSdkService.restoreBackupState(backup) + val backupState = backup.takeIf { it.startsWith(BACKUP_STATE_PREFIX) } + ?.removePrefix(BACKUP_STATE_PREFIX) + ?.let { json.decodeFromString(it) } + state = PrivatePaykitState( + contacts = backupState?.consumedPaymentListVersionsByContact.orEmpty() + .mapValues { (_, versions) -> + ContactState(consumedPaymentListVersionsByReceiverPath = versions) + } + .toMutableMap(), + ) + paykitSdkService.restoreBackupState(backupState?.sdkState ?: backup) } persistState(preserveCleanupMarkers = false) notifyBackupStateChanged() @@ -1425,3 +1450,9 @@ class PrivatePaykitRepo @Inject constructor( _backupStateVersion.update { it + 1 } } } + +@Serializable +private data class PrivatePaykitBackupState( + val sdkState: String, + val consumedPaymentListVersionsByContact: Map> = emptyMap(), +) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index abe1a655f..5df9aed6b 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -2369,9 +2369,12 @@ class AppViewModel @Inject constructor( val tags = _sendUiState.value.selectedTags val contactPublicKey = activeContactPaymentPublicKey() - sendOnchain(address, amount, tags = tags) + discardContactOnchainEndpoint(contactPublicKey, address) + .fold( + onSuccess = { sendOnchain(address, amount, tags = tags) }, + onFailure = { Result.failure(it) }, + ) .onSuccess { txId -> - discardContactOnchainEndpoint(contactPublicKey, address) Logger.info("Onchain send result txid: $txId", context = TAG) onSendSuccess( NewTransactionSheetDetails( @@ -3137,9 +3140,12 @@ class AppViewModel @Inject constructor( } } - private suspend fun discardContactOnchainEndpoint(contactPublicKey: String?, address: String) { - if (contactPublicKey == null) return - privatePaykitRepo.discardRemoteOnchainEndpoints(contactPublicKey, setOf(address)).onFailure { + private suspend fun discardContactOnchainEndpoint( + contactPublicKey: String?, + address: String, + ): Result { + if (contactPublicKey == null) return Result.success(Unit) + return privatePaykitRepo.discardRemoteOnchainEndpoints(contactPublicKey, setOf(address)).onFailure { Logger.warn( "Failed to discard private Paykit address for '${PubkyPublicKeyFormat.redacted(contactPublicKey)}'", it, diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 53fa61337..a6b41ec63 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -788,6 +788,11 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(contactCache.remoteEndpoints.isEmpty()) assertEquals(7uL, contactCache.consumedPaymentListVersionsByReceiverPath[WALLET_RECEIVER_PATH]) + whenever(paykitSdkService.exportBackupState()).thenReturn("sdk-backup") + val backup = sut.backupSnapshot().getOrThrow() + sut.restoreBackup(backup).getOrThrow() + sut.prepareSavedContacts(listOf(CONTACT_KEY)) + whenever { paykitSdkService.prepareAndResolveContactPayment( CONTACT_KEY, @@ -1126,7 +1131,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { val snapshot = sut.backupSnapshot().getOrThrow() sut.restoreBackup(snapshot).getOrThrow() - assertEquals(backup, snapshot) + assertTrue(snapshot?.startsWith("bitkit-paykit-v1:") == true) verifyBlocking(paykitSdkService) { restoreBackupState(backup) } } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 53d2352c0..f7a6eb9ab 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -1416,6 +1416,38 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(privatePaykitRepo).discardRemoteOnchainEndpoints(contactKey, setOf(address)) } + @Test + fun `private onchain contact payment stops when list consumption fails`() = test { + val address = "bcrt1qprivatecontact" + val contactKey = "pubkycontact" + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + whenever { privatePaykitRepo.discardRemoteOnchainEndpoints(contactKey, setOf(address)) } + .thenReturn(Result.failure(AppError("backup failed"))) + setActiveContactPaymentContext(contactKey) + setSendState( + SendUiState( + address = address, + amount = 1000u, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Medium, + ), + ) + + confirmCurrentPayment() + + verify(lightningRepo, never()).sendOnChain( + address = any(), + sats = any(), + speed = anyOrNull(), + utxosToSpend = anyOrNull(), + feeRates = anyOrNull(), + isTransfer = any(), + channelId = anyOrNull(), + isMaxAmount = any(), + tags = any(), + ) + } + @Test fun `non-contact onchain payment does not discard private endpoint`() = test { val address = "bcrt1qpublicpayment" From 47dd8c6aefb44c9b66cd7dcc8344156b986cd993 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 28 Jul 2026 17:37:55 +0200 Subject: [PATCH 7/9] fix: gate paykit payment submission --- .../bitkit/repositories/PrivatePaykitRepo.kt | 28 ++++++-- .../java/to/bitkit/viewmodels/AppViewModel.kt | 67 ++++++++++--------- .../repositories/PrivatePaykitRepoTest.kt | 35 ++++++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 32 +++++++-- 4 files changed, 122 insertions(+), 40 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index ee6a41191..697f5d81e 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -323,8 +323,11 @@ class PrivatePaykitRepo @Inject constructor( } if (filteredEntries.size == contactState.remoteEndpoints.size) return@runSuspendCatching - contactState.consumeRemotePaymentList(PaykitReceiverPaths.WALLET) - persistState(markWalletBackup = true) + persistConsumedRemotePaymentList( + publicKey = normalizedKey, + contactState = contactState, + receiverPath = PaykitReceiverPaths.WALLET, + ).getOrThrow() } } @@ -341,8 +344,11 @@ class PrivatePaykitRepo @Inject constructor( } if (filteredEntries.size == contactState.remoteEndpoints.size) return@runSuspendCatching - contactState.consumeRemotePaymentList(PaykitReceiverPaths.WALLET) - persistState(markWalletBackup = true) + persistConsumedRemotePaymentList( + publicKey = normalizedKey, + contactState = contactState, + receiverPath = PaykitReceiverPaths.WALLET, + ).getOrThrow() } } @@ -1089,6 +1095,20 @@ class PrivatePaykitRepo @Inject constructor( } } + private suspend fun persistConsumedRemotePaymentList( + publicKey: String, + contactState: ContactState, + receiverPath: String, + ): Result { + val previousState = contactState.copy() + contactState.consumeRemotePaymentList(receiverPath) + return runSuspendCatching { + persistState(markWalletBackup = true) + }.onFailure { + state?.contacts?.set(publicKey, previousState) + } + } + private suspend fun consumedPaymentListVersion(publicKey: String) = ensureState() .contacts[publicKey] ?.consumedPaymentListVersionsByReceiverPath diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 5df9aed6b..1da911681 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -2426,37 +2426,37 @@ class AppViewModel @Inject constructor( } } - sendLightning(bolt11, paymentAmount).onSuccess { actualPaymentHash -> - discardContactLightningEndpoint(contactPublicKey, actualPaymentHash) - Logger.info("Lightning send result payment hash: $actualPaymentHash", context = TAG) - onSendSuccess( - NewTransactionSheetDetails( - type = NewTransactionSheetType.LIGHTNING, - direction = NewTransactionSheetDirection.SENT, - paymentHashOrTxId = actualPaymentHash, - sats = displayAmountSats.toLong(), // TODO Add fee when available - ), + discardContactLightningEndpoint(contactPublicKey, paymentHash) + .fold( + onSuccess = { sendLightning(bolt11, paymentAmount) }, + onFailure = { Result.failure(it) }, ) - }.onFailure { - if (it is PaymentPendingException) { - discardContactLightningEndpoint(contactPublicKey, it.paymentHash) - Logger.info("Lightning payment pending", context = TAG) - pendingPaymentRepo.track(it.paymentHash) - preserveContactPaymentContext(it.paymentHash) - setSendEffect(SendEffect.NavigateToPending(it.paymentHash, displayAmountSats.toLong())) - return@onFailure - } - if (contactPublicKey != null) { - discardContactLightningEndpoint(contactPublicKey, paymentHash) - } - // Delete pre-activity metadata on failure - if (createdMetadataPaymentId != null) { - preActivityMetadataRepo.deletePreActivityMetadata(createdMetadataPaymentId) + .onSuccess { actualPaymentHash -> + Logger.info("Lightning send result payment hash: $actualPaymentHash", context = TAG) + onSendSuccess( + NewTransactionSheetDetails( + type = NewTransactionSheetType.LIGHTNING, + direction = NewTransactionSheetDirection.SENT, + paymentHashOrTxId = actualPaymentHash, + sats = displayAmountSats.toLong(), // TODO Add fee when available + ), + ) + }.onFailure { + if (it is PaymentPendingException) { + Logger.info("Lightning payment pending", context = TAG) + pendingPaymentRepo.track(it.paymentHash) + preserveContactPaymentContext(it.paymentHash) + setSendEffect(SendEffect.NavigateToPending(it.paymentHash, displayAmountSats.toLong())) + return@onFailure + } + // Delete pre-activity metadata on failure + if (createdMetadataPaymentId != null) { + preActivityMetadataRepo.deletePreActivityMetadata(createdMetadataPaymentId) + } + Logger.error("Error sending lightning payment", it, context = TAG) + toast(it) + hideSheet() } - Logger.error("Error sending lightning payment", it, context = TAG) - toast(it) - hideSheet() - } } } } @@ -3129,9 +3129,12 @@ class AppViewModel @Inject constructor( } } - private suspend fun discardContactLightningEndpoint(contactPublicKey: String?, paymentHash: String) { - if (contactPublicKey == null) return - privatePaykitRepo.discardRemoteLightningEndpoints(contactPublicKey, setOf(paymentHash)).onFailure { + private suspend fun discardContactLightningEndpoint( + contactPublicKey: String?, + paymentHash: String, + ): Result { + if (contactPublicKey == null) return Result.success(Unit) + return privatePaykitRepo.discardRemoteLightningEndpoints(contactPublicKey, setOf(paymentHash)).onFailure { Logger.warn( "Failed to discard private Paykit invoice for '${PubkyPublicKeyFormat.redacted(contactPublicKey)}'", it, diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index a6b41ec63..e2c6ac3b1 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -824,6 +824,41 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { } } + @Test + fun `private payment list remains available when consumption persistence fails`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) + sut.prepareSavedContacts(listOf(CONTACT_KEY)) + whenever { + paykitSdkService.prepareAndResolveContactPayment( + CONTACT_KEY, + WALLET_RECEIVER_PATH, + includePublicEndpoints = true, + afterPrivatePaymentListVersion = null, + ) + }.thenReturn( + resolution( + resolvedEndpoint(MethodId.Bolt11, PRIVATE_BOLT11), + privatePaymentListVersion = 7uL, + ), + ) + whenever(coreService.decode(PRIVATE_BOLT11)) + .thenReturn(Scanner.Lightning(lightningInvoice(PRIVATE_BOLT11, byteArrayOf(9, 9, 9)))) + assertTrue(sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() is PublicPaykitPaymentResult.Opened) + + whenever { cacheStore.update(any()) }.thenAnswer { throw AppError("write failed") } + assertTrue(sut.discardRemoteLightningEndpoints(CONTACT_KEY, setOf("090909")).isFailure) + + whenever { cacheStore.update(any()) }.thenAnswer { + val transform = it.getArgument<(PrivatePaykitCacheData) -> PrivatePaykitCacheData>(0) + cacheData.value = transform(cacheData.value) + } + sut.discardRemoteLightningEndpoints(CONTACT_KEY, setOf("090909")).getOrThrow() + + val contactCache = cacheData.value.contacts.getValue(CONTACT_KEY) + assertTrue(contactCache.remoteEndpoints.isEmpty()) + assertEquals(7uL, contactCache.consumedPaymentListVersionsByReceiverPath[WALLET_RECEIVER_PATH]) + } + @Test fun `beginSavedContactPayment refreshes private endpoints before unified resolution`() = test { settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index f7a6eb9ab..69301b38d 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -1477,7 +1477,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `private lightning contact payment discards remote invoice after send`() = test { + fun `private lightning contact payment consumes remote list before send`() = test { val bolt11 = "lnbcrt1privatecontact" val paymentHash = "payment_hash" val contactKey = "pubkycontact" @@ -1505,11 +1505,35 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) advanceUntilIdle() - verify(privatePaykitRepo).discardRemoteLightningEndpoints(contactKey, setOf(paymentHash)) + verify(privatePaykitRepo).discardRemoteLightningEndpoints(contactKey, setOf("010203")) } @Test - fun `private lightning pending payment discards remote invoice`() = test { + fun `private lightning contact payment stops when list consumption fails`() = test { + val bolt11 = "lnbcrt1privatecontact" + val paymentHash = "010203" + val contactKey = "pubkycontact" + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + whenever { privatePaykitRepo.discardRemoteLightningEndpoints(contactKey, setOf(paymentHash)) } + .thenReturn(Result.failure(AppError("backup failed"))) + setActiveContactPaymentContext(contactKey) + setSendState( + SendUiState( + address = bolt11, + amount = 1000u, + payMethod = SendMethod.LIGHTNING, + decodedInvoice = lightningInvoice(bolt11, amountSats = 1000u), + ), + ) + + sut.setSendEvent(SendEvent.PayConfirmed) + advanceUntilIdle() + + verify(lightningRepo, never()).payInvoice(any(), anyOrNull()) + } + + @Test + fun `private lightning pending payment consumes decoded invoice`() = test { val bolt11 = "lnbcrt1pending" val paymentHash = "pending_hash" val contactKey = "pubkycontact" @@ -1529,7 +1553,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.setSendEvent(SendEvent.PayConfirmed) advanceUntilIdle() - verify(privatePaykitRepo).discardRemoteLightningEndpoints(contactKey, setOf(paymentHash)) + verify(privatePaykitRepo).discardRemoteLightningEndpoints(contactKey, setOf("010203")) } @Test From 540f2bdd2b935eae2f7744fbd5cf3325b3d11066 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 28 Jul 2026 18:10:55 +0200 Subject: [PATCH 8/9] fix: support external paykit sessions --- .../bitkit/repositories/PrivatePaykitRepo.kt | 34 ++++++++++++--- .../to/bitkit/services/PaykitSdkService.kt | 30 +++++++++----- .../screens/profile/PayContactsViewModel.kt | 15 ++++--- .../repositories/PrivatePaykitRepoTest.kt | 41 ++++++++++++++++++- .../bitkit/services/PaykitSdkServiceTest.kt | 37 +++++++++++++++++ .../profile/PayContactsViewModelTest.kt | 20 +++++++-- 6 files changed, 150 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 697f5d81e..fdcfed964 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -118,6 +118,8 @@ class PrivatePaykitRepo @Inject constructor( suspend fun reconcileReservedReceiveIndexes(): Result = addressReservationRepo.reconcileReservedIndexesWithLdk() + suspend fun hasPrivatePaymentAccess(): Boolean = hasPrivatePaymentAccessForCurrentProfile() + suspend fun prepareSavedContacts( publicKeys: Collection, requireImmediatePublication: Boolean = false, @@ -651,7 +653,7 @@ class PrivatePaykitRepo @Inject constructor( } private suspend fun prepareRelevantPrivateLinksIfAvailable(publicKeys: Collection, reason: String) { - if (!hasLocalSecretKeyForCurrentProfile()) return + if (!hasPrivatePaymentAccessForCurrentProfile()) return val retryKeys = mutableListOf() for (publicKey in publicKeys) { @@ -1274,7 +1276,7 @@ class PrivatePaykitRepo @Inject constructor( } if (staleLightningHashes.isNotEmpty()) { - discardRemoteLightningEndpoints(publicKey, staleLightningHashes).onFailure { + pruneRemoteLightningEndpoints(publicKey, staleLightningHashes).onFailure { if (it is CancellationException) throw it Logger.warn( "Failed to discard already-attempted private Paykit invoice for '${redacted(publicKey)}'", @@ -1286,6 +1288,28 @@ class PrivatePaykitRepo @Inject constructor( return reusable } + private suspend fun pruneRemoteLightningEndpoints( + publicKey: String, + paymentHashes: Set, + ): Result = withContext(serializedDispatcher) { + runSuspendCatching { + val normalizedKey = normalizedPublicKey(publicKey) ?: return@runSuspendCatching + val contactState = ensureState().contacts[normalizedKey] ?: return@runSuspendCatching + val filteredEntries = contactState.remoteEndpoints.filterNot { + shouldDiscardRemoteLightningEntry(it, paymentHashes) + } + if (filteredEntries.size == contactState.remoteEndpoints.size) return@runSuspendCatching + + val previousState = contactState.copy() + contactState.remoteEndpoints = filteredEntries + runSuspendCatching { + persistState(markWalletBackup = true) + }.onFailure { + state?.contacts?.set(normalizedKey, previousState) + }.getOrThrow() + } + } + private suspend fun shouldDiscardRemoteLightningEntry( entry: StoredPaymentEntry, paymentHashes: Set, @@ -1308,15 +1332,15 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun canPublishPrivateEndpoints(): Boolean { val settings = settingsStore.data.first() return settings.sharesPrivatePaykitEndpoints && - hasLocalSecretKeyForCurrentProfile() && + hasPrivatePaymentAccessForCurrentProfile() && App.currentActivity?.value != null && walletRepo.walletExists() && lightningRepo.lightningState.value.nodeLifecycleState.isRunning() } - private suspend fun hasLocalSecretKeyForCurrentProfile(): Boolean = runSuspendCatching { + private suspend fun hasPrivatePaymentAccessForCurrentProfile(): Boolean = runSuspendCatching { pubkyService.currentPublicKey() ?: return@runSuspendCatching false - paykitSdkService.hasLocalSecretKey() + paykitSdkService.hasPrivatePaymentAccess() }.getOrDefault(false) private suspend fun isContactSharingCleanupPending(): Boolean = diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index cdf970eac..2721a2718 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -160,10 +160,10 @@ class PaykitSdkService @Inject constructor( } } - suspend fun hasLocalSecretKey(): Boolean { + suspend fun hasPrivatePaymentAccess(): Boolean { isSetup.await() return operationMutex.withLock { - sessionProvider.loadLocalSecretKey() != null + sessionProvider.hasSessionAccess() } } @@ -445,7 +445,7 @@ class PaykitSdkService @Inject constructor( handle.publishPaykitReceiverMarker( PaykitReceiverCapabilities( - privatePayments = sessionProvider.loadLocalSecretKey() != null, + privatePayments = sessionProvider.hasSessionAccess(), paymentRequests = false, receipts = false, outgoingPayments = true, @@ -555,7 +555,9 @@ class PaykitSdkService @Inject constructor( maxAdvanceSteps = 8u, ).resolution val publicResolution = if (includePublicEndpoints) { - handle.resolvePublicContactPayment(counterparty, receiverPath, amount = null) + optionalPublicPaymentResolution { + handle.resolvePublicContactPayment(counterparty, receiverPath, amount = null) + } } else { null } @@ -827,7 +829,7 @@ private class PaykitSdkStateBlobStore( } } -private class PaykitSdkSessionProvider( +internal class PaykitSdkSessionProvider( private val keychain: Keychain, ) : SdkPubkySessionProvider { private val lock = Any() @@ -862,6 +864,9 @@ private class PaykitSdkSessionProvider( override fun publicStorageAvailable(): Boolean = true + fun hasSessionAccess(): Boolean = + keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)?.isNotBlank() == true + override fun clearSessionAccess() { clearLiveSessionAccess() keychain.accessBlocking { @@ -885,6 +890,9 @@ private class PaykitSdkSessionProvider( } } +internal suspend fun optionalPublicPaymentResolution(block: suspend () -> T): T? = + runSuspendCatching { block() }.getOrNull() + internal object PaykitReceiverNoiseKeyDerivation { private const val DOMAIN = "bitkit/paykit/receiver-noise-key" private const val VERSION = "v1" @@ -971,14 +979,14 @@ internal class PaykitReceiverNoiseKeyStore( } 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") - loadBytes()?.let { storedBytes -> - checkKeyLength(storedBytes, "Stored Paykit receiver Noise key is invalid") - if (!storedBytes.contentEquals(derivedBytes)) { - throw AppError("Stored Paykit receiver Noise key does not match the wallet seed") - } - } ?: upsertBytes(derivedBytes.copyOf()) + upsertBytes(derivedBytes.copyOf()) return derivedBytes } diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsViewModel.kt index 5ed92e43c..94582ae9e 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsViewModel.kt @@ -43,10 +43,10 @@ class PayContactsViewModel @Inject constructor( init { viewModelScope.launch { val settings = settingsStore.data.first() - val hasLocalSecretKey = pubkyRepo.hasSecretKey() + val hasPrivatePaymentAccess = privatePaykitRepo.hasPrivatePaymentAccess() _uiState.update { it.copy( - isPaymentSharingEnabled = resolvedSharingDefault(settings, hasLocalSecretKey), + isPaymentSharingEnabled = resolvedSharingDefault(settings, hasPrivatePaymentAccess), ) } } @@ -75,7 +75,10 @@ class PayContactsViewModel @Inject constructor( } .onFailure { val settings = settingsStore.data.first() - val persistedValue = resolvedSharingDefault(settings, pubkyRepo.hasSecretKey()) + val persistedValue = resolvedSharingDefault( + settings, + privatePaykitRepo.hasPrivatePaymentAccess(), + ) ToastEventBus.send( type = Toast.ToastType.ERROR, title = context.getString(R.string.common__error), @@ -99,7 +102,7 @@ class PayContactsViewModel @Inject constructor( return Result.failure(it) } - val canUsePrivateContactPayments = pubkyRepo.hasSecretKey() + val canUsePrivateContactPayments = privatePaykitRepo.hasPrivatePaymentAccess() if (canUsePrivateContactPayments) { privatePaykitRepo.setContactSharingCleanupPending(false) .onFailure { @@ -256,9 +259,9 @@ class PayContactsViewModel @Inject constructor( else -> context.getString(R.string.common__error_body) } - private fun resolvedSharingDefault(settings: SettingsData, hasLocalSecretKey: Boolean): Boolean = + private fun resolvedSharingDefault(settings: SettingsData, hasPrivatePaymentAccess: Boolean): Boolean = settings.sharesPublicPaykitEndpoints || - (settings.sharesPrivatePaykitEndpoints && hasLocalSecretKey) || + (settings.sharesPrivatePaykitEndpoints && hasPrivatePaymentAccess) || !settings.hasConfirmedPublicPaykitEndpoints } diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index e2c6ac3b1..372f75ee4 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -114,7 +114,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(lightningRepo.lightningState).thenReturn(lightningState) whenever(clock.now()).thenReturn(Instant.fromEpochSeconds(NOW_SECONDS)) whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) - whenever(paykitSdkService.hasLocalSecretKey()).thenReturn(true) + whenever(paykitSdkService.hasPrivatePaymentAccess()).thenReturn(true) whenever(walletRepo.walletExists()).thenReturn(true) whenever { walletRepo.refreshReusableReceiveAddressIfReserved() }.thenReturn(Result.success(Unit)) whenever { addressReservationRepo.reconcileReservedIndexesWithLdk() }.thenReturn(Result.success(Unit)) @@ -702,7 +702,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `beginSavedContactPayment uses public SDK endpoint when private capability is unavailable`() = test { settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) - whenever(paykitSdkService.hasLocalSecretKey()).thenReturn(false) + whenever(paykitSdkService.hasPrivatePaymentAccess()).thenReturn(false) sut.prepareSavedContacts(listOf(CONTACT_KEY)) whenever { paykitSdkService.prepareAndResolveContactPayment( @@ -859,6 +859,43 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals(7uL, contactCache.consumedPaymentListVersionsByReceiverPath[WALLET_RECEIVER_PATH]) } + @Test + fun `private payment filtering keeps the unattempted list available`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) + sut.prepareSavedContacts(listOf(CONTACT_KEY)) + whenever { + paykitSdkService.prepareAndResolveContactPayment( + CONTACT_KEY, + WALLET_RECEIVER_PATH, + includePublicEndpoints = true, + afterPrivatePaymentListVersion = null, + ) + }.thenReturn( + resolution( + resolvedEndpoint(MethodId.Bolt11, PRIVATE_BOLT11), + resolvedEndpoint(MethodId.P2wpkh, PRIVATE_ADDRESS), + privatePaymentListVersion = 7uL, + ), + ) + whenever(coreService.decode(PRIVATE_BOLT11)) + .thenReturn(Scanner.Lightning(lightningInvoice(PRIVATE_BOLT11, byteArrayOf(9, 9, 9)))) + whenever { coreService.isAddressUsed(PRIVATE_ADDRESS) }.thenReturn(false) + PublicPaykitRepo.lightningRouteHintsValidator = { false } + + assertEquals( + PublicPaykitPaymentResult.Opened(PRIVATE_ADDRESS), + sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow(), + ) + + val contactCache = cacheData.value.contacts.getValue(CONTACT_KEY) + assertEquals( + listOf(PublicPaykitRepo.serializePayload(PRIVATE_ADDRESS)), + contactCache.remoteEndpoints.map { it.endpointData }, + ) + assertTrue(contactCache.consumedPaymentListVersionsByReceiverPath.isEmpty()) + assertEquals(7uL, contactCache.remotePaymentListVersionsByReceiverPath[WALLET_RECEIVER_PATH]) + } + @Test fun `beginSavedContactPayment refreshes private endpoints before unified resolution`() = test { settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index 8a9f097c4..da34139ec 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -3,7 +3,10 @@ package to.bitkit.services import com.synonym.paykit.EncryptedLinkRecoveryMarkerPolicy import com.synonym.paykit.EndpointManagementScope import com.synonym.paykit.PublicContactSharingPolicy +import kotlinx.coroutines.test.runTest import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever import to.bitkit.data.keychain.Keychain import to.bitkit.ext.fromHex import to.bitkit.ext.toHex @@ -11,6 +14,8 @@ import to.bitkit.utils.AppError import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue class PaykitSdkServiceTest { @Test @@ -59,6 +64,17 @@ class PaykitSdkServiceTest { 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( @@ -89,6 +105,27 @@ class PaykitSdkServiceTest { assertContentEquals(derivedBytes, persistedBytes) } + @Test + fun `external session retains private payment access`() { + val keychain = mock() + val sessionSecret = "external-session" + val provider = PaykitSdkSessionProvider(keychain) + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(sessionSecret) + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(null) + + assertTrue(provider.hasSessionAccess()) + assertNull(provider.loadLocalSecretKey()) + } + + @Test + fun `public payment resolution failure remains optional`() = runTest { + val result = optionalPublicPaymentResolution { + throw AppError("public lookup failed") + } + + assertNull(result) + } + private fun keyStore( loadBytes: () -> ByteArray?, upsertBytes: (ByteArray) -> Unit = {}, diff --git a/app/src/test/java/to/bitkit/ui/screens/profile/PayContactsViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/PayContactsViewModelTest.kt index 0cdd04d14..66cb9e065 100644 --- a/app/src/test/java/to/bitkit/ui/screens/profile/PayContactsViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/profile/PayContactsViewModelTest.kt @@ -50,7 +50,7 @@ class PayContactsViewModelTest : BaseUnitTest() { whenever(context.getString(any())).thenReturn("") whenever(settingsStore.data).thenReturn(settingsFlow) whenever(pubkyRepo.contacts).thenReturn(contactsFlow) - whenever { pubkyRepo.hasSecretKey() }.thenReturn(true) + whenever { privatePaykitRepo.hasPrivatePaymentAccess() }.thenReturn(true) whenever { settingsStore.update(any()) }.thenAnswer { val transform = it.getArgument<(SettingsData) -> SettingsData>(0) settingsFlow.value = transform(settingsFlow.value) @@ -89,8 +89,8 @@ class PayContactsViewModelTest : BaseUnitTest() { } @Test - fun `continueToProfile enables only public sharing without local secret key`() = test { - whenever { pubkyRepo.hasSecretKey() }.thenReturn(false) + fun `continueToProfile enables only public sharing without private payment access`() = test { + whenever { privatePaykitRepo.hasPrivatePaymentAccess() }.thenReturn(false) val sut = createSut() advanceUntilIdle() @@ -111,6 +111,20 @@ class PayContactsViewModelTest : BaseUnitTest() { verify(privatePaykitRepo, never()).prepareSavedContacts(any>(), any()) } + @Test + fun `continueToProfile enables private sharing for external session access`() = test { + whenever { pubkyRepo.hasSecretKey() }.thenReturn(false) + val sut = createSut() + advanceUntilIdle() + + sut.setPaymentSharingEnabled(true) + sut.continueToProfile() + advanceUntilIdle() + + assertTrue(settingsFlow.value.sharesPrivatePaykitEndpoints) + verify(privatePaykitRepo).prepareSavedContacts(listOf(CONTACT_KEY), false) + } + @Test fun `continueToProfile cleans up when initial public publish fails`() = test { whenever { publicPaykitRepo.syncPublishedEndpoints(publish = true) } From e0e32b31ffe3f7c6c13330342c4c1611666dc1ea Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 28 Jul 2026 18:59:51 +0200 Subject: [PATCH 9/9] fix: complete paykit payment handling --- .../bitkit/repositories/PrivatePaykitRepo.kt | 21 +++-- .../to/bitkit/services/PaykitSdkService.kt | 13 +-- .../java/to/bitkit/viewmodels/AppViewModel.kt | 21 ++++- .../repositories/PrivatePaykitRepoTest.kt | 83 +++++++++++++++++++ .../bitkit/services/PaykitSdkServiceTest.kt | 10 --- .../viewmodels/AppViewModelSendFlowTest.kt | 64 +++++++++++++- 6 files changed, 183 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index fdcfed964..7d82fe441 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -314,14 +314,15 @@ class PrivatePaykitRepo @Inject constructor( suspend fun discardRemoteLightningEndpoints( publicKey: String, paymentHashes: Set, + paymentRequests: Set = emptySet(), ): Result = withContext(serializedDispatcher) { runSuspendCatching { - if (paymentHashes.isEmpty()) return@runSuspendCatching + if (paymentHashes.isEmpty() && paymentRequests.isEmpty()) return@runSuspendCatching val normalizedKey = normalizedPublicKey(publicKey) ?: return@runSuspendCatching val contactState = ensureState().contacts[normalizedKey] ?: return@runSuspendCatching val normalizedHashes = paymentHashes.map { it.lowercase() }.toSet() val filteredEntries = contactState.remoteEndpoints.filterNot { - shouldDiscardRemoteLightningEntry(it, normalizedHashes) + shouldDiscardRemoteLightningEntry(it, normalizedHashes, paymentRequests) } if (filteredEntries.size == contactState.remoteEndpoints.size) return@runSuspendCatching @@ -525,6 +526,8 @@ class PrivatePaykitRepo @Inject constructor( ) } + resolution.publicResolutionError?.let { throw it } + if (privateEndpoints.isEmpty() && publicEndpoints.isEmpty()) { PublicPaykitPaymentResult.NoEndpoint } else { @@ -1296,7 +1299,7 @@ class PrivatePaykitRepo @Inject constructor( val normalizedKey = normalizedPublicKey(publicKey) ?: return@runSuspendCatching val contactState = ensureState().contacts[normalizedKey] ?: return@runSuspendCatching val filteredEntries = contactState.remoteEndpoints.filterNot { - shouldDiscardRemoteLightningEntry(it, paymentHashes) + shouldDiscardRemoteLightningEntry(it, paymentHashes, emptySet()) } if (filteredEntries.size == contactState.remoteEndpoints.size) return@runSuspendCatching @@ -1313,11 +1316,17 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun shouldDiscardRemoteLightningEntry( entry: StoredPaymentEntry, paymentHashes: Set, + paymentRequests: Set, ): Boolean { - if (entry.methodId != MethodId.Bolt11.rawValue) return false val endpoint = PublicPaykitRepo.parseEndpoint(entry.methodId, entry.endpointData) ?: return false - val paymentHash = paymentHashForBolt11(endpoint.value)?.lowercase() ?: return false - return paymentHash in paymentHashes + return when (endpoint.methodId) { + MethodId.Bolt11 -> { + val paymentHash = paymentHashForBolt11(endpoint.value)?.lowercase() ?: return false + paymentHash in paymentHashes + } + MethodId.Lnurl -> endpoint.value in paymentRequests + else -> false + } } private fun shouldDiscardRemoteOnchainEntry( diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 2721a2718..cb1eb98fd 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -80,6 +80,7 @@ data class PaykitContactPaymentResolution( val privateState: PrivatePaymentResolutionState, val payableEndpoints: List, val privatePaymentListVersion: ULong? = null, + val publicResolutionError: Throwable? = null, ) enum class PaykitPaymentEndpointSource { @@ -555,7 +556,7 @@ class PaykitSdkService @Inject constructor( maxAdvanceSteps = 8u, ).resolution val publicResolution = if (includePublicEndpoints) { - optionalPublicPaymentResolution { + runSuspendCatching { handle.resolvePublicContactPayment(counterparty, receiverPath, amount = null) } } else { @@ -564,7 +565,10 @@ class PaykitSdkService @Inject constructor( privateResolution to publicResolution } } - return privateResolution.toPaykitContactPaymentResolution(publicResolution) + return privateResolution.toPaykitContactPaymentResolution( + publicResolution = publicResolution?.getOrNull(), + publicResolutionError = publicResolution?.exceptionOrNull(), + ) } suspend fun resolvePublicContactPayment( @@ -580,6 +584,7 @@ class PaykitSdkService @Inject constructor( private fun PrivateContactPaymentResolution.toPaykitContactPaymentResolution( publicResolution: PublicContactPaymentResolution?, + publicResolutionError: Throwable?, ): PaykitContactPaymentResolution { return PaykitContactPaymentResolution( privateState = state, @@ -592,6 +597,7 @@ class PaykitSdkService @Inject constructor( ) } + publicResolution?.resolvedEndpoints().orEmpty(), privatePaymentListVersion = privatePaymentListVersion, + publicResolutionError = publicResolutionError, ) } @@ -890,9 +896,6 @@ internal class PaykitSdkSessionProvider( } } -internal suspend fun optionalPublicPaymentResolution(block: suspend () -> T): T? = - runSuspendCatching { block() }.getOrNull() - internal object PaykitReceiverNoiseKeyDerivation { private const val DOMAIN = "bitkit/paykit/receiver-noise-key" private const val VERSION = "v1" diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 1da911681..b02b43eab 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1676,7 +1676,7 @@ class AppViewModel @Inject constructor( fun openContactPayment(paymentRequest: String, publicKey: String) { synchronized(contactPaymentContextLock) { - activeContactPaymentContext = ContactPaymentContext(publicKey) + activeContactPaymentContext = ContactPaymentContext(publicKey, paymentRequest) } onScanResult(paymentRequest) } @@ -1856,6 +1856,10 @@ class AppViewModel @Inject constructor( activeContactPaymentContext?.publicKey } + private fun activeContactPaymentRequest() = synchronized(contactPaymentContextLock) { + activeContactPaymentContext?.paymentRequest + } + private fun activeContactPaymentProfile(): PubkyProfile? { val publicKey = activeContactPaymentPublicKey() ?: return null return pubkyRepo.contacts.value.firstOrNull { @@ -2409,6 +2413,7 @@ class AppViewModel @Inject constructor( val tags = _sendUiState.value.selectedTags var createdMetadataPaymentId: String? = null val contactPublicKey = activeContactPaymentPublicKey() + val contactPaymentRequest = activeContactPaymentRequest() // Extract payment hash from invoice for pre-activity metadata val paymentHash = decodedInvoice.paymentHash.toHex() @@ -2426,7 +2431,7 @@ class AppViewModel @Inject constructor( } } - discardContactLightningEndpoint(contactPublicKey, paymentHash) + discardContactLightningEndpoint(contactPublicKey, paymentHash, contactPaymentRequest) .fold( onSuccess = { sendLightning(bolt11, paymentAmount) }, onFailure = { Result.failure(it) }, @@ -3132,9 +3137,14 @@ class AppViewModel @Inject constructor( private suspend fun discardContactLightningEndpoint( contactPublicKey: String?, paymentHash: String, + paymentRequest: String?, ): Result { if (contactPublicKey == null) return Result.success(Unit) - return privatePaykitRepo.discardRemoteLightningEndpoints(contactPublicKey, setOf(paymentHash)).onFailure { + return privatePaykitRepo.discardRemoteLightningEndpoints( + publicKey = contactPublicKey, + paymentHashes = setOf(paymentHash), + paymentRequests = setOfNotNull(paymentRequest), + ).onFailure { Logger.warn( "Failed to discard private Paykit invoice for '${PubkyPublicKeyFormat.redacted(contactPublicKey)}'", it, @@ -3435,7 +3445,10 @@ sealed class SendFee(open val value: Long) { enum class SendMethod { ONCHAIN, LIGHTNING } -data class ContactPaymentContext(val publicKey: String) +data class ContactPaymentContext( + val publicKey: String, + val paymentRequest: String? = null, +) private data class PaykitContactSyncState( val publicKey: String?, diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 372f75ee4..f1932eb3b 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -824,6 +824,40 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { } } + @Test + fun `private lnurl payment consumes the complete payment list version`() = test { + val lnurl = "lnurl1private" + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) + sut.prepareSavedContacts(listOf(CONTACT_KEY)) + whenever { + paykitSdkService.prepareAndResolveContactPayment( + CONTACT_KEY, + WALLET_RECEIVER_PATH, + includePublicEndpoints = true, + afterPrivatePaymentListVersion = null, + ) + }.thenReturn( + resolution( + resolvedEndpoint(MethodId.Lnurl, lnurl), + privatePaymentListVersion = 7uL, + ), + ) + + assertEquals( + PublicPaykitPaymentResult.Opened(lnurl), + sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow(), + ) + sut.discardRemoteLightningEndpoints( + publicKey = CONTACT_KEY, + paymentHashes = emptySet(), + paymentRequests = setOf(lnurl), + ).getOrThrow() + + val contactCache = cacheData.value.contacts.getValue(CONTACT_KEY) + assertTrue(contactCache.remoteEndpoints.isEmpty()) + assertEquals(7uL, contactCache.consumedPaymentListVersionsByReceiverPath[WALLET_RECEIVER_PATH]) + } + @Test fun `private payment list remains available when consumption persistence fails`() = test { settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) @@ -1021,6 +1055,53 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { verifyBlocking(publicPaykitRepo) { beginPayment(CONTACT_KEY) } } + @Test + fun `beginSavedContactPayment falls back when public resolution fails without a private endpoint`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) + sut.prepareSavedContacts(listOf(CONTACT_KEY)) + whenever { + paykitSdkService.prepareAndResolveContactPayment( + CONTACT_KEY, + WALLET_RECEIVER_PATH, + includePublicEndpoints = true, + ) + }.thenReturn( + resolution(publicResolutionError = AppError("public lookup failed")), + ) + whenever(publicPaykitRepo.beginPayment(CONTACT_KEY)).thenReturn( + Result.success(PublicPaykitPaymentResult.Opened("public-fallback")), + ) + + val result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() + + assertEquals(PublicPaykitPaymentResult.Opened("public-fallback"), result) + verifyBlocking(publicPaykitRepo) { beginPayment(CONTACT_KEY) } + } + + @Test + fun `beginSavedContactPayment uses a private endpoint when public resolution fails`() = test { + val lnurl = "lnurl1private" + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) + sut.prepareSavedContacts(listOf(CONTACT_KEY)) + whenever { + paykitSdkService.prepareAndResolveContactPayment( + CONTACT_KEY, + WALLET_RECEIVER_PATH, + includePublicEndpoints = true, + ) + }.thenReturn( + resolution( + resolvedEndpoint(MethodId.Lnurl, lnurl), + publicResolutionError = AppError("public lookup failed"), + ), + ) + + val result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() + + assertEquals(PublicPaykitPaymentResult.Opened(lnurl), result) + verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } + } + @Test fun `beginSavedContactPayment uses public endpoint from unified resolution when private has no endpoints`() = test { settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) @@ -1225,10 +1306,12 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { vararg endpoints: PaykitResolvedPaymentEndpoint, privateState: PrivatePaymentResolutionState = PrivatePaymentResolutionState.AVAILABLE, privatePaymentListVersion: ULong? = null, + publicResolutionError: Throwable? = null, ) = PaykitContactPaymentResolution( privateState = privateState, payableEndpoints = endpoints.toList(), privatePaymentListVersion = privatePaymentListVersion, + publicResolutionError = publicResolutionError, ) private fun resolvedEndpoint( diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index da34139ec..20a85c7b8 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -3,7 +3,6 @@ package to.bitkit.services import com.synonym.paykit.EncryptedLinkRecoveryMarkerPolicy import com.synonym.paykit.EndpointManagementScope import com.synonym.paykit.PublicContactSharingPolicy -import kotlinx.coroutines.test.runTest import org.junit.Test import org.mockito.kotlin.mock import org.mockito.kotlin.whenever @@ -117,15 +116,6 @@ class PaykitSdkServiceTest { assertNull(provider.loadLocalSecretKey()) } - @Test - fun `public payment resolution failure remains optional`() = runTest { - val result = optionalPublicPaymentResolution { - throw AppError("public lookup failed") - } - - assertNull(result) - } - private fun keyStore( loadBytes: () -> ByteArray?, upsertBytes: (ByteArray) -> Unit = {}, diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 69301b38d..5248ef48b 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -10,6 +10,7 @@ import android.nfc.NfcAdapter import androidx.core.net.toUri import app.cash.turbine.test import com.synonym.bitkitcore.LightningInvoice +import com.synonym.bitkitcore.LnurlPayData import com.synonym.bitkitcore.NetworkType import com.synonym.bitkitcore.Scanner import kotlinx.collections.immutable.persistentListOf @@ -245,7 +246,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } whenever { privatePaykitRepo.contactPublicKeyForPrivateOnchainAddresses(any>()) } .thenReturn(null) - whenever { privatePaykitRepo.discardRemoteLightningEndpoints(any(), any()) } + whenever { privatePaykitRepo.discardRemoteLightningEndpoints(any(), any(), any()) } .thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.discardRemoteOnchainEndpoints(any(), any()) } .thenReturn(Result.success(Unit)) @@ -1532,6 +1533,47 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(lightningRepo, never()).payInvoice(any(), anyOrNull()) } + @Test + fun `private lnurl contact payment stops when list consumption fails`() = test { + val lnurl = lnurlPayData() + val bolt11 = "lnbcrt1privatecontact" + val contactKey = "pubkycontact" + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + whenever( + lightningRepo.fetchLnurlInvoice( + data = lnurl, + amountMsats = 1_000_000uL, + comment = null, + ), + ).thenReturn(Result.success(lightningInvoice(bolt11, amountSats = 1000u))) + whenever { + privatePaykitRepo.discardRemoteLightningEndpoints( + publicKey = contactKey, + paymentHashes = setOf("010203"), + paymentRequests = setOf(lnurl.uri), + ) + }.thenReturn(Result.failure(AppError("backup failed"))) + setActiveContactPaymentContext(contactKey, lnurl.uri) + setSendState( + SendUiState( + address = lnurl.uri, + amount = 1000u, + payMethod = SendMethod.LIGHTNING, + lnurl = LnurlParams.LnurlPay(lnurl), + ), + ) + + sut.setSendEvent(SendEvent.PayConfirmed) + advanceUntilIdle() + + verify(privatePaykitRepo).discardRemoteLightningEndpoints( + publicKey = contactKey, + paymentHashes = setOf("010203"), + paymentRequests = setOf(lnurl.uri), + ) + verify(lightningRepo, never()).payInvoice(any(), anyOrNull()) + } + @Test fun `private lightning pending payment consumes decoded invoice`() = test { val bolt11 = "lnbcrt1pending" @@ -1606,7 +1648,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) advanceUntilIdle() - verify(privatePaykitRepo, never()).discardRemoteLightningEndpoints(any(), any()) + verify(privatePaykitRepo, never()).discardRemoteLightningEndpoints(any(), any(), any()) } @Test @@ -1822,6 +1864,17 @@ class AppViewModelSendFlowTest : BaseUnitTest() { payeeNodeId = null, ) + private fun lnurlPayData() = LnurlPayData( + uri = "lnurl1private", + callback = "https://example.com/callback", + minSendable = 1_000uL, + maxSendable = 100_000_000uL, + metadataStr = "[[\"text/plain\",\"test\"]]", + commentAllowed = null, + allowsNostr = false, + nostrPubkey = null, + ) + private suspend fun enablePublicPaykitSharing() { whenever { publicPaykitRepo.syncCurrentPublishedEndpoints(any(), any()) }.thenReturn(Result.success(Unit)) walletState.value = WalletState(onchainAddress = "bc1qtest") @@ -1885,10 +1938,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { contexts[paymentHash] = ContactPaymentContext(publicKey) } - private fun setActiveContactPaymentContext(publicKey: String) { + private fun setActiveContactPaymentContext( + publicKey: String, + paymentRequest: String? = null, + ) { val field = AppViewModel::class.java.getDeclaredField("activeContactPaymentContext") field.isAccessible = true - field.set(sut, ContactPaymentContext(publicKey)) + field.set(sut, ContactPaymentContext(publicKey, paymentRequest)) } private fun activeContactPaymentContext(): ContactPaymentContext? {