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
12 changes: 12 additions & 0 deletions app/src/main/java/to/bitkit/ext/PaykitExceptionExt.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ package to.bitkit.ext

import com.synonym.paykit.PaykitException

/** Protocol error code for an outbound Private Application Message larger than one pubky-noise message. */
private const val MESSAGE_TOO_LARGE_CODE = "message_too_large"

/** Identity error code for a signup code the homeserver refused during sign-up. */
private const val SIGNUP_CODE_REJECTED_CODE = "signup_code_rejected"

fun Throwable.isPaykitIdentityError(): Boolean =
generateSequence(this) { it.cause }.any { it is PaykitException.Identity }

Expand All @@ -13,3 +19,9 @@ fun Throwable.isPaykitTemporarilyUnavailable(): Boolean =
it is PaykitException.ConcurrentUpdate || it is PaykitException.SharedStateBusy ||
it is PaykitException.Transport
}

fun Throwable.isPaykitMessageTooLarge(): Boolean =
generateSequence(this) { it.cause }.any { it is PaykitException.Protocol && it.code == MESSAGE_TOO_LARGE_CODE }

fun Throwable.isPaykitSignupCodeRejected(): Boolean =
generateSequence(this) { it.cause }.any { it is PaykitException.Identity && it.code == SIGNUP_CODE_REJECTED_CODE }
5 changes: 3 additions & 2 deletions app/src/main/java/to/bitkit/models/PaykitUsdtStateBackup.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,17 @@ data class PaykitUsdtStateBackup(
val receivedAtMillis: Long,
val underpaid: Boolean,
val afterExpiry: Boolean,
val beforeRequest: Boolean = false,
) {
constructor(value: PaykitUsdtReceipt) : this(
value.wallet, value.identity, value.requestId, value.paymentId, value.proofEventId,
value.verified, value.transferId, value.amount.atomic, value.receivedAtMillis,
value.underpaid, value.afterExpiry,
value.underpaid, value.afterExpiry, value.beforeRequest,
)

fun restored() = PaykitUsdtReceipt(
wallet, identity, requestId, paymentId, proofEventId, verified, transferId,
PaykitAmount(PaykitAsset.USDT, amountAtomic), receivedAtMillis, underpaid, afterExpiry,
PaykitAmount(PaykitAsset.USDT, amountAtomic), receivedAtMillis, underpaid, afterExpiry, beforeRequest,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -710,6 +710,22 @@ class PaykitAllowanceExecutor @Inject constructor(
Logger.info("Kept an incoming request manual: the payee offers no Lightning invoice", context = TAG)
return PaykitAllowanceAutoPayResult.MANUAL
}
// Measured before the acceptance, so a proof that can never be delivered is never accepted on the payee's side.
val proofFits = runSuspendCatching {
paykitSdkService.requirePaymentProofFits(
paymentReference = request.paymentReference,
paymentAppId = payment.appId,
paymentEndpointIdentifier = payment.endpoint.methodId.rawValue,
kind = PaykitPaymentProofKind.Lightning,
billingPeriod = request.billingPeriod,
conversionQuoteId = null,
allowanceId = candidate.allowanceId,
)
}.isSuccess
if (!proofFits) {
Logger.info("Kept an incoming request manual: its payment proof would not fit one message", context = TAG)
return PaykitAllowanceAutoPayResult.MANUAL
}

if (!admits(activation)) {
Logger.info("Kept an incoming request manual: allowances turned off before acceptance", context = TAG)
Expand Down
75 changes: 75 additions & 0 deletions app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package to.bitkit.repositories

import com.synonym.bitkitcore.UsdtPaymentProof
import com.synonym.paykit.BillingPeriod
import com.synonym.paykit.PaymentProofRecord
import com.synonym.paykit.PaymentRequestLifecycleState
Expand Down Expand Up @@ -41,6 +42,7 @@ import to.bitkit.ext.toHex
import to.bitkit.models.HwFundingSignedTx
import to.bitkit.models.PaykitPaymentStateBackup
import to.bitkit.models.PaykitRequestPayment
import to.bitkit.models.PaykitUsdt
import to.bitkit.models.PubkyPublicKeyFormat
import to.bitkit.models.WalletScope
import to.bitkit.services.PaykitSdkOperationLock
Expand Down Expand Up @@ -203,6 +205,15 @@ class PaykitPaymentProofRepo @Inject constructor(
) {
throw PaykitPaymentRequestError.RequestUnavailable
}
paykitSdkService.requirePaymentProofFits(
paymentReference = request.paymentReference,
paymentAppId = paymentAppId,
paymentEndpointIdentifier = paymentEndpointIdentifier,
kind = kind,
billingPeriod = request.billingPeriod,
conversionQuoteId = paymentTerms?.quoteId,
allowanceId = allowanceId,
)
val read = paykitSdkService.readPaymentRequests(request.counterparty)
val identityStatus = read.identityStatus
val identity = identityStatus?.publicKey?.let(PubkyPublicKeyFormat::normalized)
Expand Down Expand Up @@ -1684,6 +1695,70 @@ private fun proofJson(kind: PaykitPaymentProofKind, data: String): String {
}.toString()
}

/** Hex length of the 32-byte preimage or transaction id a Bitcoin proof carries. */
private const val BITCOIN_PROOF_DATA_LENGTH = 64

/** Hex digits of a 32-byte ERC-20 transaction hash after its `0x` prefix. */
private const val USDT_TRANSACTION_HASH_DIGITS = 64

/** Hex digits of a 65-byte EIP-712 signature after its `0x` prefix. */
private const val USDT_SIGNATURE_DIGITS = 130

/** The largest uint256 receipt log index, the longest one the SDK accepts in an ERC-20 proof. */
internal const val USDT_MAX_RECEIPT_LOG_INDEX =
"115792089237316195423570985008687907853269984665640564039457584007913129639935"

/** The longest proof Bitkit submits for this kind, so a measured proof is never shorter than the one sent. */
internal fun PaykitPaymentProofKind.longestProofJson(): String = when (this) {
PaykitPaymentProofKind.Usdt -> Json.encodeToString(
PaykitUsdtProof.serializer(),
PaykitUsdtProof(
UsdtPaymentProof(
chainId = PaykitUsdt.CHAIN_ID,
transactionHash = "0x" + "0".repeat(USDT_TRANSACTION_HASH_DIGITS),
receiptLogIndex = USDT_MAX_RECEIPT_LOG_INDEX,
signature = "0x" + "0".repeat(USDT_SIGNATURE_DIGITS),
),
),
)
PaykitPaymentProofKind.Lightning, PaykitPaymentProofKind.Onchain ->
proofJson(this, "0".repeat(BITCOIN_PROOF_DATA_LENGTH))
}

/** Log context for measuring a Payment Proof before payment. */
private const val PROOF_SIZE_TAG = "PaykitProofSize"

/**
* Fails with [PaykitPaymentRequestError.RequestUnavailable] when the longest proof of [kind] for these values would
* not fit one Private Application Message, or cannot be measured, so nothing is paid for a proof that could never be
* delivered.
*/
internal fun PaykitSdkService.requirePaymentProofFits(
paymentReference: String,
paymentAppId: String,
paymentEndpointIdentifier: String,
kind: PaykitPaymentProofKind,
billingPeriod: PaykitBillingPeriod?,
conversionQuoteId: String?,
allowanceId: String?,
) {
val length = runCatching {
paymentProofEventLength(
paymentReference = paymentReference,
paymentAppId = paymentAppId,
paymentEndpointIdentifier = paymentEndpointIdentifier,
proofJson = kind.longestProofJson(),
billingPeriod = billingPeriod,
conversionQuoteId = conversionQuoteId,
allowanceId = allowanceId,
)
}.getOrElse {
Logger.warn("Failed to measure a Paykit payment proof", it, context = PROOF_SIZE_TAG)
throw PaykitPaymentRequestError.RequestUnavailable
}
if (length > PaykitSdkService.MAX_MESSAGE_BYTES.toUInt()) throw PaykitPaymentRequestError.RequestUnavailable
}

private fun String.proofValues(): JsonObject? = runCatching { Json.parseToJsonElement(this).jsonObject }.getOrNull()

private fun String.matchesPaymentHash(paymentHash: String): Boolean {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import to.bitkit.data.SettingsStore
import to.bitkit.di.IoDispatcher
import to.bitkit.di.SubscriptionClock
import to.bitkit.env.Env
import to.bitkit.ext.isPaykitMessageTooLarge
import to.bitkit.ext.runSuspendCatching
import to.bitkit.flags.PaykitFeatureFlags
import to.bitkit.models.PaykitAmount
Expand Down Expand Up @@ -307,6 +308,7 @@ sealed class PaykitPaymentRequestError(message: String) : AppError(message) {
data object RequestExpired : PaykitPaymentRequestError("Payment request has expired")
data object OperationInProgress : PaykitPaymentRequestError("Payment request operation is already in progress")
data object SubscriptionTooLong : PaykitPaymentRequestError("Subscription proposal exceeds the message size limit")
data object RequestTooLong : PaykitPaymentRequestError("Payment request proposal exceeds the message size limit")
}

enum class PaykitPaymentRequestRefreshMode {
Expand Down Expand Up @@ -898,10 +900,11 @@ class PaykitPaymentRequestRepo @Inject constructor(
proposalDate
)
actionVersion++
val record = paykitSdkService.proposePaymentRequest(
val record = proposeWithinMessageLimit(
counterparty = target.publicKey,
proposal = proposal,
expectedIdentity = expectedIdentity,
tooLong = PaykitPaymentRequestError.RequestTooLong,
)
val reports = processPendingMessages(target.publicKey)

Expand All @@ -924,6 +927,19 @@ class PaykitPaymentRequestRepo @Inject constructor(
}
}

private suspend fun proposeWithinMessageLimit(
counterparty: String,
proposal: PaykitPaymentRequestProposalTerms,
expectedIdentity: String,
tooLong: PaykitPaymentRequestError,
): PaymentRequestRecord = runSuspendCatching {
paykitSdkService.proposePaymentRequest(
counterparty = counterparty,
proposal = proposal,
expectedIdentity = expectedIdentity,
)
}.getOrElse { throw if (it.isPaykitMessageTooLarge()) tooLong else it }

private fun buildPaymentRequestProposal(
draft: PaykitPaymentRequestDraft,
endpointIdentifiers: List<String>,
Expand Down Expand Up @@ -996,10 +1012,11 @@ class PaykitPaymentRequestRepo @Inject constructor(
val proposal = buildSubscriptionProposal(draft, name, description, iconUri, endpoints, clock.now())
PaykitSubscriptionProposal.validate(proposal)
actionVersion++
val record = paykitSdkService.proposePaymentRequest(
val record = proposeWithinMessageLimit(
counterparty = target.publicKey,
proposal = proposal,
expectedIdentity = expectedIdentity,
tooLong = PaykitPaymentRequestError.SubscriptionTooLong,
)
val reports = processPendingMessages(target.publicKey)
val deliveryStatus = if (proposalWasSent(record, reports, generation, expectedIdentity)) {
Expand Down
65 changes: 27 additions & 38 deletions app/src/main/java/to/bitkit/repositories/PaykitUsdtPaymentRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,6 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import to.bitkit.data.keychain.Keychain
import to.bitkit.di.IoDispatcher
import to.bitkit.ext.runSuspendCatching
Expand All @@ -47,6 +44,7 @@ import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Clock
import kotlin.time.Duration.Companion.hours
import kotlin.time.ExperimentalTime
import kotlin.time.Instant

Expand Down Expand Up @@ -95,8 +93,10 @@ data class PaykitUsdtReceipt(
val receivedAtMillis: Long,
val underpaid: Boolean,
val afterExpiry: Boolean,
/** Paid earlier than the payee recorded its own proposal, so it does not count as paying the request. */
val beforeRequest: Boolean = false,
) {
val satisfied: Boolean get() = verified && !underpaid && !afterExpiry
val satisfied: Boolean get() = verified && !underpaid && !afterExpiry && !beforeRequest

fun matches(request: PaykitPaymentRequest): Boolean =
requestId == request.id && proofEventId == request.paymentProofEventId
Expand All @@ -115,6 +115,12 @@ class PaykitUsdtPaymentRepo @Inject constructor(
private val KEY = Keychain.Key.PAYKIT_USDT_PAYMENTS.name
private const val TAG = "PaykitUsdtPaymentRepo"

/**
* How far an Arbitrum block timestamp may trail the payee's clock. The sequencer may set timestamps up to a
* day in the past, so a transfer counts as earlier than the request only beyond this margin.
*/
internal val PAYMENT_TIME_TOLERANCE = 24.hours

fun binding(
request: PaykitPaymentRequest,
identity: String,
Expand All @@ -138,39 +144,6 @@ class PaykitUsdtPaymentRepo @Inject constructor(
conversionQuoteId = quoteId.orEmpty(),
)
}

fun validateProofSize(binding: UsdtPaymentProofBinding) {
val profile =
PaykitUsdtProof(
UsdtPaymentProof("42161", "0x" + "0".repeat(64), "9".repeat(78), "0x" + "0".repeat(130))
)
val message = buildJsonObject {
put("version", 1)
put("kind", "paykit.payment_proof")
put("app_id", "bitkit")
put("event_id", UUID.randomUUID().toString())
put("payment_request_id", binding.paymentRequestId)
put("payment_reference", binding.paymentReference)
put("payment_app_id", binding.paymentAppId)
put("payment_endpoint_identifier", binding.paymentEndpointIdentifier)
put(
"billing_period",
if (binding.periodStartsAt.isEmpty()) {
JsonNull
} else {
buildJsonObject {
put("starts_at", binding.periodStartsAt)
put("ends_at", binding.periodEndsAt)
}
}
)
if (binding.conversionQuoteId.isNotEmpty()) put("conversion_quote_id", binding.conversionQuoteId)
put("proof", Json.encodeToJsonElement(PaykitUsdtProof.serializer(), profile))
}
if (message.toString().toByteArray(Charsets.UTF_8).size > PaykitSdkService.MAX_MESSAGE_BYTES) {
throw PaykitPaymentRequestError.RequestUnavailable
}
}
}

@Serializable
Expand Down Expand Up @@ -433,7 +406,17 @@ class PaykitUsdtPaymentRepo @Inject constructor(
it.billingPeriod?.sdkValue
)
}
binding?.let(::validateProofSize)
binding?.let {
sdk.requirePaymentProofFits(
paymentReference = it.paymentReference,
paymentAppId = it.paymentAppId,
paymentEndpointIdentifier = it.paymentEndpointIdentifier,
kind = PaykitPaymentProofKind.Usdt,
billingPeriod = request.billingPeriod,
conversionQuoteId = it.conversionQuoteId.takeIf(String::isNotEmpty),
allowanceId = null,
)
}
val attempt =
PaykitUsdtAttempt(
quote.id,
Expand Down Expand Up @@ -665,6 +648,11 @@ class PaykitUsdtPaymentRepo @Inject constructor(
updated
}

private fun PaymentRequestRecord.isPaidBeforeProposal(paidAt: Instant): Boolean {
val proposedAt = proposalRecordedAt?.let { runCatching { Instant.parse(it) }.getOrNull() } ?: return false
return paidAt < proposedAt - PAYMENT_TIME_TOLERANCE
}

private fun PaykitUsdtAttempt.bindingState() = copy(proof = null, proofQueued = false, paymentStarted = false)

private suspend fun reconcileReceipts(reconciliation: Reconciliation) {
Expand Down Expand Up @@ -742,6 +730,7 @@ class PaykitUsdtPaymentRepo @Inject constructor(
receivedAtMillis = receivedAt.toEpochMilliseconds(),
underpaid = amount.atomic < terms.amount.atomic,
afterExpiry = !terms.isValid(receivedAt),
beforeRequest = record.isPaidBeforeProposal(receivedAt),
)
mutex.withLock {
if (!isCurrent(reconciliation)) return@withLock
Expand Down
26 changes: 26 additions & 0 deletions app/src/main/java/to/bitkit/services/PaykitSdkService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ import com.synonym.paykit.defaultConfig
import com.synonym.paykit.defaultPubkyClientConfig
import com.synonym.paykit.parsePubkyAuthUrl
import com.synonym.paykit.paykitAuthorizerSessionCapabilities
import com.synonym.paykit.paymentProofEventLen
import com.synonym.paykit.pubkyPublicKeyFromSecret
import com.synonym.paykit.pubkySecretKeyFromBip39Mnemonic
import dagger.hilt.android.qualifiers.ApplicationContext
Expand Down Expand Up @@ -1297,6 +1298,31 @@ class PaykitSdkService @Inject constructor(
}
}

/**
* Byte length of the Payment Proof Event Message [submitPaymentProof] would queue for these values. The SDK builds
* it with the code it queues with, so a payer can check it against [MAX_MESSAGE_BYTES] before paying.
*/
fun paymentProofEventLength(
paymentReference: String,
paymentAppId: String,
paymentEndpointIdentifier: String,
proofJson: String,
billingPeriod: PaykitBillingPeriod? = null,
conversionQuoteId: String? = null,
allowanceId: String? = null,
): UInt = PaymentReference(paymentReference).use { reference ->
PaymentProofSubmission(
billingPeriod = billingPeriod?.sdkValue,
paymentAppId = paymentAppId,
paymentEndpointIdentifier = paymentEndpointIdentifier,
allowanceId = allowanceId,
conversionQuoteId = conversionQuoteId,
proof = PrivateJsonObject(proofJson),
).use { submission ->
paymentProofEventLen(localAppId = "bitkit", paymentReference = reference, submission = submission)
}
}

suspend fun listAllowances(filter: AllowanceFilter): List<AllowanceRecord> {
isSetup.await()
return operationLock.withLock {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -820,6 +820,7 @@ private fun PaymentRequestsPreview() {
internal fun paykitUsdtReceiptText(receipt: PaykitUsdtReceipt): String {
val label = when {
!receipt.verified -> R.string.wallet__payment_request_pending
receipt.beforeRequest -> R.string.wallet__payment_request_before_request
receipt.underpaid -> R.string.wallet__payment_request_underpaid
receipt.afterExpiry -> R.string.wallet__payment_request_after_expiry
else -> R.string.wallet__payment_request_payment_verified
Expand Down
Loading
Loading