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/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/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/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 905f0c269..7d82fe441 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 @@ -21,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 @@ -29,10 +31,12 @@ 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 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 @@ -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 @@ -111,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, @@ -305,19 +314,23 @@ 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 - contactState.remoteEndpoints = filteredEntries - persistState(markWalletBackup = true) + persistConsumedRemotePaymentList( + publicKey = normalizedKey, + contactState = contactState, + receiverPath = PaykitReceiverPaths.WALLET, + ).getOrThrow() } } @@ -334,8 +347,11 @@ class PrivatePaykitRepo @Inject constructor( } if (filteredEntries.size == contactState.remoteEndpoints.size) return@runSuspendCatching - contactState.remoteEndpoints = filteredEntries - persistState(markWalletBackup = true) + persistConsumedRemotePaymentList( + publicKey = normalizedKey, + contactState = contactState, + receiverPath = PaykitReceiverPaths.WALLET, + ).getOrThrow() } } @@ -414,7 +430,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 +446,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() @@ -455,12 +489,18 @@ class PrivatePaykitRepo @Inject constructor( counterparty = publicKey, receiverPath = PaykitReceiverPaths.WALLET, includePublicEndpoints = true, + afterPrivatePaymentListVersion = consumedPaymentListVersion(publicKey), ) 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) + cacheResolvedPrivateEndpoints( + publicKey = publicKey, + receiverPath = PaykitReceiverPaths.WALLET, + privatePaymentListVersion = resolution.privatePaymentListVersion, + endpoints = privateEndpoints, + ) val privatePayable = privatePayableEndpoints(privateEndpoints, publicKey) if (privatePayable.isNotEmpty()) { @@ -469,7 +509,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 +517,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()) { @@ -486,6 +526,8 @@ class PrivatePaykitRepo @Inject constructor( ) } + resolution.publicResolutionError?.let { throw it } + if (privateEndpoints.isEmpty() && publicEndpoints.isEmpty()) { PublicPaykitPaymentResult.NoEndpoint } else { @@ -614,7 +656,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) { @@ -994,7 +1036,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 +1057,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, @@ -1032,12 +1074,51 @@ 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 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 + ?.get(PaykitReceiverPaths.WALLET) + private suspend fun removePublishedEndpoints(): Result = withContext(serializedDispatcher) { runSuspendCatching { val keys = (knownSavedContactKeys + ensureState().contacts.keys + pendingDeletedContactCleanupPublicKeys()) @@ -1198,7 +1279,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)}'", @@ -1210,14 +1291,42 @@ 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, emptySet()) + } + 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, + 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( @@ -1232,16 +1341,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 - val status = paykitSdkService.identityStatus() ?: return@runSuspendCatching false - status.privateLinkCapable + paykitSdkService.hasPrivatePaymentAccess() }.getOrDefault(false) private suspend fun isContactSharingCleanupPending(): Boolean = @@ -1395,3 +1503,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/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..cb1eb98fd 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,28 @@ 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, + val privatePaymentListVersion: ULong? = null, + val publicResolutionError: Throwable? = null, ) +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 +161,10 @@ class PaykitSdkService @Inject constructor( } } - suspend fun identityStatus(): IdentityStatus? { + suspend fun hasPrivatePaymentAccess(): Boolean { isSetup.await() return operationMutex.withLock { - handle().identityStatus() + sessionProvider.hasSessionAccess() } } @@ -164,6 +177,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 +200,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 +221,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 +253,7 @@ class PaykitSdkService @Inject constructor( try { request.complete( localSecretKey = null, + receiverNoiseSecretKey = sessionProvider.loadOrDeriveReceiverNoiseSecretKey(), requiredCapabilities = requiredCapabilities(), ).also { activateBootstrapResult( @@ -427,10 +444,9 @@ class PaykitSdkService @Inject constructor( return@withStateRevisionTracking } - val status = handle.identityStatus() handle.publishPaykitReceiverMarker( PaykitReceiverCapabilities( - privatePayments = status?.privateLinkCapable == true, + privatePayments = sessionProvider.hasSessionAccess(), paymentRequests = false, receipts = false, outgoingPayments = true, @@ -444,7 +460,7 @@ class PaykitSdkService @Inject constructor( isSetup.await() return operationMutex.withLock { withStateRevisionTracking { handle -> - handle.syncPublicEndpointsWithReceivingDetails(endpoints.map { it.toReceivingDetail() }) + handle.syncPublicEndpointsWithReceivingDetails(endpoints.map { it.toPublicReceivingDetail() }) } } } @@ -527,20 +543,32 @@ class PaykitSdkService @Inject constructor( counterparty: String, receiverPath: String, includePublicEndpoints: Boolean, + afterPrivatePaymentListVersion: ULong? = null, ): 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 = afterPrivatePaymentListVersion, maxAdvanceSteps = 8u, - ) + ).resolution + val publicResolution = if (includePublicEndpoints) { + runSuspendCatching { + handle.resolvePublicContactPayment(counterparty, receiverPath, amount = null) + } + } else { + null + } + privateResolution to publicResolution } } - return prepared.resolution.toPaykitContactPaymentResolution() + return privateResolution.toPaykitContactPaymentResolution( + publicResolution = publicResolution?.getOrNull(), + publicResolutionError = publicResolution?.exceptionOrNull(), + ) } suspend fun resolvePublicContactPayment( @@ -554,17 +582,37 @@ class PaykitSdkService @Inject constructor( return resolution.toPaykitContactPaymentResolution() } - private fun ContactPaymentResolution.toPaykitContactPaymentResolution(): PaykitContactPaymentResolution { + private fun PrivateContactPaymentResolution.toPaykitContactPaymentResolution( + publicResolution: PublicContactPaymentResolution?, + publicResolutionError: Throwable?, + ): 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(), + privatePaymentListVersion = privatePaymentListVersion, + publicResolutionError = publicResolutionError, + ) + } + + 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 +692,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)) @@ -786,10 +835,11 @@ private class PaykitSdkStateBlobStore( } } -private class PaykitSdkSessionProvider( +internal 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,11 +864,15 @@ private class PaykitSdkSessionProvider( return PubkySessionAccess( sessionSecret = sessionSecret, localSecretKey = loadLocalSecretKey(), + receiverNoiseSecretKey = loadOrDeriveReceiverNoiseSecretKey(), ) } override fun publicStorageAvailable(): Boolean = true + fun hasSessionAccess(): Boolean = + keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)?.isNotBlank() == true + override fun clearSessionAccess() { clearLiveSessionAccess() keychain.accessBlocking { @@ -833,23 +887,159 @@ 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, + ) + }, + ) + + @Synchronized + fun loadOrDerive(): ReceiverNoiseSecretKey = + ReceiverNoiseSecretKey(validatedKeyBytes().copyOf()) + + @Synchronized + fun persist(key: ReceiverNoiseSecretKey) { + persistBytes(key.exportBytes()) + } + + @Synchronized + internal fun loadOrDeriveBytes(): ByteArray = + validatedKeyBytes().copyOf() + + @Synchronized + internal fun persistBytes(bytes: ByteArray) { + if (!validatedKeyBytes().contentEquals(bytes)) { + throw AppError("Paykit receiver Noise key changed unexpectedly") + } + } + + private fun validatedKeyBytes(): ByteArray { + loadBytes()?.let { + checkKeyLength(it, "Stored Paykit receiver Noise key is invalid") + return it + } + + val derivedBytes = deriveBytes() + checkKeyLength(derivedBytes, "Derived Paykit receiver Noise key is invalid") + upsertBytes(derivedBytes.copyOf()) + + return derivedBytes + } + + private fun checkKeyLength(bytes: ByteArray, message: String) { + if (bytes.size != RECEIVER_NOISE_KEY_LENGTH) throw AppError(message) + } + + private companion object { + const val RECEIVER_NOISE_KEY_LENGTH = 32 + } } class PaykitSdkPaymentAdapter : SdkPaymentAdapter { - override fun 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 +1051,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/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/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index abe1a655f..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 { @@ -2369,9 +2373,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( @@ -2406,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() @@ -2423,37 +2431,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, contactPaymentRequest) + .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() - } } } } @@ -3126,9 +3134,17 @@ 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, + paymentRequest: String?, + ): Result { + if (contactPublicKey == null) return Result.success(Unit) + 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, @@ -3137,9 +3153,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, @@ -3426,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/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/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 20d62407f..f1932eb3b 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 @@ -30,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 @@ -49,6 +47,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 +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.identityStatus()).thenReturn( - IdentityStatus( - publicKey = OWN_KEY, - capability = PubkyIdentityCapability.PRIVATE_LINK_CAPABLE, - liveSessionAvailable = true, - privateLinkCapable = 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)) @@ -710,14 +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.identityStatus()).thenReturn( - IdentityStatus( - publicKey = OWN_KEY, - capability = PubkyIdentityCapability.PUBLIC_ONLY, - liveSessionAvailable = true, - privateLinkCapable = false, - ), - ) + whenever(paykitSdkService.hasPrivatePaymentAccess()).thenReturn(false) sut.prepareSavedContacts(listOf(CONTACT_KEY)) whenever { paykitSdkService.prepareAndResolveContactPayment( @@ -730,7 +715,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { resolvedEndpoint( methodId = MethodId.P2wpkh, value = "bcrt1qpublic", - source = PaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, + source = PaykitPaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, ), ), ) @@ -774,6 +759,177 @@ 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.exportBackupState()).thenReturn("sdk-backup") + val backup = sut.backupSnapshot().getOrThrow() + sut.restoreBackup(backup).getOrThrow() + sut.prepareSavedContacts(listOf(CONTACT_KEY)) + + 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 `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) + 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 `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) @@ -790,7 +946,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { resolvedEndpoint( methodId = MethodId.P2wpkh, value = "bcrt1qpublic", - source = PaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, + source = PaykitPaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, ), ), ) @@ -837,7 +993,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()) } } @@ -853,7 +1011,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()) } } @@ -868,7 +1028,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()) } } @@ -893,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) @@ -908,7 +1117,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { resolvedEndpoint( methodId = MethodId.P2wpkh, value = "bcrt1qpublic", - source = PaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, + source = PaykitPaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, ), ), ) @@ -934,9 +1143,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 +1166,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 +1194,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { resolvedEndpoint( methodId = MethodId.P2wpkh, value = "bcrt1qpublic", - source = PaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, + source = PaykitPaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, ), ), ) @@ -1020,7 +1229,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { resolvedEndpoint( methodId = MethodId.P2wpkh, value = "bcrt1qpublic", - source = PaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, + source = PaykitPaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, ), ), ) @@ -1053,7 +1262,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { resolvedEndpoint( methodId = MethodId.P2wpkh, value = "bcrt1qpublic", - source = PaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, + source = PaykitPaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, ), ), ) @@ -1075,7 +1284,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) } } @@ -1095,16 +1304,20 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { private fun resolution( vararg endpoints: PaykitResolvedPaymentEndpoint, - privateState: ContactPaymentResolutionPrivateState = ContactPaymentResolutionPrivateState.AVAILABLE, + privateState: PrivatePaymentResolutionState = PrivatePaymentResolutionState.AVAILABLE, + privatePaymentListVersion: ULong? = null, + publicResolutionError: Throwable? = null, ) = PaykitContactPaymentResolution( privateState = privateState, payableEndpoints = endpoints.toList(), + privatePaymentListVersion = privatePaymentListVersion, + publicResolutionError = publicResolutionError, ) 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..20a85c7b8 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -4,7 +4,17 @@ import com.synonym.paykit.EncryptedLinkRecoveryMarkerPolicy import com.synonym.paykit.EndpointManagementScope import com.synonym.paykit.PublicContactSharingPolicy 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 +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 @@ -13,4 +23,102 @@ 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 loads for external session without wallet seed`() { + val persistedBytes = ByteArray(32) { 7 } + val store = keyStore( + loadBytes = { persistedBytes }, + deriveBytes = { throw AppError("wallet seed unavailable") }, + ) + + assertContentEquals(persistedBytes, store.loadOrDeriveBytes()) + } + + @Test + fun `receiver noise key cannot be replaced`() { + val store = keyStore( + loadBytes = { ByteArray(32) { 1 } }, + deriveBytes = { ByteArray(32) { 1 } }, + ) + + assertFailsWith { + store.persistBytes(ByteArray(32) { 2 }) + } + } + + @Test + fun `receiver noise key follows wallet replacement after keychain wipe`() { + var persistedBytes: ByteArray? = null + var derivedBytes = ByteArray(32) { 1 } + val store = keyStore( + loadBytes = { persistedBytes }, + upsertBytes = { persistedBytes = it.copyOf() }, + deriveBytes = { derivedBytes }, + ) + store.loadOrDeriveBytes() + + persistedBytes = null + derivedBytes = ByteArray(32) { 2 } + + assertContentEquals(derivedBytes, store.loadOrDeriveBytes()) + assertContentEquals(derivedBytes, persistedBytes) + } + + @Test + fun `external session retains private payment access`() { + val keychain = mock() + 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()) + } + + private fun keyStore( + loadBytes: () -> ByteArray?, + upsertBytes: (ByteArray) -> Unit = {}, + deriveBytes: () -> ByteArray, + ) = PaykitReceiverNoiseKeyStore(loadBytes, upsertBytes, deriveBytes) } 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) } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 53d2352c0..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)) @@ -1416,6 +1417,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" @@ -1445,7 +1478,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" @@ -1473,11 +1506,76 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) advanceUntilIdle() - verify(privatePaykitRepo).discardRemoteLightningEndpoints(contactKey, setOf(paymentHash)) + verify(privatePaykitRepo).discardRemoteLightningEndpoints(contactKey, setOf("010203")) + } + + @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 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 discards remote invoice`() = test { + fun `private lightning pending payment consumes decoded invoice`() = test { val bolt11 = "lnbcrt1pending" val paymentHash = "pending_hash" val contactKey = "pubkycontact" @@ -1497,7 +1595,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.setSendEvent(SendEvent.PayConfirmed) advanceUntilIdle() - verify(privatePaykitRepo).discardRemoteLightningEndpoints(contactKey, setOf(paymentHash)) + verify(privatePaykitRepo).discardRemoteLightningEndpoints(contactKey, setOf("010203")) } @Test @@ -1550,7 +1648,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) advanceUntilIdle() - verify(privatePaykitRepo, never()).discardRemoteLightningEndpoints(any(), any()) + verify(privatePaykitRepo, never()).discardRemoteLightningEndpoints(any(), any(), any()) } @Test @@ -1766,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") @@ -1829,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? { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6bce84028..dcdc8b411 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.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" } 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" } 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=""