From 69013e68148ff7fa537f27a141ddcb7479f51f54 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 4 Aug 2026 18:38:08 +0200 Subject: [PATCH 1/7] fix: require private Paykit auth scope --- app/build.gradle.kts | 2 +- .../java/to/bitkit/models/PubkyAuthRequest.kt | 12 +++++++--- .../to/bitkit/models/PubkyAuthRequestTest.kt | 24 +++++++++++++++++++ changelog.d/next/pending.fixed.md | 1 + 4 files changed, 35 insertions(+), 4 deletions(-) create mode 100644 changelog.d/next/pending.fixed.md diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 44595d6f5..82c8e71d1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -76,7 +76,7 @@ val bcp47Locales = listOf( ) val e2eEnv = envFlag("E2E", default = false) val e2eBackendEnv = providers.environmentVariable("E2E_BACKEND").orElse("local") -val e2eHomegateUrlEnv = providers.environmentVariable("E2E_HOMEGATE_URL").orElse("http://127.0.0.1:6288") +val e2eHomegateUrlEnv = providers.environmentVariable("E2E_HOMEGATE_URL").orElse("http://10.0.2.2:6288") val geoEnv = envFlag("GEO", default = true) val paykitUiDisabledEnv = envFlag("PAYKIT_UI_DISABLED", default = false) val trezorBridgeEnv = localProp("TREZOR_BRIDGE").map { it.toBoolean().toString() }.orElse("false") diff --git a/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt b/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt index a212b0b25..bba987e1a 100644 --- a/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt +++ b/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt @@ -15,7 +15,13 @@ enum class PubkyAuthClaim(val wireValue: String) { const val QUERY_PARAMETER = "x-bitkit-claim" /** Capabilities required by the watch-only Paykit Server setup flow. */ - const val WATCH_ONLY_ACCOUNT_CAPABILITIES = "/pub/paykit/v0/bitkit/server/:rw" + const val WATCH_ONLY_ACCOUNT_CAPABILITIES = + "/pub/paykit/v0/bitkit/server/:rw,/pub/paykit/v0/private/bitkit/server/:rw" + + private val watchOnlyAccountCapabilitySet = WATCH_ONLY_ACCOUNT_CAPABILITIES.split(",").toSet() + + fun matchesWatchOnlyAccountCapabilities(capabilities: String) = + capabilities.split(",").toSet() == watchOnlyAccountCapabilitySet fun fromWireValue(value: String) = entries.firstOrNull { it.wireValue == value } } @@ -95,7 +101,7 @@ data class PubkyAuthRequest( capabilities: String, ): Result = when { claimValues.size > 1 -> Result.failure(PubkyAuthRequestError.DuplicateBitkitClaim) - claimValues.isEmpty() && capabilities == PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES -> + claimValues.isEmpty() && PubkyAuthClaim.matchesWatchOnlyAccountCapabilities(capabilities) -> Result.failure(PubkyAuthRequestError.MissingBitkitClaim) claimValues.isEmpty() -> Result.success(null) else -> validateBitkitClaimValue(claimValues.first(), capabilities) @@ -108,7 +114,7 @@ data class PubkyAuthRequest( val claim = PubkyAuthClaim.fromWireValue(claimValue) ?: return Result.failure(PubkyAuthRequestError.UnsupportedBitkitClaim(claimValue)) - return if (capabilities == PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES) { + return if (PubkyAuthClaim.matchesWatchOnlyAccountCapabilities(capabilities)) { Result.success(claim) } else { Result.failure(PubkyAuthRequestError.InvalidBitkitClaimCapabilities) diff --git a/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt b/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt index db0087778..310cefcf8 100644 --- a/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt +++ b/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt @@ -20,6 +20,18 @@ class PubkyAuthRequestTest { assertEquals(PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1, request.bitkitClaim) } + @Test + fun `parse recognizes watch-only account claim with reordered capabilities`() { + val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES.split(",").reversed().joinToString(",") + val request = PubkyAuthRequest.parse( + rawUrl = authUrl(capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue), + relay = "https://httprelay.pubky.app/inbox/", + capabilities = capabilities, + ).getOrThrow() + + assertEquals(PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1, request.bitkitClaim) + } + @Test fun `parse preserves normal auth without Bitkit claim`() { val request = PubkyAuthRequest.parse( @@ -84,6 +96,18 @@ class PubkyAuthRequestTest { assertIs(result.exceptionOrNull()) } + @Test + fun `parse rejects watch-only claim without private capability`() { + val capabilities = "/pub/paykit/v0/bitkit/server/:rw" + val result = PubkyAuthRequest.parse( + rawUrl = authUrl(capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue), + relay = "https://httprelay.pubky.app/inbox/", + capabilities = capabilities, + ) + + assertIs(result.exceptionOrNull()) + } + @Test fun `parseCapabilities parses single permission`() { val permissions = PubkyAuthRequest.parseCapabilities("/pub/bitkit.to/:rw") diff --git a/changelog.d/next/pending.fixed.md b/changelog.d/next/pending.fixed.md new file mode 100644 index 000000000..5e47caa71 --- /dev/null +++ b/changelog.d/next/pending.fixed.md @@ -0,0 +1 @@ +Fixed Paykit Server authorization to require both public and private payment capabilities. From 349a8e156cc20abf7b4a1ccbda3ede146d0ccd62 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 4 Aug 2026 19:11:08 +0200 Subject: [PATCH 2/7] chore: name changelog fragment --- changelog.d/next/{pending.fixed.md => 1131.fixed.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{pending.fixed.md => 1131.fixed.md} (100%) diff --git a/changelog.d/next/pending.fixed.md b/changelog.d/next/1131.fixed.md similarity index 100% rename from changelog.d/next/pending.fixed.md rename to changelog.d/next/1131.fixed.md From c321d701e4c687516c03a07060344ca4f19dd808 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 4 Aug 2026 20:45:53 +0200 Subject: [PATCH 3/7] feat: use Paykit local testnet in E2E builds --- .../to/bitkit/services/PaykitSdkService.kt | 29 ++++++++++++++----- .../bitkit/services/PaykitSdkServiceTest.kt | 17 +++++++++++ gradle/libs.versions.toml | 2 +- 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index add30a715..0019bd9db 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -36,6 +36,7 @@ import com.synonym.paykit.PrivateReceivingDetailReservationResponseKind import com.synonym.paykit.PrivateStreamCounterpartyIntakeReport import com.synonym.paykit.PubkyAuthCompanionClaim import com.synonym.paykit.PubkyAuthRequest +import com.synonym.paykit.PubkyClientEnvironment import com.synonym.paykit.PubkyLocalSecretKey import com.synonym.paykit.PubkyProfile import com.synonym.paykit.PubkySessionAccess @@ -53,6 +54,7 @@ import com.synonym.paykit.SdkStateBlobSnapshot import com.synonym.paykit.SdkStateBlobStore import com.synonym.paykit.decodeSdkStateBlobSnapshot import com.synonym.paykit.defaultConfig +import com.synonym.paykit.defaultPubkyClientConfig import com.synonym.paykit.encodeSdkStateBlobSnapshot import com.synonym.paykit.parsePubkyAuthUrl import com.synonym.paykit.pubkyPublicKeyFromSecret @@ -186,7 +188,7 @@ class PaykitSdkService @Inject constructor( ): PubkySessionBootstrapResult { isSetup.await() val previousPublicKey = operationMutex.withLock { currentSdkStatePublicKeyLocked() } - val result = PubkySessionBootstrap().importSession( + val result = bootstrap().importSession( sessionSecret = secret, localSecretKey = if (includeLocalSecret) sessionProvider.loadLocalSecretKey() else null, receiverNoiseSecretKey = sessionProvider.loadOrDeriveReceiverNoiseSecretKey(), @@ -210,7 +212,7 @@ class PaykitSdkService @Inject constructor( ): PubkySessionBootstrapResult { isSetup.await() val previousPublicKey = operationMutex.withLock { currentSdkStatePublicKeyLocked() } - val result = PubkySessionBootstrap().signUp( + val result = bootstrap().signUp( localSecretKey = localSecretKey(secretKeyHex), receiverNoiseSecretKey = sessionProvider.loadOrDeriveReceiverNoiseSecretKey(), homeserverPublicKey = homeserverPublicKey, @@ -231,7 +233,7 @@ class PaykitSdkService @Inject constructor( suspend fun signIn(secretKeyHex: String): PubkySessionBootstrapResult { isSetup.await() val previousPublicKey = operationMutex.withLock { currentSdkStatePublicKeyLocked() } - val result = PubkySessionBootstrap().signIn( + val result = bootstrap().signIn( localSecretKey = localSecretKey(secretKeyHex), receiverNoiseSecretKey = sessionProvider.loadOrDeriveReceiverNoiseSecretKey(), requiredCapabilities = requiredCapabilities(), @@ -250,7 +252,7 @@ class PaykitSdkService @Inject constructor( suspend fun startAuth(): String { isSetup.await() return operationMutex.withLock { - val request = PubkySessionBootstrap().startSignInAuth(requiredCapabilities()) + val request = bootstrap().startSignInAuth(requiredCapabilities()) activeAuthRequest = request request.authorizationUrl() } @@ -292,7 +294,7 @@ class PaykitSdkService @Inject constructor( suspend fun approveAuth(authUrl: String, expectedCapabilities: String, secretKeyHex: String) { isSetup.await() - PubkySessionBootstrap().approveAuth( + bootstrap().approveAuth( authUrl = authUrl, expectedCapabilities = expectedCapabilities, localSecretKey = localSecretKey(secretKeyHex), @@ -306,7 +308,7 @@ class PaykitSdkService @Inject constructor( claim: PubkyAuthCompanionClaim, ) { isSetup.await() - PubkySessionBootstrap().approveAuthWithCompanionClaim( + bootstrap().approveAuthWithCompanionClaim( authUrl = authUrl, expectedCapabilities = expectedCapabilities, localSecretKey = localSecretKey(secretKeyHex), @@ -786,14 +788,17 @@ class PaykitSdkService @Inject constructor( private suspend fun handle(): PaykitSdk = handleMutex.withLock { sdk?.let { return@withLock it } - PaykitSdk.withPaymentAdapter( + PaykitSdk.withPaymentAdapterAndPubkyClientConfig( stateStore = stateStore, sessionProvider = sessionProvider, paymentAdapter = paymentAdapter, config = paykitSdkConfig(), + pubkyClient = paykitPubkyClientConfig(), ).also { sdk = it } } + private fun bootstrap() = PubkySessionBootstrap.withPubkyClientConfig(paykitPubkyClientConfig()) + private fun resetRuntime() { sdk = null } @@ -845,6 +850,16 @@ internal fun paykitSdkConfig() = defaultConfig(PaykitReceiverPaths.WALLET).copy( publicContactSharing = BitkitPaykitSdkConfig.publicContactSharing, ) +internal fun paykitPubkyClientConfig(isLocalE2eBackend: Boolean = Env.isLocalE2eBackend) = + if (isLocalE2eBackend) { + defaultPubkyClientConfig().copy( + environment = PubkyClientEnvironment.LOCAL_TESTNET, + testnetHost = "10.0.2.2", + ) + } else { + defaultPubkyClientConfig() + } + private class PaykitSdkStateBlobStore( private val keychain: Keychain, ) : SdkStateBlobStore { diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index 20a85c7b8..14909bb98 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -2,6 +2,7 @@ package to.bitkit.services import com.synonym.paykit.EncryptedLinkRecoveryMarkerPolicy import com.synonym.paykit.EndpointManagementScope +import com.synonym.paykit.PubkyClientEnvironment import com.synonym.paykit.PublicContactSharingPolicy import org.junit.Test import org.mockito.kotlin.mock @@ -24,6 +25,22 @@ class PaykitSdkServiceTest { assertEquals(EncryptedLinkRecoveryMarkerPolicy.ENABLED, BitkitPaykitSdkConfig.encryptedLinkRecoveryMarkers) } + @Test + fun `production uses default Pubky client`() { + val config = paykitPubkyClientConfig(isLocalE2eBackend = false) + + assertEquals(PubkyClientEnvironment.PRODUCTION, config.environment) + assertNull(config.testnetHost) + } + + @Test + fun `local E2E uses emulator host for Pubky testnet`() { + val config = paykitPubkyClientConfig(isLocalE2eBackend = true) + + assertEquals(PubkyClientEnvironment.LOCAL_TESTNET, config.environment) + assertEquals("10.0.2.2", config.testnetHost) + } + @Test fun `receiver noise derivation matches cross platform vector`() { val seed = ( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 72f67fc66..8bc0293a0 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.5" } -paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc39" } +paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc41" } 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 6f70858ca1d6bf15a660c40a11f716198e7538bf Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 4 Aug 2026 20:59:17 +0200 Subject: [PATCH 4/7] test: avoid native Paykit calls in JVM tests --- .../to/bitkit/services/PaykitSdkService.kt | 10 +++++--- .../bitkit/services/PaykitSdkServiceTest.kt | 23 +++++++++++++++---- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 0019bd9db..37c49b40e 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -36,6 +36,7 @@ import com.synonym.paykit.PrivateReceivingDetailReservationResponseKind import com.synonym.paykit.PrivateStreamCounterpartyIntakeReport import com.synonym.paykit.PubkyAuthCompanionClaim import com.synonym.paykit.PubkyAuthRequest +import com.synonym.paykit.PubkyClientConfig import com.synonym.paykit.PubkyClientEnvironment import com.synonym.paykit.PubkyLocalSecretKey import com.synonym.paykit.PubkyProfile @@ -850,14 +851,17 @@ internal fun paykitSdkConfig() = defaultConfig(PaykitReceiverPaths.WALLET).copy( publicContactSharing = BitkitPaykitSdkConfig.publicContactSharing, ) -internal fun paykitPubkyClientConfig(isLocalE2eBackend: Boolean = Env.isLocalE2eBackend) = +internal fun paykitPubkyClientConfig( + isLocalE2eBackend: Boolean = Env.isLocalE2eBackend, + baseConfig: PubkyClientConfig = defaultPubkyClientConfig(), +) = if (isLocalE2eBackend) { - defaultPubkyClientConfig().copy( + baseConfig.copy( environment = PubkyClientEnvironment.LOCAL_TESTNET, testnetHost = "10.0.2.2", ) } else { - defaultPubkyClientConfig() + baseConfig } private class PaykitSdkStateBlobStore( diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index 14909bb98..8d6a4af78 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -2,6 +2,7 @@ package to.bitkit.services import com.synonym.paykit.EncryptedLinkRecoveryMarkerPolicy import com.synonym.paykit.EndpointManagementScope +import com.synonym.paykit.PubkyClientConfig import com.synonym.paykit.PubkyClientEnvironment import com.synonym.paykit.PublicContactSharingPolicy import org.junit.Test @@ -18,6 +19,12 @@ import kotlin.test.assertNull import kotlin.test.assertTrue class PaykitSdkServiceTest { + private val basePubkyClientConfig = PubkyClientConfig( + requestTimeoutSecs = 30uL, + environment = PubkyClientEnvironment.PRODUCTION, + testnetHost = null, + ) + @Test fun `config scopes public endpoint sync to Bitkit managed endpoints`() { assertEquals(EndpointManagementScope.MANAGED_ONLY, BitkitPaykitSdkConfig.endpointManagementScope) @@ -26,19 +33,25 @@ class PaykitSdkServiceTest { } @Test - fun `production uses default Pubky client`() { - val config = paykitPubkyClientConfig(isLocalE2eBackend = false) + fun `production preserves Pubky client config`() { + val config = paykitPubkyClientConfig( + isLocalE2eBackend = false, + baseConfig = basePubkyClientConfig, + ) - assertEquals(PubkyClientEnvironment.PRODUCTION, config.environment) - assertNull(config.testnetHost) + assertEquals(basePubkyClientConfig, config) } @Test fun `local E2E uses emulator host for Pubky testnet`() { - val config = paykitPubkyClientConfig(isLocalE2eBackend = true) + val config = paykitPubkyClientConfig( + isLocalE2eBackend = true, + baseConfig = basePubkyClientConfig, + ) assertEquals(PubkyClientEnvironment.LOCAL_TESTNET, config.environment) assertEquals("10.0.2.2", config.testnetHost) + assertEquals(basePubkyClientConfig.requestTimeoutSecs, config.requestTimeoutSecs) } @Test From a45a301bbaf8224da6c649090b4473672e65458b Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 5 Aug 2026 10:29:39 -0500 Subject: [PATCH 5/7] chore: update paykit to rc42 --- app/src/main/java/to/bitkit/services/PaykitSdkService.kt | 4 +--- .../test/java/to/bitkit/services/PaykitSdkServiceTest.kt | 7 ++----- gradle/libs.versions.toml | 2 +- 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 37c49b40e..b14e1e8ef 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -37,7 +37,6 @@ import com.synonym.paykit.PrivateStreamCounterpartyIntakeReport import com.synonym.paykit.PubkyAuthCompanionClaim import com.synonym.paykit.PubkyAuthRequest import com.synonym.paykit.PubkyClientConfig -import com.synonym.paykit.PubkyClientEnvironment import com.synonym.paykit.PubkyLocalSecretKey import com.synonym.paykit.PubkyProfile import com.synonym.paykit.PubkySessionAccess @@ -857,8 +856,7 @@ internal fun paykitPubkyClientConfig( ) = if (isLocalE2eBackend) { baseConfig.copy( - environment = PubkyClientEnvironment.LOCAL_TESTNET, - testnetHost = "10.0.2.2", + localTestnetHost = "10.0.2.2", ) } else { baseConfig diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index 8d6a4af78..5069fbc84 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -3,7 +3,6 @@ package to.bitkit.services import com.synonym.paykit.EncryptedLinkRecoveryMarkerPolicy import com.synonym.paykit.EndpointManagementScope import com.synonym.paykit.PubkyClientConfig -import com.synonym.paykit.PubkyClientEnvironment import com.synonym.paykit.PublicContactSharingPolicy import org.junit.Test import org.mockito.kotlin.mock @@ -21,8 +20,7 @@ import kotlin.test.assertTrue class PaykitSdkServiceTest { private val basePubkyClientConfig = PubkyClientConfig( requestTimeoutSecs = 30uL, - environment = PubkyClientEnvironment.PRODUCTION, - testnetHost = null, + localTestnetHost = null, ) @Test @@ -49,8 +47,7 @@ class PaykitSdkServiceTest { baseConfig = basePubkyClientConfig, ) - assertEquals(PubkyClientEnvironment.LOCAL_TESTNET, config.environment) - assertEquals("10.0.2.2", config.testnetHost) + assertEquals("10.0.2.2", config.localTestnetHost) assertEquals(basePubkyClientConfig.requestTimeoutSecs, config.requestTimeoutSecs) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8bc0293a0..24652573b 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.5" } -paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc41" } +paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc42" } 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 e79199f72d78920c983a7452b5054f8159887b3b Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 5 Aug 2026 17:18:24 -0500 Subject: [PATCH 6/7] fix: address local Paykit review --- .env.example | 2 ++ Justfile | 2 -- README.md | 3 +++ app/build.gradle.kts | 5 +++- app/src/main/java/to/bitkit/env/Env.kt | 3 ++- .../java/to/bitkit/models/PubkyAuthRequest.kt | 13 ++++++++-- .../to/bitkit/services/PaykitSdkService.kt | 8 ++++--- .../bitkit/build/BuildOutputContractTest.kt | 1 + .../to/bitkit/models/PubkyAuthRequestTest.kt | 24 +++++++++++++++++++ .../bitkit/services/PaykitSdkServiceTest.kt | 5 ++-- docs/watch-only-account-claim-v1.md | 2 +- gradle/libs.versions.toml | 2 +- 12 files changed, 57 insertions(+), 13 deletions(-) diff --git a/.env.example b/.env.example index 5af4ffb5d..78d23fa49 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,8 @@ # KEY_PASSWORD= # E2E_BACKEND=local +# Use the development machine's LAN address when running on a physical device. +# E2E_LOCAL_HOST=192.168.1.10 # E2E_HOMEGATE_URL=http://127.0.0.1:6288 # TREZOR_BRIDGE=true diff --git a/Justfile b/Justfile index 4df82236c..1131f77c1 100644 --- a/Justfile +++ b/Justfile @@ -114,8 +114,6 @@ run mode="" logs="": build_env="" if [ "$mode" = "docker" ]; then echo "Forwarding bitkit-docker ports via adb reverse..." - adb -s "$device_id" reverse tcp:60001 tcp:60001 # local Electrum - adb -s "$device_id" reverse tcp:6288 tcp:6288 # local homegate adb -s "$device_id" reverse tcp:9735 tcp:9735 # local lnd peer adb -s "$device_id" reverse tcp:3000 tcp:3000 # local lnurl-server build_env="E2E=true" diff --git a/README.md b/README.md index 3c8ea83ba..d89a536e3 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,9 @@ Pass `E2E=true` and build any flavor. By default, E2E uses a local Electrum over just e2e ``` +Local E2E builds use `10.0.2.2` for Electrum, Homegate, and Pubky when running in an emulator. Set `E2E_LOCAL_HOST` to the development +machine's LAN address when running on a physical device. + #### Use Network Electrum (Staging/Mainnet) Set `E2E_BACKEND=network` to use the network Electrum based on the build flavor: diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 82c8e71d1..11a69278c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -76,7 +76,9 @@ val bcp47Locales = listOf( ) val e2eEnv = envFlag("E2E", default = false) val e2eBackendEnv = providers.environmentVariable("E2E_BACKEND").orElse("local") -val e2eHomegateUrlEnv = providers.environmentVariable("E2E_HOMEGATE_URL").orElse("http://10.0.2.2:6288") +val e2eLocalHostEnv = providers.environmentVariable("E2E_LOCAL_HOST").orElse("10.0.2.2") +val e2eHomegateUrlEnv = providers.environmentVariable("E2E_HOMEGATE_URL") + .orElse(e2eLocalHostEnv.map { "http://$it:6288" }) val geoEnv = envFlag("GEO", default = true) val paykitUiDisabledEnv = envFlag("PAYKIT_UI_DISABLED", default = false) val trezorBridgeEnv = localProp("TREZOR_BRIDGE").map { it.toBoolean().toString() }.orElse("false") @@ -319,6 +321,7 @@ androidComponents { } buildConfigFields.put("E2E", e2eEnv.booleanField()) buildConfigFields.put("E2E_BACKEND", e2eBackendEnv.stringField()) + buildConfigFields.put("E2E_LOCAL_HOST", e2eLocalHostEnv.stringField()) buildConfigFields.put("E2E_HOMEGATE_URL", e2eHomegateUrlEnv.stringField()) buildConfigFields.put("TREZOR_BRIDGE", trezorBridgeEnv.booleanField()) buildConfigFields.put("TREZOR_BRIDGE_URL", trezorBridgeUrlEnv.stringField()) diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index ba86bb38a..e857ef58f 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -22,6 +22,7 @@ internal object Env { const val isGeoblockingEnabled = BuildConfig.GEO val e2eBackend = BuildConfig.E2E_BACKEND.lowercase() val isLocalE2eBackend = isE2eTest && e2eBackend == "local" + const val e2eLocalHost = BuildConfig.E2E_LOCAL_HOST const val e2eHomegateUrl = BuildConfig.E2E_HOMEGATE_URL val network = Network.valueOf(BuildConfig.NETWORK) val locales = BuildConfig.LOCALES.split(",") @@ -285,7 +286,7 @@ private object ElectrumServers { object REGTEST { const val STAG = "ssl://electrs.bitkit.stag0.blocktank.to:9999" - const val LOCAL = "tcp://127.0.0.1:60001" + val LOCAL get() = "tcp://${Env.e2eLocalHost}:60001" } const val TESTNET = "ssl://electrum.blockstream.info:60002" diff --git a/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt b/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt index bba987e1a..4c42ed7d3 100644 --- a/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt +++ b/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt @@ -14,14 +14,23 @@ enum class PubkyAuthClaim(val wireValue: String) { /** Query parameter used for Bitkit-specific Pubky auth claims. */ const val QUERY_PARAMETER = "x-bitkit-claim" - /** Capabilities required by the watch-only Paykit Server setup flow. */ + /** Both public and private Paykit Server capabilities required by the watch-only setup flow. */ const val WATCH_ONLY_ACCOUNT_CAPABILITIES = "/pub/paykit/v0/bitkit/server/:rw,/pub/paykit/v0/private/bitkit/server/:rw" private val watchOnlyAccountCapabilitySet = WATCH_ONLY_ACCOUNT_CAPABILITIES.split(",").toSet() + /** + * Matches exactly the required public and private capabilities regardless of ordering or surrounding spaces. + */ fun matchesWatchOnlyAccountCapabilities(capabilities: String) = - capabilities.split(",").toSet() == watchOnlyAccountCapabilitySet + capabilitySet(capabilities) == watchOnlyAccountCapabilitySet + + private fun capabilitySet(capabilities: String): Set? { + val entries = capabilities.split(",").map { it.trim() } + if (entries.any { it.isEmpty() }) return null + return entries.toSet() + } fun fromWireValue(value: String) = entries.firstOrNull { it.wireValue == value } } diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index b14e1e8ef..818eb5c9d 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -131,6 +131,7 @@ class PaykitSdkService @Inject constructor( private val stateStore = PaykitSdkStateBlobStore(keychain) private val sessionProvider = PaykitSdkSessionProvider(keychain) private val paymentAdapter = PaykitSdkPaymentAdapter() + private val pubkyClientConfig by lazy { paykitPubkyClientConfig() } private val handleMutex = Mutex() private val operationMutex = Mutex() private val setupMutex = Mutex() @@ -793,11 +794,11 @@ class PaykitSdkService @Inject constructor( sessionProvider = sessionProvider, paymentAdapter = paymentAdapter, config = paykitSdkConfig(), - pubkyClient = paykitPubkyClientConfig(), + pubkyClient = pubkyClientConfig, ).also { sdk = it } } - private fun bootstrap() = PubkySessionBootstrap.withPubkyClientConfig(paykitPubkyClientConfig()) + private fun bootstrap() = PubkySessionBootstrap.withPubkyClientConfig(pubkyClientConfig) private fun resetRuntime() { sdk = null @@ -852,11 +853,12 @@ internal fun paykitSdkConfig() = defaultConfig(PaykitReceiverPaths.WALLET).copy( internal fun paykitPubkyClientConfig( isLocalE2eBackend: Boolean = Env.isLocalE2eBackend, + localTestnetHost: String = Env.e2eLocalHost, baseConfig: PubkyClientConfig = defaultPubkyClientConfig(), ) = if (isLocalE2eBackend) { baseConfig.copy( - localTestnetHost = "10.0.2.2", + localTestnetHost = localTestnetHost, ) } else { baseConfig diff --git a/app/src/test/java/to/bitkit/build/BuildOutputContractTest.kt b/app/src/test/java/to/bitkit/build/BuildOutputContractTest.kt index a443203ae..bca0b81dc 100644 --- a/app/src/test/java/to/bitkit/build/BuildOutputContractTest.kt +++ b/app/src/test/java/to/bitkit/build/BuildOutputContractTest.kt @@ -79,6 +79,7 @@ class BuildOutputContractTest { val fields = listOf( "E2E", "E2E_BACKEND", + "E2E_LOCAL_HOST", "E2E_HOMEGATE_URL", "TREZOR_BRIDGE", "TREZOR_BRIDGE_URL", diff --git a/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt b/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt index 310cefcf8..a04b5689f 100644 --- a/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt +++ b/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt @@ -32,6 +32,18 @@ class PubkyAuthRequestTest { assertEquals(PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1, request.bitkitClaim) } + @Test + fun `parse recognizes watch-only account claim with capability whitespace`() { + val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES.replace(",", " , ") + val request = PubkyAuthRequest.parse( + rawUrl = authUrl(capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue), + relay = "https://httprelay.pubky.app/inbox/", + capabilities = capabilities, + ).getOrThrow() + + assertEquals(PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1, request.bitkitClaim) + } + @Test fun `parse preserves normal auth without Bitkit claim`() { val request = PubkyAuthRequest.parse( @@ -108,6 +120,18 @@ class PubkyAuthRequestTest { assertIs(result.exceptionOrNull()) } + @Test + fun `parse rejects watch-only claim with empty capability`() { + val capabilities = "${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}," + val result = PubkyAuthRequest.parse( + rawUrl = authUrl(capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue), + relay = "https://httprelay.pubky.app/inbox/", + capabilities = capabilities, + ) + + assertIs(result.exceptionOrNull()) + } + @Test fun `parseCapabilities parses single permission`() { val permissions = PubkyAuthRequest.parseCapabilities("/pub/bitkit.to/:rw") diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index 5069fbc84..c68f688c9 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -41,13 +41,14 @@ class PaykitSdkServiceTest { } @Test - fun `local E2E uses emulator host for Pubky testnet`() { + fun `local E2E uses configured host for Pubky testnet`() { val config = paykitPubkyClientConfig( isLocalE2eBackend = true, + localTestnetHost = "192.0.2.1", baseConfig = basePubkyClientConfig, ) - assertEquals("10.0.2.2", config.localTestnetHost) + assertEquals("192.0.2.1", config.localTestnetHost) assertEquals(basePubkyClientConfig.requestTimeoutSecs, config.requestTimeoutSecs) } diff --git a/docs/watch-only-account-claim-v1.md b/docs/watch-only-account-claim-v1.md index bc043e916..71f15d3e0 100644 --- a/docs/watch-only-account-claim-v1.md +++ b/docs/watch-only-account-claim-v1.md @@ -5,7 +5,7 @@ This document records the client contract implemented by Bitkit iOS and Android ## Request - The Pubky Auth URL includes `x-bitkit-claim=watch-only-account-v1`. -- The exact capability is `/pub/paykit/v0/bitkit/server/:rw`. +- The exact capabilities are `/pub/paykit/v0/bitkit/server/:rw` and `/pub/paykit/v0/private/bitkit/server/:rw`. - Missing, unknown, mismatched, or duplicate companion-claim parameters are rejected. - Every distinct auth request creates a fresh native-SegWit account, beginning at BIP84 account index `1`. Account indexes increase monotonically and are never reused. Retrying the same logical auth request reuses its incomplete account even if query parameters are reordered. - Bitkit automatically names the account from the requesting service. The user can rename it later. The local name is not disclosed in the claim. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 24652573b..6958a037a 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.5" } -paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc42" } +paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc43" } 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 dcc5cbfefb4d7e94c893409e83831cae0ebd147c Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 5 Aug 2026 17:45:09 -0500 Subject: [PATCH 7/7] test: validate capability whitespace --- .../test/java/to/bitkit/models/PubkyAuthRequestTest.kt | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt b/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt index a04b5689f..cbc7d5482 100644 --- a/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt +++ b/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt @@ -33,15 +33,10 @@ class PubkyAuthRequestTest { } @Test - fun `parse recognizes watch-only account claim with capability whitespace`() { + fun `matcher recognizes watch-only account claim with capability whitespace`() { val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES.replace(",", " , ") - val request = PubkyAuthRequest.parse( - rawUrl = authUrl(capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue), - relay = "https://httprelay.pubky.app/inbox/", - capabilities = capabilities, - ).getOrThrow() - assertEquals(PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1, request.bitkitClaim) + assertTrue(PubkyAuthClaim.matchesWatchOnlyAccountCapabilities(capabilities)) } @Test