From 62b663428746c82a7eb46ff673364b8a03eea825 Mon Sep 17 00:00:00 2001 From: Jonathan Buchanan Date: Wed, 4 Aug 2021 00:11:35 -0400 Subject: partial implementation of amounts --- Taler.xcodeproj/project.pbxproj | 10 +- Taler/Amount.swift | 194 ++++++++++++++++++++++++++++++++++++++ Taler/WalletBackend.swift | 203 ++++++++++++++++++++++++++++++++++++++-- TalerTests/AmountTests.swift | 62 ++++++++++++ 4 files changed, 461 insertions(+), 8 deletions(-) create mode 100644 Taler/Amount.swift create mode 100644 TalerTests/AmountTests.swift diff --git a/Taler.xcodeproj/project.pbxproj b/Taler.xcodeproj/project.pbxproj index a8a4b3d..12f8c85 100644 --- a/Taler.xcodeproj/project.pbxproj +++ b/Taler.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ D112510026B12E3200D02E00 /* taler-wallet-embedded.js in CopyFiles */ = {isa = PBXBuildFile; fileRef = D11250FF26B12E3200D02E00 /* taler-wallet-embedded.js */; }; + D1472E5526B9206800896566 /* AmountTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1472E5426B9206800896566 /* AmountTests.swift */; }; D14AFD2124D232B300C51073 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D14AFD2024D232B300C51073 /* AppDelegate.swift */; }; D14AFD2324D232B300C51073 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D14AFD2224D232B300C51073 /* SceneDelegate.swift */; }; D14AFD2524D232B300C51073 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D14AFD2424D232B300C51073 /* ContentView.swift */; }; @@ -34,6 +35,7 @@ D17D8B8425ADB29B001BD43D /* libhistogram.a in Frameworks */ = {isa = PBXBuildFile; fileRef = D17D8B5625ADB130001BD43D /* libhistogram.a */; }; D17D8B8525ADB29B001BD43D /* libcares.a in Frameworks */ = {isa = PBXBuildFile; fileRef = D17D8B4825ADB12B001BD43D /* libcares.a */; }; D1AFF0F3268D59C200FBB744 /* libiono.a in Frameworks */ = {isa = PBXBuildFile; fileRef = D1AFF0F2268D59A500FBB744 /* libiono.a */; }; + D1BA3F9226B8889600A5848B /* Amount.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1BA3F9126B8889600A5848B /* Amount.swift */; }; D1D65B9826992E4600C1012A /* WalletBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1D65B9726992E4600C1012A /* WalletBackend.swift */; }; /* End PBXBuildFile section */ @@ -111,6 +113,7 @@ D145D1EF25AC416B00CDD61B /* libv8_base_without_compiler.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libv8_base_without_compiler.a; path = "ios-node-v8/taler-ios-build/compiled/node-x64/libv8_base_without_compiler.a"; sourceTree = ""; }; D145D1F025AC416B00CDD61B /* libhistogram.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libhistogram.a; path = "ios-node-v8/taler-ios-build/compiled/node-x64/libhistogram.a"; sourceTree = ""; }; D145D1F125AC416B00CDD61B /* libv8_libbase.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libv8_libbase.a; path = "ios-node-v8/taler-ios-build/compiled/node-x64/libv8_libbase.a"; sourceTree = ""; }; + D1472E5426B9206800896566 /* AmountTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AmountTests.swift; sourceTree = ""; }; D14AFD1D24D232B300C51073 /* Taler.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Taler.app; sourceTree = BUILT_PRODUCTS_DIR; }; D14AFD2024D232B300C51073 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; D14AFD2224D232B300C51073 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; @@ -148,6 +151,7 @@ D17D8B5725ADB130001BD43D /* libtorque_base.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libtorque_base.a; path = "ios-node-v8/taler-ios-build/compiled/node-arm64/libtorque_base.a"; sourceTree = ""; }; D1AB963B259EB13D00DEAB23 /* libnode.89.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; name = libnode.89.dylib; path = "ios-node-v8/taler-ios-build/compiled/x64-v8a/libnode.89.dylib"; sourceTree = ""; }; D1AFF0F2268D59A500FBB744 /* libiono.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libiono.a; path = iono/compiled/x64/libiono.a; sourceTree = ""; }; + D1BA3F9126B8889600A5848B /* Amount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Amount.swift; sourceTree = ""; }; D1D65B9726992E4600C1012A /* WalletBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletBackend.swift; sourceTree = ""; }; D1F0C22F25A958AE00C3179D /* libllhttp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libllhttp.a; path = "ios-node-v8/tools/ios-framework/bin/x64/libllhttp.a"; sourceTree = ""; }; D1F0C23025A958AE00C3179D /* libv8_initializers.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libv8_initializers.a; path = "ios-node-v8/tools/ios-framework/bin/x64/libv8_initializers.a"; sourceTree = ""; }; @@ -266,6 +270,7 @@ D14AFD2024D232B300C51073 /* AppDelegate.swift */, D14AFD2224D232B300C51073 /* SceneDelegate.swift */, D1D65B9726992E4600C1012A /* WalletBackend.swift */, + D1BA3F9126B8889600A5848B /* Amount.swift */, D14AFD2424D232B300C51073 /* ContentView.swift */, D14AFD2624D232B500C51073 /* Assets.xcassets */, D14AFD2B24D232B500C51073 /* LaunchScreen.storyboard */, @@ -279,6 +284,7 @@ children = ( D14AFD3724D232B500C51073 /* TalerTests.swift */, D14AFD3924D232B500C51073 /* Info.plist */, + D1472E5426B9206800896566 /* AmountTests.swift */, ); path = TalerTests; sourceTree = ""; @@ -570,7 +576,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "WALLET_CORE_VERSION=\"v0.8.1\"\nWALLET_CORE_HASH=\"23bf89b663f0fd0e84a3d7e54a19766766c7306e5704e43a25df57da72056fa7\"\nWALLET_SRC=\"https://git.taler.net/wallet-core.git/plain/${WALLET_CORE_VERSION}/taler-wallet-embedded.js?h=prebuilt\"\nWALLET_DST=\"${SRCROOT}/taler-wallet-embedded.js\"\n\n[ ! -e $WALLET_DST ] || rm $WALLET_DST\ncurl $WALLET_SRC --output $WALLET_DST\n\nRECEIVED_HASH=$(openssl sha256 -r $WALLET_DST)\nRECEIVED_HASH_SPLIT=($RECEIVED_HASH)\nif [ $WALLET_CORE_HASH != ${RECEIVED_HASH_SPLIT[0]} ]\nthen\n exit 1\nfi\n"; + shellScript = "exit 0\nWALLET_CORE_VERSION=\"v0.8.1\"\nWALLET_CORE_HASH=\"23bf89b663f0fd0e84a3d7e54a19766766c7306e5704e43a25df57da72056fa7\"\nWALLET_SRC=\"https://git.taler.net/wallet-core.git/plain/${WALLET_CORE_VERSION}/taler-wallet-embedded.js?h=prebuilt\"\nWALLET_DST=\"${SRCROOT}/taler-wallet-embedded.js\"\n\n[ ! -e $WALLET_DST ] || rm $WALLET_DST\ncurl $WALLET_SRC --output $WALLET_DST\n\nRECEIVED_HASH=$(openssl sha256 -r $WALLET_DST)\nRECEIVED_HASH_SPLIT=($RECEIVED_HASH)\nif [ $WALLET_CORE_HASH != ${RECEIVED_HASH_SPLIT[0]} ]\nthen\n exit 1\nfi\n"; }; /* End PBXShellScriptBuildPhase section */ @@ -579,6 +585,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + D1BA3F9226B8889600A5848B /* Amount.swift in Sources */, D14AFD2124D232B300C51073 /* AppDelegate.swift in Sources */, D14AFD2324D232B300C51073 /* SceneDelegate.swift in Sources */, D14AFD2524D232B300C51073 /* ContentView.swift in Sources */, @@ -591,6 +598,7 @@ buildActionMask = 2147483647; files = ( D14AFD3824D232B500C51073 /* TalerTests.swift in Sources */, + D1472E5526B9206800896566 /* AmountTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Taler/Amount.swift b/Taler/Amount.swift new file mode 100644 index 0000000..83445b4 --- /dev/null +++ b/Taler/Amount.swift @@ -0,0 +1,194 @@ +/* + * This file is part of GNU Taler + * (C) 2021 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see + */ + +import Foundation + +enum AmountError: Error { + case invalidStringRepresentation + case incompatibleCurrency + case invalidAmount + case negativeAmount +} + +class Amount: Codable, CustomStringConvertible { + private static let maxValue: UInt64 = 1 << 52 + private static let fractionalBase: UInt32 = 100000000 + private static let fractionalBaseDigits: UInt = 8 + var currency: String + var value: UInt64 + var fraction: UInt32 + var description: String { + if fraction == 0 { + return "\(currency):\(value)" + } else { + var frac = fraction + var fracStr = "" + while (frac > 0) { + fracStr += "\(frac / (Amount.fractionalBase / 10))" + frac = (frac * 10) % Amount.fractionalBase + } + return "\(currency):\(value).\(fracStr)" + } + } + var valid: Bool { + return (value <= Amount.maxValue && currency != "") + } + + init(fromString string: String) throws { + guard let separatorIndex = string.firstIndex(of: ":") else { throw AmountError.invalidStringRepresentation } + self.currency = String(string[.. Amount { + return Amount(currency: self.currency, value: self.value, fraction: self.fraction) + } + + func normalizedCopy() throws -> Amount { + let amount = self.copy() + try amount.normalize() + return amount + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(self.description) + } + + func normalize() throws { + if !valid { + throw AmountError.invalidAmount + } + self.value += UInt64(self.fraction / Amount.fractionalBase) + self.fraction = self.fraction % Amount.fractionalBase + if !valid { + throw AmountError.invalidAmount + } + } + + static func + (left: Amount, right: Amount) throws -> Amount { + if left.currency != right.currency { + throw AmountError.incompatibleCurrency + } + let leftNormalized = try left.normalizedCopy() + let rightNormalized = try right.normalizedCopy() + let result: Amount = leftNormalized + result.value += rightNormalized.value + result.fraction += rightNormalized.fraction + try result.normalize() + return result + } + + static func - (left: Amount, right: Amount) throws -> Amount { + if left.currency != right.currency { + throw AmountError.incompatibleCurrency + } + let leftNormalized = try left.normalizedCopy() + let rightNormalized = try right.normalizedCopy() + if (leftNormalized.fraction < rightNormalized.fraction) { + guard leftNormalized.value != 0 else { throw AmountError.negativeAmount } + leftNormalized.value -= 1 + leftNormalized.fraction += Amount.fractionalBase + } + guard leftNormalized.value >= rightNormalized.value else { throw AmountError.negativeAmount } + let diff = Amount.zero(currency: left.currency) + diff.value = leftNormalized.value - rightNormalized.value + diff.fraction = leftNormalized.fraction - rightNormalized.fraction + try diff.normalize() + return diff + } + + static func == (left: Amount, right: Amount) throws -> Bool { + if left.currency != right.currency { + throw AmountError.incompatibleCurrency + } + let leftNormalized = try left.normalizedCopy() + let rightNormalized = try right.normalizedCopy() + return (leftNormalized.value == rightNormalized.value && leftNormalized.fraction == rightNormalized.fraction) + } + + static func < (left: Amount, right: Amount) throws -> Bool { + if left.currency != right.currency { + throw AmountError.incompatibleCurrency + } + let leftNormalized = try left.normalizedCopy() + let rightNormalized = try right.normalizedCopy() + if (leftNormalized.value == rightNormalized.value) { + return (leftNormalized.fraction < rightNormalized.fraction) + } else { + return (leftNormalized.value < rightNormalized.value) + } + } + + static func > (left: Amount, right: Amount) throws -> Bool { + return try right < left + } + + static func zero(currency: String) -> Amount { + return Amount(currency: currency, value: 0, fraction: 0) + } +} diff --git a/Taler/WalletBackend.swift b/Taler/WalletBackend.swift index 5a28371..d5cc3ab 100644 --- a/Taler/WalletBackend.swift +++ b/Taler/WalletBackend.swift @@ -17,19 +17,26 @@ import Foundation import iono +enum WalletBackendResponseError: Error { + case malformedResponse +} + protocol WalletBackendRequest { associatedtype Args: Encodable + associatedtype Response: Decodable func operation() -> String func args() -> Args + func success(result: Response) + func error(_ err: WalletBackendResponseError) } fileprivate struct WalletBackendRequestData: Encodable { var operation: String - var id: Int + var id: UInt var args: T.Args - init(request: T, id: Int) { + init(request: T, id: UInt) { operation = request.operation() self.id = id args = request.args() @@ -41,10 +48,22 @@ fileprivate struct WalletBackendInitRequest: WalletBackendRequest { var persistentStoragePath: String } typealias Args = RequestArgs + struct Response: Codable { + struct SupportedProtocolVersions: Codable { + var exchange: String + var merchant: String + } + var supportedProtocolVersions: SupportedProtocolVersions + enum CodingKeys: String, CodingKey { + case supportedProtocolVersions = "supported_protocol_versions" + } + } private var requestArgs: RequestArgs + private let success: () -> Void - init(persistentStoragePath: String) { + init(persistentStoragePath: String, onSuccess: @escaping () -> Void) { requestArgs = RequestArgs(persistentStoragePath: persistentStoragePath) + self.success = onSuccess } func operation() -> String { @@ -54,6 +73,14 @@ fileprivate struct WalletBackendInitRequest: WalletBackendRequest { func args() -> Args { return requestArgs } + + func success(result: Response) { + self.success() + } + + func error(_ err: WalletBackendResponseError) { + + } } fileprivate struct WalletBackendGetTransactionsRequest: WalletBackendRequest { @@ -61,6 +88,7 @@ fileprivate struct WalletBackendGetTransactionsRequest: WalletBackendRequest { } typealias Args = RequestArgs + typealias Response = String private var requestArgs: RequestArgs init() { @@ -74,19 +102,110 @@ fileprivate struct WalletBackendGetTransactionsRequest: WalletBackendRequest { func args() -> Args { return requestArgs } + + func success(result: Response) { + + } + + func error(_ err: WalletBackendResponseError) { + + } +} + +struct WalletBackendGetBalancesRequest: WalletBackendRequest { + struct Balance: Decodable { + var available: Amount + var pendingIncoming: Amount + var pendingOutgoing: Amount + var requiresUserInput: Bool + } + struct BalancesResponse: Decodable { + var balances: [Balance] + } + struct RequestArgs: Encodable { + + } + typealias Args = RequestArgs + typealias Response = BalancesResponse + private var requestArgs: RequestArgs + private let success: ([Balance]) -> Void + private let failure: () -> Void + + init(onSuccess: @escaping ([Balance]) -> Void, onFailure: @escaping () -> Void) { + self.requestArgs = RequestArgs() + self.success = onSuccess + self.failure = onFailure + } + + func operation() -> String { + return "getBalances" + } + + func args() -> Args { + return requestArgs + } + + func success(result: Response) { + print("balances???") + } + + func error(_ err: WalletBackendResponseError) { + + } +} + +struct WalletBackendWithdrawTestBalance: WalletBackendRequest { + struct RequestArgs: Encodable { + var amount: String + var bankBaseUrl: String + var exchangeBaseUrl: String + } + typealias Args = RequestArgs + typealias Response = String + private var requestArgs: RequestArgs + + init(amount: String, bankBaseUrl: String, exchangeBaseUrl: String) { + requestArgs = RequestArgs(amount: amount, bankBaseUrl: bankBaseUrl, exchangeBaseUrl: exchangeBaseUrl) + } + + func operation() -> String { + return "withdrawTestBalance" + } + + func args() -> Args { + return requestArgs + } + + func success(result: Response) { + + } + + func error(_ err: WalletBackendResponseError) { + + } } enum WalletBackendError: Error { case serializationError + case deserializationError } class WalletBackend: IonoMessageHandler { private var iono: Iono - private var requestsMade: Int + private var requestsMade: UInt + private var backendReady: Bool + private var backendReadyCondition: NSCondition + private struct RequestDetail { + let decodeSuccess: (Data) -> Void + //let handleError: (Data) -> Void + } + private var requests: [UInt : RequestDetail] = [:] init() { iono = Iono() requestsMade = 0 + self.backendReady = false + self.backendReadyCondition = NSCondition() iono.messageHandler = self @@ -107,21 +226,91 @@ class WalletBackend: IonoMessageHandler { var storageDir = documentUrls[0] storageDir.appendPathComponent("talerwalletdb-v30", isDirectory: false) storageDir.appendPathExtension("json") - try! sendRequest(request: WalletBackendInitRequest(persistentStoragePath: storageDir.path)) + try! sendRequest(request: WalletBackendInitRequest(persistentStoragePath: storageDir.path, onSuccess: { + self.backendReady = true + self.backendReadyCondition.broadcast() + })) + } + + waitUntilReady() + //try! sendRequest(request: WalletBackendWithdrawTestBalance(amount: "TESTKUDOS:10", bankBaseUrl: "https://bank.test.taler.net/", exchangeBaseUrl: "https://exchange.test.taler.net/")) + try! sendRequest(request: WalletBackendGetBalancesRequest(onSuccess: { ([WalletBackendGetBalancesRequest.Balance]) -> Void in + + }, onFailure: { () -> Void in + + })) + } + + func waitUntilReady() { + backendReadyCondition.lock() + while (!self.backendReady) { + backendReadyCondition.wait() } - //try! sendRequest(request: WalletBackendGetTransactionsRequest()) + backendReadyCondition.unlock() } func handleMessage(message: String) { - print("received message \(message)") + print(message) + do { + guard let messageData = message.data(using: .utf8) else { throw WalletBackendError.deserializationError } + let data = try JSONSerialization.jsonObject(with: messageData, options: .allowFragments) as? [String : Any] + if let responseData = data { + let type = (responseData["type"] as? String) ?? "" + if type == "response" { + guard let id = responseData["id"] as? UInt else { /* TODO: error. */ return } + guard let request = requests[id] else { /* TODO: error. */ return } + request.decodeSuccess(messageData) + requests[id] = nil + } else if type == "tunnelHttp" { + + } else if type == "notification" { + + } else if type == "error" { + guard let id = responseData["id"] as? UInt else { /* TODO: error. */ return } + guard let request = requests[id] else { /* TODO: error. */ return } + //request.handleError(messageData) + requests[id] = nil + } else { + /* TODO: unknown response type. */ + } + } + } catch { + + } } + private struct FullResponse: Decodable { + let type: String + let operation: String + let id: UInt + let result: T.Response + } + + /*private struct FullError: Decodable { + let type: String + let operation: String + let id: UInt + let error: WalletErrorDetail + }*/ + func sendRequest(request: T) throws { let data = WalletBackendRequestData(request: request, id: requestsMade) requestsMade += 1 + let decodeSuccess = { (data: Data) -> Void in + do { + let decoded = try JSONDecoder().decode(FullResponse.self, from: data) + request.success(result: decoded.result) + } catch { + + } + } + + /* Encode the request and send it to the backend. */ do { let encoded = try JSONEncoder().encode(data) guard let jsonString = String(data: encoded, encoding: .utf8) else { throw WalletBackendError.serializationError } + let detail = RequestDetail(decodeSuccess: decodeSuccess) + requests[data.id] = detail iono.sendMessage(message: jsonString) } catch { throw WalletBackendError.serializationError diff --git a/TalerTests/AmountTests.swift b/TalerTests/AmountTests.swift new file mode 100644 index 0000000..b7dd28d --- /dev/null +++ b/TalerTests/AmountTests.swift @@ -0,0 +1,62 @@ +/* + * This file is part of GNU Taler + * (C) 2021 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see + */ + +import XCTest +@testable import Taler + +class AmountTests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testAmounts() throws { + var amount: Amount = try! Amount(fromString: "EUR:633.59") + XCTAssert(amount.currency == "EUR") + XCTAssert(amount.value == 633) + XCTAssert(amount.fraction == 59000000) + XCTAssert(amount.description == "EUR:633.59") + XCTAssert(try amount == Amount(currency: "EUR", value: 633, fraction: 59000000)) + XCTAssert(try amount == amount) + + amount = try! Amount(fromString: "EUR:883") + XCTAssert(amount.currency == "EUR") + XCTAssert(amount.value == 883) + XCTAssert(amount.fraction == 0) + XCTAssert(amount.description == "EUR:883") + + XCTAssertThrowsError(try Amount(fromString: "EUR:6548$f.59.**")) + + let amount2: Amount = try! Amount(fromString: "EUR:971.32") + XCTAssert(try amount < amount2) + XCTAssert(try amount2 > amount) + XCTAssert(try (amount + amount2) == Amount(fromString: "EUR:1854.32")) + XCTAssert(try (amount2 - amount) == Amount(fromString: "EUR:88.32")) + XCTAssertThrowsError(try amount - amount2) + + let amount3: Amount = try! Amount(fromString: "USD:12.34") + XCTAssertThrowsError(try amount == amount3) + XCTAssertThrowsError(try amount < amount3) + XCTAssertThrowsError(try amount > amount3) + XCTAssertThrowsError(try amount + amount3) + XCTAssertThrowsError(try amount - amount3) + } + +} -- cgit v1.2.3