Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
1 change: 1 addition & 0 deletions app/src/main/java/to/bitkit/data/keychain/Keychain.kt
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ class Keychain @Inject constructor(
PAYKIT_SESSION,
PAYKIT_RECEIVER_NOISE_SECRET_KEY,
PAYKIT_SDK_STATE,
PAYKIT_PENDING_PAYMENT_PROOFS,
PAYKIT_PRESENTED_PAYMENT_REQUESTS,
PUBKY_SECRET_KEY,
}
Expand Down
391 changes: 391 additions & 0 deletions app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package to.bitkit.repositories

import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import to.bitkit.data.keychain.Keychain
import to.bitkit.utils.Logger
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class PaykitPaymentProofStore @Inject constructor(
private val keychain: Keychain,
) {
companion object {
private const val TAG = "PaykitPaymentProofStore"
private val KEY = Keychain.Key.PAYKIT_PENDING_PAYMENT_PROOFS.name
}

@Serializable
private data class State(
val proofs: List<PendingPaykitPaymentProof> = emptyList(),
)

fun load(): List<PendingPaykitPaymentProof> {
val value = keychain.loadString(KEY) ?: return emptyList()
return runCatching { Json.decodeFromString<State>(value).proofs }
.getOrElse {
Logger.warn("Discarded corrupt pending Paykit payment proof state", it, context = TAG)
emptyList()
}
}

suspend fun save(proofs: List<PendingPaykitPaymentProof>) {
if (proofs.isEmpty()) {
keychain.delete(KEY)
} else {
keychain.upsertString(KEY, Json.encodeToString(State(proofs)))
}
}

fun hasPendingProofs(): Boolean = keychain.exists(KEY)
}
25 changes: 25 additions & 0 deletions app/src/main/java/to/bitkit/services/PaykitSdkService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import com.synonym.paykit.PaykitSdk
import com.synonym.paykit.PaykitSdkDefaults
import com.synonym.paykit.PaymentAmountContext
import com.synonym.paykit.PaymentPayload
import com.synonym.paykit.PaymentProofSubmission
import com.synonym.paykit.PaymentReference
import com.synonym.paykit.PaymentRequestAmount
import com.synonym.paykit.PaymentRequestFilter
Expand Down Expand Up @@ -654,6 +655,30 @@ class PaykitSdkService @Inject constructor(
}
}

suspend fun submitPaymentProof(
counterparty: String,
counterpartyReceiverPath: String,
paymentRequestId: String,
paymentEndpointIdentifier: String,
proofJson: String,
): PaymentRequestRecord {
isSetup.await()
return operationMutex.withLock {
withStateRevisionTracking { handle ->
handle.submitPaymentProof(
counterparty,
counterpartyReceiverPath,
paymentRequestId,
PaymentProofSubmission(
billingPeriod = null,
paymentEndpointIdentifier = paymentEndpointIdentifier,
proof = PrivateJsonObject(proofJson),
),
)
}
}
}

suspend fun rejectPaymentRequest(
counterparty: String,
counterpartyReceiverPath: String,
Expand Down
86 changes: 85 additions & 1 deletion app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,10 @@ import to.bitkit.repositories.HealthRepo
import to.bitkit.repositories.HwWalletRepo
import to.bitkit.repositories.LightningRepo
import to.bitkit.repositories.LnurlPayInvoiceMismatchError
import to.bitkit.repositories.MethodId
import to.bitkit.repositories.NodeEventUpdate
import to.bitkit.repositories.PaykitPaymentProofKind
import to.bitkit.repositories.PaykitPaymentProofRepo
import to.bitkit.repositories.PaykitPaymentRequest
import to.bitkit.repositories.PaykitPaymentRequestCreation
import to.bitkit.repositories.PaykitPaymentRequestDraft
Expand Down Expand Up @@ -238,6 +241,7 @@ class AppViewModel @Inject constructor(
private val publicPaykitRepo: PublicPaykitRepo,
private val privatePaykitRepo: PrivatePaykitRepo,
private val paykitPaymentRequestRepo: PaykitPaymentRequestRepo,
private val paykitPaymentProofRepo: PaykitPaymentProofRepo,
private val refreshContactPaykitReceivers: RefreshContactPaykitReceiversUseCase,
private val samRockRepo: SamRockRepo,
private val appUpdateSheet: AppUpdateTimedSheet,
Expand Down Expand Up @@ -724,6 +728,7 @@ class AppViewModel @Inject constructor(

private suspend fun refreshIncomingPaykitPaymentRequests(): Boolean {
if (!isPaykitEnabled.value || pubkyRepo.publicKey.value == null || !walletRepo.walletExists()) return false
paykitPaymentProofRepo.reconcile()
Comment thread
ben-kaufman marked this conversation as resolved.
val previousRequests = paykitPaymentRequestRepo.pendingRequests.value
return paykitPaymentRequestRepo.refresh().fold(
onSuccess = {
Expand Down Expand Up @@ -1356,6 +1361,7 @@ class AppViewModel @Inject constructor(
)
val paymentHash = event.paymentHash ?: outcome.invoicePaymentHash ?: event.paymentId
if (paymentHash != null) {
viewModelScope.launch { paykitPaymentProofRepo.failLightningPayment(paymentHash) }
refreshPaymentActivity(paymentHash)
if (pendingPaymentRepo.isPending(paymentHash)) {
clearPendingContactPaymentContext(paymentHash)
Expand Down Expand Up @@ -1439,6 +1445,9 @@ class AppViewModel @Inject constructor(

private suspend fun handlePaymentSuccessful(event: Event.PaymentSuccessful) {
val paymentHash = event.paymentHash
viewModelScope.launch {
paykitPaymentProofRepo.completeLightningPayment(paymentHash, event.paymentPreimage)
}
val isQuickPay = quickPayRepo.signalCompletion(
paymentId = event.paymentId,
paymentHash = paymentHash,
Expand Down Expand Up @@ -2786,6 +2795,7 @@ class AppViewModel @Inject constructor(
return
}

_sendUiState.update { it.copy(isAmountInputValid = validateAmount(amount)) }
Comment thread
ben-kaufman marked this conversation as resolved.
navigateToSendRoute(fromMainScanner, SendRoute.Confirm, SendEffect.NavigateToConfirm)
refreshOnchainSendIfNeeded()
estimateLightningRoutingFeesIfNeeded()
Expand Down Expand Up @@ -3325,18 +3335,29 @@ class AppViewModel @Inject constructor(
}
}

@Suppress("LongMethod")
@Suppress("LongMethod", "ReturnCount")
private suspend fun proceedWithPayment(contactPaymentContext: ContactPaymentContext?) {
delay(SCREEN_TRANSITION_DELAY) // wait for screen transitions when applicable

if (!validateIncomingPaymentRequest(contactPaymentContext)) return

val incomingPaymentRequest = contactPaymentContext?.incomingPaymentRequest
var preparedPaymentProofRequest = preparePaymentProof(incomingPaymentRequest).fold(
onSuccess = { it },
onFailure = {
handlePaymentPreparationFailure(it)
return
},
Comment thread
ben-kaufman marked this conversation as resolved.
)

consumePrivatePaymentListIfNeeded(contactPaymentContext).onFailure {
cancelPaymentProofPreparation(preparedPaymentProofRequest)
handlePaymentPreparationFailure(it)
return
}

acceptIncomingPaymentRequestIfNeeded(contactPaymentContext).onFailure {
cancelPaymentProofPreparation(preparedPaymentProofRequest)
handlePaymentPreparationFailure(it)
return
}
Expand All @@ -3357,6 +3378,7 @@ class AppViewModel @Inject constructor(
it.copy(decodedInvoice = invoice)
}
}.onFailure {
cancelPaymentProofPreparation(preparedPaymentProofRequest)
val message = getLnurlInvoiceFetchErrorMessage(it)
toast(Exception(message))
hideSheet()
Expand All @@ -3370,6 +3392,8 @@ class AppViewModel @Inject constructor(
val tags = _sendUiState.value.selectedTags
sendOnchain(address, amount, tags = tags)
.onSuccess { txId ->
preparedPaymentProofRequest = null
completeOnchainPaymentProof(incomingPaymentRequest, txId)
Logger.info("Onchain send result txid: $txId", context = TAG)
onSendSuccess(
NewTransactionSheetDetails(
Expand All @@ -3384,6 +3408,7 @@ class AppViewModel @Inject constructor(
activityRepo.syncActivities()
_successSendUiState.update { it.copy(isLoadingDetails = false) }
}.onFailure { e ->
cancelPaymentProofPreparation(preparedPaymentProofRequest)
Logger.error("Error sending onchain payment", e, context = TAG)
toast(
type = Toast.ToastType.ERROR,
Expand All @@ -3406,6 +3431,11 @@ class AppViewModel @Inject constructor(

// Extract payment hash from invoice for pre-activity metadata
val paymentHash = decodedInvoice.paymentHash.toHex()
associateLightningPaymentProof(incomingPaymentRequest, paymentHash).onFailure {
Comment thread
ben-kaufman marked this conversation as resolved.
cancelPaymentProofPreparation(preparedPaymentProofRequest)
handlePaymentPreparationFailure(it)
return
}

// Create pre-activity metadata before sending
if (tags.isNotEmpty()) {
Expand All @@ -3421,6 +3451,7 @@ class AppViewModel @Inject constructor(
}

sendLightning(bolt11, paymentAmount).onSuccess { actualPaymentHash ->
preparedPaymentProofRequest = null
Logger.info("Lightning send result payment hash: $actualPaymentHash", context = TAG)
onSendSuccess(
NewTransactionSheetDetails(
Expand All @@ -3432,12 +3463,15 @@ class AppViewModel @Inject constructor(
)
}.onFailure {
if (it is PaymentPendingException) {
preparedPaymentProofRequest = null
Logger.info("Lightning payment pending", context = TAG)
pendingPaymentRepo.track(it.paymentHash)
preserveContactPaymentContext(it.paymentHash)
setSendEffect(SendEffect.NavigateToPending(it.paymentHash, displayAmountSats.toLong()))
return@onFailure
}
paykitPaymentProofRepo.failLightningPayment(paymentHash)
cancelPaymentProofPreparation(preparedPaymentProofRequest)
// Delete pre-activity metadata on failure
if (createdMetadataPaymentId != null) {
preActivityMetadataRepo.deletePreActivityMetadata(createdMetadataPaymentId)
Expand Down Expand Up @@ -3480,6 +3514,51 @@ class AppViewModel @Inject constructor(
return true
}

private suspend fun preparePaymentProof(request: PaykitPaymentRequest?): Result<PaykitPaymentRequest?> {
if (request == null) return Result.success(null)
val preparation = paymentProofPreparation()
return paykitPaymentProofRepo.prepare(
request = request,
paymentEndpointIdentifier = preparation.endpointIdentifier,
kind = preparation.kind,
).map { request }
}

private suspend fun associateLightningPaymentProof(
request: PaykitPaymentRequest?,
paymentHash: String,
): Result<Unit> = request?.let { paykitPaymentProofRepo.associateLightningPayment(it, paymentHash) }
?: Result.success(Unit)

private suspend fun completeOnchainPaymentProof(request: PaykitPaymentRequest?, txId: String) {
request?.let {
paykitPaymentProofRepo.completeOnchainPayment(
request = it,
txid = txId,
paymentEndpointIdentifier = paymentProofPreparation().endpointIdentifier,
)
}
}

private suspend fun cancelPaymentProofPreparation(request: PaykitPaymentRequest?) {
request?.let { paykitPaymentProofRepo.cancelPreparation(it) }
}

private fun paymentProofPreparation(): PaymentProofPreparation {
val methodId = when (_sendUiState.value.payMethod) {
SendMethod.ONCHAIN -> PublicPaykitRepo.onchainMethodId(_sendUiState.value.address)
SendMethod.LIGHTNING -> if (_sendUiState.value.lnurl is LnurlParams.LnurlPay) {
MethodId.Lnurl
} else {
MethodId.Bolt11
}
}
return PaymentProofPreparation(
endpointIdentifier = methodId.rawValue,
kind = if (methodId.isOnchain) PaykitPaymentProofKind.Onchain else PaykitPaymentProofKind.Lightning,
)
}

private suspend fun hasMismatchedIncomingPaymentRequest(contactPaymentContext: ContactPaymentContext?): Boolean {
val incomingPaymentRequest = contactPaymentContext?.incomingPaymentRequest ?: return false
if (!incomingPaymentRequest.acceptsPaymentAmount(_sendUiState.value.amount)) return true
Expand Down Expand Up @@ -4773,6 +4852,11 @@ data class ContactPaymentContext(
val incomingPaymentRequest: PaykitPaymentRequest? = null,
)

private data class PaymentProofPreparation(
val endpointIdentifier: String,
val kind: PaykitPaymentProofKind,
)

private data class PaykitContactSyncState(
val publicKey: String?,
val contactKeys: Set<String>,
Expand Down
Loading
Loading