Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand Down
50 changes: 50 additions & 0 deletions app/src/main/java/to/bitkit/repositories/PaykitIssuerInterop.kt
Original file line number Diff line number Diff line change
@@ -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<String>,
network: Network,
): List<String> = identifiers
.filter { MethodId.fromRawValue(it, network) != null }
.distinct()

fun parseEndpointPayload(endpointData: String): PaykitEndpointPayload? {
val payload = runCatching {
payloadJson.decodeFromString<PaykitEndpointPayload>(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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -614,21 +616,23 @@ private fun List<PaykitPaymentRequest>.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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
44 changes: 14 additions & 30 deletions app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -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,
Expand All @@ -105,29 +94,28 @@ 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<PaymentEndpointPayload>(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,
)
}

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 =
Expand Down Expand Up @@ -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,
)
3 changes: 2 additions & 1 deletion app/src/main/java/to/bitkit/services/PaykitSdkService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,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
Expand Down Expand Up @@ -629,7 +630,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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -553,7 +555,9 @@ internal fun PaymentRequestCard(
)
},
size = ButtonSize.Small,
modifier = Modifier.weight(1f),
modifier = Modifier
.weight(1f)
.testTag("PaymentRequestPay${request.paymentRequestId}")
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading