From cd805ed899a2dd9cff9719d50632704c8c0dd70b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 01:55:13 +0200 Subject: [PATCH 1/4] docs: codify paykit issuer contract --- .../repositories/PaykitPaymentRequestRepo.kt | 13 +++-- .../bitkit/repositories/PublicPaykitRepo.kt | 13 +++-- .../PaykitPaymentRequestRepoTest.kt | 52 ++++++++++++++++++- .../repositories/PublicPaykitRepoTest.kt | 47 +++++++++++++++++ docs/paykit-issuer-interoperability.md | 48 +++++++++++++++++ docs/pubky.md | 2 + 6 files changed, 168 insertions(+), 7 deletions(-) create mode 100644 docs/paykit-issuer-interoperability.md diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 4159a4d5d4..af64d9bb94 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -30,10 +30,12 @@ import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive +import org.lightningdevkit.ldknode.Network import to.bitkit.async.appScope import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.di.IoDispatcher +import to.bitkit.env.Env import to.bitkit.ext.runSuspendCatching import to.bitkit.flags.PaykitFeatureFlags import to.bitkit.models.PubkyPublicKeyFormat @@ -613,6 +615,13 @@ private fun List.withExpiredLifecycle(now: Instant): List< private val bitcoinAmountPattern = Regex("(?:[0-9]+(?:\\.[0-9]*)?|\\.[0-9]+)") +internal fun supportedPaykitEndpointIdentifiers( + identifiers: List, + network: Network = Env.network, +): List = identifiers + .filter { MethodId.fromRawValue(it, network) != null } + .distinct() + @Suppress("CyclomaticComplexMethod", "ReturnCount") private fun PaymentRequestRecord.toPaykitPaymentRequest( expectedRole: PaymentRequestLocalRole, @@ -626,9 +635,7 @@ private fun PaymentRequestRecord.toPaykitPaymentRequest( val amountSats = requestTerms.amount.value.toSats() ?.takeIf { it <= ULong.MAX_VALUE / 1000uL } ?: return null - val endpoints = requestTerms.acceptedPaymentEndpointIdentifiers - .filter { MethodId.fromRawValue(it) != null } - .distinct() + val endpoints = supportedPaykitEndpointIdentifiers(requestTerms.acceptedPaymentEndpointIdentifiers) if (requiresActionableRequest && endpoints.isEmpty()) return null val expiresAt = requestTerms.proposalExpiresAt?.let { diff --git a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt index 91e277bb9c..ff81b8a2c1 100644 --- a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -105,10 +105,14 @@ class PublicPaykitRepo @Inject constructor( fun isOnchainPaymentOptionEnabled(settings: SettingsData): Boolean = settings.publicPaykitOnchainEnabled - fun parseEndpoint(methodId: String, endpointData: String): Endpoint? { + fun parseEndpoint( + methodId: String, + endpointData: String, + network: Network = Env.network, + ): Endpoint? { if (!methodIdPattern.matches(methodId)) return null - val knownMethodId = MethodId.fromRawValue(methodId) ?: return null + val knownMethodId = MethodId.fromRawValue(methodId, network) ?: return null val payload = runCatching { payloadJson.decodeFromString(endpointData) }.getOrNull() ?: return null @@ -463,7 +467,10 @@ enum class MethodId( } companion object { - fun fromRawValue(value: String): MethodId? = entries.firstOrNull { it.rawValue == value } + fun fromRawValue( + value: String, + network: Network = Env.network, + ): MethodId? = entries.firstOrNull { it.rawValueForNetwork(network) == value } } } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index a11dc7ec88..3abafeadea 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.test.runCurrent import org.junit.After import org.junit.Before import org.junit.Test +import org.lightningdevkit.ldknode.Network import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.clearInvocations @@ -49,6 +50,7 @@ import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime import kotlin.time.Instant +@Suppress("LargeClass") class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { companion object { private const val PAYMENT_REQUEST_ID = "550e8400-e29b-41d4-a716-446655440000" @@ -105,6 +107,53 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals(listOf(MethodId.Bolt11.rawValue), request.acceptedPaymentEndpointIdentifiers) } + @Test + fun `refresh accepts only exact lowercase bitcoin asset`() = test { + whenever(paykitSdkService.paymentRequests()).thenReturn( + listOf( + paymentRequestRecord(id = "lowercase", asset = "btc"), + paymentRequestRecord(id = "uppercase", asset = "BTC"), + paymentRequestRecord(id = "mixed-case", asset = "Btc"), + paymentRequestRecord(id = "whitespace", asset = " btc "), + ), + ) + + sut.refresh().getOrThrow() + + assertEquals(listOf("lowercase"), sut.pendingRequests.value.map { it.paymentRequestId }) + } + + @Test + fun `request filtering accepts network correct and chain independent identifiers`() { + val networkRails = listOf( + Network.BITCOIN to "bitcoin", + Network.TESTNET to "testnet", + Network.SIGNET to "signet", + Network.REGTEST to "regtest", + ) + val scriptTypes = listOf("p2tr", "p2wpkh", "p2sh", "p2pkh") + val onchainIdentifiers = networkRails.flatMap { (_, rail) -> + scriptTypes.map { "btc-$rail-$it" } + } + + networkRails.forEach { (network, rail) -> + val accepted = listOf( + "btc-lightning-bolt11", + "btc-lightning-lnurl", + ) + scriptTypes.map { "btc-$rail-$it" } + val rejected = onchainIdentifiers.filterNot { it in accepted } + listOf( + "BTC-$rail-p2wpkh", + "btc-$rail-bolt12", + "btc-unsupported-method", + ) + + assertEquals( + accepted, + supportedPaykitEndpointIdentifiers(accepted + accepted.first() + rejected, network), + ) + } + } + @Test fun `refresh rejects amounts outside the app payment range`() = test { whenever(paykitSdkService.paymentRequests()).thenReturn( @@ -654,6 +703,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { role: PaymentRequestLocalRole? = PaymentRequestLocalRole.PAYER, state: PaymentRequestLifecycleState = PaymentRequestLifecycleState.PROPOSED, amount: String = "0.001", + asset: String = "btc", expiresAt: String? = null, endpoints: List = listOf(MethodId.Bolt11.rawValue), counterparty: String = COUNTERPARTY, @@ -669,7 +719,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { proposalOutboundStatus = null, proposalEventId = "proposal-event", terms = PaymentRequestTerms( - amount = PaymentRequestAmount(value = amount, asset = "btc"), + amount = PaymentRequestAmount(value = amount, asset = asset), paymentReference = PAYMENT_REFERENCE, proposalExpiresAt = expiresAt, recurrence = null, diff --git a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt index 612a5d8205..6c35825544 100644 --- a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import org.junit.After import org.junit.Before import org.junit.Test +import org.lightningdevkit.ldknode.Network import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.mock @@ -25,6 +26,7 @@ import to.bitkit.services.PaykitResolvedPaymentEndpoint import to.bitkit.services.PaykitSdkService import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals +import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Clock import kotlin.time.Duration.Companion.hours @@ -78,6 +80,51 @@ class PublicPaykitRepoTest : BaseUnitTest() { PublicPaykitRepo.lightningRouteHintsValidator = null } + @Test + fun `parseEndpoint accepts network correct JSON endpoint fixtures`() { + val fixtures = listOf( + Network.BITCOIN to "btc-bitcoin-p2wpkh", + Network.TESTNET to "btc-testnet-p2wpkh", + Network.SIGNET to "btc-signet-p2wpkh", + Network.REGTEST to "btc-regtest-p2wpkh", + ) + + fixtures.forEach { (network, identifier) -> + val endpoint = PublicPaykitRepo.parseEndpoint( + methodId = identifier, + endpointData = """{"value":" address ","min":"1000","max":"2000"}""", + network = network, + ) + + assertEquals(MethodId.P2wpkh, endpoint?.methodId) + assertEquals("address", endpoint?.value) + assertEquals("1000", endpoint?.min) + assertEquals("2000", endpoint?.max) + } + } + + @Test + fun `parseEndpoint rejects non canonical JSON endpoint payloads`() { + val rejectedPayloads = listOf( + "bcrt1qraw", + """"bcrt1qjsonstring"""", + "{}", + """{"value":""}""", + """{"value":" "}""", + """{"value":1}""", + ) + + rejectedPayloads.forEach { + assertNull( + PublicPaykitRepo.parseEndpoint( + methodId = "btc-regtest-p2wpkh", + endpointData = it, + network = Network.REGTEST, + ), + ) + } + } + @Test fun `syncCurrentPublishedEndpoints configures SDK public endpoints`() = test { walletState.value = WalletState(onchainAddress = "bc1ptest") diff --git a/docs/paykit-issuer-interoperability.md b/docs/paykit-issuer-interoperability.md new file mode 100644 index 0000000000..aae04642f2 --- /dev/null +++ b/docs/paykit-issuer-interoperability.md @@ -0,0 +1,48 @@ +# Paykit Issuer Interoperability + +Paykit issuers must use the following shapes for payment requests and endpoint payloads that Bitkit can open. + +## Payment requests + +- Set `terms.amount.asset` to the exact lowercase value `btc`. +- Advertise at least one endpoint identifier supported by the active Bitkit network. +- Lightning identifiers are chain-independent: + - `btc-lightning-bolt11` + - `btc-lightning-lnurl` +- On-chain identifiers are chain-specific and use `btc--