// // Copyright (c) 2021 Open Whisper Systems. All rights reserved. // import Foundation import PromiseKit import MobileCoin public class MobileCoinAPI: Dependencies { // MARK: - Passphrases & Entropy public static func passphrase(forPaymentsEntropy paymentsEntropy: Data) throws -> PaymentsPassphrase { guard paymentsEntropy.count == PaymentsConstants.paymentsEntropyLength else { throw PaymentsError.invalidEntropy } let result = MobileCoin.Mnemonic.mnemonic(fromEntropy: paymentsEntropy) switch result { case .success(let mnemonic): return try PaymentsPassphrase.parse(passphrase: mnemonic, validateWords: false) case .failure(let error): owsFailDebug("Error: \(error)") let error = Self.convertMCError(error: error) throw error } } public static func paymentsEntropy(forPassphrase passphrase: PaymentsPassphrase) throws -> Data { let mnemonic = passphrase.asPassphrase let result = MobileCoin.Mnemonic.entropy(fromMnemonic: mnemonic) switch result { case .success(let paymentsEntropy): guard paymentsEntropy.count == PaymentsConstants.paymentsEntropyLength else { throw PaymentsError.invalidEntropy } return paymentsEntropy case .failure(let error): owsFailDebug("Error: \(error)") let error = Self.convertMCError(error: error) throw error } } public static func isValidPassphraseWord(_ word: String?) -> Bool { guard let word = word?.strippedOrNil else { return false } return !MobileCoin.Mnemonic.words(matchingPrefix: word).isEmpty } // MARK: - private let paymentsEntropy: Data // PAYMENTS TODO: Finalize this value with the designers. private static let timeoutDuration: TimeInterval = 60 let localAccount: MobileCoinAccount private let client: MobileCoinClient private init(paymentsEntropy: Data, localAccount: MobileCoinAccount, client: MobileCoinClient) throws { guard paymentsEntropy.count == PaymentsConstants.paymentsEntropyLength else { throw PaymentsError.invalidEntropy } owsAssertDebug(Self.payments.arePaymentsEnabled) self.paymentsEntropy = paymentsEntropy self.localAccount = localAccount self.client = client } // MARK: - public static func configureSDKLogging() { if DebugFlags.internalLogging, !CurrentAppContext().isRunningTests { MobileCoinLogging.logSensitiveData = true } } // MARK: - static func buildLocalAccount(paymentsEntropy: Data) throws -> MobileCoinAccount { try Self.buildAccount(forPaymentsEntropy: paymentsEntropy) } private static func parseAuthorizationResponse(responseObject: Any?) throws -> OWSAuthorization { guard let params = ParamParser(responseObject: responseObject) else { throw OWSAssertionError("Invalid responseObject.") } let username: String = try params.required(key: "username") let password: String = try params.required(key: "password") return OWSAuthorization(username: username, password: password) } public static func buildPromise(paymentsEntropy: Data) -> Promise { firstly(on: .global()) { () -> Promise in let request = OWSRequestFactory.paymentsAuthenticationCredentialRequest() return Self.networkManager.makePromise(request: request) }.map(on: .global()) { (_: URLSessionDataTask, responseObject: Any?) -> OWSAuthorization in try Self.parseAuthorizationResponse(responseObject: responseObject) }.map(on: .global()) { (signalAuthorization: OWSAuthorization) -> MobileCoinAPI in let localAccount = try Self.buildAccount(forPaymentsEntropy: paymentsEntropy) let client = try localAccount.buildClient(signalAuthorization: signalAuthorization) return try MobileCoinAPI(paymentsEntropy: paymentsEntropy, localAccount: localAccount, client: client) } } // MARK: - class func isValidMobileCoinPublicAddress(_ publicAddressData: Data) -> Bool { MobileCoin.PublicAddress(serializedData: publicAddressData) != nil } // MARK: - func getLocalBalance() -> Promise { Logger.verbose("") let client = self.client return firstly(on: .global()) { () throws -> Promise in let (promise, resolver) = Promise.pending() if DebugFlags.paymentsNoRequestsComplete.get() { // Never resolve. return promise } client.updateBalance { (result: Swift.Result) in switch result { case .success(let balance): resolver.fulfill(balance) case .failure(let error): let error = Self.convertMCError(error: error) resolver.reject(error) } } return promise }.map(on: .global()) { (balance: MobileCoin.Balance) -> TSPaymentAmount in Logger.verbose("Success: \(balance)") // We do not need to support amountPicoMobHigh. guard let amountPicoMob = balance.amountPicoMob() else { throw OWSAssertionError("Invalid balance.") } return TSPaymentAmount(currency: .mobileCoin, picoMob: amountPicoMob) }.recover(on: .global()) { (error: Error) -> Promise in owsFailDebugUnlessMCNetworkFailure(error) throw error }.timeout(seconds: Self.timeoutDuration, description: "getLocalBalance") { () -> Error in PaymentsError.timeout } } func getEstimatedFee(forPaymentAmount paymentAmount: TSPaymentAmount) throws -> TSPaymentAmount { Logger.verbose("") guard paymentAmount.isValidAmount(canBeEmpty: false) else { throw OWSAssertionError("Invalid amount.") } // We don't need to support amountPicoMobHigh. let result = client.estimateTotalFee(toSendAmount: paymentAmount.picoMob, feeLevel: Self.feeLevel) switch result { case .success(let feePicoMob): let fee = TSPaymentAmount(currency: .mobileCoin, picoMob: feePicoMob) guard fee.isValidAmount(canBeEmpty: false) else { throw OWSAssertionError("Invalid amount.") } Logger.verbose("Success paymentAmount: \(paymentAmount), fee: \(fee), ") return fee case .failure(let error): let error = Self.convertMCError(error: error) if case PaymentsError.insufficientFunds = error { Logger.warn("Error: \(error)") } else { owsFailDebugUnlessMCNetworkFailure(error) } throw error } } func maxTransactionAmount() throws -> TSPaymentAmount { // We don't need to support amountPicoMobHigh. let result = client.amountTransferable(feeLevel: Self.feeLevel) switch result { case .success(let feePicoMob): let paymentAmount = TSPaymentAmount(currency: .mobileCoin, picoMob: feePicoMob) guard paymentAmount.isValidAmount(canBeEmpty: true) else { throw OWSAssertionError("Invalid amount.") } Logger.verbose("Success paymentAmount: \(paymentAmount), ") return paymentAmount case .failure(let error): let error = Self.convertMCError(error: error) owsFailDebugUnlessMCNetworkFailure(error) throw error } } struct PreparedTransaction { let transaction: MobileCoin.Transaction let receipt: MobileCoin.Receipt let feeAmount: TSPaymentAmount } func prepareTransaction(paymentAmount: TSPaymentAmount, recipientPublicAddress: MobileCoin.PublicAddress, shouldUpdateBalance: Bool) -> Promise { Logger.verbose("") Logger.verbose("paymentAmount: \(paymentAmount.picoMob)") let client = self.client return firstly(on: .global()) { () throws -> Promise in guard shouldUpdateBalance else { return Promise.value(()) } return firstly(on: .global()) { () throws -> Promise in // prepareTransaction() will fail if local balance is not yet known. self.getLocalBalance() }.done(on: .global()) { (balance: TSPaymentAmount) in Logger.verbose("balance: \(balance.picoMob)") } }.map(on: .global()) { () -> TSPaymentAmount in try self.getEstimatedFee(forPaymentAmount: paymentAmount) }.then(on: .global()) { (estimatedFeeAmount: TSPaymentAmount) -> Promise in Logger.verbose("estimatedFeeAmount: \(estimatedFeeAmount.picoMob)") guard paymentAmount.isValidAmount(canBeEmpty: false) else { throw OWSAssertionError("Invalid amount.") } guard estimatedFeeAmount.isValidAmount(canBeEmpty: false) else { throw OWSAssertionError("Invalid fee.") } let (promise, resolver) = Promise.pending() if DebugFlags.paymentsNoRequestsComplete.get() { // Never resolve. return promise } // We don't need to support amountPicoMobHigh. client.prepareTransaction(to: recipientPublicAddress, amount: paymentAmount.picoMob, fee: estimatedFeeAmount.picoMob) { (result: Swift.Result<(transaction: MobileCoin.Transaction, receipt: MobileCoin.Receipt), TransactionPreparationError>) in switch result { case .success(let transactionAndReceipt): let (transaction, receipt) = transactionAndReceipt let finalFeeAmount = TSPaymentAmount(currency: .mobileCoin, picoMob: transaction.fee) owsAssertDebug(estimatedFeeAmount == finalFeeAmount) let preparedTransaction = PreparedTransaction(transaction: transaction, receipt: receipt, feeAmount: finalFeeAmount) resolver.fulfill(preparedTransaction) case .failure(let error): let error = Self.convertMCError(error: error) resolver.reject(error) } } return promise }.recover(on: .global()) { (error: Error) -> Promise in owsFailDebugUnlessMCNetworkFailure(error) throw error }.timeout(seconds: Self.timeoutDuration, description: "prepareTransaction") { () -> Error in PaymentsError.timeout } } // TODO: Are we always going to use _minimum_ fee? private static let feeLevel: MobileCoin.FeeLevel = .minimum func requiresDefragmentation(forPaymentAmount paymentAmount: TSPaymentAmount) -> Promise { Logger.verbose("") let client = self.client return firstly(on: .global()) { () throws -> Promise in let result = client.requiresDefragmentation(toSendAmount: paymentAmount.picoMob, feeLevel: Self.feeLevel) switch result { case .success(let shouldDefragment): return Promise.value(shouldDefragment) case .failure(let error): let error = Self.convertMCError(error: error) throw error } }.timeout(seconds: Self.timeoutDuration, description: "requiresDefragmentation") { () -> Error in PaymentsError.timeout } } func prepareDefragmentationStepTransactions(forPaymentAmount paymentAmount: TSPaymentAmount) -> Promise<[MobileCoin.Transaction]> { Logger.verbose("") let client = self.client return firstly(on: .global()) { () throws -> Promise<[MobileCoin.Transaction]> in let (promise, resolver) = Promise<[MobileCoin.Transaction]>.pending() if DebugFlags.paymentsNoRequestsComplete.get() { // Never resolve. return promise } client.prepareDefragmentationStepTransactions(toSendAmount: paymentAmount.picoMob, feeLevel: Self.feeLevel) { (result: Swift.Result<[MobileCoin.Transaction], MobileCoin.DefragTransactionPreparationError>) in switch result { case .success(let transactions): resolver.fulfill(transactions) break case .failure(let error): let error = Self.convertMCError(error: error) resolver.reject(error) break } } return promise }.timeout(seconds: Self.timeoutDuration, description: "prepareDefragmentationStepTransactions") { () -> Error in PaymentsError.timeout } } func submitTransaction(transaction: MobileCoin.Transaction) -> Promise { Logger.verbose("") guard !DebugFlags.paymentsFailOutgoingSubmission.get() else { return Promise(error: OWSGenericError("Failed.")) } return firstly(on: .global()) { () throws -> Promise in let (promise, resolver) = Promise.pending() if DebugFlags.paymentsNoRequestsComplete.get() { // Never resolve. return promise } let client = self.client client.submitTransaction(transaction) { (result: Swift.Result) in switch result { case .success: resolver.fulfill(()) break case .failure(let error): let error = Self.convertMCError(error: error) resolver.reject(error) break } } return promise }.map(on: .global()) { () -> Void in Logger.verbose("Success.") }.recover(on: .global()) { (error: Error) -> Promise in owsFailDebugUnlessMCNetworkFailure(error) throw error }.timeout(seconds: Self.timeoutDuration, description: "submitTransaction") { () -> Error in PaymentsError.timeout } } func getOutgoingTransactionStatus(transaction: MobileCoin.Transaction) -> Promise { Logger.verbose("") guard !DebugFlags.paymentsFailOutgoingVerification.get() else { return Promise(error: OWSGenericError("Failed.")) } let client = self.client return firstly(on: .global()) { () throws -> Promise in // .status(of: transaction) requires an updated balance. // // TODO: We could improve perf when verifying multiple transactions by getting balance just once. self.getLocalBalance() }.then(on: .global()) { (_: TSPaymentAmount) -> Promise in let (promise, resolver) = Promise.pending() if DebugFlags.paymentsNoRequestsComplete.get() { // Never resolve. return promise } client.status(of: transaction) { (result: Swift.Result) in switch result { case .success(let transactionStatus): resolver.fulfill(MCOutgoingTransactionStatus(transactionStatus: transactionStatus)) break case .failure(let error): let error = Self.convertMCError(error: error) resolver.reject(error) break } } return promise }.map(on: .global()) { (value: MCOutgoingTransactionStatus) -> MCOutgoingTransactionStatus in Logger.verbose("Success: \(value)") return value }.recover(on: .global()) { (error: Error) -> Promise in owsFailDebugUnlessMCNetworkFailure(error) throw error }.timeout(seconds: Self.timeoutDuration, description: "getOutgoingTransactionStatus") { () -> Error in PaymentsError.timeout } } func paymentAmount(forReceipt receipt: MobileCoin.Receipt) throws -> TSPaymentAmount { try Self.paymentAmount(forReceipt: receipt, localAccount: localAccount) } static func paymentAmount(forReceipt receipt: MobileCoin.Receipt, localAccount: MobileCoinAccount) throws -> TSPaymentAmount { guard let picoMob = receipt.validateAndUnmaskValue(accountKey: localAccount.accountKey) else { // This can happen if the receipt was address to a different account. owsFailDebug("Receipt missing amount.") throw PaymentsError.invalidAmount } guard picoMob > 0 else { owsFailDebug("Receipt has invalid amount.") throw PaymentsError.invalidAmount } return TSPaymentAmount(currency: .mobileCoin, picoMob: picoMob) } func getIncomingReceiptStatus(receipt: MobileCoin.Receipt) -> Promise { Logger.verbose("") guard !DebugFlags.paymentsFailIncomingVerification.get() else { return Promise(error: OWSGenericError("Failed.")) } let client = self.client let localAccount = self.localAccount return firstly(on: .global()) { () throws -> Promise in // .status(of: receipt) requires an updated balance. // // TODO: We could improve perf when verifying multiple receipts by getting balance just once. self.getLocalBalance() }.map(on: .global()) { (_: TSPaymentAmount) -> MCIncomingReceiptStatus in let paymentAmount: TSPaymentAmount do { paymentAmount = try Self.paymentAmount(forReceipt: receipt, localAccount: localAccount) } catch { owsFailDebug("Error: \(error)") return MCIncomingReceiptStatus(receiptStatus: .failed, paymentAmount: .zeroMob, txOutPublicKey: Data()) } let txOutPublicKey: Data = receipt.txOutPublicKey let result = client.status(of: receipt) switch result { case .success(let receiptStatus): return MCIncomingReceiptStatus(receiptStatus: receiptStatus, paymentAmount: paymentAmount, txOutPublicKey: txOutPublicKey) case .failure(let error): let error = Self.convertMCError(error: error) throw error } }.map(on: .global()) { (value: MCIncomingReceiptStatus) -> MCIncomingReceiptStatus in Logger.verbose("Success: \(value)") return value }.recover(on: .global()) { (error: Error) -> Promise in owsFailDebugUnlessMCNetworkFailure(error) throw error }.timeout(seconds: Self.timeoutDuration, description: "getIncomingReceiptStatus") { () -> Error in PaymentsError.timeout } } func getAccountActivity() -> Promise { Logger.verbose("") let client = self.client return firstly(on: .global()) { () throws -> Promise in let (promise, resolver) = Promise.pending() if DebugFlags.paymentsNoRequestsComplete.get() { // Never resolve. return promise } client.updateBalance { (result: Swift.Result) in switch result { case .success: resolver.fulfill(client.accountActivity) case .failure(let error): let error = Self.convertMCError(error: error) resolver.reject(error) } } return promise }.map(on: .global()) { (accountActivity: MobileCoin.AccountActivity) -> MobileCoin.AccountActivity in Logger.verbose("Success: \(accountActivity.blockCount)") return accountActivity }.recover(on: .global()) { (error: Error) -> Promise in owsFailDebugUnlessMCNetworkFailure(error) throw error }.timeout(seconds: Self.timeoutDuration, description: "getAccountActivity") { () -> Error in PaymentsError.timeout } } } // MARK: - extension MobileCoin.PublicAddress { var asPaymentAddress: TSPaymentAddress { return TSPaymentAddress(currency: .mobileCoin, mobileCoinPublicAddressData: serializedData) } } // MARK: - extension TSPaymentAddress { func asPublicAddress() throws -> MobileCoin.PublicAddress { guard currency == .mobileCoin else { throw PaymentsError.invalidCurrency } guard let address = MobileCoin.PublicAddress(serializedData: mobileCoinPublicAddressData) else { throw OWSAssertionError("Invalid mobileCoinPublicAddressData.") } return address } } // MARK: - struct MCIncomingReceiptStatus { let receiptStatus: MobileCoin.ReceiptStatus let paymentAmount: TSPaymentAmount let txOutPublicKey: Data } // MARK: - struct MCOutgoingTransactionStatus { let transactionStatus: MobileCoin.TransactionStatus } // MARK: - Error Handling extension MobileCoinAPI { public static func convertMCError(error: Error) -> PaymentsError { switch error { case let error as MobileCoin.InvalidInputError: owsFailDebug("Error: \(error)") return PaymentsError.invalidInput case let error as MobileCoin.ConnectionError: switch error { case .connectionFailure(let reason): Logger.warn("Error: \(error), reason: \(reason)") return PaymentsError.connectionFailure case .authorizationFailure(let reason): owsFailDebug("Error: \(error), reason: \(reason)") // Immediately discard the SDK client instance; the auth token may be stale. SSKEnvironment.shared.payments.didReceiveMCAuthError() return PaymentsError.authorizationFailure case .invalidServerResponse(let reason): owsFailDebug("Error: \(error), reason: \(reason)") return PaymentsError.invalidServerResponse case .attestationVerificationFailed(let reason): owsFailDebug("Error: \(error), reason: \(reason)") return PaymentsError.attestationVerificationFailed case .outdatedClient(let reason): owsFailDebug("Error: \(error), reason: \(reason)") return PaymentsError.outdatedClient case .serverRateLimited(let reason): owsFailDebug("Error: \(error), reason: \(reason)") return PaymentsError.serverRateLimited } case let error as MobileCoin.TransactionPreparationError: switch error { case .invalidInput(let reason): owsFailDebug("Error: \(error), reason: \(reason)") return PaymentsError.invalidInput case .insufficientBalance: Logger.warn("Error: \(error)") return PaymentsError.insufficientFunds case .defragmentationRequired: Logger.warn("Error: \(error)") return PaymentsError.defragmentationRequired case .connectionError(let connectionError): // Recurse. return convertMCError(error: connectionError) } case let error as MobileCoin.TransactionSubmissionError: switch error { case .connectionError(let connectionError): // Recurse. return convertMCError(error: connectionError) case .invalidTransaction: Logger.warn("Error: \(error)") return PaymentsError.invalidTransaction case .feeError: Logger.warn("Error: \(error)") return PaymentsError.invalidFee case .tombstoneBlockTooFar: Logger.warn("Error: \(error)") // Map to .invalidTransaction return PaymentsError.invalidTransaction case .inputsAlreadySpent: Logger.warn("Error: \(error)") return PaymentsError.inputsAlreadySpent } case let error as MobileCoin.TransactionEstimationError: switch error { case .invalidInput(let reason): owsFailDebug("Error: \(error), reason: \(reason)") return PaymentsError.invalidInput case .insufficientBalance: Logger.warn("Error: \(error)") return PaymentsError.insufficientFunds } case let error as MobileCoin.DefragTransactionPreparationError: switch error { case .invalidInput(let reason): owsFailDebug("Error: \(error), reason: \(reason)") return PaymentsError.invalidInput case .insufficientBalance: Logger.warn("Error: \(error)") return PaymentsError.insufficientFunds case .connectionError(let connectionError): // Recurse. return convertMCError(error: connectionError) } case let error as MobileCoin.BalanceTransferEstimationError: switch error { case .feeExceedsBalance: // TODO: Review this mapping. Logger.warn("Error: \(error)") return PaymentsError.insufficientFunds case .balanceOverflow: // TODO: Review this mapping. Logger.warn("Error: \(error)") return PaymentsError.insufficientFunds } default: owsFailDebug("Unexpected error: \(error)") return PaymentsError.unknownSDKError } } } // MARK: - public extension PaymentsError { var isPaymentsNetworkFailure: Bool { switch self { case .notEnabled, .userNotRegisteredOrAppNotReady, .userHasNoPublicAddress, .invalidCurrency, .invalidWalletKey, .invalidAmount, .invalidFee, .insufficientFunds, .invalidModel, .tooOldToSubmit, .indeterminateState, .unknownSDKError, .invalidInput, .authorizationFailure, .invalidServerResponse, .attestationVerificationFailed, .outdatedClient, .serverRateLimited, .serializationError, .verificationStatusUnknown, .ledgerBlockTimestampUnknown, .missingModel, .defragmentationRequired, .invalidTransaction, .inputsAlreadySpent, .defragmentationFailed, .invalidPassphrase, .invalidEntropy, .killSwitch: return false case .connectionFailure, .timeout: return true } } var isExpected: Bool { switch self { case .notEnabled, .userNotRegisteredOrAppNotReady, .userHasNoPublicAddress, .invalidCurrency, .invalidWalletKey, .invalidAmount, .invalidFee, .invalidModel, .indeterminateState, .unknownSDKError, .invalidInput, .authorizationFailure, .invalidServerResponse, .attestationVerificationFailed, .outdatedClient, .serverRateLimited, .serializationError, .verificationStatusUnknown, .ledgerBlockTimestampUnknown, .missingModel, .defragmentationRequired, .invalidTransaction, .inputsAlreadySpent, .defragmentationFailed, .invalidPassphrase, .invalidEntropy, .killSwitch, .connectionFailure, .timeout: return false case .tooOldToSubmit, .insufficientFunds: return true } } } // MARK: - // A variant of owsFailDebugUnlessNetworkFailure() that can handle // network failures from the MobileCoin SDK. @inlinable public func owsFailDebugUnlessMCNetworkFailure(_ error: Error, file: String = #file, function: String = #function, line: Int = #line) { if let paymentsError = error as? PaymentsError { if paymentsError.isPaymentsNetworkFailure { // Log but otherwise ignore network failures. Logger.warn("Error: \(error)", file: file, function: function, line: line) } else if paymentsError.isExpected { Logger.warn("Error: \(error)", file: file, function: function, line: line) } else { owsFailDebug("Error: \(error)", file: file, function: function, line: line) } } else if nil != error as? OWSAssertionError { owsFailDebug("Unexpected error: \(error)") } else { owsFailDebugUnlessNetworkFailure(error) } } // MARK: - URLs extension MobileCoinAPI { static func formatAsBase58(publicAddress: MobileCoin.PublicAddress) -> String { return Base58Coder.encode(publicAddress) } static func formatAsUrl(publicAddress: MobileCoin.PublicAddress) -> String { MobUri.encode(publicAddress) } static func parseAsPublicAddress(url: URL) -> MobileCoin.PublicAddress? { let result = MobUri.decode(uri: url.absoluteString) switch result { case .success(let payload): switch payload { case .publicAddress(let publicAddress): return publicAddress case .paymentRequest(let paymentRequest): // TODO: We could honor the amount and memo. return paymentRequest.publicAddress case .transferPayload: // TODO: We could handle transferPayload. owsFailDebug("Unexpected payload.") return nil } case .failure(let error): let error = Self.convertMCError(error: error) owsFailDebugUnlessMCNetworkFailure(error) return nil } } static func parse(publicAddressBase58 base58: String) -> MobileCoin.PublicAddress? { guard let result = Base58Coder.decode(base58) else { Logger.verbose("Invalid base58: \(base58)") Logger.warn("Invalid base58.") return nil } switch result { case .publicAddress(let publicAddress): return publicAddress default: Logger.verbose("Invalid base58: \(base58)") Logger.warn("Invalid base58.") return nil } } }