diff --git a/secant.xcodeproj/project.pbxproj b/secant.xcodeproj/project.pbxproj index 07c15bc..c13214b 100644 --- a/secant.xcodeproj/project.pbxproj +++ b/secant.xcodeproj/project.pbxproj @@ -101,6 +101,9 @@ 9E2F1C8F280EDE09004E65FE /* Drawer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E2F1C8E280EDE09004E65FE /* Drawer.swift */; }; 9E37A2B827C8F59F00AE57B3 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 9E37A2B727C8F59F00AE57B3 /* Localizable.strings */; }; 9E391124283E4CAC0073DD9A /* ImportWalletTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E391123283E4CAC0073DD9A /* ImportWalletTests.swift */; }; + 9E391129283F74590073DD9A /* Zatoshi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E391128283F74590073DD9A /* Zatoshi.swift */; }; + 9E39112A283F90F10073DD9A /* Double+Zcash.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EDDEA8B28250F9C00B4100C /* Double+Zcash.swift */; }; + 9E39112E283F91600073DD9A /* ZatoshiTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E39112D283F91600073DD9A /* ZatoshiTests.swift */; }; 9E4DC6E027C409A100E657F4 /* NeumorphicDesignModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E4DC6DF27C409A100E657F4 /* NeumorphicDesignModifier.swift */; }; 9E4DC6E227C4C6B700E657F4 /* SecantButtonStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E4DC6E127C4C6B700E657F4 /* SecantButtonStyles.swift */; }; 9E5BF63F2819542C00BA3F17 /* TransactionHistoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E5BF63E2819542C00BA3F17 /* TransactionHistoryTests.swift */; }; @@ -138,7 +141,6 @@ 9EAFEB9228081E9400199FC9 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F93874EF273C4DE200F0E875 /* HomeView.swift */; }; 9EBEF87A27CE369800B4F343 /* RecoveryPhraseValidationFlowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EBEF87927CE369800B4F343 /* RecoveryPhraseValidationFlowView.swift */; }; 9ECAE56827FC713C0089A0EF /* DatabaseFiles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9ECAE56727FC713C0089A0EF /* DatabaseFiles.swift */; }; - 9EDDEA8C28250F9C00B4100C /* Double+Zcash.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EDDEA8B28250F9C00B4100C /* Double+Zcash.swift */; }; 9EDDEAA22829610D00B4100C /* CurrencySelectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EDDEA9F2829610D00B4100C /* CurrencySelectionTests.swift */; }; 9EDDEAA32829610D00B4100C /* TransactionAmountInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EDDEAA02829610D00B4100C /* TransactionAmountInputTests.swift */; }; 9EDDEAA42829610D00B4100C /* TransactionAddressInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EDDEAA12829610D00B4100C /* TransactionAddressInputTests.swift */; }; @@ -291,6 +293,8 @@ 9E2F1C8E280EDE09004E65FE /* Drawer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Drawer.swift; sourceTree = ""; }; 9E37A2B727C8F59F00AE57B3 /* Localizable.strings */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; path = Localizable.strings; sourceTree = ""; }; 9E391123283E4CAC0073DD9A /* ImportWalletTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportWalletTests.swift; sourceTree = ""; }; + 9E391128283F74590073DD9A /* Zatoshi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Zatoshi.swift; sourceTree = ""; }; + 9E39112D283F91600073DD9A /* ZatoshiTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZatoshiTests.swift; sourceTree = ""; }; 9E4DC6DF27C409A100E657F4 /* NeumorphicDesignModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NeumorphicDesignModifier.swift; sourceTree = ""; }; 9E4DC6E127C4C6B700E657F4 /* SecantButtonStyles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecantButtonStyles.swift; sourceTree = ""; }; 9E5BF63B2818305D00BA3F17 /* TransactionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionState.swift; sourceTree = ""; }; @@ -834,6 +838,7 @@ 0D35CC45277A36E00074316A /* ScrollableWhenScaled.swift */, 2EDA07A327EDE2A900D6F09B /* DebugFrame.swift */, 9E2F1C832809B606004E65FE /* DebugMenu.swift */, + 9E391128283F74590073DD9A /* Zatoshi.swift */, ); path = Utils; sourceTree = ""; @@ -956,6 +961,7 @@ 9EAFEB852805A23100199FC9 /* WrappedSecItemTests.swift */, 9EF8135B27ECC25E0075AF48 /* UserPreferencesStorageTests.swift */, 9E02B56B27FED475005B809B /* DatabaseFilesTests.swift */, + 9E39112D283F91600073DD9A /* ZatoshiTests.swift */, ); path = UtilTests; sourceTree = ""; @@ -1302,7 +1308,9 @@ 9EBEF87A27CE369800B4F343 /* RecoveryPhraseValidationFlowView.swift in Sources */, 9E4DC6E227C4C6B700E657F4 /* SecantButtonStyles.swift in Sources */, 0DDB6A5127737D4A0012A410 /* RecoveryPhraseBackupFailedView.swift in Sources */, + 9E391129283F74590073DD9A /* Zatoshi.swift in Sources */, 0D6D628B276A528E002FB4CC /* DropDelegate.swift in Sources */, + 9E39112A283F90F10073DD9A /* Double+Zcash.swift in Sources */, 9E2DF99D27CF704D00649636 /* ImportSeedEditor.swift in Sources */, F9971A5327680DD000A2DB75 /* ProfileStore.swift in Sources */, 669FDAEB272C23C2007B9422 /* CircularFrameBadge.swift in Sources */, @@ -1354,7 +1362,6 @@ 0DF2DC5427235E3E00FA31E2 /* View+InnerShadow.swift in Sources */, 9EAFEB84280597B700199FC9 /* WrappedSecItem.swift in Sources */, 9E2AC10327DA28200042AA47 /* WalletStorage.swift in Sources */, - 9EDDEA8C28250F9C00B4100C /* Double+Zcash.swift in Sources */, 9ECAE56827FC713C0089A0EF /* DatabaseFiles.swift in Sources */, 9E5BF6462821028C00BA3F17 /* WrappedUserDefaults.swift in Sources */, F9971A6B27680E1000A2DB75 /* WalletInfoStore.swift in Sources */, @@ -1403,6 +1410,7 @@ 9E01F8282833CDA0000EFC57 /* ScanTests.swift in Sources */, 9EDDEAA42829610D00B4100C /* TransactionAddressInputTests.swift in Sources */, 6654C7442715A4AC00901167 /* OnboardingStoreTests.swift in Sources */, + 9E39112E283F91600073DD9A /* ZatoshiTests.swift in Sources */, 9EDDEAA32829610D00B4100C /* TransactionAmountInputTests.swift in Sources */, 9EAFEB862805A23100199FC9 /* WrappedSecItemTests.swift in Sources */, 9E5BF644281FEC9900BA3F17 /* SendTests.swift in Sources */, diff --git a/secant/SecantApp.swift b/secant/SecantApp.swift index 945b8f0..0155036 100644 --- a/secant/SecantApp.swift +++ b/secant/SecantApp.swift @@ -19,6 +19,8 @@ final class AppDelegate: NSObject, UIApplicationDelegate { _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil ) -> Bool { + // set the default behavior for the NSDecimalNumber + NSDecimalNumber.defaultBehavior = Zatoshi.decimalHandler appViewStore.send(.appDelegate(.didFinishLaunching)) return true } diff --git a/secant/Utils/Double+Zcash.swift b/secant/Utils/Double+Zcash.swift index b17beb4..ccd8920 100644 --- a/secant/Utils/Double+Zcash.swift +++ b/secant/Utils/Double+Zcash.swift @@ -14,6 +14,6 @@ extension Double { } func asZecString() -> String { - NumberFormatter.zcashFormatter.string(from: NSNumber(value: self)) ?? "" + NumberFormatter.zcashNumberFormatter.string(from: NSNumber(value: self)) ?? "" } } diff --git a/secant/Utils/Int64+Zcash.swift b/secant/Utils/Int64+Zcash.swift index 37276ac..bd95c22 100644 --- a/secant/Utils/Int64+Zcash.swift +++ b/secant/Utils/Int64+Zcash.swift @@ -14,6 +14,6 @@ extension Int64 { } func asZecString() -> String { - NumberFormatter.zcashFormatter.string(from: NSNumber(value: self.asHumanReadableZecBalance())) ?? "" + NumberFormatter.zcashNumberFormatter.string(from: NSNumber(value: self.asHumanReadableZecBalance())) ?? "" } } diff --git a/secant/Utils/Strings.swift b/secant/Utils/Strings.swift index c65e344..49b7302 100644 --- a/secant/Utils/Strings.swift +++ b/secant/Utils/Strings.swift @@ -10,7 +10,7 @@ extension String { } extension NumberFormatter { - static let zcashFormatter: NumberFormatter = { + static let zcashNumberFormatter: NumberFormatter = { var formatter = NumberFormatter() formatter.maximumFractionDigits = 8 formatter.maximumIntegerDigits = 8 @@ -22,7 +22,7 @@ extension NumberFormatter { extension String { var doubleValue: Double? { - return NumberFormatter.zcashFormatter.number(from: self)?.doubleValue + return NumberFormatter.zcashNumberFormatter.number(from: self)?.doubleValue } } diff --git a/secant/Utils/Zatoshi.swift b/secant/Utils/Zatoshi.swift new file mode 100644 index 0000000..5a96ed5 --- /dev/null +++ b/secant/Utils/Zatoshi.swift @@ -0,0 +1,74 @@ +// +// Zatoshi.swift +// secant-testnet +// +// Created by Lukáš Korba on 26.05.2022. +// + +import Foundation + +struct Zatoshi { + enum Constants { + static let oneZecInZatoshi: Int64 = 100_000_000 + static let maxZecSupply: Int64 = 21_000_000 + static let maxZatoshi: Int64 = Constants.oneZecInZatoshi * Constants.maxZecSupply + } + + static var decimalHandler = NSDecimalNumberHandler( + roundingMode: NSDecimalNumber.RoundingMode.bankers, + scale: 8, + raiseOnExactness: true, + raiseOnOverflow: true, + raiseOnUnderflow: true, + raiseOnDivideByZero: true + ) + + @Clamped(-Constants.maxZatoshi...Constants.maxZatoshi) + var amount: Int64 = 0 + + /// Converts `Zatoshi` to `NSDecimalNumber` + var decimalValue: NSDecimalNumber { + NSDecimalNumber(decimal: Decimal(amount) / Decimal(Constants.oneZecInZatoshi)) + } + + /// Converts `Zatoshi` to human readable format, up to 8 fraction digits + var decimalString: String { + decimalValue.roundedZec.stringValue + } + + /// Converts `Decimal` to `Zatoshi` + static func from(decimal: Decimal) -> Zatoshi { + let roundedZec = NSDecimalNumber(decimal: decimal).roundedZec + let zec2zatoshi = Decimal(Constants.oneZecInZatoshi) * roundedZec.decimalValue + return Zatoshi(amount: NSDecimalNumber(decimal: zec2zatoshi).int64Value) + } + + /// Converts `String` to `Zatoshi` + static func from(decimalString: String, formatter: NumberFormatter = NumberFormatter.zcashNumberFormatter) -> Zatoshi? { + if let number = formatter.number(from: decimalString) { + return Zatoshi.from(decimal: number.decimalValue) + } + + return nil + } + + static func + (left: Zatoshi, right: Zatoshi) -> Zatoshi { + Zatoshi(amount: left.amount + right.amount) + } + + static func - (left: Zatoshi, right: Zatoshi) -> Zatoshi { + Zatoshi(amount: left.amount - right.amount) + } +} + +extension NSDecimalNumber { + /// Converts `NSDecimalNumber` to human readable format, up to 8 fraction digits + var decimalString: String { + self.rounding(accordingToBehavior: Zatoshi.decimalHandler).stringValue + } + + /// Round the decimal to 8 fraction digits + var roundedZec: NSDecimalNumber { + self.rounding(accordingToBehavior: Zatoshi.decimalHandler) + } +} diff --git a/secantTests/UtilTests/ZatoshiTests.swift b/secantTests/UtilTests/ZatoshiTests.swift new file mode 100644 index 0000000..9f6ed26 --- /dev/null +++ b/secantTests/UtilTests/ZatoshiTests.swift @@ -0,0 +1,172 @@ +// +// ZatoshiTests.swift +// secantTests +// +// Created by Lukáš Korba on 26.05.2022. +// + +import XCTest +@testable import secant_testnet + +class ZatoshiTests: XCTestCase { + func testLowerBound() throws { + let number = Zatoshi(amount: -Zatoshi.Constants.maxZatoshi - 1) + + XCTAssertEqual( + -Zatoshi.Constants.maxZatoshi, + number.amount, + "Zatoshi tests: `testLowerBound` the value is expected to be clamped to lower bound but it's \(number.amount)" + ) + } + + func testUpperBound() throws { + let number = Zatoshi(amount: Zatoshi.Constants.maxZatoshi + 1) + + XCTAssertEqual( + Zatoshi.Constants.maxZatoshi, + number.amount, + "Zatoshi tests: `testUpperBound` the value is expected to be clamped to upper bound but it's \(number.amount)" + ) + } + + func testAddingZatoshi() throws { + let numberA1 = Zatoshi(amount: 100_000) + let numberB1 = Zatoshi(amount: 200_000) + let result1 = numberA1 + numberB1 + + XCTAssertEqual( + result1.amount, + Zatoshi(amount: 300_000).amount, + "Zatoshi tests: `testAddingZatoshi` the value is expected to be 300_000 but it's \(result1.amount)" + ) + + let numberA2 = Zatoshi(amount: -100_000) + let numberB2 = Zatoshi(amount: 200_000) + let result2 = numberA2 + numberB2 + + XCTAssertEqual( + result2.amount, + Zatoshi(amount: 100_000).amount, + "Zatoshi tests: `testAddingZatoshi` the value is expected to be 100_000 but it's \(result2.amount)" + ) + + let numberA3 = Zatoshi(amount: 100_000) + let numberB3 = Zatoshi(amount: -200_000) + let result3 = numberA3 + numberB3 + + XCTAssertEqual( + result3.amount, + Zatoshi(amount: -100_000).amount, + "Zatoshi tests: `testAddingZatoshi` the value is expected to be -100_000 but it's \(result3.amount)" + ) + + let number = Zatoshi(amount: Zatoshi.Constants.maxZatoshi) + let result4 = number + number + + XCTAssertEqual( + result4.amount, + Zatoshi.Constants.maxZatoshi, + "Zatoshi tests: `testAddingZatoshi` the value is expected to be clapmed to upper bound but it's \(result4.amount)" + ) + } + + func testSubtractingZatoshi() throws { + let numberA1 = Zatoshi(amount: 100_000) + let numberB1 = Zatoshi(amount: 200_000) + let result1 = numberA1 - numberB1 + + XCTAssertEqual( + result1.amount, + Zatoshi(amount: -100_000).amount, + "Zatoshi tests: `testSubtractingZatoshi` the value is expected to be -100_000 but it's \(result1.amount)" + ) + + let numberA2 = Zatoshi(amount: -100_000) + let numberB2 = Zatoshi(amount: 200_000) + let result2 = numberA2 - numberB2 + + XCTAssertEqual( + result2.amount, + Zatoshi(amount: -300_000).amount, + "Zatoshi tests: `testSubtractingZatoshi` the value is expected to be -300_000 but it's \(result2.amount)" + ) + + let numberA3 = Zatoshi(amount: 100_000) + let numberB3 = Zatoshi(amount: -200_000) + let result3 = numberA3 - numberB3 + + XCTAssertEqual( + result3.amount, + Zatoshi(amount: 300_000).amount, + "Zatoshi tests: `testSubtractingZatoshi` the value is expected to be 300_000 but it's \(result3.amount)" + ) + + let number = Zatoshi(amount: -Zatoshi.Constants.maxZatoshi) + let result4 = number + number + + XCTAssertEqual( + result4.amount, + -Zatoshi.Constants.maxZatoshi, + "Zatoshi tests: `testSubtractingZatoshi` the value is expected to be clapmed to lower bound but it's \(result4.amount)" + ) + } + + func testHumanReadable() throws { + // result of this division is 1.4285714285714285714285714285714285714 + let number = Zatoshi.from(decimal: Decimal(200.0 / 140.0)) + + // IMPORTANT: the INTERNAL value of number is still 1.4285714285714285714285714285714285714!!! + // but decimalString is rounding it to maximumFractionDigits set to be 8 + + // We can't compare it to double value 1.42857143 (or even Decimal(1.42857143)) + // so we convert it to string, in that case we are prooving it to be rendered + // to the user exactly the way we want + XCTAssertEqual( + number.decimalString, + "1.42857143", + "Zatoshi tests: the value is expected to be 1.42857143 but it's \(number.decimalString)" + ) + } + + func testUSDtoZecToUSD() throws { + // The price of zec is $140, we want to send $200 + let usd2zec = NSDecimalNumber(decimal: 200.0 / 140.0) + + XCTAssertEqual( + usd2zec.decimalString, + "1.42857143", + "Zatoshi tests: `testUSDtoZatoshiToUSD` the value is expected to be 1.42857143 but it's \(usd2zec.decimalString)" + ) + + // convert it back + let zec2usd = NSDecimalNumber(decimal: usd2zec.decimalValue * 140.0) + + XCTAssertEqual( + zec2usd.decimalString, + "200", + "Zatoshi tests: `testUSDtoZatoshiToUSD` the value is expected to be 200 but it's \(zec2usd.decimalString)" + ) + } + + func testStringToZatoshi() throws { + if let number = Zatoshi.from(decimalString: "200.0") { + XCTAssertEqual( + number.decimalString, + "200", + "Zatoshi tests: `testStringToZec` the value is expected to be 200 but it's \(number.decimalString)" + ) + } else { + XCTFail("Zatoshi tests: `testStringToZatoshi` failed to convert number.") + } + + if let number = Zatoshi.from(decimalString: "0.02836478949923") { + XCTAssertEqual( + number.amount, + 2_836_479, + "Zatoshi tests: the value is expected to be 2_836_478 but it's \(number.amount)" + ) + } else { + XCTFail("Zatoshi tests: `testStringToZatoshi` failed to convert number.") + } + } +}