secant-ios-wallet/modules/Sources/Models/TransactionState.swift

451 lines
15 KiB
Swift

//
// TransactionState.swift
// Zashi
//
// Created by Lukáš Korba on 26.04.2022.
//
import Foundation
import SwiftUI
import ZcashLightClientKit
import Generated
/// Representation of the transaction on the SDK side, used as a bridge to the TCA wallet side.
public struct TransactionState: Equatable, Identifiable {
public enum Status: Equatable {
case failed
case paid
case received
case receiving
case sending
case shielding
case shielded
}
public var errorMessage: String?
public var expiryHeight: BlockHeight?
public var memoCount: Int
public var minedHeight: BlockHeight?
public var shielded = true
public var zAddress: String?
public var isSentTransaction: Bool
public var isShieldingTransaction: Bool
public var isTransparentRecipient: Bool
public var fee: Zatoshi?
public var id: String
public var status: Status
public var timestamp: TimeInterval?
public var zecAmount: Zatoshi
public var isMarkedAsRead = false
public var isInAddressBook = false
public var hasTransparentOutputs = false
public var totalSpent: Zatoshi?
public var totalReceived: Zatoshi?
public var rawID: Data? = nil
// UI Colors
public func balanceColor(_ colorScheme: ColorScheme) -> Color {
status == .failed
? Design.Utility.ErrorRed._600.color(colorScheme)
: (isSpending || isShieldingTransaction)
? Design.Utility.ErrorRed._600.color(colorScheme)
: Asset.Colors.primary.color
}
public func titleColor(_ colorScheme: ColorScheme) -> Color {
status == .failed
? Design.Text.error.color(colorScheme)
: !isSentTransaction
? Design.Utility.SuccessGreen._700.color(colorScheme)
: Design.Text.primary.color(colorScheme)
}
public func iconColor(_ colorScheme: ColorScheme) -> Color {
status == .failed
? Design.Utility.WarningYellow._500.color(colorScheme)
: isPending
? Design.Utility.HyperBlue._500.color(colorScheme)
: Design.Text.tertiary.color(colorScheme)
}
public func iconGradientStartColor(_ colorScheme: ColorScheme) -> Color {
status == .failed
? Design.Utility.WarningYellow._50.color(colorScheme)
: isPending
? Design.Utility.HyperBlue._50.color(colorScheme)
: Design.Surfaces.bgSecondary.color(colorScheme)
}
public func iconGradientEndColor(_ colorScheme: ColorScheme) -> Color {
status == .failed
? Design.Utility.WarningYellow._100.color(colorScheme)
: isPending
? Design.Utility.HyperBlue._100.color(colorScheme)
: Design.Surfaces.bgSecondary.color(colorScheme)
}
// UI Texts
public var address: String {
zAddress ?? ""
}
public var title: String {
switch status {
case .failed:
// TODO: failed shileded is not covered!
return isSentTransaction
? L10n.Transaction.failedSend
: L10n.Transaction.failedReceive
case .paid:
return L10n.Transaction.sent
case .received:
return L10n.Transaction.received
case .receiving:
return L10n.Transaction.receiving
case .sending:
return L10n.Transaction.sending
case .shielding:
return L10n.Transaction.shieldingFunds
case .shielded:
return L10n.Transaction.shieldedFunds
}
}
public var dateString: String? {
guard let timestamp else { return nil }
return Date(timeIntervalSince1970: timestamp).asHumanReadable()
}
public var listDateString: String? {
guard let timestamp else { return nil }
let formatter = DateFormatter()
let date = Date(timeIntervalSince1970: timestamp)
formatter.dateFormat = "MMM d '\(L10n.Filter.at)' h:mm a"
return formatter.string(from: date)
}
public var listDateYearString: String? {
guard let timestamp else { return nil }
let formatter = DateFormatter()
let date = Date(timeIntervalSince1970: timestamp)
formatter.dateFormat = "MMM d, YYYY '\(L10n.Filter.at)' h:mm a"
return formatter.string(from: date)
}
public var daysAgo: String {
guard let timestamp else { return "" }
let transactionDate = Date(timeIntervalSince1970: timestamp)
let calendar = Calendar.current
let startOfToday = calendar.startOfDay(for: Date())
let startOfGivenDate = calendar.startOfDay(for: transactionDate)
let components = calendar.dateComponents([.day], from: startOfGivenDate, to: startOfToday)
if let daysAgo = components.day {
if daysAgo == 0 {
return L10n.Filter.today
} else if daysAgo == 1 {
return L10n.Filter.yesterday
} else if daysAgo < 31 {
return L10n.Filter.daysAgo(daysAgo)
} else {
return listDateString ?? ""
}
} else {
return ""
}
}
// Helper flags
public var isPending: Bool {
switch status {
case .failed:
return false
case .paid:
return false
case .received:
return false
case .receiving:
return true
case .sending:
return true
case .shielded:
return false
case .shielding:
return true
}
}
/// The purpose of this flag is to help understand if the transaction affected the wallet and a user paid a fee
public var isSpending: Bool {
switch status {
case .paid, .sending:
return true
case .received, .receiving:
return false
case .shielded, .shielding:
return false
case .failed:
return isSentTransaction
}
}
// Values
public var totalAmount: Zatoshi {
Zatoshi(zecAmount.amount + (fee?.amount ?? 0))
}
public var netValue: String {
isShieldingTransaction
? Zatoshi(totalSpent?.amount ?? 0).atLeastThreeDecimalsZashiFormatted()
: zecAmount.atLeastThreeDecimalsZashiFormatted()
}
public var amountWithoutFee: Zatoshi {
Zatoshi(zecAmount.amount - (fee?.amount ?? 0))
}
public init(
errorMessage: String? = nil,
expiryHeight: BlockHeight? = nil,
memoCount: Int = 0,
minedHeight: BlockHeight? = nil,
shielded: Bool = true,
zAddress: String? = nil,
fee: Zatoshi?,
id: String,
status: Status,
timestamp: TimeInterval? = nil,
zecAmount: Zatoshi,
isSentTransaction: Bool = false,
isShieldingTransaction: Bool = false,
isTransparentRecipient: Bool = false,
isMarkedAsRead: Bool = false
) {
self.errorMessage = errorMessage
self.expiryHeight = expiryHeight
self.memoCount = memoCount
self.minedHeight = minedHeight
self.shielded = shielded
self.zAddress = zAddress
self.fee = fee
self.id = id
self.status = status
self.timestamp = timestamp
self.zecAmount = zecAmount
self.isSentTransaction = isSentTransaction
self.isShieldingTransaction = isShieldingTransaction
self.isTransparentRecipient = isTransparentRecipient
self.isMarkedAsRead = isMarkedAsRead
}
public func confirmationsWith(_ latestMinedHeight: BlockHeight?) -> BlockHeight {
guard let minedHeight, let latestMinedHeight, minedHeight > 0, latestMinedHeight > 0 else {
return 0
}
return latestMinedHeight - minedHeight
}
public func transactionListHeight(_ mempoolHeight: BlockHeight) -> BlockHeight {
var tlHeight = mempoolHeight
if let minedHeight = minedHeight {
tlHeight = minedHeight
} else if let expiredHeight = expiryHeight, expiredHeight > 0 {
tlHeight = expiredHeight
}
return tlHeight
}
}
extension TransactionState {
public init(
transaction: ZcashTransaction.Overview,
memos: [Memo]? = nil,
hasTransparentOutputs: Bool = false,
latestBlockHeight: BlockHeight?
) {
expiryHeight = transaction.expiryHeight
minedHeight = transaction.minedHeight
fee = transaction.fee
id = transaction.rawID.toHexStringTxId()
timestamp = transaction.blockTime
isSentTransaction = transaction.isSentTransaction
isShieldingTransaction = transaction.isShielding
zecAmount = isSentTransaction ? Zatoshi(-transaction.value.amount) : transaction.value
isTransparentRecipient = false
self.hasTransparentOutputs = hasTransparentOutputs
memoCount = transaction.memoCount
totalSpent = transaction.totalSpent
totalReceived = transaction.totalReceived
// TODO: [#1313] SDK improvements so a client doesn't need to determing if the transaction isPending
// https://github.com/zcash/ZcashLightClientKit/issues/1313
// The only reason why `latestBlockHeight` is provided here is to determine pending
// state of the transaction. SDK knows the latestBlockHeight so ideally ZcashTransaction.Overview
// already knows and provides isPending as a bool value.
// Once SDK's #1313 is done, adopt the SDK and remove latestBlockHeight here.
var isPending = false
var isExpired = false
if let latestBlockHeight {
isPending = transaction.isPending(currentHeight: latestBlockHeight)
if let expiryHeight = transaction.expiryHeight, expiryHeight <= latestBlockHeight && minedHeight == nil {
isExpired = true
}
}
// failed check
if isExpired {
status = .failed
} else if isShieldingTransaction {
status = isPending ? .shielding : .shielded
} else {
switch (isSentTransaction, isPending) {
case (true, true): status = .sending
case (true, false): status = .paid
case (false, true): status = .receiving
case (false, false): status = .received
}
}
}
}
// MARK: - Placeholders
extension TransactionState {
public static func placeholder(
amount: Zatoshi = .zero,
fee: Zatoshi = .zero,
shielded: Bool = true,
status: Status = .received,
timestamp: TimeInterval = 0.0,
uuid: String = UUID().debugDescription
) -> TransactionState {
.init(
expiryHeight: -1,
minedHeight: -1,
shielded: shielded,
zAddress: nil,
fee: fee,
id: uuid,
status: status,
timestamp: timestamp,
zecAmount: status == .received ? amount : Zatoshi(-amount.amount)
)
}
public static let mockedSent = TransactionState(
minedHeight: BlockHeight(1),
zAddress: "utest1vergg5jkp4xy8sqfasw6s5zkdpnxvfxlxh35uuc3me7dp596y2r05t6dv9htwe3pf8ksrfr8ksca2lskzjanqtl8uqp5vln3zyy246ejtx86vqftp73j7jg9099jxafyjhfm6u956j3",
fee: Zatoshi(10_000),
id: "t1vergg5jkp4wy8sqfasw6s5zkdpnxvfxlxh35uuc3me7dp596y2r05t6dv9htwe3pf8ksrfr8ksca2lskzja",
status: .paid,
timestamp: 1699290621,
zecAmount: Zatoshi(25_000_000)
)
public static let mockedReceived = TransactionState(
minedHeight: BlockHeight(1),
fee: Zatoshi(10_000),
id: "t1vergg5jkp4xy8sqfasw6s5zkdpnxvfxlxh35uuc3me7dp596y2r05t6dv9htwe3pf8ksrfr8ksca2lskzja",
status: .received,
timestamp: 1699292621,
zecAmount: Zatoshi(25_000_000)
)
public static let mockedFailed = TransactionState(
minedHeight: nil,
zAddress: "utest1vergg5jkp4xy8sqfasw6s5zkdpnxvfxlxh35uuc3me7dp596y2r05t6dv9htwe3pf8ksrfr8ksca2lskzjanqtl8uqp5vln3zyy246ejtx86vqftp73j7jg9099jxafyjhfm6u956j3",
fee: Zatoshi(10_000),
id: "t1vergg5jkp4wy8sqfasw6s5zkdpnxvfxlxh35uuc3me7dp596y2r05t6dv9htwe3pf8ksrfr8ksca2lskzja",
status: .failed,
timestamp: 1699290621,
zecAmount: Zatoshi(25_108_700),
isSentTransaction: true
)
public static let mockedFailedReceive = TransactionState(
minedHeight: nil,
fee: Zatoshi(10_000),
id: "t1vergg5jkp4wy8sqfasw6s5zkdpnxvfxlxh35uuc3me7dp596y2r05t6dv9htwe3pf8ksrfr8ksca2lskzja",
status: .failed,
timestamp: 1699290621,
zecAmount: Zatoshi(25_001_000),
isSentTransaction: false
)
public static let mockedSending = TransactionState(
minedHeight: nil,
zAddress: "utest1vergg5jkp4xy8sqfasw6s5zkdpnxvfxlxh35uuc3me7dp596y2r05t6dv9htwe3pf8ksrfr8ksca2lskzjanqtl8uqp5vln3zyy246ejtx86vqftp73j7jg9099jxafyjhfm6u956j3",
fee: Zatoshi(10_000),
id: "t1vergg5jkp4wy8sqfasw6s5zkdpnxvfxlxh35uuc3me7dp596y2r05t6dv9htwe3pf8ksrfr8ksca2lskzja",
status: .sending,
timestamp: 1699290621,
zecAmount: Zatoshi(25_000_000),
isSentTransaction: true
)
public static let mockedReceiving = TransactionState(
minedHeight: nil,
fee: Zatoshi(10_000),
id: "t1vergg5jkp4wy8sqfasw6s5zkdpnxvfxlxh35uuc3me7dp596y2r05t6dv9htwe3pf8ksrfr8ksca2lskzja",
status: .receiving,
timestamp: 1699290621,
zecAmount: Zatoshi(25_000_000),
isSentTransaction: false
)
public static let mockedShielded = TransactionState(
minedHeight: BlockHeight(1),
zAddress: "utest1vergg5jkp4xy8sqfasw6s5zkdpnxvfxlxh35uuc3me7dp596y2r05t6dv9htwe3pf8ksrfr8ksca2lskzjanqtl8uqp5vln3zyy246ejtx86vqftp73j7jg9099jxafyjhfm6u956j3",
fee: Zatoshi(10_000),
id: "t1vergg5jkp4wy8sqfasw6s5zkdpnxvfxlxh35uuc3me7dp596y2r05t6dv9htwe3pf8ksrfr8ksca2lskzja",
status: .shielded,
timestamp: 1699290621,
zecAmount: Zatoshi(25_000_000),
isShieldingTransaction: true
)
public static let mockedShieldedExpanded = TransactionState(
minedHeight: BlockHeight(1),
zAddress: "utest1vergg5jkp4xy8sqfasw6s5zkdpnxvfxlxh35uuc3me7dp596y2r05t6dv9htwe3pf8ksrfr8ksca2lskzjanqtl8uqp5vln3zyy246ejtx86vqftp73j7jg9099jxafyjhfm6u956j3",
fee: Zatoshi(10_000),
id: "t1vergg5jkp4wy8sqfasw6s5zkdpnxvfxlxh35uuc3me7dp596y2r05t6dv9htwe3pf8ksrfr8ksca2lskzja",
status: .shielded,
timestamp: 1699290621,
zecAmount: Zatoshi(25_000_000),
isShieldingTransaction: true
)
}
public struct TransactionStateMockHelper {
public var date: TimeInterval
public var amount: Zatoshi
public var shielded = true
public var status: TransactionState.Status = .received
public var uuid = ""
public init(
date: TimeInterval,
amount: Zatoshi,
shielded: Bool = true,
status: TransactionState.Status = .received,
uuid: String = ""
) {
self.date = date
self.amount = amount
self.shielded = shielded
self.status = status
self.uuid = uuid
}
}