1220 lines
51 KiB
Swift
1220 lines
51 KiB
Swift
//
|
|
// Copyright 2021 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import Foundation
|
|
public import LibSignalClient
|
|
public import MobileCoin
|
|
public import SignalServiceKit
|
|
|
|
public class PaymentsImpl: NSObject, PaymentsSwift {
|
|
|
|
private let appReadiness: AppReadiness
|
|
private var refreshBalanceEvent: RefreshEvent?
|
|
|
|
fileprivate let paymentsReconciliation: PaymentsReconciliation
|
|
|
|
private let paymentsProcessor: PaymentsProcessor
|
|
|
|
public static let maxPaymentMemoMessageLength: Int = 32
|
|
|
|
public init(appReadiness: AppReadiness) {
|
|
self.appReadiness = appReadiness
|
|
self.paymentsReconciliation = PaymentsReconciliation(appReadiness: appReadiness)
|
|
self.paymentsProcessor = PaymentsProcessor(appReadiness: appReadiness)
|
|
super.init()
|
|
|
|
// Note: this isn't how often we refresh the balance, it's how often we
|
|
// check whether we should refresh the balance.
|
|
//
|
|
// TODO: Tune.
|
|
let refreshCheckInterval = kMinuteInterval * 5
|
|
refreshBalanceEvent = RefreshEvent(appReadiness: appReadiness, refreshInterval: refreshCheckInterval) { [weak self] in
|
|
self?.updateCurrentPaymentBalanceIfNecessary()
|
|
}
|
|
|
|
MobileCoinAPI.configureSDKLogging()
|
|
|
|
appReadiness.runNowOrWhenAppDidBecomeReadyAsync {
|
|
DispatchQueue.global().async {
|
|
self.updateLastKnownLocalPaymentAddressProtoDataIfNecessary()
|
|
}
|
|
}
|
|
}
|
|
|
|
// NOTE: This k-v store is shared by PaymentsHelperImpl and PaymentsImpl.
|
|
fileprivate static var keyValueStore: KeyValueStore { SSKEnvironment.shared.paymentsHelperRef.keyValueStore}
|
|
fileprivate var keyValueStore: KeyValueStore { SSKEnvironment.shared.paymentsHelperRef.keyValueStore}
|
|
|
|
private func updateLastKnownLocalPaymentAddressProtoDataIfNecessary() {
|
|
guard DependenciesBridge.shared.tsAccountManager.registrationStateWithMaybeSneakyTransaction.isRegistered else {
|
|
return
|
|
}
|
|
guard appReadiness.isAppReady else {
|
|
return
|
|
}
|
|
|
|
let appVersionKey = "appVersion"
|
|
let currentAppVersion = AppVersionImpl.shared.currentAppVersion
|
|
|
|
let shouldUpdate = SSKEnvironment.shared.databaseStorageRef.read { (transaction: SDSAnyReadTransaction) -> Bool in
|
|
// Check if the app version has changed.
|
|
let lastAppVersion = self.keyValueStore.getString(appVersionKey, transaction: transaction.asV2Read)
|
|
guard lastAppVersion == currentAppVersion else {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
guard shouldUpdate else {
|
|
return
|
|
}
|
|
Logger.info("Updating last known local payment address.")
|
|
|
|
SSKEnvironment.shared.databaseStorageRef.write { transaction in
|
|
self.updateLastKnownLocalPaymentAddressProtoData(transaction: transaction)
|
|
|
|
self.keyValueStore.setString(currentAppVersion, key: appVersionKey, transaction: transaction.asV2Write)
|
|
}
|
|
}
|
|
|
|
struct ApiHandle {
|
|
let api: MobileCoinAPI
|
|
let creationDate: Date
|
|
|
|
var hasExpired: Bool {
|
|
// Authentication expires after 24 hours, so we build new
|
|
// API instances every 12 hours.
|
|
let expiration = kHourInterval * 12
|
|
return abs(creationDate.timeIntervalSinceNow) > expiration
|
|
}
|
|
}
|
|
|
|
private static let unfairLock = UnfairLock()
|
|
private var currentApiHandle: ApiHandle?
|
|
|
|
public func didReceiveMCAuthError() {
|
|
discardApiHandle()
|
|
}
|
|
|
|
private func discardApiHandle() {
|
|
Self.unfairLock.withLock {
|
|
currentApiHandle = nil
|
|
}
|
|
}
|
|
|
|
private func getOrBuildCurrentApi(paymentsEntropy: Data) -> Promise<MobileCoinAPI> {
|
|
func getCurrentApi() -> MobileCoinAPI? {
|
|
return Self.unfairLock.withLock { () -> MobileCoinAPI? in
|
|
if let handle = self.currentApiHandle,
|
|
!handle.hasExpired {
|
|
return handle.api
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func setCurrentApi(_ api: MobileCoinAPI) {
|
|
Self.unfairLock.withLock {
|
|
self.currentApiHandle = ApiHandle(api: api, creationDate: Date())
|
|
}
|
|
}
|
|
|
|
if let api = getCurrentApi() {
|
|
return Promise.value(api)
|
|
}
|
|
|
|
return firstly(on: DispatchQueue.global()) {
|
|
MobileCoinAPI.buildPromise(paymentsEntropy: paymentsEntropy)
|
|
}.map(on: DispatchQueue.global()) { (api: MobileCoinAPI) -> MobileCoinAPI in
|
|
setCurrentApi(api)
|
|
return api
|
|
}
|
|
}
|
|
|
|
// Instances of MobileCoinAPI are slightly expensive to
|
|
// build since we need to obtain authentication from
|
|
// the service, so we cache and reuse instances.
|
|
func getMobileCoinAPI() -> Promise<MobileCoinAPI> {
|
|
guard !CurrentAppContext().isNSE else {
|
|
return Promise(error: OWSAssertionError("Payments disabled in NSE."))
|
|
}
|
|
switch paymentsState {
|
|
case .enabled(let paymentsEntropy):
|
|
return getOrBuildCurrentApi(paymentsEntropy: paymentsEntropy)
|
|
case .disabled, .disabledWithPaymentsEntropy:
|
|
return Promise(error: PaymentsError.notEnabled)
|
|
}
|
|
}
|
|
|
|
public var hasValidPhoneNumberForPayments: Bool { SSKEnvironment.shared.paymentsHelperRef.hasValidPhoneNumberForPayments }
|
|
|
|
public var isKillSwitchActive: Bool { SSKEnvironment.shared.paymentsHelperRef.isKillSwitchActive }
|
|
|
|
public var canEnablePayments: Bool { SSKEnvironment.shared.paymentsHelperRef.canEnablePayments }
|
|
|
|
public var shouldShowPaymentsUI: Bool {
|
|
arePaymentsEnabled || canEnablePayments
|
|
}
|
|
|
|
// MARK: - PaymentsState
|
|
|
|
public var paymentsState: PaymentsState {
|
|
SSKEnvironment.shared.paymentsHelperRef.paymentsState
|
|
}
|
|
|
|
public var arePaymentsEnabled: Bool {
|
|
SSKEnvironment.shared.paymentsHelperRef.arePaymentsEnabled
|
|
}
|
|
|
|
public var paymentsEntropy: Data? {
|
|
SSKEnvironment.shared.paymentsHelperRef.paymentsEntropy
|
|
}
|
|
|
|
public var passphrase: PaymentsPassphrase? {
|
|
guard let paymentsEntropy = paymentsEntropy else {
|
|
owsFailDebug("Missing paymentsEntropy.")
|
|
return nil
|
|
}
|
|
return passphrase(forPaymentsEntropy: paymentsEntropy)
|
|
}
|
|
|
|
public func passphrase(forPaymentsEntropy paymentsEntropy: Data) -> PaymentsPassphrase? {
|
|
do {
|
|
return try MobileCoinAPI.passphrase(forPaymentsEntropy: paymentsEntropy)
|
|
} catch {
|
|
owsFailDebug("Error: \(error)")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
public func paymentsEntropy(forPassphrase passphrase: PaymentsPassphrase) -> Data? {
|
|
do {
|
|
return try MobileCoinAPI.paymentsEntropy(forPassphrase: passphrase)
|
|
} catch {
|
|
owsFailDebug("Error: \(error)")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
public func isValidPassphraseWord(_ word: String?) -> Bool {
|
|
MobileCoinAPI.isValidPassphraseWord(word)
|
|
}
|
|
|
|
public func clearState(transaction: SDSAnyWriteTransaction) {
|
|
paymentBalanceCache.set(nil)
|
|
|
|
discardApiHandle()
|
|
}
|
|
|
|
// MARK: - Public Keys
|
|
|
|
public func isValidMobileCoinPublicAddress(_ publicAddressData: Data) -> Bool {
|
|
MobileCoinAPI.isValidMobileCoinPublicAddress(publicAddressData)
|
|
}
|
|
|
|
// MARK: - Balance
|
|
|
|
public static let currentPaymentBalanceDidChange = Notification.Name("currentPaymentBalanceDidChange")
|
|
|
|
private let paymentBalanceCache = AtomicOptional<PaymentBalance>(nil, lock: .sharedGlobal)
|
|
|
|
public var currentPaymentBalance: PaymentBalance? {
|
|
paymentBalanceCache.get()
|
|
}
|
|
|
|
private func setCurrentPaymentBalance(amount: TSPaymentAmount) {
|
|
owsAssertDebug(amount.isValidAmount(canBeEmpty: true))
|
|
|
|
let balance = PaymentBalance(amount: amount, date: Date())
|
|
|
|
let oldBalance = paymentBalanceCache.get()
|
|
|
|
paymentBalanceCache.set(balance)
|
|
|
|
if let oldAmount = oldBalance?.amount,
|
|
oldAmount != amount {
|
|
// When the balance changes, there might be new transactions
|
|
// that aren't accounted for in the database yet. Perform
|
|
// reconciliation to ensure we're up-to-date.
|
|
SSKEnvironment.shared.databaseStorageRef.asyncWrite { transaction in
|
|
self.scheduleReconciliationNow(transaction: transaction)
|
|
}
|
|
}
|
|
|
|
// TODO: We could only fire if the value actually changed.
|
|
NotificationCenter.default.postNotificationNameAsync(Self.currentPaymentBalanceDidChange, object: nil)
|
|
}
|
|
|
|
private var canUsePayments: Bool {
|
|
arePaymentsEnabled && !CurrentAppContext().isNSE
|
|
}
|
|
|
|
// We need to update our balance:
|
|
//
|
|
// * On launch.
|
|
// * Periodically.
|
|
// * After making or receiving payments.
|
|
// * When user navigates into a view that displays the balance.
|
|
public func updateCurrentPaymentBalance() {
|
|
guard canUsePayments else {
|
|
return
|
|
}
|
|
guard
|
|
appReadiness.isAppReady,
|
|
CurrentAppContext().isMainAppAndActive,
|
|
DependenciesBridge.shared.tsAccountManager.registrationStateWithMaybeSneakyTransaction.isRegistered
|
|
else {
|
|
return
|
|
}
|
|
|
|
firstly {
|
|
self.updateCurrentPaymentBalancePromise()
|
|
}.catch { error in
|
|
let paymentsError = error as? PaymentsError
|
|
let outdated = paymentsError == .outdatedClient || paymentsError == .attestationVerificationFailed
|
|
SSKEnvironment.shared.paymentsHelperRef.setPaymentsVersionOutdated(outdated)
|
|
owsFailDebugUnlessMCNetworkFailure(error)
|
|
}
|
|
}
|
|
|
|
public func updateCurrentPaymentBalancePromise() -> Promise<TSPaymentAmount> {
|
|
return firstly { () -> Promise<TSPaymentAmount> in
|
|
self.getCurrentBalance()
|
|
}.map { (balance: TSPaymentAmount) -> TSPaymentAmount in
|
|
self.setCurrentPaymentBalance(amount: balance)
|
|
return balance
|
|
}
|
|
}
|
|
|
|
private func updateCurrentPaymentBalanceIfNecessary() {
|
|
guard CurrentAppContext().isMainApp else {
|
|
return
|
|
}
|
|
if let lastUpdateDate = paymentBalanceCache.get()?.date {
|
|
// Don't bother updating if we've already updated in the last N hours.
|
|
let updateFrequency: TimeInterval = kHourInterval * 4
|
|
guard abs(lastUpdateDate.timeIntervalSinceNow) > updateFrequency else {
|
|
return
|
|
}
|
|
}
|
|
|
|
updateCurrentPaymentBalance()
|
|
}
|
|
|
|
public func clearCurrentPaymentBalance() {
|
|
paymentBalanceCache.set(nil)
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
public func findPaymentModels(withMCLedgerBlockIndex mcLedgerBlockIndex: UInt64,
|
|
mcIncomingTransactionPublicKey: Data,
|
|
transaction: SDSAnyReadTransaction) -> [TSPaymentModel] {
|
|
PaymentFinder.paymentModels(forMcLedgerBlockIndex: mcLedgerBlockIndex,
|
|
transaction: transaction).filter {
|
|
let publicKeys = $0.mobileCoin?.incomingTransactionPublicKeys ?? []
|
|
return publicKeys.contains(mcIncomingTransactionPublicKey)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Operations
|
|
|
|
public extension PaymentsImpl {
|
|
|
|
private func fetchPublicAddress(for recipientAci: Aci) -> Promise<MobileCoin.PublicAddress> {
|
|
return Promise.wrapAsync {
|
|
let profileFetcher = SSKEnvironment.shared.profileFetcherRef
|
|
let fetchedProfile = try await profileFetcher.fetchProfile(for: recipientAci)
|
|
|
|
guard let decryptedProfile = fetchedProfile.decryptedProfile else {
|
|
throw PaymentsError.userHasNoPublicAddress
|
|
}
|
|
|
|
// We don't need to persist this value in the cache; the ProfileFetcher
|
|
// will take care of that.
|
|
guard
|
|
let paymentAddress = decryptedProfile.paymentAddress(identityKey: fetchedProfile.identityKey),
|
|
paymentAddress.isValid,
|
|
paymentAddress.currency == .mobileCoin
|
|
else {
|
|
throw PaymentsError.userHasNoPublicAddress
|
|
}
|
|
do {
|
|
return try paymentAddress.asPublicAddress()
|
|
} catch {
|
|
owsFailDebug("Can't parse public address: \(error)")
|
|
throw PaymentsError.userHasNoPublicAddress
|
|
}
|
|
}
|
|
}
|
|
|
|
private func upsertNewOutgoingPaymentModel(
|
|
recipientAci: Aci?,
|
|
recipientPublicAddress: MobileCoin.PublicAddress,
|
|
paymentAmount: TSPaymentAmount,
|
|
feeAmount: TSPaymentAmount,
|
|
memoMessage: String?,
|
|
transaction: MobileCoin.Transaction,
|
|
receipt: MobileCoin.Receipt,
|
|
isOutgoingTransfer: Bool
|
|
) -> Promise<TSPaymentModel> {
|
|
guard !isKillSwitchActive else {
|
|
return Promise(error: PaymentsError.killSwitch)
|
|
}
|
|
return firstly(on: DispatchQueue.global()) {
|
|
let recipientPublicAddressData = recipientPublicAddress.serializedData
|
|
guard paymentAmount.currency == .mobileCoin,
|
|
paymentAmount.isValidAmount(canBeEmpty: false) else {
|
|
throw OWSAssertionError("Invalid amount.")
|
|
}
|
|
guard feeAmount.currency == .mobileCoin,
|
|
feeAmount.isValidAmount(canBeEmpty: false) else {
|
|
throw OWSAssertionError("Invalid fee.")
|
|
}
|
|
|
|
let mcTransactionData = transaction.serializedData
|
|
let mcReceiptData = receipt.serializedData
|
|
let paymentType: TSPaymentType = isOutgoingTransfer ? .outgoingTransfer : .outgoingPayment
|
|
let inputKeyImages = Array(Set(transaction.inputKeyImages))
|
|
owsAssertDebug(inputKeyImages.count == transaction.inputKeyImages.count)
|
|
let outputPublicKeys = Array(Set(transaction.outputPublicKeys))
|
|
owsAssertDebug(outputPublicKeys.count == transaction.outputPublicKeys.count)
|
|
|
|
let mobileCoin = MobileCoinPayment(recipientPublicAddressData: recipientPublicAddressData,
|
|
transactionData: mcTransactionData,
|
|
receiptData: mcReceiptData,
|
|
incomingTransactionPublicKeys: nil,
|
|
spentKeyImages: inputKeyImages,
|
|
outputPublicKeys: outputPublicKeys,
|
|
ledgerBlockTimestamp: 0,
|
|
ledgerBlockIndex: 0,
|
|
feeAmount: feeAmount)
|
|
|
|
let paymentModel = TSPaymentModel(
|
|
paymentType: paymentType,
|
|
paymentState: .outgoingUnsubmitted,
|
|
paymentAmount: paymentAmount,
|
|
createdDate: Date(),
|
|
senderOrRecipientAci: recipientAci.map { AciObjC($0) },
|
|
memoMessage: memoMessage?.nilIfEmpty,
|
|
isUnread: false,
|
|
interactionUniqueId: nil,
|
|
mobileCoin: mobileCoin
|
|
)
|
|
|
|
guard paymentModel.isValid else {
|
|
throw OWSAssertionError("Invalid paymentModel.")
|
|
}
|
|
|
|
try SSKEnvironment.shared.databaseStorageRef.write { transaction in
|
|
try SSKEnvironment.shared.paymentsHelperRef.tryToInsertPaymentModel(paymentModel, transaction: transaction)
|
|
}
|
|
|
|
return paymentModel
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - TSPaymentAddress
|
|
|
|
public extension PaymentsImpl {
|
|
|
|
private func localMobileCoinAccount(paymentsState: PaymentsState) -> MobileCoinAPI.MobileCoinAccount? {
|
|
guard let paymentsEntropy = paymentsState.paymentsEntropy else {
|
|
owsFailDebug("Missing paymentsEntropy.")
|
|
return nil
|
|
}
|
|
|
|
do {
|
|
return try MobileCoinAPI.buildLocalAccount(paymentsEntropy: paymentsEntropy)
|
|
} catch {
|
|
owsFailDebug("Error: \(error)")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Only valid for the recipient
|
|
func unmaskReceiptAmount(data: Data?) -> Amount? {
|
|
guard let data = data else { return nil }
|
|
let account = localMobileCoinAccount(paymentsState: self.paymentsState)
|
|
guard let accountKey = account?.accountKey else { return nil }
|
|
guard let receipt = Receipt(serializedData: data) else { return nil }
|
|
guard let amount = receipt.validateAndUnmaskAmount(accountKey: accountKey) else { return nil }
|
|
return amount
|
|
}
|
|
|
|
func buildLocalPaymentAddress(paymentsState: PaymentsState) -> TSPaymentAddress? {
|
|
owsAssertDebug(paymentsState.isEnabled)
|
|
|
|
guard let localAccount = self.localMobileCoinAccount(paymentsState: paymentsState) else {
|
|
owsFailDebug("Missing local account.")
|
|
return nil
|
|
}
|
|
return localAccount.accountKey.publicAddress.asPaymentAddress
|
|
}
|
|
|
|
func walletAddressBase58() -> String? {
|
|
let paymentsState = self.paymentsState
|
|
owsAssertDebug(paymentsState.isEnabled)
|
|
guard let localAccount = self.localMobileCoinAccount(paymentsState: paymentsState) else {
|
|
return nil
|
|
}
|
|
return Self.formatAsBase58(publicAddress: localAccount.accountKey.publicAddress)
|
|
}
|
|
|
|
func localPaymentAddressProtoData(paymentsState: PaymentsState, tx: SDSAnyReadTransaction) -> Data? {
|
|
owsAssertDebug(paymentsState.isEnabled)
|
|
|
|
guard let localPaymentAddress = buildLocalPaymentAddress(paymentsState: paymentsState) else {
|
|
owsFailDebug("Missing localPaymentAddress.")
|
|
return nil
|
|
}
|
|
guard localPaymentAddress.isValid, localPaymentAddress.currency == .mobileCoin else {
|
|
owsFailDebug("Invalid localPaymentAddress.")
|
|
return nil
|
|
}
|
|
|
|
do {
|
|
let proto = try localPaymentAddress.buildProto(tx: tx)
|
|
return try proto.serializedData()
|
|
} catch {
|
|
owsFailDebug("Error: \(error)")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func updateLastKnownLocalPaymentAddressProtoData(transaction: SDSAnyWriteTransaction) {
|
|
let data: Data?
|
|
let paymentsState = self.paymentsState
|
|
if paymentsState.isEnabled {
|
|
data = localPaymentAddressProtoData(paymentsState: paymentsState, tx: transaction)
|
|
} else {
|
|
data = nil
|
|
}
|
|
SSKEnvironment.shared.paymentsHelperRef.setLastKnownLocalPaymentAddressProtoData(data, transaction: transaction)
|
|
}
|
|
}
|
|
|
|
// MARK: - Current Balance
|
|
|
|
public extension PaymentsImpl {
|
|
func getCurrentBalance() -> Promise<TSPaymentAmount> {
|
|
firstly { () -> Promise<MobileCoinAPI> in
|
|
self.getMobileCoinAPI()
|
|
}.then(on: DispatchQueue.global()) { (mobileCoinAPI: MobileCoinAPI) in
|
|
return mobileCoinAPI.getLocalBalance()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - PaymentTransaction
|
|
|
|
public extension PaymentsImpl {
|
|
|
|
func maximumPaymentAmount() -> Promise<TSPaymentAmount> {
|
|
return firstly(on: DispatchQueue.global()) { () -> Promise<MobileCoinAPI> in
|
|
self.getMobileCoinAPI()
|
|
}.then(on: DispatchQueue.global()) { (mobileCoinAPI: MobileCoinAPI) -> Promise<TSPaymentAmount> in
|
|
try mobileCoinAPI.maxTransactionAmount()
|
|
}
|
|
}
|
|
|
|
func getEstimatedFee(forPaymentAmount paymentAmount: TSPaymentAmount) -> Promise<TSPaymentAmount> {
|
|
guard paymentAmount.currency == .mobileCoin else {
|
|
return Promise(error: OWSAssertionError("Invalid currency."))
|
|
}
|
|
|
|
return firstly(on: DispatchQueue.global()) { () -> Promise<MobileCoinAPI> in
|
|
self.getMobileCoinAPI()
|
|
}.then(on: DispatchQueue.global()) { (mobileCoinAPI: MobileCoinAPI) -> Promise<TSPaymentAmount> in
|
|
try mobileCoinAPI.getEstimatedFee(forPaymentAmount: paymentAmount)
|
|
}
|
|
}
|
|
|
|
func prepareOutgoingPayment(
|
|
recipient: SendPaymentRecipient,
|
|
paymentAmount: TSPaymentAmount,
|
|
memoMessage: String?,
|
|
isOutgoingTransfer: Bool,
|
|
canDefragment: Bool
|
|
) -> Promise<PreparedPayment> {
|
|
|
|
guard !isKillSwitchActive else {
|
|
return Promise(error: PaymentsError.killSwitch)
|
|
}
|
|
guard let recipient = recipient as? SendPaymentRecipientImpl else {
|
|
return Promise(error: OWSAssertionError("Invalid recipient."))
|
|
}
|
|
|
|
switch recipient {
|
|
case .address(let recipientAddress):
|
|
// Cannot send "user-to-user" payment if kill switch is active.
|
|
guard !SUIEnvironment.shared.paymentsRef.isKillSwitchActive else {
|
|
return Promise(error: PaymentsError.killSwitch)
|
|
}
|
|
|
|
guard let recipientAci = recipientAddress.serviceId as? Aci else {
|
|
return Promise(error: PaymentsError.userHasNoPublicAddress)
|
|
}
|
|
|
|
return firstly(on: DispatchQueue.global()) { () -> Promise<MobileCoin.PublicAddress> in
|
|
self.fetchPublicAddress(for: recipientAci)
|
|
}.then(on: DispatchQueue.global()) { (recipientPublicAddress: MobileCoin.PublicAddress) -> Promise<PreparedPayment> in
|
|
self.prepareOutgoingPayment(
|
|
recipientAci: recipientAci,
|
|
recipientPublicAddress: recipientPublicAddress,
|
|
paymentAmount: paymentAmount,
|
|
memoMessage: memoMessage,
|
|
isOutgoingTransfer: isOutgoingTransfer,
|
|
canDefragment: canDefragment
|
|
)
|
|
}
|
|
case .publicAddress(let recipientPublicAddress):
|
|
return prepareOutgoingPayment(
|
|
recipientAci: nil,
|
|
recipientPublicAddress: recipientPublicAddress,
|
|
paymentAmount: paymentAmount,
|
|
memoMessage: memoMessage,
|
|
isOutgoingTransfer: isOutgoingTransfer,
|
|
canDefragment: canDefragment
|
|
)
|
|
}
|
|
}
|
|
|
|
private func prepareOutgoingPayment(
|
|
recipientAci: Aci?,
|
|
recipientPublicAddress: MobileCoin.PublicAddress,
|
|
paymentAmount: TSPaymentAmount,
|
|
memoMessage: String?,
|
|
isOutgoingTransfer: Bool,
|
|
canDefragment: Bool
|
|
) -> Promise<PreparedPayment> {
|
|
|
|
guard !isKillSwitchActive else {
|
|
return Promise(error: PaymentsError.killSwitch)
|
|
}
|
|
guard paymentAmount.currency == .mobileCoin else {
|
|
return Promise(error: OWSAssertionError("Invalid currency."))
|
|
}
|
|
guard recipientAci != DependenciesBridge.shared.tsAccountManager.localIdentifiersWithMaybeSneakyTransaction?.aci else {
|
|
return Promise(error: OWSAssertionError("Can't make payment to yourself."))
|
|
}
|
|
|
|
return firstly(on: DispatchQueue.global()) { () -> Promise<MobileCoinAPI> in
|
|
self.getMobileCoinAPI()
|
|
}.then(on: DispatchQueue.global()) { (mobileCoinAPI: MobileCoinAPI) -> Promise<PreparedPayment> in
|
|
return firstly(on: DispatchQueue.global()) { () throws -> Promise<TSPaymentAmount> in
|
|
// prepareTransaction() will fail if local balance is not yet known.
|
|
mobileCoinAPI.getLocalBalance()
|
|
}.then(on: DispatchQueue.global()) { (balance: TSPaymentAmount) -> Promise<Void> in
|
|
return self.defragmentIfNecessary(forPaymentAmount: paymentAmount,
|
|
mobileCoinAPI: mobileCoinAPI,
|
|
canDefragment: canDefragment)
|
|
}.then(on: DispatchQueue.global()) { () -> Promise<MobileCoinAPI.PreparedTransaction> in
|
|
// prepareTransaction() will fail if local balance is not yet known.
|
|
let shouldUpdateBalance = self.currentPaymentBalance == nil
|
|
return mobileCoinAPI.prepareTransaction(paymentAmount: paymentAmount,
|
|
recipientPublicAddress: recipientPublicAddress,
|
|
shouldUpdateBalance: shouldUpdateBalance)
|
|
}.map(on: DispatchQueue.global()) { (preparedTransaction: MobileCoinAPI.PreparedTransaction) -> PreparedPayment in
|
|
PreparedPaymentImpl(
|
|
recipientAci: recipientAci,
|
|
recipientPublicAddress: recipientPublicAddress,
|
|
paymentAmount: paymentAmount,
|
|
memoMessage: memoMessage,
|
|
isOutgoingTransfer: isOutgoingTransfer,
|
|
preparedTransaction: preparedTransaction
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func defragmentIfNecessary(forPaymentAmount paymentAmount: TSPaymentAmount,
|
|
mobileCoinAPI: MobileCoinAPI,
|
|
canDefragment: Bool) -> Promise<Void> {
|
|
return firstly(on: DispatchQueue.global()) { () throws -> Promise<Bool> in
|
|
mobileCoinAPI.requiresDefragmentation(forPaymentAmount: paymentAmount)
|
|
}.then(on: DispatchQueue.global()) { (shouldDefragment: Bool) -> Promise<Void> in
|
|
guard shouldDefragment else {
|
|
return Promise.value(())
|
|
}
|
|
guard canDefragment else {
|
|
throw PaymentsError.defragmentationRequired
|
|
}
|
|
return self.defragment(forPaymentAmount: paymentAmount,
|
|
mobileCoinAPI: mobileCoinAPI)
|
|
}
|
|
}
|
|
|
|
private func defragment(forPaymentAmount paymentAmount: TSPaymentAmount,
|
|
mobileCoinAPI: MobileCoinAPI) -> Promise<Void> {
|
|
Logger.info("")
|
|
|
|
// 1. Prepare defragmentation transactions.
|
|
// 2. Record defragmentation transactions in database.
|
|
// 3. Submit defragmentation transactions (payment processor will do this).
|
|
// 4. Verify defragmentation transactions (payment processor will do this).
|
|
// 5. Block on verification of defragmentation transactions.
|
|
return firstly(on: DispatchQueue.global()) { () throws -> Promise<[MobileCoin.Transaction]> in
|
|
mobileCoinAPI.prepareDefragmentationStepTransactions(forPaymentAmount: paymentAmount)
|
|
}.map(on: DispatchQueue.global()) { (mcTransactions: [MobileCoin.Transaction]) -> [TSPaymentModel] in
|
|
Logger.info("mcTransactions: \(mcTransactions.count)")
|
|
|
|
// To initiate the defragmentation transactions, all we need to do
|
|
// is save TSPaymentModels to the database. The PaymentsProcessor
|
|
// will observe this and take responsibility for their submission,
|
|
// verification.
|
|
return try SSKEnvironment.shared.databaseStorageRef.write { dbTransaction in
|
|
try mcTransactions.map { mcTransaction in
|
|
let paymentAmount = TSPaymentAmount(currency: .mobileCoin, picoMob: 0)
|
|
let feeAmount = TSPaymentAmount(currency: .mobileCoin, picoMob: mcTransaction.fee)
|
|
let mcTransactionData = mcTransaction.serializedData
|
|
let inputKeyImages = Array(Set(mcTransaction.inputKeyImages))
|
|
owsAssertDebug(inputKeyImages.count == mcTransaction.inputKeyImages.count)
|
|
let outputPublicKeys = Array(Set(mcTransaction.outputPublicKeys))
|
|
owsAssertDebug(outputPublicKeys.count == mcTransaction.outputPublicKeys.count)
|
|
let mobileCoin = MobileCoinPayment(recipientPublicAddressData: nil,
|
|
transactionData: mcTransactionData,
|
|
receiptData: nil,
|
|
incomingTransactionPublicKeys: nil,
|
|
spentKeyImages: inputKeyImages,
|
|
outputPublicKeys: outputPublicKeys,
|
|
ledgerBlockTimestamp: 0,
|
|
ledgerBlockIndex: 0,
|
|
feeAmount: feeAmount)
|
|
|
|
let paymentModel = TSPaymentModel(paymentType: .outgoingDefragmentation,
|
|
paymentState: .outgoingUnsubmitted,
|
|
paymentAmount: paymentAmount,
|
|
createdDate: Date(),
|
|
senderOrRecipientAci: nil,
|
|
memoMessage: nil,
|
|
isUnread: false,
|
|
interactionUniqueId: nil,
|
|
mobileCoin: mobileCoin)
|
|
|
|
guard paymentModel.isValid else {
|
|
throw OWSAssertionError("Invalid paymentModel.")
|
|
}
|
|
|
|
try SSKEnvironment.shared.paymentsHelperRef.tryToInsertPaymentModel(paymentModel, transaction: dbTransaction)
|
|
|
|
return paymentModel
|
|
}
|
|
}
|
|
}.then(on: DispatchQueue.global()) { (paymentModels: [TSPaymentModel]) -> Promise<Void> in
|
|
self.blockOnVerificationOfDefragmentation(paymentModels: paymentModels)
|
|
}
|
|
}
|
|
|
|
func initiateOutgoingPayment(preparedPayment: PreparedPayment) -> Promise<TSPaymentModel> {
|
|
guard !isKillSwitchActive else {
|
|
return Promise(error: PaymentsError.killSwitch)
|
|
}
|
|
return firstly(on: DispatchQueue.global()) { () -> Promise<TSPaymentModel> in
|
|
guard let preparedPayment = preparedPayment as? PreparedPaymentImpl else {
|
|
throw OWSAssertionError("Invalid preparedPayment.")
|
|
}
|
|
let preparedTransaction = preparedPayment.preparedTransaction
|
|
|
|
// To initiate the outgoing payment, all we need to do is save
|
|
// the TSPaymentModel to the database. The PaymentsProcessor
|
|
// will observe this and take responsibility for the submission,
|
|
// verification and notification of the payment.
|
|
return self.upsertNewOutgoingPaymentModel(
|
|
recipientAci: preparedPayment.recipientAci,
|
|
recipientPublicAddress: preparedPayment.recipientPublicAddress,
|
|
paymentAmount: preparedPayment.paymentAmount,
|
|
feeAmount: preparedTransaction.feeAmount,
|
|
memoMessage: preparedPayment.memoMessage,
|
|
transaction: preparedTransaction.transaction,
|
|
receipt: preparedTransaction.receipt,
|
|
isOutgoingTransfer: preparedPayment.isOutgoingTransfer
|
|
)
|
|
}
|
|
}
|
|
|
|
private func blockOnVerificationOfDefragmentation(paymentModels: [TSPaymentModel]) -> Promise<Void> {
|
|
let maxBlockInterval = kSecondInterval * 30
|
|
|
|
return firstly(on: DispatchQueue.global()) { () -> Promise<Void> in
|
|
let promises = paymentModels.map { paymentModel in
|
|
firstly(on: DispatchQueue.global()) { () -> Promise<Bool> in
|
|
self.blockOnOutgoingVerification(paymentModel: paymentModel)
|
|
}.map(on: DispatchQueue.global()) { (didSucceed: Bool) -> Void in
|
|
guard didSucceed else {
|
|
throw PaymentsError.defragmentationFailed
|
|
}
|
|
}
|
|
}
|
|
return Promise.when(fulfilled: promises)
|
|
}.timeout(seconds: maxBlockInterval, description: "blockOnVerificationOfDefragmentation") { () -> Error in
|
|
PaymentsError.timeout
|
|
}
|
|
}
|
|
|
|
func blockOnOutgoingVerification(paymentModel: TSPaymentModel) -> Promise<Bool> {
|
|
firstly(on: DispatchQueue.global()) { () -> Promise<Bool> in
|
|
let paymentModelLatest = SSKEnvironment.shared.databaseStorageRef.read { transaction in
|
|
TSPaymentModel.anyFetch(uniqueId: paymentModel.uniqueId,
|
|
transaction: transaction)
|
|
}
|
|
guard let paymentModel = paymentModelLatest else {
|
|
throw PaymentsError.missingModel
|
|
}
|
|
|
|
switch paymentModel.paymentState {
|
|
case .outgoingUnsubmitted,
|
|
.outgoingUnverified:
|
|
// Not yet verified, wait then try again.
|
|
return firstly(on: DispatchQueue.global()) {
|
|
Guarantee.after(seconds: 0.05)
|
|
}.then(on: DispatchQueue.global()) { () -> Promise<Bool> in
|
|
// Recurse.
|
|
self.blockOnOutgoingVerification(paymentModel: paymentModel)
|
|
}
|
|
case .outgoingVerified,
|
|
.outgoingSending,
|
|
.outgoingSent,
|
|
.outgoingComplete:
|
|
// Success: Verified.
|
|
return Promise.value(true)
|
|
case .outgoingFailed:
|
|
// Success: Failed.
|
|
return Promise.value(false)
|
|
case .incomingUnverified,
|
|
.incomingVerified,
|
|
.incomingComplete,
|
|
.incomingFailed:
|
|
owsFailDebug("Unexpected paymentState: \(paymentModel.descriptionForLogs)")
|
|
throw PaymentsError.invalidModel
|
|
@unknown default:
|
|
owsFailDebug("Invalid paymentState: \(paymentModel.descriptionForLogs)")
|
|
throw PaymentsError.invalidModel
|
|
}
|
|
}
|
|
}
|
|
|
|
class func sendDefragmentationSyncMessage(paymentModel: TSPaymentModel,
|
|
transaction: SDSAnyWriteTransaction) {
|
|
guard paymentModel.isDefragmentation else {
|
|
owsFailDebug("Invalid paymentType.")
|
|
return
|
|
}
|
|
guard let paymentAmount = paymentModel.paymentAmount,
|
|
paymentAmount.currency == .mobileCoin,
|
|
paymentAmount.isValidAmount(canBeEmpty: true),
|
|
paymentAmount.picoMob == 0 else {
|
|
owsFailDebug("Missing or invalid paymentAmount.")
|
|
return
|
|
}
|
|
guard let feeAmount = paymentModel.mobileCoin?.feeAmount,
|
|
feeAmount.currency == .mobileCoin,
|
|
feeAmount.isValidAmount(canBeEmpty: false) else {
|
|
owsFailDebug("Missing or invalid feeAmount.")
|
|
return
|
|
}
|
|
guard let mcTransactionData = paymentModel.mcTransactionData,
|
|
!mcTransactionData.isEmpty,
|
|
let mcTransaction = MobileCoin.Transaction(serializedData: mcTransactionData) else {
|
|
if DebugFlags.paymentsIgnoreBadData.get() {
|
|
Logger.warn("Missing or invalid mcTransactionData.")
|
|
} else {
|
|
owsFailDebug("Missing or invalid mcTransactionData.")
|
|
}
|
|
return
|
|
}
|
|
guard let mcReceiptData = paymentModel.mcReceiptData,
|
|
!mcReceiptData.isEmpty,
|
|
nil != MobileCoin.Receipt(serializedData: mcReceiptData) else {
|
|
if DebugFlags.paymentsIgnoreBadData.get() {
|
|
Logger.warn("Missing or invalid mcReceiptData.")
|
|
} else {
|
|
owsFailDebug("Missing or invalid mcReceiptData.")
|
|
}
|
|
return
|
|
}
|
|
let mcSpentKeyImages = Array(mcTransaction.inputKeyImages)
|
|
guard !mcSpentKeyImages.isEmpty else {
|
|
if DebugFlags.paymentsIgnoreBadData.get() {
|
|
Logger.warn("Missing or invalid mcSpentKeyImages.")
|
|
} else {
|
|
owsFailDebug("Missing or invalid mcSpentKeyImages.")
|
|
}
|
|
return
|
|
}
|
|
let mcOutputPublicKeys = Array(mcTransaction.outputPublicKeys)
|
|
guard !mcOutputPublicKeys.isEmpty else {
|
|
if DebugFlags.paymentsIgnoreBadData.get() {
|
|
Logger.warn("Missing or invalid mcOutputPublicKeys.")
|
|
} else {
|
|
owsFailDebug("Missing or invalid mcOutputPublicKeys.")
|
|
}
|
|
return
|
|
}
|
|
|
|
_ = sendOutgoingPaymentSyncMessage(recipientAci: nil,
|
|
recipientAddress: nil,
|
|
paymentAmount: paymentAmount,
|
|
feeAmount: feeAmount,
|
|
mcLedgerBlockTimestamp: paymentModel.mcLedgerBlockTimestamp,
|
|
mcLedgerBlockIndex: paymentModel.mcLedgerBlockIndex,
|
|
memoMessage: nil,
|
|
mcSpentKeyImages: mcSpentKeyImages,
|
|
mcOutputPublicKeys: mcOutputPublicKeys,
|
|
mcReceiptData: mcReceiptData,
|
|
isDefragmentation: true,
|
|
transaction: transaction)
|
|
}
|
|
|
|
class func sendPaymentNotificationMessage(paymentModel: TSPaymentModel,
|
|
transaction: SDSAnyWriteTransaction) throws -> OWSOutgoingPaymentMessage {
|
|
guard paymentModel.paymentType == .outgoingPayment else {
|
|
owsFailDebug("Invalid paymentType.")
|
|
throw PaymentsError.invalidModel
|
|
}
|
|
guard paymentModel.paymentState == .outgoingVerified ||
|
|
DebugFlags.paymentsDoubleNotify.get() else {
|
|
owsFailDebug("Invalid paymentState: \(paymentModel.paymentState.formatted).")
|
|
throw PaymentsError.invalidModel
|
|
}
|
|
guard let paymentAmount = paymentModel.paymentAmount else {
|
|
owsFailDebug("Missing paymentAmount.")
|
|
throw PaymentsError.invalidModel
|
|
}
|
|
guard paymentAmount.currency == .mobileCoin else {
|
|
owsFailDebug("Invalid currency.")
|
|
throw PaymentsError.invalidModel
|
|
}
|
|
guard paymentAmount.picoMob > 0 else {
|
|
owsFailDebug("Invalid amount.")
|
|
throw PaymentsError.invalidModel
|
|
}
|
|
guard let recipientAci = paymentModel.senderOrRecipientAci?.wrappedAciValue else {
|
|
owsFailDebug("Invalid recipientAci.")
|
|
throw PaymentsError.invalidModel
|
|
}
|
|
guard let mcTransactionData = paymentModel.mcTransactionData,
|
|
mcTransactionData.count > 0 else {
|
|
owsFailDebug("Missing mcTransactionData.")
|
|
throw PaymentsError.invalidModel
|
|
}
|
|
guard let mcReceiptData = paymentModel.mcReceiptData,
|
|
mcReceiptData.count > 0 else {
|
|
if DebugFlags.paymentsIgnoreBadData.get() {
|
|
Logger.warn("Missing mcReceiptData.")
|
|
} else {
|
|
owsFailDebug("Missing mcReceiptData.")
|
|
}
|
|
throw PaymentsError.invalidModel
|
|
}
|
|
|
|
let message = self.sendPaymentNotificationMessage(
|
|
paymentModel: paymentModel,
|
|
recipientAci: recipientAci,
|
|
memoMessage: paymentModel.memoMessage,
|
|
mcReceiptData: mcReceiptData,
|
|
transaction: transaction
|
|
)
|
|
return message
|
|
}
|
|
|
|
class func sendOutgoingPaymentSyncMessage(paymentModel: TSPaymentModel,
|
|
transaction: SDSAnyWriteTransaction) {
|
|
|
|
guard let recipientAci = paymentModel.senderOrRecipientAci else {
|
|
owsFailDebug("Missing recipientAci.")
|
|
return
|
|
}
|
|
guard let recipientAddress = paymentModel.mobileCoin?.recipientPublicAddressData else {
|
|
owsFailDebug("Missing recipientAddress.")
|
|
return
|
|
}
|
|
guard paymentModel.paymentType == .outgoingPayment else {
|
|
owsFailDebug("Invalid paymentType.")
|
|
return
|
|
}
|
|
guard let paymentAmount = paymentModel.paymentAmount,
|
|
paymentAmount.currency == .mobileCoin,
|
|
paymentAmount.isValidAmount(canBeEmpty: false) else {
|
|
owsFailDebug("Missing or invalid paymentAmount.")
|
|
return
|
|
}
|
|
guard let feeAmount = paymentModel.mobileCoin?.feeAmount,
|
|
feeAmount.currency == .mobileCoin,
|
|
feeAmount.isValidAmount(canBeEmpty: false) else {
|
|
owsFailDebug("Missing or invalid feeAmount.")
|
|
return
|
|
}
|
|
guard let mcReceiptData = paymentModel.mcReceiptData,
|
|
!mcReceiptData.isEmpty,
|
|
nil != MobileCoin.Receipt(serializedData: mcReceiptData) else {
|
|
if DebugFlags.paymentsIgnoreBadData.get() {
|
|
Logger.warn("Missing mcReceiptData.")
|
|
} else {
|
|
owsFailDebug("Missing mcReceiptData.")
|
|
}
|
|
return
|
|
}
|
|
guard let mcTransactionData = paymentModel.mcTransactionData,
|
|
!mcTransactionData.isEmpty,
|
|
let mcTransaction = MobileCoin.Transaction(serializedData: mcTransactionData) else {
|
|
if DebugFlags.paymentsIgnoreBadData.get() {
|
|
Logger.warn("Missing or invalid mcTransactionData.")
|
|
} else {
|
|
owsFailDebug("Missing or invalid mcTransactionData.")
|
|
}
|
|
return
|
|
}
|
|
let mcSpentKeyImages = Array(mcTransaction.inputKeyImages)
|
|
guard !mcSpentKeyImages.isEmpty else {
|
|
if DebugFlags.paymentsIgnoreBadData.get() {
|
|
Logger.warn("Missing or invalid mcSpentKeyImages.")
|
|
} else {
|
|
owsFailDebug("Missing or invalid mcSpentKeyImages.")
|
|
}
|
|
return
|
|
}
|
|
let mcOutputPublicKeys = Array(mcTransaction.outputPublicKeys)
|
|
guard !mcOutputPublicKeys.isEmpty else {
|
|
if DebugFlags.paymentsIgnoreBadData.get() {
|
|
Logger.warn("Missing or invalid mcOutputPublicKeys.")
|
|
} else {
|
|
owsFailDebug("Missing or invalid mcOutputPublicKeys.")
|
|
}
|
|
return
|
|
}
|
|
_ = sendOutgoingPaymentSyncMessage(recipientAci: recipientAci.wrappedAciValue,
|
|
recipientAddress: recipientAddress,
|
|
paymentAmount: paymentAmount,
|
|
feeAmount: feeAmount,
|
|
mcLedgerBlockTimestamp: paymentModel.mcLedgerBlockTimestamp,
|
|
mcLedgerBlockIndex: paymentModel.mcLedgerBlockIndex,
|
|
memoMessage: paymentModel.memoMessage,
|
|
mcSpentKeyImages: mcSpentKeyImages,
|
|
mcOutputPublicKeys: mcOutputPublicKeys,
|
|
mcReceiptData: mcReceiptData,
|
|
isDefragmentation: false,
|
|
transaction: transaction)
|
|
|
|
}
|
|
}
|
|
|
|
// MARK: - Messages
|
|
|
|
public extension PaymentsImpl {
|
|
private class func sendPaymentNotificationMessage(
|
|
paymentModel: TSPaymentModel,
|
|
recipientAci: Aci,
|
|
memoMessage: String?,
|
|
mcReceiptData: Data,
|
|
transaction: SDSAnyWriteTransaction
|
|
) -> OWSOutgoingPaymentMessage {
|
|
|
|
if
|
|
let paymentModel = TSPaymentModel.anyFetch(uniqueId: paymentModel.uniqueId, transaction: transaction),
|
|
let interactionUniqueId = paymentModel.interactionUniqueId
|
|
{
|
|
if
|
|
let existingInteraction = TSInteraction.anyFetch(uniqueId: interactionUniqueId, transaction: transaction),
|
|
let message = existingInteraction as? OWSOutgoingPaymentMessage
|
|
{
|
|
// We already have a message, no need to send anything.
|
|
return message
|
|
} else {
|
|
owsFailBeta("Missing or incorrect interaction type")
|
|
}
|
|
}
|
|
|
|
let thread = TSContactThread.getOrCreateThread(
|
|
withContactAddress: SignalServiceAddress(recipientAci),
|
|
transaction: transaction
|
|
)
|
|
let paymentNotification = TSPaymentNotification(
|
|
memoMessage: memoMessage,
|
|
mcReceiptData: mcReceiptData
|
|
)
|
|
let dmConfigurationStore = DependenciesBridge.shared.disappearingMessagesConfigurationStore
|
|
let dmConfig = dmConfigurationStore.fetchOrBuildDefault(for: .thread(thread), tx: transaction.asV2Read)
|
|
|
|
let messageBody: String? = {
|
|
guard let picoMob = paymentModel.paymentAmount?.picoMob else {
|
|
return nil
|
|
}
|
|
// Reverse type direction, so it reads correctly incoming to the recipient.
|
|
return PaymentsFormat.paymentPreviewText(
|
|
amount: picoMob,
|
|
transaction: transaction,
|
|
type: .incomingMessage
|
|
)
|
|
}()
|
|
|
|
let message = OWSOutgoingPaymentMessage(
|
|
thread: thread,
|
|
messageBody: messageBody,
|
|
paymentNotification: paymentNotification,
|
|
expiresInSeconds: dmConfig.durationSeconds,
|
|
expireTimerVersion: NSNumber(value: dmConfig.timerVersion),
|
|
transaction: transaction
|
|
)
|
|
|
|
paymentModel.update(withInteractionUniqueId: message.uniqueId, transaction: transaction)
|
|
// No attachments to add.
|
|
let unpreparedMessage = UnpreparedOutgoingMessage.forMessage(message)
|
|
|
|
ThreadUtil.enqueueMessage(
|
|
unpreparedMessage,
|
|
thread: thread
|
|
)
|
|
|
|
return message
|
|
}
|
|
|
|
class func sendOutgoingPaymentSyncMessage(recipientAci: Aci?,
|
|
recipientAddress: Data?,
|
|
paymentAmount: TSPaymentAmount,
|
|
feeAmount: TSPaymentAmount,
|
|
mcLedgerBlockTimestamp: UInt64?,
|
|
mcLedgerBlockIndex: UInt64?,
|
|
memoMessage: String?,
|
|
mcSpentKeyImages: [Data],
|
|
mcOutputPublicKeys: [Data],
|
|
mcReceiptData: Data,
|
|
isDefragmentation: Bool,
|
|
transaction: SDSAnyWriteTransaction) -> TSOutgoingMessage? {
|
|
|
|
guard let thread = TSContactThread.getOrCreateLocalThread(transaction: transaction) else {
|
|
owsFailDebug("Missing local thread.")
|
|
return nil
|
|
}
|
|
let mobileCoin = OutgoingPaymentMobileCoin(
|
|
recipientAci: recipientAci.map { AciObjC($0) },
|
|
recipientAddress: recipientAddress,
|
|
amountPicoMob: paymentAmount.picoMob,
|
|
feePicoMob: feeAmount.picoMob,
|
|
blockIndex: mcLedgerBlockIndex ?? 0,
|
|
blockTimestamp: mcLedgerBlockTimestamp ?? 0,
|
|
memoMessage: memoMessage?.nilIfEmpty,
|
|
spentKeyImages: mcSpentKeyImages,
|
|
outputPublicKeys: mcOutputPublicKeys,
|
|
receiptData: mcReceiptData,
|
|
isDefragmentation: isDefragmentation
|
|
)
|
|
let message = OutgoingPaymentSyncMessage(
|
|
thread: thread,
|
|
mobileCoin: mobileCoin,
|
|
transaction: transaction
|
|
)
|
|
let preparedMessage = PreparedOutgoingMessage.preprepared(
|
|
transientMessageWithoutAttachments: message
|
|
)
|
|
SSKEnvironment.shared.messageSenderJobQueueRef.add(message: preparedMessage, transaction: transaction)
|
|
return message
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
public class PaymentsEventsMainApp: NSObject, PaymentsEvents {
|
|
public func willInsertPayment(_ paymentModel: TSPaymentModel, transaction: SDSAnyWriteTransaction) {
|
|
let payments = SUIEnvironment.shared.paymentsRef as! PaymentsImpl
|
|
|
|
payments.paymentsReconciliation.willInsertPayment(paymentModel, transaction: transaction)
|
|
|
|
// If we're inserting a new payment of any kind, our balance may have changed.
|
|
payments.updateCurrentPaymentBalance()
|
|
}
|
|
|
|
public func willUpdatePayment(_ paymentModel: TSPaymentModel, transaction: SDSAnyWriteTransaction) {
|
|
let payments = SUIEnvironment.shared.paymentsRef as! PaymentsImpl
|
|
payments.paymentsReconciliation.willUpdatePayment(paymentModel, transaction: transaction)
|
|
}
|
|
|
|
public func updateLastKnownLocalPaymentAddressProtoData(transaction: SDSAnyWriteTransaction) {
|
|
SUIEnvironment.shared.paymentsImplRef.updateLastKnownLocalPaymentAddressProtoData(transaction: transaction)
|
|
}
|
|
|
|
public func paymentsStateDidChange() {
|
|
SUIEnvironment.shared.paymentsImplRef.updateCurrentPaymentBalance()
|
|
}
|
|
|
|
public func clearState(transaction: SDSAnyWriteTransaction) {
|
|
SSKEnvironment.shared.paymentsHelperRef.clearState(transaction: transaction)
|
|
SUIEnvironment.shared.paymentsRef.clearState(transaction: transaction)
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
public extension PaymentsImpl {
|
|
|
|
func scheduleReconciliationNow(transaction: SDSAnyWriteTransaction) {
|
|
paymentsReconciliation.scheduleReconciliationNow(transaction: transaction)
|
|
}
|
|
|
|
func replaceAsUnidentified(paymentModel oldPaymentModel: TSPaymentModel,
|
|
transaction: SDSAnyWriteTransaction) {
|
|
paymentsReconciliation.replaceAsUnidentified(paymentModel: oldPaymentModel,
|
|
transaction: transaction)
|
|
}
|
|
|
|
// MARK: - URLs
|
|
|
|
static func formatAsBase58(publicAddress: MobileCoin.PublicAddress) -> String {
|
|
MobileCoinAPI.formatAsBase58(publicAddress: publicAddress)
|
|
}
|
|
|
|
static func parseAsPublicAddress(url: URL) -> MobileCoin.PublicAddress? {
|
|
return MobileCoinAPI.parseAsPublicAddress(url: url)
|
|
}
|
|
|
|
static func parse(publicAddressBase58 base58: String) -> MobileCoin.PublicAddress? {
|
|
MobileCoinAPI.parse(publicAddressBase58: base58)
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
public enum SendPaymentRecipientImpl: SendPaymentRecipient {
|
|
case address(address: SignalServiceAddress)
|
|
case publicAddress(publicAddress: MobileCoin.PublicAddress)
|
|
|
|
public var address: SignalServiceAddress? {
|
|
switch self {
|
|
case .address(let address):
|
|
return address
|
|
case .publicAddress:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
public var isIdentifiedPayment: Bool {
|
|
address != nil
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
public struct PreparedPaymentImpl: PreparedPayment {
|
|
fileprivate let recipientAci: Aci?
|
|
fileprivate let recipientPublicAddress: MobileCoin.PublicAddress
|
|
fileprivate let paymentAmount: TSPaymentAmount
|
|
fileprivate let memoMessage: String?
|
|
fileprivate let isOutgoingTransfer: Bool
|
|
fileprivate let preparedTransaction: MobileCoinAPI.PreparedTransaction
|
|
|
|
public var transaction: Transaction { preparedTransaction.transaction }
|
|
public var receipt: Receipt { preparedTransaction.receipt }
|
|
public var feeAmount: TSPaymentAmount { preparedTransaction.feeAmount }
|
|
}
|
|
|
|
extension Amount {
|
|
public var tsPaymentAmount: TSPaymentAmount? {
|
|
TSPaymentAmount(
|
|
currency: self.tokenId == .MOB ? .mobileCoin : .unknown,
|
|
picoMob: self.value
|
|
)
|
|
}
|
|
}
|