From d7b091f5b644bc50cfd6273ec52c4ce8e510b415 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 15 Sep 2026 17:23:35 +0300 Subject: [PATCH 1/2] fix: republish pubky identity records --- .../java/to/bitkit/repositories/PubkyRepo.kt | 4 + .../to/bitkit/services/PaykitSdkService.kt | 56 ++++++++++- .../java/to/bitkit/services/PubkyService.kt | 6 ++ .../java/to/bitkit/viewmodels/AppViewModel.kt | 7 +- .../services/PubkyIdentityRepublishTest.kt | 98 +++++++++++++++++++ .../to/bitkit/services/PubkyServiceTest.kt | 10 +- changelog.d/next/1271.fixed.md | 1 + gradle/libs.versions.toml | 2 +- 8 files changed, 175 insertions(+), 9 deletions(-) create mode 100644 app/src/test/java/to/bitkit/services/PubkyIdentityRepublishTest.kt create mode 100644 changelog.d/next/1271.fixed.md diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 2d35508f4..5654b5aa7 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -173,6 +173,10 @@ class PubkyRepo @Inject constructor( initializationReady.await() } + suspend fun republishIdentityIfNeeded(): Result = withContext(ioDispatcher) { + runSuspendCatching { pubkyService.republishIdentityIfNeeded(publicKey.value) } + } + suspend fun initialize() = withContext(ioDispatcher) { runSuspendCatching { ensureServiceInitialized() diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 5b030e4f7..5e648b0e1 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -82,6 +82,7 @@ import org.lightningdevkit.ldknode.Network import to.bitkit.data.keychain.Keychain import to.bitkit.env.Env import to.bitkit.ext.fromHex +import to.bitkit.ext.nowMillis import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.toHex import to.bitkit.models.PubkyAuthRequestError @@ -98,6 +99,7 @@ import javax.crypto.Mac import javax.crypto.spec.SecretKeySpec import javax.inject.Inject import javax.inject.Singleton +import kotlin.time.Duration.Companion.minutes data class PaykitPreparedPrivateContactPayment( val resolution: PaykitPrivateContactPaymentResolution, @@ -163,6 +165,16 @@ class PaykitSdkService @Inject constructor( private val sessionProvider = PaykitSdkSessionProvider(keychain) private val paymentAdapter = PaykitSdkPaymentAdapter() private val pubkyClientConfig by lazy { paykitPubkyClientConfig() } + private var bootstrapFactory = { + PubkySessionBootstrap.withPubkyClientConfig( + clientId = BitkitPaykitSdkConfig.clientId, + pubkyClient = pubkyClientConfig, + ) + } + private val cachedBootstrap by lazy { bootstrapFactory() } + private val identityRepublishMutex = Mutex() + private var republishPublicKey: String? = null + private var nextIdentityRepublishAt = 0L private val handleMutex = Mutex() private val operationMutex = Mutex() private val setupMutex = Mutex() @@ -182,8 +194,14 @@ class PaykitSdkService @Inject constructor( ) } - internal constructor(context: Context, keychain: Keychain, sdkFactory: () -> PaykitSdk) : this(context, keychain) { + internal constructor( + context: Context, + keychain: Keychain, + bootstrapFactory: (() -> PubkySessionBootstrap)? = null, + sdkFactory: () -> PaykitSdk, + ) : this(context, keychain) { this.sdkFactory = sdkFactory + if (bootstrapFactory != null) this.bootstrapFactory = bootstrapFactory isSetup.complete(Unit) } @@ -198,6 +216,7 @@ class PaykitSdkService @Inject constructor( try { PaykitAndroid.initializeOrThrow(context) + republishIdentityIfNeeded() operationMutex.withLock { var handle = handle() try { @@ -230,6 +249,29 @@ class PaykitSdkService @Inject constructor( } } + suspend fun republishIdentityIfNeeded(publicKey: String? = null, now: Long = nowMillis()) { + if (!identityRepublishMutex.tryLock()) return + try { + runSuspendCatching { + if (!isSetup.isCompleted) PaykitAndroid.initializeOrThrow(context) + val identity = (publicKey ?: sessionProvider.loadLocalSecretKey()?.let(::pubkyPublicKeyFromSecret)) + ?.let(PubkyPublicKeyFormat::normalized) ?: return@runSuspendCatching + if (identity == republishPublicKey && now < nextIdentityRepublishAt) return@runSuspendCatching + + republishPublicKey = identity + nextIdentityRepublishAt = now + IDENTITY_REPUBLISH_RETRY_INTERVAL.inWholeMilliseconds + if (bootstrap().republishIdentity(identity)) { + nextIdentityRepublishAt = now + IDENTITY_REPUBLISH_INTERVAL.inWholeMilliseconds + Logger.debug("Republished Pubky identity", context = TAG) + } else { + Logger.debug("Found no Pubky identity record to republish", context = TAG) + } + }.onFailure { Logger.warn("Failed to republish Pubky identity", it, context = TAG) } + } finally { + identityRepublishMutex.unlock() + } + } + suspend fun currentPublicKey(): String? { isSetup.await() return operationMutex.withLock { @@ -956,6 +998,7 @@ class PaykitSdkService @Inject constructor( val handle = handle() handle.initialize() publishReceiverMarkerIfLiveSessionAvailable(handle) + republishIdentityIfNeeded(publicKey = result.publicKey) } private suspend fun clearRegisteredIdentityActivationLocked() = withContext(NonCancellable) { @@ -1007,10 +1050,7 @@ class PaykitSdkService @Inject constructor( sdkFactory().also { sdk = it } } - private fun bootstrap() = PubkySessionBootstrap.withPubkyClientConfig( - clientId = BitkitPaykitSdkConfig.clientId, - pubkyClient = pubkyClientConfig, - ) + private fun bootstrap() = cachedBootstrap private fun approvalBootstrap(authUrl: String, approvedClientId: String): PubkySessionBootstrap { val requestClientId = parsePubkyAuthUrl(authUrl).clientId.orEmpty() @@ -1039,6 +1079,12 @@ class PaykitSdkService @Inject constructor( companion object { private const val TAG = "PaykitSdkService" + /** Minimum delay between successful identity republications. */ + private val IDENTITY_REPUBLISH_INTERVAL = 30.minutes + + /** Minimum delay before retrying missing records or failed publication. */ + private val IDENTITY_REPUBLISH_RETRY_INTERVAL = 1.minutes + fun localSecretKey(secretKeyHex: String): PubkyLocalSecretKey = PubkyLocalSecretKey(secretKeyHex.fromHex()) diff --git a/app/src/main/java/to/bitkit/services/PubkyService.kt b/app/src/main/java/to/bitkit/services/PubkyService.kt index b0cf7582e..b0d468aa5 100644 --- a/app/src/main/java/to/bitkit/services/PubkyService.kt +++ b/app/src/main/java/to/bitkit/services/PubkyService.kt @@ -31,6 +31,9 @@ class PubkyService @Inject constructor( paykitSdkService.initialize() } + suspend fun republishIdentityIfNeeded(publicKey: String? = null) = + paykitSdkService.republishIdentityIfNeeded(publicKey) + // region Session management suspend fun importSession(secret: String): String = ServiceQueue.CORE.background { @@ -144,6 +147,7 @@ class PubkyService @Inject constructor( approvedClientId: String, secretKeyHex: String, ) = ServiceQueue.CORE.background { + paykitSdkService.republishIdentityIfNeeded(publicKeyFromSecret(secretKeyHex)) paykitSdkService.approveAuth(authUrl, expectedCapabilities, approvedClientId, secretKeyHex) } @@ -152,6 +156,7 @@ class PubkyService @Inject constructor( secretKeyHex: String, timeout: Duration = AUTHORIZATION_TIMEOUT, ) = ServiceQueue.CORE.background { + paykitSdkService.republishIdentityIfNeeded(publicKeyFromSecret(secretKeyHex)) withTimeoutOrNull(timeout) { approvePubkyAuth(authUrl, secretKeyHex) } ?: throw PubkyRingAuthTimeoutError() @@ -164,6 +169,7 @@ class PubkyService @Inject constructor( secretKeyHex: String, claim: PubkyAuthCompanionClaim, ) = ServiceQueue.CORE.background { + paykitSdkService.republishIdentityIfNeeded(publicKeyFromSecret(secretKeyHex)) paykitSdkService.approveAuthWithCompanionClaim( authUrl = authUrl, expectedCapabilities = expectedCapabilities, diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index f80607e09..c91206653 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -745,7 +745,10 @@ class AppViewModel @Inject constructor( isOnline .drop(1) .filter { it == ConnectivityState.CONNECTED } - .collect { refreshPrivatePaykitEndpointsIfEnabled("network restored") } + .collect { + if (paykitPaymentRequestPollingJob?.isActive == true) pubkyRepo.republishIdentityIfNeeded() + refreshPrivatePaykitEndpointsIfEnabled("network restored") + } } } @@ -836,6 +839,7 @@ class AppViewModel @Inject constructor( if (paykitPaymentRequestPollingJob?.isActive == true) return paykitPaymentRequestPollingJob = viewModelScope.launch { + if (isOnline.value == ConnectivityState.CONNECTED) pubkyRepo.republishIdentityIfNeeded() var refreshIntervalIndex = 0 var maintenanceIntervalIndex = 0 var maintenanceDelay = PAYKIT_MAINTENANCE_INTERVALS.first() @@ -845,6 +849,7 @@ class AppViewModel @Inject constructor( maintenanceDelay -= refreshInterval val refreshMaintenance = maintenanceDelay <= Duration.ZERO if (refreshMaintenance) { + if (isOnline.value == ConnectivityState.CONNECTED) pubkyRepo.republishIdentityIfNeeded() privatePaykitRepo.refreshKnownSavedContactEndpoints("payment request polling") maintenanceIntervalIndex = (maintenanceIntervalIndex + 1).coerceAtMost(PAYKIT_MAINTENANCE_INTERVALS.lastIndex) diff --git a/app/src/test/java/to/bitkit/services/PubkyIdentityRepublishTest.kt b/app/src/test/java/to/bitkit/services/PubkyIdentityRepublishTest.kt new file mode 100644 index 000000000..4a08329f4 --- /dev/null +++ b/app/src/test/java/to/bitkit/services/PubkyIdentityRepublishTest.kt @@ -0,0 +1,98 @@ +package to.bitkit.services + +import com.synonym.paykit.PaykitSdk +import com.synonym.paykit.PubkySessionBootstrap +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class PubkyIdentityRepublishTest { + private val publicKey = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + + @Test + fun `successful publication is throttled and reuses bootstrap`() = runTest { + val bootstrap = mock() + whenever(bootstrap.republishIdentity(any())).thenReturn(true) + var factories = 0 + val service = PaykitSdkService( + context = mock(), + keychain = mock(), + bootstrapFactory = { + factories++ + bootstrap + }, + sdkFactory = { mock() }, + ) + + service.republishIdentityIfNeeded(publicKey, now = 0) + service.republishIdentityIfNeeded("pubky$publicKey", now = 1_799_000) + service.republishIdentityIfNeeded(publicKey, now = 1_800_000) + + verify(bootstrap, times(2)).republishIdentity("pubky$publicKey") + assertEquals(1, factories) + } + + @Test + fun `missing records and failures retry before success interval`() = runTest { + for (fails in listOf(false, true)) { + val bootstrap = mock() + if (fails) { + whenever(bootstrap.republishIdentity(any())).thenThrow(IllegalStateException("offline")) + } else { + whenever(bootstrap.republishIdentity(any())).thenReturn(false) + } + val service = PaykitSdkService(mock(), mock(), { bootstrap }) { mock() } + + service.republishIdentityIfNeeded(publicKey, now = 0) + service.republishIdentityIfNeeded(publicKey, now = 59_000) + service.republishIdentityIfNeeded(publicKey, now = 60_000) + + verify(bootstrap, times(2)).republishIdentity("pubky$publicKey") + } + } + + @Test + fun `new identity has separate throttle without restoring a session`() = runTest { + val bootstrap = mock() + whenever(bootstrap.republishIdentity(any())).thenReturn(true) + val sdk = mock() + val service = PaykitSdkService(mock(), mock(), { bootstrap }) { sdk } + val otherKey = publicKey.dropLast(1) + "y" + + service.republishIdentityIfNeeded(publicKey, now = 0) + service.republishIdentityIfNeeded(otherKey, now = 0) + + verify(bootstrap).republishIdentity("pubky$publicKey") + verify(bootstrap).republishIdentity("pubky$otherKey") + verify(sdk, never()).initialize() + verify(sdk, never()).identityStatus() + } + + @Test + fun `concurrent triggers do not overlap publication`() = runTest { + val gate = CompletableDeferred() + val bootstrap = mock() + whenever(bootstrap.republishIdentity(any())).doSuspendableAnswer { gate.await() } + val service = PaykitSdkService(mock(), mock(), { bootstrap }) { mock() } + val first = async { service.republishIdentityIfNeeded(publicKey, now = 0) } + runCurrent() + + service.republishIdentityIfNeeded(publicKey, now = 3_600_000) + verify(bootstrap).republishIdentity("pubky$publicKey") + + gate.complete(true) + first.await() + } +} diff --git a/app/src/test/java/to/bitkit/services/PubkyServiceTest.kt b/app/src/test/java/to/bitkit/services/PubkyServiceTest.kt index b7b66bc2d..6aab68e7b 100644 --- a/app/src/test/java/to/bitkit/services/PubkyServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PubkyServiceTest.kt @@ -4,7 +4,10 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import org.junit.Test import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock +import org.mockito.kotlin.spy +import org.mockito.kotlin.whenever import to.bitkit.async.ServiceQueue import to.bitkit.ext.runSuspendCatching import to.bitkit.test.BaseUnitTest @@ -28,7 +31,8 @@ class PubkyServiceTest : BaseUnitTest() { String::class.java, Continuation::class.java, ) - val sut = PubkyService(mock()) + val sut = spy(PubkyService(mock())) + doReturn("pubky-test").whenever(sut).publicKeyFromSecret("secret") var cancelled = false mockStatic(binding).use { native -> native.`when` { approve.invoke(null, "auth", "secret", null) }.thenAnswer { @@ -63,13 +67,15 @@ class PubkyServiceTest : BaseUnitTest() { Continuation::class.java, ) val cancellation = CancellationException("cancelled") + val sut = spy(PubkyService(mock())) + doReturn("pubky-test").whenever(sut).publicKeyFromSecret("secret") mockStatic(binding).use { native -> native.`when` { approve.invoke(null, "auth", "secret", null) }.thenThrow(cancellation) assertEquals( cancellation.javaClass, assertFailsWith { - PubkyService(mock()).approveRingAuth("auth", "secret", 50.milliseconds) + sut.approveRingAuth("auth", "secret", 50.milliseconds) }.javaClass, ) } diff --git a/changelog.d/next/1271.fixed.md b/changelog.d/next/1271.fixed.md new file mode 100644 index 000000000..ae439640a --- /dev/null +++ b/changelog.d/next/1271.fixed.md @@ -0,0 +1 @@ +Improved Pubky identity discovery by periodically refreshing existing identity records while Bitkit is open. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9585ea6ba..6e8719ace 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,7 +22,7 @@ appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" } biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" } bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.5.14" } -paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc54" } +paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc55" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" } From c61ede68b80c0dd67d372292b4502a852bcc4c49 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 15 Sep 2026 17:46:56 +0300 Subject: [PATCH 2/2] fix: bound identity republishing --- .../to/bitkit/services/PaykitSdkService.kt | 37 +++++++++++-------- .../services/PubkyIdentityRepublishTest.kt | 28 ++++++++++++++ 2 files changed, 50 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 5e648b0e1..1a0bf7682 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -78,6 +78,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull import org.lightningdevkit.ldknode.Network import to.bitkit.data.keychain.Keychain import to.bitkit.env.Env @@ -100,6 +101,7 @@ import javax.crypto.spec.SecretKeySpec import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds data class PaykitPreparedPrivateContactPayment( val resolution: PaykitPrivateContactPaymentResolution, @@ -252,21 +254,23 @@ class PaykitSdkService @Inject constructor( suspend fun republishIdentityIfNeeded(publicKey: String? = null, now: Long = nowMillis()) { if (!identityRepublishMutex.tryLock()) return try { - runSuspendCatching { - if (!isSetup.isCompleted) PaykitAndroid.initializeOrThrow(context) - val identity = (publicKey ?: sessionProvider.loadLocalSecretKey()?.let(::pubkyPublicKeyFromSecret)) - ?.let(PubkyPublicKeyFormat::normalized) ?: return@runSuspendCatching - if (identity == republishPublicKey && now < nextIdentityRepublishAt) return@runSuspendCatching - - republishPublicKey = identity - nextIdentityRepublishAt = now + IDENTITY_REPUBLISH_RETRY_INTERVAL.inWholeMilliseconds - if (bootstrap().republishIdentity(identity)) { - nextIdentityRepublishAt = now + IDENTITY_REPUBLISH_INTERVAL.inWholeMilliseconds - Logger.debug("Republished Pubky identity", context = TAG) - } else { - Logger.debug("Found no Pubky identity record to republish", context = TAG) - } - }.onFailure { Logger.warn("Failed to republish Pubky identity", it, context = TAG) } + withTimeoutOrNull(IDENTITY_REPUBLISH_TIMEOUT) { + runSuspendCatching { + if (!isSetup.isCompleted) PaykitAndroid.initializeOrThrow(context) + val identity = (publicKey ?: sessionProvider.loadLocalSecretKey()?.let(::pubkyPublicKeyFromSecret)) + ?.let(PubkyPublicKeyFormat::normalized) ?: return@runSuspendCatching + if (identity == republishPublicKey && now < nextIdentityRepublishAt) return@runSuspendCatching + + republishPublicKey = identity + nextIdentityRepublishAt = now + IDENTITY_REPUBLISH_RETRY_INTERVAL.inWholeMilliseconds + if (bootstrap().republishIdentity(identity)) { + nextIdentityRepublishAt = now + IDENTITY_REPUBLISH_INTERVAL.inWholeMilliseconds + Logger.debug("Republished Pubky identity", context = TAG) + } else { + Logger.debug("Found no Pubky identity record to republish", context = TAG) + } + }.onFailure { Logger.warn("Failed to republish Pubky identity", it, context = TAG) } + } } finally { identityRepublishMutex.unlock() } @@ -1085,6 +1089,9 @@ class PaykitSdkService @Inject constructor( /** Minimum delay before retrying missing records or failed publication. */ private val IDENTITY_REPUBLISH_RETRY_INTERVAL = 1.minutes + /** Maximum time identity maintenance may delay its caller. */ + private val IDENTITY_REPUBLISH_TIMEOUT = 5.seconds + fun localSecretKey(secretKeyHex: String): PubkyLocalSecretKey = PubkyLocalSecretKey(secretKeyHex.fromHex()) diff --git a/app/src/test/java/to/bitkit/services/PubkyIdentityRepublishTest.kt b/app/src/test/java/to/bitkit/services/PubkyIdentityRepublishTest.kt index 4a08329f4..ce20e1b7b 100644 --- a/app/src/test/java/to/bitkit/services/PubkyIdentityRepublishTest.kt +++ b/app/src/test/java/to/bitkit/services/PubkyIdentityRepublishTest.kt @@ -5,6 +5,9 @@ import com.synonym.paykit.PubkySessionBootstrap import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.test.currentTime import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Test @@ -16,6 +19,7 @@ import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import kotlin.test.assertEquals +import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class PubkyIdentityRepublishTest { @@ -95,4 +99,28 @@ class PubkyIdentityRepublishTest { gate.complete(true) first.await() } + + @Test + fun `timeout and cancellation release publication for retry`() = runTest { + for (cancel in listOf(false, true)) { + val bootstrap = mock() + whenever(bootstrap.republishIdentity(any())).doSuspendableAnswer { awaitCancellation() } + val service = PaykitSdkService(mock(), mock(), { bootstrap }) { mock() } + val start = currentTime + val caller = async { service.republishIdentityIfNeeded(publicKey, now = 0) } + + if (cancel) { + runCurrent() + caller.cancelAndJoin() + assertTrue(caller.isCancelled) + } else { + caller.await() + assertEquals(5_000L, currentTime - start) + } + + whenever(bootstrap.republishIdentity(any())).thenReturn(true) + service.republishIdentityIfNeeded(publicKey, now = 60_000) + verify(bootstrap, times(2)).republishIdentity("pubky$publicKey") + } + } }