-
Notifications
You must be signed in to change notification settings - Fork 5
fix: republish pubky identity records #1271
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -78,10 +78,12 @@ 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 | ||
| 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 +100,8 @@ import javax.crypto.Mac | |
| 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, | ||
|
|
@@ -163,6 +167,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 +196,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 +218,7 @@ class PaykitSdkService @Inject constructor( | |
|
|
||
| try { | ||
| PaykitAndroid.initializeOrThrow(context) | ||
| republishIdentityIfNeeded() | ||
| operationMutex.withLock { | ||
| var handle = handle() | ||
| try { | ||
|
|
@@ -230,6 +251,31 @@ class PaykitSdkService @Inject constructor( | |
| } | ||
| } | ||
|
|
||
| suspend fun republishIdentityIfNeeded(publicKey: String? = null, now: Long = nowMillis()) { | ||
| if (!identityRepublishMutex.tryLock()) return | ||
| try { | ||
| 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() | ||
| } | ||
| } | ||
|
|
||
| suspend fun currentPublicKey(): String? { | ||
| isSetup.await() | ||
| return operationMutex.withLock { | ||
|
|
@@ -956,6 +1002,7 @@ class PaykitSdkService @Inject constructor( | |
| val handle = handle() | ||
| handle.initialize() | ||
| publishReceiverMarkerIfLiveSessionAvailable(handle) | ||
| republishIdentityIfNeeded(publicKey = result.publicKey) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Recording a verified negative here, since this is the one line where android and iOS differ structurally and I want the reason written down before someone "aligns" the two. This republish is the last statement of Two things close it, and it's worth knowing both because they're independent:
The conservative version, if you'd rather not lean on the ServiceQueue re-parenting: the window is bounded to 5s instead of unbounded. The property to preserve: if this call ever moves out of For contrast, iOS is safe here for a completely different reason — its
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Confirmed against the full signup path. Activation stays inside |
||
| } | ||
|
|
||
| private suspend fun clearRegisteredIdentityActivationLocked() = withContext(NonCancellable) { | ||
|
|
@@ -1007,10 +1054,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 +1083,15 @@ 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 | ||
|
|
||
| /** Maximum time identity maintenance may delay its caller. */ | ||
| private val IDENTITY_REPUBLISH_TIMEOUT = 5.seconds | ||
|
|
||
| fun localSecretKey(secretKeyHex: String): PubkyLocalSecretKey = | ||
| PubkyLocalSecretKey(secretKeyHex.fromHex()) | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,126 @@ | ||
| 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.awaitCancellation | ||
| import kotlinx.coroutines.cancelAndJoin | ||
| import kotlinx.coroutines.test.currentTime | ||
| 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 | ||
| import kotlin.test.assertTrue | ||
|
|
||
| @OptIn(ExperimentalCoroutinesApi::class) | ||
| class PubkyIdentityRepublishTest { | ||
| private val publicKey = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" | ||
|
|
||
| @Test | ||
| fun `successful publication is throttled and reuses bootstrap`() = runTest { | ||
| val bootstrap = mock<PubkySessionBootstrap>() | ||
| 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<PubkySessionBootstrap>() | ||
| 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<PubkySessionBootstrap>() | ||
| whenever(bootstrap.republishIdentity(any())).thenReturn(true) | ||
| val sdk = mock<PaykitSdk>() | ||
| 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<Boolean>() | ||
| val bootstrap = mock<PubkySessionBootstrap>() | ||
| 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() | ||
| } | ||
|
|
||
| @Test | ||
| fun `timeout and cancellation release publication for retry`() = runTest { | ||
| for (cancel in listOf(false, true)) { | ||
| val bootstrap = mock<PubkySessionBootstrap>() | ||
| 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") | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| Improved Pubky identity discovery by periodically refreshing existing identity records while Bitkit is open. |
Uh oh!
There was an error while loading. Please reload this page.