Skip to content

Commit

Permalink
Merge pull request #36 from pacu/big-decimal
Browse files Browse the repository at this point in the history
[#35] Use BigDecimal instead of Decimal
  • Loading branch information
pacu authored Jan 2, 2024
2 parents 203341e + fc2c2ca commit fa082f7
Show file tree
Hide file tree
Showing 10 changed files with 225 additions and 78 deletions.
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.1.0-beta.1] - 2024-01-01
- Fixed [problem with literal Decimals](https://github.com/pacu/zcash-swift-payment-uri/issues/35)
- Always favor using `BigDecimal` to avoid misrepresentations of Decimal from
implicit conversion from `Double`.

### additions
- BigDecimal library that handles the internals of `Amount`
- `init(decimal:)` uses BigDecimal
- `init(value:)` uses Swift's Double

## [0.1.0-beta] - 2023-12-23
- CI had to be disabled because of swift 5.9 issue with SwiftFormat
- `Parser` API
Expand Down
27 changes: 27 additions & 0 deletions Package.resolved
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
{
"pins" : [
{
"identity" : "bigdecimal",
"kind" : "remoteSourceControl",
"location" : "https://github.com/mgriebling/BigDecimal.git",
"state" : {
"revision" : "c4e8348c7fbc90f29225d5f8681ce33a16ab33a2",
"version" : "2.2.3"
}
},
{
"identity" : "bigint",
"kind" : "remoteSourceControl",
"location" : "https://github.com/mgriebling/BigInt.git",
"state" : {
"revision" : "498d4d290658d7c43a24b9e309c321592dc294f2",
"version" : "2.0.10"
}
},
{
"identity" : "collectionconcurrencykit",
"kind" : "remoteSourceControl",
Expand Down Expand Up @@ -90,6 +108,15 @@
"version" : "7.0.2"
}
},
{
"identity" : "uint128",
"kind" : "remoteSourceControl",
"location" : "https://github.com/mgriebling/UInt128.git",
"state" : {
"revision" : "59dac4f14d657fd60bacfdfb7398d38b450af74f",
"version" : "3.1.5"
}
},
{
"identity" : "xctest-dynamic-overlay",
"kind" : "remoteSourceControl",
Expand Down
8 changes: 7 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,18 @@ let dependencies: [Package.Dependency] = [
.package(url: "https://github.com/realm/SwiftLint.git", from: "0.54.0"),
.package(url: "https://github.com/pointfreeco/swift-parsing", from: "0.13.0"),
.package(url: "https://github.com/pointfreeco/swift-case-paths", exact: Version(stringLiteral: "1.0.0")),
.package(url: "https://github.com/mgriebling/BigDecimal.git", from: "2.0.0")
]

let targets: [Target] = [
// Targets are the basic building blocks of a package, defining a module or a test suite.
// Targets can depend on other targets in this package and products from dependencies.
.target(
name: "zcash-swift-payment-uri",
dependencies: [.product(name: "Parsing", package: "swift-parsing")],
dependencies: [
.product(name: "Parsing", package: "swift-parsing"),
.product(name: "BigDecimal", package: "BigDecimal"),
],
plugins: [.plugin(name: "SwiftLintPlugin", package: "SwiftLint")]),
.testTarget(
name: "zcash-swift-payment-uriTests",
Expand All @@ -25,6 +29,7 @@ let targets: [Target] = [
let dependencies: [Package.Dependency] = [
.package(url: "https://github.com/pointfreeco/swift-parsing", from: "0.13.0"),
.package(url: "https://github.com/pointfreeco/swift-case-paths", exact: Version(stringLiteral: "1.0.0")),
.package(url: "https://github.com/mgriebling/BigDecimal.git", from: "2.0.0")
]

let targets: [Target] = [
Expand All @@ -34,6 +39,7 @@ let targets: [Target] = [
name: "zcash-swift-payment-uri",
dependencies: [
.product(name: "Parsing", package: "swift-parsing"),
.product(name: "BigDecimal", package: "BigDecimal"),
]
),
.testTarget(
Expand Down
120 changes: 120 additions & 0 deletions Sources/zcash-swift-payment-uri/Amount.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
//
// Amount.swift
//
//
// Created by Pacu on 2024-01-01.
//

import Foundation
import BigDecimal
/// An *non-negative* decimal ZEC amount represented as specified in ZIP-321.
/// Amount can be from 1 zatoshi (0.00000001) to the `maxSupply` of 21M ZEC (`21_000_000`)
public struct Amount: Equatable {
public enum AmountError: Error {
case negativeAmount
case greaterThanSupply
case tooManyFractionalDigits
case invalidTextInput
}

static let maxFractionalDecimalDigits: Int = 8

static let zecRounding = Rounding(.toNearestOrEven, maxFractionalDecimalDigits)

static let decimalHandler = NSDecimalNumberHandler(
roundingMode: NSDecimalNumber.RoundingMode.bankers,
scale: Int16(Self.maxFractionalDecimalDigits),
raiseOnExactness: true,
raiseOnOverflow: true,
raiseOnUnderflow: true,
raiseOnDivideByZero: true
)

static let maxSupply: BigDecimal = 21_000_000

static let zero = Amount(unchecked: 0)

let value: BigDecimal

/// Initializes an Amount from a `Decimal` number
/// - parameter value: decimal representation of the desired amount. **Important:** `Decimal` values with more than 8 fractional digits ** will be rounded** using bankers rounding.
/// - returns A valid ZEC amount
/// - throws `Amount.AmountError` then the provided value can't represent or can't be rounded to a non-negative ZEC decimal amount.
public init(value: Double) throws {
guard value >= 0 else { throw AmountError.negativeAmount }

guard value <= Self.maxSupply.asDouble() else { throw AmountError.greaterThanSupply }

try self.init(decimal: BigDecimal(value).round(Self.zecRounding))
}

/// Initializes an Amount from a `BigDecimal` number
/// - parameter decimal: decimal representation of the desired amount. **Important:** `Decimal` values with more than 8 fractional digits ** will be rounded** using bankers rounding.
/// - returns A valid ZEC amount
/// - throws `Amount.AmountError` then the provided value can't represent or can't be rounded to a non-negative ZEC decimal amount.
public init(decimal: BigDecimal) throws {
guard decimal >= 0 else { throw AmountError.negativeAmount }

guard decimal <= Self.maxSupply else { throw AmountError.greaterThanSupply }

guard decimal.significantFractionalDecimalDigits <= Self.maxFractionalDecimalDigits else {
throw AmountError.tooManyFractionalDigits
}

guard decimal <= Self.maxSupply else { throw AmountError.greaterThanSupply }

self.value = decimal
}

/// Initializes an Amount from a `BigDecimal` number
/// - parameter decimal: decimal representation of the desired amount. **Important:** `Decimal` values with more than 8 fractional digits ** will be rounded** using bankers rounding.
/// - returns A valid ZEC amount
/// - throws `Amount.AmountError` then the provided value can't represent or can't be rounded to a non-negative ZEC decimal amount.
public init(decimal: Decimal) throws {
guard decimal >= 0 else { throw AmountError.negativeAmount }

guard decimal.significantFractionalDecimalDigits <= Self.maxFractionalDecimalDigits else {
throw AmountError.tooManyFractionalDigits
}

guard decimal <= Self.maxSupply.asDecimal() else { throw AmountError.greaterThanSupply }

self.value = BigDecimal(decimal).round(Self.zecRounding)
}

public init(string: String) throws {
let decimalAmount = BigDecimal(string).trim

guard !decimalAmount.isNaN else {
throw AmountError.invalidTextInput
}

guard decimalAmount.significantFractionalDecimalDigits <= Self.maxFractionalDecimalDigits else {
throw AmountError.tooManyFractionalDigits
}

try self.init(decimal: decimalAmount)
}

init(unchecked: BigDecimal) {
self.value = unchecked
}

public func toString() -> String {
let decimal = value.round(Rounding(.toNearestOrEven, Self.maxFractionalDecimalDigits)).trim

return decimal.asString(.plain) // this value is already validated.
}
}

extension BigDecimal {
var significantFractionalDecimalDigits: Int {
return max(-exponent, 0)
}
}

extension Decimal {
var significantFractionalDecimalDigits: Int {
return max(-exponent, 0)
}
}
73 changes: 2 additions & 71 deletions Sources/zcash-swift-payment-uri/Model.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,70 +49,6 @@ public struct OtherParam: Equatable {
public let value: String
}

/// An *non-negative* decimal ZEC amount represented as specified in ZIP-321.
/// Amount can be from 1 zatoshi (0.00000001) to the `maxSupply` of 21M ZEC (`21_000_000`)
public struct Amount: Equatable {
public enum AmountError: Error {
case negativeAmount
case greaterThanSupply
case tooManyFractionalDigits
case invalidTextInput
}

static let maxFractionalDecimalDigits: Int16 = 8
static let decimalHandler = NSDecimalNumberHandler(
roundingMode: NSDecimalNumber.RoundingMode.bankers,
scale: Self.maxFractionalDecimalDigits,
raiseOnExactness: true,
raiseOnOverflow: true,
raiseOnUnderflow: true,
raiseOnDivideByZero: true
)

static let maxSupply: Decimal = 21_000_000

static let zero = Amount(unchecked: 0)

let value: Decimal
/// Initializes an Amount from a `Decimal` number
/// - parameter value: decimal representation of the desired amount. **Important:** `Decimal` values with more than 8 fractional digits ** will be rounded** using bankers rounding.
/// - returns A valid ZEC amount
/// - throws `Amount.AmountError` then the provided value can't represent or can't be rounded to a non-negative ZEC decimal amount.
public init(value: Decimal) throws {
guard value >= 0 else { throw AmountError.negativeAmount }

guard value <= Self.maxSupply else { throw AmountError.greaterThanSupply }

self.value = value
}

public init(string: String) throws {
let formatter = NumberFormatter.zcashNumberFormatter

guard let decimalAmount = formatter.number(from: string)?.decimalValue else {
throw AmountError.invalidTextInput
}

guard decimalAmount.significantFractionalDecimalDigits <= Self.maxFractionalDecimalDigits else {
throw AmountError.tooManyFractionalDigits
}

try self.init(value: decimalAmount)
}

init(unchecked: Decimal) {
self.value = unchecked
}

public func toString() -> String {
let formatter = NumberFormatter.zcashNumberFormatter

let decimal = NSDecimalNumber(decimal: self.value)

return formatter.string(from: decimal.rounding(accordingToBehavior: Self.decimalHandler)) ?? "" // this value is already validated.
}
}

public struct MemoBytes: Equatable {
public enum MemoError: Error {
case memoTooLong
Expand Down Expand Up @@ -185,17 +121,12 @@ extension NumberFormatter {
formatter.numberStyle = .decimal
formatter.usesGroupingSeparator = false
formatter.decimalSeparator = "."

formatter.roundingMode = .halfUp

return formatter
}()
}

extension Decimal {
var significantFractionalDecimalDigits: Int {
return max(-exponent, 0)
}
}

extension String.StringInterpolation {
mutating func appendInterpolation(_ value: Amount) {
appendLiteral(value.toString())
Expand Down
2 changes: 1 addition & 1 deletion Sources/zcash-swift-payment-uri/Parser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ enum Parser {
}
}

return try paramsByIndex.compactMap {
return try paramsByIndex.map {
try Payment.uniqueIndexedParameters(index: $0, parameters: $1)
}
}
Expand Down
7 changes: 7 additions & 0 deletions Tests/zcash-swift-payment-uriTests/AmountTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,17 @@
//

import XCTest
import BigDecimal
@testable import zcash_swift_payment_uri
final class AmountTests: XCTestCase {
func testAmountStringDecimals() throws {
XCTAssertEqual(try Amount(value: 123.456).toString(), "123.456")

XCTAssertEqual("\(try Amount(value: 123.456))", "123.456")

let stringDecimal = try Amount(string: "123.456")
let literalDecimal = try Amount(value: 123.456)
XCTAssertEqual(stringDecimal, literalDecimal)
}

func testAmountTrailing() throws {
Expand All @@ -27,7 +32,9 @@ final class AmountTests: XCTestCase {
}

func testAmountThrowsIfMaxSupply() throws {
XCTAssertThrowsError(try Amount(decimal: BigDecimal(21_000_000.00000001)).toString())
XCTAssertThrowsError(try Amount(value: 21_000_000.00000001).toString())
XCTAssertThrowsError(try Amount(string: "21_000_000.00000001").toString())
}

func testAmountThrowsIfNegativeAmount() throws {
Expand Down
6 changes: 3 additions & 3 deletions Tests/zcash-swift-payment-uriTests/ParsingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ final class ParsingTests: XCTestCase {
payments: [
Payment(
recipientAddress: recipient,
amount: Amount(unchecked: 1.0001),
amount: try Amount(string:"1.0001"),
memo: nil,
label: nil,
message: "lunch",
Expand Down Expand Up @@ -560,7 +560,7 @@ final class ParsingTests: XCTestCase {
let value = "1.00020112"[...]

XCTAssertEqual(
IndexedParameter(index: 0, param: .amount(try Amount(value: 1.00020112))),
IndexedParameter(index: 0, param: .amount(try Amount(string: String(value)))),
try Parser.zcashParameter((query, nil, value), validating: Parser.onlyCharsetValidation)
)
}
Expand Down Expand Up @@ -944,4 +944,4 @@ final class ParsingTests: XCTestCase {
}
}
}
// swiftlint:enable line_length

4 changes: 2 additions & 2 deletions Tests/zcash-swift-payment-uriTests/RendererTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ final class RendererTests: XCTestCase {
func testAmountRendersNoParamIndex() throws {
let expected = "amount=123.456"

let amount = try Amount(value: Decimal(123.456))
let amount = try Amount(string: "123.456")

XCTAssertEqual(Render.parameter(amount, index: nil), expected)

Expand All @@ -21,7 +21,7 @@ final class RendererTests: XCTestCase {
func testAmountRendersWithParamIndex() throws {
let expected = "amount.1=123.456"

let amount = try Amount(value: Decimal(123.456))
let amount = try Amount(string: "123.456")

XCTAssertEqual(Render.parameter(amount, index: 1), expected)
}
Expand Down
Loading

0 comments on commit fa082f7

Please sign in to comment.