1156 lines
49 KiB
Swift
1156 lines
49 KiB
Swift
//
|
|
// Copyright 2021 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import Foundation
|
|
public import MobileCoin
|
|
public import SignalServiceKit
|
|
|
|
public class PaymentsReconciliation {
|
|
|
|
private let appReadiness: AppReadiness
|
|
private var refreshEvent: RefreshEvent?
|
|
|
|
public init(appReadiness: AppReadiness) {
|
|
self.appReadiness = appReadiness
|
|
appReadiness.runNowOrWhenAppDidBecomeReadyAsync {
|
|
// Note: this isn't how often we perform reconciliation, it's how often we
|
|
// check whether we should perform reconciliation.
|
|
//
|
|
// TODO: Tune.
|
|
let refreshCheckInterval = kMinuteInterval * 5
|
|
self.refreshEvent = RefreshEvent(appReadiness: appReadiness, refreshInterval: refreshCheckInterval) { [weak self] in
|
|
self?.reconcileIfNecessary()
|
|
}
|
|
}
|
|
NotificationCenter.default.addObserver(self,
|
|
selector: #selector(reconcileIfNecessary),
|
|
name: PaymentsConstants.arePaymentsEnabledDidChange,
|
|
object: nil)
|
|
}
|
|
|
|
private let operationQueue = SerialTaskQueue()
|
|
|
|
@objc
|
|
private func reconcileIfNecessary() {
|
|
if CurrentAppContext().isNSE {
|
|
return
|
|
}
|
|
operationQueue.enqueue { [appReadiness] in
|
|
do {
|
|
try await Self._reconcileIfNecessary(appReadiness: appReadiness)
|
|
} catch {
|
|
owsFailDebugUnlessMCNetworkFailure(error)
|
|
Logger.warn("\(error)")
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func shouldReconcile(appReadiness: AppReadiness) -> Bool {
|
|
guard !CurrentAppContext().isRunningTests else {
|
|
return false
|
|
}
|
|
guard SSKEnvironment.shared.paymentsHelperRef.arePaymentsEnabled else {
|
|
return false
|
|
}
|
|
guard
|
|
appReadiness.isAppReady,
|
|
CurrentAppContext().isMainAppAndActive,
|
|
DependenciesBridge.shared.tsAccountManager.registrationStateWithMaybeSneakyTransaction.isRegistered
|
|
else {
|
|
return false
|
|
}
|
|
guard shouldReconcileByDateWithSneakyTransaction() else {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
private static func _reconcileIfNecessary(appReadiness: AppReadiness) async throws {
|
|
guard shouldReconcile(appReadiness: appReadiness) else {
|
|
return
|
|
}
|
|
let mobileCoinAPI = try await SUIEnvironment.shared.paymentsImplRef.getMobileCoinAPI().awaitable()
|
|
let accountActivity = try await mobileCoinAPI.getAccountActivity().awaitable()
|
|
await Self.reconcileIfNecessary(transactionHistory: accountActivity)
|
|
}
|
|
|
|
private static let schedulingStore = KeyValueStore(collection: "PaymentsReconciliation.schedulingStore")
|
|
private static let successDateKey = "successDateKey"
|
|
private static let lastKnownBlockCountKey = "lastKnownBlockCountKey"
|
|
private static let lastKnownReceivedTXOCountKey = "lastKnownReceivedTXOCountKey"
|
|
private static let lastKnownSpentTXOCountKey = "lastKnownSpentTXOCountKey"
|
|
|
|
private static func shouldReconcileByDateWithSneakyTransaction() -> Bool {
|
|
SSKEnvironment.shared.databaseStorageRef.read { transaction in
|
|
Self.shouldReconcileByDate(transaction: transaction)
|
|
}
|
|
}
|
|
|
|
private static func shouldReconcileByDate(transaction: SDSAnyReadTransaction) -> Bool {
|
|
guard let date = Self.schedulingStore.getDate(Self.successDateKey, transaction: transaction.asV2Read) else {
|
|
return true
|
|
}
|
|
let reconciliationInterval = kHourInterval * 1
|
|
return abs(date.timeIntervalSinceNow) >= reconciliationInterval
|
|
}
|
|
|
|
private static func shouldReconcileWithSneakyTransaction(transactionHistory: MCTransactionHistory) -> Bool {
|
|
SSKEnvironment.shared.databaseStorageRef.read { transaction in
|
|
Self.shouldReconcile(transaction: transaction,
|
|
transactionHistory: transactionHistory)
|
|
}
|
|
}
|
|
|
|
private static func shouldReconcile(transaction: SDSAnyReadTransaction,
|
|
transactionHistory: MCTransactionHistory) -> Bool {
|
|
|
|
// Ledger state monotonically increases, so its sufficient
|
|
// to do change detection by comparing these values.
|
|
let lastKnownBlockCount = transactionHistory.blockCount
|
|
let spentTXOCount = transactionHistory.spentItems.count
|
|
let receivedTXOCount = transactionHistory.receivedItems.count
|
|
|
|
guard lastKnownBlockCount == Self.schedulingStore.getUInt64(Self.lastKnownBlockCountKey,
|
|
defaultValue: 0,
|
|
transaction: transaction.asV2Read) else {
|
|
return true
|
|
}
|
|
guard spentTXOCount == Self.schedulingStore.getUInt(Self.lastKnownSpentTXOCountKey,
|
|
defaultValue: 0,
|
|
transaction: transaction.asV2Read) else {
|
|
return true
|
|
}
|
|
guard receivedTXOCount == Self.schedulingStore.getUInt(Self.lastKnownReceivedTXOCountKey,
|
|
defaultValue: 0,
|
|
transaction: transaction.asV2Read) else {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
private static func reconciliationDidSucceed(transaction: SDSAnyWriteTransaction,
|
|
transactionHistory: MCTransactionHistory) {
|
|
Self.schedulingStore.setDate(Date(), key: Self.successDateKey, transaction: transaction.asV2Write)
|
|
|
|
let lastKnownBlockCount = transactionHistory.blockCount
|
|
let spentItemsCount = transactionHistory.spentItems.count
|
|
let receivedItemsCount = transactionHistory.receivedItems.count
|
|
|
|
Self.schedulingStore.setUInt64(lastKnownBlockCount,
|
|
key: Self.lastKnownBlockCountKey,
|
|
transaction: transaction.asV2Write)
|
|
Self.schedulingStore.setInt(spentItemsCount,
|
|
key: Self.lastKnownSpentTXOCountKey,
|
|
transaction: transaction.asV2Write)
|
|
Self.schedulingStore.setInt(receivedItemsCount,
|
|
key: Self.lastKnownReceivedTXOCountKey,
|
|
transaction: transaction.asV2Write)
|
|
}
|
|
|
|
public func scheduleReconciliationNow(transaction: SDSAnyWriteTransaction) {
|
|
Self.schedulingStore.removeAll(transaction: transaction.asV2Write)
|
|
|
|
transaction.addAsyncCompletionOffMain {
|
|
self.reconcileIfNecessary()
|
|
}
|
|
}
|
|
|
|
enum ReconciliationError: Error {
|
|
case unsavedChanges
|
|
}
|
|
|
|
private static func reconcileIfNecessary(transactionHistory: MCTransactionHistory) async {
|
|
|
|
// We should skip reconciliation if accountActivity hasn't changed
|
|
// since the last reconciliation.
|
|
guard shouldReconcileWithSneakyTransaction(transactionHistory: transactionHistory) else {
|
|
return
|
|
}
|
|
|
|
// Reconciliation is expensive. We need to load into memory our entire transaction
|
|
// history from the SDK (MobileCoin.AccountActivity) and our payment history from
|
|
// the database (PaymentsDatabaseState) and reconcile them which involves a bunch of
|
|
// computation and some queries. We need to be able to safely perform reconciliation
|
|
// often, even if a user has an extensive transaction history.
|
|
//
|
|
// For consistency, all db reads & writes must be done within a single write
|
|
// transaction so the risk is of a long-running write transaction.
|
|
//
|
|
// Therefore we perform the reconciliation in a read transaction. If any db writes
|
|
// are necessary, we throw ReconciliationError.unsavedChanges. We then re-perform
|
|
// reconciliation with a write transaction. Most reconciliations don't need to
|
|
// perform any db writes, so in this way we can avoid write transactions unless
|
|
// necessary.
|
|
do {
|
|
try SSKEnvironment.shared.databaseStorageRef.read { transaction in
|
|
let databaseState = Self.buildPaymentsDatabaseState(transaction: transaction)
|
|
|
|
try reconcile(transactionHistory: transactionHistory,
|
|
databaseState: databaseState,
|
|
transaction: transaction)
|
|
|
|
try cleanUpDatabase(transaction: transaction)
|
|
}
|
|
await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { transaction in
|
|
reconciliationDidSucceed(transaction: transaction,
|
|
transactionHistory: transactionHistory)
|
|
}
|
|
} catch {
|
|
if case ReconciliationError.unsavedChanges = error {
|
|
Logger.info("Reconciliation has unsaved changes.")
|
|
|
|
do {
|
|
try await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { transaction in
|
|
let databaseState = Self.buildPaymentsDatabaseState(transaction: transaction)
|
|
|
|
try reconcile(transactionHistory: transactionHistory,
|
|
databaseState: databaseState,
|
|
transaction: transaction)
|
|
|
|
try cleanUpDatabase(transaction: transaction)
|
|
|
|
reconciliationDidSucceed(transaction: transaction,
|
|
transactionHistory: transactionHistory)
|
|
}
|
|
} catch {
|
|
owsFailDebug("Error: \(error)")
|
|
}
|
|
} else {
|
|
owsFailDebug("Error: \(error)")
|
|
}
|
|
}
|
|
}
|
|
|
|
// This method performs the core of the reconciliation.
|
|
//
|
|
// We review the MobileCoin.AccountActivity and identify any
|
|
// "unaccounted-for" MC TXOs from the SDK transaction which don't
|
|
// correspond to a TSPaymentModel in the database. We account for
|
|
// them by creating "unidentified" TSPaymentModels.
|
|
//
|
|
// For a given block we want to track the following:
|
|
//
|
|
// * Received TXOs (public keys)
|
|
// * SDK transaction history contains this.
|
|
// * Spent TXOs (public keys)
|
|
// * SDK transaction history contains this.
|
|
// * Spent TXOs (key images)
|
|
// * MC Transaction model entity contains this; SDK transaction history does not.
|
|
// * Output TXOs (public keys)
|
|
// * MC Transaction model has all of these for identified payments.
|
|
// * MC Receipt model has the public key for the recipient TXO, but not any change TXOs.
|
|
//
|
|
// Identifying a change TXO if you have Receipt/Transaction models for this block:
|
|
//
|
|
// * Will be in output TXO list and received TXOs list.
|
|
// * Those TXOs will match value and TXO public key.
|
|
// * Will not match the Receipt recipient TXO public key.
|
|
//
|
|
// Identifying a change TXO without (or partial) Receipt/Transaction models for this block:
|
|
//
|
|
// * There will be received and outgoing (NOT spent) TXOs match in value.
|
|
// * Sum of values of total change TXOs will be less than sum of values of total spent TXIs.
|
|
//
|
|
// * Multiple "unaccounted for" incoming TXOs in a block should be rare.
|
|
// * Multiple "unaccounted for" outgoing TXOs in a block should be very rare.
|
|
// * Well-behaved payments should only have 1-2 outgoing TXOs (change is optional).
|
|
// * But we can't meaningfully group "unaccounted for" outgoing TXOs, so
|
|
// group all "unaccounted for" outgoing TXOs into a single payment.
|
|
//
|
|
// NOTE: There's no reliable way to identify defrag transactions.
|
|
internal static func reconcile(transactionHistory: MCTransactionHistory,
|
|
databaseState: PaymentsDatabaseState,
|
|
transaction: SDSAnyReadTransaction) throws {
|
|
|
|
Logger.info("")
|
|
|
|
// Fill in/reconcile incoming transactions.
|
|
|
|
let items: [MCTransactionHistoryItem] = transactionHistory.safeItems.sortedByBlockIndex(descending: false)
|
|
|
|
// 1. Collate transactions by block: Block Activity
|
|
var blockActivityMap = [UInt64: BlockActivity]()
|
|
func blockActivity(forBlockIndex blockIndex: UInt64) -> BlockActivity {
|
|
let blockActivity = blockActivityMap[blockIndex] ?? BlockActivity(blockIndex: blockIndex)
|
|
blockActivityMap[blockIndex] = blockActivity
|
|
return blockActivity
|
|
}
|
|
for item in items {
|
|
// Incoming
|
|
blockActivity(forBlockIndex: item.receivedBlockIndex).addReceived(item: item)
|
|
|
|
// Outgoing
|
|
if let spentBlock = item.spentBlock {
|
|
blockActivity(forBlockIndex: spentBlock.index).addSpent(item: item)
|
|
}
|
|
}
|
|
let blockActivities = Array(blockActivityMap.values).sortedByBlockIndex(descending: false)
|
|
|
|
// 2. Fill in missing unidentified transactions.
|
|
//
|
|
// We account for all unidentified transactions with a single payment model.
|
|
// This might "lump together" incoming (received) and outgoing (spent)
|
|
// transactions into a single "omnibus" payment model.
|
|
//
|
|
// It's definitely possible for a user to receive multiple incoming
|
|
// payments in a given ledger block.
|
|
//
|
|
// Hypothetically the local user might also have more than one outgoing
|
|
// transaction in a given block. Well-behaved clients should prevent
|
|
// this, but a user might send two payments from linked devices at
|
|
// the same time.
|
|
//
|
|
// There's no reliable way to separate this activity into multiple
|
|
// "unidentified payments", so we just create one "omnibus
|
|
// unidentified" payment model.
|
|
//
|
|
// Because we're making an "omnibus" payment, we _CANNOT_ assume that
|
|
// MC transaction limitations apply:
|
|
//
|
|
// * max 16 TXIs
|
|
// * max 16 TXOs
|
|
// * usually 1-2 TXOs (one for recipient, one for change)
|
|
// * max 1 change TXO?
|
|
//
|
|
// If we later learn of identified activity in that block (via sync
|
|
// message), we'll recover by discarding all "unidentified" payments
|
|
// in the block and re-reconcile.
|
|
for blockActivity in blockActivities {
|
|
// For each ledger block, we first try to identify any blocks with
|
|
// any outgoing/spent TXOs which are not already "accounted for".
|
|
//
|
|
// * A received TXO has been spent if the SDK transaction history
|
|
// includes a "spent block."
|
|
// * A spent TXO is unaccounted for if you don't yet have an
|
|
// outgoing TSPaymentModel without that TXO's keyImage in its
|
|
// mcSpentKeyImages.
|
|
// * If there is an archived payment present for the TXO, record this
|
|
// and attempt to rebuild a TSPaymentModel based on this information later.
|
|
var restoredPayments = Set<ArchivedPayment>()
|
|
var unaccountedForSpentItems = [MCTransactionHistoryItem]()
|
|
let restoredSpentPayments = MultiMap<ArchivedPayment, MCTransactionHistoryItem>()
|
|
for spentItem in blockActivity.spentItems {
|
|
switch databaseState.spentImageKeyMap[spentItem.keyImage] {
|
|
case .none:
|
|
unaccountedForSpentItems.append(spentItem)
|
|
case .model:
|
|
break
|
|
case .archivedPayment(let history):
|
|
// An archived outgoing payment was found. Record it and attempt
|
|
// to restore the TSPaymentModel later.
|
|
restoredPayments.insert(history)
|
|
restoredSpentPayments.add(key: history, value: spentItem)
|
|
}
|
|
}
|
|
|
|
var unaccountedForReceivedItems = [MCTransactionHistoryItem]()
|
|
let restoredReceivedPayments = MultiMap<ArchivedPayment, MCTransactionHistoryItem>()
|
|
for receivedItem in blockActivity.receivedItems {
|
|
let existingReceivingPaymentModels = databaseState.incomingAnyMap.values(forKey: receivedItem.txoPublicKey)
|
|
let knownTransaction = databaseState.outputPublicKeyMap[receivedItem.txoPublicKey]
|
|
let isPossibleChange = knownTransaction != nil
|
|
|
|
switch existingReceivingPaymentModels.first {
|
|
case .none:
|
|
if !isPossibleChange {
|
|
unaccountedForReceivedItems.append(receivedItem)
|
|
} else if case let .archivedPayment(history) = knownTransaction {
|
|
// This isn't the leftovers (change) from an outgoing transaciton,
|
|
// and matches a received transaction. Record this item and attempt
|
|
// to rebuild an incoming payment from it. This isn't the full list
|
|
// of public keys associated with this transaction, so later on during
|
|
// restore, the list of public keys will be restored from the archived payment
|
|
restoredPayments.insert(history)
|
|
restoredReceivedPayments.add(key: history, value: receivedItem)
|
|
}
|
|
case .model:
|
|
break
|
|
case .archivedPayment(let history):
|
|
// Found an archived incoming payment that can be reattached.
|
|
restoredPayments.insert(history)
|
|
restoredReceivedPayments.add(key: history, value: receivedItem)
|
|
}
|
|
}
|
|
|
|
let createdTimestamp: UInt64 = Self.guesstimateBlockTimestamp(
|
|
forBlockActivity: blockActivity,
|
|
allBlockActivities: blockActivities
|
|
)
|
|
|
|
func insert(model: TSPaymentModel) throws {
|
|
if let transaction = transaction as? SDSAnyWriteTransaction {
|
|
try SSKEnvironment.shared.paymentsHelperRef.tryToInsertPaymentModel(model, transaction: transaction)
|
|
} else {
|
|
throw ReconciliationError.unsavedChanges
|
|
}
|
|
}
|
|
|
|
if restoredPayments.isEmpty.negated {
|
|
// An archived payment at this point means there is something from a backup that is now
|
|
// being populated from the ledger. For each ArchivedPayment matched above, attempt
|
|
// to rebuild a TSPaymentModel from the matched public keys and spent key image information.
|
|
for restoredPayment in restoredPayments {
|
|
let restoredSpentPayments = restoredSpentPayments[restoredPayment]
|
|
let restoredReceivedPayments = restoredReceivedPayments[restoredPayment]
|
|
if let paymentModel = buildArchivedPaymentModel(
|
|
timestamp: createdTimestamp,
|
|
blockActivity: blockActivity,
|
|
restoredSpentItems: restoredSpentPayments,
|
|
restoredReceivedItems: restoredReceivedPayments,
|
|
archivedPayment: restoredPayment
|
|
) {
|
|
try insert(model: paymentModel)
|
|
databaseState.add(paymentModel: paymentModel)
|
|
} else {
|
|
unaccountedForSpentItems.append(contentsOf: restoredSpentPayments)
|
|
unaccountedForReceivedItems.append(contentsOf: restoredReceivedPayments)
|
|
}
|
|
}
|
|
}
|
|
|
|
if !unaccountedForSpentItems.isEmpty || !unaccountedForReceivedItems.isEmpty {
|
|
let paymentModel = buildPaymentModel(
|
|
timestamp: createdTimestamp,
|
|
blockActivity: blockActivity,
|
|
unaccountedForSpentItems: unaccountedForSpentItems,
|
|
unaccountedForReceivedItems: unaccountedForReceivedItems
|
|
)
|
|
try insert(model: paymentModel)
|
|
databaseState.add(paymentModel: paymentModel)
|
|
}
|
|
}
|
|
|
|
// 3. Fill in missing ledger timestamps.
|
|
for blockActivity in blockActivities {
|
|
// If we know the ledger block timestamp for a given block...
|
|
guard let ledgerBlockTimestamp = blockActivity.blockTimestamp else {
|
|
continue
|
|
}
|
|
let ledgerBlockIndex = blockActivity.blockIndex
|
|
|
|
// Review all existing payment models in the block and fill in
|
|
// any missing ledger block timestamps.
|
|
let paymentStates = databaseState.ledgerBlockIndexMap[ledgerBlockIndex]
|
|
for paymentState in paymentStates {
|
|
guard case .model(let paymentModel) = paymentState else { continue }
|
|
let hasLedgerBlockIndex = (paymentModel.mobileCoin?.ledgerBlockIndex ?? 0) > 0
|
|
if !hasLedgerBlockIndex {
|
|
if let transaction = transaction as? SDSAnyWriteTransaction {
|
|
paymentModel.update(mcLedgerBlockTimestamp: ledgerBlockTimestamp,
|
|
transaction: transaction)
|
|
} else {
|
|
throw ReconciliationError.unsavedChanges
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func buildPaymentModel(
|
|
timestamp: UInt64,
|
|
blockActivity: BlockActivity,
|
|
unaccountedForSpentItems: [MCTransactionHistoryItem],
|
|
unaccountedForReceivedItems: [MCTransactionHistoryItem]
|
|
) -> TSPaymentModel {
|
|
let spentPicoMob = unaccountedForSpentItems.map { $0.amountPicoMob }.reduce(0, +)
|
|
let receivedPicoMob = unaccountedForReceivedItems.map { $0.amountPicoMob }.reduce(0, +)
|
|
|
|
// If the net MOB received > spent, the "omnibus" payment is an
|
|
// "unidentified incoming" payment; otherwise it is "unidentified
|
|
// outgoing."
|
|
//
|
|
let isOutgoing = spentPicoMob > receivedPicoMob
|
|
|
|
let netPicoMob: UInt64
|
|
if isOutgoing {
|
|
netPicoMob = spentPicoMob - receivedPicoMob
|
|
} else {
|
|
netPicoMob = receivedPicoMob - spentPicoMob
|
|
}
|
|
let paymentAmount = TSPaymentAmount(currency: .mobileCoin,
|
|
picoMob: netPicoMob)
|
|
let paymentType: TSPaymentType = (isOutgoing ? .outgoingUnidentified : .incomingUnidentified)
|
|
let paymentState: TSPaymentState = (isOutgoing ? .outgoingComplete : .incomingComplete)
|
|
|
|
let ledgerBlockIndex: UInt64 = blockActivity.blockIndex
|
|
let ledgerBlockTimestamp: UInt64 = blockActivity.blockTimestamp ?? 0
|
|
let createdTimestamp: UInt64 = timestamp
|
|
let createdDate = Date(millisecondsSince1970: createdTimestamp)
|
|
|
|
let unaccountedForSpentKeyImages: [Data] = unaccountedForSpentItems.map { $0.keyImage }
|
|
let spentKeyImages: [Data]? = Array(Set(unaccountedForSpentKeyImages)).nilIfEmpty
|
|
let incomingTransactionPublicKeys: [Data]? = unaccountedForReceivedItems.map { $0.txoPublicKey }.nilIfEmpty
|
|
|
|
let mobileCoin = MobileCoinPayment(recipientPublicAddressData: nil,
|
|
transactionData: nil,
|
|
receiptData: nil,
|
|
incomingTransactionPublicKeys: incomingTransactionPublicKeys,
|
|
spentKeyImages: spentKeyImages,
|
|
outputPublicKeys: nil,
|
|
ledgerBlockTimestamp: ledgerBlockTimestamp,
|
|
ledgerBlockIndex: ledgerBlockIndex,
|
|
feeAmount: nil)
|
|
return TSPaymentModel(paymentType: paymentType,
|
|
paymentState: paymentState,
|
|
paymentAmount: paymentAmount,
|
|
createdDate: createdDate,
|
|
senderOrRecipientAci: nil,
|
|
memoMessage: nil,
|
|
isUnread: true,
|
|
interactionUniqueId: nil,
|
|
mobileCoin: mobileCoin)
|
|
}
|
|
|
|
/// Take an ArchivedPayment and any matched spent keys and public keys and rebuild a payment model
|
|
///
|
|
/// The bulk of this is an intentional duplication of `buildPaymentModel`, so as to leave the restore of
|
|
/// non-archived payments untouched and avoid any sublte bugs in extending the existing reconciliation logic.
|
|
///
|
|
/// One note is that, even though most of the mobileCoin information can be sourced from the backup,
|
|
/// the data from the ledger is preferred and helps protect against pieces of information being missing from backups.
|
|
private static func buildArchivedPaymentModel(
|
|
timestamp: UInt64,
|
|
blockActivity: BlockActivity,
|
|
restoredSpentItems: [MCTransactionHistoryItem],
|
|
restoredReceivedItems: [MCTransactionHistoryItem],
|
|
archivedPayment: ArchivedPayment
|
|
) -> TSPaymentModel? {
|
|
let isOutgoing = archivedPayment.direction == .outgoing
|
|
|
|
guard let receipt = archivedPayment.receipt else {
|
|
return nil
|
|
}
|
|
|
|
// Can't restore if the public key is missing. However, due to how ledger payments
|
|
// are matched to archived payments, this shouldn't get this far without a public key
|
|
guard let publicKey = archivedPayment.mobileCoinIdentification?.publicKey else {
|
|
return nil
|
|
}
|
|
|
|
// spentKeyImages and transaction data can be nil for incoming payments, so only validate for outgoing
|
|
let spentKeyImages = archivedPayment.mobileCoinIdentification?.keyImages?.nilIfEmpty
|
|
if isOutgoing {
|
|
if archivedPayment.transaction == nil || spentKeyImages == nil {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
let spentPicoMob = restoredSpentItems.map { $0.amountPicoMob }.reduce(0, +)
|
|
let receivedPicoMob = restoredReceivedItems.map { $0.amountPicoMob }.reduce(0, +)
|
|
let netPicoMob: UInt64
|
|
if isOutgoing {
|
|
netPicoMob = spentPicoMob - receivedPicoMob
|
|
} else {
|
|
netPicoMob = receivedPicoMob - spentPicoMob
|
|
}
|
|
|
|
let paymentAmount = TSPaymentAmount(currency: .mobileCoin, picoMob: netPicoMob)
|
|
let paymentType: TSPaymentType = isOutgoing ? .outgoingRestored : .incomingRestored
|
|
let paymentState: TSPaymentState = (isOutgoing ? .outgoingComplete : .incomingComplete)
|
|
|
|
let ledgerBlockIndex: UInt64 = blockActivity.blockIndex
|
|
let ledgerBlockTimestamp: UInt64 = blockActivity.blockTimestamp ?? 0
|
|
let createdDate = Date(millisecondsSince1970: timestamp)
|
|
|
|
let mobileCoin = MobileCoinPayment(
|
|
recipientPublicAddressData: nil,
|
|
transactionData: archivedPayment.transaction,
|
|
receiptData: receipt,
|
|
incomingTransactionPublicKeys: isOutgoing ? nil : publicKey,
|
|
spentKeyImages: spentKeyImages,
|
|
outputPublicKeys: isOutgoing ? publicKey : nil,
|
|
ledgerBlockTimestamp: ledgerBlockTimestamp,
|
|
ledgerBlockIndex: ledgerBlockIndex,
|
|
feeAmount: nil
|
|
)
|
|
return TSPaymentModel(
|
|
paymentType: paymentType,
|
|
paymentState: paymentState,
|
|
paymentAmount: paymentAmount,
|
|
createdDate: createdDate,
|
|
senderOrRecipientAci: archivedPayment.senderOrRecipientAci.map { AciObjC($0) },
|
|
memoMessage: archivedPayment.note,
|
|
isUnread: false,
|
|
interactionUniqueId: archivedPayment.interactionUniqueId,
|
|
mobileCoin: mobileCoin
|
|
)
|
|
}
|
|
|
|
// When creating "unidentified" payments, the ledger block timestamp
|
|
// might not always be available. In these cases, its important to
|
|
// make a "best guess" about when the transaction occurred in order to:
|
|
//
|
|
// * Ensure correct ordering of the transactions.
|
|
// * Display the "best guess" of when transaction occurred in the UI.
|
|
private static func guesstimateBlockTimestamp(forBlockActivity blockActivity: BlockActivity,
|
|
allBlockActivities: [BlockActivity]) -> UInt64 {
|
|
// A given block has a single timestamp, so we can
|
|
// consult all TXOs sent or received in the same block
|
|
// to find a timestamp.
|
|
if let timestamp = blockActivity.blockTimestamp {
|
|
return timestamp
|
|
}
|
|
|
|
// This should be very rare, and we can fill in the correct
|
|
// value later.
|
|
Logger.warn("Unknown ledgerBlockTimestamp.")
|
|
|
|
// If we don't have a value from FOG, we can make a best guess that
|
|
// at least ensures the correct ordering by examining other known blocks
|
|
// in the ledger. Any blocks with a higher block index should have a
|
|
// higher timestamp.
|
|
let blockIndex = blockActivity.blockIndex
|
|
var timestampUpperBound = Date().ows_millisecondsSince1970
|
|
|
|
for otherBlockActivity in allBlockActivities {
|
|
if otherBlockActivity.blockIndex > blockIndex,
|
|
let timestamp = otherBlockActivity.blockTimestamp {
|
|
timestampUpperBound = min(timestampUpperBound, timestamp)
|
|
}
|
|
}
|
|
return timestampUpperBound - 1
|
|
}
|
|
|
|
private static func cleanUpDatabase(transaction: SDSAnyReadTransaction) throws {
|
|
try cleanUpDatabaseMobileCoin(transaction: transaction)
|
|
}
|
|
|
|
private static func cleanUpDatabaseMobileCoin(transaction: SDSAnyReadTransaction) throws {
|
|
|
|
var unidentifiedPaymentModelsToCull = [String: TSPaymentModel]()
|
|
|
|
func cullPaymentModelsIfUnidentified(_ paymentModels: [TSPaymentModel]) -> UInt {
|
|
var cullCount: UInt = 0
|
|
for paymentModel in paymentModels {
|
|
guard paymentModel.isUnidentified else {
|
|
continue
|
|
}
|
|
guard nil == unidentifiedPaymentModelsToCull[paymentModel.uniqueId] else {
|
|
continue
|
|
}
|
|
unidentifiedPaymentModelsToCull[paymentModel.uniqueId] = paymentModel
|
|
cullCount += 1
|
|
}
|
|
return cullCount
|
|
}
|
|
|
|
func cullUnidentifiedDuplicates(_ map: MultiMap<Data, TSPaymentModel>,
|
|
label: String) {
|
|
for (_, paymentModels) in map {
|
|
guard paymentModels.count > 1 else {
|
|
continue
|
|
}
|
|
let culled = cullPaymentModelsIfUnidentified(paymentModels)
|
|
owsAssertDebug(culled > 0)
|
|
Logger.warn("Culling \(label): \(culled)")
|
|
}
|
|
}
|
|
|
|
let transactionMap = MultiMap<Data, TSPaymentModel>()
|
|
let receiptMap = MultiMap<Data, TSPaymentModel>()
|
|
let incomingTransactionPublicKeyMap = MultiMap<Data, TSPaymentModel>()
|
|
let spentKeyImagesMap = MultiMap<Data, TSPaymentModel>()
|
|
let outputPublicKeys = MultiMap<Data, TSPaymentModel>()
|
|
|
|
let allPaymentModels = TSPaymentModel.anyFetchAll(transaction: transaction)
|
|
for paymentModel in allPaymentModels {
|
|
owsAssertDebug(paymentModel.isFailed == (paymentModel.mobileCoin == nil))
|
|
guard !paymentModel.isFailed,
|
|
let mobileCoin = paymentModel.mobileCoin else {
|
|
// Ignore failed models.
|
|
continue
|
|
}
|
|
if let key = mobileCoin.transactionData {
|
|
transactionMap.add(key: key, value: paymentModel)
|
|
}
|
|
if let key = mobileCoin.receiptData {
|
|
receiptMap.add(key: key, value: paymentModel)
|
|
}
|
|
for key in mobileCoin.incomingTransactionPublicKeys ?? [] {
|
|
incomingTransactionPublicKeyMap.add(key: key, value: paymentModel)
|
|
}
|
|
for key in mobileCoin.spentKeyImages ?? [] {
|
|
spentKeyImagesMap.add(key: key, value: paymentModel)
|
|
}
|
|
for key in mobileCoin.outputPublicKeys ?? [] {
|
|
outputPublicKeys.add(key: key, value: paymentModel)
|
|
}
|
|
}
|
|
|
|
// Cull incoming unidentified payment models which are actually
|
|
// change for an outgoing payment model.
|
|
for (incomingTransactionPublicKey, paymentModels) in incomingTransactionPublicKeyMap {
|
|
let isChange = !outputPublicKeys.values(forKey: incomingTransactionPublicKey).isEmpty
|
|
if isChange {
|
|
let culled = cullPaymentModelsIfUnidentified(paymentModels)
|
|
owsAssertDebug(culled > 0)
|
|
let label = "change"
|
|
Logger.warn("Culling \(label): \(culled)")
|
|
}
|
|
}
|
|
|
|
// Only one payment model should correspond to a given MC transaction.
|
|
cullUnidentifiedDuplicates(transactionMap, label: "transactionMap")
|
|
|
|
// Only one payment model should correspond to a given MC receipt.
|
|
cullUnidentifiedDuplicates(receiptMap, label: "receiptMap")
|
|
|
|
// Only one payment model should correspond to a given MC incoming TXO public key.
|
|
cullUnidentifiedDuplicates(incomingTransactionPublicKeyMap, label: "incomingTransactionPublicKeyMap")
|
|
|
|
// Only one payment model should correspond to a given MC spent key image.
|
|
cullUnidentifiedDuplicates(spentKeyImagesMap, label: "spentKeyImagesMap")
|
|
|
|
// Only one payment model should correspond to a given MC output public key.
|
|
cullUnidentifiedDuplicates(outputPublicKeys, label: "outputPublicKeys")
|
|
|
|
if !unidentifiedPaymentModelsToCull.isEmpty {
|
|
owsFailDebug("Culling payment models: \(unidentifiedPaymentModelsToCull.count)")
|
|
if let transaction = transaction as? SDSAnyWriteTransaction {
|
|
for paymentModel in unidentifiedPaymentModelsToCull.values {
|
|
Logger.info("Culling payment model: \(paymentModel.descriptionForLogs)")
|
|
paymentModel.anyRemove(transaction: transaction)
|
|
}
|
|
} else {
|
|
throw ReconciliationError.unsavedChanges
|
|
}
|
|
}
|
|
}
|
|
|
|
public func replaceAsUnidentified(paymentModel oldPaymentModel: TSPaymentModel,
|
|
transaction: SDSAnyWriteTransaction) {
|
|
guard !oldPaymentModel.isUnidentified else {
|
|
owsFailDebug("Unexpected payment: \(oldPaymentModel.descriptionForLogs)")
|
|
return
|
|
}
|
|
|
|
let paymentType: TSPaymentType
|
|
let paymentState: TSPaymentState
|
|
switch oldPaymentModel.paymentType {
|
|
case .outgoingUnidentified,
|
|
.incomingUnidentified:
|
|
owsFailDebug("Unexpected payment: \(oldPaymentModel.descriptionForLogs)")
|
|
return
|
|
case .incomingPayment,
|
|
.incomingRestored:
|
|
paymentType = .incomingUnidentified
|
|
paymentState = .incomingComplete
|
|
case .outgoingPayment,
|
|
.outgoingRestored,
|
|
.outgoingPaymentNotFromLocalDevice,
|
|
.outgoingTransfer,
|
|
.outgoingDefragmentation,
|
|
.outgoingDefragmentationNotFromLocalDevice:
|
|
paymentType = .outgoingUnidentified
|
|
paymentState = .outgoingComplete
|
|
@unknown default:
|
|
owsFailDebug("Invalid value: \(oldPaymentModel.paymentType.formatted)")
|
|
return
|
|
}
|
|
|
|
oldPaymentModel.anyRemove(transaction: transaction)
|
|
|
|
let spentKeyImages: [Data]? = Array(Set(oldPaymentModel.mobileCoin?.spentKeyImages ?? [])).nilIfEmpty
|
|
let outputPublicKeys: [Data]? = Array(Set(oldPaymentModel.mobileCoin?.outputPublicKeys ?? [])).nilIfEmpty
|
|
|
|
let mobileCoin = MobileCoinPayment(recipientPublicAddressData: nil,
|
|
transactionData: nil,
|
|
receiptData: nil,
|
|
incomingTransactionPublicKeys: oldPaymentModel.mobileCoin?.incomingTransactionPublicKeys,
|
|
spentKeyImages: spentKeyImages,
|
|
outputPublicKeys: outputPublicKeys,
|
|
ledgerBlockTimestamp: oldPaymentModel.mobileCoin?.ledgerBlockTimestamp ?? 0,
|
|
ledgerBlockIndex: oldPaymentModel.mobileCoin?.ledgerBlockIndex ?? 0,
|
|
feeAmount: nil)
|
|
let newPaymentModel = TSPaymentModel(paymentType: paymentType,
|
|
paymentState: paymentState,
|
|
paymentAmount: oldPaymentModel.paymentAmount,
|
|
createdDate: oldPaymentModel.createdDate,
|
|
senderOrRecipientAci: nil,
|
|
memoMessage: nil,
|
|
isUnread: false,
|
|
interactionUniqueId: nil,
|
|
mobileCoin: mobileCoin)
|
|
do {
|
|
try SSKEnvironment.shared.paymentsHelperRef.tryToInsertPaymentModel(newPaymentModel, transaction: transaction)
|
|
} catch {
|
|
owsFailDebug("Error: \(error)")
|
|
}
|
|
|
|
scheduleReconciliationNow(transaction: transaction)
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
internal static func buildPaymentsDatabaseState(transaction: SDSAnyReadTransaction) -> PaymentsDatabaseState {
|
|
let databaseState = PaymentsDatabaseState()
|
|
|
|
TSPaymentModel.anyEnumerate(transaction: transaction,
|
|
batchSize: 100) { (paymentModel, _) in
|
|
databaseState.add(paymentModel: paymentModel)
|
|
}
|
|
|
|
DependenciesBridge.shared.archivedPaymentStore.enumerateAll(tx: transaction.asV2Read) { (archivedPayment, _) in
|
|
databaseState.add(archivedPayment: archivedPayment)
|
|
}
|
|
return databaseState
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
public func willInsertPayment(_ paymentModel: TSPaymentModel, transaction: SDSAnyWriteTransaction) {
|
|
// Cull unidentified payment models which might be replaced by this identified model,
|
|
// then schedule reconciliation pass to create new unidentified payment models if necessary.
|
|
if !paymentModel.isUnidentified,
|
|
paymentModel.mcLedgerBlockIndex > 0 {
|
|
cullUnidentifiedPaymentsInSameBlock(paymentModel, transaction: transaction)
|
|
}
|
|
}
|
|
|
|
public func willUpdatePayment(_ paymentModel: TSPaymentModel, transaction: SDSAnyWriteTransaction) {
|
|
// Cull unidentified payment models which might be replaced by this identified model,
|
|
// then schedule reconciliation pass to create new unidentified payment models if necessary.
|
|
if !paymentModel.isUnidentified,
|
|
paymentModel.mcLedgerBlockIndex > 0 {
|
|
cullUnidentifiedPaymentsInSameBlock(paymentModel, transaction: transaction)
|
|
}
|
|
}
|
|
|
|
private func cullUnidentifiedPaymentsInSameBlock(_ paymentModel: TSPaymentModel, transaction: SDSAnyWriteTransaction) {
|
|
guard !paymentModel.isUnidentified,
|
|
paymentModel.mcLedgerBlockIndex > 0 else {
|
|
owsFailDebug("Invalid paymentModel.")
|
|
return
|
|
}
|
|
|
|
let otherPaymentModels = PaymentFinder.paymentModels(forMcLedgerBlockIndex: paymentModel.mcLedgerBlockIndex,
|
|
transaction: transaction)
|
|
for otherPaymentModel in otherPaymentModels {
|
|
guard otherPaymentModel.isUnidentified else {
|
|
continue
|
|
}
|
|
guard paymentModel.uniqueId != otherPaymentModel.uniqueId else {
|
|
owsFailDebug("Invalid paymentModel.")
|
|
continue
|
|
}
|
|
otherPaymentModel.anyRemove(transaction: transaction)
|
|
scheduleReconciliationNow(transaction: transaction)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension MCTransactionHistoryItem {
|
|
var receivedBlockIndex: UInt64 {
|
|
receivedBlock.index
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension Array where Element == MCTransactionHistoryItem {
|
|
private func sortByBlockIndexBlock(descending: Bool) -> (MCTransactionHistoryItem, MCTransactionHistoryItem) -> Bool {
|
|
return { (left, right) -> Bool in
|
|
if descending {
|
|
return left.receivedBlockIndex > right.receivedBlockIndex
|
|
} else {
|
|
return left.receivedBlockIndex < right.receivedBlockIndex
|
|
}
|
|
}
|
|
}
|
|
|
|
func sortedByBlockIndex(descending: Bool) -> [MCTransactionHistoryItem] {
|
|
sorted(by: sortByBlockIndexBlock(descending: descending))
|
|
}
|
|
|
|
mutating func sortByBlockIndex(descending: Bool) {
|
|
sort(by: sortByBlockIndexBlock(descending: descending))
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
private class BlockActivity {
|
|
let blockIndex: UInt64
|
|
var receivedItems = [MCTransactionHistoryItem]()
|
|
var spentItems = [MCTransactionHistoryItem]()
|
|
|
|
init(blockIndex: UInt64) {
|
|
self.blockIndex = blockIndex
|
|
}
|
|
|
|
func addReceived(item: MCTransactionHistoryItem) {
|
|
owsAssertDebug(receivedItems.filter { $0.txoPublicKey == item.txoPublicKey}.isEmpty)
|
|
|
|
receivedItems.append(item)
|
|
}
|
|
|
|
func addSpent(item: MCTransactionHistoryItem) {
|
|
owsAssertDebug(spentItems.filter { $0.txoPublicKey == item.txoPublicKey}.isEmpty)
|
|
|
|
spentItems.append(item)
|
|
}
|
|
|
|
var blockTimestamp: UInt64? {
|
|
for receivedItem in receivedItems {
|
|
if let timestamp = receivedItem.receivedBlock.timestamp {
|
|
return timestamp.ows_millisecondsSince1970
|
|
}
|
|
}
|
|
for spentItem in spentItems {
|
|
if let timestamp = spentItem.spentBlock?.timestamp {
|
|
return timestamp.ows_millisecondsSince1970
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension Array where Element == BlockActivity {
|
|
private func sortByBlockIndexBlock(descending: Bool) -> (BlockActivity, BlockActivity) -> Bool {
|
|
return { (left, right) -> Bool in
|
|
if descending {
|
|
return left.blockIndex > right.blockIndex
|
|
} else {
|
|
return left.blockIndex < right.blockIndex
|
|
}
|
|
}
|
|
}
|
|
|
|
func sortedByBlockIndex(descending: Bool) -> [BlockActivity] {
|
|
sorted(by: sortByBlockIndexBlock(descending: descending))
|
|
}
|
|
|
|
mutating func sortByBlockIndex(descending: Bool) {
|
|
sort(by: sortByBlockIndexBlock(descending: descending))
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
internal class PaymentsDatabaseState {
|
|
enum PaymentState {
|
|
case model(TSPaymentModel)
|
|
case archivedPayment(ArchivedPayment)
|
|
}
|
|
|
|
var allPaymentState = [PaymentState]()
|
|
|
|
// A map of "received TXO public key" to TSPaymentModel or ArchivedPayment
|
|
// representing an (incoming) transactions.
|
|
var incomingAnyMap = MultiMap<Data, PaymentState>()
|
|
|
|
// A map of "spent TXO image key" to TSPaymentModel or ArchivedPayment
|
|
// representing a (known, outgoing) transactions.
|
|
var spentImageKeyMap = [Data: PaymentState]()
|
|
|
|
// A map of "output TXO public key" to TSPaymentModel or ArchivedPayment
|
|
// representing a (known, outgoing) transactions.
|
|
var outputPublicKeyMap = [Data: PaymentState]()
|
|
|
|
// A map of "ledger block index" to TSPaymentModel or ArchivedPayment for
|
|
// all payment models.
|
|
//
|
|
// TODO: Extend unit test to verify this state.
|
|
var ledgerBlockIndexMap = MultiMap<UInt64, PaymentState>()
|
|
|
|
// MARK: -
|
|
|
|
func add(paymentModel: TSPaymentModel) {
|
|
|
|
owsAssertDebug(paymentModel.isValid)
|
|
|
|
allPaymentState.append(.model(paymentModel))
|
|
|
|
let formattedState = paymentModel.descriptionForLogs
|
|
|
|
guard !paymentModel.isFailed else {
|
|
// Ignore failed payments/transactions.
|
|
return
|
|
}
|
|
|
|
if let incomingTransactionPublicKeys = paymentModel.mobileCoin?.incomingTransactionPublicKeys {
|
|
owsAssertDebug(paymentModel.canHaveMCIncomingTransaction)
|
|
for key in incomingTransactionPublicKeys {
|
|
incomingAnyMap.add(key: key, value: .model(paymentModel))
|
|
}
|
|
} else if paymentModel.shouldHaveMCIncomingTransaction {
|
|
owsFailDebug("Empty or missing mcIncomingTransaction: \(formattedState).")
|
|
}
|
|
|
|
if let mcSpentKeyImages = paymentModel.mcSpentKeyImages {
|
|
owsAssertDebug(paymentModel.canHaveMCSpentKeyImages)
|
|
for spentImageKey in mcSpentKeyImages {
|
|
spentImageKeyMap[spentImageKey] = .model(paymentModel)
|
|
}
|
|
} else if paymentModel.shouldHaveMCSpentKeyImages {
|
|
owsFailDebug("Empty or missing mcSpentKeyImages: \(formattedState).")
|
|
}
|
|
|
|
if let mcOutputPublicKeys = paymentModel.mcOutputPublicKeys {
|
|
owsAssertDebug(paymentModel.canHaveMCOutputPublicKeys)
|
|
for outputPublicKeys in mcOutputPublicKeys {
|
|
outputPublicKeyMap[outputPublicKeys] = .model(paymentModel)
|
|
}
|
|
} else if paymentModel.shouldHaveMCOutputPublicKeys {
|
|
owsFailDebug("Empty or missing mcOutputPublicKeys: \(formattedState).")
|
|
}
|
|
|
|
let ledgerBlockIndex = paymentModel.mobileCoin?.ledgerBlockIndex ?? 0
|
|
if ledgerBlockIndex > 0 {
|
|
ledgerBlockIndexMap.add(key: ledgerBlockIndex, value: .model(paymentModel))
|
|
}
|
|
}
|
|
|
|
func add(archivedPayment: ArchivedPayment) {
|
|
|
|
guard !archivedPayment.status.isFailure else { return }
|
|
|
|
var wasPaymentAdded = false
|
|
switch archivedPayment.direction {
|
|
case .unknown:
|
|
return
|
|
case .incoming:
|
|
archivedPayment.mobileCoinIdentification?.publicKey?
|
|
.filter { item in
|
|
incomingAnyMap[item].isEmpty
|
|
}
|
|
.forEach {
|
|
incomingAnyMap.add(key: $0, value: .archivedPayment(archivedPayment))
|
|
wasPaymentAdded = true
|
|
}
|
|
case .outgoing:
|
|
if archivedPayment.mobileCoinIdentification?.keyImages == nil || archivedPayment.mobileCoinIdentification?.publicKey == nil {
|
|
owsFailDebug("missing data ")
|
|
}
|
|
|
|
archivedPayment.mobileCoinIdentification?.keyImages?
|
|
.filter { item in
|
|
spentImageKeyMap[item] == nil
|
|
}
|
|
.forEach {
|
|
spentImageKeyMap[$0] = .archivedPayment(archivedPayment)
|
|
wasPaymentAdded = true
|
|
}
|
|
archivedPayment.mobileCoinIdentification?.publicKey?
|
|
.filter { item in
|
|
outputPublicKeyMap[item] == nil
|
|
}
|
|
.forEach {
|
|
outputPublicKeyMap[$0] = .archivedPayment(archivedPayment)
|
|
wasPaymentAdded = true
|
|
}
|
|
}
|
|
|
|
if wasPaymentAdded {
|
|
allPaymentState.append(.archivedPayment(archivedPayment))
|
|
}
|
|
|
|
if
|
|
let ledgerBlockIndex = archivedPayment.blockIndex,
|
|
ledgerBlockIndexMap[ledgerBlockIndex].isEmpty
|
|
{
|
|
ledgerBlockIndexMap.add(key: ledgerBlockIndex, value: .archivedPayment(archivedPayment))
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
public class MultiMap<KeyType: Hashable, ValueType>: Sequence {
|
|
public typealias MapType = [KeyType: [ValueType]]
|
|
private var map = MapType()
|
|
|
|
public func add(key: KeyType, value: ValueType) {
|
|
var values = map[key] ?? []
|
|
values.append(value)
|
|
map[key] = values
|
|
}
|
|
|
|
public func values(forKey key: KeyType) -> [ValueType] {
|
|
map[key] ?? []
|
|
}
|
|
|
|
// MARK: - Sequence
|
|
|
|
public typealias Iterator = MapType.Iterator
|
|
|
|
public func makeIterator() -> Iterator {
|
|
map.makeIterator()
|
|
}
|
|
|
|
public var count: Int { map.count }
|
|
|
|
public subscript(_ key: KeyType) -> [ValueType] {
|
|
values(forKey: key)
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
public protocol MCTransactionHistoryItem {
|
|
var amountPicoMob: UInt64 { get }
|
|
|
|
var txoPublicKey: Data { get }
|
|
|
|
var keyImage: Data { get }
|
|
|
|
var receivedBlock: MobileCoin.BlockMetadata { get }
|
|
|
|
var spentBlock: MobileCoin.BlockMetadata? { get }
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
public protocol MCTransactionHistory {
|
|
var items: [MCTransactionHistoryItem] { get }
|
|
|
|
var blockCount: UInt64 { get }
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension MobileCoin.AccountActivity: MCTransactionHistory {
|
|
public var items: [MCTransactionHistoryItem] { Array(txOuts) }
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension MobileCoin.OwnedTxOut: MCTransactionHistoryItem {
|
|
|
|
public var amountPicoMob: UInt64 { value }
|
|
|
|
public var txoPublicKey: Data { publicKey }
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
fileprivate extension MCTransactionHistory {
|
|
// Well-behaved clients should never make TXOs of zero value,
|
|
// but we can't count on that. Therefore we filter records in
|
|
// the SDK transaction history, discarding any zero value TXOs.
|
|
// They have no consequence to the user and this simplifies our
|
|
// logic.
|
|
var safeItems: [MCTransactionHistoryItem] {
|
|
items.filter { $0.amountPicoMob > 0 }
|
|
}
|
|
var receivedItems: [MCTransactionHistoryItem] {
|
|
safeItems
|
|
}
|
|
var spentItems: [MCTransactionHistoryItem] {
|
|
safeItems.filter { $0.spentBlock != nil }
|
|
}
|
|
}
|
|
|
|
//
|
|
|
|
fileprivate extension Array {
|
|
var nilIfEmpty: [Element]? {
|
|
isEmpty ? nil : self
|
|
}
|
|
}
|