TM-SGNL-iOS/SignalUI/Payments/PaymentsProcessor.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

811 lines
35 KiB
Swift

//
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import MobileCoin
public import SignalServiceKit
public class PaymentsProcessor: NSObject {
private let appReadiness: AppReadiness
public init(appReadiness: AppReadiness) {
self.appReadiness = appReadiness
super.init()
appReadiness.runNowOrWhenAppDidBecomeReadySync {
DependenciesBridge.shared.databaseChangeObserver.appendDatabaseChangeDelegate(self)
}
appReadiness.runNowOrWhenAppDidBecomeReadyAsync {
self.process()
}
NotificationCenter.default.addObserver(self,
selector: #selector(process),
name: SSKReachability.owsReachabilityDidChange,
object: nil)
NotificationCenter.default.addObserver(self,
selector: #selector(process),
name: .OWSApplicationDidBecomeActive,
object: nil)
NotificationCenter.default.addObserver(self,
selector: #selector(process),
name: PaymentsConstants.arePaymentsEnabledDidChange,
object: nil)
}
private static let unfairLock = UnfairLock()
// Each instance of PaymentProcessingOperation represents
// an attempt to usher a payment "one step forward" in the
// processing state machine.
//
// On success, a PaymentProcessingOperation will enqueue
// a new PaymentProcessingOperation to continue the process
// until the payment is complete or failed.
//
// If a PaymentProcessingOperation fails but can be retried,
// we'll enqueue a new PaymentProcessingOperation (possibly
// after a retry delay).
//
// We use this collection to ensure that we don't process
// a payment that is already being processed via a sequence
// of PaymentProcessingOperations.
//
// This should only be accessed via unfairLock.
private var processingPaymentIds = Set<String>()
private let highPriorityProcessingQueue = SerialTaskQueue()
private let defaultProcessingQueue = SerialTaskQueue()
private func processingQueue(forPaymentModel paymentModel: TSPaymentModel) -> SerialTaskQueue {
if paymentModel.isOutgoing, !paymentModel.isVerified {
return highPriorityProcessingQueue
} else {
return defaultProcessingQueue
}
}
// This method tries to process every "unresolved" transaction.
//
// For incoming transactions, this involves verification.
//
// For outgoing transactions, this involves verification,
// sending a notification message, etc.
//
// We need to ensure that there's only one operation for a given
// payment record at a time. This processor works in batches.
// A given payment record's operation will make a best effort to
// resolve that payment record until it is complete or failed,
// although the operations may fail to do so.
@objc
public func process() {
DispatchQueue.global().async { [appReadiness] in
guard !CurrentAppContext().isRunningTests else {
return
}
guard SSKEnvironment.shared.paymentsHelperRef.arePaymentsEnabled else {
return
}
guard
appReadiness.isAppReady,
CurrentAppContext().isMainAppAndActive,
DependenciesBridge.shared.tsAccountManager.registrationStateWithMaybeSneakyTransaction.isRegistered
else {
return
}
guard !DebugFlags.paymentsHaltProcessing.get() else {
return
}
// Kick of processing for any payments that need
// processing but are not yet being processed.
self.buildProcessingOperations()
}
}
private func buildProcessingOperations() {
// Find all unresolved payment records.
var paymentModels: [TSPaymentModel] = SSKEnvironment.shared.databaseStorageRef.read { transaction in
PaymentFinder.paymentModels(paymentStates: Array(Self.paymentStatesToProcess),
transaction: transaction)
}
paymentModels.sort { (left, right) -> Bool in
left.sortDate.compare(right.sortDate) == .orderedAscending
}
// Create a new operation for any payment that needs to be
// processed that we're not already processing.
let delegate: PaymentProcessingOperationDelegate = self
Self.unfairLock.withLock { [paymentModels] in
for paymentModel in paymentModels {
let paymentId = paymentModel.uniqueId
// Don't add an operation if we're already processing this payment model.
guard !self.processingPaymentIds.contains(paymentId) else {
continue
}
self.processingPaymentIds.insert(paymentId)
let operation = PaymentProcessingOperation(delegate: delegate, paymentModel: paymentModel)
processingQueue(forPaymentModel: paymentModel).enqueue { await operation.run() }
}
}
}
// The list of payment states that _do not_ need processing.
private static var paymentStatesToIgnore: Set<TSPaymentState> {
Set([
.outgoingComplete,
.outgoingFailed,
.incomingComplete,
.incomingFailed
])
}
// The list of payment states that _do_ need processing.
private static var paymentStatesToProcess: Set<TSPaymentState> {
Set([
.outgoingUnsubmitted,
.outgoingUnverified,
.outgoingVerified,
.outgoingSending,
.outgoingSent,
.incomingUnverified,
.incomingVerified
])
}
// MARK: - RetryScheduler
// Retries occur after a fixed delay (e.g. per exponential backoff)
// but this should short-circuit if reachability becomes available.
fileprivate class RetryScheduler: NSObject {
private let paymentModel: TSPaymentModel
private let nextRetryDelayInteral: TimeInterval
private weak var delegate: PaymentProcessingOperationDelegate?
private let hasScheduled = AtomicBool(false, lock: .sharedGlobal)
private var timer: Timer?
var paymentId: String { paymentModel.uniqueId }
init(paymentModel: TSPaymentModel,
retryDelayInteral: TimeInterval,
nextRetryDelayInteral: TimeInterval,
delegate: PaymentProcessingOperationDelegate) {
self.paymentModel = paymentModel
self.nextRetryDelayInteral = nextRetryDelayInteral
self.delegate = delegate
super.init()
DispatchQueue.global().asyncAfter(deadline: .now() + retryDelayInteral) { [weak self] in
self?.tryToSchedule()
}
NotificationCenter.default.addObserver(self,
selector: #selector(reachabilityChanged),
name: SSKReachability.owsReachabilityDidChange,
object: nil)
}
@objc
private func reachabilityChanged() {
AssertIsOnMainThread()
guard SSKEnvironment.shared.reachabilityManagerRef.isReachable else {
return
}
tryToSchedule()
}
private func tryToSchedule() {
guard hasScheduled.tryToSetFlag() else {
return
}
timer?.invalidate()
timer = nil
delegate?.retryProcessing(paymentModel: paymentModel,
nextRetryDelayInteral: nextRetryDelayInteral)
}
}
private var retrySchedulerMap = [String: RetryScheduler]()
private func add(retryScheduler: RetryScheduler) {
Self.unfairLock.withLock {
retrySchedulerMap[retryScheduler.paymentId] = retryScheduler
}
}
private func remove(retryScheduler: RetryScheduler) {
Self.unfairLock.withLock {
_ = retrySchedulerMap.removeValue(forKey: retryScheduler.paymentId)
}
}
}
// MARK: -
extension PaymentsProcessor: DatabaseChangeDelegate {
public func databaseChangesDidUpdate(databaseChanges: DatabaseChanges) {
owsAssertDebug(appReadiness.isAppReady)
guard databaseChanges.didUpdate(tableName: TSPaymentModel.table.tableName) else {
return
}
process()
}
public func databaseChangesDidUpdateExternally() {
owsAssertDebug(appReadiness.isAppReady)
process()
}
public func databaseChangesDidReset() {
owsAssertDebug(appReadiness.isAppReady)
process()
}
}
// MARK: - PaymentProcessingOperationDelegate
extension PaymentsProcessor: PaymentProcessingOperationDelegate {
static func canBeProcessed(paymentModel: TSPaymentModel) -> Bool {
guard !paymentModel.isUnidentified,
!paymentModel.isComplete,
!paymentModel.isFailed else {
return false
}
return true
}
func continueProcessing(paymentModel: TSPaymentModel) {
tryToScheduleProcessingOperation(paymentModel: paymentModel,
label: "Continue processing",
retryDelayInteral: nil)
}
func retryProcessing(paymentModel: TSPaymentModel, nextRetryDelayInteral: TimeInterval) {
tryToScheduleProcessingOperation(paymentModel: paymentModel,
label: "Retry processing",
retryDelayInteral: nextRetryDelayInteral)
}
private func tryToScheduleProcessingOperation(paymentModel: TSPaymentModel,
label: String,
retryDelayInteral: TimeInterval?) {
let paymentId = paymentModel.uniqueId
Self.unfairLock.withLock {
owsAssertDebug(processingPaymentIds.contains(paymentId))
}
guard Self.canBeProcessed(paymentModel: paymentModel) else {
self.endProcessing(paymentModel: paymentModel)
return
}
let operation = PaymentProcessingOperation(delegate: self, paymentModel: paymentModel, retryDelayInteral: retryDelayInteral)
processingQueue(forPaymentModel: paymentModel).enqueue { await operation.run() }
}
func scheduleRetryProcessing(
paymentModel: TSPaymentModel,
retryDelayInteral: TimeInterval,
nextRetryDelayInteral: TimeInterval
) {
add(retryScheduler: RetryScheduler(
paymentModel: paymentModel,
retryDelayInteral: retryDelayInteral,
nextRetryDelayInteral: nextRetryDelayInteral,
delegate: self
))
}
func endProcessing(paymentModel: TSPaymentModel) {
endProcessing(paymentId: paymentModel.uniqueId)
}
func endProcessing(paymentId: String) {
Self.unfairLock.withLock {
owsAssertDebug(processingPaymentIds.contains(paymentId))
processingPaymentIds.remove(paymentId)
}
}
}
// MARK: -
private protocol PaymentProcessingOperationDelegate: AnyObject {
func continueProcessing(paymentModel: TSPaymentModel)
func retryProcessing(paymentModel: TSPaymentModel,
nextRetryDelayInteral: TimeInterval)
func scheduleRetryProcessing(paymentModel: TSPaymentModel,
retryDelayInteral: TimeInterval,
nextRetryDelayInteral: TimeInterval)
func endProcessing(paymentModel: TSPaymentModel)
func endProcessing(paymentId: String)
}
// MARK: -
// See comments on PaymentsProcessor.process().
private class PaymentProcessingOperation {
private weak var delegate: PaymentProcessingOperationDelegate?
private let paymentId: String
private let retryDelayInteral: TimeInterval
private static let defaultRetryDelayInteral: TimeInterval = 1 * kSecondInterval
init(delegate: PaymentProcessingOperationDelegate,
paymentModel: TSPaymentModel,
retryDelayInteral: TimeInterval? = nil) {
self.delegate = delegate
self.paymentId = paymentModel.uniqueId
self.retryDelayInteral = retryDelayInteral ?? Self.defaultRetryDelayInteral
}
func run() async {
await processStep()
}
// Try to usher a payment "one step forward" in the processing
// state machine.
//
// We need to user transactions/payments through the various
// steps of the state machine as quickly as possible, retry when
// necessary, and avoid getting stuck in a tight retry loop.
// Therefore retries are throttled and/or do backoff - but retry
// behavior depends on the type of operation.
private func processStep() async {
// When this promise chain completes, we must call continueProcessing()
// or endProcessing().
do {
let paymentModel = try await Promise.wrapAsync {
return try await self.processStep(paymentModel: self.loadPaymentModelWithSneakyTransaction())
}.timeout(seconds: Self.timeoutDuration, description: "process") { () -> Error in
return PaymentsError.timeout
}.awaitable()
self.delegate?.continueProcessing(paymentModel: paymentModel)
} catch {
switch error {
case let paymentsError as PaymentsError:
switch paymentsError {
case .notEnabled,
.userNotRegisteredOrAppNotReady,
.userHasNoPublicAddress,
.invalidCurrency,
.invalidWalletKey,
.invalidAmount,
.invalidFee,
.insufficientFunds,
.invalidModel,
.tooOldToSubmit,
.indeterminateState,
.unknownSDKError,
.invalidInput,
.invalidServerResponse,
.attestationVerificationFailed,
.outdatedClient,
.serverRateLimited,
.serializationError,
.missingModel,
.connectionFailure,
.timeout,
.invalidTransaction,
.inputsAlreadySpent,
.defragmentationFailed,
.invalidPassphrase,
.invalidEntropy,
.killSwitch,
.fogOutOfSync,
.outgoingVerificationTakingTooLong,
.missingMemo:
owsFailDebugUnlessMCNetworkFailure(error)
case .authorizationFailure:
owsFailDebugUnlessMCNetworkFailure(error)
case .verificationStatusUnknown,
.ledgerBlockTimestampUnknown:
// These errors are expected.
Logger.info("Error: \(error)")
case .defragmentationRequired:
// These errors are expected but should be very rare.
owsFailDebugUnlessMCNetworkFailure(error)
}
default:
owsFailDebugUnlessMCNetworkFailure(error)
}
if let paymentModel = self.loadPaymentModelWithSneakyTransaction() {
self.handleProcessingError(paymentModel: paymentModel, error: error)
} else {
owsFailDebug("Could not reload payment model.")
self.delegate?.endProcessing(paymentId: self.paymentId)
}
}
}
private func handleProcessingError(paymentModel: TSPaymentModel, error: Error) {
switch error {
case let paymentsError as PaymentsError:
switch paymentsError {
case .notEnabled,
.userNotRegisteredOrAppNotReady,
.userHasNoPublicAddress,
.invalidCurrency,
.invalidWalletKey,
.invalidAmount,
.invalidFee,
.insufficientFunds,
.invalidModel,
.tooOldToSubmit,
.indeterminateState,
.unknownSDKError,
.invalidInput,
.authorizationFailure,
.invalidServerResponse,
.attestationVerificationFailed,
.outdatedClient,
.serializationError,
.missingModel,
.invalidTransaction,
.inputsAlreadySpent,
.defragmentationFailed,
.invalidPassphrase,
.invalidEntropy,
.killSwitch,
.outgoingVerificationTakingTooLong,
.missingMemo:
// Do not retry these errors.
delegate?.endProcessing(paymentId: self.paymentId)
case .serverRateLimited:
// Exponential backoff of at least 30 seconds.
//
// TODO: Revisit when FOG rate limiting behavior is well-defined.
let retryDelayInteral = 30 + self.retryDelayInteral
let nextRetryDelayInteral = self.retryDelayInteral * 2
delegate?.scheduleRetryProcessing(paymentModel: paymentModel,
retryDelayInteral: retryDelayInteral,
nextRetryDelayInteral: nextRetryDelayInteral)
case .connectionFailure,
.fogOutOfSync,
.timeout:
// Vanilla exponential backoff.
let retryDelayInteral = self.retryDelayInteral
let nextRetryDelayInteral = self.retryDelayInteral * 2
delegate?.scheduleRetryProcessing(paymentModel: paymentModel,
retryDelayInteral: retryDelayInteral,
nextRetryDelayInteral: nextRetryDelayInteral)
case .verificationStatusUnknown,
.ledgerBlockTimestampUnknown:
// Exponential backoff.
//
// If the payment _has_ already been verified, we're just trying to fill
// in a missing ledger timestamp. That is low priority and we can
// backoff aggressively.
//
// If the payment _hasn't_ been verified yet, we want to retry fairly
// aggressively.
let backoffFactor: TimeInterval = paymentModel.isVerified ? 4 : 1.5
var retryDelayInteral = self.retryDelayInteral
if paymentModel.isVerified {
// Don't try to fill in a missing ledger timestamp more than once per hour.
// Things work reasonably well without this extra info, it's not clear
// how long a missing ledger timestamp might take to appear, and we might
// have a large number of payments without this info.
retryDelayInteral += kHourInterval * 1
}
let nextRetryDelayInteral = self.retryDelayInteral * backoffFactor
delegate?.scheduleRetryProcessing(paymentModel: paymentModel,
retryDelayInteral: retryDelayInteral,
nextRetryDelayInteral: nextRetryDelayInteral)
case .defragmentationRequired:
// Vanilla exponential backoff.
let retryDelayInteral = self.retryDelayInteral
let nextRetryDelayInteral = self.retryDelayInteral * 2
delegate?.scheduleRetryProcessing(paymentModel: paymentModel,
retryDelayInteral: retryDelayInteral,
nextRetryDelayInteral: nextRetryDelayInteral)
}
default:
// Do not retry assertion errors.
delegate?.endProcessing(paymentId: self.paymentId)
}
}
private func loadPaymentModelWithSneakyTransaction() -> TSPaymentModel? {
SSKEnvironment.shared.databaseStorageRef.read { transaction in
TSPaymentModel.anyFetch(uniqueId: self.paymentId, transaction: transaction)
}
}
private static let timeoutDuration: TimeInterval = 60
private func processStep(paymentModel: TSPaymentModel?) async throws -> TSPaymentModel {
guard let paymentModel = paymentModel else {
throw OWSAssertionError("Could not reload the payment record.")
}
let paymentStateBeforeProcessing = paymentModel.paymentState
let formattedState = paymentModel.descriptionForLogs
guard PaymentsProcessor.canBeProcessed(paymentModel: paymentModel) else {
throw OWSAssertionError("Cannot process: \(formattedState)")
}
owsAssertDebug(paymentModel.isValid)
switch paymentModel.paymentState {
case .outgoingUnsubmitted:
try await self.submitOutgoingPayment(paymentModel: paymentModel)
case .outgoingUnverified:
try await self.verifyOutgoingPayment(paymentModel: paymentModel)
case .outgoingVerified:
try await self.sendPaymentNotificationMessage(paymentModel: paymentModel)
case .outgoingSending, .outgoingSent:
try await Self.updatePaymentStatePromise(paymentModel: paymentModel, fromState: paymentModel.paymentState, toState: .outgoingComplete)
case .incomingUnverified:
try await self.verifyIncomingPayment(paymentModel: paymentModel)
case .incomingVerified:
try await Self.updatePaymentStatePromise(paymentModel: paymentModel, fromState: .incomingVerified, toState: .incomingComplete)
case .outgoingComplete, .incomingComplete, .outgoingFailed, .incomingFailed:
throw OWSAssertionError("Cannot process: \(formattedState)")
@unknown default:
throw OWSAssertionError("Unknown paymentState: \(formattedState)")
}
guard let latestModel = self.loadPaymentModelWithSneakyTransaction() else {
owsFailDebug("Could not reload payment model.")
throw PaymentsError.missingModel
}
let paymentStateAfterProcessing = latestModel.paymentState
if paymentStateBeforeProcessing == paymentStateAfterProcessing {
owsFailDebug("Payment state did not change after successful processing step: \(latestModel.descriptionForLogs)")
}
return latestModel
}
private func submitOutgoingPayment(paymentModel: TSPaymentModel) async throws {
owsAssertDebug(paymentModel.paymentState == .outgoingUnsubmitted)
if SUIEnvironment.shared.paymentsRef.isKillSwitchActive {
do {
try await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { transaction in
try paymentModel.updatePaymentModelState(fromState: .outgoingUnsubmitted, toState: .outgoingUnverified, transaction: transaction)
}
} catch {
owsFailDebug("Error: \(error)")
}
throw PaymentsError.killSwitch
}
if DebugFlags.paymentsSkipSubmissionAndOutgoingVerification.get() {
try await Self.updatePaymentStatePromise(paymentModel: paymentModel, fromState: .outgoingUnsubmitted, toState: .outgoingUnverified)
return
}
// Only try to submit transactions within the first N minutes of them
// being initiated. If the app is terminated right after a transaction
// is initiated (before transaction it can be submitted), we don't want
// to submit it if the app is opened much later.
let submissionInterval = kMinuteInterval * 5
let createdDate = paymentModel.createdDate
let isRecentEnoughToSubmit = abs(createdDate.timeIntervalSinceNow) <= submissionInterval
guard isRecentEnoughToSubmit else {
// We mark the payment as "unverified", not "failed". It's conceivable
// that the transaction was submitted but the record was never marked
// as such (due to a race around app being terminated).
do {
try await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { transaction in
try paymentModel.updatePaymentModelState(fromState: .outgoingUnsubmitted, toState: .outgoingUnverified, transaction: transaction)
}
} catch {
owsFailDebug("Error: \(error)")
}
Logger.warn("Not recent enough to submit.")
throw PaymentsError.tooOldToSubmit
}
guard
let mcTransactionData = paymentModel.mcTransactionData,
mcTransactionData.count > 0,
let transaction = MobileCoin.Transaction(serializedData: mcTransactionData)
else {
await Self.handleIndeterminatePayment(paymentModel: paymentModel)
throw PaymentsError.indeterminateState
}
do {
let mobileCoinAPI = try await SUIEnvironment.shared.paymentsImplRef.getMobileCoinAPI().awaitable()
_ = try await mobileCoinAPI.submitTransaction(transaction: transaction).awaitable()
try await Self.updatePaymentStatePromise(paymentModel: paymentModel, fromState: .outgoingUnsubmitted, toState: .outgoingUnverified)
} catch PaymentsError.inputsAlreadySpent {
// e.g. if we double-submit a transaction, it should become unverified,
// not stuck in unsubmitted.
try await Self.updatePaymentStatePromise(paymentModel: paymentModel, fromState: .outgoingUnsubmitted, toState: .outgoingUnverified)
}
}
private func verifyOutgoingPayment(paymentModel: TSPaymentModel) async throws {
owsAssertDebug(paymentModel.paymentState == .outgoingUnverified)
if DebugFlags.paymentsSkipSubmissionAndOutgoingVerification.get() {
try await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { transaction in
guard let paymentModel = TSPaymentModel.anyFetch(uniqueId: paymentModel.uniqueId, transaction: transaction) else {
throw OWSAssertionError("Missing TSPaymentModel.")
}
paymentModel.update(mcLedgerBlockIndex: 111, transaction: transaction)
paymentModel.update(paymentState: .outgoingVerified, transaction: transaction)
}
return
}
let mobileCoinAPI = try await SUIEnvironment.shared.paymentsImplRef.getMobileCoinAPI().awaitable()
guard
let mcTransactionData = paymentModel.mcTransactionData,
mcTransactionData.count > 0,
let transaction = MobileCoin.Transaction(serializedData: mcTransactionData)
else {
await Self.handleIndeterminatePayment(paymentModel: paymentModel)
throw PaymentsError.indeterminateState
}
let transactionStatus = try await mobileCoinAPI.getOutgoingTransactionStatus(transaction: transaction).awaitable()
try await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { transaction in
switch transactionStatus.transactionStatus {
case .unknown:
throw PaymentsError.verificationStatusUnknown
case .accepted(let block):
if !paymentModel.hasMCLedgerBlockIndex {
paymentModel.update(mcLedgerBlockIndex: block.index, transaction: transaction)
}
if let ledgerBlockDate = block.timestamp,
!paymentModel.hasMCLedgerBlockTimestamp {
paymentModel.update(mcLedgerBlockTimestamp: ledgerBlockDate.ows_millisecondsSince1970, transaction: transaction)
}
try paymentModel.updatePaymentModelState(fromState: .outgoingUnverified, toState: .outgoingVerified, transaction: transaction)
// If we've verified a payment, our balance may have changed.
SUIEnvironment.shared.paymentsImplRef.updateCurrentPaymentBalance()
case .failed:
Self.markAsFailed(paymentModel: paymentModel, paymentFailure: .validationFailed, paymentState: .outgoingFailed, transaction: transaction)
}
}
}
private class func markAsFailed(paymentModel: TSPaymentModel, paymentFailure: TSPaymentFailure, paymentState: TSPaymentState) async {
await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { transaction in
markAsFailed(paymentModel: paymentModel, paymentFailure: paymentFailure, paymentState: paymentState, transaction: transaction)
}
}
private class func markAsFailed(paymentModel: TSPaymentModel, paymentFailure: TSPaymentFailure, paymentState: TSPaymentState, transaction: SDSAnyWriteTransaction) {
paymentModel.update(withPaymentFailure: paymentFailure, paymentState: paymentState, transaction: transaction)
}
private func sendPaymentNotificationMessage(paymentModel: TSPaymentModel) async throws {
owsAssertDebug(paymentModel.paymentState == .outgoingVerified)
guard !paymentModel.isOutgoingTransfer else {
// No need to notify for "transfer out" transactions.
try await Self.updatePaymentStatePromise(paymentModel: paymentModel, fromState: .outgoingVerified, toState: .outgoingSent)
return
}
try await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { transaction in
guard let paymentModel = TSPaymentModel.anyFetch(uniqueId: paymentModel.uniqueId, transaction: transaction) else {
throw OWSAssertionError("Missing paymentModel.")
}
guard paymentModel.paymentState == .outgoingVerified else {
throw OWSAssertionError("Unexpected paymentState: \(paymentModel.descriptionForLogs).")
}
do {
let notify = {
if paymentModel.isDefragmentation {
PaymentsImpl.sendDefragmentationSyncMessage(paymentModel: paymentModel, transaction: transaction)
} else {
_ = try PaymentsImpl.sendPaymentNotificationMessage(paymentModel: paymentModel, transaction: transaction)
PaymentsImpl.sendOutgoingPaymentSyncMessage(paymentModel: paymentModel, transaction: transaction)
}
}
try notify()
if DebugFlags.paymentsDoubleNotify.get() {
// Notify again.
try notify()
}
try paymentModel.updatePaymentModelState(fromState: .outgoingVerified, toState: .outgoingSending, transaction: transaction)
} catch {
if case PaymentsError.invalidModel = error {
try paymentModel.updatePaymentModelState(fromState: .outgoingVerified, toState: .outgoingComplete, transaction: transaction)
}
throw error
}
}
}
private func verifyIncomingPayment(paymentModel: TSPaymentModel) async throws {
owsAssertDebug(paymentModel.paymentState == .incomingUnverified)
let mobileCoinAPI = try await SUIEnvironment.shared.paymentsImplRef.getMobileCoinAPI().awaitable()
guard let mcReceiptData = paymentModel.mcReceiptData, let receipt = MobileCoin.Receipt(serializedData: mcReceiptData) else {
await Self.handleIndeterminatePayment(paymentModel: paymentModel)
throw PaymentsError.indeterminateState
}
let receiptStatus = try await mobileCoinAPI.getIncomingReceiptStatus(receipt: receipt).awaitable()
try await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { transaction in
switch receiptStatus.receiptStatus {
case .unknown:
throw PaymentsError.verificationStatusUnknown
case .received(let block):
paymentModel.update(mcLedgerBlockIndex: block.index, transaction: transaction)
if let ledgerBlockDate = block.timestamp {
paymentModel.update(mcLedgerBlockTimestamp: ledgerBlockDate.ows_millisecondsSince1970, transaction: transaction)
} else {
Logger.warn("Missing ledgerBlockDate.")
}
paymentModel.update(withPaymentAmount: receiptStatus.paymentAmount, transaction: transaction)
try paymentModel.updatePaymentModelState(fromState: .incomingUnverified, toState: .incomingVerified, transaction: transaction)
// If we've verified a payment, our balance may have changed.
SUIEnvironment.shared.paymentsImplRef.updateCurrentPaymentBalance()
case .failed:
Self.markAsFailed(paymentModel: paymentModel, paymentFailure: .validationFailed, paymentState: .incomingFailed, transaction: transaction)
}
}
}
class func handleIndeterminatePayment(paymentModel: TSPaymentModel) async {
owsFailDebug("Indeterminate payment: \(paymentModel.descriptionForLogs)")
// A payment is indeterminate if we don't know if it exists
// in the ledger and have no way to resolve that. For example,
// a user might submit a payment, then scrub the payment before
// it is verified. In this case, the only way to recover is
// to discard the payment record and reconcile using the transaction
// history. Presumably this should only be possible if the user
// scrubs an unverified payment.
await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { transaction in
paymentModel.anyRemove(transaction: transaction)
SUIEnvironment.shared.paymentsRef.scheduleReconciliationNow(transaction: transaction)
}
}
private static func updatePaymentStatePromise(paymentModel: TSPaymentModel, fromState: TSPaymentState, toState: TSPaymentState) async throws {
owsAssertDebug(paymentModel.paymentState == fromState)
try await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { transaction in
guard let paymentModel = TSPaymentModel.anyFetch(uniqueId: paymentModel.uniqueId, transaction: transaction) else {
throw OWSAssertionError("Missing TSPaymentModel.")
}
guard paymentModel.paymentState == fromState else {
throw OWSAssertionError("Unexpected paymentState: \(paymentModel.paymentState.formatted) != \(fromState.formatted).")
}
paymentModel.update(paymentState: toState, transaction: transaction)
}
}
}