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

828 lines
26 KiB
Swift

//
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
public import LibSignalClient
@objc
public protocol TSPaymentBaseModel: AnyObject {
var isValid: Bool { get }
}
// MARK: -
@objc
extension TSPaymentAmount: TSPaymentBaseModel {
public static var zeroMob: TSPaymentAmount {
TSPaymentAmount(currency: .mobileCoin, picoMob: 0)
}
public var isValid: Bool {
isValidAmount(canBeEmpty: false)
}
public func isValidAmount(canBeEmpty: Bool) -> Bool {
if canBeEmpty {
return currency != .unknown && picoMob >= 0
} else {
return currency != .unknown && picoMob > 0
}
}
public var isZero: Bool {
picoMob == 0
}
public func buildProto() throws -> SSKProtoDataMessagePaymentAmount {
guard isValid,
currency == .mobileCoin else {
throw PaymentsError.invalidModel
}
let mobileCoinBuilder = SSKProtoDataMessagePaymentAmountMobileCoin.builder(picoMob: picoMob)
let builder = SSKProtoDataMessagePaymentAmount.builder()
builder.setMobileCoin(try mobileCoinBuilder.build())
return try builder.build()
}
public class func fromProto(_ proto: SSKProtoDataMessagePaymentAmount) throws -> TSPaymentAmount {
guard let mobileCoin = proto.mobileCoin else {
throw PaymentsError.invalidModel
}
let instance = TSPaymentAmount(currency: .mobileCoin,
picoMob: mobileCoin.picoMob)
guard instance.isValidAmount(canBeEmpty: true) else {
throw PaymentsError.invalidModel
}
return instance
}
public func plus(_ other: TSPaymentAmount) -> TSPaymentAmount {
owsAssertDebug(self.isValidAmount(canBeEmpty: true))
owsAssertDebug(other.isValidAmount(canBeEmpty: true))
owsAssertDebug(self.currency == .mobileCoin)
owsAssertDebug(other.currency == .mobileCoin)
return TSPaymentAmount(currency: currency, picoMob: self.picoMob + other.picoMob)
}
public var formatted: String {
owsAssertDebug(currency == .mobileCoin)
return "picoMob: \(picoMob)"
}
}
// MARK: -
@objc
extension TSPaymentAddress: TSPaymentBaseModel {
public var isValid: Bool {
guard currency == .mobileCoin else {
owsFailDebug("Unexpected currency.")
return false
}
return SSKEnvironment.shared.mobileCoinHelperRef.isValidMobileCoinPublicAddress(mobileCoinPublicAddressData)
}
public func buildProto(tx: SDSAnyReadTransaction) throws -> SSKProtoPaymentAddress {
guard isValid, currency == .mobileCoin else {
throw PaymentsError.invalidModel
}
// Sign the MC public address.
let identityManager = DependenciesBridge.shared.identityManager
guard let identityKeyPair: ECKeyPair = identityManager.identityKeyPair(for: .aci, tx: tx.asV2Read) else {
throw OWSAssertionError("Missing identityKeyPair")
}
let signatureData = try Self.sign(identityKeyPair: identityKeyPair,
publicAddressData: mobileCoinPublicAddressData)
let mobileCoinBuilder = SSKProtoPaymentAddressMobileCoin.builder(publicAddress: mobileCoinPublicAddressData,
signature: signatureData)
let builder = SSKProtoPaymentAddress.builder()
builder.setMobileCoin(try mobileCoinBuilder.build())
return try builder.build()
}
@nonobjc
public class func fromProto(_ proto: SSKProtoPaymentAddress, identityKey: IdentityKey) throws -> TSPaymentAddress {
guard let mobileCoin = proto.mobileCoin else {
throw PaymentsError.invalidModel
}
let mobileCoinPublicAddressData = mobileCoin.publicAddress
let signatureData = mobileCoin.signature
guard !mobileCoinPublicAddressData.isEmpty,
!signatureData.isEmpty else {
throw PaymentsError.invalidModel
}
guard Self.verifySignature(identityKey: identityKey,
publicAddressData: mobileCoinPublicAddressData,
signatureData: signatureData) else {
owsFailDebug("Signature verification failed.")
throw PaymentsError.invalidModel
}
let instance = TSPaymentAddress(currency: .mobileCoin,
mobileCoinPublicAddressData: mobileCoin.publicAddress)
guard instance.isValid else {
throw PaymentsError.invalidModel
}
return instance
}
static func sign(identityKeyPair: ECKeyPair, publicAddressData: Data) throws -> Data {
let privateKey: LibSignalClient.PrivateKey = identityKeyPair.identityKeyPair.privateKey
return Data(privateKey.generateSignature(message: publicAddressData))
}
@nonobjc
static func verifySignature(identityKey: IdentityKey, publicAddressData: Data, signatureData: Data) -> Bool {
do {
return try identityKey.publicKey.verifySignature(message: publicAddressData, signature: signatureData)
} catch {
owsFailDebug("Error: \(error)")
return false
}
}
}
// MARK: -
@objc
extension TSPaymentNotification: TSPaymentBaseModel {
public var isValid: Bool {
guard mcReceiptData.count > 0 else {
return false
}
return true
}
public func buildProto() throws -> SSKProtoDataMessagePaymentNotification {
guard isValid else {
throw PaymentsError.invalidModel
}
var mcReceiptData = self.mcReceiptData
if DebugFlags.paymentsMalformedMessages.get() {
mcReceiptData = Randomness.generateRandomBytes(UInt(mcReceiptData.count))
}
let mobileCoinBuilder = SSKProtoDataMessagePaymentNotificationMobileCoin.builder(receipt: mcReceiptData)
let builder = SSKProtoDataMessagePaymentNotification.builder()
builder.setMobileCoin(try mobileCoinBuilder.build())
if let memoMessage = memoMessage {
builder.setNote(memoMessage)
}
return try builder.build()
}
@objc(addToDataBuilder:error:)
public func add(toDataBuilder dataBuilder: SSKProtoDataMessageBuilder) throws {
let paymentBuilder = SSKProtoDataMessagePayment.builder()
paymentBuilder.setNotification(try buildProto())
dataBuilder.setPayment(try paymentBuilder.build())
}
public class func fromProto(
_ proto: SSKProtoDataMessagePaymentNotification,
dataMessage: SSKProtoDataMessage
) throws -> TSPaymentNotification {
guard let mobileCoin = proto.mobileCoin else {
owsFailDebug("Missing mobileCoin.")
throw PaymentsError.invalidModel
}
let mcReceiptData = mobileCoin.receipt
let instance = TSPaymentNotification(memoMessage: proto.note, mcReceiptData: mcReceiptData)
guard instance.isValid else {
throw PaymentsError.invalidModel
}
return instance
}
}
// MARK: -
@objc
public class TSPaymentModels: NSObject {
@objc
public var notification: TSPaymentNotification
private init(notification: TSPaymentNotification) {
self.notification = notification
}
@objc(parsePaymentProtosInDataMessage:thread:)
public class func parsePaymentProtos(dataMessage: SSKProtoDataMessage, thread: TSThread) -> TSPaymentModels? {
guard !CurrentAppContext().isRunningTests else {
return nil
}
guard SSKEnvironment.shared.paymentsHelperRef.arePaymentsEnabled else {
return nil
}
guard let paymentProto = dataMessage.payment else {
return nil
}
guard thread is TSContactThread else {
owsFailDebug("Invalid thread.")
return nil
}
do {
if let notificationProto = paymentProto.notification {
let notification = try TSPaymentNotification.fromProto(notificationProto, dataMessage: dataMessage)
if notification.isValid {
return TSPaymentModels(notification: notification)
} else {
owsFailDebug("Invalid payment notification proto.")
}
}
if paymentProto.activation != nil {
// Handled seprarately.
return nil
}
owsFailDebug("Empty payment proto.")
} catch {
owsFailDebug("Error: \(error)")
}
return nil
}
}
// MARK: -
@objc
public extension TSPaymentModel {
// We need to be cautious when updating the state of payment records,
// to avoid races.
@objc(isCurrentPaymentState:transaction:)
func isCurrentPaymentState(paymentState: TSPaymentState, transaction: SDSAnyReadTransaction) -> Bool {
guard self.paymentState == paymentState else {
owsFailDebug("Payment model in memory has unexpected state: \(self.paymentState.formatted) != expected: \(paymentState.formatted)")
return false
}
guard let latestModel = TSPaymentModel.anyFetch(uniqueId: self.uniqueId, transaction: transaction) else {
owsFailDebug("Payment model no longer exists.")
return false
}
guard latestModel.paymentState == paymentState else {
owsFailDebug("Payment model in database has unexpected state: \(latestModel.paymentState.formatted) != expected: \(paymentState.formatted)")
return false
}
return true
}
// We need to be cautious when updating the state of payment records,
// to avoid races.
func updatePaymentModelState(fromState: TSPaymentState,
toState: TSPaymentState,
transaction: SDSAnyWriteTransaction) throws {
guard isCurrentPaymentState(paymentState: fromState, transaction: transaction) else {
throw OWSAssertionError("Payment model has unexpected state.")
}
self.update(paymentState: toState, transaction: transaction)
}
}
// MARK: -
@objc
extension TSPaymentModel: TSPaymentBaseModel {
public var isValid: Bool {
var isValid = true
let formattedState = descriptionForLogs
if isIncoming != paymentState.isIncoming {
owsFailDebug("Invalid payment: \(formattedState).")
isValid = false
}
let hasFailedPaymentState = (paymentState == .incomingFailed || paymentState == .outgoingFailed)
let hasFailureType = paymentFailure != .none
if hasFailedPaymentState, !hasFailureType {
owsFailDebug("Failed payment state: \(formattedState), no paymentFailure: \(paymentFailure.formatted).")
isValid = false
} else if !hasFailedPaymentState, hasFailureType {
owsFailDebug("Payment state: \(formattedState), unexpected paymentFailure: \(paymentFailure.formatted).")
isValid = false
}
if let paymentAmount = paymentAmount {
// This might be a scrubbed defragmentation.
let canBeEmpty = self.isDefragmentation || self.isUnidentified
if !paymentAmount.isValidAmount(canBeEmpty: canBeEmpty) {
owsFailDebug("Invalid paymentAmount: \(formattedState).")
isValid = false
}
} else {
let shouldHavePaymentAmount = paymentState != .incomingUnverified && !isFailed
if shouldHavePaymentAmount {
owsFailDebug("Missing paymentAmount: \(formattedState).")
isValid = false
}
}
if let feeAmount = mobileCoin?.feeAmount {
if !feeAmount.isValidAmount(canBeEmpty: false) {
owsFailDebug("Invalid feeAmount: \(formattedState).")
isValid = false
}
} else {
let shouldHaveFeeAmount = !isUnidentified && isOutgoing && !isRestored && !isFailed
if shouldHaveFeeAmount {
owsFailDebug("Missing feeAmount: \(formattedState).")
isValid = false
}
}
let shouldHaveAddressUuidString = isIdentifiedPayment
if shouldHaveAddressUuidString, addressUuidString == nil {
owsFailDebug("Missing addressUuidString: \(formattedState).")
isValid = false
}
let shouldHaveMCRecipientPublicAddressData = !isRestored && isOutgoing && (isIdentifiedPayment || isOutgoingTransfer) && !isFailed
if shouldHaveMCRecipientPublicAddressData, mcRecipientPublicAddressData == nil {
owsFailDebug("Missing mcRecipientPublicAddressData: \(formattedState).")
}
if shouldHaveMCTransaction, mcTransactionData == nil {
owsFailDebug("Missing mcTransactionData: \(formattedState).")
isValid = false
} else if !canHaveMCTransaction, mcTransactionData != nil {
owsFailDebug("Unexpected mcTransactionData: \(formattedState).")
}
if shouldHaveMCReceipt, mcReceiptData == nil {
owsFailDebug("Missing mcReceiptData: \(formattedState).")
isValid = false
}
let hasMCIncomingTransaction = !(self.mobileCoin?.incomingTransactionPublicKeys ?? []).isEmpty
if shouldHaveMCIncomingTransaction, !hasMCIncomingTransaction {
owsFailDebug("Missing mcIncomingTransaction: \(formattedState).")
isValid = false
} else if !canHaveMCIncomingTransaction, hasMCIncomingTransaction {
owsFailDebug("Unexpected mcIncomingTransaction: \(formattedState).")
isValid = false
}
let shouldHaveRecipient = !isUnidentified && isOutgoing && !isDefragmentation
let hasRecipient = addressUuidString != nil || mcRecipientPublicAddressData != nil
if shouldHaveRecipient, !hasRecipient {
owsFailDebug("Missing recipient: \(formattedState).")
isValid = false
}
if shouldHaveMCSpentKeyImages,
mcSpentKeyImages == nil {
owsFailDebug("Missing mcSpentKeyImages: \(formattedState).")
isValid = false
} else if !canHaveMCSpentKeyImages,
mcSpentKeyImages != nil {
owsFailDebug("Unexpected mcSpentKeyImages: \(formattedState).")
isValid = false
}
if shouldHaveMCOutputPublicKeys,
mcOutputPublicKeys == nil {
owsFailDebug("Missing mcOutputPublicKeys: \(formattedState).")
isValid = false
} else if !canHaveMCOutputPublicKeys,
mcOutputPublicKeys != nil {
owsFailDebug("Unexpected mcOutputPublicKeys: \(formattedState).")
isValid = false
}
let shouldHaveMCLedgerBlockTimestamp = isComplete && !isUnidentified && !isFailed
if shouldHaveMCLedgerBlockTimestamp,
!hasMCLedgerBlockTimestamp {
// For some payments, we'll never be able to fill in the block timestamp.
Logger.warn("Missing mcLedgerBlockTimestamp: \(formattedState).")
}
let shouldHaveMCLedgerBlockIndex = isVerified || isUnidentified && !isFailed
if shouldHaveMCLedgerBlockIndex,
!hasMCLedgerBlockIndex {
owsFailDebug("Missing mcLedgerBlockIndex: \(formattedState).")
isValid = false
}
let shouldHaveMobileCoin = !isFailed
if shouldHaveMobileCoin,
mobileCoin == nil {
owsFailDebug("Missing mobileCoin: \(formattedState).")
isValid = false
} else if !shouldHaveMobileCoin,
mobileCoin != nil {
owsFailDebug("Unexpected mobileCoin: \(formattedState).")
isValid = false
}
return isValid
}
public var canHaveMCTransaction: Bool {
isOutgoing && !isUnidentified && !isFailed
}
public var shouldHaveMCTransaction: Bool {
canHaveMCTransaction && !wasNotCreatedLocally
}
public var shouldHaveMCReceipt: Bool {
isIncoming && isIdentifiedPayment && !isFailed
}
public var shouldHaveMCIncomingTransaction: Bool {
isIncoming && !isFailed
}
public var canHaveMCIncomingTransaction: Bool {
shouldHaveMCIncomingTransaction || isUnidentified
}
public var shouldHaveMCSpentKeyImages: Bool {
isOutgoing && !isUnidentified && !isFailed
}
public var canHaveMCSpentKeyImages: Bool {
shouldHaveMCSpentKeyImages || isUnidentified
}
public var shouldHaveMCOutputPublicKeys: Bool {
isOutgoing && !isUnidentified && !isFailed
}
public var canHaveMCOutputPublicKeys: Bool {
shouldHaveMCSpentKeyImages || isUnidentified
}
public var isComplete: Bool { paymentState.isComplete }
public var isFailed: Bool { paymentState.isFailed }
public var isVerified: Bool { paymentState.isVerified }
public var isIncoming: Bool {
paymentType.isIncoming
}
public var isOutgoingTransfer: Bool {
paymentType == .outgoingTransfer
}
public var isOutgoing: Bool {
!isIncoming
}
public var isIdentifiedPayment: Bool {
paymentType.isIdentifiedPayment
}
public var isUnidentified: Bool {
paymentType.isUnidentified
}
public var isRestored: Bool {
paymentType.isRestored
}
public var isDefragmentation: Bool {
paymentType.isDefragmentation
}
public var wasNotCreatedLocally: Bool {
paymentType.wasNotCreatedLocally
}
public var hasMCLedgerBlockIndex: Bool {
mcLedgerBlockIndex > 0
}
public var hasMCLedgerBlockTimestamp: Bool {
mcLedgerBlockTimestamp > 0
}
// Only set for outgoing mobileCoin payments.
//
// This only applies to mobilecoin.
public var mcRecipientPublicAddressData: Data? {
mobileCoin?.recipientPublicAddressData
}
// Only set for outgoing mobileCoin payments.
//
// This only applies to mobilecoin.
public var mcSpentKeyImages: [Data]? {
mobileCoin?.spentKeyImages
}
// Only set for outgoing mobileCoin payments.
//
// This only applies to mobilecoin.
public var mcOutputPublicKeys: [Data]? {
mobileCoin?.outputPublicKeys
}
// This only applies to mobilecoin.
public var mcLedgerBlockTimestamp: UInt64 {
mobileCoin?.ledgerBlockTimestamp ?? 0
}
// This only applies to mobilecoin.
public var mcLedgerBlockDate: Date? {
if mcLedgerBlockTimestamp != 0 {
return Date(millisecondsSince1970: mcLedgerBlockTimestamp)
}
return nil
}
public var descriptionForLogs: String { buildDescription() }
private func buildDescription() -> String {
var components = [String]()
components.append("paymentType: \(paymentType.formatted)")
components.append("paymentState: \(paymentState.formatted)")
if isFailed {
components.append("paymentFailure: \(paymentFailure.formatted)")
}
return "[" + components.joined(separator: ", ") + "]"
}
#if TESTABLE_BUILD
public var diffableRepresentation: String {
var result = [String]()
let pairs = dictionaryValue.sorted { $0.0 < $1.0 }
for (key, value) in pairs {
result.append("\(key): \(value)")
}
return result.joined(separator: "\n")
}
#endif
}
// MARK: - DeepCopyable
extension TSPaymentAmount: DeepCopyable {
public func deepCopy() throws -> AnyObject {
try TSPaymentAmount(dictionary: self.dictionaryValue)
}
}
// MARK: -
public extension Array where Element == TSPaymentModel {
private func sortBySortDateBlock(descending: Bool) -> (TSPaymentModel, TSPaymentModel) -> Bool {
return { (left, right) -> Bool in
if descending {
return left.sortDate > right.sortDate
} else {
return left.sortDate < right.sortDate
}
}
}
func sortedBySortDate(descending: Bool) -> [TSPaymentModel] {
sorted(by: sortBySortDateBlock(descending: descending))
}
mutating func sortBySortDate(descending: Bool) {
sort(by: sortBySortDateBlock(descending: descending))
}
}
// MARK: -
extension TSPaymentState {
public var isIncoming: Bool {
switch self {
case .outgoingUnsubmitted,
.outgoingUnverified,
.outgoingVerified,
.outgoingSending,
.outgoingSent,
.outgoingComplete,
.outgoingFailed:
return false
case .incomingUnverified,
.incomingVerified,
.incomingComplete,
.incomingFailed:
return true
@unknown default:
owsFailDebug("Unknown value: \(rawValue)")
return false
}
}
public var formatted: String {
NSStringFromTSPaymentState(self)
}
}
// MARK: -
extension TSPaymentType {
public var isIncoming: Bool {
switch self {
case .incomingPayment,
.incomingRestored,
.incomingUnidentified:
return true
case .outgoingPayment,
.outgoingPaymentNotFromLocalDevice,
.outgoingUnidentified,
.outgoingTransfer,
.outgoingDefragmentation,
.outgoingDefragmentationNotFromLocalDevice,
.outgoingRestored:
return false
@unknown default:
owsFailDebug("Invalid value: \(rawValue)")
return false
}
}
public var isIdentifiedPayment: Bool {
switch self {
case .incomingPayment,
.incomingRestored,
.outgoingPayment,
.outgoingRestored,
.outgoingPaymentNotFromLocalDevice:
return true
case .incomingUnidentified,
.outgoingUnidentified,
.outgoingTransfer,
.outgoingDefragmentation,
.outgoingDefragmentationNotFromLocalDevice:
return false
@unknown default:
owsFailDebug("Invalid value: \(rawValue)")
return false
}
}
public var isUnidentified: Bool {
switch self {
case .incomingUnidentified,
.outgoingUnidentified:
return true
case .incomingPayment,
.outgoingPayment,
.outgoingPaymentNotFromLocalDevice,
.outgoingTransfer,
.outgoingDefragmentation,
.outgoingDefragmentationNotFromLocalDevice,
.incomingRestored,
.outgoingRestored:
return false
@unknown default:
owsFailDebug("Invalid value: \(rawValue)")
return false
}
}
public var isRestored: Bool {
switch self {
case .incomingRestored,
.outgoingRestored:
return true
case .incomingPayment,
.outgoingPayment,
.outgoingPaymentNotFromLocalDevice,
.outgoingTransfer,
.outgoingDefragmentation,
.outgoingDefragmentationNotFromLocalDevice,
.incomingUnidentified,
.outgoingUnidentified:
return false
@unknown default:
owsFailDebug("Invalid value: \(rawValue)")
return false
}
}
public var isDefragmentation: Bool {
switch self {
case .outgoingDefragmentation,
.outgoingDefragmentationNotFromLocalDevice:
return true
case .incomingPayment,
.outgoingPayment,
.outgoingPaymentNotFromLocalDevice,
.outgoingTransfer,
.incomingUnidentified,
.outgoingUnidentified,
.incomingRestored,
.outgoingRestored:
return false
@unknown default:
owsFailDebug("Invalid value: \(rawValue)")
return false
}
}
public var wasNotCreatedLocally: Bool {
switch self {
case .outgoingPaymentNotFromLocalDevice,
.outgoingDefragmentationNotFromLocalDevice:
return true
case .incomingPayment,
.outgoingPayment,
.outgoingTransfer,
.incomingUnidentified,
.outgoingUnidentified,
.outgoingDefragmentation,
.incomingRestored,
.outgoingRestored:
return false
@unknown default:
owsFailDebug("Invalid value: \(rawValue)")
return false
}
}
public var formatted: String {
NSStringFromTSPaymentType(self)
}
}
// MARK: -
extension TSPaymentFailure {
public var formatted: String {
NSStringFromTSPaymentFailure(self)
}
}
// MARK: -
@objc
@available(swift, obsoleted: 1.0)
public class PaymentUtils: NSObject {
@available(*, unavailable, message: "Do not instantiate this class.")
private override init() {}
@objc
public static func isIncomingPaymentState(_ value: TSPaymentState) -> Bool {
value.isIncoming
}
@objc
public static func isIncomingPaymentType(_ value: TSPaymentType) -> Bool {
value.isIncoming
}
}
extension TSPaymentState {
public var isComplete: Bool {
switch self {
case .outgoingComplete,
.incomingComplete:
return true
default:
return false
}
}
public var isFailed: Bool {
switch self {
case .outgoingFailed,
.incomingFailed:
return true
default:
return false
}
}
public var isVerified: Bool {
switch self {
case .outgoingUnsubmitted,
.outgoingUnverified:
return false
case .outgoingVerified,
.outgoingSending,
.outgoingSent,
.outgoingComplete:
return true
case .outgoingFailed:
return false
case .incomingUnverified:
return false
case .incomingVerified,
.incomingComplete:
return true
case .incomingFailed:
return false
@unknown default:
owsFailDebug("Unknown payment state.")
return false
}
}
}