Skip to content
Merged
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
@@ -0,0 +1,58 @@
package to.bitkit.repositories

import com.synonym.paykit.PaymentConversion
import com.synonym.paykit.PaymentRequestTerms
import java.math.BigDecimal
import java.math.RoundingMode

internal object PaykitBitcoinRequestPricing {
/** Maximum input precision and combined normalized operand precision. */
private const val MAX_SIGNIFICANT_DIGITS = 38

/** Maximum length of an amount or rate before decimal parsing. */
private const val MAX_DECIMAL_LENGTH = 80

/** Millisatoshis per Bitcoin, expressed as a decimal scale. */
private const val MILLISATOSHI_SCALE = 11

data class BitcoinPayment(val amountSats: ULong, val endpointIdentifiers: List<String>)

@Suppress("ReturnCount")
fun bitcoinPayment(terms: PaymentRequestTerms, endpoints: List<String>): BitcoinPayment? {
val rates = (terms.conversion as? PaymentConversion.Fixed)?.rates ?: return null
if (rates.isEmpty() || rates.distinctBy { it.asset }.size != rates.size) return null
val requestedAmount = positiveDecimal(terms.amount.value) ?: return null
val amounts = mutableSetOf<ULong>()
val payableEndpoints = mutableListOf<String>()
for (endpoint in endpoints) {
val selector = endpoint.split('-').take(2).joinToString("-")
val rate = rates.firstOrNull { it.asset == selector }
?: rates.firstOrNull { it.asset == PaykitIssuerInterop.BITCOIN_ASSET }
val rateValue = rate?.value
?: "1".takeIf { terms.amount.asset == PaykitIssuerInterop.BITCOIN_ASSET } ?: continue
val multiplier = positiveDecimal(rateValue) ?: return null
val sats = sats(requestedAmount, multiplier, lightning = selector == "btc-lightning") ?: return null
amounts.add(sats)
payableEndpoints.add(endpoint)
}
return amounts.singleOrNull()?.let { BitcoinPayment(it, payableEndpoints) }
}

private fun sats(amount: BigDecimal, rate: BigDecimal, lightning: Boolean): ULong? {
val combinedPrecision = amount.stripTrailingZeros().precision() + rate.stripTrailingZeros().precision()
if (combinedPrecision > MAX_SIGNIFICANT_DIGITS) return null
val bitcoin = amount.multiply(rate).stripTrailingZeros()
val units = bitcoin.movePointRight(if (lightning) MILLISATOSHI_SCALE else 8).setScale(0, RoundingMode.CEILING)
val integer = units.toPlainString().toULongOrNull()?.takeIf { it > 0uL } ?: return null
if (lightning && integer % 1000uL != 0uL) return null
val sats = if (lightning) integer / 1000uL else integer
return sats.takeIf { it <= ULong.MAX_VALUE / 1000uL }
}

private fun positiveDecimal(value: String): BigDecimal? {
if (value.length > MAX_DECIMAL_LENGTH) return null
if (!value.matches(Regex("(?:[0-9]+(?:\\.[0-9]*)?|\\.[0-9]+)"))) return null
if (value.replace(".", "").trimStart('0').length > MAX_SIGNIFICANT_DIGITS) return null
return value.toBigDecimalOrNull()?.takeIf { it.signum() > 0 }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ data class PaykitPaymentRequest(
RecurringRequest("recurring_request", shouldLogIncomingRejection = false),
UnsupportedRecurrence("unsupported_recurrence"),
UnsupportedAsset("unsupported_asset"),
UnsupportedPricing("unsupported_pricing"),
Comment thread
ben-kaufman marked this conversation as resolved.
UnsupportedPaymentDeadline("unsupported_payment_deadline"),
InvalidAmount("invalid_amount"),
AmountOutOfRange("amount_out_of_range"),
Expand Down Expand Up @@ -1776,18 +1777,26 @@ private fun PaymentRequestRecord.parsePaykitPaymentRequest(
if (requiresActionableRequest && paymentDeadlineAt?.let { now > it } == true) {
return PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.Expired)
}
if (requestTerms.amount.asset != PaykitIssuerInterop.BITCOIN_ASSET) {
return PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.UnsupportedAsset)
}
val amountSats = requestTerms.amount.value.toPaykitSats()
?: return PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.InvalidAmount)
if (amountSats > ULong.MAX_VALUE / 1000uL) {
return PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.AmountOutOfRange)
}
val endpoints = PaykitIssuerInterop.supportedEndpointIdentifiers(
var endpoints = PaykitIssuerInterop.supportedEndpointIdentifiers(
requestTerms.acceptedPaymentEndpointIdentifiers,
network,
)
val amountSats: ULong
if (requestTerms.conversion != null) {
val payment = PaykitBitcoinRequestPricing.bitcoinPayment(requestTerms, endpoints)
?: return PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.UnsupportedPricing)
amountSats = payment.amountSats
endpoints = payment.endpointIdentifiers
} else {
if (requestTerms.amount.asset != PaykitIssuerInterop.BITCOIN_ASSET) {
return PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.UnsupportedAsset)
}
amountSats = requestTerms.amount.value.toPaykitSats()
?: return PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.InvalidAmount)
if (amountSats > ULong.MAX_VALUE / 1000uL) {
return PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.AmountOutOfRange)
}
}
if (requiresActionableRequest && endpoints.isEmpty()) {
return PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.NoSupportedEndpoint)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,7 @@ internal fun PaymentRequestRecord.toPaykitSubscription(
): PaykitSubscription? {
val role = localRole.toSubscriptionRole() ?: return null
val requestTerms = terms ?: return null
if (requestTerms.conversion != null) return null
val sdkRecurrence = requestTerms.recurrence ?: return null
if (
requestTerms.amount.asset != "btc" ||
Expand Down
20 changes: 14 additions & 6 deletions app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1322,6 +1322,11 @@ class AppViewModel @Inject constructor(
if (result !is PublicPaykitPaymentResult.Opened) {
if (result == PublicPaykitPaymentResult.PrivateLinkPending) {
finishPrivateLinkPendingPaymentRequestPresentation(request)
} else if (result == PublicPaykitPaymentResult.NotOpened) {
finishUnavailablePaymentRequestPresentation(
request,
IncomingPaykitPaymentRequestFailureReason.EndpointNotPayable,
)
} else {
deferPaymentRequestPresentation(
request = request,
Expand Down Expand Up @@ -1448,20 +1453,23 @@ class AppViewModel @Inject constructor(
if (restorePaymentRequestSheet && currentSheet.value == null) showSheet(Sheet.PaymentRequests)
}

private fun finishUnavailablePaymentRequestPresentation(request: PaykitPaymentRequest) {
paykitPaymentRequestDiagnostics.logPresentationRejection(
request.counterparty,
IncomingPaykitPaymentRequestFailureReason.ResolutionFailed,
)
private fun finishUnavailablePaymentRequestPresentation(
request: PaykitPaymentRequest,
reason: IncomingPaykitPaymentRequestFailureReason = IncomingPaykitPaymentRequestFailureReason.ResolutionFailed,
) {
paykitPaymentRequestDiagnostics.logPresentationRejection(request.counterparty, reason)
val endpointNotPayable = reason == IncomingPaykitPaymentRequestFailureReason.EndpointNotPayable
val restorePaymentRequestSheet =
requestedPaymentRequestId.value == request.id && shouldRestorePaymentRequestSheet
val showUnavailableToast = requestedPaymentRequestId.value == request.id
val showUnavailableToast = requestedPaymentRequestId.value == request.id || endpointNotPayable
if (endpointNotPayable) dismissedPreparingRequestIds.add(request.id)
if (requestedPaymentRequestId.value == request.id) {
invalidatePaymentRequestPresentation()
clearRequestedPaymentRequest()
}
clearPaymentRequestPresentationRetry(request.id)
if (!showUnavailableToast) return
finishPaymentRequestPreparation(paymentRequestPreparation.value)
toast(
type = Toast.ToastType.ERROR,
title = context.getString(R.string.wallet__payment_request),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ import com.synonym.bitkitcore.AddressType
import com.synonym.bitkitcore.NetworkType
import com.synonym.bitkitcore.ValidationResult
import com.synonym.bitkitcore.validateBitcoinAddress
import com.synonym.paykit.ConversionRate
import com.synonym.paykit.IdentityStatus
import com.synonym.paykit.LinkedPeerRecord
import com.synonym.paykit.LinkedPeerState
import com.synonym.paykit.OutboundPrivateMessageStatus
import com.synonym.paykit.OutboundPrivateSendFailure
import com.synonym.paykit.OutboundPrivateSendReport
import com.synonym.paykit.PaykitException
import com.synonym.paykit.PaymentConversion
import com.synonym.paykit.PaymentDeadline
import com.synonym.paykit.PaymentProofRecord
import com.synonym.paykit.PaymentReference
Expand Down Expand Up @@ -59,6 +61,7 @@ import org.mockito.kotlin.whenever
import to.bitkit.data.SettingsData
import to.bitkit.data.SettingsStore
import to.bitkit.models.PubkyPublicKeyFormat
import to.bitkit.models.safe
import to.bitkit.services.PaykitPaymentRequestProposalTerms
import to.bitkit.services.PaykitReadLane
import to.bitkit.services.PaykitSdkOperationLock.Priority
Expand Down Expand Up @@ -92,6 +95,144 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) {
}
}

@Test
fun `fixed bitcoin pricing uses rail rates before asset rates and parity`() {
data class Case(
val asset: String,
val amount: String,
val rates: List<ConversionRate>,
val endpoints: List<String>,
val expected: ULong,
)
val cases = listOf(
Case("usd", "1000", listOf(ConversionRate("btc", "0.001")), listOf("btc-lightning-bolt11"), 100_000_000uL),
Case("usdt", "12.34", listOf(ConversionRate("btc", "0.00002")), listOf("btc-lightning-lnurl"), 24_680uL),
Case("btc", "0.001", listOf(ConversionRate("btc", "0.5")), listOf("btc-lightning-bolt11"), 50_000uL),
Case("btc", "0.001", listOf(ConversionRate("btc", "2")), listOf("btc-lightning-bolt11"), 200_000uL),
Case(
"btc",
"0.001",
listOf(ConversionRate("usdt", "1000")),
listOf("btc-regtest-p2wpkh", "usdt-ethereum-erc20"),
100_000uL,
),
Case(
"btc",
"0.001",
listOf(ConversionRate("btc", "2"), ConversionRate("btc-regtest", "0.5")),
listOf("btc-regtest-p2wpkh"),
50_000uL,
),
Case("usd", "1", listOf(ConversionRate("btc", "0.000000011")), listOf("btc-regtest-p2wpkh"), 2uL),
Case("usd", "1", listOf(ConversionRate("btc", "0.000000019999")), listOf("btc-lightning-bolt11"), 2uL),
Case(
"usd",
"1",
listOf(ConversionRate("btc", "0.00021")),
listOf("btc-regtest-p2wpkh", "btc-lightning-bolt11", "btc-lightning-lnurl"),
21_000uL,
),
)
cases.forEach {
val record = paymentRequestRecord(
amount = it.amount,
asset = it.asset,
conversion = PaymentConversion.Fixed(it.rates),
endpoints = it.endpoints,
)
val result = record.parseIncomingPaykitPaymentRequest(clock.now()) as PaykitPaymentRequestParseResult.Parsed
val request = result.request
assertEquals(it.expected, request.amountSats)
assertEquals(it.amount, request.amountValue)
assertEquals(it.asset, record.terms?.amount?.asset)
assertEquals(PaymentConversion.Fixed(it.rates), record.terms?.conversion)
assertTrue(request.acceptsPaymentAmount(it.expected))
assertFalse(request.acceptsPaymentAmount(it.expected.safe() + 1uL.safe()))
val expectedMsats = it.expected.safe() * 1000uL.safe()
assertTrue(request.acceptsLightningInvoiceAmountMsats(expectedMsats))
assertFalse(request.acceptsLightningInvoiceAmountMsats(expectedMsats.safe() + 1uL.safe()))
}
}

@Test
fun `fixed pricing rejects products beyond exact decimal precision`() {
val cases = listOf(
Triple("9." + "0".repeat(35) + "1", "0.00004", 36_001uL),
Triple("9." + "0".repeat(36) + "1", "0.00004", null),
Triple("9." + "0".repeat(36) + "1", "1", null),
Triple("0009." + "0".repeat(35) + "10", "0.0000400", 36_001uL),
)
cases.forEach { (amount, rate, expected) ->
val record = paymentRequestRecord(
amount = amount,
asset = "usd",
conversion = PaymentConversion.Fixed(listOf(ConversionRate("btc", rate))),
endpoints = listOf("btc-regtest-p2wpkh"),
)
val result = record.parseIncomingPaykitPaymentRequest(clock.now())
if (expected != null) {
assertEquals(expected, (result as PaykitPaymentRequestParseResult.Parsed).request.amountSats)
} else {
assertEquals(
PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.UnsupportedPricing),
result,
"$amount * $rate",
)
}
}
}

@Test
fun `fixed pricing omits unquoted cross asset rails`() {
val record = paymentRequestRecord(
amount = "1",
asset = "usd",
conversion = PaymentConversion.Fixed(listOf(ConversionRate("btc-regtest", "0.00021"))),
endpoints = listOf("btc-lightning-bolt11", "btc-regtest-p2wpkh", "btc-regtest-p2tr"),
)
val result = record.parseIncomingPaykitPaymentRequest(clock.now()) as PaykitPaymentRequestParseResult.Parsed
assertEquals(21_000uL, result.request.amountSats)
assertEquals(
listOf("btc-regtest-p2wpkh", "btc-regtest-p2tr"),
result.request.acceptedPaymentEndpointIdentifiers,
)
}

@Test
fun `unsupported fixed pricing cannot become payable`() {
val rates = listOf(
emptyList(), listOf(ConversionRate("usdt", "1")),
listOf(ConversionRate("btc", "0")), listOf(ConversionRate("btc", "-1")),
listOf(ConversionRate("btc", "1e-3")), listOf(ConversionRate("btc", " 1")),
listOf(ConversionRate("btc", "1\n")), listOf(ConversionRate("btc", "184467440738")),
listOf(ConversionRate("btc", "0.000000011")),
listOf(ConversionRate("btc", "0.123456789012345678901234567890123456789")),
listOf(ConversionRate("btc", "1"), ConversionRate("btc", "2")),
listOf(ConversionRate("btc", "1"), ConversionRate("btc-lightning", "2")),
)
(rates.map { PaymentConversion.Fixed(it) } + PaymentConversion.PerPeriod).forEach {
val record = paymentRequestRecord(
amount = "1",
asset = "usd",
conversion = it,
endpoints = listOf("btc-regtest-p2wpkh", "btc-lightning-bolt11"),
)
assertEquals(
PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.UnsupportedPricing),
record.parseIncomingPaykitPaymentRequest(clock.now()),
)
}
}

@Test
fun `conversion subscriptions remain unsupported`() {
val record = paymentRequestRecord(
conversion = PaymentConversion.Fixed(listOf(ConversionRate("btc", "0.5"))),
recurrence = PaymentRequestRecurrence(1u, "month", "2027-01-01T00:00:00Z", "2027-01-01T00:00:00Z", null),
)
assertNull(record.toPaykitSubscription())
}

private val paykitSdkService = mock<PaykitSdkService>()
private val settingsStore = mock<SettingsStore>()
private val presentationStore = mock<PaykitPaymentRequestPresentationStore>()
Expand Down Expand Up @@ -2371,6 +2512,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) {
state: PaymentRequestLifecycleState = PaymentRequestLifecycleState.PROPOSED,
amount: String = "0.001",
asset: String = "btc",
conversion: PaymentConversion? = null,
expiresAt: String? = null,
paymentDeadline: PaymentDeadline? = null,
endpoints: List<String> = listOf(MethodId.Bolt11.rawValue),
Expand All @@ -2393,7 +2535,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) {
proposalExpiresAt = expiresAt,
recurrence = recurrence,
acceptedPaymentEndpointIdentifiers = endpoints,
conversion = null,
conversion = conversion,
paymentDeadline = paymentDeadline,
metadata = metadata,
paymentEndpoints = null,
Expand Down
Loading
Loading