diff --git a/Mixin.xcodeproj/project.pbxproj b/Mixin.xcodeproj/project.pbxproj index 756ceabce..4172e3113 100644 --- a/Mixin.xcodeproj/project.pbxproj +++ b/Mixin.xcodeproj/project.pbxproj @@ -1220,6 +1220,7 @@ 94F237562C6915B70057D1AB /* PriceHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94F237552C6915B70057D1AB /* PriceHistory.swift */; }; 94F2375C2C691D5C0057D1AB /* TokenPriceChartCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94F2375A2C691D5C0057D1AB /* TokenPriceChartCell.swift */; }; 94F2375D2C691D5C0057D1AB /* TokenPriceChartCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 94F2375B2C691D5C0057D1AB /* TokenPriceChartCell.xib */; }; + 94F35A602D255B61001BCAE7 /* Invoice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94F35A5F2D255B5F001BCAE7 /* Invoice.swift */; }; 94F36AC626CA59B300AC30A5 /* PhoneNumberValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94F36AC526CA59B300AC30A5 /* PhoneNumberValidator.swift */; }; 94F952582AE616F40025B995 /* TransferOutViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94F952572AE616F40025B995 /* TransferOutViewController.swift */; }; 94F9525D2AE6174A0025B995 /* TransferOutView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 94F9525C2AE6174A0025B995 /* TransferOutView.xib */; }; @@ -2715,6 +2716,7 @@ 94F237552C6915B70057D1AB /* PriceHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PriceHistory.swift; sourceTree = ""; }; 94F2375A2C691D5C0057D1AB /* TokenPriceChartCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenPriceChartCell.swift; sourceTree = ""; }; 94F2375B2C691D5C0057D1AB /* TokenPriceChartCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TokenPriceChartCell.xib; sourceTree = ""; }; + 94F35A5F2D255B5F001BCAE7 /* Invoice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Invoice.swift; sourceTree = ""; }; 94F36AC526CA59B300AC30A5 /* PhoneNumberValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneNumberValidator.swift; sourceTree = ""; }; 94F952572AE616F40025B995 /* TransferOutViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransferOutViewController.swift; sourceTree = ""; }; 94F9525C2AE6174A0025B995 /* TransferOutView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TransferOutView.xib; sourceTree = ""; }; @@ -4230,6 +4232,7 @@ 940DC5912B075E03009A15D1 /* SafePaymentURL.swift */, 94BD7E762B2983CA0004576D /* MultisigURL.swift */, 94EE1C002B4C647500BE9AD1 /* CodeURL.swift */, + 94F35A5F2D255B5F001BCAE7 /* Invoice.swift */, 94C5A8922B2157CE00201CDA /* Payment.swift */, 94C6987A2B23257B002377EA /* PaymentPrecondition.swift */, 94A40C462B2781AF0028BC02 /* TransferPaymentOperation.swift */, @@ -6207,6 +6210,7 @@ 7C427BC428373F8000FFDE12 /* Wallpaper.swift in Sources */, E090B90F23B0B27F0012C7E9 /* ConversationDAO+Search.swift in Sources */, 7BF42E3122DC85E9005066E6 /* GalleryImageItemViewController.swift in Sources */, + 94F35A602D255B61001BCAE7 /* Invoice.swift in Sources */, 940764C529B26B0B00B779A6 /* PropertyListEncoder+Global.swift in Sources */, 7B8ED48021198EC20039946B /* StickerInputModelController.swift in Sources */, 94A0C8C32C77259E00BDE672 /* AddressReceiversCell.swift in Sources */, diff --git a/Mixin/UserInterface/Controllers/Wallet/Payment/Invoice.swift b/Mixin/UserInterface/Controllers/Wallet/Payment/Invoice.swift new file mode 100644 index 000000000..e72f432e9 --- /dev/null +++ b/Mixin/UserInterface/Controllers/Wallet/Payment/Invoice.swift @@ -0,0 +1,192 @@ +import Foundation +import CryptoKit +import MixinServices + +struct Invoice { + + enum Reference { + case hash(String) + case index(Int) + } + + struct Entry { + let traceID: String + let assetID: String + let amount: Decimal + let memo: String + let references: [Reference] + } + + let recipient: MIXAddress + let entries: [Entry] + +} + +extension Invoice { + + private enum InitError: Error { + case invalidString + case base64Decoding + case invalidLength(Int) + case invalidChecksum + case unknownVersion + case invalidRecipient + case invalidEntriesCount + case invalidEntry + case invalidReferenceType + case invalidHashReference + case invalidIndexReference + case sha3 + } + + private class DataReader { + + private let data: Data + + private var location: Data.Index + + init(data: Data) { + self.data = data + self.location = data.startIndex + } + + func readUInt8() -> UInt8? { + guard location != data.endIndex else { + return nil + } + let location = self.location + self.location = location.advanced(by: 1) + return data[location] + } + + func readUInt16() -> UInt16? { + let nextLocation = location.advanced(by: 2) + guard nextLocation <= data.endIndex else { + return nil + } + let low = data[location] + let high = data[location.advanced(by: 1)] + self.location = nextLocation + return UInt16(data: [low, high], endianess: .big) + } + + func readBytes(count: Int) -> Data? { + if count == 0 { + return Data() + } + let nextLocation = location.advanced(by: count) + guard nextLocation <= data.endIndex else { + return nil + } + let bytes = data[location.. String? { + if let data = readBytes(count: UUID.dataCount) { + UUID(data: data).uuidString.lowercased() + } else { + nil + } + } + + } + + private static let version: UInt8 = 0 + + init(string: String) throws { + let prefix = "MIN" + + guard string.hasPrefix(prefix) else { + throw InitError.invalidString + } + guard let data = Data(base64URLEncoded: string.suffix(string.count - prefix.count)) else { + throw InitError.base64Decoding + } + guard data.count >= 3 + 23 + 1 else { + throw InitError.invalidLength(data.count) + } + + let payload = data.prefix(data.count - 4) + let expectedChecksum = data.suffix(4) + let checksum = try { + let data = prefix.data(using: .utf8)! + payload + guard let digest = SHA3_256.hash(data: data) else { + throw InitError.sha3 + } + return digest.prefix(4) + }() + guard expectedChecksum == checksum else { + throw InitError.invalidChecksum + } + + let reader = DataReader(data: payload) + guard reader.readUInt8() == Self.version else { + throw InitError.unknownVersion + } + + guard + let recipientLength = reader.readUInt16(), + let recipientData = reader.readBytes(count: Int(recipientLength)), + let recipient = MIXAddress(data: recipientData) + else { + throw InitError.invalidRecipient + } + + guard let entriesCount = reader.readUInt8() else { + throw InitError.invalidEntriesCount + } + var entries: [Entry] = [] + entries.reserveCapacity(Int(entriesCount)) + for _ in 0..(base64URLEncoded string: S) { var str = string .replacingOccurrences(of: "-", with: "+") .replacingOccurrences(of: "_", with: "/") - if string.count % 4 != 0 { - str.append(String(repeating: "=", count: 4 - string.count % 4)) + let remainder = string.count % 4 + if remainder != 0 { + str.append(String(repeating: "=", count: 4 - remainder)) } self.init(base64Encoded: str) }