diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt index 35fd856b3d..7fb258025d 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt @@ -51,6 +51,8 @@ class PaymentRequestsScreenTest { composeTestRule.onNodeWithTag("MoneyPrimary").assertIsDisplayed() composeTestRule.onNodeWithTag("MoneySecondary").assertIsDisplayed() composeTestRule.onNodeWithTag("PaymentRequestsSeeAll").assertIsDisplayed() + composeTestRule.onNodeWithTag("PaymentRequestRejectincoming").assertIsDisplayed() + composeTestRule.onNodeWithTag("PaymentRequestPayincoming").assertIsDisplayed() composeTestRule.onNodeWithText("Dismiss").assertIsDisplayed() } diff --git a/app/src/main/java/to/bitkit/repositories/PaykitIssuerInterop.kt b/app/src/main/java/to/bitkit/repositories/PaykitIssuerInterop.kt new file mode 100644 index 0000000000..405cbc47e5 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PaykitIssuerInterop.kt @@ -0,0 +1,50 @@ +package to.bitkit.repositories + +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.lightningdevkit.ldknode.Network +import to.bitkit.di.json as appJson + +internal object PaykitIssuerInterop { + /** Canonical lowercase Bitcoin asset used by Paykit Payment Requests. */ + const val BITCOIN_ASSET = "btc" + + private val payloadJson = Json(appJson) { + prettyPrint = false + isLenient = false + encodeDefaults = false + } + + fun supportedEndpointIdentifiers( + identifiers: List, + network: Network, + ): List = identifiers + .filter { MethodId.fromRawValue(it, network) != null } + .distinct() + + fun parseEndpointPayload(endpointData: String): PaykitEndpointPayload? { + val payload = runCatching { + payloadJson.decodeFromString(endpointData) + }.getOrNull() ?: return null + val value = payload.value.trim() + if (value.isEmpty()) return null + + return payload.copy(value = value) + } + + fun serializeEndpointPayload(value: String): String? { + val trimmedValue = value.trim() + if (trimmedValue.isEmpty()) return null + + return payloadJson.encodeToString(PaykitEndpointPayload(value = trimmedValue)) + } +} + +@Serializable +internal data class PaykitEndpointPayload( + val value: String, + val min: String? = null, + val max: String? = null, +) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 4159a4d5d4..79a3f18659 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 @@ -614,21 +616,23 @@ private fun List.withExpiredLifecycle(now: Instant): List< private val bitcoinAmountPattern = Regex("(?:[0-9]+(?:\\.[0-9]*)?|\\.[0-9]+)") @Suppress("CyclomaticComplexMethod", "ReturnCount") -private fun PaymentRequestRecord.toPaykitPaymentRequest( +internal fun PaymentRequestRecord.toPaykitPaymentRequest( expectedRole: PaymentRequestLocalRole, now: Instant, requiresActionableRequest: Boolean = true, + network: Network = Env.network, ): PaykitPaymentRequest? { if (localRole != expectedRole || state == PaymentRequestLifecycleState.ACTIVE_RECURRING) return null if (requiresActionableRequest && state != PaymentRequestLifecycleState.PROPOSED) return null val requestTerms = terms ?: return null - if (requestTerms.recurrence != null || requestTerms.amount.asset != "btc") return null + if (requestTerms.recurrence != null || requestTerms.amount.asset != PaykitIssuerInterop.BITCOIN_ASSET) return null 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 = PaykitIssuerInterop.supportedEndpointIdentifiers( + requestTerms.acceptedPaymentEndpointIdentifiers, + network, + ) if (requiresActionableRequest && endpoints.isEmpty()) return null val expiresAt = requestTerms.proposalExpiresAt?.let { diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 10b24fac99..961353fd7b 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -541,7 +541,9 @@ class PrivatePaykitRepo @Inject constructor( val consumedVersion = ensureState().contacts[publicKey] ?.consumedPrivatePaymentListVersionsByReceiverPath ?.get(receiverPath) - val amount = paymentRequest?.let { PaymentAmountContext(it.amountValue, "btc") } + val amount = paymentRequest?.let { + PaymentAmountContext(it.amountValue, PaykitIssuerInterop.BITCOIN_ASSET) + } val prepared = preparePrivateContactPayment( publicKey = publicKey, receiverPath = receiverPath, diff --git a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt index 91e277bb9c..c4a2af123b 100644 --- a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -8,10 +8,6 @@ import kotlinx.coroutines.flow.first 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 kotlinx.serialization.json.Json import org.lightningdevkit.ldknode.Bolt11Invoice import org.lightningdevkit.ldknode.Network import to.bitkit.data.SettingsData @@ -35,7 +31,6 @@ import kotlin.time.Clock import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes import kotlin.time.ExperimentalTime -import to.bitkit.di.json as appJson sealed class PublicPaykitError(message: String) : AppError(message) { data object InvalidPayload : PublicPaykitError("Invalid Paykit payment endpoint payload") @@ -78,12 +73,6 @@ class PublicPaykitRepo @Inject constructor( companion object { private val methodIdPattern = Regex("^[a-z0-9]+-[a-z0-9]+-[a-z0-9]+$") - private val payloadJson = Json(appJson) { - prettyPrint = false - isLenient = false - encodeDefaults = false - } - internal val payablePreferenceOrder = listOf( MethodId.Bolt11, MethodId.Lnurl, @@ -105,19 +94,19 @@ 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 payload = runCatching { - payloadJson.decodeFromString(endpointData) - }.getOrNull() ?: return null - val value = payload.value.trim() - if (value.isEmpty()) return null + val knownMethodId = MethodId.fromRawValue(methodId, network) ?: return null + val payload = PaykitIssuerInterop.parseEndpointPayload(endpointData) ?: return null return Endpoint( methodId = knownMethodId, - value = value, + value = payload.value, min = payload.min, max = payload.max, rawPayload = endpointData, @@ -125,9 +114,8 @@ class PublicPaykitRepo @Inject constructor( } fun serializePayload(value: String): String { - val trimmedValue = value.trim() - if (trimmedValue.isEmpty()) throw PublicPaykitError.InvalidPayload - return payloadJson.encodeToString(PaymentEndpointPayload(value = trimmedValue)) + return PaykitIssuerInterop.serializeEndpointPayload(value) + ?: throw PublicPaykitError.InvalidPayload } fun hasLightningRouteHints(bolt11: String): Boolean = @@ -463,13 +451,9 @@ 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 } } } - -@Serializable -private data class PaymentEndpointPayload( - val value: String, - val min: String? = null, - val max: String? = null, -) diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index fcf69c9e75..c8e1b27609 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -83,6 +83,7 @@ import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.toHex import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.repositories.Endpoint +import to.bitkit.repositories.PaykitIssuerInterop import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.utils.AppError import to.bitkit.utils.Logger @@ -630,7 +631,7 @@ class PaykitSdkService @Inject constructor( PubkyPublicKeyFormat.matches(identityStatus.publicKey, expectedIdentity) ) { "Paykit identity changed before proposing the payment request" } val terms = PaymentRequestTerms( - amount = PaymentRequestAmount(proposal.amountValue, "btc"), + amount = PaymentRequestAmount(proposal.amountValue, PaykitIssuerInterop.BITCOIN_ASSET), paymentReference = PaymentReference(proposal.paymentReference), proposalExpiresAt = proposal.proposalExpiresAt, recurrence = null, diff --git a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt index abdab879f5..a69616eadb 100644 --- a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt @@ -539,7 +539,9 @@ internal fun PaymentRequestCard( ) }, size = ButtonSize.Small, - modifier = Modifier.weight(1f), + modifier = Modifier + .weight(1f) + .testTag("PaymentRequestReject${request.paymentRequestId}") ) PrimaryButton( text = stringResource(R.string.wallet__payment_request_pay), @@ -553,7 +555,9 @@ internal fun PaymentRequestCard( ) }, size = ButtonSize.Small, - modifier = Modifier.weight(1f), + modifier = Modifier + .weight(1f) + .testTag("PaymentRequestPay${request.paymentRequestId}") ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt index cb38d9a411..38b06639ed 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt @@ -213,7 +213,11 @@ private fun Content( onBiometricsSuccess: () -> Unit = {}, onBiometricsFailure: () -> Unit = {}, ) { - Box(modifier = modifier) { + Box( + modifier = modifier.testTag( + if (uiState.isPaymentRequest) "PaymentRequestConfirm" else "SendConfirm", + ) + ) { Column( modifier = Modifier .fillMaxSize() diff --git a/app/src/test/java/to/bitkit/repositories/PaykitIssuerInteropTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitIssuerInteropTest.kt new file mode 100644 index 0000000000..052b0136cf --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/PaykitIssuerInteropTest.kt @@ -0,0 +1,199 @@ +package to.bitkit.repositories + +import com.synonym.paykit.PaymentReference +import com.synonym.paykit.PaymentRequestAmount +import com.synonym.paykit.PaymentRequestLifecycleState +import com.synonym.paykit.PaymentRequestLocalRole +import com.synonym.paykit.PaymentRequestRecord +import com.synonym.paykit.PaymentRequestTerms +import com.synonym.paykit.PrivateJsonObject +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import org.junit.Test +import org.lightningdevkit.ldknode.Network +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Instant + +class PaykitIssuerInteropTest { + companion object { + private val NOW = Instant.parse("2026-09-02T12:00:00Z") + private val PAYMENT_REFERENCE = mock { + on { exportText() } doReturn "marketplace-order-713" + } + private val METADATA = mock { + on { exportText() } doReturn """{"order":"713"}""" + } + } + + @Test + fun `request fixtures match issuer contract`() { + val fixtures = loadFixtures() + assertEquals(1, fixtures.schemaVersion) + + fixtures.requestFixtures.forEach { fixture -> + val record = paymentRequestRecord( + asset = fixture.asset, + endpointIdentifiers = fixture.acceptedPaymentEndpointIdentifiers, + ) + val request = record.toPaykitPaymentRequest( + expectedRole = PaymentRequestLocalRole.PAYER, + now = NOW, + network = fixture.network.ldkNetwork, + ) + + assertEquals(fixture.accepted, request != null, fixture.name) + assertEquals( + fixture.expectedIdentifiers, + request?.acceptedPaymentEndpointIdentifiers.orEmpty(), + fixture.name, + ) + } + } + + @Test + fun `endpoint fixtures match issuer contract`() { + loadFixtures().endpointFixtures.forEach { fixture -> + val endpoint = PublicPaykitRepo.parseEndpoint( + methodId = fixture.identifier, + endpointData = fixture.payload, + network = Network.REGTEST, + ) + + assertEquals(fixture.accepted, endpoint != null, fixture.name) + assertEquals(fixture.expectedValue, endpoint?.value, fixture.name) + assertEquals(fixture.expectedMin, endpoint?.min, fixture.name) + assertEquals(fixture.expectedMax, endpoint?.max, fixture.name) + } + } + + @Test + fun `request fixtures cover every documented identifier`() { + val acceptedFixtures = loadFixtures().requestFixtures.filter { it.accepted } + val expectedOnchainFixtures = FixtureNetwork.entries.flatMap { network -> + FixtureScript.entries.map { script -> + "${network.serializedName}|btc-${network.serializedName}-${script.serializedName}" + } + }.toSet() + val actualOnchainFixtures = acceptedFixtures.mapNotNull { fixture -> + val identifier = fixture.expectedIdentifiers.singleOrNull() ?: return@mapNotNull null + if (!identifier.startsWith("btc-${fixture.network.serializedName}-")) return@mapNotNull null + "${fixture.network.serializedName}|$identifier" + }.toSet() + + assertEquals(expectedOnchainFixtures, actualOnchainFixtures) + + FixtureNetwork.entries.forEach { network -> + assertTrue( + acceptedFixtures.any { + it.network == network && it.expectedIdentifiers == listOf("btc-lightning-bolt11") + }, + "Missing Bolt11 fixture for '${network.serializedName}'", + ) + assertTrue( + acceptedFixtures.any { + it.network == network && it.expectedIdentifiers == listOf("btc-lightning-lnurl") + }, + "Missing LNURL fixture for '${network.serializedName}'", + ) + } + } + + private fun loadFixtures(): IssuerInteropFixtures { + val resource = requireNotNull(javaClass.classLoader?.getResource("paykit-issuer-interoperability.json")) { + "Missing Paykit issuer interoperability fixtures" + } + return Json.decodeFromString(resource.readText()) + } + + private fun paymentRequestRecord( + asset: String, + endpointIdentifiers: List, + ) = PaymentRequestRecord( + counterparty = "pubkyissuerfixture", + counterpartyReceiverPath = "bitkit/server", + paymentRequestId = "71300000-0000-4000-8000-000000000001", + localRole = PaymentRequestLocalRole.PAYER, + state = PaymentRequestLifecycleState.PROPOSED, + proposalStreamItemId = 1uL, + proposalOutboundMessageId = null, + proposalOutboundStatus = null, + proposalEventId = "71300000-0000-4000-8000-000000000002", + terms = PaymentRequestTerms( + amount = PaymentRequestAmount(value = "0.001", asset = asset), + paymentReference = PAYMENT_REFERENCE, + proposalExpiresAt = null, + recurrence = null, + acceptedPaymentEndpointIdentifiers = endpointIdentifiers, + metadata = METADATA, + ), + acceptedEventId = null, + acceptedOutboundStatus = null, + rejectedEventId = null, + rejectedOutboundStatus = null, + canceledEventId = null, + canceledOutboundStatus = null, + paymentProofs = emptyList(), + lastStreamItemId = 1uL, + lastOutboundMessageId = null, + lastOutboundStatus = null, + lastEventAt = NOW.toString(), + invalidReason = null, + ) +} + +@Serializable +private data class IssuerInteropFixtures( + val schemaVersion: Int, + val requestFixtures: List, + val endpointFixtures: List, +) + +@Serializable +private data class RequestFixture( + val name: String, + val network: FixtureNetwork, + val asset: String, + val acceptedPaymentEndpointIdentifiers: List, + val accepted: Boolean, + val expectedIdentifiers: List, +) + +@Serializable +private data class EndpointFixture( + val name: String, + val identifier: String, + val payload: String, + val accepted: Boolean, + val expectedValue: String? = null, + val expectedMin: String? = null, + val expectedMax: String? = null, +) + +@Serializable +private enum class FixtureNetwork( + val ldkNetwork: Network, + val serializedName: String, +) { + @SerialName("bitcoin") + Bitcoin(Network.BITCOIN, "bitcoin"), + + @SerialName("testnet") + Testnet(Network.TESTNET, "testnet"), + + @SerialName("signet") + Signet(Network.SIGNET, "signet"), + + @SerialName("regtest") + Regtest(Network.REGTEST, "regtest"), +} + +private enum class FixtureScript(val serializedName: String) { + P2tr("p2tr"), + P2wpkh("p2wpkh"), + P2sh("p2sh"), + P2pkh("p2pkh"), +} diff --git a/app/src/test/resources/paykit-issuer-interoperability.json b/app/src/test/resources/paykit-issuer-interoperability.json new file mode 100644 index 0000000000..489584bd9d --- /dev/null +++ b/app/src/test/resources/paykit-issuer-interoperability.json @@ -0,0 +1,372 @@ +{ + "schemaVersion": 1, + "requestFixtures": [ + { + "name": "bitcoin-p2tr", + "network": "bitcoin", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-bitcoin-p2tr"], + "accepted": true, + "expectedIdentifiers": ["btc-bitcoin-p2tr"] + }, + { + "name": "bitcoin-p2wpkh", + "network": "bitcoin", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-bitcoin-p2wpkh"], + "accepted": true, + "expectedIdentifiers": ["btc-bitcoin-p2wpkh"] + }, + { + "name": "bitcoin-p2sh", + "network": "bitcoin", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-bitcoin-p2sh"], + "accepted": true, + "expectedIdentifiers": ["btc-bitcoin-p2sh"] + }, + { + "name": "bitcoin-p2pkh", + "network": "bitcoin", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-bitcoin-p2pkh"], + "accepted": true, + "expectedIdentifiers": ["btc-bitcoin-p2pkh"] + }, + { + "name": "testnet-p2tr", + "network": "testnet", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-testnet-p2tr"], + "accepted": true, + "expectedIdentifiers": ["btc-testnet-p2tr"] + }, + { + "name": "testnet-p2wpkh", + "network": "testnet", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-testnet-p2wpkh"], + "accepted": true, + "expectedIdentifiers": ["btc-testnet-p2wpkh"] + }, + { + "name": "testnet-p2sh", + "network": "testnet", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-testnet-p2sh"], + "accepted": true, + "expectedIdentifiers": ["btc-testnet-p2sh"] + }, + { + "name": "testnet-p2pkh", + "network": "testnet", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-testnet-p2pkh"], + "accepted": true, + "expectedIdentifiers": ["btc-testnet-p2pkh"] + }, + { + "name": "signet-p2tr", + "network": "signet", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-signet-p2tr"], + "accepted": true, + "expectedIdentifiers": ["btc-signet-p2tr"] + }, + { + "name": "signet-p2wpkh", + "network": "signet", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-signet-p2wpkh"], + "accepted": true, + "expectedIdentifiers": ["btc-signet-p2wpkh"] + }, + { + "name": "signet-p2sh", + "network": "signet", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-signet-p2sh"], + "accepted": true, + "expectedIdentifiers": ["btc-signet-p2sh"] + }, + { + "name": "signet-p2pkh", + "network": "signet", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-signet-p2pkh"], + "accepted": true, + "expectedIdentifiers": ["btc-signet-p2pkh"] + }, + { + "name": "regtest-p2tr", + "network": "regtest", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-regtest-p2tr"], + "accepted": true, + "expectedIdentifiers": ["btc-regtest-p2tr"] + }, + { + "name": "regtest-p2wpkh", + "network": "regtest", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-regtest-p2wpkh"], + "accepted": true, + "expectedIdentifiers": ["btc-regtest-p2wpkh"] + }, + { + "name": "regtest-p2sh", + "network": "regtest", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-regtest-p2sh"], + "accepted": true, + "expectedIdentifiers": ["btc-regtest-p2sh"] + }, + { + "name": "regtest-p2pkh", + "network": "regtest", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-regtest-p2pkh"], + "accepted": true, + "expectedIdentifiers": ["btc-regtest-p2pkh"] + }, + { + "name": "bitcoin-bolt11", + "network": "bitcoin", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-lightning-bolt11"], + "accepted": true, + "expectedIdentifiers": ["btc-lightning-bolt11"] + }, + { + "name": "testnet-bolt11", + "network": "testnet", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-lightning-bolt11"], + "accepted": true, + "expectedIdentifiers": ["btc-lightning-bolt11"] + }, + { + "name": "signet-bolt11", + "network": "signet", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-lightning-bolt11"], + "accepted": true, + "expectedIdentifiers": ["btc-lightning-bolt11"] + }, + { + "name": "regtest-bolt11", + "network": "regtest", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-lightning-bolt11"], + "accepted": true, + "expectedIdentifiers": ["btc-lightning-bolt11"] + }, + { + "name": "bitcoin-lnurl", + "network": "bitcoin", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-lightning-lnurl"], + "accepted": true, + "expectedIdentifiers": ["btc-lightning-lnurl"] + }, + { + "name": "testnet-lnurl", + "network": "testnet", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-lightning-lnurl"], + "accepted": true, + "expectedIdentifiers": ["btc-lightning-lnurl"] + }, + { + "name": "signet-lnurl", + "network": "signet", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-lightning-lnurl"], + "accepted": true, + "expectedIdentifiers": ["btc-lightning-lnurl"] + }, + { + "name": "regtest-lnurl", + "network": "regtest", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-lightning-lnurl"], + "accepted": true, + "expectedIdentifiers": ["btc-lightning-lnurl"] + }, + { + "name": "regtest-filters-and-deduplicates", + "network": "regtest", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": [ + "btc-lightning-bolt11", + "btc-lightning-bolt11", + "btc-bitcoin-p2wpkh", + "btc-regtest-p2wpkh", + "btc-unsupported-method" + ], + "accepted": true, + "expectedIdentifiers": ["btc-lightning-bolt11", "btc-regtest-p2wpkh"] + }, + { + "name": "uppercase-asset", + "network": "regtest", + "asset": "BTC", + "acceptedPaymentEndpointIdentifiers": ["btc-regtest-p2wpkh"], + "accepted": false, + "expectedIdentifiers": [] + }, + { + "name": "bitcoin-foreign-onchain", + "network": "bitcoin", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-testnet-p2wpkh"], + "accepted": false, + "expectedIdentifiers": [] + }, + { + "name": "testnet-foreign-onchain", + "network": "testnet", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-signet-p2wpkh"], + "accepted": false, + "expectedIdentifiers": [] + }, + { + "name": "signet-foreign-onchain", + "network": "signet", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-regtest-p2wpkh"], + "accepted": false, + "expectedIdentifiers": [] + }, + { + "name": "regtest-foreign-onchain", + "network": "regtest", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-bitcoin-p2wpkh"], + "accepted": false, + "expectedIdentifiers": [] + }, + { + "name": "uppercase-identifier", + "network": "regtest", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["BTC-regtest-p2wpkh"], + "accepted": false, + "expectedIdentifiers": [] + }, + { + "name": "unknown-identifier", + "network": "regtest", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-unsupported-method"], + "accepted": false, + "expectedIdentifiers": [] + }, + { + "name": "empty-identifiers", + "network": "regtest", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": [], + "accepted": false, + "expectedIdentifiers": [] + } + ], + "endpointFixtures": [ + { + "name": "onchain-json-value", + "identifier": "btc-regtest-p2wpkh", + "payload": "{\"value\":\"bcrt1qissuerfixture\"}", + "accepted": true, + "expectedValue": "bcrt1qissuerfixture" + }, + { + "name": "foreign-network-endpoint", + "identifier": "btc-bitcoin-p2wpkh", + "payload": "{\"value\":\"bc1qissuerfixture\"}", + "accepted": false + }, + { + "name": "bolt11-json-value-with-bounds", + "identifier": "btc-lightning-bolt11", + "payload": "{\"value\":\"lnbc1issuerfixture\",\"min\":\"1000\",\"max\":\"2000\"}", + "accepted": true, + "expectedValue": "lnbc1issuerfixture", + "expectedMin": "1000", + "expectedMax": "2000" + }, + { + "name": "numeric-min", + "identifier": "btc-regtest-p2wpkh", + "payload": "{\"value\":\"bcrt1qissuerfixture\",\"min\":1000}", + "accepted": false + }, + { + "name": "boolean-max", + "identifier": "btc-regtest-p2wpkh", + "payload": "{\"value\":\"bcrt1qissuerfixture\",\"max\":true}", + "accepted": false + }, + { + "name": "lnurl-trims-value", + "identifier": "btc-lightning-lnurl", + "payload": " {\"value\":\" lnurl1issuerfixture \"} ", + "accepted": true, + "expectedValue": "lnurl1issuerfixture" + }, + { + "name": "raw-string", + "identifier": "btc-regtest-p2wpkh", + "payload": "bcrt1qissuerfixture", + "accepted": false + }, + { + "name": "empty-payload", + "identifier": "btc-regtest-p2wpkh", + "payload": "", + "accepted": false + }, + { + "name": "missing-value", + "identifier": "btc-regtest-p2wpkh", + "payload": "{}", + "accepted": false + }, + { + "name": "empty-value", + "identifier": "btc-regtest-p2wpkh", + "payload": "{\"value\":\"\"}", + "accepted": false + }, + { + "name": "whitespace-value", + "identifier": "btc-regtest-p2wpkh", + "payload": "{\"value\":\" \"}", + "accepted": false + }, + { + "name": "numeric-value", + "identifier": "btc-regtest-p2wpkh", + "payload": "{\"value\":713}", + "accepted": false + }, + { + "name": "top-level-array", + "identifier": "btc-regtest-p2wpkh", + "payload": "[{\"value\":\"bcrt1qissuerfixture\"}]", + "accepted": false + }, + { + "name": "malformed-json", + "identifier": "btc-regtest-p2wpkh", + "payload": "{\"value\":", + "accepted": false + }, + { + "name": "unsupported-identifier", + "identifier": "btc-lightning-bolt12", + "payload": "{\"value\":\"lno1issuerfixture\"}", + "accepted": false + } + ] +} diff --git a/docs/paykit-issuer-interoperability.md b/docs/paykit-issuer-interoperability.md new file mode 100644 index 0000000000..f2ce0b0982 --- /dev/null +++ b/docs/paykit-issuer-interoperability.md @@ -0,0 +1,86 @@ +# Paykit issuer interoperability + +This is the Bitkit issuer contract for one-time Paykit Payment Requests. It describes the request and payment-endpoint shapes an issuer must provide for Bitkit to present and open a request. The canonical accepted and rejected examples are in +[`app/src/test/resources/paykit-issuer-interoperability.json`](../app/src/test/resources/paykit-issuer-interoperability.json). + +This contract records Bitkit behavior. Paykit protocol or SDK policy remains owned by Paykit. + +## Payment Request + +An actionable request must satisfy all of these requirements: + +- The amount asset is exactly lowercase `btc`. +- The amount is a positive decimal Bitcoin value with at most eight significant fractional digits and no more than `18,446,744,073,709,551` satoshis. +- The request is a one-time proposal: the local role is payer, lifecycle state is proposed, and recurrence is absent. +- The proposal expiration is absent or is a valid future ISO 8601 timestamp. +- `acceptedPaymentEndpointIdentifiers` retains at least one identifier supported on the wallet's current network. + +Bitkit filters `acceptedPaymentEndpointIdentifiers` in issuer order, removes duplicates after their first occurrence, and drops unknown or wrong-network identifiers. The request remains actionable when at least one identifier survives. + +### Endpoint identifiers + +Lightning identifiers are chain-independent and are accepted on every network: + +- `btc-lightning-bolt11` +- `btc-lightning-lnurl` + +On-chain identifiers include the wallet network: + +| Network | P2TR | P2WPKH | P2SH | P2PKH | +| --- | --- | --- | --- | --- | +| Bitcoin | `btc-bitcoin-p2tr` | `btc-bitcoin-p2wpkh` | `btc-bitcoin-p2sh` | `btc-bitcoin-p2pkh` | +| Testnet | `btc-testnet-p2tr` | `btc-testnet-p2wpkh` | `btc-testnet-p2sh` | `btc-testnet-p2pkh` | +| Signet | `btc-signet-p2tr` | `btc-signet-p2wpkh` | `btc-signet-p2sh` | `btc-signet-p2pkh` | +| Regtest | `btc-regtest-p2tr` | `btc-regtest-p2wpkh` | `btc-regtest-p2sh` | `btc-regtest-p2pkh` | + +For example, a regtest issuer can propose: + +```json +{ + "amount": { "value": "0.001", "asset": "btc" }, + "paymentReference": { "text": "marketplace-order-713" }, + "proposalExpiresAt": "2030-01-01T00:00:00Z", + "recurrence": null, + "acceptedPaymentEndpointIdentifiers": [ + "btc-regtest-p2wpkh", + "btc-lightning-bolt11" + ], + "metadata": { "order": "713" } +} +``` + +The object above shows the Paykit term values an issuer supplies; Paykit owns their wire serialization. + +## Payment endpoint + +For every advertised identifier, the endpoint payload is a JSON object. `value` is a required, non-empty string: + +```json +{"value":"bcrt1qissuerfixture"} +``` + +Optional `min` and `max` string fields are retained: + +```json +{"value":"lnbc1issuerfixture","min":"1000","max":"2000"} +``` + +Bitkit trims whitespace around the payload and `value`. It rejects a bare address or invoice string, invalid JSON, a non-object top level, a missing `value`, a non-string `value`, non-string `min` or `max` values, an empty value, a whitespace-only value, a wrong-network on-chain identifier, or an unknown identifier. + +After this shape check, Bitkit validates that the value is usable: an on-chain address matches the current network, a BOLT 11 invoice is unexpired and network-correct, and an LNURL value is an LNURL-pay request. + +## Delivery prerequisites + +The issuer and wallet must be linked Paykit peers on the same receiver path before Bitkit polls the request. The issuer must advertise a usable endpoint for at least one identifier retained from the request. A request that fails the request gate is not presented; a request whose endpoint cannot be resolved is deferred until usable payment details arrive. + +## Contract fixtures + +The fixture file is the cross-platform source of truth for Bitkit iOS and Android: + +- Request fixtures cover every documented P2TR, P2WPKH, P2SH, and P2PKH identifier for Bitcoin, testnet, signet, and regtest. +- Request fixtures cover both Lightning identifiers on every network. +- Rejected request fixtures cover uppercase `BTC`, a foreign-network on-chain identifier on every network, an uppercase identifier, an unknown identifier, and an empty identifier list. +- Endpoint fixtures accept JSON object payloads with a non-empty string `value`, including optional string bounds and surrounding whitespace. +- Rejected endpoint fixtures cover a raw string, empty payload, missing/empty/whitespace/numeric `value`, non-string bounds, a wrong-network on-chain identifier, top-level array, malformed JSON, and unsupported identifier. + +The iOS fixture suite in [PR #719](https://github.com/synonymdev/bitkit-ios/pull/719) consumes the same fixture names, inputs, and expected results. Any intentional platform difference requires changing this contract and both fixture suites together. diff --git a/docs/pubky.md b/docs/pubky.md index 2fbdc06120..742ec6967b 100644 --- a/docs/pubky.md +++ b/docs/pubky.md @@ -1,5 +1,7 @@ # Pubky Integration +Paykit issuers should follow the [Paykit issuer interoperability contract](paykit-issuer-interoperability.md) for payment request and endpoint shapes accepted by Bitkit. + ## Overview Bitkit integrates [Pubky](https://pubky.org) decentralized identity, allowing users to connect their Pubky profile via [Pubky Ring](https://play.google.com/store/apps/details?id=to.pubky.ring) authentication. Once connected, the user's profile name and avatar appear on the home screen header, a full profile page shows their bio, links, and a shareable QR code, and the contacts screen shows followed Pubky users. diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md new file mode 100644 index 0000000000..bd7870154c --- /dev/null +++ b/journeys/payment-requests/README.md @@ -0,0 +1,34 @@ +# Payment Request journeys + +Cover incoming Paykit Payment Requests from a linked issuer. The issuer contract and exact accepted/rejected data live in +[`docs/paykit-issuer-interoperability.md`](../../docs/paykit-issuer-interoperability.md) and +[`app/src/test/resources/paykit-issuer-interoperability.json`](../../app/src/test/resources/paykit-issuer-interoperability.json). + +## Setup + +Run Bitkit against regtest with Paykit UI enabled. Authenticate a Pubky identity, link the fixture issuer on receiver path `bitkit/server`, and give the wallet enough on-chain balance to pay 100,000 sats. The fixture issuer must be able to publish a Paykit endpoint and send a one-time Payment Request to that linked peer. + +The accepted journey uses: + +- Payment Request ID: `71300000-0000-4000-8000-000000000001` +- Asset: `btc` +- Amount: `0.001` +- Accepted identifier: `btc-regtest-p2wpkh` +- Endpoint payload: `{"value":"bcrt1qissuerfixture"}`, replacing the placeholder address with a valid current receive address from the issuer + +Rejected fixture shapes stay in unit tests because Bitkit intentionally does not present requests that fail the contract gate. + +## Reference evidence + +The source wallet-leg run completed this path on regtest on 2026-08-22: Bitkit presented the incoming request, opened the on-chain payment, broadcast it, and confirmed transaction +`cc85df0e24b54be353a57700429d144b35264c1af97f3de41c503dc52f1e4792` at height `77318`. + +That run established the issuer shapes captured by the fixture: lowercase `btc`, `btc-regtest-p2wpkh`, and a JSON object endpoint payload with a non-empty string `value`. The exact Debug binary SHA was not recorded, so the canonical fixture tests lock the same production gates on the current code. + +## Identifiers used + +- Incoming sheet: `PaymentRequestsSheet` +- Request row: `PaymentRequestRow` +- Pay action: `PaymentRequestPay` +- Reject action: `PaymentRequestReject` +- Payment confirmation: `PaymentRequestConfirm` diff --git a/journeys/payment-requests/issuer-interoperability.xml b/journeys/payment-requests/issuer-interoperability.xml new file mode 100644 index 0000000000..8186700204 --- /dev/null +++ b/journeys/payment-requests/issuer-interoperability.xml @@ -0,0 +1,14 @@ + + + Verifies that the canonical accepted regtest issuer fixture reaches Bitkit and opens the payment confirmation flow. Requires the linked fixture issuer and funded regtest wallet described in README.md. Rejected shapes are covered by the shared fixture unit tests because Bitkit intentionally does not present them. + + + Launch the E2E Bitkit app with Paykit UI enabled and the fixture issuer linked on receiver path "bitkit/server" + Have the issuer publish a current regtest P2WPKH address under identifier "btc-regtest-p2wpkh" with JSON payload {"value":"<current address>"} + Have the issuer send proposed one-time Payment Request "71300000-0000-4000-8000-000000000001" for amount "0.001", asset "btc", and accepted identifier "btc-regtest-p2wpkh" + Verify the incoming Payment Requests sheet (id "PaymentRequestsSheet") appears + Verify request row (id "PaymentRequestRow71300000-0000-4000-8000-000000000001") shows 100,000 sats + Tap Pay (id "PaymentRequestPay71300000-0000-4000-8000-000000000001") + Verify the Payment Request confirmation screen (id "PaymentRequestConfirm") shows 100,000 sats and the issuer as recipient + +